Skip to content

fix(docs): derive the sitemap's static-page locales from the pages that own the copy - #195

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-173-derive-static-page-locales
Aug 26, 2026
Merged

fix(docs): derive the sitemap's static-page locales from the pages that own the copy#195
hotlong merged 2 commits into
mainfrom
claude/issue-173-derive-static-page-locales

Conversation

@hotlong

@hotlong hotlong commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #173

apps/docs/app/sitemap.ts restated the locale surface of privacy and terms as a hand-maintained constant:

const STATIC_PAGE_LOCALES = ['en', 'zh-Hans'];

Correct today, and silently wrong the moment a key is added to either page's content record: the translation would exist, be reachable, render — and never be advertised, with no gate failing. It was the one hand-maintained thing left in a file #169 otherwise made fully derived.

What changed

Each route module now exports the keys of its own content record:

export const contentLocales = Object.keys(content);

and sitemap.ts reads the two of them instead of restating the pair.

Why this shape rather than a shared module

The card offered either. Exporting from the route modules wins on this code:

  • The copy stays with the page that renders it. Both records are page copy, not configuration; a shared module would move them away from their only consumer to serve a second one. Exporting the derived keys moves the fact without moving the copy.
  • It keeps the change inside the card's three-file surface. A shared module is a fourth file, and a fourth file that exists only so sitemap.ts can reach two records.
  • The two records are read separatelyprivacyLocales and termsLocales, not one merged set. They agree today and nothing requires them to; the old constant could not express a disagreement, and this can. The probe below shows a locale added to privacy alone advertising privacy alone.
  • Nothing heavy joins the sitemap's module graph. Neither route file is a client component and neither pulls one in, so this is a plain server-module import.

The thing worth checking is whether a named export beside a page's default is legal App Router. It is: Next's generated route validator (.next/types/validator.ts) constrains the known page exports through a structural extends and ignores additional ones. pnpm run type-check — which is fumadocs-mdx && next typegen && tsc --noEmit, so the validator is regenerated against the edited files — passes with the export present.

