Internationalisation
Multi-locale docs with URL prefixes and translated site chrome.
Internationalisation#
Vellum has first-class support for multi-locale documentation. Locales are configured at the site level and apply across every repo.
Site-level locale config#
{
"site": {
"defaultLocale": "en",
"locales": [
{ "code": "en", "label": "English", "prefix": "" },
{ "code": "zh", "label": "中文", "prefix": "zh" },
{ "code": "ja", "label": "日本語", "prefix": "ja" }
]
}
}
code is the locale code (used as cache key suffix and i18n lookup).
label is shown in the language picker.
prefix is the URL segment. An empty string means this locale lives at the root of each repo — typically used for the default locale.
A reader on /zh/repo/getting-started is reading the zh version of getting-started in repo.
Per-repo content layout#
For each locale with a non-empty prefix, the worker looks for content under {docsRoot}/{prefix}/. The default locale’s pages live directly under docsRoot.
docs/
index.md # en (default locale)
getting-started.md
zh/
index.md # zh
getting-started.md
ja/
index.md
getting-started.md
You don’t have to translate every page. A reader on /zh/repo/missing-page gets a 404, not a fallback to the English page — keeping the missing-translation cost obvious to the docs maintainer.
Routing#
The router (src/worker/router.ts) resolves URLs as:
[/{localePrefix}]/{repoSlug}[/{pagePath}]
It detects the locale by matching the second segment against the configured prefixes. The page path strips that segment, so the same pagePath (“getting-started”) looks up docs/getting-started.md for en and docs/zh/getting-started.md for zh.
The language picker#
The NavBar shows a globe icon when more than one locale is configured. Clicking switches to the same page in the chosen locale — preserving the repo, page path, and any hash.
If the user picks a locale that doesn’t have the current page translated, they’ll land on a 404 (with the localised 404 chrome).
Locale-aware site chrome#
src/shared/i18n.ts holds the dictionary for everything the worker controls — search dialog labels, “Previous” / “Next” page nav, callout default titles, the 404 page, etc. Currently ships English (en) and Simplified Chinese (zh) hand-curated dictionaries.
Two paths to add a third language: ship a hand-curated dictionary, or turn on machine translation (next section). Hand-curation wins when both are present — the worker only fills gaps the author didn’t.
To hand-curate:
Add an entry to site.locales in vellum.config.json.
Add a dictionary in 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 };
Translate your content into docs/fr/.
Any key missing from a non-English dictionary falls back to the English string — so you can ship a partial translation without breaking the UI.
Machine translation#
Configure site.translate and the worker will fill in any locale you ask for by running the source markdown — plus sidebar labels, repo nav, frontmatter strings, UI dictionary entries, and repo display strings — through an LLM provider. Translations live in a D1 database, get busted on push via webhook, and refresh in the background on an hourly cron tick.
{
"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
}
}
}
A locale declared in site.locales and backed by a docs/{code}/ subdir keeps its hand-curated content. Machine translation only kicks in for locales the author didn’t hand-curate — pages that resolve to no source file fall back to the default-locale source and run through the translator.
Configuration#
| provider | ✓ | "workers-ai", "openai-compatible", or "anthropic". Same matrix as aiSummary / aiChat. |
| model | Model id. Defaults: Llama 3.3 70B Fast / gpt-4o-mini / Haiku 4.5. | |
| baseUrl | OpenAI-compatible base URL (OpenRouter, Together, …). VELLUM_AI_BASE_URL overrides. | |
| targets | ✓ | Array of BCP-47 codes OR the sentinel "all" (see below). |
| refreshDays | How many days a cached translation is considered fresh. Defaults to 5. The cron deletes rows older than this. | |
| concurrency | Reserved for future use (in-flight call cap per refresh tick). | |
| batchSize | Rows pruned per cron tick. Defaults to 50. |
Credentials reuse the same VELLUM_AI_API_KEY worker secret as aiSummary / aiChat. There’s nothing translation-specific to set besides the D1 binding (D1 setup below).
Target locales#
Three shapes for targets:
// 1. Explicit BCP-47 codes — including region-coded variants where it
// matters. Region codes give the translator regional vocabulary
// ("Brazilian Portuguese" vs "European Portuguese").
"targets": ["zh-CN", "zh-TW", "pt-BR", "es-MX", "ja", "ko"]
// 2. Bare ISO 639-1 codes — language only, no region.
"targets": ["es", "fr", "de", "ja", "ko"]
// 3. The sentinel "all" — expands to every code in the IANA ISO 639-1
// registry (~180 codes), sourced via the iso-639-1 npm package.
"targets": "all"
Each resolved code is auto-merged into site.locales with machineTranslated: true, a label drawn from iso-639-1’s native-name table (bare codes) or Intl.DisplayNames (region-coded), and a URL prefix equal to the code — so "zh-CN" produces /zh-CN/... URLs and "es" produces /es/... URLs. Codes that already appear in site.locales are skipped so author-declared locales keep their hand-curated label and prefix.
The whole language layer is driven by externally-curated sources, not hardcoded tables in the worker:
The “all” code set — IANA ISO 639-1, via the iso-639-1 npm package.
Native labels — iso-639-1.getNativeName() for bare codes (ja → 日本語), Intl.DisplayNames for region-coded codes (pt-BR → Português (Brasil)).
Bare → BCP-47 expansion for <html lang> and hreflang — CLDR’s likely-subtags data via Intl.Locale.prototype.maximize() (zh → zh-CN, pt → pt-BR).
Translator model prompt — Intl.DisplayNames in English with languageDisplay: "dialect", so the prompt reads “Chinese (Simplified, China) (zh-CN)”.
D1 setup#
The worker stores cached translations in a D1 database. Create one and point the binding at it:
wrangler d1 create vellum-translations
That prints a UUID. Paste it into wrangler.jsonc:
"d1_databases": [
{
"binding": "VELLUM_TRANSLATION_DB",
"database_name": "vellum-translations",
"database_id": "00000000-0000-0000-0000-000000000000",
"migrations_dir": "migrations"
}
]
Apply the migrations:
wrangler d1 migrations apply vellum-translations --remote
The binding is optional at runtime — if it’s missing, the translation layer no-ops and locales listed only in targets fall back to the default-locale source. Useful for local dev where you’d rather not provision D1 just to render the page.
What gets translated#
| page | {repoSlug}@{branch}:{pagePath} | When a request for an MT locale finds no localized source file. The default-locale markdown is translated. |
| sidebar | {repoSlug}@{branch} | When the sidebar loader builds tree for an MT locale; every .text field is batched into one call. |
| repo-nav | {repoSlug}@{branch} | Same idea, for the per-repo top nav from vellum.json#nav or themeConfig.nav. |
| frontmatter | {repoSlug}@{branch}:{pagePath} | Built into the page call — the prompt tells the model to translate title, description, hero / features. |
| ui | ui:v1 | The static UI dictionary from src/shared/i18n.ts. One call per locale, regardless of which page is loaded. |
| config | site:v1 | vellum.config.json text: tagline, every repo’s displayName / description, and site-level nav[].text. |
site.title and site.footer are intentionally never translated — they are brand-level and the project owner asked them to stay verbatim.
Markdown preservation#
The page translation prompt is strict about syntax:
All code fences, inline code, and HTML tags pass through verbatim — no translation of identifiers, function names, command flags, or anything inside backticks.
Link and image URLs stay untouched; only the visible label / alt text is translated.
YAML frontmatter delimiters survive; inside the frontmatter, only the values of title, description, tagline, text, name, details, linkText are translated.
VitePress containers (::: tip, [!INCLUDE], [!NOTE], …), xref tokens (@xref:uid, cross-repo @slug/...), and OPS directives are preserved.
Refresh#
Two trigger paths keep the cache aligned with source content:
Webhook, on push: webhook.ts calls invalidateForRepo() after busting the HTML / sidebar / tree caches, so the next read for any MT locale re-translates against the fresh source.
Cron, hourly at :00 (declared in wrangler.jsonc#triggers.crons): prunes rows whose refreshed_at is older than refreshDays. Pruned rows lazily re-translate on the next request for that page; cold paths pay no model call.
The cron handler caps deletions per tick at batchSize (default 50) so a huge table doesn’t blow the Worker CPU budget. Adjust if you have a firehose of pages × locales and want a faster background refresh.
Cost shape#
Translations are lazy — they only run when a reader actually visits that locale’s URL. A targets: "all" site with 180 codes and 100 pages doesn’t pre-translate 18,000 page bodies; it translates each on demand, caches it in D1, and serves the cache on subsequent reads. The cron prunes rows nobody touched in refreshDays days, which keeps the table size bounded by actual readership rather than configured target count.
The translation banner#
Every page rendered through the MT pipeline gets a status banner at the top of the article — <MachineTranslatedBanner />, mounted by the doc layout, the home layout, and the MS Learn layout so it shows up regardless of which page kind the reader landed on.
Two states:
Translated (info MessageBar, translate icon). The model produced a translation and the reader is looking at it. The banner reads “Translating, you can view this page in” followed by an inline list of other locales the same page is available in.
Attempted but unavailable (warning MessageBar, warning icon). The router triggered MT for this request but the provider call no-op’d (no VELLUM_AI_API_KEY set, network error, rate limit, …). The reader is seeing the un-translated source under their requested URL. The banner reads “Translation not ready yet” with a one-line note inviting the reader to try again later or pick another language.
The inline locale list is filtered by page.meta.translatedLocales, populated by the router from D1 — so the banner only advertises locales the reader can actually navigate to right now (default + hand-curated + MT locales with a cached row). When more than ~6 alternatives qualify, the inline list truncates and surfaces an “All languages” link to the dedicated languages page below.
The languages page#
Route: /{localePrefix}/languages (and /languages for the default locale). Full-page locale chooser — used when translate.targets: "all" makes the NavBar dropdown unwieldy. The picker truncates to 10 entries and adds a “More languages…” link to this page once a site has more.
Locales are grouped by continent via Intl.Locale.maximize().region → countries-list (Asia → Europe → Africa → North America → South America → Oceania → Antarctica → Other for codes without country data). Inside each continent they’re sorted by native label. A search input at the top filters by label, code, or URL prefix.
Each tile is a FluentUI Card showing the native name, the BCP47 code, and a “Machine-translated” badge for synthesized locales. The reader’s current locale gets a brand-tinted background and is non-interactive. Pass ?page=<repo-rooted-path> in the URL and tile clicks swap locale while keeping the page path (so the banner’s “All languages” link lands the reader on the same page in their chosen locale).
Debugging translation#
The translator logs every call to the worker console under the [vellum][translate] tag. Run wrangler dev (or wrangler tail against a deployed worker) and a request to a translated URL produces lines like:
[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
When something goes wrong the same tag carries the reason. The most common ones:
skip: site.translate not configured — vellum.config.json has no site.translate block.
skip: locale is the default / skip: locale is hand-curated, not an MT target — the request was for a locale that doesn’t need MT.
no D1 binding (VELLUM_TRANSLATION_DB); running uncached provider call — the binding wasn’t declared in wrangler.jsonc, so every request pays for a fresh model call. Fine in dev, bad in prod.
provider failed: VELLUM_AI_API_KEY is not set. — the API key secret is missing. Set it with wrangler secret put VELLUM_AI_API_KEY and redeploy.
provider failed: Upstream 429: … — the provider rate-limited. Reduce translate.concurrency or batchSize, or switch to a tier with higher limits.
The router emits a complementary [vellum][router] MT no-op for … line whenever it falls back to serving the source unchanged because the translator returned the input verbatim — that’s the signal that paired with the warning banner the reader sees.
Frontmatter and i18n#
Frontmatter title and description are content, not chrome — translate them in each locale’s copy of the file. The English frontmatter never leaks to a translated page.
Locale-aware links in markdown#
Markdown links written as docs-root-relative paths (e.g. [Getting started](./getting-started)) are rewritten by the worker to include the current locale prefix. So in docs/zh/index.md, that link resolves to /zh/repo/getting-started, not /repo/getting-started.
Cross-repo @slug/ links work the same way:
See [the Prism guide](@prism/getting-started) for OAuth setup.
renders as a link to the same locale of the target repo (/zh/prism/getting-started when read on a zh page).
The landing page#
If homepageRepo is a local source, you can localise its landing page by adding local-docs/{homepage-slug}/{prefix}/index.md. The bundled config does exactly that — see local-docs/homepage/zh/index.md for the Chinese homepage.
Per-page translation status#
Every page computes which locales it is available in and propagates that information in the bootstrap payload via page.meta.translatedLocales. This powers three UI surfaces:
Languages page badges. Each locale card shows one of four states: Current (brand tint), Source (informative outline), Machine-translated (brand outline), or Not translated yet (subtle outline). The state is derived from whether the referring page has a cached translation row in D1 for that locale.
Locale picker filtering. The NavBar dropdown only shows locales that have an actual translation for the current page. Human-translated locales appear above machine-translated ones.
Banner locale chips. The inline “view this page in…” list in the translation banner is filtered to locales the reader can actually navigate to right now.
Translate full repo#
The languages page includes a Translate all button on every machine-translated locale card. Clicking it opens a dialog that translates every page in every configured repo into the selected language. Index and sidebar files are always translated first so the navigation structure is ready before individual pages.
How it works#
The client sends POST /api/translate-repo?repo={slug}&locale={code} for each repo, one at a time.
The server enumerates the repo’s source tree, filters to .md files, and sorts them with root index and nested index pages first.
Sidebar labels are translated via loadSidebar() before page content.
Each page is translated sequentially — the server calls the same translate() function the lazy per-request path uses, so results are cached in D1 and served immediately on subsequent reads.
Progress is streamed back as Server-Sent Events (start, progress, complete, cancelled, error).
Progress bar#
The dialog shows a FluentUI ProgressBar with:
Current percentage (0–100 %).
The page path currently being translated.
A phase indicator (Translating sidebar & index… vs Translating page:).
A counter: 12 / 47 pages.
When multiple repos are configured, the header shows which repo is active and its position in the queue (prism (1/3)).
Cancel authorisation#
Only the browser that started the translation can cancel it.
On job start the server generates a random cancel token and returns it in the SSE start event. The client stores the token in localStorage.
The Cancel button is only rendered when the current browser holds the matching token. Other viewers see a Close button that dismisses the dialog without stopping the job.
Cancel sends DELETE /api/translate-repo?repo={slug}&locale={code} with the token in an x-cancel-token header. The server verifies the token against the D1 job row and returns 403 Unauthorized on mismatch.
The translation loop checks D1 for the cancelled status before each page and stops early when it finds it.
Floating progress banner#
Translation progress is visible from any page, not just the languages page. A fixed-position MessageBar in the bottom-right corner appears whenever localStorage contains an active translation job.
The banner:
Polls localStorage every second and the server every 3 seconds.
Shows the locale label, percentage, page count, and current file.
Clicking it opens the full TranslateRepoDialog.
After completion or cancellation, a dismiss button clears the banner.
The banner hides automatically on the languages page when the dialog is already open, so the two don’t overlap.
Server-side job persistence#
Job progress is persisted in D1 using the existing translations table with kind = "translate-job". This lets any browser tab — or even a different device — poll the status endpoint and see live progress:
GET /api/translate-repo?repo=prism&locale=ja → { status, done, total, current, phase }
POST /api/translate-repo?repo=prism&locale=ja → SSE stream (starts job)
DELETE /api/translate-repo?repo=prism&locale=ja → cancel (requires x-cancel-token header)