@katerynakhar/i18n-keeper 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +666 -0
- package/dist/apply.d.ts +66 -0
- package/dist/apply.js +254 -0
- package/dist/apply.js.map +1 -0
- package/dist/check.d.ts +5 -0
- package/dist/check.js +326 -0
- package/dist/check.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +525 -0
- package/dist/cli.js.map +1 -0
- package/dist/formats/error.d.ts +12 -0
- package/dist/formats/error.js +19 -0
- package/dist/formats/error.js.map +1 -0
- package/dist/formats/flatten.d.ts +6 -0
- package/dist/formats/flatten.js +26 -0
- package/dist/formats/flatten.js.map +1 -0
- package/dist/formats/json-write.d.ts +7 -0
- package/dist/formats/json-write.js +55 -0
- package/dist/formats/json-write.js.map +1 -0
- package/dist/formats/json.d.ts +6 -0
- package/dist/formats/json.js +21 -0
- package/dist/formats/json.js.map +1 -0
- package/dist/formats/php-write.d.ts +16 -0
- package/dist/formats/php-write.js +130 -0
- package/dist/formats/php-write.js.map +1 -0
- package/dist/formats/php.d.ts +44 -0
- package/dist/formats/php.js +356 -0
- package/dist/formats/php.js.map +1 -0
- package/dist/formats/po-write.d.ts +3 -0
- package/dist/formats/po-write.js +200 -0
- package/dist/formats/po-write.js.map +1 -0
- package/dist/formats/po.d.ts +30 -0
- package/dist/formats/po.js +202 -0
- package/dist/formats/po.js.map +1 -0
- package/dist/formats/write.d.ts +29 -0
- package/dist/formats/write.js +19 -0
- package/dist/formats/write.js.map +1 -0
- package/dist/formats/yaml-write.d.ts +12 -0
- package/dist/formats/yaml-write.js +42 -0
- package/dist/formats/yaml-write.js.map +1 -0
- package/dist/formats/yaml.d.ts +18 -0
- package/dist/formats/yaml.js +51 -0
- package/dist/formats/yaml.js.map +1 -0
- package/dist/glossary.d.ts +38 -0
- package/dist/glossary.js +140 -0
- package/dist/glossary.js.map +1 -0
- package/dist/lengths.d.ts +37 -0
- package/dist/lengths.js +195 -0
- package/dist/lengths.js.map +1 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +374 -0
- package/dist/mcp.js.map +1 -0
- package/dist/memory.d.ts +72 -0
- package/dist/memory.js +162 -0
- package/dist/memory.js.map +1 -0
- package/dist/placeholders.d.ts +15 -0
- package/dist/placeholders.js +103 -0
- package/dist/placeholders.js.map +1 -0
- package/dist/plurals.d.ts +59 -0
- package/dist/plurals.js +280 -0
- package/dist/plurals.js.map +1 -0
- package/dist/report.d.ts +2 -0
- package/dist/report.js +126 -0
- package/dist/report.js.map +1 -0
- package/dist/scan.d.ts +23 -0
- package/dist/scan.js +250 -0
- package/dist/scan.js.map +1 -0
- package/dist/translate.d.ts +93 -0
- package/dist/translate.js +369 -0
- package/dist/translate.js.map +1 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +50 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +28 -0
- package/dist/version.js.map +1 -0
- package/package.json +77 -0
- package/src/apply.ts +334 -0
- package/src/check.ts +468 -0
- package/src/cli.ts +637 -0
- package/src/formats/error.ts +19 -0
- package/src/formats/flatten.ts +38 -0
- package/src/formats/json-write.ts +66 -0
- package/src/formats/json.ts +27 -0
- package/src/formats/php-write.ts +146 -0
- package/src/formats/php.ts +414 -0
- package/src/formats/po-write.ts +230 -0
- package/src/formats/po.ts +233 -0
- package/src/formats/write.ts +41 -0
- package/src/formats/yaml-write.ts +53 -0
- package/src/formats/yaml.ts +62 -0
- package/src/glossary.ts +191 -0
- package/src/lengths.ts +214 -0
- package/src/mcp.ts +451 -0
- package/src/memory.ts +227 -0
- package/src/placeholders.ts +123 -0
- package/src/plurals.ts +320 -0
- package/src/report.ts +162 -0
- package/src/scan.ts +280 -0
- package/src/translate.ts +486 -0
- package/src/types.ts +136 -0
- package/src/version.ts +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KATERYNA
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
# i18n-keeper
|
|
2
|
+
|
|
3
|
+
[](https://github.com/katerynaKhar/i18n-keeper/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Deterministic linter for JSON, Laravel PHP, gettext and YAML locale files, as a
|
|
6
|
+
CLI and an MCP server. No LLM, no network, no API key — every finding is mechanically
|
|
7
|
+
verifiable, which is the point: you can trust the report in languages you do not
|
|
8
|
+
read.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx @katerynakhar/i18n-keeper check
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
i18n check · source: en · 16 keys · locales
|
|
16
|
+
|
|
17
|
+
locale coverage missing orphan stale errors warnings
|
|
18
|
+
de 93.8% 0 0 2 1 2
|
|
19
|
+
es 100.0% 0 1 2 0 5
|
|
20
|
+
fr 93.8% 1 0 2 2 3
|
|
21
|
+
pl 68.8% 4 0 1 8 2
|
|
22
|
+
|
|
23
|
+
errors
|
|
24
|
+
de nav.home structure_mismatch value in source, object in target
|
|
25
|
+
fr cart.total placeholder_missing {{amount}} lost
|
|
26
|
+
pl order.thanks placeholder_extra {{imie}} not in source
|
|
27
|
+
...
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Rules
|
|
31
|
+
|
|
32
|
+
| Rule | Default | What it catches |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `missing_key` | error | Not translated: absent from a target, or present and empty |
|
|
35
|
+
| `structure_mismatch` | error | Value on one side, object on the other |
|
|
36
|
+
| `placeholder_missing` | error | `{{name}}`, `%s`, `<0>` dropped in translation |
|
|
37
|
+
| `placeholder_extra` | error | Placeholder that does not exist in the source |
|
|
38
|
+
| `orphan_key` | warning | Key in a target locale, gone from the source |
|
|
39
|
+
| `identical_to_source` | off | Probably untranslated — but usually a proper noun, so opt in |
|
|
40
|
+
| `stale` | warning | Source changed after the translation was recorded |
|
|
41
|
+
| `untracked` | off | Translated but absent from the memory |
|
|
42
|
+
| `icu_syntax_error` | error | Malformed ICU message — throws at format time |
|
|
43
|
+
| `plural_missing_category` | warning | Plural lacks a form the target language requires |
|
|
44
|
+
| `plural_extra_category` | warning | Plural branch the target language never selects |
|
|
45
|
+
| `plural_selector_lost` | warning | Laravel `a|b` selection flattened to one form |
|
|
46
|
+
| `dnt_violation` | warning | A do-not-translate token did not survive |
|
|
47
|
+
| `glossary_violation` | warning | A glossary term rendered with an unapproved word |
|
|
48
|
+
| `inconsistent_translation` | off | One source string translated two different ways |
|
|
49
|
+
| `length_over_max` | warning | Wider than the limit configured for that key |
|
|
50
|
+
| `length_overflow` | off | Grew more than translation expansion normally allows |
|
|
51
|
+
|
|
52
|
+
Errors break at runtime. Warnings only look bad — an outdated translation still
|
|
53
|
+
renders, so `stale` is a warning even though it is the most interesting rule
|
|
54
|
+
here. Naming a rule with `--rule` also enables it, so `--rule untracked` works
|
|
55
|
+
without extra configuration.
|
|
56
|
+
|
|
57
|
+
## Translation memory
|
|
58
|
+
|
|
59
|
+
Everything above compares locales against each other, which any script can do.
|
|
60
|
+
The memory is what makes the difference: it remembers **which source string a
|
|
61
|
+
translation was made from**, so a later edit to the source surfaces every
|
|
62
|
+
translation that silently went out of date.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
i18n-keeper sync # record what is already translated
|
|
66
|
+
# ... someone edits an English string ...
|
|
67
|
+
i18n-keeper check # every locale still holding the old translation is stale
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The memory lives at `.i18n/memory.json`, sorted for readable diffs, and is meant
|
|
71
|
+
to be committed — it turns translation state into something reviewable in git.
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"version": 1,
|
|
76
|
+
"sourceLocale": "en",
|
|
77
|
+
"entries": {
|
|
78
|
+
"fr": {
|
|
79
|
+
"cart.checkout": {
|
|
80
|
+
"sourceHash": "9d0277a31e87",
|
|
81
|
+
"value": "Passer à la caisse",
|
|
82
|
+
"origin": "human",
|
|
83
|
+
"reviewed": true,
|
|
84
|
+
"updatedAt": "2026-08-30T15:03:09.972Z"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Two safeguards matter more than they look:
|
|
92
|
+
|
|
93
|
+
**`sync` never silently clears a stale flag.** An entry whose translation is
|
|
94
|
+
unchanged keeps its old source hash, because nothing about the translation was
|
|
95
|
+
actually redone. Only `sync --force` accepts the current state wholesale, and
|
|
96
|
+
the command says how many entries it deliberately left stale.
|
|
97
|
+
|
|
98
|
+
**A hand-edited translation is not called stale.** If the target no longer
|
|
99
|
+
matches what the memory recorded, someone already touched it and we cannot claim
|
|
100
|
+
it is outdated — so the rule stays quiet rather than guessing.
|
|
101
|
+
|
|
102
|
+
## String length
|
|
103
|
+
|
|
104
|
+
German runs about a third longer than English, so a button that fits in the
|
|
105
|
+
source overflows its container once translated — silently, because nothing
|
|
106
|
+
throws.
|
|
107
|
+
|
|
108
|
+
### Measured in display columns, not characters
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
"Subscribe" .length 9 columns 9
|
|
112
|
+
"Newsletter abonnieren" .length 21 columns 21
|
|
113
|
+
"ニュースレターを購読する" .length 12 columns 24
|
|
114
|
+
"설정" .length 2 columns 4
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
That Japanese string is twelve characters and would pass a limit of sixteen.
|
|
118
|
+
It occupies twenty-four columns and does not fit. CJK and fullwidth characters
|
|
119
|
+
count as two, combining marks and variation selectors as zero.
|
|
120
|
+
|
|
121
|
+
Strings that are never displayed whole are not measured whole: an ICU plural
|
|
122
|
+
holds every branch at once but shows one, so it is skipped entirely, and
|
|
123
|
+
Laravel's `a|b` is measured at its widest segment.
|
|
124
|
+
|
|
125
|
+
### Explicit limits
|
|
126
|
+
|
|
127
|
+
`.i18n/limits.json`, checked against every locale including the source:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"version": 1,
|
|
132
|
+
"keys": { "cta.subscribe": 16 },
|
|
133
|
+
"patterns": [{ "match": "nav.*.button", "max": 12 }]
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
An exact key beats a pattern, the first matching pattern beats `default`, and
|
|
138
|
+
anything unmatched is not checked. `*` matches any run of characters.
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
de cta.subscribe length_over_max 21 columns, limit 16
|
|
142
|
+
de nav.settings.button length_over_max 13 columns, limit 12
|
|
143
|
+
ja cta.subscribe length_over_max 24 columns, limit 16
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Expansion without configuration
|
|
147
|
+
|
|
148
|
+
`length_overflow` needs no limits file: it compares each translation to its
|
|
149
|
+
source and complains when it grew more than translation normally does. A single
|
|
150
|
+
ratio would be useless — short strings expand far more in relative terms — so
|
|
151
|
+
the allowance shrinks as strings grow:
|
|
152
|
+
|
|
153
|
+
| Source width | Allowed |
|
|
154
|
+
|---|---|
|
|
155
|
+
| ≤ 10 columns | 300% |
|
|
156
|
+
| ≤ 20 | 200% |
|
|
157
|
+
| ≤ 30 | 180% |
|
|
158
|
+
| ≤ 50 | 160% |
|
|
159
|
+
| ≤ 70 | 140% |
|
|
160
|
+
| longer | 130% |
|
|
161
|
+
|
|
162
|
+
These are the conventional expansion rules of thumb, not a standard, and the
|
|
163
|
+
rule is approximate by nature — so it is off until asked for with `--rule
|
|
164
|
+
length_overflow`.
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
de settings.delete length_overflow 36 columns vs 14 in source — 257%, allowance 200%
|
|
168
|
+
de body.welcome length_overflow 130 columns vs 93 in source — 140%, allowance 130%
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Glossary and do-not-translate
|
|
172
|
+
|
|
173
|
+
Translating one string well is easy. Keeping one word rendered the same way
|
|
174
|
+
across three thousand keys, several translators and two years is the part that
|
|
175
|
+
drifts — and it is checkable without knowing the language.
|
|
176
|
+
|
|
177
|
+
`.i18n/glossary.json`, committed alongside the memory:
|
|
178
|
+
|
|
179
|
+
```json
|
|
180
|
+
{
|
|
181
|
+
"version": 1,
|
|
182
|
+
"doNotTranslate": ["Acme", "GitHub", "OAuth"],
|
|
183
|
+
"terms": [
|
|
184
|
+
{
|
|
185
|
+
"source": "cart",
|
|
186
|
+
"targets": {
|
|
187
|
+
"fr": ["panier"],
|
|
188
|
+
"pl": ["koszyk"],
|
|
189
|
+
"ru": ["корзин"],
|
|
190
|
+
"ja": ["カート"]
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
]
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
A term is only checked in strings whose **source** actually contains it, and
|
|
198
|
+
only for locales the entry lists. Anything you have not defined is not judged.
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
pl cart.empty glossary_violation "cart" should be "koszyk"
|
|
202
|
+
pl auth.signin dnt_violation GitHub must stay verbatim
|
|
203
|
+
ja cart.add glossary_violation "cart" should be "カート"
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Matching is built for inflected languages
|
|
207
|
+
|
|
208
|
+
Demanding a literal substring would fire on every correctly translated Slavic
|
|
209
|
+
string, so matching is **prefix by default**: a term written `koszyk` accepts
|
|
210
|
+
`koszyka`, and `корзин` accepts `корзина`, `корзину` and `корзине`. Write the
|
|
211
|
+
stem, not the dictionary form. Per entry, `"match"` can be `"exact"` or
|
|
212
|
+
`"substring"` instead, and `"caseSensitive"` can be turned on.
|
|
213
|
+
|
|
214
|
+
A term still has to start a word, so `cart` does not match `Uncartlike`. That
|
|
215
|
+
check is skipped for scripts written without spaces — Japanese, Chinese, Thai,
|
|
216
|
+
Khmer, Lao, Burmese — where a term is normally surrounded by other letters and a
|
|
217
|
+
boundary test would never match at all.
|
|
218
|
+
|
|
219
|
+
Do-not-translate tokens are compared **case-sensitively**, because that is the
|
|
220
|
+
whole point of a brand name: `Github` is reported where `GitHub` was expected.
|
|
221
|
+
|
|
222
|
+
### Consistency without a glossary
|
|
223
|
+
|
|
224
|
+
`inconsistent_translation` needs no configuration: it reports one source string
|
|
225
|
+
that received two different translations within a locale. Reusing a wording is
|
|
226
|
+
often deliberate, so it is off until asked for with `--rule
|
|
227
|
+
inconsistent_translation`.
|
|
228
|
+
|
|
229
|
+
## Plural forms
|
|
230
|
+
|
|
231
|
+
English has two plural forms, Polish has four, Arabic has six, Japanese has one.
|
|
232
|
+
A translation copied from the English shape is therefore not merely stylistically
|
|
233
|
+
off — it renders the wrong grammar for whole ranges of numbers, silently.
|
|
234
|
+
|
|
235
|
+
Categories come from `Intl.PluralRules`, i.e. the ICU data already in the
|
|
236
|
+
runtime, rather than a table in this repository that would drift out of date.
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
pl cart.removed plural_missing_category pl needs one/few/many/other, has one/other
|
|
240
|
+
ja cart.removed plural_extra_category one is not a plural category in ja
|
|
241
|
+
ar file_* plural_missing_category ar needs zero/one/two/few/many/other, has one/other
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Three plural conventions are understood: ICU messages
|
|
245
|
+
(`{count, plural, one {# item} other {# items}}`), i18next suffix keys
|
|
246
|
+
(`item_one`, `item_few`), and Rails or Symfony nesting (`items.one`,
|
|
247
|
+
`items.few`). Findings name the group the way the project writes it —
|
|
248
|
+
`item_*` or `items.*`.
|
|
249
|
+
|
|
250
|
+
A single sibling is not treated as a plural group, so a key literally named
|
|
251
|
+
`numbers.one` is never asked to grow a `few` form.
|
|
252
|
+
|
|
253
|
+
Getting this right also **removes** findings that a locale-diffing tool would
|
|
254
|
+
otherwise invent:
|
|
255
|
+
|
|
256
|
+
- `item_few` exists in Polish and not in English. That is correct, not an orphan.
|
|
257
|
+
- `item_one` is absent from Japanese, which has no such form. That is correct,
|
|
258
|
+
not a missing key — and it is left out of the coverage denominator, so a
|
|
259
|
+
complete Japanese locale reads as 100%.
|
|
260
|
+
|
|
261
|
+
When a locale tag is not recognised, nothing is asserted. `Intl.PluralRules`
|
|
262
|
+
quietly falls back to the system locale for unknown tags — asking about `zz` on
|
|
263
|
+
a Russian machine reports four categories — so a resolved language subtag that
|
|
264
|
+
does not match the request is treated as unknown rather than as an answer.
|
|
265
|
+
|
|
266
|
+
Laravel's `a|b` and `{0} none|[1,*] many` selection is its own mechanism, not
|
|
267
|
+
CLDR, so it is not judged against CLDR categories. The one unambiguous failure —
|
|
268
|
+
a source that selects between forms translated as a single form — is reported as
|
|
269
|
+
`plural_selector_lost`.
|
|
270
|
+
|
|
271
|
+
## Placeholder syntaxes
|
|
272
|
+
|
|
273
|
+
Detected by default: `{{name}}` (mustache/i18next), `{name}` and
|
|
274
|
+
`{count, plural, ...}` (ICU), `%{name}` (Ruby), `%s` / `%1$s` (printf),
|
|
275
|
+
`<0>…</0>` (react-i18next `<Trans>`).
|
|
276
|
+
|
|
277
|
+
Patterns are applied most-specific first and each match is masked out, so
|
|
278
|
+
`{{name}}` is never also counted as `{name}`.
|
|
279
|
+
|
|
280
|
+
Laravel's `:name` is off in a plain JSON project, where it false-positives on
|
|
281
|
+
prose like `Warning:Important`. It turns on for a PHP project — one with PHP
|
|
282
|
+
language files, a `composer.json`, or a `lang/` directory — because Laravel
|
|
283
|
+
keeps string-keyed translations in `lang/xx.json`, placeholders and all, so the
|
|
284
|
+
file extension alone would miss them. `--syntax` overrides the choice.
|
|
285
|
+
|
|
286
|
+
## Formats and layouts
|
|
287
|
+
|
|
288
|
+
JSON, Laravel PHP, gettext and YAML, in either layout, auto-detected:
|
|
289
|
+
|
|
290
|
+
```
|
|
291
|
+
locales/en.json locales/en/common.json -> common.cart.total
|
|
292
|
+
lang/en.php lang/en/validation.php -> validation.max.string
|
|
293
|
+
config/locales/en.yml locale/en/LC_MESSAGES/app.po -> app.<msgid>
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Formats can coexist: a locale directory holding `messages.php` next to a
|
|
297
|
+
`lang/en.json` is read as one keyspace. When PHP files are present, Laravel's
|
|
298
|
+
`:name` interpolation is enabled automatically, and `:name`, `:Name` and
|
|
299
|
+
`:NAME` are treated as one placeholder because Laravel renders them from the
|
|
300
|
+
same replacement.
|
|
301
|
+
|
|
302
|
+
### gettext
|
|
303
|
+
|
|
304
|
+
The msgid *is* the source text, so a `.pot` — or any catalogue with empty
|
|
305
|
+
`msgstr` — works as the source locale without a parallel English file.
|
|
306
|
+
|
|
307
|
+
The format also already tracks what the translation memory was built for: an
|
|
308
|
+
entry flagged `#, fuzzy` is reported as `stale` with no memory involved.
|
|
309
|
+
|
|
310
|
+
```
|
|
311
|
+
fr messages.Add to cart stale marked fuzzy in the catalogue
|
|
312
|
+
fr messages.Welcome, %s! placeholder_missing %s lost
|
|
313
|
+
pl messages.adjective|Open missing_key not translated
|
|
314
|
+
pl messages.%d file plural_missing_category header declares nplurals=3, entry has 2
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`msgctxt` disambiguates, and shows in keys as `context|msgid`. Entries
|
|
318
|
+
commented out with `#~` are already removed from the catalogue and are not
|
|
319
|
+
reported as orphans. `LC_MESSAGES` is dropped from key paths, since it is
|
|
320
|
+
directory layout rather than namespace. The last check above needs no CLDR at
|
|
321
|
+
all: the catalogue header states its own form count.
|
|
322
|
+
|
|
323
|
+
### YAML
|
|
324
|
+
|
|
325
|
+
Rails nests a whole file under its locale code, which is stripped — otherwise
|
|
326
|
+
every key in `en.yml` would differ from every key in `fr.yml`. Rails also
|
|
327
|
+
writes plurals as nested `one:` / `other:` keys, which are recognised
|
|
328
|
+
alongside i18next's `item_one` suffixes.
|
|
329
|
+
|
|
330
|
+
This is the one format with a dependency (`yaml`). The PHP parser is hand
|
|
331
|
+
written because the alternative there was executing untrusted code; YAML poses
|
|
332
|
+
no such hazard, and its spec is deep enough — anchors, block scalars, implicit
|
|
333
|
+
typing — that a hand-rolled subset would quietly misread real files. Notably,
|
|
334
|
+
under YAML 1.1 a `no:` key becomes `false`, which would silently corrupt a
|
|
335
|
+
Norwegian entry; the library's 1.2 default keeps it a string.
|
|
336
|
+
|
|
337
|
+
### PHP files are parsed, never executed
|
|
338
|
+
|
|
339
|
+
Locale files come from the repository being linted. Running them would mean
|
|
340
|
+
executing untrusted code, and would force PHP onto every machine and CI runner
|
|
341
|
+
using the linter. So `i18n-keeper` ships its own parser for the
|
|
342
|
+
`<?php return [...];` subset — literal arrays, both quote styles with full
|
|
343
|
+
escape handling, `array()`, integer and string keys, and all three comment
|
|
344
|
+
styles.
|
|
345
|
+
|
|
346
|
+
Anything outside that subset — variables, interpolation, concatenation,
|
|
347
|
+
function calls, heredocs, statements after the return — is a clear error naming
|
|
348
|
+
the line, not a silent guess:
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
Cannot parse lang/fr.php
|
|
352
|
+
Constants and function calls are not supported (line 4)
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
The parser is verified differentially against PHP itself: `npm run test:php`
|
|
356
|
+
reads every fixture with the real interpreter and compares the two results
|
|
357
|
+
structurally. PHP is a development dependency for that test only.
|
|
358
|
+
|
|
359
|
+
## Usage
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
i18n-keeper check [path] lint locale files
|
|
363
|
+
i18n-keeper scan [path] show what would be checked
|
|
364
|
+
i18n-keeper sync [path] record current translations in the memory
|
|
365
|
+
i18n-keeper translate [path] fill the missing and stale set with Claude
|
|
366
|
+
i18n-keeper apply <file> [path] write proposals saved by translate
|
|
367
|
+
i18n-keeper review [path] sign off on machine translations
|
|
368
|
+
|
|
369
|
+
--locales <dir> locales directory (default: auto-detect)
|
|
370
|
+
--source <locale> source locale (default: en, else the first found)
|
|
371
|
+
--locale <locale> limit to this locale (repeatable)
|
|
372
|
+
--memory <file> translation memory (default: .i18n/memory.json)
|
|
373
|
+
--no-memory ignore the memory; disables stale detection
|
|
374
|
+
--glossary <file> glossary (default: .i18n/glossary.json)
|
|
375
|
+
--no-glossary ignore the glossary
|
|
376
|
+
--limits <file> width limits (default: .i18n/limits.json)
|
|
377
|
+
--no-limits ignore the width limits
|
|
378
|
+
|
|
379
|
+
check
|
|
380
|
+
--rule <rule> only report this rule, enabling it if off (repeatable)
|
|
381
|
+
--ignore-identical <a,b> values allowed to equal the source
|
|
382
|
+
--syntax <a,b> override placeholder syntaxes
|
|
383
|
+
--limit <n> max findings printed (default: 40)
|
|
384
|
+
--json machine-readable output
|
|
385
|
+
|
|
386
|
+
sync
|
|
387
|
+
--origin <human|machine> who produced these translations (default: human)
|
|
388
|
+
--force re-record unchanged translations, clearing stale
|
|
389
|
+
|
|
390
|
+
translate
|
|
391
|
+
--write apply accepted translations (default: write nothing)
|
|
392
|
+
--cap <n> most strings per run (default: 50)
|
|
393
|
+
--batch <n> strings per request (default: 20)
|
|
394
|
+
--model <id> default: claude-opus-5
|
|
395
|
+
--effort <level> low|medium|high|xhigh|max (default: medium)
|
|
396
|
+
--only <kind> fill | repair | refresh (repeatable; default: all)
|
|
397
|
+
--save <file> keep the proposals for a later apply
|
|
398
|
+
|
|
399
|
+
apply
|
|
400
|
+
--dry-run re-check the saved proposals and report, writing nothing
|
|
401
|
+
|
|
402
|
+
review
|
|
403
|
+
--key <k> only this key (repeatable)
|
|
404
|
+
--all every unreviewed translation
|
|
405
|
+
--dry-run list what would be signed off, changing nothing
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Exit codes: `0` clean, `1` at least one error, `2` the tool itself failed.
|
|
409
|
+
Suitable for CI and pre-commit as-is.
|
|
410
|
+
|
|
411
|
+
## MCP server
|
|
412
|
+
|
|
413
|
+
The same core is exposed over MCP, so an agent can audit locales itself.
|
|
414
|
+
|
|
415
|
+
```bash
|
|
416
|
+
claude mcp add i18n-keeper -- node /path/to/i18n-keeper/dist/mcp.js
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Or per project, in `.mcp.json`:
|
|
420
|
+
|
|
421
|
+
```json
|
|
422
|
+
{
|
|
423
|
+
"mcpServers": {
|
|
424
|
+
"i18n-keeper": {
|
|
425
|
+
"command": "node",
|
|
426
|
+
"args": ["/path/to/i18n-keeper/dist/mcp.js"]
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
| Tool | Returns |
|
|
433
|
+
|---|---|
|
|
434
|
+
| `i18n_scan` | Locale directory, layout, locales and whether a memory exists |
|
|
435
|
+
| `i18n_status` | Per-locale coverage and counts, no individual findings |
|
|
436
|
+
| `i18n_check` | Findings, filterable by `locale` / `rule` / `severity`, paged via `offset` |
|
|
437
|
+
| `i18n_sync` | Records translations in the memory — the only tool that writes |
|
|
438
|
+
|
|
439
|
+
Findings are paged (25 per call by default) because tool output costs the agent
|
|
440
|
+
context; `i18n_status` exists so an agent can get the shape of the problem for a
|
|
441
|
+
few dozen tokens before asking for detail. `i18n_status`, `i18n_check` and
|
|
442
|
+
`i18n_sync` also return `structuredContent`, so the numbers can be consumed
|
|
443
|
+
without parsing the table.
|
|
444
|
+
|
|
445
|
+
## Machine translation
|
|
446
|
+
|
|
447
|
+
Everything above is deterministic and offline. This one command is neither: it
|
|
448
|
+
calls Claude, and its output cannot be verified by reading it in a language you
|
|
449
|
+
do not speak.
|
|
450
|
+
|
|
451
|
+
So it is not trusted. **Every proposal goes back through the same checks the
|
|
452
|
+
linter applies, and anything that fails is rejected rather than written.**
|
|
453
|
+
|
|
454
|
+
```bash
|
|
455
|
+
i18n-keeper translate # propose, validate, print — writes nothing
|
|
456
|
+
i18n-keeper translate --write # also apply the accepted ones
|
|
457
|
+
i18n-keeper translate --save review.json # keep the proposals for later
|
|
458
|
+
i18n-keeper apply review.json # write them, without translating again
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
The work list comes from the report, so the linter decides what needs doing —
|
|
462
|
+
in three kinds:
|
|
463
|
+
|
|
464
|
+
| Kind | From | Meaning |
|
|
465
|
+
|---|---|---|
|
|
466
|
+
| `fill` | `missing_key` | No usable translation exists — absent, or present and empty |
|
|
467
|
+
| `repair` | see below | One exists and the linter proved it wrong |
|
|
468
|
+
| `refresh` | `stale` | One exists and its source has moved |
|
|
469
|
+
|
|
470
|
+
Narrow it with `--only fill`, `--only repair`, `--only refresh` (repeatable).
|
|
471
|
+
|
|
472
|
+
### What counts as repairable
|
|
473
|
+
|
|
474
|
+
A defect is only handed back to the model if the local check can **confirm the
|
|
475
|
+
repair afterwards**. A fix nobody can verify is a fix nobody should trust, so
|
|
476
|
+
those findings are left for a human. That single rule picks the set:
|
|
477
|
+
|
|
478
|
+
`placeholder_missing`, `placeholder_extra`, `icu_syntax_error`,
|
|
479
|
+
`plural_missing_category`, `glossary_violation`, `dnt_violation`,
|
|
480
|
+
`length_over_max`.
|
|
481
|
+
|
|
482
|
+
It leaves out `identical_to_source` — often correct, since "Email" really is
|
|
483
|
+
"Email" in French, and forcing a change would make it worse — along with
|
|
484
|
+
`plural_extra_category` and `plural_selector_lost`, which the single-string
|
|
485
|
+
validator does not check and therefore could not confirm.
|
|
486
|
+
|
|
487
|
+
A repair is sent with the wording someone already chose and the exact findings
|
|
488
|
+
against it, and asked to change only what those require:
|
|
489
|
+
|
|
490
|
+
```json
|
|
491
|
+
{
|
|
492
|
+
"key": "order.thanks",
|
|
493
|
+
"source": "Thanks, {{name}}!",
|
|
494
|
+
"placeholders_that_must_survive": ["{{name}}"],
|
|
495
|
+
"current_translation": "Dziękujemy, {{imie}}!",
|
|
496
|
+
"problems_to_fix": [
|
|
497
|
+
"placeholder_extra: {{imie}} not in source",
|
|
498
|
+
"placeholder_missing: {{name}} lost"
|
|
499
|
+
]
|
|
500
|
+
}
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
One key broken several ways carries every reason at once, and a repair that does
|
|
504
|
+
not actually repair is rejected like any other proposal.
|
|
505
|
+
|
|
506
|
+
### The constraints go in, not just on afterwards
|
|
507
|
+
|
|
508
|
+
Each string is sent with everything the checks will later demand of it: the
|
|
509
|
+
placeholders that must survive, the plural categories the target language
|
|
510
|
+
requires, the glossary forms and do-not-translate tokens that apply to *that*
|
|
511
|
+
string, and the width limit for that key.
|
|
512
|
+
|
|
513
|
+
Then the result is checked anyway. When a proposal fails, it goes back once with
|
|
514
|
+
the specific rule it broke:
|
|
515
|
+
|
|
516
|
+
```
|
|
517
|
+
request: fr, 4 strings
|
|
518
|
+
request: fr, 3 strings (retry)
|
|
519
|
+
|
|
520
|
+
accept cart.empty Votre panier est vide
|
|
521
|
+
accept cart.total Total : {{amount}}
|
|
522
|
+
REJECT nav.subscribe S'abonner à la lettre d'information
|
|
523
|
+
! 35 columns, limit 12
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
The first attempt had dropped `{{amount}}` and translated *cart* as *chariot*
|
|
527
|
+
against the glossary; the retry fixed both. The third string was too wide twice
|
|
528
|
+
and was never written.
|
|
529
|
+
|
|
530
|
+
### Look first, apply later, pay once
|
|
531
|
+
|
|
532
|
+
`--save` keeps the proposals in a file that `apply` can write afterwards, so
|
|
533
|
+
reviewing before applying does not mean paying for the translation twice. The
|
|
534
|
+
file is written even when a run stops early, so partial work survives.
|
|
535
|
+
|
|
536
|
+
**`apply` puts every proposal through the checks again.** A saved file can be
|
|
537
|
+
days old and is editable by hand, so nothing is written on the strength of a
|
|
538
|
+
check made earlier against files that may since have moved:
|
|
539
|
+
|
|
540
|
+
```
|
|
541
|
+
dropped
|
|
542
|
+
fr cart.total ! placeholders lost: {{amount}}
|
|
543
|
+
fr gone ! the key is no longer in the source locale
|
|
544
|
+
fr moved ! the source string changed after the proposal was made
|
|
545
|
+
fr cart.empty ! was rejected when proposed
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
The first of those is a translation someone edited inside the saved file after
|
|
549
|
+
it had passed. It does not get written.
|
|
550
|
+
|
|
551
|
+
`apply --dry-run` reports without writing.
|
|
552
|
+
|
|
553
|
+
### Nothing is written by accident
|
|
554
|
+
|
|
555
|
+
Without `--write` the command only prints. With it, accepted translations are
|
|
556
|
+
applied and recorded in the memory as `origin: "machine"`, `reviewed: false` —
|
|
557
|
+
so a human can find every unreviewed machine string later, and `stale` keeps
|
|
558
|
+
working from there.
|
|
559
|
+
|
|
560
|
+
### Writing back without losing the file
|
|
561
|
+
|
|
562
|
+
Reading discards everything that is not a key or a value: comments, quote
|
|
563
|
+
styles, blank lines, anchors, translator notes. Writing must not. So no writer
|
|
564
|
+
re-serialises a parsed tree — each edits the text in place and leaves every byte
|
|
565
|
+
it had no reason to touch.
|
|
566
|
+
|
|
567
|
+
- **JSON** is the exception, and the easy one: no comments, no styles, nothing
|
|
568
|
+
to lose, so it is re-serialised.
|
|
569
|
+
- **PHP** replaces the exact span of one value. A comment above the entry, a
|
|
570
|
+
`'C:\\Users\\shared'` escape, a `"caf\u{e9}"` written with a unicode escape —
|
|
571
|
+
all come out byte for byte as they went in. New keys are inserted into their
|
|
572
|
+
array with the indentation the neighbours use, and missing levels are created.
|
|
573
|
+
- **YAML** goes through the document model, which keeps comments and anchors
|
|
574
|
+
across a round trip. An existing scalar is mutated rather than replaced, so a
|
|
575
|
+
block scalar stays a block scalar. **An alias is refused**: writing through
|
|
576
|
+
`*shared` would silently change every key that shares the anchor.
|
|
577
|
+
- **gettext** is edited by line, leaving the header, obsolete `#~` entries and
|
|
578
|
+
multi-line msgids alone. A new entry is appended with its `msgctxt`.
|
|
579
|
+
|
|
580
|
+
A gettext entry written this way is marked `#, fuzzy`. That flag is gettext's
|
|
581
|
+
own word for "no person has reviewed this", which is exactly what the memory
|
|
582
|
+
records as `reviewed: false`; leaving it off would claim an approval nobody
|
|
583
|
+
gave. `check` then reports those entries as stale, which is correct.
|
|
584
|
+
|
|
585
|
+
A locale file that does not exist yet is created for JSON and PHP, whose empty
|
|
586
|
+
form is unambiguous. It is not invented for YAML, whose shape depends on whether
|
|
587
|
+
the project nests under a locale root, nor for gettext, whose header declares
|
|
588
|
+
the language's own plural rules. Those are reported as not written.
|
|
589
|
+
|
|
590
|
+
Exit code is 1 whenever anything was rejected, so a pipeline notices.
|
|
591
|
+
|
|
592
|
+
A refusal applies to one batch and the run carries on. Anything else — no
|
|
593
|
+
credentials, a rate limit, a dropped connection — will hit every remaining
|
|
594
|
+
batch identically, so the run stops and says how many strings were never
|
|
595
|
+
attempted. Those are not reported as rejections: the checks never saw them.
|
|
596
|
+
|
|
597
|
+
### Signing off
|
|
598
|
+
|
|
599
|
+
Recording machine output as unreviewed is only useful if there is a way out of
|
|
600
|
+
that state, and nothing else in the tool provides one — not `sync`, not
|
|
601
|
+
`--force`. Only a person can say a translation is good:
|
|
602
|
+
|
|
603
|
+
```bash
|
|
604
|
+
i18n-keeper review # what is waiting, changing nothing
|
|
605
|
+
i18n-keeper review --locale pl # sign off on one language
|
|
606
|
+
i18n-keeper review --key cart.empty # or one string
|
|
607
|
+
i18n-keeper review --all # everything outstanding
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
With no selection it lists the queue with the source beside each translation,
|
|
611
|
+
so the review can happen in the terminal rather than by hunting through files:
|
|
612
|
+
|
|
613
|
+
```
|
|
614
|
+
pl cart.empty (machine)
|
|
615
|
+
en Your cart is empty
|
|
616
|
+
pl Twój koszyk jest pusty
|
|
617
|
+
|
|
618
|
+
3 waiting. Sign off with --all, or narrow with --locale / --key.
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
A sign-off records `reviewedAt` and survives later edits to the *source*; only
|
|
622
|
+
rewriting the translation itself clears it. `--dry-run` shows what would be
|
|
623
|
+
marked without touching the file.
|
|
624
|
+
|
|
625
|
+
### Cost and credentials
|
|
626
|
+
|
|
627
|
+
Needs `ANTHROPIC_API_KEY`, or a profile from `ant auth login`. Defaults to
|
|
628
|
+
`claude-opus-5` at `--effort medium`, batches of 20 strings, and at most 50
|
|
629
|
+
strings per run — raise with `--cap`. The source strings, their keys and their
|
|
630
|
+
constraints are what gets sent.
|
|
631
|
+
|
|
632
|
+
## Known limits
|
|
633
|
+
|
|
634
|
+
- A gettext key joins `msgctxt` and `msgid` with `|`. A contextless msgid that
|
|
635
|
+
contains a pipe is indistinguishable from a contextual one, which only matters
|
|
636
|
+
when appending an entry the catalogue has never carried.
|
|
637
|
+
- New locale files are created for JSON and PHP only; see above.
|
|
638
|
+
|
|
639
|
+
## Development
|
|
640
|
+
|
|
641
|
+
```bash
|
|
642
|
+
npm install
|
|
643
|
+
npm run build
|
|
644
|
+
npm run demo # CLI against the JSON demo fixture
|
|
645
|
+
npm run demo:laravel # CLI against the Laravel fixture
|
|
646
|
+
npm run walkthrough # the whole memory/stale lifecycle, step by step
|
|
647
|
+
npm run smoke # drives the MCP server as a real client would
|
|
648
|
+
npm run test:php # our PHP parser vs the real interpreter (needs php)
|
|
649
|
+
npm run test:laravel # placeholder casing, flat PHP layout, parse errors
|
|
650
|
+
npm run test:plurals # ICU scanner and CLDR category resolution
|
|
651
|
+
npm run test:glossary # term matching across scripts, and glossary errors
|
|
652
|
+
npm run test:lengths # display width, limit resolution, limits-file errors
|
|
653
|
+
npm run test:formats # gettext parsing, YAML typing traps, parse errors
|
|
654
|
+
npm run test:translate # the translation gate and repairs, against a stub client
|
|
655
|
+
npm run test:apply # save/apply, and every way a saved proposal goes stale
|
|
656
|
+
npm run test:writers # writing into PHP, YAML and gettext without losing anything
|
|
657
|
+
npm run test:review # the review queue and its only exit
|
|
658
|
+
npm run test:detection # what a real third-party project taught the scanner
|
|
659
|
+
npm run demo:plurals # five locales with one, two, four and six plural forms
|
|
660
|
+
npm run demo:glossary # inflection, Cyrillic stems, CJK and brand names
|
|
661
|
+
npm run demo:lengths # German expansion and double-width Japanese
|
|
662
|
+
npm run demo:gettext # fuzzy entries, msgctxt, nplurals
|
|
663
|
+
npm run demo:rails # locale roots and nested plural keys
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
MIT.
|