Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

Translation

Translate your docs and catch stale translations

Translate your docs with a coding agent, then keep them current with a CI check that fails when a source page changes.

By 8 min read

By the end of this guide, a small English docs site also reads in German. A coding agent writes the German pages, a person who reads German reviews them, and a committed ledger records which English revision each one came from. A check in CI then fails whenever an English page changes and its German copy doesn't follow, so translations can't quietly fall behind.

The example is three pages and one folder, but the loop is the same for a site of hundreds of pages in several languages.

Before you start

Pick one audience and one language first. A second language costs little once the loop works, but every language needs someone who can read it, so start with the one your readers ask for most.

Blume translates pages that are Markdown or MDX files in your repository. Pages from a remote or CMS-backed source, like Notion, are skipped: there is no local file to write their translation to. Blume runs Claude Code or Codex to translate the text, but holds no API keys and calls no model itself, so the agent's usage bills to your own account with its provider.

This guide starts from a project with three English pages:

docs/
  index.mdx
  guides/
    meta.ts
    install.mdx
    configure.mdx

The meta.ts gives the Guides folder its sidebar title and page order. If you're starting fresh, npx blume init creates the docs/ folder and config.

Turn on i18n

Add an i18n block to your config with the default locale and every locale you serve:

import { defineConfig } from "blume";

export default defineConfig({
  title: "Acme",
  i18n: {
    defaultLocale: "en",
    locales: [
      { code: "en", label: "English" },
      {
        code: "de",
        label: "Deutsch",
        style: "Informal du-form. Keep the product name Acme and the terms API key and webhook in English.",
      },
    ],
  },
});

Each locale has a code, used in its URLs, and a label, shown in the language switcher. A right-to-left language also takes dir: "rtl".

style is freeform guidance that rides along in every translation prompt for that locale. Use it to settle the choices an agent would otherwise make page by page: formal or informal address, dialect, and the terms that should stay in English. Without it, the first translation decides, and later pages may decide differently. Blume's own docs use this pattern, with German set to the informal du-form. There's no separate glossary option, so your terminology rules go in style too.

English stays at the root of docs/ and keeps its URLs. German pages live in docs/de/ and publish under /de/. Blume ships its own interface strings in German, like "On this page" and the search button, so you only translate your content.

Translate with an agent

From the project root, run:

npx blume translate --claude

To use Codex instead, swap --claude for --codex. Blume works out which pages are missing or out of date in each locale, runs the agent headlessly on each one with its file, shell, and web tools turned off, checks the reply, and writes the file itself. It runs four files at a time by default, and --locale de limits a run to one language when you serve several.

When it finishes, the German files sit beside the English ones:

docs/
  index.mdx
  guides/
    meta.ts
    install.mdx
    configure.mdx
  de/
    index.mdx
    guides/
      meta.ts
      install.mdx
      configure.mdx
blume.translations.json

The German meta.ts translates the folder's title and copies everything else, like the page order, as it is. Blume never trusts the agent with structure: it rebuilds each page's frontmatter from the English file and only takes the translated title, description, sidebar label and badge, and SEO title and description. A reply with a different number of code blocks, an empty body, or frontmatter that doesn't parse is rejected, and nothing is written for that file.

Review the translation

