Repository navigation
Description
What is on main
apps/docs/app/sitemap.ts:20-33 enumerates pages from source.getPages() — the default-language set — and then emits one entry per language unconditionally:
for (const lang of i18n.languages) {
entries.push({ url: localeUrl(lang, path), ..., alternates: { languages } });
}languageAlternates() (lib/seo.ts:23-30) likewise returns all seven locales for every path, and generateMetadata attaches that same full map to every page.
But the translations are not there. Counted on this branch, 41 of the 79 English pages are missing at least one locale, and most are missing five or six:
content/docs/deploy/**,content/docs/operate/**,content/docs/reference/cli.mdx,content/docs/reference/environment-variables.mdx,content/docs/architecture.mdx,content/docs/configure/runtime.mdx— English only, all six locales missing.- The whole of
content/docs/use/**and most ofcontent/docs/build/interface/**—zh-Hansonly, five locales missing.
Overall file counts: 79 English, 62 zh-Hans, 39 each for ja/fr/es/ko, 38 de.
So the sitemap submits roughly 553 URLs, of which well over 200 are locale URLs with no translated source file behind them.
Why it matters
defineI18n in lib/i18n.ts:15-20 does not set fallbackLanguage, so those URLs are expected to resolve to the English file rather than 404. Verify that before writing the fix — the two outcomes need different handling, and the assumption should be tested against the installed fumadocs-core 16.8.12 rather than taken from this card:
- If it falls back, the site is telling crawlers
hreflang="ja"about a page whose body is English, several hundred times over. That is a wrong-language signal on a reciprocal hreflang cluster, plus duplicate content across six URLs, plus crawl budget spent on pages that cannot rank for a Japanese or German query no matter what. - If it 404s, the sitemap is advertising several hundred dead URLs, which is its own problem.
Either way the fix is the same shape: the locale surface must be derived from the files that actually exist.
What to change
- Emit sitemap entries only for locale/path pairs that have a real translated source file.
- Make
languageAlternates()take the set of locales a given path actually has, so the hreflang cluster on each page lists only real translations plusx-default. Keepx-defaultpointing at the English URL as it does today. - Keep the English entry for every page — English is the authored source and always exists.
- Do not delete any locale from
i18n.languages, and do not change routing. A visitor who switches language on an untranslated page should keep whatever behaviour it has today; this card is about what is advertised to crawlers, not about the reading experience.
Verification
- Count the generated sitemap entries and confirm the total matches the actual per-locale file counts above rather than 79 × 7.
- Pick one English-only page (
content/docs/operate/backup.mdxis one) and confirm its rendered hreflang cluster lists only English andx-default. - Pick one fully translated page and confirm all seven are still present and reciprocal.
Activity
hotlong commentedon Aug 25, 2026
Claim: PM loop round 1
Session:
session_01G5zjYc2BoFV2NjKBBapC7CBranch:
claude/issue-169-sitemap-locale-surfaceWorktree:
objectos-issue-169Domain: n/a (objectos carries no
domain:*lanes)File surface:
apps/docs/app/sitemap.ts,apps/docs/lib/seo.ts,apps/docs/app/[lang]/docs/[[...slug]]/page.tsx(stop on breach; explain in the report)Container & model: M,
mode:subagent,model: opus— PM judgement;scripts/pm/dispatch-gates.mjslives in the objectstack repo and has no counterpart here, so no--tierreading is quoted.Clause-②: no
Serial constraints cleared: #166 is the one overlapping card — it edits the same
generateMetadatainpage.tsxand is deliberately held to the next round rather than dispatched alongside this one. No other in-flight claim touchesapps/docs/**. Nothing else open in this repo has an assignee.Generated by Claude Code
hotlong commentedon Aug 25, 2026
Claim: dev subagent (os-dev), dispatched by the PM claim above.
Session:
session_01G5zjYc2BoFV2NjKBBapC7C(same container session as the PM — subagent seat, not a second session)Branch:
claude/issue-169-sitemap-locale-surface(pushed, empty, as the write-route probe)Worktree:
/home/user/objectos-issue-169File surface:
apps/docs/app/sitemap.ts,apps/docs/lib/seo.ts,apps/docs/app/[lang]/docs/[[...slug]]/page.tsxIssue body verified intact against the sanitizer: the
tsfence has content, no clause breaks off mid-sentence. The only sanitizer trace ishreflang="ja"entity-escaping in prose, which preserves meaning.Generated by Claude Code
Generated by Claude Code
hotlong commentedon Aug 25, 2026
os-dev-report
{ "issue": 169, "status": "done", "branch": "claude/issue-169-sitemap-locale-surface", "pr": "https://github.com/objectstack-ai/objectos/pull/176", "premise_still_valid": true, "head_sha": "587d9a4", "files_changed": [ "apps/docs/app/sitemap.ts", "apps/docs/lib/seo.ts", "apps/docs/app/[lang]/docs/[[...slug]]/page.tsx" ], "file_surface_breach": false, "gates": { "all_run_on": "587d9a4 (final commit; union re-run after the last commit)", "check-node-floor --self-test": "PASS — '17 rule case(s), 24 satisfies case(s) and 18 range case(s) - every rule demonstrated able to fail'", "check-node-floor": "PASS — 'Every declared floor clears what the dependency tree requires, and the declarations agree.'", "turbo run type-check --continue --force": "PASS — 'Tasks: 1 successful, 1 total'", "turbo run build --force": "PASS — 'Tasks: 1 successful, 1 total'", "turbo run test --force": "PASS — '2 self-test(s) passed' / 'Tasks: 1 successful, 1 total'", "control-char scan": "PASS — 0 hits from grep -naP over the 3 changed files", "not_triggered": "Translations workflow — path filter is content/docs/**, apps/docs/lib/i18n.ts, .github/scripts/check-translation*.mjs; diff touches none", "cache": "every turbo run used --force, so no replayed cache entry from a sibling worktree", "ci_status_at_report_time": "in_progress — reported at draft-PR time per dispatch contract" }, "premise_check": { "card_premise": "CONFIRMED — 41 of 79 English pages missing at least one locale reproduces exactly; per-locale file counts 79/62/39/38/39/39/39 confirmed", "pm_assumption_1_fallback_not_404": "CONFIRMED — fumadocs-core 16.8.12 resolves an unset fallbackLanguage to defaultLanguage; getPage(['operate','backup'],'ja') returns the English page at /ja/docs/operate/backup. Duplicate-content reasoning stands, no correction needed", "pm_assumption_2_getPages_default_set": "FALSIFIED — getPages() with no arg returns all locales concatenated (553, not 79). Consequence: main emitted 3892 entries over 574 distinct URLs = 3318 exact duplicates, ~7x worse than the card's 'roughly 553 URLs' estimate", "pm_suggested_route_getLanguages": "FALSIFIED — each locale's FileSystem is built as new FileSystem(inherit), which COPIES the English files in; getPages(lang) and getLanguages() report 79 pages for all seven locales and cannot distinguish a translation from a fallback. Discriminator used instead: page.path keeps the original locale-suffixed filename" }, "measurements": { "sitemap_entries_before": 3892, "sitemap_entries_after": 346, "distinct_urls_before": 574, "distinct_urls_after": 346, "duplicate_entries_removed": 3318, "hreflang_alternates_before": 31136, "hreflang_alternates_after": 2410, "docs_entries_per_locale_after": "en 79, zh-Hans 62, ja 39, de 38, es 39, fr 39, ko 39 = 335 — equals the file counts on disk exactly" }, "reverse_verification": { "leg_1_contract_can_go_red": "PASS, direction as predicted (RED). Dropped the second arg at one call site; mutation proven on disk by blob hash 197084a -> 7e9b75d plus grep counts (old-text 0, injected-text 1). type-check: 'app/sitemap.ts(44,23): error TS2554: Expected 2 arguments, but got 1.', 'Tasks: 0 successful, 1 total'. Restored with git checkout HEAD -- path; blob back to 197084a and git diff HEAD empty", "leg_2_measurement_is_caused_by_the_change": "PASS. All three files checked out at BASE ee74379 (each blob hash change verified individually; a zero-hit checkout would have aborted), rebuilt through the same turbo --force command: 3892 entries / 574 distinct. Restored and REBUILT: 346 / 346, git status --porcelain empty. Both legs rebuilt — no measurement was taken against a stale .next", "independent_agreement": "the 3892/574/3318 baseline was predicted analytically by driving the installed loader() over the real content tree before any build, and the build reproduced it exactly" }, "bounded_in_place_fix": { "what": "privacy and terms trimmed from 7 locales to en + zh-Hans in sitemap.ts (10 bogus entries)", "why_in_scope": "same defect class and same adjudicated rule; those two route components hold their copy in a module-private content record with exactly two keys and render content[lang] ?? content.en for the other five. Fix is inside the declared file surface (sitemap.ts) and inside the already hand-maintained staticPaths table", "declared": "named in the PR body with evidence; the drift risk it leaves is filed as #173", "root_path_left_alone": "the redirecting root stays at 7 locales / priority 1 — real in all seven under this card's rule, and whether it belongs in the sitemap at all is #171's" }, "deviations": [ "Duplicate search and issue listing used the MCP GitHub tools, not the REST list endpoint the dispatch contract prefers: REST via the proxy-injected GITHUB_TOKEN returns 'GitHub access is not enabled for this session' in this container, so that channel is closed here. gh CLI is not installed either", "No automated regression test added. This repo has no vitest and no test harness for apps/docs; its test task runs --self-test on .github/scripts/*.mjs, which is outside the declared file surface and would be a new gate surface. Verification is the build-output measurement the card's own Verification section asks for. Gap filed as #175", "No changeset (this repo has no changeset flow) and no skip-changeset label (that mechanism belongs to the objectstack repo, not this one)", "No browser run. AGENTS.md rule 2 asks for a real browser for UI changes; this change emits no UI, only document-head metadata and sitemap XML, both of which were read out of the prerendered build artifacts instead" ], "open_questions": [ { "question": "Should an untranslated locale URL (/ja/docs/operate/backup, English body via fallback) keep self-canonicalizing? It is now out of the sitemap and out of every hreflang cluster, but still declares itself canonical and names a cluster it is not a member of.", "options": [ "A. canonical -> the English URL when the locale has no real translation (textbook fix for fallback duplication)", "B. robots noindex on those URLs (strongest; also drops them as an English result)", "C. leave as-is (no longer advertised anywhere; residual exposure only from off-site links)" ], "recommendation": "A, because it states the truth the fallback already creates and concentrates signals on the English page without making the URL unusable. Not adjudicated on this card — the adjudication covers sitemap entries and hreflang alternates only — so it was left byte-identical to main and filed as #174." } ], "repricing_166": { "verdict": "unaffected", "detail": "generateMetadata keeps the same shape and the same four returned fields; only the alternates.languages expression changed, to languageAlternates(path, translatedLocales(page.slugs)). Nothing #166 adds interacts with it: the SEO-title frontmatter field, openGraph/twitter, JSON-LD and the OG site= string are all disjoint from alternates. localeUrl(params.lang, path) — which #166 wants to reuse for openGraph.url — is unchanged and still in place. The PM's recorded trap is resolved rather than inherited: languageAlternates now has its final 2-required-arg signature, so #166 reads a settled shape; only its quoted line numbers moved (page.tsx grew 4 lines).", "new_serialization_pressure": "one, created by this card: #174 (canonical on fallback URLs) also lands in generateMetadata in the same file. #166 and #174 should be sequenced against each other the same way #166 was sequenced behind this card." }, "summary": "sitemap.ts and languageAlternates() now derive the advertised locale surface from the files that actually exist. New translatedLocales(slugs) in lib/seo.ts discriminates a real translation from fumadocs' English fallback by file identity (page.path keeps the original locale-suffixed filename), because the loader's own getPages(lang)/getLanguages() report all 79 pages for all seven locales and cannot tell the two apart. languageAlternates() now takes the locale set as a required argument so no future call site can silently fall back to all seven. Sitemap goes 3892 entries over 574 distinct URLs to 346 over 346; per-locale docs counts match the files on disk exactly. x-default still points at English, English is still emitted for every page, i18n.languages and routing are untouched.", "tests": "All gates re-run on the FINAL commit 587d9a4 after the last commit: turbo run type-check --continue --force -> 'Tasks: 1 successful, 1 total'; turbo run build --force -> 'Tasks: 1 successful, 1 total'; turbo run test --force -> '2 self-test(s) passed'; check-node-floor.mjs --self-test and check-node-floor.mjs both exit 0 with their own verdict lines quoted above. Exit codes were captured before any pipe (redirect-then-tail), never from a piped $?. Card verification steps, read from apps/docs/.next/server/app/sitemap.xml.body and the prerendered HTML: 346 entries / 346 distinct URLs, docs entries per locale 79/62/39/38/39/39/39 matching the file counts; /docs/operate/backup emits only hreflang en + x-default and /ja/docs/operate/backup is absent from the sitemap; /docs/configure/permissions/positions and its /ja/ counterpart emit the identical 8-element cluster. Two reverse-verification legs, both with the mutation proven on disk by blob hash (not by the editor's exit code) and the restore proven by an empty git diff HEAD / git status --porcelain, restoring with git checkout HEAD -- <abs path> from an absolute REPO_ROOT under an EXIT/INT/TERM trap: (1) dropping the required second argument turns type-check RED with TS2554 as predicted; (2) checking the three files out at BASE ee74379 and REBUILDING reproduces 3892/574, and restoring and REBUILDING returns 346/346 — both legs rebuilt, so neither measurement came off a stale .next.", "out_of_scope_findings": [ "filed as #173: [finding] sitemap.ts restates the locale surface of privacy and terms because those two pages keep their copy module-private", "filed as #174: untranslated locale URLs still self-canonicalize after this card — options priced, lands in the same generateMetadata as #166", "filed as #175: [finding] nothing pins the generated sitemap — the 3892-entry tree was green on every gate" ] }Generated by Claude Code
Generated by Claude Code
hotlong commentedon Aug 25, 2026
ACCEPT — PM review of record. PR #176 at
587d9a4.Checklist
main✓, body first lineFixes #169✓changed_files: 3, exactly the three declared. No breach ✓buildandNode floorbothcompleted/success— every check on the PR, not a required subset ✓apps/docs/**; no governed surface touched ✓clean✓Spot checks I ran against the tree, not against the report
translatedLocales()returnsfalsebefore it ever compares paths whengetPage(slugs, lang)is undefined, so a missing English page cannot be reported as present via thelang === defaultLanguageshort-circuit. The branch is unreachable anyway — the callers enumerate fromgetPages(i18n.defaultLanguage)— but it is correct rather than accidentally correct.privacy/termsclaim holds. Those two route components carry exactly two localized titles between them (Privacy Policy/隐私政策,Terms of Service/服务条款). Trimming those paths toen+zh-Hansmatches what is actually written.x-defaultstill points at English on every cluster, including the trimmed static paths and the 7-locale root.Two of my mechanism assumptions were wrong, and the dev caught both
Recording this plainly because the dispatch asked for falsification and got it:
source.getPages()returned the default-language set. It returns every locale's set concatenated. So the real defect on main was 3892 sitemap entries over 574 distinct URLs — 3318 exact duplicates — not the "roughly 553 URLs" the card projected. My estimate was off by about 7x, and every URL was being submitted seven times over on top of the locale problem the card described.getLanguages()/getPages(lang)— cannot do this job at all. Each locale's file system is built asnew FileSystem(inherit), which copies the English files in, so all seven locales report 79 pages and those APIs cannot tell a translation from a fallback. Thepage.pathfile-identity discriminator the dev used instead is a better answer than the one I proposed.My assumption 1 (fallback rather than 404) was confirmed, so the card's duplicate-content reasoning stands as written.
On the verification
The two reverse-verification legs are what carry this, not the green gates — the dev's own #175 makes the point that
type-check,buildandtestwere green on the 3892-entry tree too, so green proves nothing about this defect. Leg 2 (check the three files out atee74379, rebuild, get 3892/574; restore, rebuild, get 346/346) is what establishes the number is caused by the change, and proving the mutation landed by blob hash rather than by the editor's exit code is the right standard.Deviations — all accepted
.github/scripts/, outside the declared file surface and a new gate surface. Filed as [finding] nothing pins the generated sitemap — the seven-locales-for-every-page defect was green on every gate #175 rather than smuggled in. Correct.sitemap.xml.bodyis the stronger check here anyway.testsstring. Cosmetic; the local copy matches otherwise.Bounded in-place fix — accepted, with the drift acknowledged
Trimming
privacy/termsto two locales is the same defect class under the same adjudicated rule, inside the declared file surface, and inside a table that was already hand-maintained. It was declared in the PR body with evidence and the drift it leaves is filed as #173. That is the shape a bounded fix is supposed to have.Follow-ups
generateMetadataasgenerateMetadatareturns four fields: docs pages ship no SEO title, no Open Graph, and no structured data #166.generateMetadatareturns four fields: docs pages ship no SEO title, no Open Graph, and no structured data #166 goes first (older card, larger change); Untranslated locale URLs still self-canonicalize after #169 — /ja/docs/operate/backup declares itself canonical while serving the English body #174 rebases onto it.Proceeding to ready and the merge queue.
Generated by Claude Code
fix(docs): point an untranslated locale URL at the English page it se…
fix(docs): point an untranslated locale URL at the English page it se…
ci: pin the generated sitemap's locale composition against the conten…
fix(docs): derive the sitemap's static-page locales from the pages th…
ci: pin the locale composition of llms.txt and llms-full.txt (#202)