localewarden 0.2.0 → 0.3.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.
Files changed (53) hide show
  1. package/README.md +108 -4
  2. package/dist/budget.d.ts +25 -0
  3. package/dist/budget.js +74 -0
  4. package/dist/checks.d.ts +15 -4
  5. package/dist/checks.js +17 -3
  6. package/dist/cli.js +44 -9
  7. package/dist/config.d.ts +27 -1
  8. package/dist/config.js +105 -3
  9. package/dist/engine/context.d.ts +81 -0
  10. package/dist/engine/context.js +75 -0
  11. package/dist/engine/copies.d.ts +9 -0
  12. package/dist/engine/copies.js +31 -0
  13. package/dist/engine/planner.d.ts +66 -0
  14. package/dist/engine/planner.js +167 -0
  15. package/dist/engine/repair.d.ts +16 -0
  16. package/dist/engine/repair.js +61 -0
  17. package/dist/engine/sources.d.ts +13 -0
  18. package/dist/engine/sources.js +40 -0
  19. package/dist/engine/translator.d.ts +29 -0
  20. package/dist/engine/translator.js +156 -0
  21. package/dist/engine/writer.d.ts +13 -0
  22. package/dist/engine/writer.js +63 -0
  23. package/dist/files.d.ts +9 -0
  24. package/dist/files.js +76 -14
  25. package/dist/index.d.ts +6 -3
  26. package/dist/index.js +6 -3
  27. package/dist/llm.d.ts +5 -8
  28. package/dist/llm.js +7 -15
  29. package/dist/lock.d.ts +13 -0
  30. package/dist/lock.js +91 -0
  31. package/dist/output.d.ts +21 -0
  32. package/dist/output.js +76 -0
  33. package/dist/plugins.d.ts +72 -0
  34. package/dist/plugins.js +71 -0
  35. package/dist/project.d.ts +18 -5
  36. package/dist/project.js +70 -57
  37. package/dist/prompt.d.ts +3 -1
  38. package/dist/prompt.js +2 -2
  39. package/dist/review.d.ts +1 -1
  40. package/dist/review.js +41 -35
  41. package/dist/state.d.ts +10 -3
  42. package/dist/state.js +30 -11
  43. package/dist/translate.d.ts +9 -49
  44. package/dist/translate.js +118 -429
  45. package/dist/ui/data.d.ts +50 -0
  46. package/dist/ui/data.js +178 -0
  47. package/dist/ui/page.d.ts +5 -0
  48. package/dist/ui/page.js +277 -0
  49. package/dist/ui/server.d.ts +24 -0
  50. package/dist/ui/server.js +194 -0
  51. package/dist/util.d.ts +6 -2
  52. package/dist/util.js +17 -5
  53. package/package.json +1 -1
package/README.md CHANGED
@@ -41,7 +41,11 @@ localewarden grew out of the translation pipeline of a production app that ships
41
41
  - **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.
42
42
  - **Quality check for CI.** `localewarden check` runs all checks without any API calls and exits non-zero on errors.
43
43
  - **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.
44
- - **Budget control.** A token budget per run, a dry run with a cost estimate, and graceful stop and resume.
44
+ - **Budget control.** A token budget per run and per day (for scheduled jobs), a dry run with a cost estimate, and graceful stop and resume.
45
+ - **Groups.** Parts of a project with their own files, languages and model settings, translated in a fixed order (say app UI first, long-form content last, with a cheaper setting).
46
+ - **Plugins.** Your own checks, prompt notes, post-processing and file order, without forking.
47
+ - **Web interface.** `npx localewarden ui`: progress per language, strings with inline editing, check findings, the review list, and runs with a live log. Local only.
48
+ - **Reliable in daily use.** Lock against parallel runs, atomic writes, progress saved on Ctrl+C, Windows line endings and byte order marks kept.
45
49
  - **No runtime dependencies.** Node.js 20+.
46
50
 
47
51
  ## Example
@@ -118,7 +122,12 @@ German uses "du" and French "vous", as configured. Spanish and French use senten
118
122
  git add locales .localewarden
