Repository navigation
Description
One-liner
app/og/docs/[...slug]/route.tsx renders a 1200×630 card per page and generateStaticParams builds one for every doc page — and nothing links to any of them. The build pays for 403 images that no crawler, no social platform and no user ever sees. Every page shares as a blank card.
Measured
$ grep -rn "getPageImage\|openGraph\|twitter" apps/docs --include=*.ts --include=*.tsx | grep -v node_modules
apps/docs/app/og/docs/[...slug]/route.tsx:1:import { getPageImage, source } from '@/lib/source';
apps/docs/app/og/docs/[...slug]/route.tsx:31: slug: getPageImage(page).segments,
apps/docs/lib/source.ts:18:export function getPageImage(page: InferPageType<typeof source>) {
getPageImage is called only by the route that produces the image. No openGraph, no twitter key exists anywhere in the app. Rendered page: og: property count = 0.
Expected
In generateMetadata for doc pages:
openGraph: { title, description, url: page.url, type: 'article', images: [getPageImage(page).url] }twitter: { card: 'summary_large_image', title, description, images: [...] }
The homepage and the blog need the same treatment; the homepage can reuse hero-cover-dark.png (already in public/) rather than the docs card generator.
Depends on the metadataBase card — without it the image URLs resolve relative to nothing.
Acceptance
- a doc page emits
og:title,og:description,og:url,og:imageandtwitter:card -
og:imagefetches 200 as an image (not HTML) - the homepage emits its own card, distinct from the docs template
Source
Found in an SEO review of the docs site (apps/docs) run on 2026-08-25, measured against the local dev server and against production. The canonical origin is https://objectstack.ai — maintainer ruling recorded in #10659:
这个仓的文档站规范 URL 是 https://objectstack.ai
Activity
os-zhuang commentedon Aug 25, 2026
Held for round 2 — serial, not blocked. Edits the same
generateMetadatablock inapp/[lang]/docs/[[...slug]]/page.tsxas #12234, and needs themetadataBasethat #12234 sets before relative OG image URLs resolve to anything. Known trap: the OG route already exists and is already built for every page — the work is wiring, not generation, so a diff that touchesapp/og/**is a signal something was misread.os-zhuang commentedon Aug 25, 2026
Claim: PM loop round 4 (epic PM for #12243)
Session:
session_f9f0958b-ab68-46cc-801c-216aa7ee2107Branch:
claude/issue-12235-og-unreferencedWorktree:
objectstack-issue-12235Domain:
domain:devxFile surface:
apps/docs/app/[lang]/page.tsx,apps/docs/app/[lang]/docs/[[...slug]]/page.tsx,apps/docs/app/[lang]/blog/[[...slug]]/page.tsx— thegenerateMetadata/metadataobjects only (stop on breach; explain in the report)Container & model: S,
mode:subagent,model: opus— no path-derived mandate fromscripts/pm/dispatch-gates.mjs --tierClause-②: no
Serial constraints cleared: the dependency this card waited on is satisfied — PR #12305 merged (
c463ec3b0), sometadataBaseis set and relative image URLs resolve. No in-flight PR touches these files. #12240 (JSON-LD) and #12242 (hero poster) remain queued BEHIND this card on the same files.os-zhuang commentedon Aug 25, 2026
Dev claim (os-dev subagent, under the PM claim above).
session_f9f0958b-ab68-46cc-801c-216aa7ee2107(dev subagent of the epic PM session for epic(docs-site): the site is technically un-indexable — fix robots/sitemap/canonical/OG first, then the keyword shape #12243)claude/issue-12235-og-unreferenced(pushed empty at start as the write-routing probe)objectstack-issue-12235, base0d91eaa6ea2aac46406c636c2226bcddc5b796cbapps/docs/app/[lang]/page.tsx,apps/docs/app/[lang]/docs/[[...slug]]/page.tsx,apps/docs/app/[lang]/blog/[[...slug]]/page.tsx—metadata/generateMetadataonly.Re-deriving every number on the card before writing anything, per the epic's record of five corrected PM measurements. Report lands as a comment on this issue with a first line of
os-dev-report.os-zhuang commentedon Aug 25, 2026
os-dev-report
{ "issue": 12235, "status": "done", "branch": "claude/issue-12235-og-unreferenced", "pr": "https://github.com/objectstack-ai/objectstack/pull/12325", "premise_still_valid": true, "summary": "Both halves of the premise re-derived and both hold: all 403 doc-page OG cards are built (403/403 fetch 200 image/png on production, measured by mapping every <loc> in the live sitemap to its card URL), and nothing referenced them (getPageImage had 3 source hits - its own declaration plus the two lines of the route that produces the image; grep for openGraph|twitter over apps/docs returned 0, with generateMetadata=2 as the positive control). Wired openGraph + twitter into the metadata of all five URL shapes across the three declared files and nothing else: docs pages and /docs reference their own generated card through the same getPageImage() the generator maps over, so reference and generator are one expression; homepage and blog reference public/hero-cover-dark.png, because the card generator renders from a source.getPage() result and neither has an MDX file behind it. Image URLs stay site-relative so metadataBase remains the single spelling of the origin, and every og:url equals that page's existing canonical. PM assumption 2 confirmed (408 sitemap URLs = 403 docs incl. /docs + / + /blog + 3 posts, so five shapes, not four). PM assumption 4 resolves NEGATIVE - there is no off-by-one; /og/docs/image.png is among the 403 that answer 200. PM assumption 3 confirmed: the generator is docs-only, and I took the homepage recommendation and extended the same shared card to the blog rather than leaving it blank - a per-post blog generator is a real decision, listed in open_questions, and would need an app/og/** route outside this card's surface.", "tests": "All measurements at final commit a1293e84c (= branch head; nothing pushed after). (1) EMITTED TAGS, one page per route type, from `next dev` running THIS branch on :3111, each emitted og:image then fetched from both that server and production: / -> og:type=website, og:image=https://objectstack.ai/hero-cover-dark.png, local=200 image/png, prod=200 image/png; /docs -> article, https://objectstack.ai/og/docs/image.png, local=200 image/png, prod=200 image/png; /docs/ai/agents -> article, https://objectstack.ai/og/docs/ai/agents/image.png, local=200 image/png, prod=200 image/png; /blog -> website, https://objectstack.ai/hero-cover-dark.png, local=200 image/png, prod=200 image/png; /blog/protocol-first-development -> article, https://objectstack.ai/hero-cover-dark.png, local=200 image/png, prod=200 image/png. All five emit og:title, og:description, og:url, og:image and twitter:card=summary_large_image. file(1) on the downloaded bytes: docs cards 'PNG image data, 1200 x 630', hero 'PNG image data, 2400 x 1200' - matching the og:image:width/height each page declares, so the dimensions are measured and not asserted. (2) FULL-POPULATION SWEEP: all 403 card URLs derived from the live sitemap fetched against production - 403/403 '200 image/png', zero non-200. (3) REVERSE VERIFICATION with the direction predicted first: mutated getPageImage to drop the trailing 'image.png' marker (the exact off-by-one PM assumption 4 asks about), on an already-committed tree. Mutation proved on disk by anchored grep -cF on BOTH the deleted text (\"page.slugs, 'image.png'\" -> 0) and the injected text (0 -> 1), not by diff --stat, plus git hash-object moving ef28c0f2 -> 6af2ea59. Restored with `git checkout HEAD -- <absolute path>`; restore proved by git hash-object returning to the HEAD blob ef28c0f2, `git diff HEAD` empty, `git status` clean, and by re-fetching both pages afterwards (correct URLs, 200 image/png again). PREDICTED: /docs 404s, /docs/ai/agents returns 200 with the WRONG page's card via slug.slice(0,-1) resolving ['ai']. OBSERVED: both 404 - prediction half wrong, and the reason is a real finding: proxy.ts's matcher excludes any path containing a dot, so a marker without one is rewritten to /en/og/docs/... and never reaches the route. Confirmed without any mutation on the restored tree: /og/docs/ai/agents -> 404, /en/og/docs/ai/agents -> 404, /og/docs/ai/agents/x.png -> 200 image/png, /og/docs/ai/agents/image.png -> 200 image/png. So the marker's NAME is irrelevant but a final DOTTED segment is load-bearing; filed as #12326. No build/dist is involved in this ablation (Next dev compiles from source), so there is no dist-freshness leg to report. (4) GATES: derived from the real diff by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` with no hand-written path list (stderr confirms 'objectstack-ai/objectstack at commit a1293e84c', 3 paths vs merge base 0d91eaa6e); the dispatch prompt named no gate family, so this derivation is the whole list. Five matched, all green, each quoted from its own verdict line rather than a shell $?: check:docs-locale-catch-all 'OK - 1 top-level dynamic segment(s), 1 guarded; dotted paths bypass proxy.ts: true'; check:page-declaration-shape 'OK - 34 page entries across 2188 sources ... all reach the kernel through a discoverable declaration'; check:published-files 'OK - 69 publishable package(s) of 78 workspace member(s)'; check:test-source-alias 'check-test-source-alias OK - 72 packages with tests scanned'; check:type-source-resolution 'check-type-source-resolution OK - 93 tsc program(s) across 77 packages scanned'. Plus `pnpm --filter @objectstack/docs typecheck` exit 0 with the script name echoed (not a zero-match no-op), and `tsc --noEmit --listFiles` confirming all three edited files ARE in the program (1 hit each) so the green covers these edits; `check:nul-bytes` OK (6823 files); and repo-wide `pnpm lint` = `eslint . --no-inline-config` exit 0 in 47s - the FULL population, so no narrowing was claimed or needed. Exit codes were captured before any pipe (redirect-then-tail), never after one. CI at report time, read via REST on a1293e84c: 12 success, 9 skipped, 5 in_progress, 0 failure. DISCLOSURE: this host has no usable flock, so scripts/pm/os-verify-lock.sh --status reports DECLARED UNLOCKED MODE - nothing can hold the shared verify lock here; the commands above ran without mutual exclusion because none exists on this host, not because the lock was bypassed.", "open_questions": [ { "question": "Should blog posts get their own generated OG cards, or is the shared site card the end state? This PR wires the blog to public/hero-cover-dark.png so no page ships a blank card, but that is a default, not the decision you said you had no ruling on.", "options": [ "A - keep the shared site card. Zero new routes, zero new build cost, and 3 posts is not a surface that earns a generator.", "B - add a second app/og/** route for the blog collection (blog.getPage(), same fumadocs-ui/og template). Distinct card per post, but it is a new route outside this card's file surface and adds a prerender per post.", "C - hoist the card path into lib/site.ts as a SITE_CARD constant so homepage and blog share one spelling, and defer B." ], "recommendation": "A now, C as a cheap follow-up if a third page ever needs the asset. Real business need: 3 blog posts against 403 doc pages - the generator was built for the doc surface and the pull for per-post blog cards is not measured anywhere. Long-term soundness: B duplicates the OG route rather than parameterising it, which is the shape that drifts. AI-authorability: not a metadata surface, so neither option changes how hard it is to get an app wrong. Startup scope: B is capability expansion with no puller. C is the only part I would act on soon, and only because two files now spell '/hero-cover-dark.png' - see the handoff note below." }, { "question": "Two files now spell the literal '/hero-cover-dark.png' in page metadata. The natural home for one shared constant is lib/site.ts, which is outside the file surface you declared for me.", "options": [ "A - leave the two literals, each carrying a comment naming the other and naming the #12242 coupling (what this PR does).", "B - breach the declared surface and add SITE_CARD to lib/site.ts in this PR.", "C - fold the hoist into #12242, which is going to touch that asset anyway." ], "recommendation": "C. B is a surface breach on a file this epic deliberately serialises, and A alone leaves a real hazard: #12242 is about re-encoding or shrinking that PNG, and it now has two metadata call sites to keep in step, not just the page body. Folding the hoist into #12242 puts the change in the hand that is already on the asset. If you would rather I did B, it is a five-line follow-up on this branch." } ], "out_of_scope_findings": [ "filed as #12326: the OG card URL's trailing marker segment must CONTAIN A DOT or proxy.ts rewrites the whole card surface to /en/og/... and all 403 cards 404 - an undocumented coupling between lib/source.ts and proxy.ts that only became load-bearing once this PR made the cards referenced. Observation-class (missing guard around a currently-correct invariant), filed unassigned with label 'finding', no pm:queue. Searched open issues first via the REST list endpoint for og / proxy / image.png / getPageImage / marker; #12255 is the shadowed-route finding, unrelated." ] }Null result worth recording rather than dressing up: the homepage's
og:urlandrel=canonicalboth render ashttps://objectstack.ai(no trailing slash) whilesitemap.xmllistshttps://objectstack.ai/. Next normalises the empty path on output; RFC 3986 makes the two equivalent for HTTP, so this is not a defect and I did not file it. Recording it because it looks like one at a glance and the next reader of this lane will see it too.Surface note: the diff touches exactly the three declared files and only their
metadata/generateMetadataobjects (plus two import lines and aMetadatareturn type on the blog's, which was previously untyped — so a typo in anopenGraphkey there is now a typecheck failure rather than a silently ignored property). Nothing underapp/og/**is touched, per the trap recorded on this card.os-zhuang commentedon Aug 25, 2026
ACCEPT — PR #12325, 3 files, +155/-20, exactly the declared surface. Both open questions ruled below.
The verification is the strongest in this epic so far. Not a sample: every one of the 403 card URLs was derived from the live sitemap and fetched against production — 403/403 answered
200 image/png, zero non-200. And the dimensions were read from the downloaded bytes withfile(1)(docs cards 1200×630, hero 2400×1200) rather than asserted from the declaredog:image:width.My assumption 4 resolved negative — and the ablation found something better than what I asked about. I worried about an off-by-one between
generateStaticParamsandgetPageImage()for the/docsempty-slug case. There is none. But the reverse-verification — mutating the marker to dropimage.png— predicted "one 404, one wrong-card 200" and observed two 404s. The prediction was half wrong and the dev said so, then chased the discrepancy to the real cause:proxy.tsexcludes dotted paths from locale rewriting, so a marker segment without a dot gets rewritten to/en/og/docs/...and the entire card surface 404s. Confirmed on the restored tree without any mutation:/og/docs/ai/agents→ 404,/og/docs/ai/agents/x.png→ 200. The marker's name is irrelevant; a final dotted segment is load-bearing. Filed as #12326.That coupling is worth naming plainly: it has been latent since the OG route was written, and it only became load-bearing the moment this PR made the cards actually referenced. A rename to
cardorog— a natural-looking tidy — would silently 404 all 403 cards with every gate green.Rulings on the two open questions
Q1 — blog OG cards: option A, keep the shared site card. Three posts against 403 doc pages; the generator was built for the doc surface and nothing measures a pull for per-post blog cards. Option B also duplicates the OG route rather than parameterising it, which is the shape that drifts — if per-post cards are ever wanted, the honest change is to parameterise the existing route over a collection, not to clone it. No card filed: a card for a capability nobody asked for is the backlog noise this repo's scope rule exists to prevent.
Q2 — the two
/hero-cover-dark.pngliterals: option C, fold the hoist into #12242. You are right that A alone leaves a hazard, and right not to breach the surface. #12242 re-encodes that exact asset, so it already has its hand on it; making it own the constant puts the change where the risk is. I have written that into #12242 as an explicit requirement rather than leaving it as an understanding between two reports.