Skip to content

fix(docs): reject a non-locale first segment at the route level - #209

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-180-dotted-segment-500
Aug 26, 2026
Merged

fix(docs): reject a non-locale first segment at the route level#209
hotlong merged 2 commits into
mainfrom
claude/issue-180-dotted-segment-500

Conversation

@hotlong

@hotlong hotlong commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #180

(Angle-bracket markup omitted throughout — this repo's sanitizer strips it, including inside code fences.)

One line ships: export const dynamicParams = false on apps/docs/app/[lang]/layout.tsx, direction 2 as adjudicated. All readings below are from b61f04e, the head of this branch, taken against next start on a production build — never read off the source.

Read this first: half the card's premise is already dead

The card reports /foo.bar/docs returning 500 out of the Fumadocs page-tree lookup. It was filed against ee74379. That half is already fixed#192 (6653744) set dynamicParams = false on app/[lang]/docs/[[...slug]]/page.tsx hours later for a different reason, and it incidentally routes an unknown lang on the docs route to /_not-found before any tree lookup happens.

Measured on unmodified main at 3433803, before touching anything:

request card says main at 3433803 actually does
/foo.bar/docs 500 404, real 404 copy in server markup
/foo.bar/privacy 200 200 — live
/foo.bar/terms 200 200 — live
/foo.bar not reported 307 to /foo.bar/docs — live, and undercounted by the card
/favicon.ico not reported 307 to /favicon.ico/docs — live, and undercounted by the card

So the 500 is gone and the soft-404 family is not. That is the more SEO-relevant half by the PM's own adjudication, and it is what this PR fixes. There was no reproducible 500 left to fix.

The change

generateStaticParams on this layout already enumerated the seven locales; nothing consumed that as a constraint. The flag makes it one, so a first segment that is not a locale is answered by the prerendered /_not-found route instead of rendering a page under a bogus locale.

The mechanism was measured, not assumed

The PM flagged notFound() in the layout as the shape that looks right and fails silently, per #182. Built both ways and probed:

/foo.bar/privacy status stripped body bytes 404 copy present
main (no fix) 200 9,325 no — it is a whole privacy page
notFound() guard in the layout 404 594 no — empty error shell
dynamicParams = false (this branch) 404 1,518 yes

The middle row is the trap, and it is not hypothetical: a status-code-only check scores it as a fix. notFound() raised in a dynamically rendered request escapes both the RSC and SSR renders, and these URLs are dynamically rendered by construction — they are in no prerendered param set, which is exactly the condition #182 documented.

The probe was reverted; its restore is proven by blob identity against HEAD (2175018…), not by an exit code.

Verification

The defect, and the strongest form of the evidence. Not just "404 and the copy is there" — the served document is byte-identical to the one /no-such-page has always returned. sha256 of the response with script bodies stripped:

/no-such-page      fa59d702c28d07c8484083371fbd3a451c01a6963273f7f8fad7878434132ffc
/foo.bar/privacy   fa59d702c28d07c8484083371fbd3a451c01a6963273f7f8fad7878434132ffc
/foo.bar/terms     fa59d702c28d07c8484083371fbd3a451c01a6963273f7f8fad7878434132ffc
/foo.bar/docs      fa59d702c28d07c8484083371fbd3a451c01a6963273f7f8fad7878434132ffc
/foo.bar           fa59d702c28d07c8484083371fbd3a451c01a6963273f7f8fad7878434132ffc
/favicon.ico       fa59d702c28d07c8484083371fbd3a451c01a6963273f7f8fad7878434132ffc
/1.2.3/privacy     fa59d702c28d07c8484083371fbd3a451c01a6963273f7f8fad7878434132ffc

/privacy           e3969814557ea22e081abd282df1a5f3db3771c9347a5474f65b85cd780eb61d  (control)
/docs/architecture accd8e8122687cf5d09f7f902dded9ae0beb574b48f476ca734bb097276b4e65  (control)

The naive command still lies, and the strip is still what makes the reading real. #192's warning reproduces exactly on this tree — raw grep reports the 404 copy present on a perfectly healthy 200 page, because it is in the RSC payload either way:

                        raw grep   stripped
/foo.bar/privacy           1           1
/no-such-page              1           1
/docs/architecture         1           0     -- healthy page the naive command scores as a 404
/privacy                   1           0     -- ditto

Controls, all against the running server. Every row identical before and after.

request main this branch
/docs/architecture 200, lang=en unchanged
/zh-Hans/docs/architecture 200, lang=zh-Hans unchanged
/ja/docs/architecture 200, lang=ja unchanged
/privacy, /terms, /zh-Hans/privacy 200 unchanged
/docs, / 200, 307 to /docs unchanged
/no-such-page 404 with body unchanged — #168 preserved
/docs/no-such-page, /zh-Hans/docs/no-such-page 404 with body unchanged — #192 preserved
/cn/docs/architecture 308 to /zh-Hans/docs/architecture unchanged
/cn/privacy 308 to /zh-Hans/privacy unchanged
Accept-Language: zh-CN on /docs/architecture 307 to /zh-Hans/... unchanged
Accept-Language: ja on /docs/architecture 307 to /ja/... unchanged

The six-case host table from #204, re-run:

request result
Host: www.objectos.app /docs/architecture 308 to https://docs.objectos.ai/docs/architecture
Host: www.objectos.app / 308 to https://docs.objectos.ai/
Host: www.objectos.app /docs/architecture?q=1 308, query kept
Host: www.objectos.app /zh-Hans/docs/architecture 308, prefix kept
Host: docs.objectos.ai /docs/architecture 200
Host: docs.objectos.ai /cn/docs/architecture 308 to /zh-Hans/docs/architecture

The direction-1 hazard, confirmed untouched. Every dotted machine-facing route still 200, still no locale prefix, still no redirect at all:

route status redirect bytes first line
/llms.txt 200 none 14,557 # ObjectOS
/llms-full.txt 200 none 685,243 # ObjectOS
/sitemap.xml 200 none 299,145 the XML declaration
/robots.txt 200 none 117 User-Agent: *
/docs/architecture.mdx 200 none 12,488 # Architecture

Nothing left the enumeration. Prerender count 996/996 on both sides; 577 prerendered HTML files, matching the figure #192 and #204 recorded.

Gates, all at b61f04e, exit code captured before any pipe, each read from the command's own verdict line:

gate result
pnpm turbo run type-check --continue --force Tasks: 1 successful, 1 total (script echoed: fumadocs-mdx && next typegen && tsc --noEmit)
pnpm turbo run build --force Compiled successfully in 39.3s, Generating static pages (996/996)
pnpm turbo run test --force Tasks: 1 successful, 1 total16 case(s) over 9 rule(s) and 3 artifact(s), 3 self-test(s) passed
node .github/scripts/check-locale-surface.mjs exit 0 — 346/346 sitemap URLs, 0 unexpected, 0 missing; both llms bodies 60/60
node .github/scripts/check-node-floor.mjs exit 0 — "Every declared floor clears what the dependency tree requires"

--force on all three turbo tasks: the cache is shared across worktrees in this container and would otherwise replay a sibling's green.

A note on /favicon.ico

Not in the card, and it changes: 307 to /favicon.ico/docs before, a clean 404 now. apps/docs/public/ holds only logo.svg, and the root metadata declares icon: '/logo.svg', so there is no favicon.ico to serve either way — the request was redirecting into a 404 and now answers one directly. Strictly an improvement, called out because it is a behaviour change the card did not predict.

Scope

One file, exactly the granted surface. middleware.ts was read and not touched. No changeset (this repo has no changeset flow), and the diff is app code, so skip-changeset does not apply — that label is not a mechanism in this repo.

Filed, not fixed here

Two findings from the running-server sweep, both unassigned:

On the matcher. The PM asked to hear it if I came out convinced direction 1 is also needed. Measured answer: after this fix, the unanchored .*\..* exemption has no live consequence I could produce — #208 is the only remaining one and it has zero instances. So it is recorded as a finding with its measurement, not fixed here, and the adjudication that the matcher's blast radius is not worth this card's risk holds.


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review August 26, 2026 03:30
@hotlong
hotlong merged commit ffe36c5 into main Aug 26, 2026
2 checks passed
hotlong added a commit that referenced this pull request Aug 26, 2026
…213)

