Match literal navigation entries in every locale - #185
Conversation
- Resolve nav entries against locale-stripped logical paths. A
non-default locale's SourceDoc.relativePath is the locale-prefixed
output path ("zh/index"), while nav entries are authored once against
the default layout ("index") — so generate's per-locale validation
failed resolution for every non-default locale and every
`navigation` + `i18n` project exited 1, contradicting generate's own
comment that navigation resolves per locale over locale-stripped
paths. Literal refs, include globs, excludes, pins, and the path sort
key all match on `logicalPath ?? relativePath`; for the default
locale and non-i18n projects the two are identical, so nothing
changes there. An entry naming an untranslated page resolves to the
default locale's file re-selected under the locale — exactly what
`fallback: "default"` promises — and the shared resolver, doctor, and
nav mirror the fix through the same resolution call.
- Skip, rather than fail on, entries whose page has no translation in
the translated-only artifact views. A non-default locale's llms.txt,
llms-full, readability manifest, and AGENTS.md read with
`includeFallback: false` on purpose (fallback pages are served, not
advertised as localized content), so a literal ref or pin can now
miss there for pages the strict fallback-complete validation pass
already accepted. The default locale and non-i18n projects keep
failing on a real miss.
- Report a shared fallback file's unknown group once, not per locale.
`fallback: "default"` re-selects every default-locale page under each
non-default locale, so one untranslated file with a bad `group:`
produced one doctor finding per configured locale where generate
reports one total. Unknown-group manifest entries carry the page's
`isFallback` marker, per-locale validation drops fallback
re-selections (the default manifest already carries that file's
finding), and a locale's own defective pages still report under
their locale.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 99087a8e79
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return i18n && locale !== undefined && locale !== i18n.defaultLocale | ||
| ? "skip" | ||
| : "error"; |
There was a problem hiding this comment.
Validate missing nav entries before skipping translations
When an exported artifact generator such as generateLlmsTxt, generateLLMFullContextFiles, generateAgentReadabilityArtifacts, or generateAgentsMd is called directly for a non-default locale, this selects "skip" solely from the locale and assumes the CLI's fallback-complete validation already ran. A typo such as nav: ["missing-page"] is therefore silently omitted instead of raising the documented missing-page error. Validate the navigation against the complete logical page set inside these standalone APIs, or only skip entries proven to exist in the default locale.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Important
The core fix holds up — I traced the logicalPath keying swap and the fallback dedupe and found no divergence. But this PR makes generate.ts's locale loop reachable for the first time on navigation + i18n projects, and the loop captures the wrong locale's tree when i18n.locales doesn't happen to list the default first.
Reviewed changes — the whole diff at 99087a8 (8 files, 1 commit), plus the surrounding nav/i18n resolution path, the four artifact generators that consume it, and the doctor/nav validators.
- Locale-stripped nav matching —
navDocPath(doc) = doc.logicalPath ?? doc.relativePathnow backs literal-entry lookup, theinclude/excludeglob filter, pin resolution, and the path sort key, so an entry authored once against the default layout resolves in every locale. NavMissingPageBehavior—navMissingPageBehavior(i18n, locale)returns"skip"for every non-default locale, letting the translated-only artifact views (llms.txt, llms-full, readability manifest, AGENTS.md) drop entries whose page has no translation instead of throwing.- Unknown-group dedupe — manifest
unknownentries carryisFallback, and bothconfig/navigation.ts:296andcli/generate.ts:1897drop fallback re-selections so one shared default-locale file with a badgroup:reports once rather than once per locale. - Public type change —
DocsNavigation.unknowngains an optionalisFallback(llm/readability.ts:177); the doctor/nav drift report maps it back to{ urlPath, slug }. - Tests — per-locale literal/include/exclude/pin resolution and fallback marking in
llm.test.ts, the generate-level repro incli.test.ts, the retired locale-failure assertion innav.test.ts, and the dedupe indoctor.test.ts.
I separately confirmed the two load-bearing claims in the description: logicalPath and outputRelativePath are identical for non-i18n reads and for the default locale (so the four keying swaps are behavior-preserving there), no two docs can collapse to one logicalPath key ahead of the existing duplicate-route throw, and the default-locale and per-locale passes in resolveCollectionNavigation share one resolveConfig, so the dedupe premise holds under collection filters and mounted dirs.
⚠️ The locale loop captures whichever locale is listed first, not the default locale
Three lines below the changed firstUnknownGroup filter, defaultLocaleNavigation ??= navigation (cli/generate.ts:1905) takes the first iterated locale — i18n.locales[0] — which is not necessarily i18n.defaultLocale. normalizeDocsI18nConfig preserves config order and only validates membership, and docs/reference/i18n.mdx:36 documents no ordering requirement, so { defaultLocale: "en", locales: ["zh", "en"] } is a legal config whose root llms.txt blocks would be inferred from the zh tree (Chinese titles, /docs/zh/... links).
The line is untouched by this diff, but this PR is what makes it reachable: before the fix, the loop threw at the first non-default locale for every navigation + i18n project, so a hard failure masked the mis-capture. Now the loop completes and the wrong tree flows into inferLlmsBlocks silently.
Technical details
# `defaultLocaleNavigation` must be pinned to `i18n.defaultLocale`
## Affected sites
- `packages/leadtype/src/cli/generate.ts:1905` — `defaultLocaleNavigation ??= navigation;` inside `for (const locale of localesToValidate)`. `localesToValidate` is `i18n.locales.map((l) => l.code)` (line 1881), in author-declared order.
- `packages/leadtype/src/cli/generate.ts:1912-1915` — the captured tree is the sole input to `inferLlmsBlocks`, which populates `product.blocks` when the author declared none.
- `packages/leadtype/src/i18n/index.ts:107-127` — `normalizeDocsI18nConfig` pushes locales in config order and only asserts `defaultLocale` is a member; it never hoists it.
## Required outcome
- The navigation handed to `inferLlmsBlocks` is the default locale's tree regardless of the order `i18n.locales` is declared in.
- Non-i18n projects (where `localesToValidate` is `[undefined]`) keep capturing the single resolved tree.
## Suggested approach (optional)
Assign on the default-locale iteration rather than the first one, e.g.
`if (locale === undefined || locale === i18n?.defaultLocale) { defaultLocaleNavigation = navigation; }`.
A regression test with `locales: ["zh", "en"], defaultLocale: "en"` asserting the root `llms.txt` links resolve under `/docs/`, not `/docs/zh/`, would pin it.ℹ️ Nothing pins that a real nav typo still fails in a non-default locale
"skip" is the only new way for a nav entry to disappear without an error, and every added test exercises the legitimate case — a page that simply has no translation yet. None asserts the complementary guarantee the design rests on: that a genuinely missing page (a typo or a rename the config missed) still exits 1 for an i18n project. The existing Nav page "…" / Nav pin "…" failure tests are all non-i18n, so a future change to navMissingPageBehavior could widen "skip" with a fully green suite.
Technical details
# Pin the strict-pass safety net for i18n projects
## Affected sites
- `packages/leadtype/src/llm/llm.ts:2221-2223`, `2180-2182`, `2252-2254` — the three new `"skip"` escape hatches.
- Existing failure coverage, all non-i18n: `packages/leadtype/src/llm/llm.test.ts:2805`, `packages/leadtype/src/cli/doctor.test.ts:411,477`, `packages/leadtype/src/cli/nav.test.ts:373,395`, `packages/leadtype/src/navigation/authoring.test.ts:164,178`.
## Required outcome
- A test proves that in an i18n project (`locales: ["en", "zh"]`), a `navigation` entry naming a page that exists in *no* locale still fails `generate` with `Nav page "…" did not match a documentation page.` — i.e. the strict `resolveDocsNavigation` pass catches what `"skip"` lets through downstream.
- Ideally a second case for a pin that matches nothing anywhere.ℹ️ Nitpicks
resolveNavEntryPages(llm/llm.ts:2252-2254) returns before theentry.requiredthrow, sorequired: trueon anincludeis a no-op for non-default locales. That follows from the design, but theNavMissingPageBehaviorJSDoc (llm/llm.ts:2014-2024) names only literal refs and pins — worth mentioningrequiredthere so the next reader doesn't treat the omission as an oversight.
Claude Opus | 𝕏
| locale: string | undefined | ||
| ): NavMissingPageBehavior { | ||
| // An unset locale reads as the default locale, matching readSourceDocs. | ||
| return i18n && locale !== undefined && locale !== i18n.defaultLocale |
There was a problem hiding this comment.
"skip" is selected purely from locale !== defaultLocale, but the property that makes it safe is that the doc set was read with includeFallback: false and that a strict resolveDocsNavigation pass already validated the entry. Neither is checked here: all four generators are public API (llm/index.ts:63-67) and none of them runs the strict pass — only executeGenerate does, by convention.
Technical details
# `navMissingPageBehavior` gates on locale, not on the condition that makes skipping safe
## Affected sites
- `packages/leadtype/src/llm/llm.ts:2628-2636` — returns `"skip"` for any non-default locale.
- `packages/leadtype/src/llm/llm.ts:2014-2024` — the JSDoc asserts "the strict per-locale pass in `resolveDocsNavigation` … has already validated the entry itself." Nothing enforces that.
- Call sites that rely on the convention: `generateLlmsTxt` (2740), `generateLLMFullContextFiles` (2808), `generateAgentReadabilityArtifacts` (2991), `generateAgentsMd` (3624). All four are exported from `packages/leadtype/src/llm/index.ts:63-67`.
- Direct in-repo consumers that bypass the CLI: `apps/tanstack/scripts/llm-generate-real.ts:17`, `apps/tanstack/scripts/llm-generate.ts`, `apps/c15t-example/scripts/llm-generate-real.ts`, `packages/leadtype/scripts/generate-docs.ts`.
- The only enforcement point: `packages/leadtype/src/cli/generate.ts:1884-1906`.
## Required outcome
- A consumer calling `generateLlmsTxt` / `generateAgentsMd` / `generateLLMFullContextFiles` / `generateAgentReadabilityArtifacts` directly with an i18n config and a non-default locale does not silently drop a typo'd literal entry or pin.
## Suggested approach (optional)
Derive the behavior from the actual reason an entry can miss — the doc set being fallback-incomplete — rather than from the locale. Each call site already knows it passed `includeFallback: false`, so threading that through (or the resolved `includeFallback` from `resolveLocaleReadOptions`) ties `"skip"` to the condition it is justified by, and keeps a caller who reads the fallback-complete set on the strict path.
## Open questions for the human
- Is the CLI the only supported entry point for i18n projects? If so, saying that in the JSDoc is probably enough and no code change is needed.
Stacked on #181 → #179 → #167.
Reproduction (verified, not inferred)
Minimal i18n project with a fully translated navigation:
Every
navigation+i18nproject failed the same way — generate's per-locale validation loop cannot resolve any literal entry for any non-default locale, contradictinggenerate.ts's own comment that navigation resolves per locale over locale-stripped paths. Flagged in review on 041d445 during #179; thenav.test.tsi18n literal-nav test pinned the failure as expected behavior.Root cause
For a non-default locale,
SourceDoc.relativePathis the locale-prefixed output path (zh/index), while nav entries are authored once against the default layout (index).createDocsByRelativePath, the include-glob filter,applyNavPins, and the path sort key all matched onrelativePath, so the zh pass had nothing an entry could ever name.The fix: locale-stripped matching,
fallback: "default"honoredNav resolution now matches pages by
logicalPath ?? relativePath— the locale-stripped logical path when locale selection ran, the output path otherwise. That one rule covers literal refs,include/excludeglobs,pin, and path sorting:zh, entryindexmatcheszh/index.mdx; an entry naming an untranslated page resolves to the default locale's file re-selected underzh(isFallback: truein the manifest) — exactly what the forcedfallback: "default"promises.config/navigation.ts), doctor, and nav mirror the fixed generate through the same resolution call — parity by construction; the test that pinned the locale-scoped failure now pins the success.Dogfood corpus (en/zh/fr, nested sections, include+exclude+pin, partial translations): generate exits 0; every locale's tree mirrors the default tree with fallback pages marked; doctor/nav report only genuine drift.
One consequence handled explicitly: the translated-only artifact views (a non-default locale's llms.txt, llms-full, readability manifest, AGENTS.md) read with
includeFallback: falseon purpose — fallback pages are served, not advertised as localized content. Entries that miss there because the page has no translation are skipped (NavMissingPageBehavior), since the strict fallback-complete validation pass already accepted them; the default locale and non-i18n projects keep failing on a real miss.Per-locale unknown-group findings dedupe (open #179 thread)
fallback: "default"re-selects every default-locale page under each non-default locale, so a singledocs/index.mdxwithgroup: mysteryand locales en/zh/fr produced three doctor findings (/docs,/docs/zh,/docs/fr) where generate reports one. Unknown-group manifest entries now carry the page'sisFallbackmarker; per-locale validation drops fallback re-selections (the default manifest already carries that file's finding — one finding per edit site), while a locale's own defective page still reports under its locale (/docs/zh declares unknown group "mystery"). Generate's per-locale loop applies the same rule, so the failure always names the true source. The report shape stays{ urlPath, slug }.Verification
.find(...)-based locale assertion is tightened to an exact list).tsgo --noEmitclean,ultracite checkclean./docsfor en,/docs/zhfor zh).Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.