Uh oh!
There was an error while loading. Please reload this page.
fix(docs): reject non-locale [lang] segments instead of serving the homepage (soft-404 class) - #12258
Merged
Merged
Conversation
… homepage `apps/docs/app/[lang]/` matches ANY single path segment, and `proxy.ts`'s matcher deliberately excludes dotted paths from locale rewriting (static assets must not be rewritten). So every dotted single-segment URL skipped the proxy, landed on `[lang]` with `lang` set to that literal segment, and rendered the full homepage under a 200 -- an unbounded set of duplicate homepages at exactly the URLs crawlers probe by default, including `/robots.txt` and `/sitemap.xml`. Measured before the fix on the dev server: `/foo.txt`, `/ads.txt`, `/security.txt`, `/anything.html`, `/sitemap_index.xml`, `/robots.txt` and `/sitemap.xml` all returned 200 with the homepage, while `/this-page-does-not-exist` correctly returned 404. A temporary probe in the layout printed `lang="foo.txt"`, `lang="ads.txt"`, `lang="robots.txt"`, `lang="sitemap.xml"` against `lang="en"` for `/docs`, `/blog` and `/`. The declared locales are the contract, so enforce them where they are violated: `lib/i18n.ts` gains `isSupportedLanguage()` derived from `i18n.languages`, and the `[lang]` layout calls `notFound()` before it renders anything when the segment is not a declared locale. `scripts/check-docs-locale-catch-all.mjs` pins it. The guard is three lines in a layout that is otherwise pure presentation, and deleting it breaks nothing any other check can see -- every page still renders, every type still checks, every link still resolves; the only symptom is a 200 where a 404 belongs, on URLs no test requests. The gate checks the two halves as one conditional invariant (if dotted paths bypass the proxy, then every top-level dynamic segment must reject a non-locale parameter before rendering), so it reasons rather than pattern-matches and covers the class rather than the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 25, 2026
os-zhuang
marked this pull request as ready for review
August 25, 2026 16:29
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#12233
apps/docs/app/[lang]/is a catch-all: it matches any single path segment. It is normallyunreachable with a junk segment, because
proxy.tsrewrites/<x>to/en/<x>— two segments,which match nothing, so
/nonsense404s. But the proxy's matcher deliberately excludes pathscontaining a dot (static assets must not be locale-rewritten), so a dotted single-segment path
skipped the proxy entirely, landed on
[lang]withlangset to that literal segment, andrendered the full homepage under a 200.
The declared locales are the contract, so this enforces them at the point they were violated:
lib/i18n.tsgainsisSupportedLanguage()derived fromi18n.languages, and the[lang]layoutcalls
notFound()before it renders anything when the segment is not a declared locale.The root cause, measured rather than reasoned
The issue's root-cause section was a reading of the code. Both halves of it were confirmed on a
running server before anything was edited.
The proxy is skipped for dotted paths. The dev request log prints a
proxy.ts:timing onlywhere the proxy actually ran:
langreally is the literal segment. A temporaryconsole.login the layout, added and thenrestored to its exact HEAD blob (
f1c6ad457), printed:The
generate-paramstiming on the dotted requests isgenerateStaticParamsfromapp/[lang]/layout.tsxrunning: the[lang]segment matched, anddynamicParamsdefaulting totruerendered the unlisted parameter on demand.The issue body's quoted matcher arrived intact — byte-identical to
apps/docs/proxy.tsonorigin/main(blob4c8540cfd). No reconstruction was needed.Before / after
Both modes were measured both ways: the "before" column is a real run of the tree at
20b0fdb56, not an inherited claim. In production that meant a fullnext buildof the pre-fixtree, so the "after" column can be read as a change rather than as a state.
/foo.txt/ads.txt/security.txt/anything.html/sitemap_index.xml/robots.txt/sitemap.xml/this-page-does-not-exist/docsDocumentationDocumentation/docs/getting-started/quick-start/docs/getting-started/quick-start.mdxtext/markdowntext/markdown/en/////en/docs/docs/docs/docs/docs/blogBlogBlog/blog/protocol-first-development//llms.txttext/plaintext/plain/llms-full.txttext/plaintext/plain/og/docs/image.pngimage/pngimage/png/og/docs/getting-started/quick-start/image.pngimage/pngimage/png/api/search?query=objectapplication/jsonapplication/json*
/robots.txtand/sitemap.xml404 here only because those routes do not exist yet — theyare #12232's work. See the precedence measurement below: once they exist they win, and this guard
does not touch them.
The
llms.txt/llms-full.txt/.mdxendpoints are a deliberate feature for agent readers andare explicitly out of bounds for this epic. They are unaffected:
/llms.txtnever reached[lang]in the first place (no
generate-paramsin its timing), because a static route segment beatsthe dynamic one.
The sibling card's precedence assumption holds — measured, not assumed
#12233's acceptance list requires that
/robots.txtand/sitemap.xmlstill reach their realroutes once #12232 adds them, and #12232 asserts that metadata routes take precedence over the
[lang]catch-all. That was measured directly rather than inherited:apps/docs/app/robots.tsandapps/docs/app/sitemap.tswere added temporarily, the site was rebuilt with this guard inplace, and:
The build's own route table listed them as
○ /robots.txtand○ /sitemap.xml. Precedence goesthe way #12232 assumes, and this guard does not interfere with it. The two probe files were then
removed — they belong to #12232, not to this PR, and the tree is clean.
Why the explicit guard and not
dynamicParams = falseThe issue offered two candidates and asked which Next 16 honours in both modes. Both work in
dev — measured separately, each on its own, with every dotted path 404ing and every real page
unchanged. So the choice was not forced by the router; it was made on the merits:
if (!isSupportedLanguage(lang)) notFound()states the contract at the point it is violated. Itis independent of render mode, independent of whether
generateStaticParamsexists, greppable,and it cannot silently degrade if someone later edits the static params.
export const dynamicParams = falsederives its correctness fromgenerateStaticParamsratherthan stating it, and it is a segment config that also governs the nested segments —
[lang]/docs/[[...slug]]and[lang]/blog/[[...slug]]would start rejecting any slug missingfrom
source.generateParams(). That is a wider blast radius than this card, for no additionalexternally visible behaviour: both produce a 404.
dynamicParams = falseremains available as a follow-up if router-level rejection (no render atall for a junk URL) is wanted; it is an optimization, not the fix.
proxy.tsis unchanged, deliberately. Making the matcher rewrite dotted paths would send/llms.txt,/llms-full.txt,/og/**/*.pngand/docs/**.mdxto/en/<same>, where nothingmatches — it would 404 exactly the endpoints the epic protects. The proxy's dot exclusion is
correct; the unvalidated catch-all was the defect.
The regression pin
scripts/check-docs-locale-catch-all.mjs, wired aspnpm check:docs-locale-catch-allinLint & Repo Gates.A gate rather than a test because nothing else can see this regression: delete the three-line guard
and every page still renders, every type still checks, every link still resolves. The only symptom
is a 200 where a 404 belongs, on URLs no test requests.
It checks the two halves as one conditional invariant, so it reasons instead of pattern-matching:
The requirement is on the segment, not on one filename — a future
app/[slug]/reintroduces thesame soft-404 class and is named the moment it appears.
Evidence it is not a no-op:
--self-test: 12 assertions over a temp fixture through the realcheckApp()path. Everylimb observed failing — deleted guard, guard placed after the
return, a predicate that stoppedreading
i18n.languages, a new unguarded top-level segment, an uncompilable matcher — and theproxy condition observed flipping the requirement off (a matcher that does cover dotted paths
makes the missing guard green), which is what proves the condition is live rather than decorative.
A real red against real data, not construction: run against the actual pre-fix files from
20b0fdb56it reports 2 findings —and 0 on this branch.
Verification
All at
6f5d011(the final commit of this branch), except where a run is explicitly against20b0fdb56.pnpm check:docs-locale-catch-all✓ ... 1 top-level dynamic segment(s), 1 guarded; dotted paths bypass proxy.ts: true; self-test✓ 12 assertionspnpm --filter @objectstack/docs typecheckfumadocs-mdx && next typegen && tsc --noEmit,✓ Types generated successfullypnpm --filter @objectstack/docs exec next build✓ Compiled successfully in 25.4s,✓ Generating static pages using 2 workers (1220/1220)eslint . --no-inline-config(repo-wide)pnpm check:nul-bytespnpm check:docs-redirectspnpm check:entry-guard/check:parse-guardcheck-self-test-wired/check-self-test-workflow-commandscheck-step-collectors/check-aggregator-roster/check-ci-filter-parity/check-whole-set-label-writepnpm check:required-contexts/check:workflow-status-functions/check:pnpm-filter-targetspnpm check:cross-package-test-inputs/check:agent-test-spellingThe family list came from
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(27 families matched for these five paths), re-derived after the edits rather than taken from the
dispatch prompt. That run reports the tree as stale against a fast-moving
origin/main; thefiles it named as stale (
check-driver-conformance.mjs,gen-sdui-manifest.sh,os-regen-merge.sh,publish-smoke.sh) are unrelated to this change surface.Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usable
flock. The sharedverify lock is declared Linux-only (
flockis util-linux, and a stock macOS doesnot ship it), so the command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held for this
run, nor for any sibling agent in this container while it ran.
Heap note on the repo-wide lint.
pnpm lintas spelled aborts on this host withFATAL ERROR: Ineffective mark-compacts near heap limit(exit 134) at ~4055 MB — the script pins--stack-size=4000but not--max-old-space-size, and the sweep now sits at the default V8 heaplimit. That is a crash, not a lint finding, so it is reported as NOT MEASURED rather than as red.
Re-run with an 8 GB heap the same sweep is green with zero findings, which is the row in the
table above. Filed separately as an observation.
Scope
Docs-site only — no published package changes, so no changeset (
skip-changeset).apps/docs/app/[lang]/page.tsxis not touched: #12218 is editing it right now, and the fixbelongs in the layout regardless, since the layout is what wraps
[lang]/docs/**and[lang]/blog/**as well.Generated by Claude Code