Skip to content

fix(docs): reference the per-page Open Graph cards that were already being built - #12325

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-12235-og-unreferenced
Aug 25, 2026
Merged

fix(docs): reference the per-page Open Graph cards that were already being built#12325
os-zhuang merged 1 commit into
mainfrom
claude/issue-12235-og-unreferenced

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#12235

What was already true, re-measured before writing anything

The card's premise holds, and both halves were re-derived rather than inherited:

claimre-derived valuemethod
the cards are built403 / 403 doc pages answer 200 image/png on productionevery <loc> in the live sitemap.xml mapped to its card URL and fetched — 403 requests, zero non-200, zero non-image/png
the cards are referenced by nobodygetPageImage had exactly 3 source hits — its own declaration and the two lines of the route that produces the imagegrep -rn over apps/docs, positive control generateMetadata = 2 hits, so the zero is absence rather than a broken query
no card metadata anywheregrep -rn 'openGraph|twitter' over apps/docs = 0 hitssame run, same control

Population also re-derived, because this epic has corrected five PM measurements already: the live sitemap carries 408 URLs = 403 docs (including /docs, the empty-slug case) + / + /blog + 3 blog posts. That is five URL shapes across three files, confirming the correction made on #12234 rather than the four this epic originally listed.

What this changes

generateMetadata / metadata in the three page modules, and nothing else. No file under app/og/** is touched — the images were never the problem.

  • docs pages and /docs reference their own generated card via the same getPageImage() that the generator's generateStaticParams maps over. The generator and the reference are now one expression, so a slug shape that stops matching breaks the build instead of shipping an og:image that 404s.
  • the homepage and the blog reference public/hero-cover-dark.png. The card generator is docs-only: it renders from a source.getPage() result and neither the homepage nor the blog has an MDX file behind it, so there is no slug to hand it. The hero cover is already shipped and is already this page's video poster, so the shared card costs no extra bytes and no extra route.
  • Image URLs stay site-relative; metadataBase (PR fix(docs): set metadataBase and emit one absolute canonical per page type #12305) absolutises them, so the origin keeps being spelled in exactly one place. Every og:url is the same absolute string as that page's existing canonical link — og:url is the identity a social platform de-duplicates shares by, and a spelling that differs from the canonical splits one page into two.

Verification — one page of each route type, fetched

Measured against next dev running this branch at commit a1293e84c, and each emitted URL then fetched from both that server and production:

route og:type emitted og:image fetch
/ website 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 confirms the declared dimensions are not a claim: docs cards are PNG image data, 1200 x 630, the hero is PNG image data, 2400 x 1200 — matching the og:image:width / og:image:height each page emits.

The homepage card is distinct from the docs template by URL and by bytes.

The index page's card works — and the reason is not the one anyone wrote down

The dispatch asked whether generateStaticParams and getPageImage() agree on the slug shape for content/docs/index.mdx. They do: /og/docs/image.png is in the 403 that answered 200 image/png.

A reverse verification was run to check the measurement was sensitive to the failure it was looking for, not just to a happy path. getPageImage was mutated to drop the trailing image.png marker — the exact off-by-one the question describes — on a committed tree, with the mutation proved on disk by anchored grep -cF counts on both the deleted and the injected text (not by diff --stat, which any other edit in the round would have turned green), and restored via git checkout HEAD -- <abs path> with the restore proved by git hash-object matching the HEAD blob and git diff HEAD empty.

Predicted:/docs 404s, /docs/ai/agents returns 200 with the wrong page's card, because the route's slug.slice(0, -1) would resolve ['ai'].

Observed: both 404. The prediction was half wrong, and the reason is a coupling worth recording:

/og/docs/ai/agents -> 404 (no dot anywhere in the path)
/en/og/docs/ai/agents -> 404 (where proxy.ts rewrites it to)
/og/docs/ai/agents/x.png -> 200 image/png
/og/docs/ai/agents/image.png -> 200 image/png

proxy.ts's matcher excludes any path containing a dot. The trailing marker segment is therefore doing two jobs, only one of which is documented: it is the sacrificial segment slug.slice(0, -1) discards, and its dot is what keeps every OG URL out of the locale rewriter. Its name is irrelevant — x.png resolves the same page — but a marker without a dot takes the whole card surface to 404. That is why this PR references the card through getPageImage() rather than rebuilding the URL locally.

Gates

Derived from the real diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no hand-written path list), at commit a1293e84c; all five ran green, each quoted by its own verdict line rather than by a shell $?:

  • check:docs-locale-catch-all✓ 1 top-level dynamic segment(s), 1 guarded; dotted paths bypass proxy.ts: true
  • check:page-declaration-shapeOK — 34 page entries across 2188 sources ... all reach the kernel through a discoverable declaration
  • check:published-files✓ 69 publishable package(s) of 78 workspace member(s) ...
  • check:test-source-aliascheck-test-source-alias OK — 72 packages with tests scanned
  • check:type-source-resolutioncheck-type-source-resolution OK — 93 tsc program(s) across 77 packages scanned

Plus pnpm --filter @objectstack/docs typecheck (exit 0) and repo-wide pnpm lint = eslint . --no-inline-config (exit 0, 47s — the full population, not a narrowed run). tsc --noEmit --listFiles confirms all three edited files are in the program, so "typecheck is clean" is a statement about these edits and not about a program that never read them. check:nul-bytes OK.

⚠️ 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 and the entry point takes it from nobody. The commands above ran without mutual exclusion because none is available on this host, not because the lock was bypassed.

Not in this PR

  • No changeset — docs-site only, nothing published. skip-changeset applied.
  • No JSON-LD — that is docs site: no structured data (JSON-LD) anywhere #12240, queued behind this card.
  • No per-post card generator for the blog. It would mean a second app/og/** route, which is outside this card's file surface and is a decision rather than a default. The blog is wired to the shared site card instead, so no page on the site ships a blank card today.
  • ⚠️Handoff to docs site: homepage hero cover is a 406 KB PNG served at ~1024 px #12242 (hero cover weight): public/hero-cover-dark.png is now referenced from page metadata in two files, not just from the page body. Renaming, re-encoding to another format, or deleting it now breaks the homepage and blog og:image as well as the LCP image. Both call sites carry a comment saying so.

Generated by Claude Code

…being built
`app/og/docs/[...slug]/route.tsx` prerenders a 1200x630 card for every one of
the 403 doc pages, and until now nothing linked any of them: `getPageImage()`
was called only by the route that *produces* the image, and no `openGraph` or
`twitter` key existed anywhere in the app. Every page shared as a blank card.
Wire the cards into the metadata of all five URL shapes:
- docs pages and `/docs` (the empty-slug case) reference their own generated
card through the same `getPageImage()` the generator maps over, so the
reference and the generator cannot disagree about the slug shape;
- the homepage and the blog reference `public/hero-cover-dark.png`, which is
already shipped and already this page's video poster -- the card generator is
docs-only and has no slug to render either of them from.
Image URLs stay site-relative so `metadataBase` remains the single place the
origin is spelled, and every `og:url` is the same absolute URL as the page's
canonical link.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs site: Open Graph images are generated for all 403 pages but no page references them

1 participant

@os-zhuang