Structural checks catch a broken file, not a bad sentence. Before you merge, have your reviewer read every page, and check these yourself:

  • Prose. Register, dialect, and your terms from style should hold on every page.
  • Code. Code blocks and inline code are copied, never translated, and that includes comments inside code blocks. Commands and config should be identical to the English page.
  • Headings. Each translated heading ends with a marker like [#install]. It pins the heading to the English anchor, so a link to #install works in every language. Leave the markers in.
  • Links. Link targets aren't translated. Blume moves each root-relative link into the reader's locale when it renders, so /guides/install on a German page goes to /de/guides/install.
  • Navigation. Sidebar labels come from each page's title and the folder's meta.ts. Header tab labels live in blume.config.ts, not in content, so give each one a per-locale map there, like label: { en: "Guides", de: "Anleitungen" }. Header links and footer links take the same map.

Then check every link and anchor in both languages, and read the site:

npx blume validate
npx blume dev

Open http://localhost:4321/de, click through the German sidebar, and use the language switcher in the header to flip each page between languages.

Commit the ledger

blume.translations.json records, for each English file and each locale, a short hash of the English file at the moment its translation was written:

{
  "files": {
    "docs/guides/configure.mdx": {
      "de": "5b0e71c9d4a3f812"
    },
    "docs/guides/install.mdx": {
      "de": "a41f96e02c7d3b58"
    },
    "docs/guides/meta.ts": {
      "de": "e8c2d7a95f1b0634"
    },
    "docs/index.mdx": {
      "de": "0d93b4f6a82e17c5"
    }
  },
  "version": 1
}

Commit it with the translations. It's how the next run knows which pages are already current, and it's what the CI check reads. It tracks freshness, not quality: a hash match means the German page was translated from the current English file, not that the German is good. That's what the review is for.

git add docs/de blume.translations.json
git commit -m "Add German translations"

Change a page and catch the drift

Now edit one paragraph of docs/guides/install.mdx in English. The file's hash no longer matches its ledger entry, and the check sees it:

npx blume translate --check
  ✖ docs/guides/install.mdx → de stale

  1 stale · 3 up to date

--check runs no agent and writes nothing. It lists every missing or stale translation and exits with code 1 when there is one. The hash covers the whole file, frontmatter included, so any edit counts, even a typo fix. Add --json for a machine-readable report on stdout. Its translate key groups the drift by locale:

{
  "locales": {
    "de": {
      "missing": [],
      "stale": ["docs/guides/install.mdx"],
      "untracked": []
    }
  },
  "upToDate": 3
}

The report also carries diagnostics and summary, in the same shape as blume validate --json. Each stale page is a BLUME_TRANSLATE_STALE error, and each missing one a BLUME_TRANSLATE_MISSING error.

Run the translation again:

npx blume translate --claude

It retranslates only install.mdx. The agent sees the existing German page and is told to match its wording and change only what the English edit requires, so the diff should be about one paragraph, not a rewritten page. Review that paragraph, then commit the page and the updated ledger together.

Bring in translations you already have

If some German pages already exist, written by hand before you started, the first translate run adopts them. A translation with no ledger entry is stamped as current and never overwritten, because Blume can't tell which English revision a person translated from, and it won't throw away their work to guess.

Until that run, --check lists those pages as untracked and still passes. They don't fail the gate, because nothing says they're out of date. That cuts both ways: a hand-written page that was already behind the English is adopted as current too. Review those pages before your first run, and delete any that are too far behind so they're retranslated as missing. --force retranslates everything, hand-written pages included.

Add the check to CI

The check is read-only and needs no agent or API key, so it runs anywhere your docs install. On GitHub Actions:

name: Translations

on:
  pull_request:

permissions:
  contents: read

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npx blume translate --check

Decide where the gate belongs. Run on every pull request, it asks each author to translate their own change. If translations happen in one batch before each release, run it only on the release branch instead, the way Blume's own docs run it only on their release pull request.

Ship it

Build and deploy as you normally would. If you don't have a host yet, the GitHub Pages guide walks through one. Once it's live, open a German page's source and check what search engines see:

  • <html lang="de"> on every German page.
  • A hreflang alternate link for each real translation of the page, plus an x-default pointing at the English page.
  • A canonical URL in the page's own language.

Pages you haven't translated yet still work at their German URLs: Blume renders the English content there, points its canonical at the English page, and leaves it out of hreflang and the search index, so it doesn't compete with the original. The language switcher marks those pages as not translated.

Troubleshooting

There are no hreflang links

hreflang alternates need your site's absolute URL. Blume detects it on Vercel, Netlify, and Cloudflare Pages. On any other host, set deployment.site in your config.

German pages name the English page as canonical

The English page sets seo.canonical in its frontmatter. blume translate copies every frontmatter key it doesn't translate, so the German page inherits that URL. Remove seo.canonical from the English page unless it really points elsewhere, then retranslate. The hreflang guide walks through the fix and the audit that catches it.

The agent isn't found

Blume runs the agent's own CLI, claude or codex, and stops if it isn't on your PATH. The error names the install command. Sign in to the agent once on its own before you translate.

A page failed to translate

A reply that fails the structural checks writes nothing, and the run exits with code 1 after finishing the rest. Everything that succeeded is already in the ledger, so running the same command again retries only the failures. A very long page can hit the ten-minute limit per file: raise it with --timeout, in seconds.

The ledger has merge conflicts

If two branches both translated pages, blume.translations.json can conflict. Blume refuses to read a ledger with conflict markers and reports BLUME_TRANSLATE_LEDGER_CONFLICT, because treating it as empty would mark every translation current. Resolve the conflict, keeping the entries from both sides, then run the check again.

An anchor link breaks on a hand-written page

Translated headings get their own anchors unless they're pinned, so #install can stop matching. blume validate reports it. Add the English anchor to the end of the translated heading, like ## Installation [#install].

Next step

Translate your docs

Add a locale to your config, then translate your docs into it.

npx blume translate --claude
Read the translate docs

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init