Skip to content

Translation System

This site is published in 17 languages. Not by a team of translators, but by a Python script, a Claude Opus model, and a file structure designed so everything slots into place. Here’s how the whole thing works.

The documentation lives in src/content/docs/. English is the root — every other language mirrors it exactly:

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)

Every translated file is a structural mirror of its English source. Same filename, same subdirectory path, same frontmatter keys. The only difference is the prose is in another language.

Starlight (the documentation framework) relies on this symmetry. When a user switches languages, Starlight swaps /docs/getting-started/ for /fr/docs/getting-started/ — same path, different locale prefix. If the French file doesn’t exist at exactly fr/getting-started.md, the switcher breaks or falls back to English silently.

Languages are ordered by global chess-playing population, based on data from Lichess, Chess.com, and FIDE registrations. English comes first as the source language; everything else follows the chess rankings:

RankCodeLanguageStyle
1enEnglishSource of truth
2esSpanishStandard formal
3hiHindiDevanagari script
4ruRussianStandard formal
5deGermanStandard formal
6frFrenchStandard formal
7ptPortugueseEuropean Portuguese
11plPolishStandard formal
12itItalianStandard formal
13ukUkrainianStandard formal
14trTurkishStandard formal
17koKorean합니다/습니다 form
18zhChinese (Simplified)Simplified characters
zh-twChinese (Traditional)Traditional characters
23nbNorwegian BokmålStandard Bokmål
beBelarusianStandard Belarusian
34jaJapaneseです/ます form

The “style” column matters. Japanese and Korean have formal register choices that affect every sentence. The translation prompt includes these instructions so the model produces natural, polished prose — not stiff machine output.

This ordering also controls the language dropdown in the site header. The most-spoken chess languages appear first, so users are more likely to find theirs without scrolling.

All documentation starts as English markdown in src/content/docs/. Frontmatter has a title and description:

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

A Python script (scripts/translate-docs.py) reads every English source file, sends it to the Claude API, and writes the translated markdown:

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

The script takes about 60–70 minutes to translate all 28 source files into all 16 target languages (448 files total). It runs 5 parallel API calls to stay within rate limits.

For a single new language, it’s about 4 minutes.

Terminal window
pnpm build

Astro reads the source markdown, renders it through Starlight’s templates, and outputs static HTML into dist/. The build takes about 30 seconds for all ~500 pages.

Terminal window
pnpm run deploy

Pushes the built site to Cloudflare Workers.

The translation script is intentionally simple — about 300 lines of Python. Here’s the flow:

┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ 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

The prompt tells Claude exactly what to translate and what to leave alone:

  • Translate: prose, headings, table labels, frontmatter title and description
  • Keep in English: code blocks, inline code, command examples, file paths, URLs, product names, people’s names, ASCII diagrams

This separation is critical. A command like pnpm build must stay pnpm build in every language. A product name like “En Parlant~” or “Stockfish” stays untranslated. But “Getting Started” becomes “はじめに” in Japanese and “Начало работы” in Russian.

Claude Opus 4.6 produces noticeably better translations than faster models. The difference shows up in:

  • Natural phrasing — Opus writes like a native speaker, not a translator. It restructures sentences when the English word order would sound awkward in the target language.
  • Technical accuracy — Chess terminology, TTS jargon, and software concepts are translated using the correct domain-specific terms.
  • Consistency — Formal register stays consistent throughout. Japanese uses です/ます form everywhere, not switching between casual and polite mid-paragraph.
  • MDX handling — Opus correctly preserves JSX component tags (<Card>, <CardGrid>) and import statements in .mdx files without mangling them.

The cost difference is real — about $28 for a full site translation with Opus vs $5 with Sonnet — but for 448 files that users will actually read, the quality is worth it.

This is the part that took the longest to get right.

Starlight detects a page’s language by checking the first segment of its content slug. When it sees fr/docs/getting-started, it knows that’s French because fr is the first segment. But the initial implementation produced slugs like docs/fr/getting-started — locale buried under docs/. Starlight saw docs as the first segment, treated everything as English, and generated 7,000+ duplicate pages instead of ~500.

A custom generateId function in src/content.config.ts controls how file paths become content slugs:

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}`;
}

This puts the locale prefix before docs/:

File pathSlugURL
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/

English uses defaultLocale: "root", which means no prefix — it lives at /docs/ directly, not /en/docs/.

A localeKeys array in the same file must list every non-English locale. If a locale exists in Astro’s config but not in this array, its translated content gets treated as English content — the language switcher breaks and page counts explode.

Astro caches slug mappings in .astro/data-store.json. After any locale configuration change, this file must be deleted before rebuilding, or the build succeeds with stale (wrong) routing.

The sidebar is defined in astro.config.mjs. Items that point to a page (slug property) automatically use the translated page’s frontmatter title — no manual translation needed:

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

But group labels and external links need explicit translations:

{
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" },
// ...
],
}

Seven sidebar elements need manual translations: Welcome, Features, App Menus, Setup Guides, Under the Hood, Credits, and Accessibility. Adding a new language means adding one entry to each of these seven blocks.

En Parlant~ links to this documentation from its Help menu. The link is locale-aware — if you’re using the app in French, it opens the French docs:

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]);

Every language available in the app has a matching translation on the site. The mapping is automatic — fr_FR maps to /fr/docs/, ja_JP maps to /ja/docs/. The only special case is Traditional Chinese: zh_TW maps to /zh-tw/docs/ (hyphenated).

The hard parts are done:

  1. The routing architecture is solved. The generateId function, localeKeys array, and defaultLocale: "root" config work together so Starlight generates the correct URL structure. This was the biggest pain point — it took tracing through 6+ source files in Starlight and Astro to find and fix.

  2. The translation script handles everything. Full site retranslation, single language addition, individual file updates — all the same script with different flags. It retries on rate limits, parallelizes across workers, and reports errors clearly.

  3. Adding a new language is four config edits and one command. Add the locale to astro.config.mjs, content.config.ts, and translate-docs.py, add sidebar translations, run the script. About 10 minutes of work plus 4 minutes of translation time.

  4. Updating content is even simpler. Edit the English source, run the script with --files and --overwrite for just the changed files, rebuild. Or for small prose fixes, edit the translated files directly.

  5. The whole pipeline is captured in a skill. Running /translate_docs walks through the entire process — which mode to use, what flags to pass, pre-flight checks, post-translation verification. No institutional knowledge required.

The total translation of 448 files across 16 languages cost about $28 and took about 70 minutes. A single new language costs about $1.70. A single file retranslated across all languages costs about $1. These are the ongoing costs of keeping documentation current in 17 languages.