Skip to content

fix(docs): reject non-locale [lang] segments instead of serving the homepage (soft-404 class) - #12258

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-12233-dotted-soft-404
Aug 25, 2026
Merged

fix(docs): reject non-locale [lang] segments instead of serving the homepage (soft-404 class)#12258
os-zhuang merged 1 commit into
mainfrom
claude/issue-12233-dotted-soft-404

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#12233

apps/docs/app/[lang]/ is a catch-all: it matches any single path segment. It is normally
unreachable with a junk segment, because proxy.ts rewrites /<x> to /en/<x> — two segments,
which match nothing, so /nonsense 404s. But the proxy's matcher deliberately excludes paths
containing a dot (static assets must not be locale-rewritten), so a dotted single-segment path
skipped the proxy entirely, landed on [lang] with lang set to that literal segment, and
rendered the full homepage under a 200.

The declared locales are the contract, so this enforces them at the point they were 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.

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 only
where the proxy actually ran:

GET /docs 200 (next.js: 23ms, proxy.ts: 1.9ms, generate-params: 1.0ms, ...)
GET /blog 200 (next.js: 6ms, proxy.ts: 1.5ms, generate-params: 1.0ms, ...)
GET /this-page-does-not-exist 404 (next.js: 525ms, proxy.ts: 6ms, ...)
GET /foo.txt 200 (next.js: 5ms, generate-params: 2ms, ...) <- no proxy.ts
GET /ads.txt 200 (next.js: 1.8ms, generate-params: 0.0ms, ...) <- no proxy.ts
GET /robots.txt 200 (next.js: 1.8ms, generate-params: 1.5ms, ...) <- no proxy.ts
GET /llms.txt 200 (next.js: 4.4s, application-code: 16ms) <- no proxy.ts, no [lang]

lang really is the literal segment. A temporary console.log in the layout, added and then
restored to its exact HEAD blob (f1c6ad457), printed:

[probe] lang="foo.txt" [probe] lang="ads.txt" [probe] lang="robots.txt"
[probe] lang="sitemap.xml" [probe] lang="en" <- /docs, /blog and / all arrive as "en"

The generate-params timing on the dotted requests is generateStaticParams from
app/[lang]/layout.tsx running: the [lang] segment matched, and dynamicParams defaulting to
true rendered the unlisted parameter on demand.

The issue body's quoted matcher arrived intact — byte-identical to apps/docs/proxy.ts on
origin/main (blob 4c8540cfd). 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 full next build of the pre-fix
tree, so the "after" column can be read as a change rather than as a state.

pathdev beforedev afterprod beforeprod after
/foo.txt200 homepage404200 homepage404
/ads.txt200 homepage404200 homepage404
/security.txt200 homepage404200 homepage404
/anything.html200 homepage404200 homepage404
/sitemap_index.xml200 homepage404200 homepage404
/robots.txt200 homepage404 *200 homepage404 *
/sitemap.xml200 homepage404 *200 homepage404 *
/this-page-does-not-exist404404404404
/docs200 Documentation200 Documentation200200
/docs/getting-started/quick-start200200200200
/docs/getting-started/quick-start.mdx200 text/markdown200 text/markdown200200
/en307 → /307 → /307 → /307 → /
/en/docs307 → /docs307 → /docs307 → /docs307 → /docs
/blog200 Blog200 Blog200200
/blog/protocol-first-development200200200200
/200 homepage200 homepage200200
/llms.txt200 text/plain200 text/plain200200
/llms-full.txt200 text/plain200 text/plain200200
/og/docs/image.png200 image/png200 image/png200200
/og/docs/getting-started/quick-start/image.png200 image/png200 image/png200200
/api/search?query=object200 application/json200 application/json200200

* /robots.txt and /sitemap.xml 404 here only because those routes do not exist yet — they
are #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 / .mdx endpoints are a deliberate feature for agent readers and
are explicitly out of bounds for this epic. They are unaffected: /llms.txt never reached [lang]
in the first place (no generate-params in its timing), because a static route segment beats
the dynamic one.

The sibling card's precedence assumption holds — measured, not assumed

#12233's acceptance list requires that /robots.txt and /sitemap.xml still reach their real
routes 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.ts and
apps/docs/app/sitemap.ts were added temporarily, the site was rebuilt with this guard in
place
, and:

/robots.txt 200 text/plain User-Agent: * / Allow: / / Sitemap: https://objectstack.ai/sitemap.xml
/sitemap.xml 200 application/xml <?xml version="1.0" encoding="UTF-8"?><urlset ...
/ads.txt 404 still rejected
/foo.txt 404 still rejected
/docs 200 unchanged
/llms.txt 200 text/plain unchanged

