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.
- package/README.md +108 -4
- package/dist/budget.d.ts +25 -0
- package/dist/budget.js +74 -0
- package/dist/checks.d.ts +15 -4
- package/dist/checks.js +17 -3
- package/dist/cli.js +44 -9
- package/dist/config.d.ts +27 -1
- package/dist/config.js +105 -3
- package/dist/engine/context.d.ts +81 -0
- package/dist/engine/context.js +75 -0
- package/dist/engine/copies.d.ts +9 -0
- package/dist/engine/copies.js +31 -0
- package/dist/engine/planner.d.ts +66 -0
- package/dist/engine/planner.js +167 -0
- package/dist/engine/repair.d.ts +16 -0
- package/dist/engine/repair.js +61 -0
- package/dist/engine/sources.d.ts +13 -0
- package/dist/engine/sources.js +40 -0
- package/dist/engine/translator.d.ts +29 -0
- package/dist/engine/translator.js +156 -0
- package/dist/engine/writer.d.ts +13 -0
- package/dist/engine/writer.js +63 -0
- package/dist/files.d.ts +9 -0
- package/dist/files.js +76 -14
- package/dist/index.d.ts +6 -3
- package/dist/index.js +6 -3
- package/dist/llm.d.ts +5 -8
- package/dist/llm.js +7 -15
- package/dist/lock.d.ts +13 -0
- package/dist/lock.js +91 -0
- package/dist/output.d.ts +21 -0
- package/dist/output.js +76 -0
- package/dist/plugins.d.ts +72 -0
- package/dist/plugins.js +71 -0
- package/dist/project.d.ts +18 -5
- package/dist/project.js +70 -57
- package/dist/prompt.d.ts +3 -1
- package/dist/prompt.js +2 -2
- package/dist/review.d.ts +1 -1
- package/dist/review.js +41 -35
- package/dist/state.d.ts +10 -3
- package/dist/state.js +30 -11
- package/dist/translate.d.ts +9 -49
- package/dist/translate.js +118 -429
- package/dist/ui/data.d.ts +50 -0
- package/dist/ui/data.js +178 -0
- package/dist/ui/page.d.ts +5 -0
- package/dist/ui/page.js +277 -0
- package/dist/ui/server.d.ts +24 -0
- package/dist/ui/server.js +194 -0
- package/dist/util.d.ts +6 -2
- package/dist/util.js +17 -5
- 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).
|
package/dist/budget.d.ts
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
186
|
-
|
|
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,
|
|
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;
|