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)