119
123
  ```
120
124
 
121
- `.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.
125
+ `.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, except two local files:
126
+
127
+ ```gitignore
128
+ .localewarden/usage.json
129
+ .localewarden/run.lock
130
+ ```
122
131
 
123
132
  ## How it decides what to translate
124
133
 
@@ -222,6 +231,22 @@ npx localewarden --fix-flagged
222
231
 
223
232
  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.
224
233
 
234
+ ## Web interface
235
+
236
+ ```bash
237
+ npx localewarden ui # prints a local URL with an access token
238
+ ```
239
+
240
+ - **Overview:** progress per group and language, hand edits waiting for review, tokens used today.
241
+ - **Strings:** search by key, source or translation; show only missing ones; edit a translation inline. An edit counts as checked by a person: it is approved and protected from runs.
242
+ - **Check:** run the quality check and filter findings by language and check.
243
+ - **Review:** approve hand edits or hand them back.
244
+ - **Run:** dry run, translate or fix flagged strings, with a live log.
245
+
246
+ The interface listens on `127.0.0.1` only, needs the random token from the printed URL, and
247
+ rejects requests with another host name, so websites open in your browser cannot use it.
248
+ It writes only to the project's own locale files.
249
+
225
250
  ## Hand edits and review
226
251
 
227
252
  ```bash
@@ -264,6 +289,71 @@ npx localewarden review --release de:home.title # hand it back: next run revis
264
289
  | `concurrency` | `4` | Languages translated in parallel |
265
290
  | `batchSize` | `20` | Strings per request (smaller for scripts that need many tokens) |
266
291
  | `stateDir` | `".localewarden"` | Where state and the review list live |
