国际化

带 URL 前缀的多语言文档,外加本地化的站点外壳。

国际化#

Vellum 原生支持多语言文档。语言在站点级别配置,对每个仓库都生效。

站点级语言配置#

json
{
  "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-startedzh 版本。

每个仓库的内容布局#

对于 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 只填补作者没翻译的空缺。

手工新增:

  1. vellum.config.jsonsite.locales 里新增一项。

  2. src/shared/i18n.ts 里新增一份字典:

ts
const fr: MessageMap = {
  "ui.search": "Recherche",
  "ui.search.placeholder": "Rechercher dans la documentation",
  // ...
};

const dictionaries: Record<string, MessageMap> = { en, zh, fr };
  1. 把你的内容翻译到 docs/fr/ 里。

任何在非英语字典中缺失的 key 都会回退到英语字符串——所以可以先发 一份部分翻译,也不会让 UI 坏掉。

机器翻译#

配置 site.translate 之后,worker 会按需翻译你指定的任何语言—— 页面 markdown、侧边栏标签、仓库 nav、frontmatter 字段、UI 字典以及 仓库展示文案,全部交给所配置的 LLM provider。翻译结果存在一个 D1 数据库里,每次 webhook 推送会清掉对应仓库的行,每小时一次的 cron 触发器在后台刷新过期行。

json
{
  "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。
baseUrlOpenAI 兼容端点的 base URL(OpenRouter、Together 等)。VELLUM_AI_BASE_URL 优先。
targetsBCP-47 代码数组,或字面量 "all"(见下)。
refreshDays缓存行的新鲜期(天)。默认 5 天。cron 会删除比它更老的行,下次访问时懒翻译。
concurrency预留字段:每次 cron tick 的并发翻译调用上限。
batchSize每次 cron tick 删除的行数上限。默认 50。

凭证沿用 aiSummary / aiChat 用的同一份 VELLUM_AI_API_KEY worker secret。翻译特有的只有 D1 绑定(见下面的 D1 配置)。

目标语言#

targets 支持三种写法:

jsonc
// 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-BRPortuguês (Brasil))。

  • 裸代码 → BCP-47 扩展<html lang> 和 hreflang 用的)——CLDR 的 likely-subtags 数据,通过 Intl.Locale.prototype.maximize()zhzh-CN ptpt-BR)。

  • 翻译模型的提示词——Intl.DisplayNames(英文, languageDisplay: "dialect"),所以提示词读作 “Chinese (Simplified, China) (zh-CN)”。

D1 配置#

worker 把缓存的翻译存进一个 D1 数据库。先创建:

bash
wrangler d1 create vellum-translations

命令会打印一个 UUID。把它粘进 wrangler.jsonc

jsonc
"d1_databases": [
  {
    "binding": "VELLUM_TRANSLATION_DB",
    "database_name": "vellum-translations",
    "database_id": "00000000-0000-0000-0000-000000000000",
    "migrations_dir": "migrations"
  }
]

应用迁移:

bash
wrangler d1 migrations apply vellum-translations --remote

绑定在运行时是可选的——如果缺失,翻译层会变成 no-op,仅在 targets 里列出的语言会回退到默认语言的源文件。本地开发时不想 provision D1 也能渲染页面,挺方便。

翻译范围#

类型
缓存 key
触发时机
page{repoSlug}@{branch}:{pagePath}一个 MT 语言请求找不到本地化源文件时——默认语言的 markdown 会被翻译。
sidebar{repoSlug}@{branch}侧边栏加载器为 MT 语言构建树时——所有 .text 字段批量进入同一次调用。
repo-nav{repoSlug}@{branch}同理,针对来自 vellum.json#navthemeConfig.nav 的仓库顶部导航。
frontmatter{repoSlug}@{branch}:{pagePath}内嵌在 page 调用中——提示词要求模型翻译 titledescription、hero / features 字段。
uiui:v1src/shared/i18n.ts 的静态 UI 字典。每种语言一次调用,与所访问的页面无关。
configsite:v1vellum.config.json 的文案:tagline、每个仓库的 displayName / description,以及站点级 nav[].text

site.titlesite.footer 故意永远不被翻译——它们属于品牌层, 项目维护者要求保留原样。

Markdown 保真#

page 翻译的提示词对语法非常严格:

  • 代码块、行内 code 和 HTML 标签全部原样穿透——绝不翻译标识符、 函数名、命令参数,以及任何反引号 / 代码块里的内容。

  • 链接和图片的 URL 不动;只翻译可见的标签 / alt 文本。

  • YAML frontmatter 的分隔符保留;frontmatter 内部只翻译 title descriptiontaglinetextnamedetailslinkText 值。

  • 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.concurrencybatchSize 调小,或换更高额度的套餐。

路由还会在翻译器返回未变源文本时打印一条 [vellum][router] MT no-op for …——这正是横幅显示「翻译暂未就绪」 warning 状态的信号。

frontmatter 与 i18n#

frontmatter 的 titledescription 是内容,不是外壳—— 在每种语言各自的文件副本里翻译它们。英语的 frontmatter 永远不会 泄漏到已翻译的页面。

markdown 中的本地化链接#

写成相对文档根的 markdown 链接(例如 [Getting started](./getting-started))会被 worker 改写, 自动带上当前语言的前缀。所以在 docs/zh/index.md 里,这条链接 会解析为 /zh/repo/getting-started,而不是 /repo/getting-started

跨仓库的 @slug/ 链接行为一致:

md
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 和侧边栏文件始终优先翻译,确保导航结构在各页面翻译之前 就绪。

工作原理#

  1. 客户端为每个仓库依次发送 POST /api/translate-repo?repo={slug}&locale={code}

  2. 服务端枚举仓库源码树,筛选 .md 文件,按优先级排序——根 index 和嵌套 index 页面排在最前面。

  3. 侧边栏标签通过 loadSidebar() 先于页面内容完成翻译。

  4. 每页顺序翻译——服务端调用与惰性请求路径相同的 translate() 函数,结果缓存到 D1,后续访问立即返回缓存。

  5. 进度以 Server-Sent Events 流式推送回客户端(start progresscompletecancellederror)。

进度条#

对话框显示一个 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 请求头)