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} +
- +
- + + ); diff --git a/apps/docs/app/[lang]/layout.tsx b/apps/docs/app/[lang]/layout.tsx index 32e0843..d59faee 100644 --- a/apps/docs/app/[lang]/layout.tsx +++ b/apps/docs/app/[lang]/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { i18n } from '@/lib/i18n'; +import { fumadocsTranslations, uiText } from '@/lib/ui-text'; import { DocsRootProvider } from './root-provider'; // Language display names mapping @@ -53,6 +54,7 @@ export default async function LanguageLayout({ name: LANGUAGE_NAMES[l] || l, locale: l, }))} + translations={fumadocsTranslations(uiText(lang))} > {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..fa99157 --- /dev/null +++ b/apps/docs/lib/ui-text.ts @@ -0,0 +1,141 @@ +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.', + /** The sidebar footer's links to `/privacy` and `/terms`, and their nav's name. */ + legalNav: 'Legal', + privacy: 'Privacy', + terms: 'Terms', +} 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..14de3c1 --- /dev/null +++ b/apps/docs/lib/ui-text/de.json @@ -0,0 +1,21 @@ +{ + "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.", + "legalNav": "Rechtliches", + "privacy": "Datenschutz", + "terms": "Nutzungsbedingungen" +} diff --git a/apps/docs/lib/ui-text/es.json b/apps/docs/lib/ui-text/es.json new file mode 100644 index 0000000..343c69b --- /dev/null +++ b/apps/docs/lib/ui-text/es.json @@ -0,0 +1,21 @@ +{ + "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.", + "legalNav": "Información legal", + "privacy": "Privacidad", + "terms": "Términos" +} diff --git a/apps/docs/lib/ui-text/fr.json b/apps/docs/lib/ui-text/fr.json new file mode 100644 index 0000000..4adc207 --- /dev/null +++ b/apps/docs/lib/ui-text/fr.json @@ -0,0 +1,21 @@ +{ + "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.", + "legalNav": "Mentions légales", + "privacy": "Confidentialité", + "terms": "Conditions" +} diff --git a/apps/docs/lib/ui-text/ja.json b/apps/docs/lib/ui-text/ja.json new file mode 100644 index 0000000..f60684c --- /dev/null +++ b/apps/docs/lib/ui-text/ja.json @@ -0,0 +1,21 @@ +{ + "search": "検索", + "searchNoResult": "結果が見つかりませんでした", + "toc": "このページの内容", + "tocNoHeadings": "見出しはありません", + "lastUpdate": "最終更新日", + "chooseLanguage": "言語を選択", + "nextPage": "次のページ", + "previousPage": "前のページ", + "chooseTheme": "テーマ", + "editOnGithub": "GitHub で編集", + "copyMarkdown": "Markdown をコピー", + "openMenu": "開く", + "openInGitHub": "GitHub で開く", + "openInChatGPT": "ChatGPT で開く", + "openInClaude": "Claude で開く", + "notTranslated": "このページはまだ翻訳されていません。英語版を表示しています。", + "legalNav": "法的情報", + "privacy": "プライバシー", + "terms": "利用規約" +} diff --git a/apps/docs/lib/ui-text/ko.json b/apps/docs/lib/ui-text/ko.json new file mode 100644 index 0000000..dd0d56d --- /dev/null +++ b/apps/docs/lib/ui-text/ko.json @@ -0,0 +1,21 @@ +{ + "search": "검색", + "searchNoResult": "검색 결과가 없습니다", + "toc": "이 페이지의 내용", + "tocNoHeadings": "제목이 없습니다", + "lastUpdate": "마지막 업데이트", + "chooseLanguage": "언어 선택", + "nextPage": "다음 페이지", + "previousPage": "이전 페이지", + "chooseTheme": "테마", + "editOnGithub": "GitHub에서 편집", + "copyMarkdown": "Markdown 복사", + "openMenu": "열기", + "openInGitHub": "GitHub에서 열기", + "openInChatGPT": "ChatGPT에서 열기", + "openInClaude": "Claude에서 열기", + "notTranslated": "이 페이지는 아직 번역되지 않았습니다. 영어 원문을 표시합니다.", + "legalNav": "법적 고지", + "privacy": "개인정보 보호", + "terms": "이용약관" +} 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..cee087c --- /dev/null +++ b/apps/docs/lib/ui-text/zh-Hans.json @@ -0,0 +1,21 @@ +{ + "search": "搜索", + "searchNoResult": "未找到结果", + "toc": "本页内容", + "tocNoHeadings": "本页没有标题", + "lastUpdate": "最后更新于", + "chooseLanguage": "选择语言", + "nextPage": "下一页", + "previousPage": "上一页", + "chooseTheme": "主题", + "editOnGithub": "在 GitHub 上编辑", + "copyMarkdown": "复制 Markdown", + "openMenu": "打开", + "openInGitHub": "在 GitHub 中打开", + "openInChatGPT": "在 ChatGPT 中打开", + "openInClaude": "在 Claude 中打开", + "notTranslated": "本页尚未翻译,以下显示英文原文。", + "legalNav": "法律信息", + "privacy": "隐私", + "terms": "条款" +} 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..1573cad --- /dev/null +++ b/apps/docs/lib/ui-text/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "search": "搜尋", + "searchNoResult": "未找到結果", + "toc": "本頁內容", + "tocNoHeadings": "本頁沒有標題", + "lastUpdate": "最後更新於", + "chooseLanguage": "選擇語言", + "nextPage": "下一頁", + "previousPage": "上一頁", + "chooseTheme": "主題", + "editOnGithub": "在 GitHub 上編輯", + "copyMarkdown": "複製 Markdown", + "openMenu": "開啟", + "openInGitHub": "在 GitHub 中開啟", + "openInChatGPT": "在 ChatGPT 中開啟", + "openInClaude": "在 Claude 中開啟", + "notTranslated": "本頁尚未翻譯,以下顯示英文原文。", + "legalNav": "法律資訊", + "privacy": "隱私", + "terms": "條款" +} 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.', );