app/not-found.tsx sits above the [lang] segment and hardcoded en, so a reader browsing in their own language got English 404 copy. A synchronous script reads the locale from the first path segment and sets documentElement.lang, the title and the copy together.

Reading a middleware-set header through headers() was built and rejected by measurement: app/not-found.tsx is the root not-found boundary, so a dynamic API in it takes the whole app from 577 prerendered documents to 1. With no prerendered param set left, dynamicParams = false stops rejecting -- /foo.bar/privacy back to 200, /foo.bar/docs back to 500, docs 404s back to the 594-byte empty shell. That undoes #180, #182, #192 and #209 while passing every gate.

The route stays prerendered. The served document remains byte-identical across all eleven unmatched URLs (479133a8...), 577 prerendered files, and the seven healthy control pages are byte-identical once content-hashed asset URLs are masked.

suppressHydrationWarning is required, not decorative: without it React patches the English copy back in. Zero hydration complaints measured under next dev. With scripting disabled the page is byte-for-byte what main serves today.

lang is en between parse and script execution; the page carries noindex and a 404 body is not indexed, so no consumer of parse-time lang exists here.

Closes #191
hotlong pushed a commit that referenced this pull request Aug 26, 2026
middleware.ts's matcher exempts any path containing a dot, unanchored,
so it also skips the locale rewrite for a docs page whose SLUG (not
just an asset extension) has a dot in it. Because hideLocale is
'default-locale', that rewrite is what maps the unprefixed public URL
onto the internal /en/... route -- skip it and the page builds,
prerenders, and is advertised correctly everywhere, then 404s at its
own public URL while every other locale still serves it fine (#208).

Per the PM's adjudication on #208 (and #209's measurement that the
matcher exemption has no other live consequence), this does not touch
middleware.ts. Instead check-locale-surface.mjs now asserts, as a pure
function of content/docs/, that no page's derived slug contains a dot
-- converting the silent 404 into a build-time failure that names the
offending file.

