fix(docs): reject a non-locale first segment at the route level - #209
Merged
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
This was referenced Aug 26, 2026
hotlong
marked this pull request as ready for review
August 26, 2026 03:30
This was referenced Aug 26, 2026
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
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 free
to 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 #180
(Angle-bracket markup omitted throughout — this repo's sanitizer strips it, including inside code fences.)
One line ships:
export const dynamicParams = falseonapps/docs/app/[lang]/layout.tsx, direction 2 as adjudicated. All readings below are fromb61f04e, the head of this branch, taken againstnext starton a production build — never read off the source.Read this first: half the card's premise is already dead
The card reports
/foo.bar/docsreturning 500 out of the Fumadocs page-tree lookup. It was filed againstee74379. That half is already fixed — #192 (6653744) setdynamicParams = falseonapp/[lang]/docs/[[...slug]]/page.tsxhours later for a different reason, and it incidentally routes an unknownlangon the docs route to/_not-foundbefore any tree lookup happens.Measured on unmodified
mainat3433803, before touching anything:mainat3433803actually does/foo.bar/docs/foo.bar/privacy/foo.bar/terms/foo.bar/foo.bar/docs— live, and undercounted by the card/favicon.ico/favicon.ico/docs— live, and undercounted by the cardSo 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
generateStaticParamson 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-foundroute 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/privacymain(no fix)notFound()guard in the layoutdynamicParams = false(this branch)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-pagehas always returned. sha256 of the response with script bodies stripped:The naive command still lies, and the strip is still what makes the reading real. #192's warning reproduces exactly on this tree — raw
grepreports the 404 copy present on a perfectly healthy 200 page, because it is in the RSC payload either way:Controls, all against the running server. Every row identical before and after.
main/docs/architecturelang=en/zh-Hans/docs/architecturelang=zh-Hans/ja/docs/architecturelang=ja/privacy,/terms,/zh-Hans/privacy/docs,//docs/no-such-page/docs/no-such-page,/zh-Hans/docs/no-such-page/cn/docs/architecture/zh-Hans/docs/architecture/cn/privacy/zh-Hans/privacyAccept-Language: zh-CNon/docs/architecture/zh-Hans/...Accept-Language: jaon/docs/architecture/ja/...The six-case host table from #204, re-run:
Host: www.objectos.app/docs/architecturehttps://docs.objectos.ai/docs/architectureHost: www.objectos.app/https://docs.objectos.ai/Host: www.objectos.app/docs/architecture?q=1Host: www.objectos.app/zh-Hans/docs/architectureHost: docs.objectos.ai/docs/architectureHost: docs.objectos.ai/cn/docs/architecture/zh-Hans/docs/architectureThe direction-1 hazard, confirmed untouched. Every dotted machine-facing route still 200, still no locale prefix, still no redirect at all:
/llms.txt# ObjectOS/llms-full.txt# ObjectOS/sitemap.xml/robots.txtUser-Agent: */docs/architecture.mdx# ArchitectureNothing left the enumeration. Prerender count
996/996on 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:pnpm turbo run type-check --continue --forceTasks: 1 successful, 1 total(script echoed:fumadocs-mdx && next typegen && tsc --noEmit)pnpm turbo run build --forceCompiled successfully in 39.3s,Generating static pages (996/996)pnpm turbo run test --forceTasks: 1 successful, 1 total—16 case(s) over 9 rule(s) and 3 artifact(s),3 self-test(s) passednode .github/scripts/check-locale-surface.mjsllmsbodies 60/60node .github/scripts/check-node-floor.mjs--forceon 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.icoNot in the card, and it changes: 307 to
/favicon.ico/docsbefore, a clean 404 now.apps/docs/public/holds onlylogo.svg, and the root metadata declaresicon: '/logo.svg', so there is nofavicon.icoto 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.tswas read and not touched. No changeset (this repo has no changeset flow), and the diff is app code, soskip-changesetdoes not apply — that label is not a mechanism in this repo.Filed, not fixed here
Two findings from the running-server sweep, both unassigned:
.mdxcopy/view links 404 on all six non-default locales, becausenext.config.mjsrewrites only the unprefixed/docs/:path*.mdx. Live, roughly 400 dead links, and unrelated to this card's surface. The .mdx copy/view links 404 on all six non-default locales — the rewrite only covers the unprefixed path #207 is not addressed here./en/...route. Latent — zero such pages exist today — but measured with a temporary probe page rather than reasoned. [finding] A docs page whose slug contains a dot would 404 at its public URL and answer 200 at the internal /en/ route #208 remains open.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