From bc8c8283ab4ae028ebf0c0c8fc0685df32dc5baf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 09:52:53 +0000 Subject: [PATCH 1/2] fix(docs): mark English fallbacks, localize the docs chrome, copy the page shown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A locale URL whose page has no source file in that locale serves the English page. It now says so: the title, description, body and TOC entries carry lang="en" (the document keeps the route locale, which the chrome is in), and a localized notice above the title reads "This page is not yet translated; showing English." Detection reuses translatedLocales(), which compares the page's source file against the English one — exact, because fumadocs copies the English file objects into every locale's storage. Fumadocs' interface strings (search, "On this page", "Choose a language", previous/next) and this app's own controls (Copy Markdown, Open, the notice) come from lib/ui-text.ts: English in code, one JSON table per locale. ui-text/zh-Hant.json is generated from ui-text/zh-Hans.json by gen-zh-hant (OpenCC s2twp) and covered by its --check, like every other zh-Hant file. Copy Markdown on a real translation copies that locale's Markdown, built at render time by getLLMText; English pages and fallbacks keep fetching the English .mdx URL, which stays the only Markdown surface. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr --- .../docs/app/[lang]/docs/[[...slug]]/page.tsx | 82 ++++++++++- apps/docs/app/[lang]/layout.tsx | 2 + apps/docs/app/[lang]/root-provider.tsx | 12 +- apps/docs/components/ai/page-actions.tsx | 48 ++++-- apps/docs/lib/ui-text.ts | 137 ++++++++++++++++++ apps/docs/lib/ui-text/de.json | 18 +++ apps/docs/lib/ui-text/es.json | 18 +++ apps/docs/lib/ui-text/fr.json | 18 +++ apps/docs/lib/ui-text/ja.json | 18 +++ apps/docs/lib/ui-text/ko.json | 18 +++ apps/docs/lib/ui-text/zh-Hans.json | 18 +++ apps/docs/lib/ui-text/zh-Hant.json | 18 +++ apps/docs/scripts/gen-zh-hant.mjs | 28 +++- 13 files changed, 414 insertions(+), 21 deletions(-) create mode 100644 apps/docs/lib/ui-text.ts create mode 100644 apps/docs/lib/ui-text/de.json create mode 100644 apps/docs/lib/ui-text/es.json create mode 100644 apps/docs/lib/ui-text/fr.json create mode 100644 apps/docs/lib/ui-text/ja.json create mode 100644 apps/docs/lib/ui-text/ko.json create mode 100644 apps/docs/lib/ui-text/zh-Hans.json create mode 100644 apps/docs/lib/ui-text/zh-Hant.json diff --git a/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx index 8108eb5..e4a2779 100644 --- a/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx @@ -1,17 +1,20 @@ -import { SITE_NAME, getPageImage, source } from '@/lib/source'; +import { SITE_NAME, getLLMText, getPageImage, source } from '@/lib/source'; import type { Metadata } from 'next'; import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page'; import { notFound } from 'next/navigation'; import { getMDXComponents } from '@/mdx-components'; import { createRelativeLink } from 'fumadocs-ui/mdx'; +import { Callout } from 'fumadocs-ui/components/callout'; import { Step, Steps } from 'fumadocs-ui/components/steps'; import { File, Folder, Files } from 'fumadocs-ui/components/files'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; -import { LLMCopyButton, ViewOptions } from '@/components/ai/page-actions'; +import { LLMCopyButton, type MarkdownSource, ViewOptions } from '@/components/ai/page-actions'; import { gitConfig } from '@/lib/layout.shared'; import { SITE_URL, languageAlternates, localeUrl, translatedLocales } from '@/lib/seo'; import { i18n } from '@/lib/i18n'; +import { uiText } from '@/lib/ui-text'; import type { InferPageType } from 'fumadocs-core/source'; +import type { TOCItemType } from 'fumadocs-core/toc'; /** * The page type the loader actually produces, frontmatter schema included. @@ -128,6 +131,21 @@ function canonicalUrl(lang: string, slugs: string[]): string { return localeUrl(canonicalLocale(lang, translatedLocales(slugs)), docsPath(slugs)); } +/** + * The table of contents of a page whose headings are in `contentLang`, ready to + * render inside a document declaring a different language. + * + * The TOC is the page's own headings, so on a fallback they are English — but + * Fumadocs renders them in its own container next to the localized "On this + * page" label, outside the element that carries the content's `lang`. Marking + * the container would mislabel that heading (and the "no headings" notice), so + * each item's title is wrapped instead: the list, the mobile popover and the + * popover trigger all render `title`, so this one wrap reaches all three. + */ +function tocInLanguage(toc: TOCItemType[], contentLang: string): TOCItemType[] { + return toc.map((item) => ({ ...item, title: {item.title} })); +} + /** * Open Graph wants `language_TERRITORY`. Our locale tags carry no territory * (`en`, `zh-Hans`, `ja`, …), so the honest mapping is the tag with the @@ -250,7 +268,8 @@ export default async function Page(props: { const loaded = await page.data.load(); const MDX = loaded.body; - // Resolved once and handed to both controls, so they cannot drift apart and + // Resolved once and handed to both controls (the copy button takes it through + // `copySource` 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: // it is invisible in the rendered markup — `markdownUrl` reaches the browser @@ -266,6 +285,34 @@ export default async function Page(props: { // the English page is Japanese. const contentLang = canonicalLocale(params.lang, translatedLocales(page.slugs)); + // A fallback is a locale route serving the English page because that locale + // has no source file for it. Exact, not inferred: fumadocs builds each + // locale's file system by copying the English file OBJECTS in and letting + // real translations overwrite them, so a fallback page carries the English + // file's own `path` (`operate/backup.mdx`, never `operate/backup.ja.mdx`) — + // the discriminator `translatedLocales` reads. `page.locale` cannot answer + // this: it is the locale the page was looked up under, on both kinds. + const isFallback = contentLang !== params.lang; + + // What the content elements declare. Only a fallback needs anything: a + // translated or English page is already in the document's language, which + // `` declares for the chrome and the content alike. `` + // itself stays the route locale (#181) — the sidebar, search and buttons + // around this page ARE in that language. + const contentLangAttr = isFallback ? contentLang : undefined; + const text = uiText(params.lang); + + // Copy Markdown copies the page on screen. For an English page and for a + // fallback that is the English page, served at `pageMarkdownUrl`. For a real + // translation it is this locale's text, which the `.mdx` surface does not + // serve (English-only, see `markdownUrl`), so it is rendered into the page + // instead: `getLLMText` builds it exactly as `/llms.mdx/docs/...` builds the + // English body, from the module `page.data.load()` above already loaded. + const copySource: MarkdownSource = + contentLang === i18n.defaultLanguage + ? { url: pageMarkdownUrl } + : { text: await getLLMText(page) }; + // 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 @@ -291,17 +338,36 @@ export default async function Page(props: { dangerouslySetInnerHTML={{ __html: jsonLdHtml(item) }} /> ))} - - {page.data.title} - {page.data.description} + + {isFallback && ( + // In the route locale, outside every element marked `lang` below: + // the notice is addressed to this locale's reader, and it comes + // before the English it describes so it is read first. + + {text.notTranslated} + + )} + {page.data.title} + + {page.data.description} +
- +
- + {children} diff --git a/apps/docs/app/[lang]/root-provider.tsx b/apps/docs/app/[lang]/root-provider.tsx index 16fbfea..505e3e6 100644 --- a/apps/docs/app/[lang]/root-provider.tsx +++ b/apps/docs/app/[lang]/root-provider.tsx @@ -11,6 +11,7 @@ import { import { FrameworkProvider, type Framework } from 'fumadocs-core/framework'; import { RootProvider } from 'fumadocs-ui/provider/base'; import { i18n as i18nConfig } from '@/lib/i18n'; +import type { FumadocsTranslations } from '@/lib/ui-text'; /** * Map an internal Next.js route pathname onto the public URL path the browser @@ -73,10 +74,19 @@ function usePublicPathname(): string { export function DocsRootProvider({ locale, locales, + translations, children, }: { locale: string; locales: { name: string; locale: string }[]; + /** + * Fumadocs' interface strings for `locale` — the search box, "On this + * page", the language picker's heading, the previous/next footer. Required: + * without it every Fumadocs component falls back to its built-in English, + * which is the defect `lib/ui-text.ts` exists to end, and an optional prop is + * how the next caller would reproduce it without anyone noticing. + */ + translations: FumadocsTranslations; children: ReactNode; }) { return ( @@ -92,7 +102,7 @@ export function DocsRootProvider({ Link={Link as Framework['Link']} Image={Image as Framework['Image']} > - {children} + {children} ); } diff --git a/apps/docs/components/ai/page-actions.tsx b/apps/docs/components/ai/page-actions.tsx index 7bd762c..1067cbe 100644 --- a/apps/docs/components/ai/page-actions.tsx +++ b/apps/docs/components/ai/page-actions.tsx @@ -8,16 +8,33 @@ import { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/ const cache = new Map(); +/** + * Where the Markdown of the page being shown comes from. + * + * - `url`: fetched on click from the site's `.mdx` surface. That surface is + * English-only by design (see `markdownUrl` in the docs page), so this is the + * source for an English page and for a locale URL serving the English page + * as a fallback — in both cases the page on screen IS the English one. + * - `text`: the Markdown itself, rendered into the page at build time. Used for + * a real translation, whose Markdown has no URL to fetch from: publishing + * one would make the `.mdx` surface locale-aware, which is a decision about + * that whole surface and not this button's to make. + */ +export type MarkdownSource = { url: string } | { text: string }; + export function LLMCopyButton({ - /** - * A URL to fetch the raw Markdown/MDX content of page - */ - markdownUrl, + markdown, + label, }: { - markdownUrl: string; + markdown: MarkdownSource; + /** The button's visible text, in the route locale. */ + label: string; }) { const [isLoading, setLoading] = useState(false); const [checked, onClick] = useCopyButton(async () => { + if ('text' in markdown) return navigator.clipboard.writeText(markdown.text); + + const markdownUrl = markdown.url; const cached = cache.get(markdownUrl); if (cached) return navigator.clipboard.writeText(cached); @@ -52,7 +69,7 @@ export function LLMCopyButton({ onClick={onClick} > {checked ? : } - Copy Markdown + {label} ); } @@ -60,6 +77,7 @@ export function LLMCopyButton({ export function ViewOptions({ markdownUrl, githubUrl, + labels, }: { /** * A URL to the raw Markdown/MDX content of page @@ -70,6 +88,14 @@ export function ViewOptions({ * Source file URL on GitHub */ githubUrl: string; + + /** The trigger's and the menu items' visible text, in the route locale. */ + labels: { + open: string; + openInGitHub: string; + openInChatGPT: string; + openInClaude: string; + }; }) { const items = useMemo(() => { const fullMarkdownUrl = @@ -78,7 +104,7 @@ export function ViewOptions({ return [ { - title: 'Open in GitHub', + title: labels.openInGitHub, href: githubUrl, icon: ( @@ -88,7 +114,7 @@ export function ViewOptions({ ), }, { - title: 'Open in ChatGPT', + title: labels.openInChatGPT, href: `https://chatgpt.com/?${new URLSearchParams({ hints: 'search', q, @@ -106,7 +132,7 @@ export function ViewOptions({ ), }, { - title: 'Open in Claude', + title: labels.openInClaude, href: `https://claude.ai/new?${new URLSearchParams({ q, })}`, @@ -123,7 +149,7 @@ export function ViewOptions({ ), }, ]; - }, [githubUrl, markdownUrl]); + }, [githubUrl, markdownUrl, labels]); return ( @@ -136,7 +162,7 @@ export function ViewOptions({ }), )} > - Open + {labels.open} diff --git a/apps/docs/lib/ui-text.ts b/apps/docs/lib/ui-text.ts new file mode 100644 index 0000000..b12ed87 --- /dev/null +++ b/apps/docs/lib/ui-text.ts @@ -0,0 +1,137 @@ +import type { Translations } from 'fumadocs-ui/i18n'; +import { i18n } from '@/lib/i18n'; +import de from './ui-text/de.json'; +import es from './ui-text/es.json'; +import fr from './ui-text/fr.json'; +import ja from './ui-text/ja.json'; +import ko from './ui-text/ko.json'; +import zhHans from './ui-text/zh-Hans.json'; +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 + * `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. + * + * 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. + * + * 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 + * are JSON rather than TypeScript for one reason: `ui-text/zh-Hant.json` is + * GENERATED from `ui-text/zh-Hans.json` by `scripts/gen-zh-hant.mjs` (OpenCC + * `s2twp`, the converter and preset that produce every `*.zh-Hant.mdx` and + * `meta.zh-Hant.json`), and that generator converts a data file. Never edit the + * Traditional file by hand; CI runs `gen-zh-hant --check` and fails on any byte + * of drift. + */ +const en = { + search: 'Search', + searchNoResult: 'No results found', + toc: 'On this page', + tocNoHeadings: 'No Headings', + lastUpdate: 'Last updated on', + chooseLanguage: 'Choose a language', + nextPage: 'Next Page', + previousPage: 'Previous Page', + chooseTheme: 'Theme', + editOnGithub: 'Edit on GitHub', + + copyMarkdown: 'Copy Markdown', + openMenu: 'Open', + openInGitHub: 'Open in GitHub', + openInChatGPT: 'Open in ChatGPT', + openInClaude: 'Open in Claude', + /** + * The notice on a locale URL whose page has no source file in that locale, + * so the body below it is the English page. Never rendered in English — an + * English page is never a fallback — but it is the source every locale's + * string is translated from, so it is kept. + */ + notTranslated: 'This page is not yet translated; showing English.', +} satisfies Translations & Record; + +export type UiText = Record; + +/** + * Fumadocs' `Translations`, restated as a type alias. Same keys, same values; + * the difference is that an alias satisfies the index signature of the + * provider's `translations` prop (`TranslationsOption`) and an interface + * cannot, so the interface would not type-check at the one place it is used. + */ +export type FumadocsTranslations = { [K in keyof Translations]: Translations[K] }; + +type Locale = (typeof i18n.languages)[number]; + +/** + * A locale table, held to EXACTLY the English key set. + * + * A JSON import is not a fresh object literal, so assigning one to `UiText` + * catches a missing key but lets an extra one through. The second half of the + * parameter type closes that: any key English does not have must be `never`, + * which a JSON string value is not, so a stale or misspelled key is a type + * error instead of dead weight nobody reads. + */ +function table(text: T & Record, never>): UiText { + return text; +} + +/** + * Every locale `lib/i18n.ts` declares, and no other: keyed by its literal + * union, so a locale added there without a table here fails `tsc` rather than + * rendering English chrome. + */ +const TEXT: Record = { + en, + 'zh-Hans': table(zhHans), + 'zh-Hant': table(zhHant), + ja: table(ja), + de: table(de), + es: table(es), + fr: table(fr), + ko: table(ko), +}; + +function isLocale(lang: string): lang is Locale { + return (i18n.languages as readonly string[]).includes(lang); +} + +/** + * The interface copy for a route locale. + * + * Total over `string` for the same reason `documentLanguage` in + * `app/[lang]/layout.tsx` is: `dynamicParams = false` means only an enumerated + * locale reaches a caller, and the default-language branch is the quiet answer + * for a routing invariant that broke, not a fallback anything relies on. + */ +export function uiText(lang: string): UiText { + return TEXT[isLocale(lang) ? lang : i18n.defaultLanguage]; +} + +/** + * Exactly the strings Fumadocs reads, for `RootProvider`'s `i18n.translations`. + * + * 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. + */ +export function fumadocsTranslations(text: UiText): FumadocsTranslations { + return { + search: text.search, + searchNoResult: text.searchNoResult, + toc: text.toc, + tocNoHeadings: text.tocNoHeadings, + lastUpdate: text.lastUpdate, + chooseLanguage: text.chooseLanguage, + nextPage: text.nextPage, + previousPage: text.previousPage, + chooseTheme: text.chooseTheme, + editOnGithub: text.editOnGithub, + }; +} diff --git a/apps/docs/lib/ui-text/de.json b/apps/docs/lib/ui-text/de.json new file mode 100644 index 0000000..2e0dc6f --- /dev/null +++ b/apps/docs/lib/ui-text/de.json @@ -0,0 +1,18 @@ +{ + "search": "Suchen", + "searchNoResult": "Keine Ergebnisse gefunden", + "toc": "Auf dieser Seite", + "tocNoHeadings": "Keine Überschriften", + "lastUpdate": "Zuletzt aktualisiert am", + "chooseLanguage": "Sprache wählen", + "nextPage": "Nächste Seite", + "previousPage": "Vorherige Seite", + "chooseTheme": "Design", + "editOnGithub": "Auf GitHub bearbeiten", + "copyMarkdown": "Markdown kopieren", + "openMenu": "Öffnen", + "openInGitHub": "In GitHub öffnen", + "openInChatGPT": "In ChatGPT öffnen", + "openInClaude": "In Claude öffnen", + "notTranslated": "Diese Seite ist noch nicht übersetzt; angezeigt wird die englische Fassung." +} diff --git a/apps/docs/lib/ui-text/es.json b/apps/docs/lib/ui-text/es.json new file mode 100644 index 0000000..025b5eb --- /dev/null +++ b/apps/docs/lib/ui-text/es.json @@ -0,0 +1,18 @@ +{ + "search": "Buscar", + "searchNoResult": "No se encontraron resultados", + "toc": "En esta página", + "tocNoHeadings": "Sin encabezados", + "lastUpdate": "Última actualización el", + "chooseLanguage": "Elegir idioma", + "nextPage": "Página siguiente", + "previousPage": "Página anterior", + "chooseTheme": "Tema", + "editOnGithub": "Editar en GitHub", + "copyMarkdown": "Copiar Markdown", + "openMenu": "Abrir", + "openInGitHub": "Abrir en GitHub", + "openInChatGPT": "Abrir en ChatGPT", + "openInClaude": "Abrir en Claude", + "notTranslated": "Esta página aún no está traducida; se muestra en inglés." +} diff --git a/apps/docs/lib/ui-text/fr.json b/apps/docs/lib/ui-text/fr.json new file mode 100644 index 0000000..54ce0d8 --- /dev/null +++ b/apps/docs/lib/ui-text/fr.json @@ -0,0 +1,18 @@ +{ + "search": "Rechercher", + "searchNoResult": "Aucun résultat", + "toc": "Sur cette page", + "tocNoHeadings": "Aucun titre", + "lastUpdate": "Dernière mise à jour le", + "chooseLanguage": "Choisir une langue", + "nextPage": "Page suivante", + "previousPage": "Page précédente", + "chooseTheme": "Thème", + "editOnGithub": "Modifier sur GitHub", + "copyMarkdown": "Copier le Markdown", + "openMenu": "Ouvrir", + "openInGitHub": "Ouvrir dans GitHub", + "openInChatGPT": "Ouvrir dans ChatGPT", + "openInClaude": "Ouvrir dans Claude", + "notTranslated": "Cette page n'est pas encore traduite. Elle s'affiche en anglais." +} diff --git a/apps/docs/lib/ui-text/ja.json b/apps/docs/lib/ui-text/ja.json new file mode 100644 index 0000000..c6b5653 --- /dev/null +++ b/apps/docs/lib/ui-text/ja.json @@ -0,0 +1,18 @@ +{ + "search": "検索", + "searchNoResult": "結果が見つかりませんでした", + "toc": "このページの内容", + "tocNoHeadings": "見出しはありません", + "lastUpdate": "最終更新日", + "chooseLanguage": "言語を選択", + "nextPage": "次のページ", + "previousPage": "前のページ", + "chooseTheme": "テーマ", + "editOnGithub": "GitHub で編集", + "copyMarkdown": "Markdown をコピー", + "openMenu": "開く", + "openInGitHub": "GitHub で開く", + "openInChatGPT": "ChatGPT で開く", + "openInClaude": "Claude で開く", + "notTranslated": "このページはまだ翻訳されていません。英語版を表示しています。" +} diff --git a/apps/docs/lib/ui-text/ko.json b/apps/docs/lib/ui-text/ko.json new file mode 100644 index 0000000..c8d27cb --- /dev/null +++ b/apps/docs/lib/ui-text/ko.json @@ -0,0 +1,18 @@ +{ + "search": "검색", + "searchNoResult": "검색 결과가 없습니다", + "toc": "이 페이지의 내용", + "tocNoHeadings": "제목이 없습니다", + "lastUpdate": "마지막 업데이트", + "chooseLanguage": "언어 선택", + "nextPage": "다음 페이지", + "previousPage": "이전 페이지", + "chooseTheme": "테마", + "editOnGithub": "GitHub에서 편집", + "copyMarkdown": "Markdown 복사", + "openMenu": "열기", + "openInGitHub": "GitHub에서 열기", + "openInChatGPT": "ChatGPT에서 열기", + "openInClaude": "Claude에서 열기", + "notTranslated": "이 페이지는 아직 번역되지 않았습니다. 영어 원문을 표시합니다." +} diff --git a/apps/docs/lib/ui-text/zh-Hans.json b/apps/docs/lib/ui-text/zh-Hans.json new file mode 100644 index 0000000..52bcb02 --- /dev/null +++ b/apps/docs/lib/ui-text/zh-Hans.json @@ -0,0 +1,18 @@ +{ + "search": "搜索", + "searchNoResult": "未找到结果", + "toc": "本页内容", + "tocNoHeadings": "本页没有标题", + "lastUpdate": "最后更新于", + "chooseLanguage": "选择语言", + "nextPage": "下一页", + "previousPage": "上一页", + "chooseTheme": "主题", + "editOnGithub": "在 GitHub 上编辑", + "copyMarkdown": "复制 Markdown", + "openMenu": "打开", + "openInGitHub": "在 GitHub 中打开", + "openInChatGPT": "在 ChatGPT 中打开", + "openInClaude": "在 Claude 中打开", + "notTranslated": "本页尚未翻译,以下显示英文原文。" +} diff --git a/apps/docs/lib/ui-text/zh-Hant.json b/apps/docs/lib/ui-text/zh-Hant.json new file mode 100644 index 0000000..71f00a7 --- /dev/null +++ b/apps/docs/lib/ui-text/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "search": "搜尋", + "searchNoResult": "未找到結果", + "toc": "本頁內容", + "tocNoHeadings": "本頁沒有標題", + "lastUpdate": "最後更新於", + "chooseLanguage": "選擇語言", + "nextPage": "下一頁", + "previousPage": "上一頁", + "chooseTheme": "主題", + "editOnGithub": "在 GitHub 上編輯", + "copyMarkdown": "複製 Markdown", + "openMenu": "開啟", + "openInGitHub": "在 GitHub 中開啟", + "openInChatGPT": "在 ChatGPT 中開啟", + "openInClaude": "在 Claude 中開啟", + "notTranslated": "本頁尚未翻譯,以下顯示英文原文。" +} diff --git a/apps/docs/scripts/gen-zh-hant.mjs b/apps/docs/scripts/gen-zh-hant.mjs index b56b3ce..100bbc4 100644 --- a/apps/docs/scripts/gen-zh-hant.mjs +++ b/apps/docs/scripts/gen-zh-hant.mjs @@ -47,6 +47,19 @@ * two together — when Simplified goes stale, Traditional goes stale with it, * and refreshing Simplified plus a regeneration clears both. * + * ## The interface copy is the same conversion + * + * `apps/docs/lib/ui-text/zh-Hans.json` holds the Simplified strings for the + * site's chrome — the search box, "On this page", Copy Markdown, the notice on + * an untranslated page — and `apps/docs/lib/ui-text/zh-Hant.json` is produced + * from it here, by the same converter and preset, and committed. One generator + * for the locale rather than a second hand-kept Chinese voice for the chrome: + * the rule above holds for a button label exactly as it holds for a page. + * Converted whole, like `meta..json` — the keys are ASCII, which the + * converter never touches — and checked by the same byte comparison. Unlike + * the content pair it is not discovered by walking a tree: it is one fixed + * pair, so a missing Simplified file is an error rather than nothing to do. + * * ## Hand-editing is a gate, not a convention * * `--check` regenerates every file in memory and compares bytes. A hand edit to @@ -73,6 +86,9 @@ const DOCS = join(ROOT, 'content/docs'); const SOURCE = 'zh-Hans'; const TARGET = 'zh-Hant'; +/** The interface-copy pair (see "The interface copy is the same conversion"). */ +const UI_TEXT = join(ROOT, 'apps/docs/lib/ui-text'); + /** * Simplified (mainland) -> Traditional (Taiwan, with phrase conversion). * @@ -192,6 +208,15 @@ function plan() { items.push({ source: file, target, text: generateMeta(readFileSync(file, 'utf8')) }); } } + // Read unconditionally: `readFileSync` throws on a missing source, which is + // the point — the docs page imports both files, so a deleted Simplified table + // is a broken build, not an empty plan. + const uiSource = join(UI_TEXT, `${SOURCE}.json`); + items.push({ + source: uiSource, + target: join(UI_TEXT, `${TARGET}.json`), + text: generateMeta(readFileSync(uiSource, 'utf8')), + }); return items.sort((a, b) => (a.target < b.target ? -1 : 1)); } @@ -252,7 +277,8 @@ function main() { } console.error( '\n zh-Hant is generated, not authored. Edit the English source (the Simplified\n' + - ' page is derived from it, and the Traditional page from that), then run:\n' + + ' page is derived from it, and the Traditional page from that) — or, for the\n' + + ' interface copy, apps/docs/lib/ui-text/zh-Hans.json — then run:\n' + ' pnpm --filter @objectos/docs gen:zh-hant\n' + ' See docs/TRANSLATION.md and the header of this script.', ); From 0e5d34852a09c40196ce187f511ee360fa3e0ac7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 10:19:08 +0000 Subject: [PATCH 2/2] fix(docs): localize the sidebar's Privacy and Terms links The legal links #304 added to the docs sidebar footer rendered "Legal", "Privacy" and "Terms" in English under every locale. They are interface copy, so they now come from lib/ui-text.ts like the rest of the chrome; the zh-Hant strings are regenerated from zh-Hans by gen-zh-hant. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr --- apps/docs/app/[lang]/docs/layout.tsx | 10 +++++++--- apps/docs/lib/ui-text.ts | 4 ++++ apps/docs/lib/ui-text/de.json | 5 ++++- apps/docs/lib/ui-text/es.json | 5 ++++- apps/docs/lib/ui-text/fr.json | 5 ++++- apps/docs/lib/ui-text/ja.json | 5 ++++- apps/docs/lib/ui-text/ko.json | 5 ++++- apps/docs/lib/ui-text/zh-Hans.json | 5 ++++- apps/docs/lib/ui-text/zh-Hant.json | 5 ++++- 9 files changed, 39 insertions(+), 10 deletions(-) diff --git a/apps/docs/app/[lang]/docs/layout.tsx b/apps/docs/app/[lang]/docs/layout.tsx index b19886b..158af15 100644 --- a/apps/docs/app/[lang]/docs/layout.tsx +++ b/apps/docs/app/[lang]/docs/layout.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import type { ReactNode } from 'react'; import { baseOptions } from '@/lib/layout.shared'; import { i18n } from '@/lib/i18n'; +import { uiText } from '@/lib/ui-text'; /** * Links to `/privacy` and `/terms`, at the foot of the docs sidebar (#299). @@ -15,17 +16,20 @@ import { i18n } from '@/lib/i18n'; * * Each link stays in the reader's locale. A locale the page is not written in * renders the English text, and that route names the English URL as canonical. + * The labels are interface copy, so they come from `lib/ui-text.ts` in the + * reader's locale like the rest of the sidebar's chrome. */ function LegalLinks({ lang }: { lang: string }) { const prefix = lang === i18n.defaultLanguage ? '' : `/${lang}`; + const text = uiText(lang); return ( -