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
21 changes: 21 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -1111,6 +1111,27 @@ jobs:
- name: Docs redirect destinations resolve, and no chains
run: pnpm check:docs-redirects

# The docs site's `app/[lang]/` is a catch-all that matches ANY single
# path segment, and `proxy.ts`'s matcher deliberately excludes dotted
# paths from locale rewriting — so before the guard landed, `/ads.txt`,
# `/security.txt`, `/sitemap_index.xml` and every other dotted
# single-segment URL rendered the full homepage under a 200 (measured on
# the dev server; the request log shows no `proxy.ts:` timing for those
# paths, and a probe printed `lang="ads.txt"`).
#
# It sits here rather than in a test because nothing else can see the
# regression: delete the three-line guard and every page still renders,
# every type still checks, every link still resolves. The only symptom is
# a 200 where a 404 belongs, on URLs no test requests.
#
# Runs its own --self-test first (via the pnpm script). Not ceremony: the
# live tree is green by construction after the fix, so a passing run over
# real data cannot distinguish a working gate from one that approves
# everything. The self-test is where every limb is observed failing, and
# where the proxy condition is observed flipping the requirement off.
- name: Docs locale catch-all rejects non-locale segments
run: pnpm check:docs-locale-catch-all

# #11050 route spellings taught in prose: every /api/v1 wire-path
# literal in the published corpora (content/docs/** minus releases/,
# plus skills/**) is judged against the route ledgers, and a literal
Expand Down
13 changes: 12 additions & 1 deletion apps/docs/app/[lang]/layout.tsx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import { RootProvider } from 'fumadocs-ui/provider/next';
import { i18n } from '@/lib/i18n';
import { notFound } from 'next/navigation';
import { i18n, isSupportedLanguage } from '@/lib/i18n';

// Language display names mapping
const LANGUAGE_NAMES: Record<string, string> = {
Expand All@@ -16,6 +17,16 @@ export default async function LanguageLayout({
children: ReactNode;
}) {
const { lang } = await params;

// `[lang]` is a catch-all: it matches ANY single path segment. Paths that
// contain a dot reach it unrewritten, because `proxy.ts`'s matcher excludes
// them from locale rewriting on purpose (static assets must not be
// rewritten) -- so `/ads.txt`, `/anything.html` and `/sitemap_index.xml`
// arrive here with `lang` set to that literal segment. Without this check
// every one of them renders the homepage under a 200, publishing an
// unbounded set of duplicate homepages at exactly the URLs crawlers probe.
// Declared locales are the contract; reject anything else.
if (!isSupportedLanguage(lang)) notFound();

return (
<RootProvider
Expand Down
15 changes: 15 additions & 0 deletions apps/docs/lib/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,3 +15,18 @@ export const i18n = defineI18n({
// Hide locale prefix for default language (e.g., /docs instead of /en/docs)
hideLocale: 'default-locale',
});

/**
* True when `value` is one of the locales declared above.
*
* The `[lang]` route segment is a catch-all: without this check it matches ANY
* single path segment and renders the homepage under it. Paths containing a dot
* are the reachable case, because `proxy.ts`'s matcher deliberately excludes
* them from locale rewriting (static assets must not be rewritten), so they
* arrive at `[lang]` with `lang` set to the literal segment — `"robots.txt"`,
* `"ads.txt"`, `"anything.html"`. Declared locales are the contract; this is
* where it is enforced.
*/
export function isSupportedLanguage(value: string): boolean {
return (i18n.languages as readonly string[]).includes(value);
}
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
"check:doc-anchors": "node scripts/check-doc-anchors.mjs --self-test && node scripts/check-doc-anchors.mjs",
"check:docs-audit-scope": "node scripts/docs-audit/affected-docs.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs",
"check:docs-redirects": "node scripts/check-docs-redirects.mjs --self-test && node scripts/check-docs-redirects.mjs",
"check:docs-locale-catch-all": "node scripts/check-docs-locale-catch-all.mjs --self-test && node scripts/check-docs-locale-catch-all.mjs",
"check:docs-image-tag": "node scripts/check-docs-image-tag.mjs --self-test && node scripts/check-docs-image-tag.mjs",
"check:docs-image-tag-sync": "node scripts/sync-docs-image-tags.mjs --self-test",
"check:react-page-adapter-contract": "node scripts/check-react-page-adapter-contract.mjs --self-test && node scripts/check-react-page-adapter-contract.mjs",
Expand Down
Loading
Loading