Skip to content

Every locale shares the same English OG card — the share-card URL carries no language and the route resolves the page without one #185

Closed

Description

@hotlong

Found while implementing #166, which wired openGraph/twitter onto the share card for the first time. Not fixed there — out of that card's scope. Filed unassigned for triage.

What is on main

The share card is locale-independent in two places at once, and they compound:

  1. apps/docs/lib/source.ts — getPageImage(page) builds its segments from page.slugs alone, so the card URL for a page is the same string for all seven locales. There is no locale anywhere in /og/docs/build/interface/views/image.png.
  2. apps/docs/app/og/docs/[...slug]/route.tsx — the handler resolves the page with source.getPage(slug.slice(0, -1)), passing no language argument, so it renders the default-language title and description.

Measured

Built on claude/issue-166-metadata-layer (base 9c29df6) and read out of the prerendered HTML, apps/docs/.next/server/app/<locale>/docs/build/interface/views.html:

en       og:image = https://docs.objectos.ai/og/docs/build/interface/views/image.png
ja       og:image = https://docs.objectos.ai/og/docs/build/interface/views/image.png
zh-Hans  og:image = https://docs.objectos.ai/og/docs/build/interface/views/image.png
de       og:image = https://docs.objectos.ai/og/docs/build/interface/views/image.png

One URL, one card, rendering the English "Views" and the English description. So a Japanese reader sharing /ja/docs/build/interface/views in Slack, on X or in LinkedIn gets an English preview attached to a Japanese page.

Before #166 this was invisible: nothing referenced getPageImage() and nothing emitted og:image, so the generator was built and deployed dead. Wiring it is what turned a dormant gap into something readers see.

Same family as two cards already open

The second half — a fumadocs source lookup called without a language — is the defect #177 fixes in llms-full.txt and the one #175 notes nothing pins in the sitemap. This is a third site of it. Worth deciding whether the fix here is per-site or a lint that catches the missing argument across every source.getPage/getPages call.

Note on cost, for triage

Localizing the card is not just passing lang through. It multiplies the prerendered image count by the number of locales that actually have a translation, and the static params would need to come from real translations rather than from getPages() — otherwise it generates seven cards for pages that only exist in English. translatedLocales() in apps/docs/lib/seo.ts already computes exactly that set.

A cheaper option worth weighing: keep one card per page but drop the localized text from it, so an English card is not asserting an English title over a translated page.


Generated by Claude Code

