The translation reviewer for your pull requests.
The placeholder that went missing in French, the plural form Polish needs,
{{ modelli }} where the source says {{ models }}:
polygo finds them in milliseconds — zero config, no model, no account.
Install the app and it reviews every pull request. Or four lines of CI. Or one 4 MB binary.
Strings missing? It translates them too, with a local model.
What lands on a pull request. The suggestion is written by the parser, not a model — one click and the name is back.
On every pull request — install polygolint on a repository and stop. It reviews the pull requests that touch translation files, comments on the lines that broke, and leaves a committable suggestion where the fix is mechanical. It never blocks a pull request, and it says a thing only once.
In your own CI, if you would rather not install an app — the same review, posted by your own workflow with the repository's token, nothing leaving the runner:
- uses: Na5co/polygo/action@v0
with:
mode: check
review: "true" # needs pull-requests: writeOn your machine, on any repository, including ones you do not own:
curl -fsSL https://raw.githubusercontent.com/Na5co/polygo/main/install.sh | sh
polygo check open-webui/open-webui # a GitHub repo, cloned shallow and checkedOr brew install na5co/tap/polygo · cargo install polygo · Windows zip. Then polygo completions zsh for tab completion.
Real output on DuckDuckGo's shipped macOS catalog, unmodified. Clone it and run this yourself.
polygo check Localizable.xcstrings # a file (.xcstrings, .strings, strings.xml, .json, .arb, .po, .resx)
polygo check app/src/main/res # a folder: format and locales are detected
polygo check Dimillian/IceCubesApp # a GitHub repo (or any git URL): cloned shallow, checked
polygo check # the project, once polygo.toml existsNothing to configure. It finds the string files your app already has, works out which locales exist and which one is the source, and reports what a user would eventually notice — every finding with the file and line where the fix goes. A polygo.toml exists for when you want to tune it; the checks below run without one:
| code | severity | what it catches |
|---|---|---|
locale |
warning | a locale file sitting next to the others that nothing checks, because its locale is not in target_locales |
syntax |
error | a string file that does not parse (a dropped comma, an unclosed <string>) — reported instead of the locale silently vanishing from the run |
placeholders |
error | %1$@, {{name}}, %(count)s, {0}, {n, plural, …} missing, added, retyped or reordered — and a translated name ({{ modelli }} for {{ models }}) named as such |
plural |
error | a CLDR form the locale needs and doesn't have: few/many for Polish, six for Arabic; ICU, .xcstrings, .stringsdict, <plurals>, msgstr[n], i18next _few |
markup |
error | <b>, </a>, <br> the source has and the translation lacks, or the reverse |
escape |
error | Android: unescaped ', or a leading @/? that aapt reads as a resource reference |
empty |
error | a translation that is blank |
identical |
warning | the translation is the English text |
fragment |
warning | half translated: ようこそ back! |
punctuation |
warning | Settings… became Ajustes; a trailing : or . dropped |
whitespace |
warning | a leading/trailing space or newline lost (strings glued together in the UI) |
orphan |
warning | a key the locale file has and the source no longer does |
state / fuzzy |
warning | marked needs_review in Xcode or #, fuzzy in gettext, shipped anyway |
length |
warning | 2.5× the source, or a polygo:max=20 comment exceeded |
duplicate |
error | the same key twice in one file; the last one wins silently |
array |
error | an Android <string-array> with more or fewer items than the source |
glossary |
error | a glossary.toml term translated, or not rendered as required |
encoding |
warning | é, ’: a file saved in the wrong encoding |
invisible |
warning | zero-width space, mid-string BOM, bidi controls, control characters |
link |
warning | a URL or email in the source that the translation changed or dropped |
brackets |
warning | ( opened and never closed, when the source keeps it balanced |
entities |
warning | &amp;: an entity escaped twice |
inconsistent |
warning | the same short term translated two ways in one locale (Réglages ×3, Paramètres ×1) |
It ends with the coverage per locale. A translated placeholder name is the one bug with a mechanical repair, so polygo check --fix puts the source name back with no model involved — on a file, a folder or a repo (polygo check owner/repo --fix, then git diff in the clone); everything else --fix re-translates with the configured model. Exit 1 on errors (--strict for warnings too); --json for machines ("fix" carries the corrected text); polygo check --explain punctuation says what a code means and what to do. A key that is right the way it is: polygo:ignore=identical in its comment, or [keys] ignore = { "legal.*" = ["length"] } in polygo.toml.
Adopting it on an old catalog: polygo check --write-baseline accepts everything it finds today into polygo-baseline.json (commit it). From then on check reports and fails only on new findings, and tells you when a known one got fixed so you can prune the file. --no-baseline shows the whole picture.
In CI — one annotation per finding on the PR's Files tab, a table in the job summary, no secrets:
- uses: Na5co/polygo/action@v0
with:
mode: checksarif: "true" also files them in GitHub's Security tab with new/fixed history. Any other CI: polygo check --github or --sarif. Pre-commit: entry: polygo check (snippet).
As a reviewer, review: "true" turns those annotations into inline comments with committable suggestions — the same thing polygolint does, running in your own CI instead. The app is the same code, deployed: bot/ is one container, no database, nothing kept between events.
polygo check on shipped, production catalogs, unmodified (polygo check <owner/repo> reproduces any of these):
| App | Found |
|---|---|
| DuckDuckGo macOS | 12 placeholder bugs: Ouvrir dans % @ (a space inside %@), a dropped %5$d, mangled %#@var@ |
| Ice Cubes | 44 missing Slavic plural forms; a Ukrainian string reading "ключа DeepL API key" |
| boringnotch | 3,374 strings Xcode marks needs_review, shipped |
| NewPipe, DuckDuckGo Android | stray " that Android silently drops from the UI |
| Penpot | 4 #, fuzzy entries gettext shows in English |
| Signal iOS | French strings with literal <strong> tags the English source doesn't have; Russian one forms without the number, shown for 21, 31, 101 items |
Details in KNOWN_BUGS.md. Zero false positives on 3,756 strings from five Android apps.
The same check over 33 popular open-source apps found about a thousand broken placeholders in production, almost all a translator translating the placeholder name in a language the maintainers don't read. That is what polygo check <owner/repo> --fix repairs mechanically; the fixes are landing upstream:
| Project | Fix | Status |
|---|---|---|
| Open WebUI | 22 placeholders in 13 locales ({{ modelli }} for {{ models }}, {model}}) |
merged |
| Dashy | {brukernavn} for {username} |
merged |
| AppFlowy | 14 translated placeholder names in 10 locales | open |
| Cal.diy | 10 placeholder names in de, es, et, ro | open |
| Umami | {ব্রাউজার} for {browser}, [[9000]]] for {time} |
open |
| DuckDuckGo macOS | 12 broken %@ specifiers |
open |
| Ice Cubes | 2 dropped %@ |
open |
| Teable | {{rowCount}} for {{count}} in 8 locales |
open |
| Medusa | {{von}}/{{bis}} for {{from}}/{{until}} |
open |
| Strapi | {tutaj}/{qui}/{bläddra} for {here}/{browse} in 7 locales |
open |
| Outline | { appName }, {{documentTitle}}, {{username}} |
open |
| Linkwarden | Spanish billed the annual price per month | open |
Scan results for all 23 in launch/upstream/FINDINGS.md; scripts/scan_upstream.sh owner/repo path runs it on any repo.
cd your-app
polygo init # finds your string files, writes polygo.toml
polygo use gemma4 # pulls a model through Ollama, or openai/… with a key
polygo translate # new and changed strings, into every target locale
polygo check # the same checks, on what the model just wrote
git diff # look it over, commit|
🔒 Only the diff you meant |
🧠 Context from your code |
|
🔢 Plurals per language |
🚫 The model can't talk past |
|
🧑⚖️ A second opinion |
🤖 Translate in CI |
| Format | Used by | What init finds |
|
|---|---|---|---|
| 🍎 | .xcstrings |
iOS, macOS | one catalog with every locale; %#@var@ substitutions |
| 🍎 | .strings |
iOS, macOS (legacy) | <locale>.lproj/Localizable.strings, UTF-16 kept as is |
| 🤖 | strings.xml |
Android | res/values-*/; <plurals> per quantity, <string-array> per item |
| 🌐 | .json |
React, Vue, i18next | locales/<locale>.json or locales/<locale>/<ns>.json, nested keys |
| 🐦 | .arb |
Flutter | l10n.yaml + lib/l10n/app_<locale>.arb, ICU plurals |
| 🐍 | .po |
Django, Rails, PHP, gettext | locale/<l>/LC_MESSAGES/*.po or flat; msgid_plural per Plural-Forms |
| 🪟 | .resx .resw |
.NET, WinUI | Name.<locale>.resx, <locale>/Resources.resw |
Every writer round-trips byte for byte, tested on 35 real files from DuckDuckGo, Ice Cubes, Grafana, Django, NewPipe and Penpot, so a translation never hides under a reformatting diff. No string files yet? polygo extract --rewrite pulls the text out of your web markup and swaps in t("key") calls (how).
| Size | Good for | |
|---|---|---|
qwen3:8b default |
5 GB | UI strings into major languages, on any laptop |
gemma4 |
9.6 GB | Smaller languages, Slavic plurals, anything a customer reads |
openai/… anthropic/… |
API | The same, without a local GPU |
polygo models lists them, polygo use <model> switches. Honest numbers in docs/models.md.
No telemetry, no account, no server. The only network request is the translation call to the provider in your polygo.toml; with Ollama that is 127.0.0.1. check, status, init and review never touch the network at all. Verify it yourself with sandbox-exec.
Commands and polygo.toml · Per-format notes · GitHub Action and pre-commit · Extracting strings from web apps · FAQ, compared with Lokalise / Crowdin / Weblate · How it was built
MIT · cargo test runs offline in about 10 s