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.

path dev before dev after prod before prod after
/foo.txt 200 homepage 404 200 homepage 404
/ads.txt 200 homepage 404 200 homepage 404
/security.txt 200 homepage 404 200 homepage 404
/anything.html 200 homepage 404 200 homepage 404
/sitemap_index.xml 200 homepage 404 200 homepage 404
/robots.txt 200 homepage 404 * 200 homepage 404 *
/sitemap.xml 200 homepage 404 * 200 homepage 404 *
/this-page-does-not-exist 404 404 404 404
/docs 200 Documentation 200 Documentation 200 200
/docs/getting-started/quick-start 200 200 200 200
/docs/getting-started/quick-start.mdx 200 text/markdown 200 text/markdown 200 200
/en 307 → / 307 → / 307 → / 307 → /
/en/docs 307 → /docs 307 → /docs 307 → /docs 307 → /docs
/blog 200 Blog 200 Blog 200 200
/blog/protocol-first-development 200 200 200 200
/ 200 homepage 200 homepage 200 200
/llms.txt 200 text/plain 200 text/plain 200 200
/llms-full.txt 200 text/plain 200 text/plain 200 200
/og/docs/image.png 200 image/png 200 image/png 200 200
/og/docs/getting-started/quick-start/image.png 200 image/png 200 image/png 200 200
/api/search?query=object 200 application/json 200 application/json 200 200

* /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.

command result
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 typecheck exit 0 — fumadocs-mdx && next typegen && tsc --noEmit, ✓ Types generated successfully
pnpm --filter @objectstack/docs exec next build exit 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-bytes OK, 6766 text files
pnpm check:docs-redirects OK, 92 entries
pnpm check:entry-guard / check:parse-guard OK
check-self-test-wired / check-self-test-workflow-commands OK — 133 scripts, the new self-test is run by CI
check-step-collectors / check-aggregator-roster / check-ci-filter-parity / check-whole-set-label-write OK
pnpm check:required-contexts / check:workflow-status-functions / check:pnpm-filter-targets OK
pnpm check:cross-package-test-inputs / check:agent-test-spelling OK

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-zhuang os-zhuang added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 25, 2026
@github-actions github-actions Bot 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 queue Aug 25, 2026
Merged via the queue into main with commit 8cdd696 Aug 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 free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m skip-changeset PR 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