Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions apps/docs/app/[lang]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,27 @@ const LANGUAGE_NAMES: Record<string, string> = {
ko: '한국어',
};

/**
* The value the document declares as its language.
*
* The locale codes in `lib/i18n.ts` are already BCP 47 tags, so a supported
* locale is used verbatim — `zh-Hans` in particular is the correct tag and is
* deliberately not rewritten to `zh-CN`.
*
* The `[lang]` segment is dynamic, so it is not guaranteed to hold one of them.
* `middleware.ts` normalises every path it sees, but its matcher skips paths
* containing a dot, so `/foo.bar/privacy` reaches this layout with `lang` set to
* a string that is not a locale — and that route answers 200, it does not 404.
* An arbitrary URL segment must not become the document's declared language, so
* unsupported segments fall back to the default locale, which is the locale
* `middleware.ts` would have chosen for that request anyway.
*/
function documentLanguage(lang: string): string {
return (i18n.languages as readonly string[]).includes(lang)
? lang
: i18n.defaultLanguage;
}

export default async function LanguageLayout({
params,
children,
Expand All @@ -23,15 +44,19 @@ export default async function LanguageLayout({
const { lang } = await params;

return (
<DocsRootProvider
locale={lang}
locales={i18n.languages.map((l) => ({
name: LANGUAGE_NAMES[l] || l,
locale: l,
}))}
>
{children}
</DocsRootProvider>
<html lang={documentLanguage(lang)} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<DocsRootProvider
locale={lang}
locales={i18n.languages.map((l) => ({
name: LANGUAGE_NAMES[l] || l,
locale: l,
}))}
>
{children}
</DocsRootProvider>
</body>
</html>
);
}

Expand Down
34 changes: 29 additions & 5 deletions apps/docs/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,34 @@ export const metadata: Metadata = {
},
};

/**
* Pass-through root layout — the document shell lives in `app/[lang]/layout.tsx`.
*
* The document's language attribute has to be the locale, and the locale only
* exists as the `[lang]` route segment *below* this file. A layout only ever
* receives params for its own segment and the ones above it, so this layout
* cannot see `lang`, and neither of the two ways of reaching around that works
* here:
*
* - `next/root-params` collects params only down to the first layout it meets
* walking from the top of the tree (`getRootParamsImpl`). While this file
* exists, that first layout is this one, so `lang` is an ordinary route param
* and never a root param.
* - Reading a middleware-set request header via `headers()` would see it, but
* `headers()` is a dynamic API: it would opt every docs page out of static
* generation to decide one attribute.
*
* So the shell moves down to the segment that owns the locale instead. Every
* user-facing route is nested under `[lang]` (`hideLocale: 'default-locale'`
* only hides the prefix from the URL — the internal route still carries it, see
* `middleware.ts`), so each rendered document still has exactly one root
* element, now declaring the language it is actually written in.
*
* This layout stays because the app has root-level entry points that are not
* under `[lang]` — `app/page.tsx`, plus the metadata and route handlers — and
* it keeps owning what is genuinely locale-independent: the global stylesheet
* and the default metadata, both inherited by every route below.
*/
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className="flex flex-col min-h-screen">{children}</body>
</html>
);
return children;
}
80 changes: 80 additions & 0 deletions apps/docs/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* 404 shell for routes that are not under the `[lang]` segment.
*
* The document shell lives in `app/[lang]/layout.tsx` so that the language
* attribute can be derived from the locale route segment (see the comment in
* `app/layout.tsx`). That leaves the root layout a pass-through, and this is the
* one route reaching it that renders HTML rather than a redirect or a route
* handler — so without a shell here a 404 would carry no root element at all.
*
* Two paths land on this file, and neither of them renders the `[lang]` layout:
*
* - a path that matches no route (`/no-such-page`), which Next serves from the
* root `_not-found` entry;
* - `notFound()` thrown by a page below `[lang]` (`/docs/no-such-page`), which
* unwinds to the nearest not-found boundary. That boundary is this one, and it
* sits above the locale layout, so that layout's shell is replaced rather than
* wrapped. There is no nesting risk and exactly one root element either way.
*
* `en` is hardcoded deliberately: this boundary is above the locale segment and
* cannot read it, which is the same position the root layout was in before. That
* is what `main` declared here, and matching it keeps the 404 unchanged by the
* locale work rather than quietly redesigned by it. The markup below reproduces
* the Next.js built-in 404 that `main` rendered through the old root layout.
*/
export default function NotFound() {
return (
<html lang="en" suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<title>404: This page could not be found.</title>
<div
style={{
fontFamily:
'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',
height: '100vh',
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div>
<style
dangerouslySetInnerHTML={{
__html:
'body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}',
}}
/>
<h1
className="next-error-h1"
style={{
display: 'inline-block',
margin: '0 20px 0 0',
padding: '0 23px 0 0',
fontSize: 24,
fontWeight: 500,
verticalAlign: 'top',
lineHeight: '49px',
}}
>
404
</h1>
<div style={{ display: 'inline-block' }}>
<h2
style={{
fontSize: 14,
fontWeight: 400,
lineHeight: '49px',
margin: 0,
}}
>
This page could not be found.
</h2>
</div>
</div>
</div>
</body>
</html>
);
}