292
+ | `dailyTokenBudget` | | Token limit per UTC day across runs (for scheduled jobs); usage is kept in `<stateDir>/usage.json` (add it to `.gitignore`) |
293
+ | `copies` | `{}` | Locales that are a copy of another one instead of a translation: `{"en-GB": "en-US", "fr-CA": "fr-FR"}` |
294
+ | `chunkChars` | `8000` | Longer strings are translated paragraph by paragraph |
295
+ | `plugins` | `[]` | Plugin modules, see [Plugins](#plugins) |
296
+ | `groups` | | Parts of the project with their own settings, see [Groups](#groups) |
297
+
298
+ ### Groups
299
+
300
+ Different parts of a project often need different settings: the app UI translated first and
301
+ with care, long articles last and with a cheaper setting, store listings with other locale
302
+ codes. Each group inherits the top-level settings and may override them:
303
+
304
+ ```json
305
+ {
306
+ "targetLanguages": ["de", "fr", "pl"],
307
+ "dailyTokenBudget": 2000000,
308
+ "groups": [
309
+ { "name": "app", "files": "src/locales/{lang}/*.json" },
310
+ { "name": "store", "files": "fastlane/metadata/{lang}/*.txt", "sourceLanguage": "en-US",
311
+ "targetLanguages": ["de-DE", "fr-FR", "pl"], "copies": { "fr-CA": "fr-FR" } },
312
+ { "name": "articles", "files": "content/*/*.{lang}.json", "reasoningEffort": "low",
313
+ "ignoreKeys": ["id", "slug", "image"] }
314
+ ]
315
+ }
316
+ ```
317
+
318
+ Groups run in this order and share the budget, so the important ones are done first when the
319
+ budget runs out. `--group app,store` runs only some; an unknown group name is an error.
320
+
321
+ What a group inherits: maps (`formality`, `glossary` per language, `termNotes`, `instructions`,
322
+ `maxLength`) are merged with the top level, lists (`doNotTranslate`, `ignoreKeys`, `exclude`)
323
+ are extended, and everything else is replaced. A group with its own `targetLanguages` or
324
+ `sourceLanguage` does not inherit `copies`, since those name locales. `stateDir`, `plugins`, `dailyTokenBudget`,
325
+ `maxTokensPerRun` and `concurrency` apply to the whole run and can only be set at the top level.
326
+
327
+ ### Plugins
328
+
329
+ A plugin adds project rules without forking localewarden. It is an ES module; its default
330
+ export is a plugin object, or a function that receives the options from the config:
331
+
332
+ ```js
333
+ // rules/my-plugin.mjs
334
+ export default (options) => ({
335
+ name: 'my-rules',
336
+ // Extra findings for one translated string. "error" blocks writing it and fails `check`;
337
+ // "fixable" lets --fix-flagged repair it.
338
+ checks: ({ lang, key, file, source, text }) =>
339
+ lang === 'es' && /\bcoger\b/i.test(text)
340
+ ? [{ check: 'regional-term', note: 'use "tomar"', fixable: true }]
341
+ : [],
342
+ // Extra prompt text for a batch (sent with every request of the batch).
343
+ promptNotes: ({ lang, items }) => (lang === 'es' ? 'Use neutral Latin American Spanish.' : ''),
344
+ // Rewrites a model answer before it is checked and written.
345
+ postProcess: ({ lang, text }) => (lang === 'fr' ? text.replace(/ ([?!:;])/g, '\u00a0$1') : text),
346
+ // Reorders or filters the files of a group.
347
+ order: (files, { group }) => files,
348
+ });
349
+ ```
350
+
351
+ ```json
352
+ { "plugins": ["./rules/my-plugin.mjs", { "module": "./rules/blog.mjs", "options": { "draftsFolder": "drafts" } }] }
353
+ ```
354
+
355
+ All hooks are optional. The interface is marked experimental in 0.x and may change in a minor
356
+ version. A complete example is in [`examples/plugin`](examples/plugin).
267
357
 
268
358
  ### App Store and Play Store listings (fastlane)
269
359
 
@@ -311,10 +401,12 @@ Small local models make noticeably more mistakes. The checks catch the mechanica
311
401
  ## Commands
312
402
 
313
403
  ```text
314
- localewarden [translate] --dry-run --lang de,fr --fix-flagged --retranslate-all
404
+ localewarden [translate] --dry-run --lang de,fr --group app --fix-flagged --retranslate-all
405
+ --retranslate-files <patterns> --refresh-before <YYYY-MM-DD>
315
406
  --overwrite-manual --max-tokens <n> --verbose
316
- localewarden check --lang de,fr --verbose --limit <n> --strict --json --fix
407
+ localewarden check --lang de,fr --group app --verbose --limit <n> --strict --json --fix
317
408
  localewarden review --all --approve <sel>... --release <sel>...
409
+ localewarden ui --port <n>
318
410
  localewarden init
319
411
  Global: --config <path> --help --version
320
412
  ```
@@ -350,6 +442,18 @@ Translations are treated as untrusted: any markup the source does not have is bl
350
442
  - Rules for form of address, gender and typography exist for the languages listed above. Other languages are translated with the general rules.
351
443
  - A run that is interrupted keeps everything written so far. Unwritten strings are picked up on the next run.
352
444
 
445
+ ## Reliability
446
+
447
+ - **One run at a time:** a lock file in the state folder stops a second run (say CI and a local run), or an approval or edit during a run, from writing the same state; a lock left by a crashed process (or committed from another machine) is taken over safely.
448
+ - **Errors stop cleanly:** when one language fails, no further language starts, and the run ends only after the running ones finished.
449
+ - **Symbolic links** to locale files are written through; the file keeps its permissions.
450
+ - **No half-written files:** locale files and state are written to a temporary file and renamed.
451
+ - **Ctrl+C or a CI timeout** saves what was translated so far.
452
+ - **File formats are kept:** indentation, key order of existing files, Windows line endings, byte order marks. A file whose content did not change is not rewritten.
453
+ - **Unicode:** text is compared NFC-normalized, so an editor that saves "é" decomposed does not look like a hand edit.
454
+ - **Clear errors** for invalid JSON, a corrupt state file, an unknown group, an old Node.js version, or a dotted key that collides with a nested one.
455
+ - **Symbolic links** to locale folders are followed (once, so a link loop does no harm).
456
+
353
457
  ## Contributing
354
458
 
355
459
  Bug reports with a concrete example (source, language, output, expected) help most. See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,25 @@
1
+ export declare class BudgetExceededError extends Error {
2
+ }
3
+ /**
4
+ * Token budget shared by every request of a run: a limit for the run and, optionally, a
5
+ * limit per UTC day that holds across runs (for scheduled jobs). Daily usage is kept in a
6
+ * small JSON file; a run that crosses midnight starts counting the new day.
7
+ */
8
+ export declare class Budget {
9
+ readonly maxRunTokens: number;
10
+ readonly dailyTokens?: number | undefined;
11
+ private readonly usageFile?;
12
+ /** Tokens and requests of this run. */
13
+ tokens: number;
14
+ requests: number;
15
+ private usage;
16
+ constructor(maxRunTokens: number, dailyTokens?: number | undefined, usageFile?: string | undefined);
17
+ private load;
18
+ /** Tokens used today, including earlier runs. */
19
+ get usedToday(): number;
20
+ /** Why no further request may start, or null. */
21
+ get exceeded(): string | null;
22
+ /** Throws when the budget is used up. Called before each request. */
23
+ check(): void;
24
+ record(tokens: number): void;
25
+ }
package/dist/budget.js ADDED
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs';
2
+ import { writeText } from './files.js';
3
+ export class BudgetExceededError extends Error {
4
+ }
5
+ const utcDay = () => new Date().toISOString().slice(0, 10);
6
+ /**
7
+ * Token budget shared by every request of a run: a limit for the run and, optionally, a
8
+ * limit per UTC day that holds across runs (for scheduled jobs). Daily usage is kept in a
9
+ * small JSON file; a run that crosses midnight starts counting the new day.
10
+ */
11
+ export class Budget {
12
+ maxRunTokens;
13
+ dailyTokens;
14
+ usageFile;
15
+ /** Tokens and requests of this run. */
16
+ tokens = 0;
17
+ requests = 0;
18
+ usage;
19
+ constructor(maxRunTokens, dailyTokens, usageFile) {
20
+ this.maxRunTokens = maxRunTokens;
21
+ this.dailyTokens = dailyTokens;
22
+ this.usageFile = usageFile;
23
+ this.usage = this.load();
24
+ }
25
+ load() {
26
+ if (this.usageFile) {
27
+ try {
28
+ const data = JSON.parse(fs.readFileSync(this.usageFile, 'utf8'));
29
+ if (data.date === utcDay())
30
+ return data;
31
+ }
32
+ catch {
33
+ // missing or unreadable: start the day at zero
34
+ }
35
+ }
36
+ return { date: utcDay(), tokens: 0, requests: 0 };
37
+ }
38
+ /** Tokens used today, including earlier runs. */
39
+ get usedToday() {
40
+ if (this.usage.date !== utcDay())
41
+ this.usage = { date: utcDay(), tokens: 0, requests: 0 };
42
+ return this.usage.tokens;
43
+ }
44
+ /** Why no further request may start, or null. */
45
+ get exceeded() {
46
+ if (this.tokens >= this.maxRunTokens)
47
+ return `token budget of ${this.maxRunTokens.toLocaleString('en')} for this run reached`;
48
+ if (this.dailyTokens !== undefined && this.usedToday >= this.dailyTokens) {
49
+ return `daily token budget of ${this.dailyTokens.toLocaleString('en')} reached (${this.usedToday.toLocaleString('en')} used today)`;
50
+ }
51
+ return null;
52
+ }
53
+ /** Throws when the budget is used up. Called before each request. */
54
+ check() {
55
+ const reason = this.exceeded;
56
+ if (reason)
57
+ throw new BudgetExceededError(reason);
58
+ }
59
+ record(tokens) {
60
+ this.tokens += tokens;
61
+ this.requests++;
62
+ this.usedToday; // rolls the day over if midnight passed
63
+ this.usage.tokens += tokens;
64
+ this.usage.requests++;
65
+ if (this.usageFile) {
66
+ try {
67
+ writeText(this.usageFile, JSON.stringify(this.usage, null, 2) + '\n');
68
+ }
69
+ catch {
70
+ // not fatal: the run limit still applies
71
+ }
72
+ }
73
+ }
74
+ }
package/dist/checks.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Config } from './config.js';
2
+ import { PluginHost } from './plugins.js';
2
3
  import { Scope } from './scope.js';
3
4
  /**
4
5
  * Deterministic quality checks. No API calls, so they can run in CI on every commit.
@@ -27,16 +28,26 @@ export declare const ERROR_CHECKS: Set<CheckName>;
27
28
  /** Checks a targeted repair (--fix-flagged) may try to fix. */
28
29
  export declare const FIXABLE_CHECKS: Set<CheckName>;
29
30
  export interface Issue {
30
- check: CheckName;
31
+ /** A built-in check name, or a plugin's own check name. */
32
+ check: CheckName | (string & {});
31
33
  note?: string;
34
+ /** Set for plugin issues; built-in checks use ERROR_CHECKS. */
35
+ severity?: 'error' | 'warning';
36
+ /** Set for plugin issues; built-in checks use FIXABLE_CHECKS. */
37
+ fixable?: boolean;
32
38
  }
39
+ /** Errors break the software (and fail `localewarden check`); everything else is a warning. */
40
+ export declare const isError: (issue: Issue) => boolean;
41
+ /** Whether `--fix-flagged` may ask the model to fix the issue. */
42
+ export declare const isFixable: (issue: Issue) => boolean;
33
43
  export declare class Checker {
34
44
  readonly config: Config;
35
45
  readonly placeholderRe: RegExp;
36
46
  readonly scope: Scope;
37
47
  /** Words of termNotes and doNotTranslate: terms a translation may keep in the source language. */
38
48
  readonly keptWords: Set<string>;
39
- constructor(config: Config);
49
+ readonly plugins: PluginHost;
50
+ constructor(config: Config, plugins?: PluginHost);
40
51
  /** The text without doNotTranslate names, which stay the same in every language. */
41
52
  withoutNames(text: string): string;
42
53
  /** "62 characters, limit 60", or null. */
@@ -45,7 +56,7 @@ export declare class Checker {
45
56
  placeholdersMatch(key: string, source: string, text: string): boolean;
46
57
  placeholderNote(source: string, text: string): string;
47
58
  /** All issues of one translated string. */
48
- checkString(lang: string, key: string, source: string, text: string): Issue[];
59
+ checkString(lang: string, key: string, source: string, text: string, file?: string): Issue[];
49
60
  /**
50
61
  * Output check right after the API call.
51
62
  * hard: certain corruption (placeholders, foreign alphabet, changed link targets, broken
@@ -53,7 +64,7 @@ export declare class Checker {
53
64
  * soft: likely loss (tag count, missing year, source words left in). Retried once, then
54
65
  * accepted; `localewarden check` keeps reporting it.
55
66
  */
56
- defect(lang: string, key: string, source: string, text: string): {
67
+ defect(lang: string, key: string, source: string, text: string, file?: string): {
57
68
  hard: string | null;
58
69
  soft: string | null;
59
70
  };
package/dist/checks.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { placeholderRegExp, placeholderSignature, placeholdersMatch } from './placeholders.js';
2
2
  import { GENDERED_FORMS, NO_AMPERSAND_LANGUAGES, SENTENCE_CASE_LANGUAGES, formalityRule, registerFor, withoutQuotedSpeech, } from './style.js';
3
+ import { PluginHost } from './plugins.js';
3
4
  import { Scope } from './scope.js';
4
5
  import { baseLanguage, escapeRegExp, isTraditionalChinese } from './util.js';
5
6
  export const CHECKS = [
@@ -29,14 +30,20 @@ export const FIXABLE_CHECKS = new Set([
29
30
  'glossary',
30
31
  'partial',
31
32
  ]);
33
+ /** Errors break the software (and fail `localewarden check`); everything else is a warning. */
34
+ export const isError = (issue) => issue.severity === 'error' || ERROR_CHECKS.has(issue.check);
35
+ /** Whether `--fix-flagged` may ask the model to fix the issue. */
36
+ export const isFixable = (issue) => issue.fixable ?? FIXABLE_CHECKS.has(issue.check);
32
37
  export class Checker {
33
38
  config;
34
39
  placeholderRe;
35
40
  scope;
36
41
  /** Words of termNotes and doNotTranslate: terms a translation may keep in the source language. */
37
42
  keptWords;
38
- constructor(config) {
43
+ plugins;
44
+ constructor(config, plugins = new PluginHost()) {
39
45
  this.config = config;
46
+ this.plugins = plugins;
40
47
  this.placeholderRe = placeholderRegExp(config.placeholders);
41
48
  this.scope = new Scope(config);
42
49
  this.keptWords = new Set([...Object.keys(config.termNotes), ...config.doNotTranslate].flatMap(term => term.toLowerCase().split(/[^\p{L}]+/u)).filter(Boolean));
@@ -61,7 +68,7 @@ export class Checker {
61
68
  return `source has [${placeholderSignature(source, this.placeholderRe)}], got [${placeholderSignature(text, this.placeholderRe)}]`;
62
69
  }
63
70
  /** All issues of one translated string. */
64
- checkString(lang, key, source, text) {
71
+ checkString(lang, key, source, text, file = '') {
65
72
  const issues = [];
66
73
  const add = (check, note) => issues.push({ check, note });
67
74
  const base = baseLanguage(lang);
@@ -136,6 +143,9 @@ export class Checker {
136
143
  add('partial', 'hedge dropped: the source says "tend to", the translation states it as certain');
137
144
  }
138
145
  }
146
+ for (const issue of this.plugins.checks({ lang, key, file, source, text })) {
147
+ issues.push({ severity: 'warning', fixable: false, ...issue });
148
+ }
139
149
  return issues;
140
150
  }
141
151
  /**
@@ -145,9 +155,13 @@ export class Checker {
145
155
  * soft: likely loss (tag count, missing year, source words left in). Retried once, then
146
156
  * accepted; `localewarden check` keeps reporting it.
147
157
  */
148
- defect(lang, key, source, text) {
158
+ defect(lang, key, source, text, file = '') {
149
159
  if (!text.trim())
150
160
  return { hard: 'empty translation', soft: null };
161
+ // A plugin error blocks writing, like a broken placeholder.
162
+ const pluginError = this.plugins.checks({ lang, key, file, source, text }).find(issue => issue.severity === 'error');
163
+ if (pluginError)
164
+ return { hard: `${pluginError.check}${pluginError.note ? `: ${pluginError.note}` : ''}`, soft: null };
151
165
  const placeholders = this.placeholdersMatch(key, source, text) ? null : `placeholder mismatch: ${this.placeholderNote(source, text)}`;
152
166
  const foreign = foreignScript(this.config.sourceLanguage, source) ? null : foreignScript(lang, text);
153
167
  const links = hrefSignature(source) !== hrefSignature(text) ? `links changed: [${hrefSignature(source)}] -> [${hrefSignature(text)}]` : null;
package/dist/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { ConfigError, CONFIG_FILE, loadConfig } from './config.js';
4
+ import { ConfigError, CONFIG_FILE, loadConfig, selectGroups } from './config.js';
5
+ import { loadPlugins } from './plugins.js';
6
+ import { startUi } from './ui/server.js';
5
7
  import { CHECKS } from './checks.js';
6
8
  import { findSourceFiles } from './files.js';
7
9
  import { isReasoningModel } from './llm.js';
@@ -14,6 +16,7 @@ Usage:
14
16
  localewarden [translate] [options] translate new and changed strings
15
17
  localewarden check [options] quality check, no API calls (use in CI)
16
18
  localewarden review [options] list and resolve hand-edited translations
19
+ localewarden ui [--port 4848] local web interface (127.0.0.1 only)
17
20
  localewarden init create ${CONFIG_FILE}
18
21
 
19
22
  Translate options:
@@ -21,15 +24,19 @@ Translate options:
21
24
  --lang de,fr only these languages
22
25
  --fix-flagged let the model fix strings the quality check flags
23
26
  --retranslate-all translate every string again (hand edits stay protected)
27
+ --retranslate-files <patterns> translate every string of these files again, e.g. "locales/{lang}/onboarding.json"
28
+ --refresh-before <YYYY-MM-DD> translate again what was written before that day (or adopted)
29
+ --group app,web only these groups (config "groups")
24
30
  --overwrite-manual also replace hand-edited translations
25
31
  --max-tokens <n> token budget for this run (default: maxTokensPerRun)
26
32
  --verbose more output
27
33
 
28
34
  Check options:
29
35
  --lang de,fr only these languages
36
+ --group app,web only these groups
30
37
  --verbose, -v list findings, not only counts
31
38
  --limit <n> findings shown per check with --verbose (default 20)
32
- --strict exit 1 on warnings too (default: only placeholder/script errors)
39
+ --strict exit 1 on warnings too (default: only errors)
33
40
  --json print findings as JSON
34
41
  --fix repair placeholders with exactly one possible fix ({heures} -> {hours})
35
42
 
@@ -43,7 +50,7 @@ Global:
43
50
  --config <path> config file (default ./${CONFIG_FILE})
44
51
  --help, --version
45
52
  `;
46
- const VALUE_FLAGS = new Set(['--lang', '--max-tokens', '--config', '--limit', '--approve', '--release']);
53
+ const VALUE_FLAGS = new Set(['--port', '--lang', '--group', '--max-tokens', '--config', '--limit', '--approve', '--release', '--retranslate-files', '--refresh-before']);
47
54
  function parseArgs(argv) {
48
55
  const args = { command: 'translate', flags: new Set(), values: new Map() };
49
56
  let commandSet = false;
@@ -76,6 +83,7 @@ function parseArgs(argv) {
76
83
  }
77
84
  return args;
78
85
  }
86
+ const listArg = (args, name) => args.values.get(name)?.flatMap(value => value.split(',')).map(v => v.trim()).filter(Boolean);
79
87
  const languagesArg = (args) => args.values.get('--lang')?.flatMap(value => value.split(',')).map(l => l.trim()).filter(Boolean);
80
88
  function version() {
81
89
  const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
@@ -131,8 +139,14 @@ async function translateCommand(args) {
131
139
  const maxTokens = args.values.get('--max-tokens')?.[0];
132
140
  if (maxTokens !== undefined && !(Number(maxTokens) > 0))
133
141
  throw new ConfigError('--max-tokens must be a positive number');
142
+ const refreshBefore = args.values.get('--refresh-before')?.[0];
143
+ if (refreshBefore !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(refreshBefore))
144
+ throw new ConfigError('--refresh-before must be a date like 2026-10-01');
134
145
  const summary = await run(config, {
135
146
  languages: languagesArg(args),
147
+ groups: listArg(args, '--group'),
148
+ retranslateFiles: listArg(args, '--retranslate-files'),
149
+ refreshBefore,
136
150
  dryRun: args.flags.has('--dry-run'),
137
151
  retranslateAll: args.flags.has('--retranslate-all'),
138
152
  overwriteManual: args.flags.has('--overwrite-manual'),
@@ -150,6 +164,7 @@ async function translateCommand(args) {
150
164
  }
151
165
  // Rough: prompt overhead per request plus input and output text (~4 characters per token).
152
166
  const requests = Math.ceil(strings / config.batchSize) + Math.ceil(strings * 0.05);
167
+ // Groups may use different models; the estimate uses the first group's.
153
168
  const low = Math.round(requests * 900 + (chars / 4) * 2.2);
154
169
  const high = Math.round(low * (isReasoningModel(config.model) ? 3 : 1.5));
155
170
  console.log(`Dry run: ${strings} string(s), ${chars.toLocaleString('en')} characters to translate.`);
@@ -171,8 +186,10 @@ async function translateCommand(args) {
171
186
  if (rows.length === 0)
172
187
  console.log('Everything is up to date.');
173
188
  console.log(`\n${summary.filesWritten.length} file(s) written, ${summary.requests} request(s), ${summary.tokens.toLocaleString('en')} tokens.`);
189
+ if (summary.filesCopied.length > 0)
190
+ console.log(`${summary.filesCopied.length} file(s) copied to their regional locales.`);
174
191
  if (summary.stoppedByBudget)
175
- console.log('Stopped at the token budget. Run again to continue.');
192
+ console.log(`Stopped: ${summary.stopReason ?? 'token budget reached'}. Run again to continue.`);
176
193
  if (summary.pendingReview > 0)
177
194
  console.log(`${summary.pendingReview} hand-edited translation(s) to review: npx localewarden review`);
178
195
  const failed = Object.values(summary.languages).some(s => s.failed > 0);
@@ -180,17 +197,21 @@ async function translateCommand(args) {
180
197
  console.log('Failed strings were not written and will be retried on the next run.');
181
198
  return 0;
182
199
  }
183
- function checkCommand(args) {
200
+ async function checkCommand(args) {
184
201
  const config = loadConfig(args.values.get('--config')?.[0]);
185
- const languages = languagesArg(args) ?? config.targetLanguages;
186
- let findings = checkProject(config, languages);
202
+ const plugins = await loadPlugins(config);
203
+ const groupNames = listArg(args, '--group');
204
+ const groups = selectGroups(config, groupNames, languagesArg(args));
205
+ const languages = languagesArg(args) ?? [...new Set(groups.flatMap(g => g.targetLanguages))];
206
+ const options = { languages, groups: groupNames, plugins };
207
+ let findings = checkProject(config, options);
187
208
  if (args.flags.has('--fix')) {
188
209
  const fixed = fixPlaceholders(config, findings);
189
210
  for (const f of fixed)
190
211
  console.log(`fixed [${f.lang}] ${f.file} ${f.key}: ${f.text}`);
191
212
  console.log(`${fixed.length} placeholder(s) repaired.\n`);
192
213
  if (fixed.length > 0)
193
- findings = checkProject(config, languages);
214
+ findings = checkProject(config, options);
194
215
  }
195
216
  if (args.flags.has('--json')) {
196
217
  console.log(JSON.stringify(findings, null, 2));
@@ -252,6 +273,9 @@ function reviewCommand(args) {
252
273
  return 0;
253
274
  }
254
275
  async function main() {
276
+ const major = Number(process.versions.node.split('.')[0]);
277
+ if (major < 20)
278
+ throw new ConfigError(`localewarden needs Node.js 20 or newer; this is ${process.versions.node}.`);
255
279
  const args = parseArgs(process.argv.slice(2));
256
280
  if (args.flags.has('--help')) {
257
281
  console.log(HELP);
@@ -265,12 +289,23 @@ async function main() {
265
289
  case 'translate':
266
290
  return translateCommand(args);
267
291
  case 'check':
268
- return checkCommand(args);
292
+ return await checkCommand(args);
269
293
  case 'review':
270
294
  return reviewCommand(args);
271
295
  case 'init':
272
296
  init();
273
297
  return 0;
298
+ case 'ui': {
299
+ const config = loadConfig(args.values.get('--config')?.[0]);
300
+ const port = args.values.get('--port')?.[0];
301
+ if (port !== undefined && !/^\d+$/.test(port))
302
+ throw new ConfigError('--port must be a number');
303
+ const ui = await startUi(config, { port: port === undefined ? undefined : Number(port) });
304
+ console.log(`localewarden interface: ${ui.url}`);
305
+ console.log('Only reachable from this computer. Press Ctrl+C to stop.');
306
+ await new Promise(() => { }); // runs until Ctrl+C
307
+ return 0;
308
+ }
274
309
  default:
275
310
  throw new ConfigError(`Unknown command "${args.command}". See --help.`);
276
311
  }
package/dist/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { PluginSpec } from './plugins.js';
1
2
  export type Register = 'formal' | 'informal';
2
3
  export interface Config {
3
4
  /** Language of the source files. Default "en". Several checks assume English. */
@@ -52,14 +53,39 @@ export interface Config {
52
53
  batchSize: number;
53
54
  /** Where state, review list and repair report live, relative to the config file. */
54
55
  stateDir: string;
56
+ /**
57
+ * Target locales that are a copy of another one instead of a translation, e.g. store
58
+ * listings: { "en-GB": "en-US", "fr-CA": "fr-FR" }. The value may be the source language.
59
+ */
60
+ copies: Record<string, string>;
61
+ /** Strings longer than this many characters are translated paragraph by paragraph. */
62
+ chunkChars: number;
63
+ /** Plugin modules (relative to the config file), see plugins.ts. */
64
+ plugins: PluginSpec[];
65
+ /** Token limit per UTC day across runs; usage is kept in <stateDir>/usage.json. */
66
+ dailyTokenBudget?: number;
67
+ /** Group name, when this config is one of `groups`. */
68
+ name?: string;
69
+ /**
70
+ * Parts of the project with their own files and settings, translated in this order
71
+ * (e.g. app UI first, long-form content last). Each group inherits the top-level settings.
72
+ */
73
+ groups?: Config[];
55
74
  /** Directory of the config file; all relative paths resolve against it. */
56
75
  root: string;
57
76
  }
58
77
  export declare const CONFIG_FILE = "localewarden.config.json";
59
- export declare const DEFAULTS: Omit<Config, 'targetLanguages' | 'files' | 'root'>;
78
+ export declare const DEFAULTS: Omit<Config, 'targetLanguages' | 'files' | 'root' | 'groups' | 'name'>;
60
79
  export declare class ConfigError extends Error {
61
80
  }
62
81
  /** Validates a parsed config object and fills in defaults. */
63
82
  export declare function resolveConfig(raw: unknown, root: string): Config;
83
+ /** The groups of a config, or the config itself as its only group. */
84
+ export declare const groupsOf: (config: Config) => Config[];
85
+ /**
86
+ * The groups to work on, in config order, limited by name; unknown group or language names
87
+ * are an error (a typo must not turn into "nothing to do" or a green check).
88
+ */
89
+ export declare function selectGroups(config: Config, names?: string[], languages?: string[]): Config[];
64
90
  /** Loads the config file (default: localewarden.config.json in the working directory). */
65
91
  export declare function loadConfig(configPath?: string): Config;