diff --git a/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx index e4a2779..918a587 100644 --- a/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx @@ -268,10 +268,10 @@ export default async function Page(props: { const loaded = await page.data.load(); const MDX = loaded.body; - // Resolved once and handed to both controls (the copy button takes it through - // `copySource` below), so they cannot drift apart and + // Resolved once and handed to both controls (through `copySource` and + // `assistantReadUrl` below), so they cannot drift apart and // so a third control added below inherits the locale-independent URL instead - // of re-deriving one from `page.url`. That re-derivation is the whole defect: + // of re-deriving a `.mdx` URL from `page.url`. That re-derivation is the whole defect: // it is invisible in the rendered markup — `markdownUrl` reaches the browser // only as a client-component prop in the RSC payload — so a broken value // produces no build error, no console warning and no failing gate, and shows @@ -313,6 +313,13 @@ export default async function Page(props: { ? { url: pageMarkdownUrl } : { text: await getLLMText(page) }; + // "Open in ChatGPT / Claude" follows the same rule (#305): the assistant is + // sent to the page on screen. The English Markdown when that is the English + // page; the translated page itself when it is a translation — `page.url` is + // this locale's URL, the one being read — because its Markdown has no URL and + // is too long to inline into the prompt (`ViewOptions` has the measurement). + const assistantReadUrl = contentLang === i18n.defaultLanguage ? pageMarkdownUrl : page.url; + // Structured data. Emitted from the page rather than from `generateMetadata`, // which can only produce meta/link elements — the Metadata API has no channel // for a JSON-LD script. Google reads `application/ld+json` from either the @@ -357,7 +364,7 @@ export default async function Page(props: {
{text}; +} + +/** + * Whether a page entry in `lang`'s tree names a page `lang` has no source file + * for — the English page, served as a fallback. + * + * The detection the docs page uses for its own body (#298), and deliberately + * the same function: `translatedLocales` compares the page's AUTHORED file + * (`operate/backup.ja.mdx` or the inherited `operate/backup.mdx`) with the + * English one. The page `getNodePage` resolves is the page the entry was built + * from — its `$ref` is the locale-independent storage key — so the sidebar, + * the breadcrumb and the footer agree with the page body about which pages are + * English, entry by entry, with no second rule to drift from the first. + */ +function isFallbackPage(node: PageTree.Item, lang: string): boolean { + const page = source.getNodePage(node, lang); + return page !== undefined && !translatedLocales(page.slugs).includes(lang); +} + +/** + * Whether a folder entry's name is English in `lang`'s tree. + * + * Fumadocs names a folder from its `meta` title, else from its index page, + * else from the directory name. The same file-identity rule applies to the + * first: a `meta..json` is the locale's own, an inherited `meta.json` is + * English. Every folder in `content/docs` has a `meta..json` with a + * title today, so this marks nothing on the current tree; it is here so a + * folder added without one is marked rather than silently read as the locale. + */ +function isEnglishFolderName(node: PageTree.Folder, lang: string): boolean { + const meta = source.getNodeMeta(node, lang); + if (meta && typeof (meta.data as { title?: unknown }).title === 'string') { + return meta.path === source.getNodeMeta(node, i18n.defaultLanguage)?.path; + } + if (node.index) return isFallbackPage(node.index, lang); + return true; +} + +function markItem(node: PageTree.Item, lang: string): PageTree.Item { + if (!isFallbackPage(node, lang)) return node; + return { + ...node, + name: inDefaultLanguage(node.name), + ...(node.description ? { description: inDefaultLanguage(node.description) } : {}), + }; +} + +function markNode(node: PageTree.Node, lang: string): PageTree.Node { + if (node.type === 'page') return markItem(node, lang); + if (node.type !== 'folder') return node; + return { + ...node, + ...(isEnglishFolderName(node, lang) ? { name: inDefaultLanguage(node.name) } : {}), + ...(node.index ? { index: markItem(node.index, lang) } : {}), + children: node.children.map((child) => markNode(child, lang)), + }; +} + +const markedTrees = new Map(); + +/** + * `lang`'s page tree with every English entry marked `lang="en"` (#305). + * + * On a locale route the sidebar, the breadcrumb and the previous/next footer + * all render names from this tree, inside `` declaring the route + * locale. A page the locale has no translation of is named by its English + * title (and described by its English description, which the footer shows), so + * without a mark a screen reader reads "Backup and Disaster Recovery" with + * Chinese pronunciation rules — the language-of-parts defect (WCAG 3.1.2) the + * page body already avoids. axe has no rule for it, so nothing else reports it. + * + * One transform here reaches all three, because all three read the tree this + * layout hands to `DocsLayout`; the `llms` routes read `source.getPageTree` + * for English directly and are not affected. Only the name and description + * are wrapped: `url`, `$id` and `$ref` stay as they were, so active-item + * matching and every lookup by node are untouched. An English route returns + * the tree it was given. + * + * Memoized per locale: the tree and the content are fixed for the life of the + * build, and the layout renders once per page. + */ +function treeWithLanguages(lang: string): PageTree.Root { + const tree = source.pageTree[lang]; + if (lang === i18n.defaultLanguage || !tree) return tree; + let marked = markedTrees.get(lang); + if (!marked) { + marked = { ...tree, children: tree.children.map((node) => markNode(node, lang)) }; + markedTrees.set(lang, marked); + } + return marked; +} + /** * Links to `/privacy` and `/terms`, at the foot of the docs sidebar (#299). * @@ -46,7 +149,7 @@ export default async function Layout({ return ( }} i18n diff --git a/apps/docs/app/not-found.tsx b/apps/docs/app/not-found.tsx index fbb783d..3f08231 100644 --- a/apps/docs/app/not-found.tsx +++ b/apps/docs/app/not-found.tsx @@ -1,4 +1,5 @@ import { i18n } from '@/lib/i18n'; +import { uiText } from '@/lib/ui-text'; import type { Metadata } from 'next'; /** @@ -72,21 +73,22 @@ import type { Metadata } from 'next'; */ /** - * 404 copy per locale. English is the source; a locale missing from this table - * keeps English, which is the same fallback Fumadocs applies to an untranslated - * page. `content/docs/` translations are derived artifacts refreshed by a - * separate pass (AGENTS.md, "Translation workflow"); this table is UI copy in - * app code, the shape `app/[lang]/privacy/page.tsx` already uses. + * 404 copy per locale, read from `lib/ui-text.ts` (`notFound`). English is the + * source, written there; the other locales are that table's UI copy, not + * `content/docs/` translations. + * + * It used to be a table of its own here, and that table had no `zh-Hant` entry + * (#305): a Traditional Chinese reader got the English message, because the + * locale is generated from Simplified by `scripts/gen-zh-hant.mjs` and nothing + * generated a file-local constant. In `lib/ui-text/` it is one more key of the + * table that generator already converts — `ui-text/zh-Hant.json` is produced + * from `ui-text/zh-Hans.json` and `gen-zh-hant --check` holds it to the bytes — + * so the Traditional string is derived, never hand-typed, and `UiText`'s + * exact-key check makes every locale in `lib/i18n.ts` carry one. */ -const COPY: Record = { - en: 'This page could not be found.', - 'zh-Hans': '找不到此页面。', - ja: 'このページは見つかりませんでした。', - de: 'Diese Seite konnte nicht gefunden werden.', - es: 'No se ha podido encontrar esta página.', - fr: 'Cette page est introuvable.', - ko: '이 페이지를 찾을 수 없습니다.', -}; +const COPY: Record = Object.fromEntries( + i18n.languages.map((lang) => [lang, uiText(lang).notFound]), +); /** * The document title, used in two places that have to agree. @@ -123,8 +125,13 @@ export const metadata: Metadata = { /** * Inlined verbatim into a `script` element, so it must stay free of anything - * that could close that element early. Every value it embeds is a compile-time - * constant in this file and in `lib/i18n.ts`; none carries markup. + * that could close that element early. Every value it embeds is a build-time + * constant from `lib/ui-text/` and `lib/i18n.ts`. The copy now comes from a data + * file rather than from this one, so every `<` in it is written as the JSON + * escape (the same rule `jsonLdHtml` applies on the docs page): a string that + * ever carried a closing script tag would then still parse back to itself + * instead of ending this element. No current string has one, so the bytes are + * unchanged. * * `i18n.languages` is read rather than `Object.keys(COPY)` on purpose: the * locale list is the authority for what may appear as a first segment, and a @@ -132,7 +139,7 @@ export const metadata: Metadata = { * rather than be treated as an unknown segment. */ const APPLY_LOCALE = `(function(){try{ -var copy=${JSON.stringify(COPY)}; +var copy=${JSON.stringify(COPY).replace(/ - This page could not be found. + {COPY.en}
diff --git a/apps/docs/components/ai/page-actions.tsx b/apps/docs/components/ai/page-actions.tsx index 1067cbe..bc394bb 100644 --- a/apps/docs/components/ai/page-actions.tsx +++ b/apps/docs/components/ai/page-actions.tsx @@ -75,14 +75,26 @@ export function LLMCopyButton({ } export function ViewOptions({ - markdownUrl, + readUrl, githubUrl, labels, }: { /** - * A URL to the raw Markdown/MDX content of page + * The site-relative URL "Open in ChatGPT / Claude" asks the assistant to + * read: the page on screen, by the same rule `MarkdownSource` follows for + * Copy Markdown (#305). + * + * - An English page, or a locale URL serving the English page as a fallback: + * the page's `.mdx` Markdown, which IS the page on screen. + * - A real translation: the translated page's own URL. Its Markdown has no + * URL to point at (the `.mdx` surface is English-only, see `MarkdownSource`), + * and it does not fit in the query string instead — measured over the 230 + * locale pages, the prompt with the page inlined runs from 2089 to 25630 + * characters, 165 of them over 8 KiB, and the two assistants publish no + * limit to hold that to. The page URL is short, stable and serves exactly + * the text the reader sees. */ - markdownUrl: string; + readUrl: string; /** * Source file URL on GitHub @@ -98,9 +110,9 @@ export function ViewOptions({ }; }) { const items = useMemo(() => { - const fullMarkdownUrl = - typeof window !== 'undefined' ? new URL(markdownUrl, window.location.origin) : 'loading'; - const q = `Read ${fullMarkdownUrl}, I want to ask questions about it.`; + const fullReadUrl = + typeof window !== 'undefined' ? new URL(readUrl, window.location.origin) : 'loading'; + const q = `Read ${fullReadUrl}, I want to ask questions about it.`; return [ { @@ -149,7 +161,7 @@ export function ViewOptions({ ), }, ]; - }, [githubUrl, markdownUrl, labels]); + }, [githubUrl, readUrl, labels]); return ( diff --git a/apps/docs/lib/ui-text.ts b/apps/docs/lib/ui-text.ts index fa99157..8e47a07 100644 --- a/apps/docs/lib/ui-text.ts +++ b/apps/docs/lib/ui-text.ts @@ -11,16 +11,28 @@ import zhHant from './ui-text/zh-Hant.json'; /** * The docs site's interface copy — the chrome around a page, never the page. * - * Two kinds of string live here. The first ten keys are Fumadocs' own + * Two kinds of string live here. The first nineteen keys are Fumadocs' own * `Translations` (the search box, "On this page", "Choose a language", the - * previous/next footer, …); `fumadocsTranslations()` hands exactly those to - * `RootProvider`, which is the only channel Fumadocs reads them through. The - * rest are this app's own controls and notices, read by the docs page. + * previous/next footer, the accessible names of its icon buttons, …); + * `fumadocsTranslations()` hands exactly those to `RootProvider`, which is the + * only channel Fumadocs reads them through. The rest are this app's own + * controls and notices, read by the docs page and the 404 page. * * English is the source, written here and nowhere else (AGENTS.md rule 1). The * Fumadocs values are its built-in defaults verbatim, so the English site * renders byte-identically to before this table existed. * + * Nine of the Fumadocs keys — `searchOpen` through `navMain` — do not exist in + * fumadocs-ui 16.8.12 as published: there the names are English literals in + * the components, outside the Translations API, so every locale page announced + * "Open Search" and "Copy Anchor Link" inside ``. + * `patches/fumadocs-ui@16.8.12.patch` adds them: eight are a backport of + * upstream 16.9.0's own fix (the same key names, the same English defaults), + * and `navMain` replaces the `aria-label="Main"` Radix's navigation menu puts + * on the legal pages' header, which upstream has not localized. They arrive + * here through the one channel the other ten already use. The patch header + * says why a patch rather than slot overrides, and when it can go. + * * The locale tables are UI copy in app code — the shape `app/not-found.tsx` * and `app/[lang]/privacy/page.tsx` already use — not `content/docs/` * translations, which the separate pass in `docs/TRANSLATION.md` produces. They @@ -42,6 +54,17 @@ const en = { previousPage: 'Previous Page', chooseTheme: 'Theme', editOnGithub: 'Edit on GitHub', + /** The icon buttons' accessible names: no visible text, only `aria-label`. */ + searchOpen: 'Open Search', + themeToggle: 'Toggle Theme', + sidebarOpen: 'Open Sidebar', + sidebarCollapse: 'Collapse Sidebar', + headingCopyAnchor: 'Copy Anchor Link', + codeBlockCopy: 'Copy Text', + codeBlockCopied: 'Copied Text', + /** The legal pages' header (`HomeLayout`): its menu button on a narrow screen, and its own name. */ + menuToggle: 'Toggle Menu', + navMain: 'Main', copyMarkdown: 'Copy Markdown', openMenu: 'Open', @@ -59,6 +82,13 @@ const en = { legalNav: 'Legal', privacy: 'Privacy', terms: 'Terms', + /** + * The 404 page's message. `app/not-found.tsx` sits above the locale segment + * and applies it in the browser from the URL's first segment (that file says + * why); it lives here so that its Traditional Chinese string is generated from + * the Simplified one like every other string in this table. + */ + notFound: 'This page could not be found.', } satisfies Translations & Record; export type UiText = Record; @@ -123,7 +153,7 @@ export function uiText(lang: string): UiText { * * Picked rather than spread: the provider merges whatever it is given into the * context every Fumadocs component reads, and this app's own keys have no - * business there. It also keeps the serialized client prop to ten strings. + * business there. It also keeps the serialized client prop to nineteen strings. */ export function fumadocsTranslations(text: UiText): FumadocsTranslations { return { @@ -137,5 +167,14 @@ export function fumadocsTranslations(text: UiText): FumadocsTranslations { previousPage: text.previousPage, chooseTheme: text.chooseTheme, editOnGithub: text.editOnGithub, + searchOpen: text.searchOpen, + themeToggle: text.themeToggle, + sidebarOpen: text.sidebarOpen, + sidebarCollapse: text.sidebarCollapse, + headingCopyAnchor: text.headingCopyAnchor, + codeBlockCopy: text.codeBlockCopy, + codeBlockCopied: text.codeBlockCopied, + menuToggle: text.menuToggle, + navMain: text.navMain, }; } diff --git a/apps/docs/lib/ui-text/de.json b/apps/docs/lib/ui-text/de.json index 14de3c1..11090a9 100644 --- a/apps/docs/lib/ui-text/de.json +++ b/apps/docs/lib/ui-text/de.json @@ -9,6 +9,15 @@ "previousPage": "Vorherige Seite", "chooseTheme": "Design", "editOnGithub": "Auf GitHub bearbeiten", + "searchOpen": "Suche öffnen", + "themeToggle": "Design wechseln", + "sidebarOpen": "Seitenleiste öffnen", + "sidebarCollapse": "Seitenleiste einklappen", + "headingCopyAnchor": "Ankerlink kopieren", + "codeBlockCopy": "Text kopieren", + "codeBlockCopied": "Text kopiert", + "menuToggle": "Menü ein- oder ausblenden", + "navMain": "Hauptnavigation", "copyMarkdown": "Markdown kopieren", "openMenu": "Öffnen", "openInGitHub": "In GitHub öffnen", @@ -17,5 +26,6 @@ "notTranslated": "Diese Seite ist noch nicht übersetzt; angezeigt wird die englische Fassung.", "legalNav": "Rechtliches", "privacy": "Datenschutz", - "terms": "Nutzungsbedingungen" + "terms": "Nutzungsbedingungen", + "notFound": "Diese Seite konnte nicht gefunden werden." } diff --git a/apps/docs/lib/ui-text/es.json b/apps/docs/lib/ui-text/es.json index 343c69b..ff8c9bc 100644 --- a/apps/docs/lib/ui-text/es.json +++ b/apps/docs/lib/ui-text/es.json @@ -9,6 +9,15 @@ "previousPage": "Página anterior", "chooseTheme": "Tema", "editOnGithub": "Editar en GitHub", + "searchOpen": "Abrir la búsqueda", + "themeToggle": "Cambiar de tema", + "sidebarOpen": "Abrir la barra lateral", + "sidebarCollapse": "Contraer la barra lateral", + "headingCopyAnchor": "Copiar enlace de anclaje", + "codeBlockCopy": "Copiar texto", + "codeBlockCopied": "Texto copiado", + "menuToggle": "Mostrar u ocultar el menú", + "navMain": "Navegación principal", "copyMarkdown": "Copiar Markdown", "openMenu": "Abrir", "openInGitHub": "Abrir en GitHub", @@ -17,5 +26,6 @@ "notTranslated": "Esta página aún no está traducida; se muestra en inglés.", "legalNav": "Información legal", "privacy": "Privacidad", - "terms": "Términos" + "terms": "Términos", + "notFound": "No se ha podido encontrar esta página." } diff --git a/apps/docs/lib/ui-text/fr.json b/apps/docs/lib/ui-text/fr.json index 4adc207..c8dfc20 100644 --- a/apps/docs/lib/ui-text/fr.json +++ b/apps/docs/lib/ui-text/fr.json @@ -9,6 +9,15 @@ "previousPage": "Page précédente", "chooseTheme": "Thème", "editOnGithub": "Modifier sur GitHub", + "searchOpen": "Ouvrir la recherche", + "themeToggle": "Changer de thème", + "sidebarOpen": "Ouvrir la barre latérale", + "sidebarCollapse": "Réduire la barre latérale", + "headingCopyAnchor": "Copier le lien d'ancre", + "codeBlockCopy": "Copier le texte", + "codeBlockCopied": "Texte copié", + "menuToggle": "Afficher ou masquer le menu", + "navMain": "Navigation principale", "copyMarkdown": "Copier le Markdown", "openMenu": "Ouvrir", "openInGitHub": "Ouvrir dans GitHub", @@ -17,5 +26,6 @@ "notTranslated": "Cette page n'est pas encore traduite. Elle s'affiche en anglais.", "legalNav": "Mentions légales", "privacy": "Confidentialité", - "terms": "Conditions" + "terms": "Conditions", + "notFound": "Cette page est introuvable." } diff --git a/apps/docs/lib/ui-text/ja.json b/apps/docs/lib/ui-text/ja.json index f60684c..c79a18e 100644 --- a/apps/docs/lib/ui-text/ja.json +++ b/apps/docs/lib/ui-text/ja.json @@ -9,6 +9,15 @@ "previousPage": "前のページ", "chooseTheme": "テーマ", "editOnGithub": "GitHub で編集", + "searchOpen": "検索を開く", + "themeToggle": "テーマを切り替える", + "sidebarOpen": "サイドバーを開く", + "sidebarCollapse": "サイドバーを折りたたむ", + "headingCopyAnchor": "アンカーリンクをコピー", + "codeBlockCopy": "テキストをコピー", + "codeBlockCopied": "テキストをコピーしました", + "menuToggle": "メニューを切り替える", + "navMain": "メインナビゲーション", "copyMarkdown": "Markdown をコピー", "openMenu": "開く", "openInGitHub": "GitHub で開く", @@ -17,5 +26,6 @@ "notTranslated": "このページはまだ翻訳されていません。英語版を表示しています。", "legalNav": "法的情報", "privacy": "プライバシー", - "terms": "利用規約" + "terms": "利用規約", + "notFound": "このページは見つかりませんでした。" } diff --git a/apps/docs/lib/ui-text/ko.json b/apps/docs/lib/ui-text/ko.json index dd0d56d..7d31411 100644 --- a/apps/docs/lib/ui-text/ko.json +++ b/apps/docs/lib/ui-text/ko.json @@ -9,6 +9,15 @@ "previousPage": "이전 페이지", "chooseTheme": "테마", "editOnGithub": "GitHub에서 편집", + "searchOpen": "검색 열기", + "themeToggle": "테마 전환", + "sidebarOpen": "사이드바 열기", + "sidebarCollapse": "사이드바 접기", + "headingCopyAnchor": "앵커 링크 복사", + "codeBlockCopy": "텍스트 복사", + "codeBlockCopied": "텍스트 복사됨", + "menuToggle": "메뉴 열기/닫기", + "navMain": "기본 탐색", "copyMarkdown": "Markdown 복사", "openMenu": "열기", "openInGitHub": "GitHub에서 열기", @@ -17,5 +26,6 @@ "notTranslated": "이 페이지는 아직 번역되지 않았습니다. 영어 원문을 표시합니다.", "legalNav": "법적 고지", "privacy": "개인정보 보호", - "terms": "이용약관" + "terms": "이용약관", + "notFound": "이 페이지를 찾을 수 없습니다." } diff --git a/apps/docs/lib/ui-text/zh-Hans.json b/apps/docs/lib/ui-text/zh-Hans.json index cee087c..78ca3f8 100644 --- a/apps/docs/lib/ui-text/zh-Hans.json +++ b/apps/docs/lib/ui-text/zh-Hans.json @@ -9,6 +9,15 @@ "previousPage": "上一页", "chooseTheme": "主题", "editOnGithub": "在 GitHub 上编辑", + "searchOpen": "打开搜索", + "themeToggle": "切换主题", + "sidebarOpen": "打开侧边栏", + "sidebarCollapse": "收起侧边栏", + "headingCopyAnchor": "复制锚点链接", + "codeBlockCopy": "复制文本", + "codeBlockCopied": "已复制文本", + "menuToggle": "切换菜单", + "navMain": "主导航", "copyMarkdown": "复制 Markdown", "openMenu": "打开", "openInGitHub": "在 GitHub 中打开", @@ -17,5 +26,6 @@ "notTranslated": "本页尚未翻译,以下显示英文原文。", "legalNav": "法律信息", "privacy": "隐私", - "terms": "条款" + "terms": "条款", + "notFound": "找不到此页面。" } diff --git a/apps/docs/lib/ui-text/zh-Hant.json b/apps/docs/lib/ui-text/zh-Hant.json index 1573cad..856a8ed 100644 --- a/apps/docs/lib/ui-text/zh-Hant.json +++ b/apps/docs/lib/ui-text/zh-Hant.json @@ -9,6 +9,15 @@ "previousPage": "上一頁", "chooseTheme": "主題", "editOnGithub": "在 GitHub 上編輯", + "searchOpen": "開啟搜尋", + "themeToggle": "切換主題", + "sidebarOpen": "開啟側邊欄", + "sidebarCollapse": "收起側邊欄", + "headingCopyAnchor": "複製錨點連結", + "codeBlockCopy": "複製文本", + "codeBlockCopied": "已複製文本", + "menuToggle": "切換選單", + "navMain": "主導航", "copyMarkdown": "複製 Markdown", "openMenu": "開啟", "openInGitHub": "在 GitHub 中開啟", @@ -17,5 +26,6 @@ "notTranslated": "本頁尚未翻譯,以下顯示英文原文。", "legalNav": "法律資訊", "privacy": "隱私", - "terms": "條款" + "terms": "條款", + "notFound": "找不到此頁面。" } diff --git a/package.json b/package.json index 5279584..a61b485 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "sharp" ], "patchedDependencies": { - "fumadocs-core@16.8.12": "patches/fumadocs-core@16.8.12.patch" + "fumadocs-core@16.8.12": "patches/fumadocs-core@16.8.12.patch", + "fumadocs-ui@16.8.12": "patches/fumadocs-ui@16.8.12.patch" } } } diff --git a/patches/fumadocs-ui@16.8.12.patch b/patches/fumadocs-ui@16.8.12.patch new file mode 100644 index 0000000..36b005c --- /dev/null +++ b/patches/fumadocs-ui@16.8.12.patch @@ -0,0 +1,312 @@ +# Read fumadocs-ui's hard-coded English accessible names from its i18n context. +# +# WHY THIS PIN EXISTS +# ------------------- +# fumadocs-ui 16.8.12 writes nine accessible names as English literals inside +# its components, outside its own Translations API, so no `translations` passed +# to `RootProvider` can reach them. Every locale page served them in English +# inside ``, ``, ... (#305, a WCAG 3.1.2 +# language-of-parts defect). Measured on the built pages of `main` at cec227a, +# per locale (82 pages each; the seven locales agree to within 12): +# +# aria-label="Copy Anchor Link" 692-699 components/heading.js +# aria-label="Copy Text" 201-206 components/codeblock.js +# aria-label="Open Search" 160 layouts/shared/slots/search-trigger.js +# aria-label="Collapse Sidebar" 158 components/sidebar/base.js +# aria-label="Toggle Theme" 81 layouts/shared/slots/theme-switch.js +# aria-label="Open Sidebar" 79 components/sidebar/base.js +# aria-label="Toggle Menu" 2 layouts/home/slots/header.js +# aria-label="Main" 2 layouts/home/slots/header.js (Radix) +# +# plus two that only exist after interaction and so are not in prerendered +# HTML: "Copied Text" (a code block's copy button, after a click) and the +# drawer's own "Open Sidebar" (rendered client-side below 768px). +# +# WHAT THIS CHANGES +# ----------------- +# Nine keys join `defaultTranslations` (contexts/i18n.js) and the +# `Translations` interface (contexts/i18n.d.ts), each defaulting to the exact +# English literal it replaces; each component reads its name from +# `useI18n().text`. `I18nProvider` already spreads the `translations` it is +# given over the defaults, so `apps/docs/lib/ui-text.ts` supplies the nine +# strings through the same `RootProvider` prop as the other ten. With no +# provider, or a provider that omits a key, the name is the old English +# literal: English renders byte-identically. +# +# Eight of the keys are a BACKPORT of upstream's own fix. fumadocs-ui 16.9.0 +# (the next minor after this pin) ships exactly these names, with these +# defaults, in the same flat Translations object: `searchOpen`, `themeToggle`, +# `sidebarOpen`, `sidebarCollapse`, `headingCopyAnchor`, `codeBlockCopy`, +# `codeBlockCopied`, `menuToggle`. Measured 2026-10-06 by unpacking the +# published 16.9.0 tarball: its `dist/i18n.js` declares them and its +# components read `t.searchOpen` and so on where 16.8.12 has literals. +# +# The ninth, `navMain`, is NOT upstream. Radix's NavigationMenu root defaults +# `aria-label="Main"`, and fumadocs' HomeLayout header (the privacy and terms +# pages here) renders that root without a label of its own. Upstream still +# passes none in 16.16.2 (latest on 2026-10-06), so this hunk has nothing to +# track and is the one to re-check on any bump. +# +# WHY A PATCH AND NOT SLOT OVERRIDES +# ---------------------------------- +# Each name was checked against the DocsLayout / HomeLayout slot API first. +# None is reachable in every place it renders without re-implementing the +# component that renders it: +# +# - "Open Search": `slots.searchTrigger.sm` covers the mobile header, but the +# collapsed-sidebar panel imports `SearchTrigger` directly. +# - "Collapse Sidebar" (both instances) and the drawer's "Open Sidebar" are +# rendered inside the docs `Sidebar` slot itself; the only override is +# replacing `slots.sidebar.root`, a ~200-line fork of fumadocs' sidebar. +# - "Toggle Theme": `slots.themeSwitch` replaces the whole switch; the label is +# not a prop of the stock one. +# - "Copy Anchor Link" / "Copy Text": the MDX `h1`-`h6` and `pre` components +# would have to be re-implemented; neither copy button takes a label. +# - "Toggle Menu" / "Main": only `slots.header` (all of the HomeLayout header). +# +# The one name an override could have reached cleanly, the header's sidebar +# button (`slots.sidebar.trigger` accepts `aria-label`), is the same component +# as the drawer's, so the patch has to change it anyway; an override on top +# would be dead code. One mechanism, in the components that own the names, +# reading the context those components already read for `text.search`. +# +# Not covered, because nothing on this site renders them: `bannerClose` +# (Banner), `accordionCopyAnchor` (Accordion), the light/dark/system labels of +# ThemeSwitch's `light-dark-system` mode, `layouts/shared/page-actions.js` (the +# app has its own, localized), the Orama/Algolia dialog footers, and +# `layouts/home/not-found.js` (the app has `app/not-found.tsx`). +# +# WHEN THIS FILE CAN BE DELETED +# ----------------------------- +# On the upgrade to fumadocs-ui 16.9.0 or later. Delete this file and its +# `pnpm.patchedDependencies` entry in the root `package.json`, re-run +# `pnpm install`, then move `navMain` to whatever that version offers (nothing, +# as of 16.16.2: it would need re-patching or a `slots.header` override). +# 16.10.0 and later replace the flat Translations object with string-keyed +# `t("Open Search")` lookups, so on those `lib/ui-text.ts` changes shape too. +# +# What makes a silent loss loud: `fumadocsTranslations()` in +# `apps/docs/lib/ui-text.ts` returns an object literal typed as fumadocs' +# `Translations`; on a version whose `Translations` lacks these keys that is an +# excess-property error and `tsc` fails `type-check`. And as with the +# fumadocs-core pin beside this one, a bump of the package without +# regenerating this file fails `pnpm install` with ERR_PNPM_UNUSED_PATCH. +# +diff --git a/dist/components/codeblock.js b/dist/components/codeblock.js +index 1a85256dc660721dfd3afe07a50b13b13f209ea0..d35cdbc183c5ca7e8b07cf98b00090cb1a782149 100644 +--- a/dist/components/codeblock.js ++++ b/dist/components/codeblock.js +@@ -1,4 +1,5 @@ + "use client"; ++import { useI18n } from "../contexts/i18n.js"; + import { cn } from "../utils/cn.js"; + import { buttonVariants } from "./ui/button.js"; + import { useCopyButton } from "../utils/use-copy-button.js"; +@@ -63,6 +64,7 @@ function CodeBlock({ ref, title, allowCopy = true, keepBackground = false, icon, + }); + } + function CopyButton({ className, containerRef, ...props }) { ++ const { text } = useI18n(); + const [checked, onClick] = useCopyButton(() => { + const pre = containerRef.current?.getElementsByTagName("pre").item(0); + if (!pre) return; +@@ -79,7 +81,7 @@ function CopyButton({ className, containerRef, ...props }) { + className: "hover:text-fd-accent-foreground data-checked:text-fd-accent-foreground", + size: "icon-xs" + }), className), +- "aria-label": checked ? "Copied Text" : "Copy Text", ++ "aria-label": checked ? text.codeBlockCopied : text.codeBlockCopy, + onClick, + ...props, + children: checked ? /* @__PURE__ */ jsx(Check, {}) : /* @__PURE__ */ jsx(Clipboard, {}) +diff --git a/dist/components/heading.js b/dist/components/heading.js +index 3ae57ec4ae30dcfc4544f9df4729d5efd80314ac..ee4387034d944349d2f05ee5575a36c0c07723d8 100644 +--- a/dist/components/heading.js ++++ b/dist/components/heading.js +@@ -1,4 +1,5 @@ + "use client"; ++import { useI18n } from "../contexts/i18n.js"; + import { cn } from "../utils/cn.js"; + import { buttonVariants } from "./ui/button.js"; + import { useCopyButton } from "../utils/use-copy-button.js"; +@@ -7,6 +8,7 @@ import { CopyCheckIcon, LinkIcon } from "lucide-react"; + //#region src/components/heading.tsx + function Heading({ as, ...props }) { + const As = as ?? "h1"; ++ const { text } = useI18n(); + const [isChecked, onCopy] = useCopyButton(() => { + if (!props.id) return; + const url = new URL(window.location.href); +@@ -22,7 +24,7 @@ function Heading({ as, ...props }) { + href: `#${props.id}`, + children: props.children + }), /* @__PURE__ */ jsx("button", { +- "aria-label": "Copy Anchor Link", ++ "aria-label": text.headingCopyAnchor, + className: cn(buttonVariants({ + variant: "ghost", + size: "icon-xs" +diff --git a/dist/components/sidebar/base.js b/dist/components/sidebar/base.js +index e3a8ad65b36c63dfd070fa0da78b480fb7475ca8..3dd2d5a7b52ad0d6f27aba0c5b8252dbc0a0414d 100644 +--- a/dist/components/sidebar/base.js ++++ b/dist/components/sidebar/base.js +@@ -1,5 +1,6 @@ + "use client"; + import { __exportAll } from "../../_virtual/_rolldown/runtime.js"; ++import { useI18n } from "../../contexts/i18n.js"; + import { cn } from "../../utils/cn.js"; + import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js"; + import { ScrollArea, ScrollViewport } from "../ui/scroll-area.js"; +@@ -231,8 +232,9 @@ function SidebarFolderContent(props) { + } + function SidebarTrigger({ children, ...props }) { + const { setOpen } = useSidebar(); ++ const { text } = useI18n(); + return /* @__PURE__ */ jsx("button", { +- "aria-label": "Open Sidebar", ++ "aria-label": text.sidebarOpen, + onClick: () => setOpen((prev) => !prev), + ...props, + children +@@ -240,9 +242,10 @@ function SidebarTrigger({ children, ...props }) { + } + function SidebarCollapseTrigger(props) { + const { collapsed, setCollapsed } = useSidebar(); ++ const { text } = useI18n(); + return /* @__PURE__ */ jsx("button", { + type: "button", +- "aria-label": "Collapse Sidebar", ++ "aria-label": text.sidebarCollapse, + "data-collapsed": collapsed, + onClick: () => { + setCollapsed((prev) => !prev); +diff --git a/dist/contexts/i18n.d.ts b/dist/contexts/i18n.d.ts +index 447e666c6a5e4042f02f02fb74354161ceb8937f..d9f836c1ff982838218a7ac080fbefe9c7690cc4 100644 +--- a/dist/contexts/i18n.d.ts ++++ b/dist/contexts/i18n.d.ts +@@ -13,6 +13,15 @@ interface Translations { + previousPage: string; + chooseTheme: string; + editOnGithub: string; ++ searchOpen: string; ++ themeToggle: string; ++ sidebarOpen: string; ++ sidebarCollapse: string; ++ headingCopyAnchor: string; ++ codeBlockCopy: string; ++ codeBlockCopied: string; ++ menuToggle: string; ++ navMain: string; + } + type TranslationsOption = { + [key: string]: string | TranslationsOption; +diff --git a/dist/contexts/i18n.js b/dist/contexts/i18n.js +index 14f3da7862506421298dd7d225a846585739e43e..858f0c67e32559b21b841729d949be9129e55541 100644 +--- a/dist/contexts/i18n.js ++++ b/dist/contexts/i18n.js +@@ -13,7 +13,16 @@ const defaultTranslations = { + nextPage: "Next Page", + previousPage: "Previous Page", + chooseTheme: "Theme", +- editOnGithub: "Edit on GitHub" ++ editOnGithub: "Edit on GitHub", ++ searchOpen: "Open Search", ++ themeToggle: "Toggle Theme", ++ sidebarOpen: "Open Sidebar", ++ sidebarCollapse: "Collapse Sidebar", ++ headingCopyAnchor: "Copy Anchor Link", ++ codeBlockCopy: "Copy Text", ++ codeBlockCopied: "Copied Text", ++ menuToggle: "Toggle Menu", ++ navMain: "Main" + }; + const I18nContext = createContext({ text: { ...defaultTranslations } }); + function I18nLabel(props) { +diff --git a/dist/layouts/home/slots/header.js b/dist/layouts/home/slots/header.js +index 7ee07bbf60fea995a5d3428d4252c96b1349a098..25fe17a35a203919580c4875f43c5a6a74b54233 100644 +--- a/dist/layouts/home/slots/header.js ++++ b/dist/layouts/home/slots/header.js +@@ -1,4 +1,5 @@ + "use client"; ++import { useI18n } from "../../../contexts/i18n.js"; + import { cn } from "../../../utils/cn.js"; + import { buttonVariants } from "../../../components/ui/button.js"; + import { LinkItem } from "../../shared/client.js"; +@@ -28,6 +29,7 @@ const navItemVariants = cva("[&_svg]:size-4", { + }); + function Header(props) { + const { navItems, menuItems, slots, props: { nav } } = useHomeLayout(); ++ const { text } = useI18n(); + if (nav?.component) return nav.component; + return /* @__PURE__ */ jsxs(HeaderNavigationMenu, { + transparentMode: nav?.transparentMode, +@@ -68,7 +70,7 @@ function Header(props) { + }), /* @__PURE__ */ jsx(NavigationMenuItem, { + asChild: true, + children: /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(NavigationMenuTrigger, { +- "aria-label": "Toggle Menu", ++ "aria-label": text.menuToggle, + className: cn(buttonVariants({ + size: "icon", + color: "ghost", +@@ -111,10 +113,12 @@ function isSecondary(item) { + return item.type === "icon"; + } + function HeaderNavigationMenu({ transparentMode = "none", ...props }) { ++ const { text } = useI18n(); + const [value, setValue] = useState(""); + const isTop = useIsScrollTop({ enabled: transparentMode === "top" }) ?? true; + const isTransparent = transparentMode === "top" ? isTop : transparentMode === "always"; + return /* @__PURE__ */ jsx(NavigationMenu, { ++ "aria-label": text.navMain, + value, + onValueChange: setValue, + asChild: true, +diff --git a/dist/layouts/shared/slots/search-trigger.js b/dist/layouts/shared/slots/search-trigger.js +index 19f55df4066e9fceda52be68f1dc5eb3ce72af3d..0df552b3863a577215a0dcf0f53a4273bb8db42a 100644 +--- a/dist/layouts/shared/slots/search-trigger.js ++++ b/dist/layouts/shared/slots/search-trigger.js +@@ -8,6 +8,7 @@ import { Search } from "lucide-react"; + //#region src/layouts/shared/slots/search-trigger.tsx + function SearchTrigger({ hideIfDisabled, size = "icon-sm", color = "ghost", ...props }) { + const { setOpenSearch, enabled } = useSearchContext(); ++ const { text } = useI18n(); + if (hideIfDisabled && !enabled) return null; + return /* @__PURE__ */ jsx("button", { + type: "button", +@@ -16,7 +17,7 @@ function SearchTrigger({ hideIfDisabled, size = "icon-sm", color = "ghost", ...p + color + }), props.className), + "data-search": "", +- "aria-label": "Open Search", ++ "aria-label": text.searchOpen, + onClick: () => { + setOpenSearch(true); + }, +diff --git a/dist/layouts/shared/slots/theme-switch.js b/dist/layouts/shared/slots/theme-switch.js +index 400c9db3dc78af6487c17c650a57d1b3f951b9b0..5945b792e2317d8e97a6fb389802d158f7ead46a 100644 +--- a/dist/layouts/shared/slots/theme-switch.js ++++ b/dist/layouts/shared/slots/theme-switch.js +@@ -1,4 +1,5 @@ + "use client"; ++import { useI18n } from "../../../contexts/i18n.js"; + import { cn } from "../../../utils/cn.js"; + import { useEffect, useState } from "react"; + import { jsx } from "react/jsx-runtime"; +@@ -17,6 +18,7 @@ const full = [ + ]; + function ThemeSwitch({ className, mode = "light-dark", ...props }) { + const { setTheme, theme, resolvedTheme } = useTheme(); ++ const { text } = useI18n(); + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); +@@ -26,7 +28,7 @@ function ThemeSwitch({ className, mode = "light-dark", ...props }) { + const value = mounted ? resolvedTheme : null; + return /* @__PURE__ */ jsx("button", { + className: container, +- "aria-label": `Toggle Theme`, ++ "aria-label": text.themeToggle, + onClick: () => setTheme(value === "light" ? "dark" : "light"), + "data-theme-toggle": "", + children: full.map(([key, Icon]) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1664026..eac6b0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ patchedDependencies: fumadocs-core@16.8.12: hash: a88318fc3b23c44ea3645ae68c43af3a905e0d00d6bc2fcefe3a7b625b3dfe57 path: patches/fumadocs-core@16.8.12.patch + fumadocs-ui@16.8.12: + hash: eb14645516e96c422e66fc74341475ca4f234ae91755eaacfaddab6ae88c85b0 + path: patches/fumadocs-ui@16.8.12.patch importers: @@ -30,7 +33,7 @@ importers: version: 15.0.7(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.17)(fumadocs-core@16.8.12(patch_hash=a88318fc3b23c44ea3645ae68c43af3a905e0d00d6bc2fcefe3a7b625b3dfe57)(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.16.0(react@19.2.7))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) fumadocs-ui: specifier: 16.8.12 - version: 16.8.12(@tailwindcss/oxide@4.3.3)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.8.12(patch_hash=a88318fc3b23c44ea3645ae68c43af3a905e0d00d6bc2fcefe3a7b625b3dfe57)(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.16.0(react@19.2.7))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.3) + version: 16.8.12(patch_hash=eb14645516e96c422e66fc74341475ca4f234ae91755eaacfaddab6ae88c85b0)(@tailwindcss/oxide@4.3.3)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.8.12(patch_hash=a88318fc3b23c44ea3645ae68c43af3a905e0d00d6bc2fcefe3a7b625b3dfe57)(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.16.0(react@19.2.7))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.3) lucide-react: specifier: ^1.16.0 version: 1.16.0(react@19.2.7) @@ -6182,7 +6185,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.8.12(@tailwindcss/oxide@4.3.3)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.8.12(patch_hash=a88318fc3b23c44ea3645ae68c43af3a905e0d00d6bc2fcefe3a7b625b3dfe57)(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.16.0(react@19.2.7))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.3): + fumadocs-ui@16.8.12(patch_hash=eb14645516e96c422e66fc74341475ca4f234ae91755eaacfaddab6ae88c85b0)(@tailwindcss/oxide@4.3.3)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.8.12(patch_hash=a88318fc3b23c44ea3645ae68c43af3a905e0d00d6bc2fcefe3a7b625b3dfe57)(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.16.0(react@19.2.7))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.3): dependencies: '@fumadocs/tailwind': 0.0.5(@tailwindcss/oxide@4.3.3)(tailwindcss@4.3.3) '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)