跳转到内容

翻译系统

本站以 17 种语言发布。这不是由一个翻译团队完成的,而是通过一个 Python 脚本、一个 Claude Opus 模型,以及一个精心设计的文件结构,让一切都能无缝衔接。以下是整个系统的工作原理。

文档存放在 src/content/docs/ 中。英文是根目录——其他每种语言都精确地镜像它:

src/content/docs/
├── index.mdx ← English (root)
├── getting-started.md
├── features/
│ ├── play-chess.md
│ ├── multiplayer.md
│ └── ...
├── setup/
│ ├── tts-overview.md
│ └── ...
├── under-the-hood/
│ ├── architecture.md
│ └── ...
├── fr/ ← French
│ ├── index.mdx
│ ├── getting-started.md
│ ├── features/
│ │ ├── play-chess.md
│ │ └── ...
│ └── ...
├── ja/ ← Japanese
│ ├── index.mdx
│ ├── getting-started.md
│ └── ...
└── ... (16 language directories total)

每个翻译文件都是其英文源文件的结构镜像。相同的文件名、相同的子目录路径、相同的 frontmatter 键。唯一的区别是正文内容使用了另一种语言。

Starlight(文档框架)依赖这种对称性。当用户切换语言时,Starlight 会将 /docs/getting-started/ 替换为 /fr/docs/getting-started/——相同的路径,不同的语言前缀。如果法文文件不是精确地位于 fr/getting-started.md,语言切换器就会失效或静默回退到英文。

语言按全球国际象棋玩家人口排序,数据来源于 LichessChess.com 和 FIDE 注册信息。英语作为源语言排在第一位;其余语言按国际象棋排名排列:

排名代码语言风格
1en英语权威源语言
2es西班牙语标准正式体
3hi印地语天城文字体
4ru俄语标准正式体
5de德语标准正式体
6fr法语标准正式体
7pt葡萄牙语欧洲葡萄牙语
11pl波兰语标准正式体
12it意大利语标准正式体
13uk乌克兰语标准正式体
14tr土耳其语标准正式体
17ko韩语합니다/습니다 体
18zh中文(简体)简体字
zh-tw中文(繁体)繁体字
23nb挪威语(书面语)标准书面语
be白俄罗斯语标准白俄罗斯语
34ja日语です/ます 体

“风格”这一列很重要。日语和韩语有敬语等级的选择,这会影响到每一句话。翻译提示词中包含了这些指令,以确保模型产出自然、精炼的文本——而非生硬的机器翻译输出。

这个排序同时也决定了网站顶部语言下拉菜单的顺序。使用人数最多的国际象棋语言排在前面,这样用户更容易在不滚动的情况下找到自己的语言。

所有文档最初以英文 markdown 形式写在 src/content/docs/ 中。Frontmatter 包含 titledescription

---
title: "Getting Started"
description: "Install En Parlant~ and play your first game."
---
Download the latest release...

一个 Python 脚本(scripts/translate-docs.py)读取每个英文源文件,将其发送给 Claude API,然后写入翻译后的 markdown 文件:

Terminal window
python3 scripts/translate-docs.py \
--anthropic-key $ANTHROPIC_API_KEY \
--model claude-opus-4-6 \
--workers 5

该脚本将全部 28 个源文件翻译成全部 16 种目标语言(共 448 个文件),大约需要 60–70 分钟。它同时运行 5 个并行 API 调用,以保持在速率限制范围内。

对于单独新增一种语言,大约需要 4 分钟。

Terminal window
pnpm build

Astro 读取源 markdown 文件,通过 Starlight 的模板渲染,将静态 HTML 输出到 dist/。构建大约 500 个页面需要约 30 秒。

Terminal window
pnpm run deploy

将构建好的站点推送到 Cloudflare Workers。

翻译脚本故意保持简洁——大约 300 行 Python 代码。以下是其流程:

┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Read English │────▶│ Claude API │────▶│ Write target │
│ source file │ │ (Opus 4.6) │ │ language file │
│ │ │ │ │ │
│ getting- │ │ "Translate into │ │ fr/getting- │
│ started.md │ │ French..." │ │ started.md │
└─────────────────┘ └──────────────────┘ └─────────────────┘
× 5 parallel
× 16 languages
× 28 files

提示词精确地告诉 Claude 哪些内容需要翻译,哪些需要保持原样:

  • 翻译: 正文、标题、表格标签、frontmatter 的 title 和 description
  • 保留英文: 代码块、行内代码、命令示例、文件路径、URL、产品名称、人名、ASCII 图表

这种区分至关重要。像 pnpm build 这样的命令在每种语言中都必须保持 pnpm build。像 “En Parlant~” 或 “Stockfish” 这样的产品名称不翻译。但 “Getting Started” 在日语中变成”はじめに”,在俄语中变成”Начало работы”。

Claude Opus 4.6 产出的翻译质量明显优于更快的模型。差异体现在:

  • 自然的表达方式 —— Opus 写出来的文字像母语者,而非翻译腔。当英文的语序在目标语言中会显得别扭时,它会重组句子结构。
  • 技术准确性 —— 国际象棋术语、TTS 专业用语和软件概念都使用了正确的领域专有术语。
  • 一致性 —— 敬语等级在全文保持一致。日语始终使用です/ます体,不会在段落中间在随意体和礼貌体之间来回切换。
  • MDX 处理 —— Opus 能正确保留 .mdx 文件中的 JSX 组件标签(<Card><CardGrid>)和 import 语句,不会破坏它们。

成本差异是实实在在的——使用 Opus 进行全站翻译约需 $28,而使用 Sonnet 约需 $5——但对于用户真正会阅读的 448 个文件来说,质量是值得的。

这是花费最长时间才搞定的部分。

Starlight 通过检查内容 slug 的第一个路径段来检测页面的语言。当它看到 fr/docs/getting-started 时,它知道这是法语,因为 fr 是第一个路径段。但最初的实现产生的 slug 类似于 docs/fr/getting-started——语言代码被埋在了 docs/ 之下。Starlight 将 docs 视为第一个路径段,把所有内容都当作英文处理,结果生成了 7,000 多个重复页面,而不是约 500 个。

src/content.config.ts 中的一个自定义 generateId 函数控制文件路径如何转换为内容 slug:

generateId({ entry }) {
const slug = entry.replace(/\.[^.]+$/, "");
const firstSeg = slug.split("/")[0];
if (firstSeg && localeKeys.includes(firstSeg)) {
return `${firstSeg}/docs/${slug.slice(firstSeg.length + 1)}`;
}
return `docs/${slug}`;
}

这将语言前缀放在了 docs/ 之前

文件路径SlugURL
getting-started.mddocs/getting-started/docs/getting-started/
fr/getting-started.mdfr/docs/getting-started/fr/docs/getting-started/
ja/features/puzzles.mdja/docs/features/puzzles/ja/docs/features/puzzles/

英语使用 defaultLocale: "root",这意味着没有前缀——它直接位于 /docs/,而不是 /en/docs/

同一文件中的 localeKeys 数组必须列出每个非英语语言代码。如果某个语言代码存在于 Astro 的配置中但不在这个数组中,其翻译内容会被当作英文内容处理——语言切换器会失效,页面数量会暴增。

Astro 将 slug 映射缓存在 .astro/data-store.json 中。任何语言配置更改后,必须在重新构建之前删除此文件,否则构建会成功但使用过期(错误)的路由。

侧边栏在 astro.config.mjs 中定义。指向页面的项目(slug 属性)会自动使用翻译页面 frontmatter 中的标题——无需手动翻译:

{ slug: "docs/getting-started" }
// English: "Getting Started" (from English frontmatter)
// French: "Premiers pas" (from French frontmatter)
// Japanese: "はじめに" (from Japanese frontmatter)

但分组标签和外部链接需要显式翻译:

{
label: "Features",
translations: {
fr: "Fonctionnalités",
es: "Características",
de: "Funktionen",
ja: "機能",
// ... all 16 languages
},
items: [
{ slug: "docs/features/play-chess" },
{ slug: "docs/features/multiplayer" },
// ...
],
}

七个侧边栏元素需要手动翻译:Welcome、Features、App Menus、Setup Guides、Under the Hood、Credits 和 Accessibility。添加新语言意味着需要在这七个块中各添加一个条目。

En Parlant~ 在其帮助菜单中链接到本文档。该链接是语言感知的——如果你正在使用法语版应用程序,它会打开法语文档:

const docsLocalePrefix = useMemo(() => {
const lang = i18n.language; // e.g. "fr_FR", "zh_TW"
if (!lang || lang.startsWith("en")) return "";
if (lang === "zh_TW") return "/zh-tw";
return `/${lang.slice(0, 2)}`;
}, [i18n.language]);

应用程序中可用的每种语言在网站上都有对应的翻译。映射是自动的——fr_FR 映射到 /fr/docs/ja_JP 映射到 /ja/docs/。唯一的特殊情况是繁体中文:zh_TW 映射到 /zh-tw/docs/(使用连字符)。

困难的部分已经解决:

  1. 路由架构已搞定。 generateId 函数、localeKeys 数组和 defaultLocale: "root" 配置协同工作,让 Starlight 生成正确的 URL 结构。这是最大的痛点——需要追踪 Starlight 和 Astro 中 6 个以上的源文件才找到并修复了问题。

  2. 翻译脚本处理一切。 全站重新翻译、单语言添加、单文件更新——都是同一个脚本配合不同的参数。它会在遇到速率限制时自动重试,跨 worker 并行处理,并清晰地报告错误。

  3. 添加新语言只需四处配置修改和一条命令。astro.config.mjscontent.config.tstranslate-docs.py 中添加语言代码,添加侧边栏翻译,运行脚本。大约 10 分钟的工作加上 4 分钟的翻译时间。

  4. 更新内容更加简单。 编辑英文源文件,使用 --files--overwrite 参数运行脚本只翻译修改过的文件,然后重新构建。或者对于小的文本修改,直接编辑翻译文件即可。

  5. 整个流水线已记录为一个技能。 运行 /translate_docs 会引导你走完整个流程——使用哪种模式、传递什么参数、预检查、翻译后验证。无需任何隐性知识。

全部 448 个文件跨 16 种语言的翻译总共花费约 $28,耗时约 70 分钟。单独新增一种语言的费用约 $1.70。单个文件跨所有语言重新翻译的费用约 $1。这就是将文档维护为 17 种语言版本的持续成本。