localewarden 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Martin Brezina
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 CHANGED
@@ -1,3 +1,269 @@
1
- # Temporary Holding Version
1
+ # localewarden
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Incremental AI translation for JSON locale files.** It translates only what changed, never overwrites a translation a person fixed, and checks every result before it is written.
4
+
5
+ ```bash
6
+ npx localewarden init # create a config
7
+ npx localewarden --dry-run # see what would be translated and roughly what it costs
8
+ npx localewarden # translate new and changed strings
9
+ npx localewarden check # quality check, no API calls (use it in CI)
10
+ ```
11
+
12
+ Works with i18next, react-intl / FormatJS, vue-i18n, next-intl, ngx-translate and any other setup that keeps strings in JSON files. Uses any OpenAI-compatible API (OpenAI, OpenRouter, a local Ollama, ...).
13
+
14
+ ## Why
15
+
16
+ Translating locale files with a language model is easy once. Keeping 20 languages correct while the source keeps changing is not:
17
+
18
+ - **Re-translating everything** on every change costs money and rewrites wording that was already fine.
19
+ - **Hand fixes get overwritten** by the next run.
20
+ - **Models are inconsistent across batches.** French screens mix "tu" and "vous", Spanish copies English Title Case ("Configure Su Cuenta"), and Polish or Russian address every user as a man.
21
+ - **Broken output ships silently.** A translated placeholder (`{heures}` instead of `{hours}`) shows raw braces in your app. A dropped `</strong>` breaks the layout. Stray Cyrillic letters end up in a Danish sentence.
22
+
23
+ localewarden grew out of the translation pipeline of a production app that ships in 38 languages. Every rule and check in it exists because one of these failures happened in real output.
24
+
25
+ ## What it does
26
+
27
+ - **Translates only what changed.** It remembers a hash of each source string per language. New strings are translated. Changed strings are *revised*: the model gets the existing translation and changes only what the source change requires. Removed strings are deleted from every language.
28
+ - **Protects hand edits.** If someone edited a translation, localewarden detects it, keeps it, and lists it for review. If the source of a hand-edited string changes later, the string is flagged instead of overwritten.
29
+ - **Checks every result before writing it.** Broken placeholders, foreign alphabets, changed links, broken HTML and echoed source text are rejected (retried once, then left for the next run). Softer problems are retried and reported.
30
+ - **Consistent style per language.** It enforces formal or informal address per language (`du`/`Sie`, `tu`/`vous`, `ты`/`вы` and 16 more), uses sentence case where the language does, avoids gendered forms for "you", and applies local typography (French spacing, `92 %` in German, CJK quotation marks).
31
+ - **Glossary and protected names.** You choose fixed renderings ("Privacy Policy" -> "Politique de confidentialité") and names that must never be translated. The check accepts grammatical case endings.
32
+ - **Quality check for CI.** `localewarden check` runs all checks without any API calls and exits non-zero on errors.
33
+ - **Targeted repair.** `--fix-flagged` asks the model to fix only what the check flagged. The fix is accepted only if the problem is gone and little else changed.
34
+ - **Budget control.** A token budget per run, a dry run with a cost estimate, and graceful stop and resume.
35
+ - **No runtime dependencies.** Node.js 20+.
36
+
37
+ ## Example
38
+
39
+ Source (`locales/en.json`) and config for a small plant-watering app:
40
+
41
+ ```json
42
+ {
43
+ "app": { "tagline": "Keep Your Plants Alive Without Thinking About It" },
44
+ "onboarding": {
45
+ "welcome": "Welcome to Plantly, {name}!",
46
+ "tip": "Most houseplants tend to need less water in winter."
47
+ },
48
+ "reminders": { "due_other": "{{count}} plants need water today", "snooze": "Remind Me Tomorrow" },
49
+ "settings": { "privacy": "Read our <a href=\"/privacy\">Privacy Policy</a> to see what we store." }
50
+ }
51
+ ```
52
+
53
+ ```json
54
+ {
55
+ "targetLanguages": ["de", "fr", "es", "ja"],
56
+ "files": "locales/{lang}.json",
57
+ "context": "Plantly is a mobile app that reminds people when to water their houseplants.",
58
+ "doNotTranslate": ["Plantly"],
59
+ "formality": { "de": "informal", "fr": "formal", "es": "informal" },
60
+ "glossary": { "fr": { "Privacy Policy": "Politique de confidentialité" } },
61
+ "termNotes": { "snooze": "postpone a reminder, not sleep" }
62
+ }
63
+ ```
64
+
65
+ Real output with the default model (`gpt-5.4-mini`), 4 requests, about 6,000 tokens:
66
+
67
+ ```text
68
+ de Halte deine Pflanzen am Leben, ohne daran denken zu müssen
69
+ Die meisten Zimmerpflanzen brauchen im Winter tendenziell weniger Wasser.
70
+ Erinnere mich morgen
71
+ fr Gardez vos plantes en vie sans y penser
72
+ Bienvenue sur Plantly, {name} !
73
+ Lisez notre <a href="/privacy">Politique de confidentialité</a> pour voir ce que nous stockons.
74
+ es Mantén vivas tus plantas sin tener que pensar en ello
75
+ Hoy necesitan agua {{count}} plantas
76
+ ja 何も考えなくても、植物を元気に保てます
77
+ ```
78
+
79
+ German uses "du" and French "vous", as configured. Spanish and French use sentence case, not the English Title Case. French has its space before "!". The hedge "tend to" survived, and so did the placeholders, the link and the brand name. The full example is in [`examples/basic`](examples/basic).
80
+
81
+ ## Quick start
82
+
83
+ 1. Install, or use `npx`:
84
+
85
+ ```bash
86
+ npm install --save-dev localewarden
87
+ ```
88
+
89
+ 2. Create a config. `init` looks for common locale folder layouts:
90
+
91
+ ```bash
92
+ npx localewarden init
93
+ ```
94
+
95
+ 3. Edit `localewarden.config.json`: set `targetLanguages`, `files` and `context`.
96
+
97
+ 4. Set your API key and do a dry run:
98
+
99
+ ```bash
100
+ export OPENAI_API_KEY=sk-...
101
+ npx localewarden --dry-run
102
+ ```
103
+
104
+ 5. Translate, then commit the locale files **and** the `.localewarden/` folder:
105
+
106
+ ```bash
107
+ npx localewarden
108
+ git add locales .localewarden
109
+ ```
110
+
111
+ `.localewarden/` holds the hashes that tell localewarden what changed and what was edited by hand. Commit it so your team and your CI share the same state.
112
+
113
+ ## How it decides what to translate
114
+
115
+ For every string and language:
116
+
117
+ | Situation | What happens |
118
+ | --- | --- |
119
+ | No translation yet | Translated |
120
+ | Translation exists, localewarden has never seen it (first run) | Kept as is. Your existing translations are adopted, not re-translated. Exception: a copy of the source text is translated. |
121
+ | Source changed since the last translation | Revised: the model gets the old translation and changes only what is needed |
122
+ | Translation differs from what localewarden wrote | Hand edit: kept, protected, listed in `localewarden review` |
123
+ | Hand-edited, and then the source changed | Kept and flagged for review (`source-changed`) |
124
+ | String removed from the source | Removed from the translation |
125
+ | Result fails a hard check | Not written. The string is retried on the next run. |
126
+
127
+ ## Quality checks
128
+
129
+ The same checks run in two places. Right after each model answer, a failed hard check means the translation is never written. `localewarden check` runs all of them over your files without any API calls.
130
+
131
+ | Check | Finds | Severity |
132
+ | --- | --- | --- |
133
+ | `placeholder` | `{name}`, `{{count}}`, `%s`, `%1$d`, `%{x}`, `${x}`, `<0></0>` renamed, translated, added or dropped. ICU `plural`/`select` arguments are compared, while plural categories may differ per language. | error |
134
+ | `script` | Letters from an alphabet the language does not use ("刺激" in German), or a word that mixes Latin with Cyrillic/Greek lookalikes ("Вarda") | error |
135
+ | `markup` | Changed link targets, different number of tags, unclosed or misnested tags, dropped list items | warning (broken tags and changed links: never written) |
136
+ | `years` | A year from the source missing or changed (citations, dates) | warning |
137
+ | `formality` | The other form of address than configured, both forms in one string, or masculine-only forms for "you" | warning |
138
+ | `titlecase` | English Title Case copied into a language that uses sentence case | warning |
139
+ | `ampersand` | "&" in languages that write the word | warning |
140
+ | `glossary` | A glossary rendering missing (case endings allowed), or a `doNotTranslate` name translated | warning |
141
+ | `untranslated` | Identical to the source (prose of 3+ words; "OK" and names are fine) | warning |
142
+ | `partial` | Source-language words left inside the translation, an untranslated bold lead-in, or a hedge that became certainty ("tend to" stated as fact) | warning |
143
+
144
+ ```bash
145
+ npx localewarden check # counts per language and check
146
+ npx localewarden check -v # with examples
147
+ npx localewarden check --strict # exit 1 on warnings too
148
+ npx localewarden check --json # for scripts
149
+ ```
150
+
151
+ Approved hand edits are skipped, except for placeholder and script errors, which break the app either way.
152
+
153
+ ### In CI
154
+
155
+ ```yaml
156
+ # .github/workflows/i18n.yml
157
+ name: i18n
158
+ on: [pull_request]
159
+ jobs:
160
+ check:
161
+ runs-on: ubuntu-latest
162
+ steps:
163
+ - uses: actions/checkout@v4
164
+ - uses: actions/setup-node@v4
165
+ with: { node-version: 22 }
166
+ - run: npx localewarden check
167
+ ```
168
+
169
+ ### Fixing what the check finds
170
+
171
+ ```bash
172
+ npx localewarden --fix-flagged
173
+ ```
174
+
175
+ For each flagged string, the model gets the source, the current translation and the exact findings, with the instruction to change only what is needed. The fix is written only if the same check passes afterwards, nothing else breaks, and few words changed. Rejected fixes are recorded in `.localewarden/repair-failures.json` and not retried until the translation changes.
176
+
177
+ ## Hand edits and review
178
+
179
+ ```bash
180
+ npx localewarden review # list pending hand edits
181
+ npx localewarden review --approve de:home.title # correct: keep it protected
182
+ npx localewarden review --approve de:* # all German entries
183
+ npx localewarden review --release de:home.title # hand it back: next run revises it
184
+ ```
185
+
186
+ `--overwrite-manual` makes a run replace hand edits on purpose.
187
+
188
+ ## Configuration
189
+
190
+ `localewarden.config.json`:
191
+
192
+ | Option | Default | Description |
193
+ | --- | --- | --- |
194
+ | `targetLanguages` | (required) | Language codes, e.g. `["de", "fr", "pt-BR", "zh-TW"]` |
195
+ | `files` | (required) | Path pattern with `{lang}`. `*` matches within one folder, `**/` any depth. Examples: `locales/{lang}.json`, `public/locales/{lang}/*.json`, `src/**/i18n/{lang}.json`, `messages.{lang}.json` |
196
+ | `sourceLanguage` | `"en"` | Language of the source files. Some checks (title case, source words left in, hedges) assume English. |
197
+ | `context` | | One or two sentences about your product. This is the most effective way to get the right meaning. |
198
+ | `tone` | | e.g. `"friendly and plain, no marketing hype"` |
199
+ | `doNotTranslate` | `[]` | Brand, product and feature names that must stay as written |
200
+ | `formality` | `{}` | `{"de": "informal", "fr": "formal"}`. Built-in rules for fr, de, es, it, pt, nl, ru, uk, cs, sk, pl, ro, ca, id, ms, hr, sl, tr, el. Other languages get a general instruction. |
201
+ | `genderNeutral` | `true` | Avoid gendered forms when addressing the user |
202
+ | `sentenceCase` | `true` | Use sentence case in languages that do not capitalise titles |
203
+ | `glossary` | `{}` | `{"fr": {"Terms of Service": "Conditions d'utilisation"}}` |
204
+ | `termNotes` | `{}` | Meanings of ambiguous terms, sent only with strings that contain them: `{"snooze": "postpone a reminder"}` |
205
+ | `instructions` | `{}` | Extra instructions per language, `"*"` for all: `{"es": "Use neutral Latin American Spanish."}` |
206
+ | `placeholders` | built-in | Regular expressions (strings) that match your placeholders. Replaces the built-in list. |
207
+ | `model` | `"gpt-5.4-mini"` | Any chat model your endpoint offers |
208
+ | `baseUrl` | `"https://api.openai.com/v1"` | Any OpenAI-compatible endpoint |
209
+ | `apiKeyEnv` | `"OPENAI_API_KEY"` | Environment variable that holds the key |
210
+ | `reasoningEffort` | `"medium"` for reasoning models | `"low"` is cheaper. `"medium"` gave noticeably more natural wording in our tests. |
211
+ | `temperature` | `0.3` for other models | |
212
+ | `maxTokensPerRun` | `500000` | The run stops when the API-reported usage reaches this |
213
+ | `concurrency` | `4` | Languages translated in parallel |
214
+ | `batchSize` | `20` | Strings per request (smaller for scripts that need many tokens) |
215
+ | `stateDir` | `".localewarden"` | Where state and the review list live |
216
+
217
+ ### Other providers
218
+
219
+ ```json
220
+ { "baseUrl": "https://openrouter.ai/api/v1", "apiKeyEnv": "OPENROUTER_API_KEY", "model": "anthropic/claude-sonnet-4.5" }
221
+ ```
222
+
223
+ ```json
224
+ { "baseUrl": "http://localhost:11434/v1", "model": "qwen3:14b" }
225
+ ```
226
+
227
+ Small local models make noticeably more mistakes. The checks catch the mechanical ones, not wrong meaning.
228
+
229
+ ## Commands
230
+
231
+ ```text
232
+ localewarden [translate] --dry-run --lang de,fr --fix-flagged --retranslate-all
233
+ --overwrite-manual --max-tokens <n> --verbose
234
+ localewarden check --lang de,fr --verbose --limit <n> --strict --json
235
+ localewarden review --all --approve <sel>... --release <sel>...
236
+ localewarden init
237
+ Global: --config <path> --help --version
238
+ ```
239
+
240
+ Exit codes: `0` ok, `1` errors (or check findings), `2` configuration problem.
241
+
242
+ ## Programmatic use
243
+
244
+ ```ts
245
+ import { loadConfig, run, checkProject } from 'localewarden';
246
+
247
+ const config = loadConfig('localewarden.config.json');
248
+ const summary = await run(config, { languages: ['de'] });
249
+ const findings = checkProject(config);
250
+ ```
251
+
252
+ `run` also accepts a `model` with a `complete(system, user)` method, so you can plug in any SDK.
253
+
254
+ ## Costs and privacy
255
+
256
+ - You pay your API provider. Run `--dry-run` first: it prints the number of strings and a rough token estimate. Reasoning models also bill reasoning tokens, which can triple the cost.
257
+ - Unchanged strings cost nothing. In the example above, changing two English strings and updating four languages took 4 requests and about 4,000 tokens.
258
+ - Strings, keys, your `context` and glossary are sent to the API you configure. Nothing else is sent anywhere. There is no telemetry.
259
+
260
+ ## Limitations
261
+
262
+ - JSON only (nested objects, arrays, flat keys). YAML, PO, XLIFF and ARB are not supported yet.
263
+ - The checks catch mechanical problems, not every wrong meaning. Have a native speaker look at important screens, then approve their edits with `review`.
264
+ - Rules for form of address, gender and typography exist for the languages listed above. Other languages are translated with the general rules.
265
+ - A run that is interrupted keeps everything written so far. Unwritten strings are picked up on the next run.
266
+
267
+ ## License
268
+
269
+ [MIT](LICENSE)
@@ -0,0 +1,75 @@
1
+ import type { Config } from './config.js';
2
+ /**
3
+ * Deterministic quality checks. No API calls, so they can run in CI on every commit.
4
+ *
5
+ * placeholder placeholder set differs from the source error
6
+ * script letters from a script the language does not use, or a word
7
+ * mixing Latin with Cyrillic/Greek lookalikes error
8
+ * markup links, tags or list items differ from the source; broken tags
9
+ * years a year from the source is missing or changed (citations, dates)
10
+ * formality the other form of address than configured, or both mixed;
11
+ * masculine-only forms for "you" when genderNeutral is on
12
+ * titlecase English Title Case copied into a sentence-case language
13
+ * ampersand "&" in a language that writes the word
14
+ * glossary a glossary rendering or a doNotTranslate name is missing
15
+ * untranslated identical to the source (prose of 3+ words)
16
+ * partial source-language words left inside an otherwise translated string,
17
+ * or a dropped hedge ("tend to" stated as certain)
18
+ */
19
+ export type CheckName = 'placeholder' | 'script' | 'markup' | 'years' | 'formality' | 'titlecase' | 'ampersand' | 'glossary' | 'untranslated' | 'partial';
20
+ export declare const CHECKS: CheckName[];
21
+ export declare const ERROR_CHECKS: Set<CheckName>;
22
+ /** Checks a targeted repair (--fix-flagged) may try to fix. */
23
+ export declare const FIXABLE_CHECKS: Set<CheckName>;
24
+ export interface Issue {
25
+ check: CheckName;
26
+ note?: string;
27
+ }
28
+ export declare class Checker {
29
+ readonly config: Config;
30
+ readonly placeholderRe: RegExp;
31
+ constructor(config: Config);
32
+ get englishSource(): boolean;
33
+ placeholdersMatch(key: string, source: string, text: string): boolean;
34
+ placeholderNote(source: string, text: string): string;
35
+ /** All issues of one translated string. */
36
+ checkString(lang: string, key: string, source: string, text: string): Issue[];
37
+ /**
38
+ * Output check right after the API call.
39
+ * hard: certain corruption (placeholders, foreign alphabet, changed link targets, broken
40
+ * markup, source echoed back). Retried once; if still present, never written.
41
+ * soft: likely loss (tag count, missing year, source words left in). Retried once, then
42
+ * accepted; `localewarden check` keeps reporting it.
43
+ */
44
+ defect(lang: string, key: string, source: string, text: string): {
45
+ hard: string | null;
46
+ soft: string | null;
47
+ };
48
+ }
49
+ /** Non-Latin scripts each language is written in. Latin is always allowed (names, codes). */
50
+ export declare const NATIVE_SCRIPTS: Record<string, string[]>;
51
+ /** Why `text` contains letters that cannot belong to `lang`, or null. */
52
+ export declare function foreignScript(lang: string, text: string): string | null;
53
+ export declare const hrefSignature: (text: string) => string;
54
+ /** A malformed, unclosed or misnested tag, or null. */
55
+ export declare function brokenMarkup(text: string): string | null;
56
+ /** Fewer "•" list items than the source: items dropped at the end of a long text. */
57
+ export declare function droppedBullets(source: string, text: string): string | null;
58
+ export declare function yearDifference(source: string, text: string): string | null;
59
+ /**
60
+ * Whether `translation` contains `target`, allowing case endings: every target word of 3+
61
+ * letters must appear in order as the start of a translation word, minus up to two final
62
+ * letters ("Polityka prywatności" matches "Polityką prywatności").
63
+ */
64
+ export declare function containsInflected(lang: string, translation: string, target: string): boolean;
65
+ /** Glossary terms in `source` whose required rendering is missing from `translation`. */
66
+ export declare function missingGlossaryTerms(config: Config, lang: string, source: string, translation: string): string[];
67
+ /** A doNotTranslate name used in the source but missing (or translated) in the translation. */
68
+ export declare function keptNameMissing(name: string, source: string, text: string): boolean;
69
+ /** `text` equals `source` and the source is prose of 3+ words (not a name, code or "OK"). */
70
+ export declare function isUnchangedProse(source: string, text: string, placeholderRe: RegExp): boolean;
71
+ /**
72
+ * First run of six ordinary source words (at most one capitalised, so titles of works stay
73
+ * allowed) copied into the translation, or null.
74
+ */
75
+ export declare function sourceRun(source: string, text: string): string | null;