The new dottedSlugPages() reuses the same page path readDocsPages()
already derives for the sitemap/llms oracle, which strips the locale
suffix (quickstart.zh-Hans.mdx -> docs/quickstart) before building
that path -- so a real translated file never trips it, only a dot
that survives into the derived URL segment does.

Self-test fixtures: one red case (a dotted slug) and one explicit
green case naming the #208 trap directly (a real locale-suffixed
filename). Verdict counts move from 16 case(s)/9 rule(s)/3
artifact(s) to 18 case(s)/10 rule(s)/3 artifact(s).

Fixes #208
hotlong added a commit that referenced this pull request Aug 26, 2026
middleware.ts's matcher exempts .*\..* unanchored, so it skips the locale rewrite for any path containing a dot. With hideLocale: 'default-locale', that rewrite is what maps an unprefixed public URL onto the internal /en/ route -- so a page whose slug contains a dot builds, prerenders, is advertised correctly by every artifact, and then 404s at its own public URL, while every other locale serves it fine.

Tightening the matcher was rejected by measurement (#209): llms.txt, llms-full.txt, sitemap.xml, robots.txt and every .mdx page route depend on that exemption, and this gate reads build output rather than a running server, so a regression there would be caught by nothing.

dottedSlugPages re-checks the path readDocsPages already derived for the oracle -- the locale suffix is stripped before that path is built, so a real translation cannot trip it.

Two fixtures: a dotted slug fires [dotted-slug]; quickstart.zh-Hans.mdx alongside its English sibling fires []. Self-tests move from 16 cases over 9 rules to 18 over 10.

Closes #208
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.

A URL whose first segment contains a dot bypasses the locale middleware and returns HTTP 500, not 404

2 participants