@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/src/cli.ts
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { relative } from 'node:path';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import {
|
|
6
|
+
applyProposals,
|
|
7
|
+
loadRun,
|
|
8
|
+
recheck,
|
|
9
|
+
recordMachine,
|
|
10
|
+
saveRun,
|
|
11
|
+
ApplyError,
|
|
12
|
+
SAVE_VERSION,
|
|
13
|
+
} from './apply.js';
|
|
14
|
+
import { check } from './check.js';
|
|
15
|
+
import { FormatError, describeFormatError } from './formats/error.js';
|
|
16
|
+
import {
|
|
17
|
+
GlossaryError,
|
|
18
|
+
glossaryPath,
|
|
19
|
+
loadGlossary,
|
|
20
|
+
type Glossary,
|
|
21
|
+
} from './glossary.js';
|
|
22
|
+
import { LimitsError, limitsPath, loadLimits, type Limits } from './lengths.js';
|
|
23
|
+
import {
|
|
24
|
+
MemoryError,
|
|
25
|
+
emptyMemory,
|
|
26
|
+
hashValue,
|
|
27
|
+
loadMemory,
|
|
28
|
+
markReviewed,
|
|
29
|
+
unreviewed,
|
|
30
|
+
memoryPath,
|
|
31
|
+
saveMemory,
|
|
32
|
+
syncMemory,
|
|
33
|
+
type Memory,
|
|
34
|
+
} from './memory.js';
|
|
35
|
+
import { renderReport } from './report.js';
|
|
36
|
+
import {
|
|
37
|
+
collectJobs,
|
|
38
|
+
runTranslation,
|
|
39
|
+
DEFAULT_BATCH,
|
|
40
|
+
DEFAULT_CAP,
|
|
41
|
+
DEFAULT_MODEL,
|
|
42
|
+
JOB_KINDS,
|
|
43
|
+
type JobKind,
|
|
44
|
+
} from './translate.js';
|
|
45
|
+
import { ALL_SYNTAXES } from './placeholders.js';
|
|
46
|
+
import { ScanError, detectProject, listLocales, loadBundle, sourceIsMsgid } from './scan.js';
|
|
47
|
+
import { RULE_IDS, type Config, type RuleId } from './types.js';
|
|
48
|
+
import { VERSION } from './version.js';
|
|
49
|
+
|
|
50
|
+
const HELP = `i18n-keeper ${VERSION}
|
|
51
|
+
|
|
52
|
+
i18n-keeper check [path] lint locale files
|
|
53
|
+
i18n-keeper scan [path] show what would be checked
|
|
54
|
+
i18n-keeper sync [path] record current translations in the memory
|
|
55
|
+
i18n-keeper translate [path] fill the missing and stale set with Claude
|
|
56
|
+
i18n-keeper apply <file> [path] write proposals saved by translate
|
|
57
|
+
i18n-keeper review [path] sign off on machine translations
|
|
58
|
+
|
|
59
|
+
Options
|
|
60
|
+
--locales <dir> locales directory (default: auto-detect)
|
|
61
|
+
--source <locale> source locale (default: en, else the first found)
|
|
62
|
+
--locale <locale> limit to this locale (repeatable)
|
|
63
|
+
--memory <file> translation memory (default: .i18n/memory.json)
|
|
64
|
+
--no-memory ignore the memory; disables stale detection
|
|
65
|
+
--glossary <file> glossary (default: .i18n/glossary.json)
|
|
66
|
+
--no-glossary ignore the glossary
|
|
67
|
+
--limits <file> width limits (default: .i18n/limits.json)
|
|
68
|
+
--no-limits ignore the width limits
|
|
69
|
+
|
|
70
|
+
check
|
|
71
|
+
--rule <rule> only report this rule (repeatable)
|
|
72
|
+
--ignore-identical <a,b> values allowed to equal the source, e.g. OK,Email
|
|
73
|
+
--syntax <a,b> placeholder syntaxes: ${ALL_SYNTAXES.join(', ')}
|
|
74
|
+
--limit <n> max findings to print (default: 40)
|
|
75
|
+
--json machine-readable output
|
|
76
|
+
|
|
77
|
+
sync
|
|
78
|
+
--origin <human|machine> who produced these translations (default: human)
|
|
79
|
+
--force also re-record unchanged translations, clearing stale
|
|
80
|
+
|
|
81
|
+
translate
|
|
82
|
+
--write apply accepted translations (otherwise nothing is written)
|
|
83
|
+
--save <file> save the proposals so apply can write them later,
|
|
84
|
+
without paying for the translation twice
|
|
85
|
+
--cap <n> most strings to translate in one run (default: ${DEFAULT_CAP})
|
|
86
|
+
--batch <n> strings per request (default: ${DEFAULT_BATCH})
|
|
87
|
+
--model <id> default: ${DEFAULT_MODEL}
|
|
88
|
+
--effort <level> low|medium|high|xhigh|max (default: medium)
|
|
89
|
+
--only <kind> fill | repair | refresh (repeatable; default: all three)
|
|
90
|
+
fill strings with no translation yet
|
|
91
|
+
repair translations the linter proved wrong
|
|
92
|
+
refresh translations whose source has moved
|
|
93
|
+
|
|
94
|
+
Sends the source strings, their keys and their constraints to the Anthropic
|
|
95
|
+
API. Every proposal is re-checked locally and rejected if it breaks a rule.
|
|
96
|
+
|
|
97
|
+
apply
|
|
98
|
+
--dry-run re-check the saved proposals and report, writing nothing
|
|
99
|
+
|
|
100
|
+
review
|
|
101
|
+
--locale <l> only this locale (repeatable)
|
|
102
|
+
--key <k> only this key (repeatable)
|
|
103
|
+
--all every unreviewed translation
|
|
104
|
+
--dry-run list what would be signed off, changing nothing
|
|
105
|
+
|
|
106
|
+
With no selection it lists what is waiting, and changes nothing. Nothing
|
|
107
|
+
else in the tool can mark a translation reviewed; that is the point of
|
|
108
|
+
recording machine output as unreviewed in the first place.
|
|
109
|
+
|
|
110
|
+
Every proposal is checked again before it is written: a saved file may be
|
|
111
|
+
days old, and it is editable by hand.
|
|
112
|
+
|
|
113
|
+
Exits 1 when there is at least one error.`;
|
|
114
|
+
|
|
115
|
+
function fail(message: string): never {
|
|
116
|
+
process.stderr.write(`${message}\n`);
|
|
117
|
+
process.exit(2);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { values, positionals } = (() => {
|
|
121
|
+
try {
|
|
122
|
+
return parseArgs({
|
|
123
|
+
allowPositionals: true,
|
|
124
|
+
options: {
|
|
125
|
+
locales: { type: 'string' },
|
|
126
|
+
source: { type: 'string' },
|
|
127
|
+
locale: { type: 'string', multiple: true },
|
|
128
|
+
rule: { type: 'string', multiple: true },
|
|
129
|
+
memory: { type: 'string' },
|
|
130
|
+
'no-memory': { type: 'boolean' },
|
|
131
|
+
glossary: { type: 'string' },
|
|
132
|
+
'no-glossary': { type: 'boolean' },
|
|
133
|
+
limits: { type: 'string' },
|
|
134
|
+
'no-limits': { type: 'boolean' },
|
|
135
|
+
'ignore-identical': { type: 'string' },
|
|
136
|
+
syntax: { type: 'string' },
|
|
137
|
+
origin: { type: 'string' },
|
|
138
|
+
force: { type: 'boolean' },
|
|
139
|
+
write: { type: 'boolean' },
|
|
140
|
+
model: { type: 'string' },
|
|
141
|
+
effort: { type: 'string' },
|
|
142
|
+
batch: { type: 'string' },
|
|
143
|
+
cap: { type: 'string' },
|
|
144
|
+
only: { type: 'string', multiple: true },
|
|
145
|
+
save: { type: 'string' },
|
|
146
|
+
'dry-run': { type: 'boolean' },
|
|
147
|
+
key: { type: 'string', multiple: true },
|
|
148
|
+
all: { type: 'boolean' },
|
|
149
|
+
limit: { type: 'string' },
|
|
150
|
+
json: { type: 'boolean' },
|
|
151
|
+
help: { type: 'boolean', short: 'h' },
|
|
152
|
+
version: { type: 'boolean' },
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
} catch (err) {
|
|
156
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
|
|
160
|
+
if (values.version) {
|
|
161
|
+
process.stdout.write(`${VERSION}\n`);
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const command = positionals[0] ?? 'check';
|
|
166
|
+
if (values.help || command === 'help') {
|
|
167
|
+
process.stdout.write(`${HELP}\n`);
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
if (
|
|
171
|
+
command !== 'check' &&
|
|
172
|
+
command !== 'scan' &&
|
|
173
|
+
command !== 'sync' &&
|
|
174
|
+
command !== 'translate' &&
|
|
175
|
+
command !== 'apply' &&
|
|
176
|
+
command !== 'review'
|
|
177
|
+
) {
|
|
178
|
+
fail(`Unknown command: ${command}\n\n${HELP}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// `apply` takes the proposals file first, then the optional project path.
|
|
182
|
+
const proposalsFile = command === 'apply' ? positionals[1] : undefined;
|
|
183
|
+
if (command === 'apply' && !proposalsFile) {
|
|
184
|
+
fail('apply needs a proposals file:\n i18n-keeper apply <file> [path]');
|
|
185
|
+
}
|
|
186
|
+
const root = (command === 'apply' ? positionals[2] : positionals[1]) ?? process.cwd();
|
|
187
|
+
|
|
188
|
+
const limit = values.limit === undefined ? 40 : Number.parseInt(values.limit, 10);
|
|
189
|
+
if (!Number.isFinite(limit) || limit < 0) fail('--limit expects a non-negative number');
|
|
190
|
+
|
|
191
|
+
for (const rule of values.rule ?? []) {
|
|
192
|
+
if (!(RULE_IDS as readonly string[]).includes(rule)) {
|
|
193
|
+
fail(`Unknown rule: ${rule}\nAvailable: ${RULE_IDS.join(', ')}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const syntaxes = values.syntax?.split(',').map((s) => s.trim()).filter(Boolean);
|
|
198
|
+
for (const syntax of syntaxes ?? []) {
|
|
199
|
+
if (!ALL_SYNTAXES.includes(syntax)) {
|
|
200
|
+
fail(`Unknown placeholder syntax: ${syntax}\nAvailable: ${ALL_SYNTAXES.join(', ')}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const origin = values.origin ?? 'human';
|
|
205
|
+
if (origin !== 'human' && origin !== 'machine') {
|
|
206
|
+
fail('--origin expects "human" or "machine"');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
|
|
210
|
+
const effort = values.effort ?? 'medium';
|
|
211
|
+
if (!(EFFORTS as readonly string[]).includes(effort)) {
|
|
212
|
+
fail(`--effort expects one of: ${EFFORTS.join(', ')}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function positive(flag: string, raw: string | undefined, fallback: number): number {
|
|
216
|
+
if (raw === undefined) return fallback;
|
|
217
|
+
const parsed = Number.parseInt(raw, 10);
|
|
218
|
+
if (!Number.isFinite(parsed) || parsed < 1) fail(`${flag} expects a positive number`);
|
|
219
|
+
return parsed;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const kind of values.only ?? []) {
|
|
223
|
+
if (!(JOB_KINDS as readonly string[]).includes(kind)) {
|
|
224
|
+
fail(`--only expects one of: ${JOB_KINDS.join(', ')}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const cap = positive('--cap', values.cap, DEFAULT_CAP);
|
|
229
|
+
const batchSize = positive('--batch', values.batch, DEFAULT_BATCH);
|
|
230
|
+
|
|
231
|
+
/** An explicit --limits must exist; the default path is simply optional. */
|
|
232
|
+
function openLimits(config: Config): { limits: Limits | null; file: string } {
|
|
233
|
+
const file = limitsPath(config.root, values.limits);
|
|
234
|
+
if (values['no-limits']) return { limits: null, file };
|
|
235
|
+
if (values.limits && !existsSync(file)) fail(`Limits file not found: ${file}`);
|
|
236
|
+
return { limits: loadLimits(file), file };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** An explicit --glossary must exist; the default path is simply optional. */
|
|
240
|
+
function openGlossary(config: Config): { glossary: Glossary | null; file: string } {
|
|
241
|
+
const file = glossaryPath(config.root, values.glossary);
|
|
242
|
+
if (values['no-glossary']) return { glossary: null, file };
|
|
243
|
+
if (values.glossary && !existsSync(file)) fail(`Glossary file not found: ${file}`);
|
|
244
|
+
return { glossary: loadGlossary(file), file };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** An explicit --memory must exist; the default path is simply optional. */
|
|
248
|
+
function openMemory(config: Config): { memory: Memory | null; file: string } {
|
|
249
|
+
const file = memoryPath(config.root, values.memory);
|
|
250
|
+
if (values['no-memory']) return { memory: null, file };
|
|
251
|
+
if (values.memory && !existsSync(file)) fail(`Memory file not found: ${file}`);
|
|
252
|
+
return { memory: loadMemory(file), file };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Returns an exit code rather than calling process.exit: the HTTP connection
|
|
257
|
+
* pool is still closing when this returns, and exiting underneath it trips a
|
|
258
|
+
* libuv assertion on Windows.
|
|
259
|
+
*/
|
|
260
|
+
async function translateCommand(config: Config): Promise<number> {
|
|
261
|
+
const { memory: existing } = openMemory(config);
|
|
262
|
+
const { glossary } = openGlossary(config);
|
|
263
|
+
const { limits } = openLimits(config);
|
|
264
|
+
|
|
265
|
+
const report = check(config, existing, glossary, limits);
|
|
266
|
+
const source = loadBundle(config, config.sourceLocale);
|
|
267
|
+
let jobs = collectJobs(
|
|
268
|
+
config,
|
|
269
|
+
report.findings,
|
|
270
|
+
source,
|
|
271
|
+
glossary,
|
|
272
|
+
limits,
|
|
273
|
+
values.locale,
|
|
274
|
+
values.only as JobKind[] | undefined,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const total = jobs.length;
|
|
278
|
+
if (total === 0) {
|
|
279
|
+
process.stdout.write('Nothing to do: no strings to fill, repair or refresh.\n');
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
if (total > cap) jobs = jobs.slice(0, cap);
|
|
283
|
+
|
|
284
|
+
const locales = [...new Set(jobs.map((job) => job.locale))];
|
|
285
|
+
const breakdown = JOB_KINDS.map((kind) => ({
|
|
286
|
+
kind,
|
|
287
|
+
count: jobs.filter((job) => job.kind === kind).length,
|
|
288
|
+
}))
|
|
289
|
+
.filter((entry) => entry.count > 0)
|
|
290
|
+
.map((entry) => `${entry.count} to ${entry.kind}`)
|
|
291
|
+
.join(', ');
|
|
292
|
+
|
|
293
|
+
process.stdout.write(
|
|
294
|
+
[
|
|
295
|
+
`Sending ${jobs.length} string${jobs.length === 1 ? '' : 's'} ` +
|
|
296
|
+
`(${locales.join(', ')}) to ${values.model ?? DEFAULT_MODEL} at effort ${effort}: ` +
|
|
297
|
+
`${breakdown}.`,
|
|
298
|
+
total > cap ? `${total - cap} more are waiting; raise --cap to include them.` : '',
|
|
299
|
+
]
|
|
300
|
+
.filter(Boolean)
|
|
301
|
+
.join('\n') + '\n',
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const run = await runTranslation(config, jobs, glossary, {
|
|
305
|
+
model: values.model ?? DEFAULT_MODEL,
|
|
306
|
+
effort: effort as 'low' | 'medium' | 'high' | 'xhigh' | 'max',
|
|
307
|
+
batchSize,
|
|
308
|
+
onBatch: (locale, size, attempt) => {
|
|
309
|
+
process.stderr.write(
|
|
310
|
+
` ${locale}: ${size} string${size === 1 ? '' : 's'}${attempt > 1 ? ' (retry)' : ''}\n`,
|
|
311
|
+
);
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const proposals = run.proposals;
|
|
316
|
+
|
|
317
|
+
if (values.save) {
|
|
318
|
+
saveRun(values.save, {
|
|
319
|
+
version: SAVE_VERSION,
|
|
320
|
+
model: values.model ?? DEFAULT_MODEL,
|
|
321
|
+
sourceLocale: config.sourceLocale,
|
|
322
|
+
createdAt: new Date().toISOString(),
|
|
323
|
+
aborted: run.aborted,
|
|
324
|
+
proposals,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (values.json) {
|
|
329
|
+
process.stdout.write(`${JSON.stringify(run, null, 2)}\n`);
|
|
330
|
+
return run.aborted || proposals.some((p) => !p.accepted) ? 1 : 0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const accepted = proposals.filter((p) => p.accepted);
|
|
334
|
+
const rejected = proposals.filter((p) => !p.accepted);
|
|
335
|
+
|
|
336
|
+
for (const proposal of accepted) {
|
|
337
|
+
process.stdout.write(
|
|
338
|
+
` ${proposal.locale} ${proposal.kind.padEnd(7)} ${proposal.key}\n ${proposal.value}\n`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (rejected.length > 0) {
|
|
342
|
+
process.stdout.write('\nrejected\n');
|
|
343
|
+
for (const proposal of rejected) {
|
|
344
|
+
process.stdout.write(
|
|
345
|
+
` ${proposal.locale} ${proposal.kind.padEnd(7)} ${proposal.key}\n` +
|
|
346
|
+
` ${proposal.value || '(nothing returned)'}\n` +
|
|
347
|
+
proposal.rejections.map((r) => ` ! ${r}\n`).join(''),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
process.stdout.write(`\n${accepted.length} accepted, ${rejected.length} rejected\n`);
|
|
353
|
+
|
|
354
|
+
// An error that is not a content refusal stopped the run before the checks
|
|
355
|
+
// could say anything, so it must not be reported as a rejection.
|
|
356
|
+
if (run.aborted) {
|
|
357
|
+
const hint = /authentication|api[ _-]?key|credential|unauthor/i.test(run.aborted)
|
|
358
|
+
? '\nSet ANTHROPIC_API_KEY, or sign in with `ant auth login`.'
|
|
359
|
+
: '';
|
|
360
|
+
process.stderr.write(
|
|
361
|
+
`Translation stopped: ${run.aborted}\n` +
|
|
362
|
+
`${jobs.length - proposals.length} of ${jobs.length} strings were never attempted.${hint}\n`,
|
|
363
|
+
);
|
|
364
|
+
return 2;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!values.write) {
|
|
368
|
+
process.stdout.write(
|
|
369
|
+
values.save
|
|
370
|
+
? `Nothing written. Saved to ${values.save}; apply with: i18n-keeper apply ${values.save}\n`
|
|
371
|
+
: 'Nothing written. Pass --write to apply, or --save <file> to apply later.\n',
|
|
372
|
+
);
|
|
373
|
+
return rejected.length > 0 ? 1 : 0;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const applied = applyProposals(config, source, proposals);
|
|
377
|
+
const memoryFile = memoryPath(config.root, values.memory);
|
|
378
|
+
const memory = existing ?? emptyMemory(config.sourceLocale);
|
|
379
|
+
recordMachine(memory, accepted, new Date().toISOString());
|
|
380
|
+
saveMemory(memoryFile, memory);
|
|
381
|
+
|
|
382
|
+
process.stdout.write(
|
|
383
|
+
`Wrote ${applied.written} string${applied.written === 1 ? '' : 's'} to ` +
|
|
384
|
+
`${applied.files.length} file${applied.files.length === 1 ? '' : 's'}, ` +
|
|
385
|
+
`recorded as unreviewed machine output in ${relative(config.root, memoryFile)}.\n`,
|
|
386
|
+
);
|
|
387
|
+
for (const skip of applied.skipped) {
|
|
388
|
+
process.stdout.write(` not written: ${skip.locale} ${skip.key} — ${skip.reason}\n`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return rejected.length > 0 ? 1 : 0;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const config = detectProject(root, { localesDir: values.locales, sourceLocale: values.source });
|
|
396
|
+
if (syntaxes && syntaxes.length > 0) config.placeholderSyntaxes = syntaxes;
|
|
397
|
+
if (values['ignore-identical']) {
|
|
398
|
+
config.ignoreIdentical = values['ignore-identical']
|
|
399
|
+
.split(',')
|
|
400
|
+
.map((s) => s.trim())
|
|
401
|
+
.filter(Boolean);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (command === 'scan') {
|
|
405
|
+
const { layout, locales } = listLocales(config.localesDir);
|
|
406
|
+
const { memory, file } = openMemory(config);
|
|
407
|
+
const glossaryInfo = openGlossary(config);
|
|
408
|
+
const limitsInfo = openLimits(config);
|
|
409
|
+
// Files that were read but carry nothing translatable are worth naming:
|
|
410
|
+
// silently ignoring part of a project is how a linter comes to be trusted
|
|
411
|
+
// for coverage it never had.
|
|
412
|
+
const sourceBundle = loadBundle(config, config.sourceLocale);
|
|
413
|
+
const passedOver = sourceBundle.skipped;
|
|
414
|
+
const summary = {
|
|
415
|
+
localesDir: config.localesDir,
|
|
416
|
+
layout,
|
|
417
|
+
sourceLocale: config.sourceLocale,
|
|
418
|
+
locales,
|
|
419
|
+
placeholderSyntaxes: config.placeholderSyntaxes,
|
|
420
|
+
memory: memory ? file : null,
|
|
421
|
+
glossary: glossaryInfo.glossary ? glossaryInfo.file : null,
|
|
422
|
+
limits: limitsInfo.limits ? limitsInfo.file : null,
|
|
423
|
+
};
|
|
424
|
+
if (values.json) {
|
|
425
|
+
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
426
|
+
} else {
|
|
427
|
+
process.stdout.write(
|
|
428
|
+
[
|
|
429
|
+
`locales dir ${summary.localesDir}`,
|
|
430
|
+
`layout ${summary.layout}`,
|
|
431
|
+
`source ${summary.sourceLocale}${
|
|
432
|
+
sourceIsMsgid(sourceBundle)
|
|
433
|
+
? ' — gettext, so the source text comes from each msgid, not from this locale'
|
|
434
|
+
: ''
|
|
435
|
+
}`,
|
|
436
|
+
`locales ${summary.locales.join(', ')}`,
|
|
437
|
+
`placeholders ${summary.placeholderSyntaxes.join(', ')}`,
|
|
438
|
+
`memory ${memory ? relative(config.root, file) : 'none'}`,
|
|
439
|
+
...(passedOver.length > 0
|
|
440
|
+
? [
|
|
441
|
+
'',
|
|
442
|
+
'passed over:',
|
|
443
|
+
...passedOver.map((s) => ` ${relative(config.root, s.file)} — ${s.reason}`),
|
|
444
|
+
]
|
|
445
|
+
: []),
|
|
446
|
+
`glossary ${
|
|
447
|
+
glossaryInfo.glossary
|
|
448
|
+
? `${relative(config.root, glossaryInfo.file)} (${glossaryInfo.glossary.terms.length} terms, ${glossaryInfo.glossary.doNotTranslate.length} verbatim)`
|
|
449
|
+
: 'none'
|
|
450
|
+
}`,
|
|
451
|
+
`limits ${
|
|
452
|
+
limitsInfo.limits
|
|
453
|
+
? `${relative(config.root, limitsInfo.file)} (${Object.keys(limitsInfo.limits.keys).length} keys, ${limitsInfo.limits.patterns.length} patterns)`
|
|
454
|
+
: 'none'
|
|
455
|
+
}`,
|
|
456
|
+
'',
|
|
457
|
+
].join('\n'),
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
process.exit(0);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (command === 'review') {
|
|
464
|
+
const memoryFile = memoryPath(config.root, values.memory);
|
|
465
|
+
const memory = loadMemory(memoryFile);
|
|
466
|
+
if (!memory) {
|
|
467
|
+
process.stdout.write(`No memory at ${relative(config.root, memoryFile)}; nothing to review.\n`);
|
|
468
|
+
process.exit(0);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const source = loadBundle(config, config.sourceLocale);
|
|
472
|
+
const selected = values.all === true;
|
|
473
|
+
const narrowed = (values.locale?.length ?? 0) > 0 || (values.key?.length ?? 0) > 0;
|
|
474
|
+
const waiting = unreviewed(memory, values.locale, values.key);
|
|
475
|
+
|
|
476
|
+
if (waiting.length === 0) {
|
|
477
|
+
process.stdout.write('Nothing waiting: every recorded translation has been reviewed.\n');
|
|
478
|
+
process.exit(0);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
for (const item of waiting) {
|
|
482
|
+
const current = source.leaves.get(item.key)?.value;
|
|
483
|
+
process.stdout.write(` ${item.locale} ${item.key} (${item.entry.origin})\n`);
|
|
484
|
+
if (current !== undefined) {
|
|
485
|
+
process.stdout.write(` ${config.sourceLocale} ${current}\n`);
|
|
486
|
+
}
|
|
487
|
+
process.stdout.write(` ${item.locale} ${item.entry.value}\n`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (!selected && !narrowed) {
|
|
491
|
+
process.stdout.write(
|
|
492
|
+
`\n${waiting.length} waiting. Sign off with --all, or narrow with --locale / --key.\n`,
|
|
493
|
+
);
|
|
494
|
+
process.exit(0);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (values['dry-run']) {
|
|
498
|
+
process.stdout.write(`\n${waiting.length} would be marked reviewed. Nothing written.\n`);
|
|
499
|
+
process.exit(0);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const marked = markReviewed(waiting, new Date().toISOString());
|
|
503
|
+
saveMemory(memoryFile, memory);
|
|
504
|
+
process.stdout.write(
|
|
505
|
+
`\nMarked ${marked} translation${marked === 1 ? '' : 's'} reviewed in ` +
|
|
506
|
+
`${relative(config.root, memoryFile)}.\n`,
|
|
507
|
+
);
|
|
508
|
+
process.exit(0);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (command === 'apply') {
|
|
512
|
+
const saved = loadRun(proposalsFile!);
|
|
513
|
+
const { glossary } = openGlossary(config);
|
|
514
|
+
const { limits } = openLimits(config);
|
|
515
|
+
const source = loadBundle(config, config.sourceLocale);
|
|
516
|
+
const { ready, dropped } = recheck(config, source, glossary, limits, saved.proposals);
|
|
517
|
+
|
|
518
|
+
process.stdout.write(
|
|
519
|
+
`${saved.proposals.length} proposal${saved.proposals.length === 1 ? '' : 's'} from ` +
|
|
520
|
+
`${saved.model}${saved.createdAt ? `, saved ${saved.createdAt}` : ''}\n`,
|
|
521
|
+
);
|
|
522
|
+
for (const proposal of ready) {
|
|
523
|
+
process.stdout.write(
|
|
524
|
+
` ${proposal.locale} ${proposal.kind.padEnd(7)} ${proposal.key}\n ${proposal.value}\n`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
if (dropped.length > 0) {
|
|
528
|
+
process.stdout.write('\ndropped\n');
|
|
529
|
+
for (const entry of dropped) {
|
|
530
|
+
process.stdout.write(
|
|
531
|
+
` ${entry.proposal.locale} ${entry.proposal.key}\n ! ${entry.reason}\n`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
process.stdout.write(`\n${ready.length} to apply, ${dropped.length} dropped\n`);
|
|
536
|
+
|
|
537
|
+
if (values['dry-run']) {
|
|
538
|
+
process.stdout.write('Dry run: nothing written.\n');
|
|
539
|
+
process.exit(dropped.length > 0 ? 1 : 0);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const applied = applyProposals(config, source, ready);
|
|
543
|
+
const memoryFile = memoryPath(config.root, values.memory);
|
|
544
|
+
const memory = loadMemory(memoryFile) ?? emptyMemory(config.sourceLocale);
|
|
545
|
+
recordMachine(memory, ready, new Date().toISOString());
|
|
546
|
+
saveMemory(memoryFile, memory);
|
|
547
|
+
|
|
548
|
+
process.stdout.write(
|
|
549
|
+
`Wrote ${applied.written} string${applied.written === 1 ? '' : 's'} to ` +
|
|
550
|
+
`${applied.files.length} file${applied.files.length === 1 ? '' : 's'}, ` +
|
|
551
|
+
`recorded as unreviewed machine output in ${relative(config.root, memoryFile)}.\n`,
|
|
552
|
+
);
|
|
553
|
+
for (const skip of applied.skipped) {
|
|
554
|
+
process.stdout.write(` not written: ${skip.locale} ${skip.key} — ${skip.reason}\n`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
process.exit(dropped.length > 0 ? 1 : 0);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (command === 'sync') {
|
|
561
|
+
const file = memoryPath(config.root, values.memory);
|
|
562
|
+
const memory = (values['no-memory'] ? null : loadMemory(file)) ?? emptyMemory(config.sourceLocale);
|
|
563
|
+
const result = syncMemory(config, memory, {
|
|
564
|
+
origin,
|
|
565
|
+
reviewed: origin === 'human',
|
|
566
|
+
locales: values.locale,
|
|
567
|
+
force: values.force === true,
|
|
568
|
+
});
|
|
569
|
+
saveMemory(file, memory);
|
|
570
|
+
|
|
571
|
+
if (values.json) {
|
|
572
|
+
process.stdout.write(`${JSON.stringify({ memory: file, ...result }, null, 2)}\n`);
|
|
573
|
+
} else {
|
|
574
|
+
process.stdout.write(
|
|
575
|
+
[
|
|
576
|
+
`memory ${relative(config.root, file)}`,
|
|
577
|
+
`created ${result.created} updated ${result.updated} ` +
|
|
578
|
+
`kept-stale ${result.keptStale} unchanged ${result.unchanged} removed ${result.removed}`,
|
|
579
|
+
result.keptStale > 0 && !values.force
|
|
580
|
+
? `\n${result.keptStale} translation(s) still stale — retranslate them, or run sync --force to accept as-is.`
|
|
581
|
+
: '',
|
|
582
|
+
]
|
|
583
|
+
.filter(Boolean)
|
|
584
|
+
.join('\n') + '\n',
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
process.exit(0);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (command === 'translate') {
|
|
591
|
+
process.exitCode = await translateCommand(config);
|
|
592
|
+
} else {
|
|
593
|
+
const localeFilter = new Set(values.locale ?? []);
|
|
594
|
+
const ruleFilter = new Set((values.rule ?? []) as RuleId[]);
|
|
595
|
+
|
|
596
|
+
// Naming a rule explicitly turns it on; otherwise --rule untracked, which is
|
|
597
|
+
// off by default, would print nothing and look broken.
|
|
598
|
+
for (const rule of ruleFilter) {
|
|
599
|
+
if (config.rules[rule] === 'off') config.rules[rule] = 'warning';
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const { memory } = openMemory(config);
|
|
603
|
+
const { glossary } = openGlossary(config);
|
|
604
|
+
const { limits } = openLimits(config);
|
|
605
|
+
const report = check(config, memory, glossary, limits);
|
|
606
|
+
if (localeFilter.size > 0 || ruleFilter.size > 0) {
|
|
607
|
+
report.findings = report.findings.filter(
|
|
608
|
+
(f) =>
|
|
609
|
+
(localeFilter.size === 0 || localeFilter.has(f.locale)) &&
|
|
610
|
+
(ruleFilter.size === 0 || ruleFilter.has(f.rule)),
|
|
611
|
+
);
|
|
612
|
+
if (localeFilter.size > 0) {
|
|
613
|
+
report.stats = report.stats.filter((s) => localeFilter.has(s.locale));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (values.json) {
|
|
618
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
619
|
+
} else {
|
|
620
|
+
process.stdout.write(`${renderReport(report, config.root, limit)}\n`);
|
|
621
|
+
if (ruleFilter.size > 0) {
|
|
622
|
+
// The table stays project-wide on purpose; only the list below it is filtered.
|
|
623
|
+
process.stdout.write(`(list filtered to: ${[...ruleFilter].join(', ')})\n`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
process.exit(report.findings.some((f) => f.severity === 'error') ? 1 : 0);
|
|
628
|
+
}
|
|
629
|
+
} catch (err) {
|
|
630
|
+
if (err instanceof ScanError) fail(err.message);
|
|
631
|
+
if (err instanceof MemoryError) fail(err.message);
|
|
632
|
+
if (err instanceof GlossaryError) fail(err.message);
|
|
633
|
+
if (err instanceof LimitsError) fail(err.message);
|
|
634
|
+
if (err instanceof ApplyError) fail(err.message);
|
|
635
|
+
if (err instanceof FormatError) fail(describeFormatError(err));
|
|
636
|
+
throw err;
|
|
637
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base for every format reader's failure.
|
|
3
|
+
*
|
|
4
|
+
* The CLI and the MCP server catch this one type, so adding a format cannot
|
|
5
|
+
* leave a new error class escaping as an uncaught stack trace.
|
|
6
|
+
*/
|
|
7
|
+
export class FormatError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
public readonly file: string,
|
|
10
|
+
message: string,
|
|
11
|
+
public readonly line?: number,
|
|
12
|
+
) {
|
|
13
|
+
super(line === undefined ? message : `${message} (line ${line})`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function describeFormatError(err: FormatError): string {
|
|
18
|
+
return `Cannot read ${err.file}\n ${err.message}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Leaf, LeafKind } from '../types.js';
|
|
2
|
+
|
|
3
|
+
export type PlainValue =
|
|
4
|
+
| string
|
|
5
|
+
| number
|
|
6
|
+
| boolean
|
|
7
|
+
| null
|
|
8
|
+
| PlainValue[]
|
|
9
|
+
| { [key: string]: PlainValue };
|
|
10
|
+
|
|
11
|
+
function kindOf(value: string | number | boolean | null): LeafKind {
|
|
12
|
+
if (value === null) return 'null';
|
|
13
|
+
if (typeof value === 'number') return 'number';
|
|
14
|
+
if (typeof value === 'boolean') return 'boolean';
|
|
15
|
+
return 'string';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Flattens a parsed locale value into dot-notation keys, shared by every format. */
|
|
19
|
+
export function flattenValue(
|
|
20
|
+
node: unknown,
|
|
21
|
+
prefix: string,
|
|
22
|
+
file: string,
|
|
23
|
+
leaves: Map<string, Leaf>,
|
|
24
|
+
containers: Set<string>,
|
|
25
|
+
): void {
|
|
26
|
+
if (node !== null && typeof node === 'object') {
|
|
27
|
+
if (prefix) containers.add(prefix);
|
|
28
|
+
const entries = Array.isArray(node)
|
|
29
|
+
? node.map((v, i) => [String(i), v] as const)
|
|
30
|
+
: Object.entries(node as Record<string, unknown>);
|
|
31
|
+
for (const [k, v] of entries) {
|
|
32
|
+
flattenValue(v, prefix ? `${prefix}.${k}` : k, file, leaves, containers);
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const value = node as string | number | boolean | null;
|
|
37
|
+
leaves.set(prefix, { value: value === null ? '' : String(value), kind: kindOf(value), file });
|
|
38
|
+
}
|