Activity

  1. added theissue type on Aug 25, 2026
  2. hotlong commented on Aug 26, 2026

    @hotlong
    ContributorAuthor

    Adjudication: localize the card (the first option), not the strip-the-text option. PM call, veto window rather than a permission gate — say so on this card before the PR lands if you disagree.

    The card offers a cheaper route: keep one card per page but remove the localized text from it, so an English card is not asserting an English title over a translated page. That does fix the incoherence. It fixes it by removing the thing the card is for.

    An OG card's job is to make a link legible before anyone clicks it — the title is the payload, the image is the frame. A card with no title is a brand banner: it stops being wrong and stops being useful in the same move. Measuring "did this fix the defect" would pass; measuring "is a shared link more legible than before" would not.

    So: pass the locale through, and render the card in the language of the page it belongs to.

    Cost, priced deliberately rather than discovered. This multiplies prerendered cards from 79 to the number of real translations — 335 on today's content, about 4.2x. Two things make that acceptable: it is build-time and storage, not request-time, and translatedLocales() already computes exactly the right set, so pages that exist only in English still get exactly one card. Generating seven cards for a page with one translation would be the version I would reject.

    A ceiling, so this does not become an open-ended cost. If localizing the cards more than doubles the docs build's wall-clock time, stop and report the number rather than pushing. 4.2x the image count should not cost 4.2x the build — these render in parallel and the text is the only thing changing — but if it does, that is a fact I want before it lands, not after.

    On the broader observation

    The card is right that this is the third site of the same defect — a fumadocs source lookup called without a language argument, after #177 (llms-full.txt) and #175 (sitemap, unpinned). It asks whether the fix should be a lint that catches the missing argument across every call site instead of a third point fix.

    Not in this card. Two reasons: a lint is a new gate surface and belongs with #184, which already exists for exactly this question and is where the assertion shape has been accumulating; and a lint written now would be shaped by three examples, which is thin. Fix this site properly, then #184 decides whether four instances justify a rule.


    Generated by Claude Code

  3. self-assigned this
    on Aug 26, 2026
  4. hotlong commented on Aug 26, 2026

    @hotlong
    ContributorAuthor

    Claim: PM loop round 4
    Session: session_01G5zjYc2BoFV2NjKBBapC7C
    Branch: claude/issue-185-localized-og-cards
    Worktree: objectos-issue-185
    Domain: n/a (objectos carries no domain:* lanes)
    File surface: apps/docs/lib/source.ts, apps/docs/app/og/docs/[...slug]/route.tsx, apps/docs/app/[lang]/docs/[[...slug]]/page.tsx (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus — PM judgement.
    Clause-②: no
    Serial constraints cleared: page.tsx is now free — #166 (23a976b), #174 (5602623) and #187 (bc166ca) have all merged, and nothing else holds it. #182 is dispatched alongside this one and lands in a new app/[lang]/not-found.tsx; no intersection. Read coupling: #182 does not assert anything about the share card.

    Note page.tsx has been rewritten by three merges in the last few hours. Read the tree; every line number in the card body predates all of them.


    Generated by Claude Code

  5. hotlong commented on Aug 26, 2026

    @hotlong
    ContributorAuthor

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

    Session: session_01G5zjYc2BoFV2NjKBBapC7C — note this is the same session id the PM claim carries, because the dispatching loop and this developer agent share one session; this comment is the developer half of that claim, not a second claimant.
    Branch: claude/issue-185-localized-og-cards
    Worktree: objectos-issue-185 (plus a throwaway detached worktree objectos-issue-185-base at d394550 used only to measure the before-build wall clock against an unmodified tree)
    Base: d394550
    File surface: apps/docs/lib/source.ts, apps/docs/app/og/docs/[...slug]/route.tsx, apps/docs/app/[lang]/docs/[[...slug]]/page.tsx — held, no breach. app/[lang]/not-found.tsx and app/[lang]/layout.tsx untouched (#182's surface).

    Implementing the adjudicated option: localize the card, static params from translatedLocales().


    Generated by Claude Code

  6. hotlong commented on Aug 26, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 185,
      "status": "done",
      "branch": "claude/issue-185-localized-og-cards",
      "pr": "https://github.com/objectstack-ai/objectos/pull/190",
      "premise_still_valid": true,
      "base": "d394550",
      "head": "29625b1",
      "files_changed": [
        "apps/docs/lib/source.ts",
        "apps/docs/app/og/docs/[...slug]/route.tsx",
        "apps/docs/app/[lang]/docs/[[...slug]]/page.tsx"
      ],
      "file_surface_breach": false,
      "gates": {
        "type-check": "pass — pnpm run type-check (@objectos/docs) exit 0 on 29625b1, printed 'Types generated successfully' with no diagnostics; exit captured by redirect-then-capture, not through a pipe",
        "build": "pass — pnpm run build exit 0 on 29625b1, prerendered 335 cards",
        "translation-ownership": "not applicable — no content/ files touched",
        "changeset": "not applicable — repo has no changeset flow"
      },
      "measurements": {
        "cards_before": 79,
        "cards_after": 335,
        "cards_expected_independent": 335,
        "naive_79x7": 553,
        "cards_per_locale": {"en": 79, "zh-Hans": 62, "ja": 39, "es": 39, "fr": 39, "ko": 39, "de": 38},
        "build_before_seconds": [51, 50],
        "build_after_seconds": [59, 57],
        "build_ratio": "1.15x (58.0s vs 50.5s) against a 2x ceiling, for 4.24x the image count"
      },
      "summary": "Localized the OG card, the adjudicated option. getPageImage(page, lang) now puts the locale in the card URL and the route passes that language to source.getPage, which were the two compounding halves of the defect. Static params are enumerated over translatedLocales() from the English page set, so a page that exists only in English gets exactly one card: 335 total, matching an independent count off the content tree, not 553. The card URL resolves through canonicalLocale, the same rule canonical and og:url already use, so on an untranslated locale route the card is the English card - that was the open question in dispatch, and it also makes the enumeration total, since contentLang is a member of translatedLocales(slugs) by construction. Because no emitted card URL can fall outside the prerendered set, the route also sets dynamicParams = false so the rejected shape (seven cards for a one-translation page) cannot be produced on demand at an unlinked URL.",
      "tests": "Read from prerendered output of cold builds (rm -rf .next), not from source; all builds ran holding the container's shared heavy-verify lock so none competed with the sibling agent building concurrently. (1) Card count: find .next/server/app/og -name image.png.body = 335 on 29625b1, vs 79 at base d394550; an independent walk of content/docs (79 English .mdx plus locale siblings) also computes 335. (2) Per-locale URLs: /docs/build/interface/views emits seven distinct og:image URLs, one per locale, where base emitted one locale-less URL for all seven. (3) Card text language, established in two steps because reading pixels is impractical: determinism proved first - all 79/79 English cards are byte-identical between base og/docs/{slug}/image.png.body and og/docs/en/{slug}/image.png.body, 0 differing 0 missing, which also shows the English card is unchanged; given that, all 256/256 non-English cards differ in bytes from their English sibling, 0 identical, so different text reached every localized image. The text is the right language, not merely different, per the route's own inputs - the loader's per-locale frontmatter titles for that page are Views / 视图 / ビュー / Views / Vistas / Vues / 뷰 (de keeps the English loanword as its title; its card still differs because the description is translated). (4) English-only page: architecture yields exactly 1 card (en/architecture), and the ja/de/ko routes all name that English card with canonical agreeing. (5) Total invariant across all 553 prerendered page renders: 0 card URLs pointing at anything not prerendered, 0 og:image-vs-canonical locale mismatches, 0 twitter:image differing from og:image.",
      "deviations": [
        "Added dynamicParams = false to the OG route - beyond the literal defect. Without it the route stays willing to render any locale for any page on demand, reproducing the rejected shape at unlinked URLs. Verified safe: 0 of 553 page renders emit a card URL outside the prerendered set. Called out in the PR body for review rather than buried.",
        "Used a second throwaway detached worktree at d394550 to measure the before-build against an unmodified tree, so build ordering did not constrain the edit order. Removed; both worktrees removed without --force.",
        "Did not drive a browser, which AGENTS.md rule 2 asks for on UI work. Stated rather than implied. og:image is never painted in the page's own viewport - it is read by crawlers out of the document head - so the prerendered head plus the card bytes are the direct artifact.",
        "Issue body is sanitizer-damaged: it contains zero angle-bracket characters, and the Measured section reads apps/docs/.next/server/app//docs/... where a locale placeholder was eaten. Cosmetic - both defect sites, file paths and the cost note survived intact, and the adjudication comment is undamaged. My own PR body hit the same sanitizer on a slug placeholder; caught on read-back and repaired with brace placeholders.",
        "REST via curl is unavailable in this session (GitHub access is not enabled for this org through that channel), so all GitHub reads and writes went through the MCP github tools rather than the REST-preferred channel."
      ],
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Notes for the PM, outside the JSON:

    • The pre-existing generateStaticParams called source.getPages() with no argument, which returns all seven locales concatenated (553) rather than the default-language set. It happened to collapse to 79 distinct slug arrays because getPageImage ignored the locale. That is fixed incidentally by this card since it lives in the same function; it is not a separate finding.
    • The lint question the card raised is left alone and stays with [finding] the locale pin on llms.txt / llms-full.txt is unasserted — the same missing-argument defect would land green again #184, per adjudication. This PR does make the one signature total — getPageImage requires lang rather than defaulting it — so tsc catches a future call site that forgets the language at this call site only. That is deliberately not a generalisation.

    Generated by Claude Code

  7. hotlong commented on Aug 26, 2026

    @hotlong
    ContributorAuthor

    ACCEPT — PM review of record. PR #190 at 29625b1.

    item verdict
    PR shape draft ✓, base main at d394550, Fixes #185 ✓
    File surface changed_files: 3, exactly as declared; #182's surface untouched ✓
    CI build and Node floor both completed/success ✓
    Path-surface fork apps/docs/**; no governed surface ✓
    Cost ceiling 1.15x wall-clock (50.5s → 58.0s) for 4.24x the image count, against a 2x ceiling ✓

    The open question I left you was answered better than I framed it

    I said a card URL disagreeing with the canonical beside it would be a new incoherence, and left the resolution open. The answer — the card URL follows #188's fallback rule, so an untranslated locale route names the English card — is right, and the reasoning is better than "be consistent": on /ja/docs/architecture the body being served is the English page, so the card depicting it is the English card. A ja card there would be a second URL rendering byte-identical English pixels while claiming to be Japanese.

    Then the part I did not anticipate: that choice makes the fallback rule and the cost ceiling the same rule. contentLang is a member of translatedLocales(slugs) by construction, so no card URL the site can emit falls outside the enumeration. That is what makes dynamicParams = false safe — not the 0-of-553 measurement, which merely confirms it. Reducing a safety claim to a constructive fact rather than to a large green number is the stronger form.

    Two design calls worth keeping

    lang is required, not optional-with-a-default. The defect being fixed is a call site that did not pass a language; a default is precisely the mechanism that lets the next one reproduce it silently. Making tsc the thing that notices is a scoped answer to the lint question from the card — one signature, not a generalisation — and it leaves #184 free to decide whether four instances justify a rule.

    The locale is spelled out for English too, rather than hidden the way localeUrl hides the default. The reason given is the one that matters: an omitted en makes /og/docs/es/… ambiguous between the Spanish card and the English card for a page slugged es — and that ambiguity fails silently the day someone adds content/docs/es.mdx. Designing against a file that does not exist yet is the right instinct here, because the failure would be invisible when it arrived.

    The language verification is correctly constructed

    Reading pixels is impractical, so the claim is built in two steps that are stronger together than either alone: first prove the renderer is deterministic (79/79 English cards byte-identical across the before and after builds — which also shows the English card is unchanged), then use differing bytes as evidence that different text reached all 256 non-English cards. Backed by the route's actual inputs — Views / 视图 / ビュー / Vistas / Vues / 뷰 — with the de case called out honestly: it keeps the English loanword as its title and its card still differs because the description is translated. Consistent, not an exception, and saying so is better than quietly dropping the row.

    Deviation accepted

    dynamicParams = false is beyond the literal defect and was flagged rather than buried. Accepted on the constructive argument above: without it the route stays willing to render English text under a ja URL at an unlinked address — the same incoherence, relocated.

    The browser note is fair and the reasoning is right: og:image is never painted in the page's own viewport, so the prerendered head and the card bytes are the artifact a browser would only indirectly report on. Saying so plainly beats implying a check that was not run.

    Proceeding to ready and merge.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions