Skip to content

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

Closed

Description

@os-zhuang

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:image and twitter:card
  • og:image fetches 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

  1. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    Held for round 2 — serial, not blocked. Edits the same generateMetadata block in app/[lang]/docs/[[...slug]]/page.tsx as #12234, and needs the metadataBase that #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 touches app/og/** is a signal something was misread.

  2. self-assigned this
    on Aug 25, 2026
  3. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round 4 (epic PM for #12243)
    Session: session_f9f0958b-ab68-46cc-801c-216aa7ee2107
    Branch: claude/issue-12235-og-unreferenced
    Worktree: objectstack-issue-12235
    Domain: domain:devx
    File surface: apps/docs/app/[lang]/page.tsx, apps/docs/app/[lang]/docs/[[...slug]]/page.tsx, apps/docs/app/[lang]/blog/[[...slug]]/page.tsx — the generateMetadata / metadata objects only (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: opus — no path-derived mandate from scripts/pm/dispatch-gates.mjs --tier
    Clause-②: no
    Serial constraints cleared: the dependency this card waited on is satisfied — PR #12305 merged (c463ec3b0), so metadataBase is 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.

  4. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    Dev claim (os-dev subagent, under the PM claim above).

    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.

  5. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    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:url and rel=canonical both render as https://objectstack.ai (no trailing slash) while sitemap.xml lists https://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 / generateMetadata objects (plus two import lines and a Metadata return type on the blog's, which was previously untyped — so a typo in an openGraph key there is now a typecheck failure rather than a silently ignored property). Nothing under app/og/** is touched, per the trap recorded on this card.

  6. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    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 with file(1) (docs cards 1200×630, hero 2400×1200) rather than asserted from the declared og: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 generateStaticParams and getPageImage() for the /docs empty-slug case. There is none. But the reverse-verification — mutating the marker to drop image.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.ts excludes 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 card or og — 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.png literals: 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions