国际化
带 URL 前缀的多语言文档,外加本地化的站点外壳。
国际化#
Vellum 原生支持多语言文档。语言在站点级别配置,对每个仓库都生效。
站点级语言配置#
{
"site": {
"defaultLocale": "en",
"locales": [
{ "code": "en", "label": "English", "prefix": "" },
{ "code": "zh", "label": "中文", "prefix": "zh" },
{ "code": "ja", "label": "日本語", "prefix": "ja" }
]
}
}
code 是语言代码(用作缓存 key 后缀与 i18n 查询 key)。
label 在语言选择器里显示。
prefix 是 URL 段。空字符串表示该语言位于每个仓库的根—— 通常给默认语言用。
读到 /zh/repo/getting-started 的人正在看 repo 仓库内 getting-started 的 zh 版本。
每个仓库的内容布局#
对于 prefix 非空的每个语言,worker 在 {docsRoot}/{prefix}/ 下查找 内容。默认语言的页面直接位于 docsRoot 下。
docs/
index.md # en(默认语言)
getting-started.md
zh/
index.md # zh
getting-started.md
ja/
index.md
getting-started.md
你不必翻译每一页。读到 /zh/repo/missing-page 时会得到 404, 而不是回退到英语版——这样维护者一眼就能看到缺失的翻译。
路由#
路由器(src/worker/router.ts)按下面的形式解析 URL:
[/{localePrefix}]/{repoSlug}[/{pagePath}]
它通过把第二段与已配置的 prefix 比对来识别语言。页面路径会把那一段 去掉,所以同一个 pagePath(“getting-started”)在 en 下查找 docs/getting-started.md,在 zh 下查找 docs/zh/getting-started.md。
语言选择器#
当配置了多于一种语言时,NavBar 会显示一个地球图标。点击它能切换到 所选语言的同一页面——仓库、页面路径、hash 都会保留。
如果用户选择的语言下当前页面没有翻译,会落到一个 404(带本地化的 404 外壳)。
本地化的站点外壳#
src/shared/i18n.ts 持有 worker 控制的所有文案——搜索对话框标签、 “上一页” / “下一页” 导航、提示框默认标题、404 页面,等等。 当前内置英语(en)和简体中文(zh)的手工字典。
新增第三种语言有两条路:手工字典,或机器翻译(见下一节)。当两者 都存在时手工内容优先——worker 只填补作者没翻译的空缺。
手工新增:
在 vellum.config.json 的 site.locales 里新增一项。
在 src/shared/i18n.ts 里新增一份字典:
const fr: MessageMap = {
"ui.search": "Recherche",
"ui.search.placeholder": "Rechercher dans la documentation",
// ...
};
const dictionaries: Record<string, MessageMap> = { en, zh, fr };
把你的内容翻译到 docs/fr/ 里。
任何在非英语字典中缺失的 key 都会回退到英语字符串——所以可以先发 一份部分翻译,也不会让 UI 坏掉。
机器翻译#
配置 site.translate 之后,worker 会按需翻译你指定的任何语言—— 页面 markdown、侧边栏标签、仓库 nav、frontmatter 字段、UI 字典以及 仓库展示文案,全部交给所配置的 LLM provider。翻译结果存在一个 D1 数据库里,每次 webhook 推送会清掉对应仓库的行,每小时一次的 cron 触发器在后台刷新过期行。
{
"site": {
"translate": {
"provider": "openai-compatible",
"baseUrl": "https://openrouter.ai/api/v1",
"model": "openai/gpt-4o-mini",
"targets": ["zh-CN", "zh-TW", "ja", "ko", "es", "fr", "de", "pt-BR"],
"refreshDays": 5
}
}
}
在 site.locales 里声明的、并且在 docs/{code}/ 下有源码的语言保 留它的手工内容。机器翻译只对作者没有手工翻译的语言生效——查询 不到本地化源文件时才回退到默认语言的源文件并跑翻译。
配置#
| provider | ✓ | "workers-ai"、"openai-compatible" 或 "anthropic"。和 aiSummary / aiChat 相同的矩阵。 |
| model | 模型 ID。默认值:Llama 3.3 70B Fast / gpt-4o-mini / Haiku 4.5。 | |
| baseUrl | OpenAI 兼容端点的 base URL(OpenRouter、Together 等)。VELLUM_AI_BASE_URL 优先。 | |
| targets | ✓ | BCP-47 代码数组,或字面量 "all"(见下)。 |
| refreshDays | 缓存行的新鲜期(天)。默认 5 天。cron 会删除比它更老的行,下次访问时懒翻译。 | |
| concurrency | 预留字段:每次 cron tick 的并发翻译调用上限。 | |
| batchSize | 每次 cron tick 删除的行数上限。默认 50。 |
凭证沿用 aiSummary / aiChat 用的同一份 VELLUM_AI_API_KEY worker secret。翻译特有的只有 D1 绑定(见下面的 D1 配置)。
目标语言#
targets 支持三种写法:
// 1. 显式 BCP-47 代码——包含区域变体。区域代码会让翻译器使用对应
// 地区的词汇("巴西葡萄牙语"对比"欧洲葡萄牙语")。
"targets": ["zh-CN", "zh-TW", "pt-BR", "es-MX", "ja", "ko"]
// 2. 裸 ISO 639-1 代码——只有语言,没有区域。
"targets": ["es", "fr", "de", "ja", "ko"]
// 3. 字面量 "all"——展开为 IANA ISO 639-1 注册表里的全部代码
// (约 180 个),数据来自 iso-639-1 npm 包。
"targets": "all"
解析后的每个代码都会自动合并进 site.locales,带上 machineTranslated: true、来自 iso-639-1 原生名表(裸代码)或 Intl.DisplayNames(区域代码)的标签,以及和代码相同的 URL 前缀—— 所以 "zh-CN" 产生 /zh-CN/... 形式的 URL,"es" 产生 /es/...。 已经在 site.locales 里出现过的代码会被跳过,作者声明的语言保留它 自己的手工标签和前缀。
整个语言层都靠外部维护的数据驱动,worker 里没有硬编码表:
“all” 代码集——IANA ISO 639-1,由 iso-639-1 npm 包提供。
原生标签——裸代码用 iso-639-1.getNativeName() (ja → 日本語),区域代码用 Intl.DisplayNames (pt-BR → Português (Brasil))。
裸代码 → BCP-47 扩展(<html lang> 和 hreflang 用的)——CLDR 的 likely-subtags 数据,通过 Intl.Locale.prototype.maximize()(zh → zh-CN、 pt → pt-BR)。
翻译模型的提示词——Intl.DisplayNames(英文, languageDisplay: "dialect"),所以提示词读作 “Chinese (Simplified, China) (zh-CN)”。
D1 配置#
worker 把缓存的翻译存进一个 D1 数据库。先创建:
wrangler d1 create vellum-translations
命令会打印一个 UUID。把它粘进 wrangler.jsonc:
"d1_databases": [
{
"binding": "VELLUM_TRANSLATION_DB",
"database_name": "vellum-translations",
"database_id": "00000000-0000-0000-0000-000000000000",
"migrations_dir": "migrations"
}
]
应用迁移:
wrangler d1 migrations apply vellum-translations --remote
绑定在运行时是可选的——如果缺失,翻译层会变成 no-op,仅在 targets 里列出的语言会回退到默认语言的源文件。本地开发时不想 provision D1 也能渲染页面,挺方便。
翻译范围#
| page | {repoSlug}@{branch}:{pagePath} | 一个 MT 语言请求找不到本地化源文件时——默认语言的 markdown 会被翻译。 |
| sidebar | {repoSlug}@{branch} | 侧边栏加载器为 MT 语言构建树时——所有 .text 字段批量进入同一次调用。 |
| repo-nav | {repoSlug}@{branch} | 同理,针对来自 vellum.json#nav 或 themeConfig.nav 的仓库顶部导航。 |
| frontmatter | {repoSlug}@{branch}:{pagePath} | 内嵌在 page 调用中——提示词要求模型翻译 title、description、hero / features 字段。 |
| ui | ui:v1 | src/shared/i18n.ts 的静态 UI 字典。每种语言一次调用,与所访问的页面无关。 |
| config | site:v1 | vellum.config.json 的文案:tagline、每个仓库的 displayName / description,以及站点级 nav[].text。 |
site.title 和 site.footer 故意永远不被翻译——它们属于品牌层, 项目维护者要求保留原样。
Markdown 保真#
page 翻译的提示词对语法非常严格:
代码块、行内 code 和 HTML 标签全部原样穿透——绝不翻译标识符、 函数名、命令参数,以及任何反引号 / 代码块里的内容。
链接和图片的 URL 不动;只翻译可见的标签 / alt 文本。
YAML frontmatter 的分隔符保留;frontmatter 内部只翻译 title、 description、tagline、text、name、details、linkText 的 值。
VitePress 容器(::: tip、[!INCLUDE]、[!NOTE] 等)、xref token(@xref:uid、跨仓库 @slug/...)和 OPS 指令均保留。
刷新#
两条触发路径让缓存和源内容保持一致:
Webhook,每次推送时:webhook.ts 在清除 HTML / 侧边栏 / tree 缓存之后调用 invalidateForRepo(),让下次任何 MT 语言的读取都 重新翻译。
Cron,每小时整点(在 wrangler.jsonc#triggers.crons 里声明): 删除 refreshed_at 早于 refreshDays 的行。被删的行会在下一次 请求到来时懒重译;没人访问的冷路径不会产生模型调用。
cron 处理器每个 tick 的删除上限是 batchSize(默认 50),让庞大的 表也不会爆掉 Worker 的 CPU 预算。如果你有海量的"页面 × 语言"组合 并想让后台刷新更快,可以调高。
成本特征#
翻译是惰性的——只有真有读者访问那种语言的 URL 时才会跑。配 targets: "all" 加 100 页,并不会预翻译 18000 个页面正文;它会按 需翻译、缓存到 D1、之后的读取直接返回缓存。cron 会删除 refreshDays 天内没人碰过的行,所以表大小由实际读者量决定,而不 是配置的目标数量。
翻译横幅#
每个经过 MT 管线渲染的页面都会在正文上方显示一个状态横幅—— <MachineTranslatedBanner />,由 doc 布局、home 布局和 MS Learn 布局 统一挂载,所以无论读者打开的是哪种页面都能看到。
两种状态:
已翻译(info MessageBar,translate 图标)。模型生成了译文, 读者正在阅读它。横幅显示「正在翻译,你也可以用其他语言查看本页」 以及一行可跳转到的其他语言链接。
已尝试但暂未就绪(warning MessageBar,warning 图标)。路由 触发了 MT,但 provider 调用空跑了(没设 VELLUM_AI_API_KEY、 网络错误、限流……)。读者看到的是源语言原文。横幅显示「翻译暂未 就绪」,并提示稍后再试或切换语言。
横幅里的语言列表由 page.meta.translatedLocales 过滤,由路由从 D1 查询填充——所以只会展示当前真的能跳转到的语言(默认语言 + 手工 语言 + 有缓存行的 MT 语言)。超过约 6 个候选时,行内列表会截断, 并把多余的合并到下一节描述的「全部语言」页面链接里。
全部语言页面#
路由:/{localePrefix}/languages(默认语言下是 /languages)。一个 整页的语言选择器——当 translate.targets: "all" 让 NavBar 下拉变得 笨重时尤其有用。一旦语言数超过 10 个,NavBar 选择器会截断到 10 项 并新增一个「更多语言…」链接指向这个页面。
页面里语言按大洲分组——通过 Intl.Locale.maximize().region 加 countries-list 决定大洲归属(亚洲 → 欧洲 → 非洲 → 北美洲 → 南美洲 → 大洋洲 → 南极洲 → 其他)。每个大洲内部按原生标签字母 顺序排序。页面顶部有一个搜索框,可以按标签、代码或 URL 前缀过滤。
每张卡片是一个 FluentUI Card,显示原生名称、BCP-47 代码,以及对 机器翻译语言显示「机器翻译」徽章。读者当前所在的语言带品牌色 背景,且不可点击。URL 上传 ?page=<repo 相对路径>,点击卡片就能 保持页面路径不变地切换语言(横幅里的「全部语言」链接正是这样 带着 page 跳过来的)。
调试翻译#
翻译器会把每一次调用以 [vellum][translate] 前缀输出到 worker 控制台。运行 wrangler dev(或对部署后的 worker 使用 wrangler tail),访问任意翻译过的 URL,会看到类似这样的日志:
[vellum][translate] kind=page key=prism@main:getting-started locale=zh-TW cache miss; calling provider
[vellum][translate] kind=page key=prism@main:getting-started locale=zh-TW provider ok model=openai/gpt-4o-mini bytes_in=4823 bytes_out=5310
[vellum][translate] kind=page key=prism@main:getting-started locale=zh-TW cached
出问题时同一个前缀会把原因带出来。常见的几条:
skip: site.translate not configured——vellum.config.json 没配 site.translate 块。
skip: locale is the default / skip: locale is hand-curated, not an MT target——请求的是不需要 MT 的语言。
no D1 binding (VELLUM_TRANSLATION_DB); running uncached provider call——wrangler.jsonc 没声明 D1 绑定,每次请求都会跑一次模型 调用。本地开发可以接受,生产环境绝对要绑上。
provider failed: VELLUM_AI_API_KEY is not set.——API key secret 没设。用 wrangler secret put VELLUM_AI_API_KEY 写入再重新部署。
provider failed: Upstream 429: …——provider 限流了。把 translate.concurrency 或 batchSize 调小,或换更高额度的套餐。
路由还会在翻译器返回未变源文本时打印一条 [vellum][router] MT no-op for …——这正是横幅显示「翻译暂未就绪」 warning 状态的信号。
frontmatter 与 i18n#
frontmatter 的 title 和 description 是内容,不是外壳—— 在每种语言各自的文件副本里翻译它们。英语的 frontmatter 永远不会 泄漏到已翻译的页面。
markdown 中的本地化链接#
写成相对文档根的 markdown 链接(例如 [Getting started](./getting-started))会被 worker 改写, 自动带上当前语言的前缀。所以在 docs/zh/index.md 里,这条链接 会解析为 /zh/repo/getting-started,而不是 /repo/getting-started。
跨仓库的 @slug/ 链接行为一致:
See [the Prism guide](@prism/getting-started) for OAuth setup.
在一个 zh 页面上阅读时,它会渲染为指向目标仓库相同语言的链接 (/zh/prism/getting-started)。
落地页#
如果 homepageRepo 是本地源,可以在 local-docs/{homepage-slug}/{prefix}/index.md 新建文件来本地化它的落地页。自带的配置就是这么做的—— 中文主页见 local-docs/homepage/zh/index.md。
逐页翻译状态#
每个页面都会计算它在哪些语言下可用,并通过 bootstrap 数据的 page.meta.translatedLocales 传播给前端。这驱动三个 UI 界面:
全部语言页面徽章。 每张语言卡片显示四种状态之一:当前 (品牌色背景)、源语言(信息色描边)、机器翻译(品牌色 描边)、尚未翻译(浅色描边)。状态取决于当前页面在 D1 里是否 有对应语言的缓存翻译行。
语言选择器过滤。 NavBar 下拉只显示当前页面确实有翻译的语言。 人工翻译的语言排在机器翻译之前。
横幅语言标签。 翻译横幅里的「你也可以用其他语言查看本页」 链接列表同样只显示读者当前真正能跳转到的语言。
一键翻译整个仓库#
全部语言页面在每张机器翻译语言卡片上提供了 翻译全部 按钮。 点击后弹出一个对话框,将所有配置仓库中的每一页翻译为所选语言。 index 和侧边栏文件始终优先翻译,确保导航结构在各页面翻译之前 就绪。
工作原理#
客户端为每个仓库依次发送 POST /api/translate-repo?repo={slug}&locale={code}。
服务端枚举仓库源码树,筛选 .md 文件,按优先级排序——根 index 和嵌套 index 页面排在最前面。
侧边栏标签通过 loadSidebar() 先于页面内容完成翻译。
每页顺序翻译——服务端调用与惰性请求路径相同的 translate() 函数,结果缓存到 D1,后续访问立即返回缓存。
进度以 Server-Sent Events 流式推送回客户端(start、 progress、complete、cancelled、error)。
进度条#
对话框显示一个 FluentUI ProgressBar,包含:
当前百分比(0–100%)。
正在翻译的页面路径。
阶段提示(正在翻译侧边栏和索引… 与 正在翻译页面:)。
计数器:12 / 47 页。
配置了多个仓库时,标题会显示当前活跃的仓库及其在队列中的位置 (prism (1/3))。
取消授权#
只有发起翻译的浏览器才能取消它。
任务开始时,服务端生成一个随机取消令牌,通过 SSE start 事件 返回。客户端将令牌存入 localStorage。
取消 按钮仅在当前浏览器持有匹配令牌时才渲染。其他查看者 只能看到 关闭 按钮——关闭对话框但不终止任务。
取消时发送 DELETE /api/translate-repo?repo={slug}&locale={code}, 在 x-cancel-token 请求头中携带令牌。服务端校验令牌是否与 D1 中的任务行匹配,不匹配返回 403 Unauthorized。
翻译循环在每页翻译之前检查 D1 中的 cancelled 状态,一旦 发现即提前终止。
浮动进度横幅#
翻译进度可以在 任意页面 上查看,不限于全部语言页面。当 localStorage 中存在活跃的翻译任务时,右下角固定位置会出现一个 MessageBar。
横幅会:
每秒轮询 localStorage,每 3 秒轮询服务端。
显示语言标签、百分比、页数和当前文件。
点击后打开完整的翻译对话框。
完成或取消后,显示关闭按钮以清除横幅。
当全部语言页面上对话框已经打开时,横幅自动隐藏,避免重叠。
服务端任务持久化#
任务进度通过已有的 translations 表持久化到 D1,使用 kind = "translate-job"。这让任何浏览器标签页——甚至不同设备—— 都能轮询状态端点查看实时进度:
GET /api/translate-repo?repo=prism&locale=ja → { status, done, total, current, phase }
POST /api/translate-repo?repo=prism&locale=ja → SSE 流(启动任务)
DELETE /api/translate-repo?repo=prism&locale=ja → 取消(需要 x-cancel-token 请求头)