Skip to content
Open
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
10 changes: 9 additions & 1 deletion web/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions web/lib/i18n/locale-layout-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading