Metadata-Version: 2.4
Name: translint
Version: 0.5.0
Summary: Catch broken translations before they ship: missing keys, placeholder mismatches, empty and untranslated values.
Author: Cole Munz
License-Expression: GPL-3.0-or-later
Project-URL: Homepage, https://github.com/munzzyy/translint
Project-URL: Issues, https://github.com/munzzyy/translint/issues
Keywords: i18n,l10n,localization,linter,translation,ci
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Localization
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: yaml
Requires-Dist: PyYAML>=5.1; extra == "yaml"
Dynamic: license-file

# translint

**A missing translation is a UI bug. A renamed placeholder is a crash.** translint checks
your locale files against a base and flags exactly that: missing keys, stale extra keys,
empty values, values that still look untranslated, and the one that actually takes an app
down: placeholder tokens that do not match between the base string and the translation.

[![CI](https://github.com/munzzyy/translint/actions/workflows/ci.yml/badge.svg)](https://github.com/munzzyy/translint/actions/workflows/ci.yml)
[![License: GPL-3.0-or-later](https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg)](LICENSE)
![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)

![translint catching a missing key and a placeholder rename in a German locale file, plus a missing key, a stale extra key, and another placeholder mismatch in a French one](docs/media/demo.svg)

Four ways to use it: see it run in the [browser demo](https://translint.munzzyy.dev/),
run the CLI by hand or in a pre-commit hook, gate CI with the GitHub Action, or install it as
a skill so your AI coding agent checks its own i18n changes before handing them back to you.
One Python file, standard library only, no dependencies.

**[Try it in your browser](https://translint.munzzyy.dev/).** Real output from the
bundled example locale files, not a mockup - nine themes, works on a phone, nothing leaves
your machine. A linter for translations should practice what it lints, so the site itself
reads in 32 languages, right-to-left included - pick yours from the header.

[![translint's site: the base/translation example, real CLI output, and the theme picker in the header](docs/media/demo.png)](https://translint.munzzyy.dev/)

## Example

Three locale files, one base:

```
examples/locales/en.json   (base)
examples/locales/fr.json
examples/locales/de.json
```

`fr.json` is missing `nav.settings`, has a leftover `checkout.oldStep` key from an old
checkout flow, renamed `{amount}` to `{total}` in a translated string, and left an error
message in English. `de.json` shipped `checkout.success` as an empty string:

```
$ translint examples/locales --base en
de (examples/locales/de.json):
  empty values (1):
    - checkout.success
  possibly untranslated (1, heuristic):
    - brand.name

fr (examples/locales/fr.json):
  missing keys (1):
    - nav.settings
  extra keys (1):
    - checkout.oldStep
  placeholder mismatches (1):
    - checkout.confirm: base has ['{amount}', '{last4}'], locale has ['{last4}', '{total}']
  possibly untranslated (2, heuristic):
    - brand.name
    - errors.networkDown
$ echo $?
1
```

`brand.name` ("Acme") shows up as "possibly untranslated" in both locales because it's
byte-identical to the base value - correctly so, since it's a brand name that should stay
the same everywhere. That's the heuristic working, and it's also exactly what
`--allow-identical brand.name` is for (see [Config](#config)).

The one that matters most is `checkout.confirm`: the base string interpolates
`{amount}` and `{last4}`, but the French translation interpolates `{total}` and
`{last4}` - `{amount}` never got translated in, `{total}` is not a placeholder the code
knows about. That code renders fine in every test that does not pass real interpolation
data, and throws the moment it does.

## As an agent skill

Point your coding agent at translint and it will check its own i18n changes before handing
a PR back to you. Two install paths, pick whichever your agent supports:

```bash
# Claude Code
/plugin marketplace add munzzyy/translint
/plugin install translint@translint

# any agent using the open Agent Skills standard (Codex, Cursor, and others)
npx skills add munzzyy/translint
```

Either way, the agent gets [SKILL.md](skills/translint/SKILL.md): when to run it (after
adding or editing locale keys, before finalizing an i18n PR) and how to read the result.
Ask the agent something like "check the locale files before you finish this PR" and it will
run `translint.py --json` on the locale directory and act on what comes back.

## Install

```bash
pipx install git+https://github.com/munzzyy/translint
```

Or skip the install entirely, since it's a single file with no dependencies:

```bash
curl -LO https://raw.githubusercontent.com/munzzyy/translint/main/translint.py
python translint.py --help
```

There is a `translint` on PyPI, but it's stuck on 0.4.0: no `--fix`, ten known
bugs still in it, and published under the Prosperity Public License rather than
GPL-3.0-or-later. Install from here until a newer release lands there.

## Usage

```bash
translint locales/                          # scan a directory, base=en
translint locales/ --base fr                # use fr.json as the reference instead
translint locales/en.json locales/de.json   # check specific files
translint locales/ --json                   # machine-readable report
translint locales/ --strict                 # also fail on extra keys + untranslated
translint locales/ --allow-identical brand.name   # suppress one heuristic false positive
translint locales/*.json --base en          # globs work even on Windows shells
translint locales/ --fix --dry-run          # preview what --fix would insert
translint locales/ --fix                    # insert missing keys, loudly marked
translint public/locales/ --recursive --locale-from dir   # en/common.json layout
```

`--base` (default `en`) is the locale name - the filename stem, so `en` means `en.json`,
`en.po`, `en.properties` or `en.yml`, whichever is present - that every other discovered
locale file gets checked against. Point translint at a directory and it scans every file with a
recognized extension; point it at specific files and it checks exactly those.

Exit code is 0 when every locale is clean, 1 when translint found something to fix, and 2
if a path could not be read or parsed at all - a bad-JSON error and a real lint finding
never look the same to a script. By default only missing keys, placeholder mismatches,
and empty values fail the run; extra keys and untranslated-value heuristic hits are
reported but do not fail unless you pass `--strict` - both are far more likely to have a
legitimate explanation (a key mid-removal, a brand name) than the other three.

### Directory layouts

A flat directory of `en.json` / `de.json` / `fr.json` is the default and needs no flags.

For the other common layout, `public/locales/<lang>/<namespace>.json` (next-i18next,
react-i18next, i18next-fs-backend), pass both `--recursive`, so the scan descends into the
language directories, and `--locale-from dir`, so the locale name comes from the directory
rather than the filename:

```bash
translint public/locales/ --recursive --locale-from dir --base en
```

Files are then grouped by namespace, so `en/common.json` is only ever compared against
`de/common.json`, never against `de/footer.json`. Every namespace needs a file for the
base locale; translint says which one is missing it if not. A locale that lacks a whole
namespace file (`de/` has `common.json` but no `footer.json`) is reported against the
path the file should have, with every key in it missing. `--fix` will not create that file
for you, so copy the base file to start it. Nested namespace directories
(`en/admin/billing.json`) work too - the namespace is the whole path below the language
directory.

## Formats

Auto-detected by extension, or forced with `--format {json,po,properties,yaml,arb}`. Forcing a
format also turns off the extension filter on a directory scan, so a directory of
`en.lang` / `de.lang` files gets read as the format you named instead of coming back
empty:

- **JSON** - both nested objects (`{"app": {"title": "..."}}`) and flat dot-namespaced
  keys (`{"app.title": "..."}`) are supported; nested files get flattened to dotted keys
  for comparison, so a nested base and a flat translation (or vice versa) still compare
  correctly key-for-key.
- **gettext .po / .pot** - `msgid`/`msgstr` pairs, `msgctxt`, multi-line strings and
  plural forms. A translation is checked against the base file's `msgstr` when it has
  one, which is how a file keyed by IDs (`msgid "app.title"`) works. When the base
  `msgstr` is empty, as in a `.pot` template or an `en.po` that uses the English text as
  its msgids, the translation is checked against the `msgid` itself, so `--base messages`
  makes `messages.pot` the base for `de.po` and the rest. Plural forms are checked the
  way `msgfmt -c` checks them: every `msgstr[n]` against `msgid_plural`, with the locale
  file's `Plural-Forms` formula deciding which forms have to keep every placeholder. A
  form the formula picks for only a handful of numbers (fewer than five from 0 to 1000,
  `msgfmt`'s own cutoff), like German's `n == 1`, may leave one out, so "Eine Datei" is
  fine for "%d files". Russian's first form also covers 21 and 31, so it has to keep the
  `%d`. A file with no `Plural-Forms` header gets that slack on every form, as it does in
  `msgfmt`, which fails the file for the missing header instead. No `polib` dependency -
  it's plain text parsing plus `json.loads` for unescaping the quoted string literals,
  since `.po`'s C-style escaping is a subset of JSON's, and the formula is parsed by hand
  rather than handed to `eval`.
- **Java .properties** - `key=value` or `key:value`, comments (`#`/`!`), and backslash
  line continuations.
- **YAML** (`.yml` / `.yaml`) - the default Rails i18n layout (`config/locales/en.yml`)
  and common in Vue/Nuxt projects. Rails wraps each file in its locale (`en.yml` starts
  with `en:`), so when a file's only top-level key is its own locale, translint reads
  what's under it and `en.yml`'s `en.title` lines up with `de.yml`'s `de.title`. Rails
  also splits locales into files like `devise.en.yml`; those are compared against
  `devise.de.yml` rather than `en.yml`. This is the one format that is not zero-dependency:
  reading a YAML file needs PyYAML, installed with `pip install translint[yaml]`. Every
  other format still needs nothing, and translint only imports `yaml` the moment it
  actually opens a `.yml`/`.yaml` file, so leaving the extra out does not cost anything
  until you point translint at one. `--fix` does not write YAML back yet - missing keys
  in a `.yml` file are reported like any other finding, just not auto-inserted.
- **Flutter .arb** - JSON whose values are ICU messages. Keys starting with `@`
  (`@@locale`, and the `@hello` block describing `hello`) are metadata and stay out of
  the comparison. The locale is the file's `@@locale`, or else the end of the name, so
  `app_en.arb` / `app_de.arb` / `app_pt_BR.arb` work with `--base en`. `--fix` adds a
  missing message as a plain key and leaves the metadata alone.

### Encoding

Locale files are read as UTF-8 (a byte-order mark is tolerated and preserved by `--fix`).

`java.util.Properties.load(InputStream)` is ISO-8859-1 by specification, and plenty of
pre-Java-9 resource bundles are still written that way, so a `.properties` file that
is not valid UTF-8 is re-read as ISO-8859-1 and `--fix` writes it back in the same
encoding. translint prints a line to stderr naming the file when it does that; it never
falls back quietly.

`--encoding ENC` names the encoding yourself, for anything else. Where translint has to
substitute U+FFFD for bytes it cannot decode, it says so on stderr rather than reporting
on text it knows is mangled: two words that differ only in an accent both decode to
U+FFFD, compare equal, and turn a correct translation into an "untranslated" finding.

## Placeholder styles

translint detects these interpolation styles and diffs the tokens as a multiset. A
translation that uses a placeholder twice when the base only uses it once still counts as
a mismatch - comparing token sets alone would let that slip through:

| Style | Example | Common in |
|---|---|---|
| `{name}` | `Hello {name}` | ICU, i18next, Python `str.format` |
| `{name, number}` / `{0,date,short}` | `Total: {amount, number, currency}` | ICU (FormatJS, Flutter ARB), Java `MessageFormat` |
| `{name, plural, ...}` / `{name, select, ...}` | `{count, plural, one {# file} other {# files}}` | ICU |
| `{{name}}` | `Hello {{name}}` | Handlebars, Vue, Mustache |
| `{{name, format}}` / `{{- name}}` | `Price: {{val, number}}` | i18next formatting and unescaped output |
| `%s` / `%d` / `%1$s` | `Hello %s` | printf, gettext, Java positional |
| `%(name)s` | `Hello %(name)s` | Python `%`-format |
| `${name}` / `$name` | `Hello ${name}` | shell, template literals |

A typed argument counts as its plain argument, so `{amount, number, currency}` is the
token `{amount}` and `{{- name}}` is `{{name}}`. Only ICU's own types (`number`, `date`,
`time`, `spellout`, `ordinal`, `duration`) count, so `{a, b}` in ordinary text is not read
as a placeholder. For an ICU `plural` or `select`, the argument name is the token and the
text in each branch is prose to translate, so `one {fichier}` in French does not clash
with `one {file}` in English, while a placeholder inside a branch is still checked. It
counts once per argument however many branches repeat it, since Arabic has six plural
branches where English has two.

A value can mix styles (rare, but not invalid) and every token from every style that
matched gets included in the comparison. A value with no placeholder syntax at all
correctly matches another value with none - most short UI strings never had a placeholder
to begin with, and that's not a bug.

A placeholder mismatch is a hard failure with no allowlist, so a false one is the worst
bug translint can have. If it flags a correct translation, please
[open an issue](https://github.com/munzzyy/translint/issues) with the base string and the
translated one.

## Plural keys

i18next writes one key per plural form (`file_one`, `file_other`), and Rails nests them
(`file:` with `one:` and `other:` under it). Which forms a locale needs depends on its
language, not on the base file: Japanese only has `other`, Russian has `one`, `few`,
`many` and `other`, Arabic has all six. translint looks the locale's language up in
CLDR's plural rules (the language part of the name, so `pt-BR` counts as Portuguese) and
expects exactly those forms. A Japanese file without `file_one` is complete, a Russian
`file_few` is not an extra key, and a Russian file without one is missing it.

The base file does matter for the forms its own language has. If `en.json` has
`installed_other` but no `installed_one`, the app gets by without a `one` form, so no
locale is asked for `installed_one` and one that has it is not extra. Russian still needs
`installed_few` and `installed_many`, since English has no such forms to leave out. This
goes by the language `--base` names, so it takes a name like `en`, not `source`.

Two kinds of form get some slack. `zero` is never extra, because i18next and Rails both
use it for a count of 0 in any language. It is only required in the languages whose CLDR
rules have it, like Arabic and Latvian. A form that no whole number up to 1000 falls
into, like French `many` (a million and up) or Czech `many` (fractions), is allowed but
not required.

A form passes the placeholder check when it matches the base's own copy of that form, or
the base's `other` form with the count made optional where one number is meant. The count
can be left out of `zero`, `one` and `two` ("One file") and added to any form, but `few`,
`many` and `other` cover a range of numbers and have to keep it. The count is
`{{count}}`/`%{count}`. When the base's `other` form has neither and its `one` form drops
exactly one token, that token is the count, so a Rails app passing the number as
`%{friendly_count}` works too. A lone `_other` key only starts a plural set when it has a
sibling form or a `{{count}}` in it, so `gender_other` next to `gender_male` stays an
ordinary key. A locale whose language translint does not know, and i18next ordinal keys
(`_ordinal_one`), get the plain key-by-key comparison.

## Config

Command-line flags for one-off runs, or drop a `.translintrc.json` next to your locale
files for anything that should persist:

```json
{
  "allow_identical": ["brand.name", "app.title.wordmark"],
  "do_not_translate": ["Acme", "kg", "kWh"]
}
```

- `allow_identical` - key names exempt from the untranslated-value heuristic entirely.
  Use this for a specific key that legitimately renders the same in every locale (a brand
  name split across markup, a deliberate loanword, a genuine cross-language cognate).
- `do_not_translate` - substrings stripped from both the base and translated value before
  the untranslated check compares them. Use this for a token that shows up inside
  otherwise-real prose across many keys (a product name, a unit symbol) so a string that's
  genuinely translated except for that one repeated token does not false-positive.

Both are also available as repeatable flags (`--allow-identical KEY`,
`--do-not-translate TOKEN`) if you'd rather not commit a config file. translint looks for
`.translintrc.json` in the directory you point it at, or, when you name the files one by
one or with a glob, in the directory they share. Pass `--config PATH` to use a specific
file instead.

Both values have to be lists of strings, and anything else stops the run with exit 2. A
key translint does not know gets a warning on stderr, since a typo like `allow-identical`
would otherwise turn your allowlist off without a word.

## `--json` output

One object per non-base locale, in a list. These ten keys are the whole contract, and
they do not change without a version bump:

| key | what's in it |
| --- | --- |
| `locale` | the locale name (`de`) |
| `path` | the file this result is about |
| `format` | `json`, `po`, `properties`, `yaml`, or `arb` |
| `missing_keys` | keys in the base that this file is missing |
| `extra_keys` | keys here that the base does not have |
| `placeholder_mismatches` | objects of `{key, base, locale}`, the token lists that differ |
| `empty_values` | keys whose value is blank |
| `untranslated_values` | keys whose value still matches the base (the heuristic) |
| `untranslated_markers` | keys still carrying the `[UNTRANSLATED]` marker `--fix` writes |
| `ok` | hard findings only, see below |

`ok` is true when the locale has no `missing_keys`, `placeholder_mismatches`,
`empty_values` or `untranslated_markers`. It ignores `extra_keys` and
`untranslated_values` on purpose, which means **`ok` can be true in a run that exits 1**:
under `--strict` those two fail the run and `ok` does not know about `--strict`. If you're
scripting against the output, take the exit code as the verdict and use `ok` for what it
says on the tin, or check the two soft lists yourself.

A `.po` key that carried a `msgctxt` is emitted as the msgctxt, a `U+0004` byte, then
the msgid, which is gettext's own convention, so every key in the output is a string
rather than sometimes being a pair.

## The untranslated-value check, honestly

This one's a **heuristic**, not a hard rule, and the CLI output says so. translint strips
placeholders, punctuation, numbers, and any configured `do_not_translate` tokens from both
the base and the translated value, then flags the key if what's left is identical - and
only if the base's remaining content has at least 3 letters, so a value that's just a unit
symbol or a bare number does not flag in every locale it's legitimately identical in
(`"kg"` staying `"kg"` in French is not a missed translation).

A hit means "this looks untranslated," not "this is definitely wrong." Brand names,
loanwords, and real cross-language cognates all trip it correctly and are not bugs - that's
what `--allow-identical` and `--do-not-translate` are for. Silence a hit that way, not by
ignoring the finding, so the check stays useful for the next change instead of turning
into noise you've trained yourself to skim past.

## Fix mode

`--fix` does exactly one thing: it inserts keys that are **completely missing** from a
locale file - present in the base, absent here entirely - and nothing else. Every other
finding (extra keys, placeholder mismatches, empty values, untranslated-value hits) stays
report-only, exactly like it does without `--fix`. Default behavior does not change; you
have to opt in.

```bash
translint locales/ --fix --dry-run   # show exactly what would be inserted, write nothing
translint locales/ --fix             # insert it for real
```

What actually gets written, for a key `checkout.confirm` whose base value is `You're about
to charge {amount}`:

- **JSON / .properties** get the literal English base text back, prefixed with an
  unmissable `[UNTRANSLATED]` tag: `"checkout.confirm": "[UNTRANSLATED] You're about to
  charge {amount}"`. The placeholder comes along for the ride, so the string still has the
  right tokens once someone translates it - it just is not translated yet, and says so.
- **.po** gets a new entry marked with gettext's own `fuzzy` flag instead of a text tag:

  ```
  #, fuzzy
  msgid "checkout.confirm"
  msgstr "You're about to charge {amount}"
  ```

  translint's own `.po` parser already skips fuzzy entries as not-live (`msgfmt` does not
  compile them either), so a fuzzy-flagged key you just inserted reads back as **still
  missing** on the very next run, the same way `[UNTRANSLATED]` does for the other two
  formats. There is no way for a fuzzy entry to be mistaken for a finished translation by
  translint or by `msgfmt`.

  A key that reads as missing because its entry is fuzzy or obsolete (`#~`) is still in
  the file, though, and a second `msgid` makes `msgfmt` refuse the whole file. `--fix`
  leaves those alone and names them on stderr, which is also why a second `--fix` run
  does not add the same entry again.

  A plural entry gets its `msgid_plural` and one `msgstr[n]` for each form the file's
  `Plural-Forms` header asks for, or two if it has no header. A format flag on the base
  entry, like `c-format`, goes on the new one too, next to `fuzzy`. With a `.pot` as the
  base there is no English `msgstr` to copy, so the new `msgstr` is empty.

What it will never do, on purpose:

- **Never write a real translation.** There is no translation engine here, and there is
  never going to be one - see [CONTRIBUTING.md](CONTRIBUTING.md). If it's not in the base
  file already, `--fix` does not invent it.
- **Never touch the identical-to-base heuristic.** A key that already exists but is flagged
  `untranslated_values` stays exactly as it is, forever. `--fix` only adds keys that do not
  exist at all; it does not second-guess a value that's already there, correct guess or not.
- **Never rewrite an existing translation**, even a broken one. A placeholder mismatch or an
  empty value on a key that already exists is left alone, byte for byte - only a genuinely
  absent key gets written.
- **Never reformat the file.** Every existing line stays exactly as it was (aside from a
  trailing comma added to what was previously the last JSON member, since valid JSON
  requires one once a sibling follows it). A new key goes in as one more line for
  `.properties` and one more blank-line-separated entry for `.po`; for JSON it goes into
  the object its path names, so the diff a fix produces is exactly the new key(s) and
  nothing else.

A JSON key is written in the shape the file already uses. In a nested file, `nav.settings`
is inserted into the `nav` object, creating any intermediate objects it needs; in a flat,
dot-namespaced file it's written as a top-level `"nav.settings"` member. translint reads
both the same way (see [Formats](#formats)), but i18next, vue-i18n and the rest walk into
the nested object for `t("nav.settings")` and never see a literal top-level key with a dot
in it, so writing the wrong shape would hand you a string the app still cannot find. An
empty locale file has no shape of its own, so it follows the base file's.

`--dry-run` only makes sense alongside `--fix`; it prints the same summary but writes
nothing, so you can see exactly what would land before it does.

## As a pre-commit hook

```yaml
repos:
  - repo: https://github.com/munzzyy/translint
    rev: main
    hooks:
      - id: translint
        args: [locales/, --base, en]
```

`main` rather than a tag on purpose: the newest tag, `v0.4.0`, predates `--fix`, the
MIT relicense, and the later move to GPL-3.0-or-later, so it is not the thing you want.
Pin to a tag once there is a newer one.

The hook always passes the full path you configure in `args`, not just the files that
changed in that commit (`pass_filenames: false`) - missing-key detection needs to see
every locale file at once, so a partial file list from a commit that only touched one
locale would make the comparison meaningless.

For YAML locale files, give the hook PyYAML:

```yaml
      - id: translint
        args: [config/locales/, --base, en]
        additional_dependencies: ["PyYAML>=5.1"]
```

## As a GitHub Action

```yaml
- uses: munzzyy/translint@v0.5.0
  with:
    paths: locales/
    base: en
    strict: "true"
```

Fails the job the same way the CLI's exit code does. `strict: "true"` also fails on extra
keys and untranslated-value hits, not just missing keys/mismatches/empty values. The other
inputs are `format`, `recursive`, `locale-from`, `yaml` and `python-version`; the nested
layout is `recursive: "true"` plus `locale-from: dir`, and `yaml: "true"` installs PyYAML
on the runner first so `.yml`/`.yaml` files can be read.

## What it does NOT do

- **YAML needs an extra, and `--fix` does not write it.** Reading `.yml`/`.yaml` files
  takes PyYAML (`pip install translint[yaml]`), the one dependency translint has, and
  it's optional: nothing imports it until a YAML file is opened. `--fix` reports a
  missing key in a YAML file but will not insert it, because writing YAML back without
  reformatting the rest of the file needs more than PyYAML gives you.
- **The untranslated-value check is a heuristic**, covered honestly above. It flags what
  looks untranslated; it does not prove anything, and legitimate identical values need an
  allowlist entry, same as any linter's suppression comment.
- **It does not translate anything.** translint tells you what's missing or broken. Writing
  the actual translation is still your job (or your translator's, or your agent's, but
  translint is not going to guess at one). `--fix` (see [Fix mode](#fix-mode)) can insert a
  loudly-marked placeholder for a key that's missing entirely, but it never writes real
  translated text - that's a hard line, not a version-1 limitation.
- **It does not validate translation quality.** A translation that's grammatically wrong,
  culturally off, or just bad prose passes every check here as long as the keys, tokens,
  and non-empty-ness line up. That's a different problem than the one this tool solves.
- **Directory scans are flat unless you ask.** Pointing translint at a directory checks
  the files directly inside it. Pass `--recursive` (with `--locale-from dir` for the
  `en/common.json` layout) to descend into subdirectories - see
  [Directory layouts](#directory-layouts).

## Roadmap

What is left needs someone other than this repo's code: a release and native speakers.

- A native reader for each of the 32 languages on the site. It still says translint reads
  three file formats and no YAML. It reads five. New wording should not ship in a language
  until someone who speaks it has read it, the same kind of review that caught real
  mistranslations when the site was first translated.

## License

[GPL-3.0-or-later](LICENSE). You can use, study, change and share it. If you distribute a
copy or a modified version, it has to stay under the GPL and come with its source.
Releases up to v0.4.0 were under the Prosperity Public License 3.0.0. The repository
moved to MIT after that, and this release moves it to GPL-3.0-or-later.

## Support

If translint caught a broken locale before your users saw it, [sponsoring](https://github.com/sponsors/munzzyy) is what keeps it maintained.
