Skip to content

Match literal navigation entries in every locale - #185

Open
KayleeWilliams wants to merge 1 commit into
dx/diagnostics-hardeningfrom
dx/i18n-literal-navigation
Open

Match literal navigation entries in every locale#185
KayleeWilliams wants to merge 1 commit into
dx/diagnostics-hardeningfrom
dx/i18n-literal-navigation

Conversation

@KayleeWilliams

@KayleeWilliams KayleeWilliams commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #181#179#167.

Reproduction (verified, not inferred)

Minimal i18n project with a fully translated navigation:

docs/docs.config.ts   → i18n: { defaultLocale: "en", locales: ["en", "zh"] },
                        navigation: ["index"]
docs/index.mdx
docs/zh/index.mdx

leadtype generate --src . --out out
  → exit 1: Nav page "index" under "root" did not match a documentation page.

Every navigation + i18n project failed the same way — generate's per-locale validation loop cannot resolve any literal entry for any non-default locale, contradicting generate.ts's own comment that navigation resolves per locale over locale-stripped paths. Flagged in review on 041d445 during #179; the nav.test.ts i18n literal-nav test pinned the failure as expected behavior.

Root cause

For a non-default locale, SourceDoc.relativePath is 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 on relativePath, so the zh pass had nothing an entry could ever name.

The fix: locale-stripped matching, fallback: "default" honored

Nav 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/exclude globs, pin, and path sorting:

  • For the default locale and non-i18n projects the two paths are identical, so behavior is byte-identical. Verified beyond the test suite: rebuilt the package's own docs corpus on this branch vs. its base — output differs only in timestamps.
  • For locale zh, entry index matches zh/index.mdx; an entry naming an untranslated page resolves to the default locale's file re-selected under zh (isFallback: true in the manifest) — exactly what the forced fallback: "default" promises.
  • The shared resolver (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: false on 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 single docs/index.mdx with group: mystery and 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's isFallback marker; 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

  • Full package suite: 919 tests pass, including new coverage for per-locale literal/include/exclude/pin resolution, fallback pages in per-locale trees, a generate-level end-to-end of the original repro, and the doctor dedupe (the previously .find(...)-based locale assertion is tightened to an exact list).
  • tsgo --noEmit clean, ultracite check clean.
  • Original repro now generates successfully with correct per-locale trees (/docs for en, /docs/zh for zh).

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

- 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.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4b9a335e-9c45-4211-978c-b0bb191e0732

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +2633 to +2635
return i18n && locale !== undefined && locale !== i18n.defaultLocale
? "skip"
: "error";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 matchingnavDocPath(doc) = doc.logicalPath ?? doc.relativePath now backs literal-entry lookup, the include/exclude glob filter, pin resolution, and the path sort key, so an entry authored once against the default layout resolves in every locale.
  • NavMissingPageBehaviornavMissingPageBehavior(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 unknown entries carry isFallback, and both config/navigation.ts:296 and cli/generate.ts:1897 drop fallback re-selections so one shared default-locale file with a bad group: reports once rather than once per locale.
  • Public type changeDocsNavigation.unknown gains an optional isFallback (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 in cli.test.ts, the retired locale-failure assertion in nav.test.ts, and the dedupe in doctor.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 the entry.required throw, so required: true on an include is a no-op for non-default locales. That follows from the design, but the NavMissingPageBehavior JSDoc (llm/llm.ts:2014-2024) names only literal refs and pins — worth mentioning required there so the next reader doesn't treat the omission as an oversight.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

locale: string | undefined
): NavMissingPageBehavior {
// An unset locale reads as the default locale, matching readSourceDocs.
return i18n && locale !== undefined && locale !== i18n.defaultLocale

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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.

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.

1 participant