Translation
How scribe translate works: staleness hashing, Gemini, output validation, cost reporting, and review tools.
View as MarkdownScribe translates the English source into every other locale with an LLM (Gemini), and stores the results in SQLite. You never edit locale files; you edit English, re-run scribe translate, and commit the store.
What gets translated
Per document, exactly two things go to the translator:
- Frontmatter fields marked
field.translatable(), including fields nested inside arrays and objects. - The MDX body.
Everything else (field.structural(), relations, built-in fields) is copied from the English document at load time. With slugStrategy: "localized" the translator also produces a per-locale URL slug (ASCII kebab-case, transliterated for non-Latin locales).
A locale you have not translated yet is not a dead end at runtime: resolve() serves the closest available translation via the locale fallback chain (e.g. pt-BR falls back to pt, then to the default locale), so you can roll languages out incrementally.
Staleness tracking
Each stored translation records a SHA-256 hash of the English translatable content it was made from. scribe translate re-translates a page only when:
- no translation exists for that locale (missing), or
- the English translatable content changed since (stale).
Editing a structural field or a _redirects.json entry does not mark translations stale; those fields aren't translated.
Output validation
A translation is only persisted if it survives two checks:
- The returned MDX body must parse (after normalizing escape artifacts and JSX attribute quoting the model sometimes gets wrong).
- The returned frontmatter must re-validate against your full Zod schema.
Items that fail validation are automatically retried once at the end of the run, with the validation errors fed back to the model so it can correct them (batch runs retry as one extra batch job, still at the batch rate). Anything that fails again is listed in the final recap with its new error, and the command exits non-zero: bad model output never reaches the store, so it can never reach production.
Running it
export GEMINI_API_KEY=... # also read from .env / .env.local
scribe status # coverage per type and locale
scribe translate # interactive picker in a TTY
scribe translate --locale fr de # specific locales
scribe translate --preset active # a localePresets group from the config
scribe translate --type blog # one content type (comma-separated: --type blog,glossary)
scribe translate --slug my-post # one document
scribe translate --dry-run # show the worklist + est. tokens/cost, write nothing
scribe translate --force # re-translate even when hashes match
scribe translate --strategy missing-only # skip stale, only fill gaps
scribe translate --batch # force batch mode (the default)
scribe translate --direct # per-page API calls, immediate results
scribe translate --resume # pick up pending batch jobs, submit nothing new
scribe translate --concurrency 5 # parallel requests (direct mode only)
scribe translate --model gemini-3.1-pro
Run in a terminal, scribe translate shows a live progress UI with batch job status, per-item results, running token counts, and an estimated cost in USD. Thinking tokens are included in the reported usage, so the estimate matches what Google bills. Pass --no-progress (or run non-interactively, e.g. in CI) for plain line-by-line logging.
--dry-run prints the worklist and writes nothing, reporting an estimated token count and USD cost per item and in the totals, computed from the actual prompt and English payload size. Under --batch the estimate applies the batch discount, so it previews what the real run will cost.
Then commit .scribe/store.sqlite.
Batch mode and resuming
By default the whole worklist goes through the Gemini Batch API, which runs at 50% of the token cost of direct calls. Scribe plans every request upfront (one job per model, chunked for very large worklists), submits all jobs at once, then polls them together and stores each job's results the moment it completes. Batch jobs usually finish within minutes, but Google only guarantees completion within 24 hours, so the command waits rather than streaming results page by page.
Every job is recorded in the SQLite store before polling starts, together with a snapshot of the English source each item was submitted from. That makes runs safe to interrupt:
- Quit during polling (Ctrl+C) and nothing is lost.
scribe translate --resumechecks pending jobs, ingests the finished ones, and submits nothing new.- Running
scribe translatenormally also picks pending jobs back up first, and items already in flight are never submitted twice. - Editing an English page while its batch is in flight is fine: the result is stored against the snapshot it was translated from, then detected as stale on the next run.
Prefer immediate, page-by-page results at full price? Use --direct, which restores per-page API calls with --concurrency parallelism. In a terminal, the interactive picker offers the same choice. Transient API errors (429, 5xx, network drops) are retried automatically with exponential backoff in both modes.
Steering the translator
The built-in prompt is built around transcreation, not literal translation. The model writes as a native-speaker copywriter producing the localized edition: it recreates idioms and wordplay rather than rendering them word-for-word, and applies native typographic conventions (quotation marks, apostrophes, punctuation spacing). Your context, rules, and prompt layer on top of that framing.
Project-wide defaults and per-type overrides:
export default defineConfig({
translate: {
context: "MyBrand is a B2B SaaS. Never translate the brand name MyBrand.",
rules: ["Keep numbers and statistics accurate."],
},
types: [
defineContentType({
id: "blog",
// ...
translate: {
rules: [
"Preserve all MDX/JSX component tags, props, and URLs exactly.",
"Translate link anchor text; never change href paths.",
],
},
}),
],
});
| Option | Scope | Description |
|---|---|---|
context | project + type | Brand/domain context prepended to every request (project and type contexts are concatenated). |
rules | project + type | Extra rules appended to the defaults. |
prompt | project or type | Extra localization instructions, prepended before the target-locale directive (the locale name and code are always appended). |
defaultModel / model | project / type | Gemini model id. Default: gemini-3.1-pro (also overridable via the PROSE_GEMINI_MODEL env var or --model). |
Built-in rules (always applied) include: do not translate brand names unless they have a well-known local name in the target market, return MDX bodies with real line breaks (not escaped \n), and fix JSX attribute quoting when values contain double quotes.
Reviewing translations
scribe history blog my-post fr # EN snapshot timeline for one page
scribe studio # read-only web UI on :3600
Every translation links to a snapshot of the English source it was made from (stored in the same SQLite file), so you can audit when, from what, and with which model a page was translated. scribe history prints that timeline; the studio shows per-locale coverage, the current missing/stale worklist, and per-document detail with the snapshot alongside the stored translation.
Locale presets
For incremental rollouts, name locale groups in the config and target them with --preset:
localePresets: {
active: ["fr", "es", "ja"],
ultraLight: ["fr"],
}
scribe translate --preset ultraLight