Keep your translations up to date automatically. Edit your base language file — or your website's pages — run one command, and every other locale catches up: placeholders, HTML, and formatting intact.
$ vhtranslator # resource files (JSON / .resx)
Processing fr.json (fr) - 3 changed, 1 missing entries...
✓ fr.json: 4 translated/updated.
Done.
$ vhtranslator site # static website (Jekyll-style)
✓ fr/free-vpn/index.html: translated.
✓ de/free-vpn/index.html: translated.
Done.
$ vhtranslator docs # standalone markdown documents
✓ posts/fr/what-is-split-tunneling.md: translated.
✓ posts/de/what-is-split-tunneling.md: translated.
Done.The tool has three modes that share the same engines, configuration file, options, and workflow:
| Mode | Command | Translates | Safety guarantee |
|---|---|---|---|
| Resource files | vhtranslator |
JSON / Microsoft .resx values |
Placeholders and HTML tags preserved |
| Static website | vhtranslator site |
Whole Jekyll-style pages into per-language folders | Every page structurally verified; unverifiable pages are never written |
| Markdown documents | vhtranslator docs |
Standalone .md files (blog posts, app content) into sibling per-language folders |
Source and translation are rendered and their element trees compared; unverifiable documents are never written |
Backed by Google Gemini, OpenAI, or Grok. Ships as a .NET tool, so any repository can adopt it without vendoring code.
Machine-translating everything on every build is slow, expensive, and overwrites work your translators did by hand. This tool tracks the source behind every key and every page, so a run only sends what actually changed — usually a handful of strings or pages. Everything else is left exactly as it is. And because website translations often ship without human review, the site mode refuses to write any page it cannot prove structurally intact.
- 🔄 Incremental — only changed entries/pages are retranslated; hand-written work survives
- 🎯 Placeholder-safe —
{variables}, HTML tags, Liquid tags, and URLs come back intact - 🛡️ Verified — translated pages are checked element-by-element and rejected on any damage
↔️ RTL-aware — for right-to-left targets (fa, ar, he, ur) an invisible direction mark keeps trailing Latin!/?— think "VpnHood!" — rendering on the correct side, and only where the next word is actually RTL ("VpnHood! CLIENT" stays untouched)- 🤖 Multi-engine — Gemini, OpenAI, or Grok, inferred from the model name
- 🗂️ Zero-argument runs — commit a
vhtranslator.jsonand just runvhtranslator - 🔧 Your terminology — project glossaries and per-key rules
- 🏗️ CI-friendly — retries with backoff, and stable exit codes
Requires the .NET 10 SDK or later.
# Pin it per repository (recommended — everyone and CI get the same version)
dotnet new tool-manifest
dotnet tool install VpnHood.Tools.ResourceTranslator
# ...or install it globally
dotnet tool install --global VpnHood.Tools.ResourceTranslatorSet the API key for the engine you use:
| Engine | Variable | Get a key |
|---|---|---|
| Gemini (default) | GEMINI_API_KEY |
makersuite.google.com |
| OpenAI | OPENAI_API_KEY |
platform.openai.com |
| Grok | GROK_API_KEY |
console.x.ai |
Point it at your base language file. Every sibling locale is brought up to date:
vhtranslator --base locales/en.jsonlocales/
├── en.json ← base (never modified)
├── fr.json ← updated
├── de.json ← updated
└── es.json ← updated
Projects that split their strings across many files keep them in one folder per language.
Point --base at the folder — its name is the source language — and every resource file
inside is translated to sibling language folders:
vhtranslator --base i18n/eni18n/
├── en/ ← base folder (never modified)
│ ├── home.json
│ └── about.json
├── fr/ ← updated file by file
│ ├── home.json
│ └── about.json
└── fa/ ← created when listed under "languages"
Everything else works exactly as with a single file: only changed or missing entries are
translated, and --show-changes, --rebuild-lang, and --ignore-changes apply per file.
Bookkeeping goes next to your vhtranslator.json when one exists, in a subfolder named after
the language trees' parent (vh_translator/watches/i18n/home.watch.json for --base i18n/en),
so data trees that are consumed verbatim — a Jekyll _data/ folder, an app bundle — stay clean.
--show-changes lists what a run would translate and exits. It needs no API key:
vhtranslator -b locales/en.json --show-changesCreate the file — even empty — then rebuild it:
echo "{}" > locales/it.json
vhtranslator -b locales/en.json --rebuild-lang itOr list it under languages in the config file, and it will be created for you.
If you already have hand-written locale files, tell the tool they are current so it does not retranslate them on the first run:
vhtranslator -b locales/en.json --ignore-changesTranslations are just file changes — read them like any other diff:
vhtranslator && git diff locales/Drop a vhtranslator.json in your repository and the tool needs no arguments at all. It is found
by walking up from the base file (or the working directory), checking each folder and its
vh_translator/ folder — keep it at the root or tuck it away, both work. Relative paths always
resolve against the repo/site root.
{
"base": "locales/en.json",
"model": "gemini-flash-lite-latest",
"batch": 20,
"extraPrompt": "translation-guidelines.txt",
"languages": ["fr", "de", "es"]
}| Key | Description |
|---|---|
base |
Base language file — or language folder (see above) — relative to the site/repo root |
engine |
gemini, gpt, or grok. Inferred from model when omitted |
model |
Model name. Defaults to gemini-flash-lite-latest |
batch |
Entries per request. Default 20 |
extraPrompt |
Extra instructions file, relative to the site/repo root |
languages |
Target languages. Missing files are created. Omit to update only existing locale files |
Command-line options always win, so one-off overrides stay easy:
vhtranslator # uses vhtranslator.json
vhtranslator -m gpt-4o-mini # same config, different model
vhtranslator --config ci/vhtranslator.jsonAfter each successful run the tool records the source text of every key in
vh_translator/watches/<base>.watch.json. On the next run a key is translated when:
- its source text changed since that record, or
- it is missing or empty in the target file.
Commit the watch file. Without it nothing is known to be current, so the next run retranslates every key in every language.
Standard .NET naming — a neutral base file with culture-specific siblings:
Resources/
├── Strings.resx ← base (neutral culture, treated as source "en")
├── Strings.fr.resx
└── Strings.de-DE.resx
vhtranslator -b Resources/Strings.resx
vhtranslator -b Resources/Strings.resx -r es # creates Strings.es.resxOnly string <data> entries are touched. Typed and binary entries, comments, metadata, and the
schema are preserved byte for byte.
One command translates every stale page into per-language folders that mirror the source paths —
on a Jekyll site the folder path is the URL, so /fr/free-vpn/ just works:
vhtranslator sitesite/
├── index.html ← base pages (never modified)
├── free-vpn/index.html
├── fr/ ← generated, mirrors the source tree
│ ├── index.html
│ └── free-vpn/index.html
└── de/
├── index.html
└── free-vpn/index.html
Lists the stale page/language pairs (and stale data entries) and exits. It needs no API key:
vhtranslator site --show-changesAdd the code to languages in the config — missing target pages are always created on the next
run. To force-refresh one language that already exists:
vhtranslator site --rebuild-lang fr # must be listed under "languages"A language rebuild retranslates that language's pages and its data files — use it after
switching models to regenerate one language wholesale.
Mark every current page as translated so the first run only fills in what is missing:
vhtranslator site --ignore-changesvhtranslator site && git diff fr/ de/The site section lives in the same vhtranslator.json; the top-level model, engine,
batch, and extraPrompt keys apply to site runs too.
{
"model": "gemini-2.5-flash",
"site": {
"pages": ["**/index.html"],
"exclude": ["privacy-policy/**", "terms-of-use/**"],
"languages": ["fr", "de"],
"output": "{lang}/{path}",
"titleMustContain": "MyBrand",
"data": ["_data/i18n/en"]
}
}| Key | Description |
|---|---|
pages |
Globs selecting the source pages. Default **/index.html |
exclude |
Globs excluded from discovery (e.g. legal pages you keep in the source language). .git/, _site/, _includes/, node_modules/, vh_translator/, and the generated locale trees are always excluded |
languages |
Required. Target language codes; each becomes an output folder |
output |
Output path template with {lang} and {path}. Default {lang}/{path}. May place files outside the served tree (e.g. a Jekyll collection folder, _langs/{lang}/{path}) — every generated page carries an explicit permalink, so the URL stays /{lang}/{path} regardless |
titleMustContain |
Text every translated page title must keep (typically the brand). Skipped with a warning when the source title itself lacks it |
data |
Key/value resources (e.g. shared UI strings) translated with the resource-file pipeline as part of every site run, into the same languages. Each entry is a file (_data/i18n/en.json) or a language folder (_data/i18n/en) |
sourceLanguage |
Language of the source pages. Default en |
pageBody |
translate (default) sends whole page bodies to the model. copy keeps bodies byte-identical and translates only the front-matter title/description — for sites whose page text lives in i18n data files (see below) |
The copy + data pattern. When every visible string of a page lives in per-language data
files (_data/i18n/en/home.json rendered via page.lang), the pages themselves contain nothing
to translate except their front-matter title and description. Set "pageBody": "copy" and list
the language folder under data: a site run then translates the data files key by key, and
generates per-language page copies whose bodies are byte-identical by construction — no
structural verification lottery, a fraction of the tokens, and the titleMustContain rule
still applies.
The end state of that pattern moves even title/description into the data files (as keys the
site injects at build time, e.g. via a small Jekyll plugin) and drops them from front matter:
pages then have no translatable metadata at all and are written as pure copies without a
single model call — page metadata gets translated by the incremental key-by-key data pipeline
like every other string.
Pin an exact model version (e.g.
gemini-2.5-flash, not-latest) — when translations ship without review, silent model drift means silent output drift.
Three levels, from coarse to fine — pick the one that matches the shape of the exclusion:
| Level | Where | Use for |
|---|---|---|
exclude globs |
vhtranslator.json |
Whole trees — legal pages, archives |
translate: false |
The page's front matter | A single page, decided where the content lives |
translate="no" |
Any HTML element | A fragment inside a translated page — an address, a testimonial quote, a brand slogan |
When a previously translated page is opted out (either way), its generated copies are pruned on the next run.
After each successful run the tool records a hash of every page in
vh_translator/watches/pages/site.watch.json. On the next run a page is translated for a language when:
- its source content changed since that record, or
- the target page is missing for that language.
Commit the watch file. Without it nothing is known to be current, so the next run retranslates every page in every language.
- Front matter is never model-generated. Only the
titleanddescriptionvalues are translated; every other line is copied verbatim, andlang: <code>, anauto_translated: truemarker, and an explicitpermalink: /<lang>/<path>/are added. - Liquid is never model-visible.
{% ... %}and{{ ... }}tags are masked with opaque tokens the model must return untouched, so template syntax cannot be corrupted. - The body is verified fail-closed against the source: identical element tree, identical
attributes (only
alt,title,aria-label,placeholdervalues may change), untouchedscript/style/svgcontent, every Liquid token back exactly once, and a sane visible-text length. On failure the errors are fed back to the model and the page is retried; after 3 attempts it is not written (exit code11) and the previously generated page keeps serving.
The marker in a generated page's front matter is what makes automation safe around human work:
- A target file without it is treated as hand-authored and is never overwritten — write
your own
fr/about/index.htmland the tool leaves it alone forever. - A discovered source page carrying it is skipped — a generated page can never be retranslated as if it were content.
- When a source page is deleted, its generated counterparts are pruned automatically — but only files carrying the marker; hand-authored files are never deleted.
For long-form content that is neither a key/value resource nor a website: blog posts, in-app help, legal summaries. The layout is folder-per-language — the source folder is named after its language and every target language becomes a sibling folder:
posts/
en/what-is-split-tunneling.md # hand-authored
fa/what-is-split-tunneling.md # generated
de/what-is-split-tunneling.md # generated
- YAML front matter is split off. Only the
frontMatterkeys are translated; every other line — dates, tags, images — is copied byte for byte. - Fenced code blocks, inline code, and
maskPatternsmatches are replaced with opaque tokens the model must echo back exactly; it can never corrupt what it was never shown. - A body longer than
chunkCharsis split at paragraph boundaries and translated in parts of the same request, so long documents cannot silently exceed a model's output budget. - Markdown has no element tree to compare, so BOTH sides are rendered to HTML with the same pinned renderer and the trees are compared: a dropped heading, broken list, changed link URL, or truncated translation is rejected. A document that cannot be verified after three attempts is never written — the previously committed translation stays.
Front matter also carries the per-file controls: translate: false opts a document out,
and translate_prompt: "..." adds instructions for that one document — the place to say
"legal disclosure — translate literally, never shorten" without burdening every other file.
The auto_translated marker works exactly as in site mode (never overwrite hand-authored
targets, never re-ingest generated files, prune when the source is deleted).
Adding a language, previewing with --show-changes, adopting existing translations with
--ignore-changes, and --rebuild-lang all work exactly as in site mode.
Add project rules via --extra-prompt, the extraPrompt config key, or by creating
vh_translator/prompt.txt next to your base file / at your site root, which is picked up
automatically (the pre-1.2 name custom_prompt.txt still works). The same rules apply to both
modes:
- Keep "VPN", "API", and "JSON" untranslated
- The brand name "VpnHood" never changes
- Use formal tone for German; use Latin American Spanish
- Return "*" for keys ending in _URL
Returning * skips an entry (resource files only): the existing translation is kept, or the
source text is used if there is none. Useful for brand names, URLs, and region-specific strings.
Rules for a single language go into an optional prompts/ subfolder, one file per target
language named {lang}.prompt.txt — {lang} being the locale file stem (or the site language
code):
vh_translator/
prompt.txt # shared — sent for every language
prompts/
fa.prompt.txt # appended after the shared prompt, only when translating fa
pt-BR.prompt.txt
A language without its own file just gets the shared prompt; a prompts/ folder works with or
without a shared prompt.txt. Note that editing prompt files does not retranslate entries that
are already current — the watch tracks source text only. Use --rebuild-lang to reapply new
rules to a whole language.
- run: dotnet tool restore
- run: dotnet tool run vhtranslator # resource files
- run: dotnet tool run vhtranslator site # static website
- run: dotnet tool run vhtranslator docs # markdown documents
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}Pair it with a create-pull-request action so translations arrive as reviewable diffs rather than unattended commits — or commit them directly before the build step when the fail-closed verification is your review.
vhtranslator [options] Translate resource files (JSON / .resx)
vhtranslator site [options] Translate a static website
vhtranslator docs [options] Translate standalone markdown documents
Shared options:
--config <path> Config file to use (default: nearest vhtranslator.json,
also found inside vh_translator/)
-x, --extra-prompt <path> Extra instructions appended to the AI prompt
(default: vh_translator/prompt.txt; per-language files in
vh_translator/prompts/, e.g. fa.prompt.txt, are appended after it)
-c, --show-changes List what would be translated and exit (no API key needed)
-r, --rebuild-lang <code> Force retranslation of everything for one language
(site mode: the pages and all "data" files)
-i, --ignore-changes Mark everything as already translated, without calling the AI
-k, --api-key <key> API key (or use the engine's environment variable)
-m, --model <name> AI model (default depends on the engine)
-e, --engine <name> gemini, gpt, or grok (default: inferred from the model name)
-n, --batch <number> Entries per request (default: 20; in site mode this applies
to the "data" files — pages always go one per request)
-?, -h, --help Show help and usage information
--version Show version information
vhtranslator only:
-b, --base <path> Base language file (.json / .resx) or language folder (i18n/en)
| Engine | Default model |
|---|---|
gemini |
gemini-flash-lite-latest |
gpt |
gpt-4o-mini |
grok |
grok-4-latest |
Two choices are baked in, because both matter more than they look:
- Sampling is deterministic (
temperature 0). The same source string gets translated in separate batches that cannot see one another, so sampling variance surfaces as one UI label rendered two ways across a site. Determinism removes that source of drift; a project glossary handles the rest — see Per-language rules. - Thinking is off on Gemini (
thinkingBudget 0). Translation is a mechanical mapping, not a reasoning task, and thinking tokens bill at the output rate — the expensive side of a run.
The response asks for Key and TranslatedText, nothing else. Earlier versions had the model
echo the source text and both language codes back, which was roughly 40% of every response in
output tokens, for values the caller already holds. A response that still includes them parses
exactly as before, so an older prompt or a chattier model cannot break a run.
| Code | Meaning |
|---|---|
0 |
Success |
1 |
Invalid arguments or configuration |
2 |
File not found, or unsupported file type |
3 |
Could not parse the base file |
4 |
Missing API key |
10 |
Translation failed after retries |
11 |
One or more pages failed verification and were not written (site mode) |
Everything is retranslated on every run. The watch file is missing or not committed. Run
once, then commit vh_translator/.
Missing API key. Set GEMINI_API_KEY, OPENAI_API_KEY, or GROK_API_KEY for your engine,
or pass -k.
Could not parse the base file. JSON must be a flat object of string values — nested
objects are not supported. Validate with jq . locales/en.json. .resx files must be
well-formed XML.
Rate limits. Lower --batch, or use a lighter model such as gemini-flash-lite-latest. The
tool already retries with increasing backoff.
A translation is wrong or should not happen. Add a rule to your custom prompt, or fix the
value by hand and run --ignore-changes so it is not overwritten. On a site, hand-fixing a
generated page does not survive the next source edit — put the rule in the custom prompt, or
remove the auto_translated marker to own that page permanently.
A page keeps failing verification (exit 11). Read the reported errors — the model is
damaging structure (dropped elements, lost placeholders). Try a stronger model. Meanwhile the
previously generated page keeps serving; nothing broken is ever written.
"skipped — carries the 'auto_translated' marker". A generated page was discovered as a
source: your pages/exclude globs or a non-default output pattern let a locale tree leak
into discovery. Fix the globs; the marker guard is the safety net, not the mechanism.
dotnet build
dotnet testSee CLAUDE.md for repository layout, architecture notes, and the release process.
Open source (LGPL) — see LICENSE.
{ "docs": { "source": "posts/en", // required: the source-language folder "languages": ["fa", "de"], // required: each becomes a sibling folder "files": ["**/*.md"], // default "output": "posts/{lang}/{path}", // default: sibling of source "sourceLanguage": "en", // default: the source folder's leaf name "frontMatter": ["title", "description", "image_alt"], // keys to translate (default) "chunkChars": 10000, // split longer bodies at paragraph boundaries "maskPatterns": ["\\{[A-Za-z0-9_]+\\}"] // masked besides code (default) } }