Relationship to the locale-surface gate (#175, merged as d6355de)

origin/main has been merged in (not rebased); this branch's base now carries .github/scripts/check-locale-surface.mjs, so CI exercises the sitemap gate against this change rather than predating it.

That gate holds the privacy/terms locale set as its own declared constant:

const STATIC_PAGES = [
  { path: 'privacy', locales: ['en', 'zh-Hans'] },
  { path: 'terms',   locales: ['en', 'zh-Hans'] },
];

It is deliberately not derived from the route modules — a gate that takes its expectations from the thing it checks cannot catch that thing being wrong. This PR makes sitemap.ts derive the same set from those modules. Both are correct, and they are supposed to be independent.

A consequence worth stating plainly, because it is now a real property of the repo: translating a legal page is a two-file action by design. Adding a locale to privacy or terms means editing the route module's content record and the gate's STATIC_PAGES constant. Whoever does it finds out from a red gate that names the page — which is the intended way to find out, not a gap. Before this PR the same edit would have needed sitemap.ts too, and forgetting that one failed silently.

Verification

Evidence below is from .next/server/app/sitemap.xml.body, built with pnpm run build in apps/docs. Final commit 3c02400.

The advertised set does not move

main @ 6653744 branch @ 99e4815
total url entries 346 346
privacy /privacy, /zh-Hans/privacy /privacy, /zh-Hans/privacy
terms /terms, /zh-Hans/terms /terms, /zh-Hans/terms
sha256 of the sorted url entries a57adad5... a57adad5...

The last row is the whole comparison: sorting the url entries and hashing them gives the same digest before and after — every entry, with its hreflang cluster, changefreq and priority, byte for byte. en + zh-Hans on both sides, as adjudicated.

Byte-identical raw output was the stated criterion and it is not meetable, for a reason that is not this change: sitemap entry order is not reproducible. Two consecutive builds of this branch at the same commit, tree untouched, produced different raw bytes (d2d5c9a1..., e1c28e8b...) and the same sorted digest a57adad5.... Pre-existing on main; filed separately as #194. It does not threaten the locale-surface gate — that gate compares url sets through Map/Set membership and never compares bytes or positions, so it is order-invariant by construction. #194 stands on reproducible-builds grounds alone.

The derivation is exercised — and confirmed twice

A derivation that is never run is indistinguishable from the constant it replaced. So a third locale key was temporarily added to privacy's content record only, with no edit to sitemap.ts (blob 09728c37... before, during and after) and no edit to the gate (blob bcdd2e90... before and after), and the app was rebuilt.

Confirmation 1 — the sitemap picked it up on its own:

before probe with ja in privacy's record
total url entries 346 347
privacy /privacy, /zh-Hans/privacy /privacy, /zh-Hans/privacy, /ja/privacy
terms /terms, /zh-Hans/terms /terms, /zh-Hans/terms — unchanged

terms staying at two is the load-bearing half: it proves the two records are genuinely read as two, which the old shared constant could not have expressed.

Confirmation 2 — the independent gate noticed, and said why:

| artifact      | advertised | distinct | expected | unexpected | missing | duplicated |
| `sitemap.xml` |        347 |      347 |      346 |          1 |       0 |          0 |

✗ **1** locale-surface finding(s)

**unexpected-url** — 1
  - sitemap.xml: https://docs.objectos.ai/ja/privacy is advertised but the content
    tree has no source file for it — an untranslated page is being advertised as a
    translation

✗ locale surface: 1 finding(s)

Exit 1. That red is the point, not a problem: the derivation picked the locale up automatically, and the gate — whose expectation comes from its own constant, not from the route module — noticed the site had started advertising something the oracle did not declare. Two mechanisms, independent, both working.

After reverting the probe, the gate is green again:

| artifact      | advertised | distinct | expected | unexpected | missing | duplicated |
| `sitemap.xml` |        346 |      346 |      346 |          0 |       0 |          0 |

✓ every advertised URL has a source file, and every source file is advertised

The revert is proven on disk, not assumed: the working blob for privacy/page.tsx is back to 1ecb2c65..., identical to its HEAD blob, with git diff HEAD and git status empty and the probe marker absent from every tracked file. It is not in this diff. check-locale-surface.mjs was never edited — it is outside this PR's file surface and its constant going red on a real locale change is its intended behaviour.

Rendering

Both pages still render in both locales — checked in the prerendered output of the final build:

page lang attribute heading sections
en/privacy en Privacy Policy 4
zh-Hans/privacy zh-Hans 隐私政策 4
en/terms en Terms of Service 5
zh-Hans/terms zh-Hans 服务条款 5

ja/privacy still prerenders the English copy under a ja document language — the untranslated-fallback behaviour this PR deliberately leaves alone and correctly declines to advertise.

A real browser was not driven (AGENTS.md rule 2): none is available in this environment, so this is prerendered-HTML inspection, not a browser check. The change adds a module-level export and touches no component body, no JSX and no markup.

Gates

Run locally on the merged tree at 3c02400; each exit status captured before any pipe.

gate result
pnpm turbo run type-check --continue --force Tasks: 1 successful, 1 total — exit 0
pnpm turbo run build ✓ Compiled successfully, ✓ Generating static pages (996/996) — exit 0
node .github/scripts/check-locale-surface.mjs ✓ every advertised URL has a source file, and every source file is advertised — exit 0
pnpm turbo run test --force Tasks: 1 successful, 1 total, ✓ 2 self-test(s) passed — exit 0
node .github/scripts/check-node-floor.mjs ✅ Every declared floor clears what the dependency tree requires — exit 0

That is the whole of ci.yml: the node-floor job plus the build job's four steps. translations.yml does not run on this PR — the diff touches none of its trigger paths. No changeset flow in this repo, and no packages/ touched.

Files

Exactly the three files the card declared, plus the merge commit bringing in origin/main:

  • apps/docs/app/[lang]/privacy/page.tsx
  • apps/docs/app/[lang]/terms/page.tsx
  • apps/docs/app/sitemap.ts

Generated by Claude Code

…at own the copy

`sitemap.ts` restated the locale surface of `privacy` and `terms` as a
hand-maintained `STATIC_PAGE_LOCALES = ['en', 'zh-Hans']`. That was correct
today and silently wrong the moment a key was added to either page's `content`
record: the translation would exist, be reachable, render — and never be
advertised, with no gate failing.

Each route module now exports the keys of its own `content` record, and
`sitemap.ts` reads the two of them instead of restating the pair. The two are
read separately because they are separate records; they agree today and nothing
requires them to.

The advertised set is unchanged: `en` + `zh-Hans` for both pages, 346 sitemap
URLs before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
@hotlong
hotlong marked this pull request as ready for review August 26, 2026 01:39
@hotlong
hotlong merged commit b62c99f into main Aug 26, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] sitemap.ts restates the locale surface of privacy and terms because those two pages keep their copy module-private

2 participants