diff --git a/web/app/[locale]/layout.tsx b/web/app/[locale]/layout.tsx index 3a69af7b1a..6e14eb877b 100644 --- a/web/app/[locale]/layout.tsx +++ b/web/app/[locale]/layout.tsx @@ -1,10 +1,11 @@ import type { Metadata } from "next"; import localFont from "next/font/local"; +import { notFound } from "next/navigation"; import { Nav } from "@/components/nav"; import { Footer } from "@/components/footer"; import { UsageCounting } from "@/components/usage-counting"; import { BUILD_FACTS } from "@/lib/facts"; -import { localeDirection, locales, type Locale } from "@/lib/i18n/config"; +import { isValidLocale, localeDirection, locales, type Locale } from "@/lib/i18n/config"; import { getChrome, getHome } from "@/lib/i18n/dictionaries"; import { serializeJsonLd } from "@/lib/json-ld"; import { buildPageMetadata } from "@/lib/page-meta"; @@ -74,6 +75,13 @@ export default async function LocaleLayout({ params: Promise<{ locale: string }>; }) { const { locale } = await params; + // A single-segment URL that is not a routed locale — e.g. a stray dotted + // path such as /foo.txt, which middleware deliberately leaves alone so real + // static files keep resolving — would otherwise bind `[locale]` to that + // segment and render the shared home page as a 200 under a fake locale + // (`lang="foo.txt"`). An unregistered locale is not a page: answer with an + // honest 404 and let the not-found boundary render instead. + if (!isValidLocale(locale)) notFound(); const chrome = getChrome(locale); // RTL locales (e.g. ar) set the document direction from the canonical // registry so the browser handles bidirectional layout from the root. diff --git a/web/lib/i18n/locale-layout-guard.test.ts b/web/lib/i18n/locale-layout-guard.test.ts new file mode 100644 index 0000000000..4593726c30 --- /dev/null +++ b/web/lib/i18n/locale-layout-guard.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +/** + * A single-segment URL that is not a routed locale — a stray dotted path such + * as /foo.txt, which middleware deliberately leaves alone so real static files + * keep resolving — binds `[locale]` to that segment and would otherwise render + * the shared home page as a 200 under a fake locale (`lang="foo.txt"`): a + * soft 404 for crawlers and generative-engine probes. The layout must turn any + * locale outside the routed registry into a real 404 before it renders. + */ +describe("locale layout rejects unregistered locales", () => { + const layout = readFileSync(new URL("../../app/[locale]/layout.tsx", import.meta.url), "utf8"); + + it("guards on isValidLocale and calls notFound()", () => { + expect(layout).toContain("import { isValidLocale, localeDirection, locales, type Locale }"); + expect(layout).toContain("if (!isValidLocale(locale)) notFound();"); + }); + + it("checks the locale before reading dictionaries or rendering chrome", () => { + const guard = layout.indexOf("isValidLocale(locale)"); + const chrome = layout.indexOf("getChrome(locale)"); + expect(guard).toBeGreaterThan(-1); + expect(chrome).toBeGreaterThan(-1); + expect(guard).toBeLessThan(chrome); + }); +});