The build's own route table listed them as ○ /robots.txt and ○ /sitemap.xml. Precedence goes
the 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 = false

The 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. It
    is independent of render mode, independent of whether generateStaticParams exists, greppable,
    and it cannot silently degrade if someone later edits the static params.
  • export const dynamicParams = false derives its correctness from generateStaticParams rather
    than 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 missing
    from source.generateParams(). That is a wider blast radius than this card, for no additional
    externally visible behaviour: both produce a 404.

dynamicParams = false remains available as a follow-up if router-level rejection (no render at
all for a junk URL) is wanted; it is an optimization, not the fix.

proxy.ts is unchanged, deliberately. Making the matcher rewrite dotted paths would send
/llms.txt, /llms-full.txt, /og/**/*.png and /docs/**.mdx to /en/<same>, where nothing
matches — 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 as pnpm check:docs-locale-catch-all in
Lint & 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:

IF a dotted single-segment path bypasses proxy.ts's matcher, THEN every top-level dynamic
segment under apps/docs/app/ must reject a parameter that is not a declared locale, before it
renders anything.

The requirement is on the segment, not on one filename — a future app/[slug]/ reintroduces the
same 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 real checkApp() path. Every
    limb observed failing — deleted guard, guard placed after the return, a predicate that stopped
    reading i18n.languages, a new unguarded top-level segment, an uncompilable matcher — and the
    proxy 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
    20b0fdb56 it reports 2 findings —

    • app/[lang]/ is a top-level catch-all and nothing rejects a non-locale `lang`: ...
    Dotted paths (/ads.txt, /security.txt, /sitemap_index.xml, /anything.html) skip proxy.ts
    and land here, so every one of them renders this segment under a 200.
    • lib/i18n.ts does not export `isSupportedLanguage`
    

    and 0 on this branch.

Verification

All at 6f5d011 (the final commit of this branch), except where a run is explicitly against
20b0fdb56.

commandresult
pnpm check:docs-locale-catch-all✓ ... 1 top-level dynamic segment(s), 1 guarded; dotted paths bypass proxy.ts: true; self-test ✓ 12 assertions
pnpm --filter @objectstack/docs typecheckexit 0 — fumadocs-mdx && next typegen && tsc --noEmit, ✓ Types generated successfully
pnpm --filter @objectstack/docs exec next buildexit 0 — ✓ Compiled successfully in 25.4s, ✓ Generating static pages using 2 workers (1220/1220)
eslint . --no-inline-config (repo-wide)exit 0, no findings — see the heap note below
pnpm check:nul-bytesOK, 6766 text files
pnpm check:docs-redirectsOK, 92 entries
pnpm check:entry-guard / check:parse-guardOK
check-self-test-wired / check-self-test-workflow-commandsOK — 133 scripts, the new self-test is run by CI
check-step-collectors / check-aggregator-roster / check-ci-filter-parity / check-whole-set-label-writeOK
pnpm check:required-contexts / check:workflow-status-functions / check:pnpm-filter-targetsOK
pnpm check:cross-package-test-inputs / check:agent-test-spellingOK

The 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; the
files 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.sh
could not take the shared verify lock on this host: no usable flock. The shared
verify lock is declared Linux-only (flock is util-linux, and a stock macOS does
not 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.

NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter @objectstack/docs exec next build
node --stack-size=4000 --max-old-space-size=8192 node_modules/eslint/bin/eslint.js . --no-inline-config

Heap note on the repo-wide lint.pnpm lint as spelled aborts on this host with
FATAL ERROR: Ineffective mark-compacts near heap limit (exit 134) at ~4055 MB — the script pins
--stack-size=4000 but not --max-old-space-size, and the sweep now sits at the default V8 heap
limit. 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.tsx is not touched: #12218 is editing it right now, and the fix
belongs in the layout regardless, since the layout is what wraps [lang]/docs/** and
[lang]/blog/** as well.

Generated by Claude Code

… 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>
@os-zhuangos-zhuang added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 25, 2026
@github-actionsgithub-actionsBot added size/m ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation labels Aug 25, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 25, 2026 16:29
@os-zhuang
os-zhuang added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 8cdd696Aug 25, 2026
37 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-12233-dotted-soft-404 branch August 25, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs site: any single-segment path containing a dot renders the homepage with 200 (soft-404 class)

2 participants

@os-zhuang@hotlong