@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/translate.ts
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import {
|
|
5
|
+
containsTerm,
|
|
6
|
+
expectationsFor,
|
|
7
|
+
missingVerbatim,
|
|
8
|
+
type Glossary,
|
|
9
|
+
} from './glossary.js';
|
|
10
|
+
import { limitFor, measurable, renderedWidth, type Limits } from './lengths.js';
|
|
11
|
+
import { categoriesFor, looksLikeIcuPlural, scanIcu } from './plurals.js';
|
|
12
|
+
import { diffPlaceholders, extractPlaceholders } from './placeholders.js';
|
|
13
|
+
import { loadBundle } from './scan.js';
|
|
14
|
+
import type { Config, Finding, LocaleBundle } from './types.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The one part of this tool that needs a network and cannot be verified by
|
|
18
|
+
* reading the output. So it is not trusted: every proposal is put back through
|
|
19
|
+
* the same deterministic checks the linter already applies, and anything that
|
|
20
|
+
* fails is rejected rather than written.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_MODEL = 'claude-opus-5';
|
|
24
|
+
export const DEFAULT_BATCH = 20;
|
|
25
|
+
export const DEFAULT_CAP = 50;
|
|
26
|
+
|
|
27
|
+
export type JobKind = 'fill' | 'repair' | 'refresh';
|
|
28
|
+
|
|
29
|
+
export const JOB_KINDS: readonly JobKind[] = ['fill', 'repair', 'refresh'];
|
|
30
|
+
|
|
31
|
+
/** No usable translation exists. */
|
|
32
|
+
const FILL_RULES = new Set(['missing_key']);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Defects worth handing back to the model.
|
|
36
|
+
*
|
|
37
|
+
* A rule belongs here only if `validate` can confirm the repair afterwards.
|
|
38
|
+
* That is the whole selection principle: a fix the gate cannot check is a fix
|
|
39
|
+
* nobody can trust, so those findings are left for a human.
|
|
40
|
+
*
|
|
41
|
+
* It excludes `identical_to_source` (often correct — "Email" is "Email" in
|
|
42
|
+
* French, and forcing a change would make it worse), `plural_extra_category`
|
|
43
|
+
* and `plural_selector_lost` (the single-string validator does not check for
|
|
44
|
+
* either, so a repair could not be verified).
|
|
45
|
+
*/
|
|
46
|
+
export const REPAIRABLE_RULES = new Set([
|
|
47
|
+
'placeholder_missing',
|
|
48
|
+
'placeholder_extra',
|
|
49
|
+
'icu_syntax_error',
|
|
50
|
+
'plural_missing_category',
|
|
51
|
+
'glossary_violation',
|
|
52
|
+
'dnt_violation',
|
|
53
|
+
'length_over_max',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const RANK: Record<JobKind, number> = { fill: 0, repair: 1, refresh: 2 };
|
|
57
|
+
|
|
58
|
+
export interface Job {
|
|
59
|
+
locale: string;
|
|
60
|
+
key: string;
|
|
61
|
+
source: string;
|
|
62
|
+
kind: JobKind;
|
|
63
|
+
/** The translation being replaced: broken for a repair, outdated for a refresh. */
|
|
64
|
+
previous: string | null;
|
|
65
|
+
/** For a repair, exactly what the linter found wrong. */
|
|
66
|
+
problems: string[];
|
|
67
|
+
placeholders: string[];
|
|
68
|
+
glossary: Array<{ term: string; accepted: string[] }>;
|
|
69
|
+
doNotTranslate: string[];
|
|
70
|
+
maxWidth: number | null;
|
|
71
|
+
isIcuPlural: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface Proposal {
|
|
75
|
+
locale: string;
|
|
76
|
+
key: string;
|
|
77
|
+
kind: JobKind;
|
|
78
|
+
source: string;
|
|
79
|
+
value: string;
|
|
80
|
+
accepted: boolean;
|
|
81
|
+
/** Deterministic checks the proposal failed, if any. */
|
|
82
|
+
rejections: string[];
|
|
83
|
+
attempts: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Turns a lint report into a work list. */
|
|
87
|
+
export function collectJobs(
|
|
88
|
+
config: Config,
|
|
89
|
+
findings: Finding[],
|
|
90
|
+
source: LocaleBundle,
|
|
91
|
+
glossary: Glossary | null,
|
|
92
|
+
limits: Limits | null,
|
|
93
|
+
locales?: string[],
|
|
94
|
+
kinds?: JobKind[],
|
|
95
|
+
): Job[] {
|
|
96
|
+
const wantedLocale = locales && locales.length > 0 ? new Set(locales) : null;
|
|
97
|
+
const wantedKind = kinds && kinds.length > 0 ? new Set(kinds) : null;
|
|
98
|
+
|
|
99
|
+
interface Draft {
|
|
100
|
+
locale: string;
|
|
101
|
+
key: string;
|
|
102
|
+
kind: JobKind;
|
|
103
|
+
problems: string[];
|
|
104
|
+
}
|
|
105
|
+
const drafts = new Map<string, Draft>();
|
|
106
|
+
|
|
107
|
+
for (const finding of findings) {
|
|
108
|
+
// The source locale is checked too — a broken or oversized source string is
|
|
109
|
+
// reported against it — but there is nothing to translate it into.
|
|
110
|
+
if (finding.locale === config.sourceLocale) continue;
|
|
111
|
+
if (wantedLocale && !wantedLocale.has(finding.locale)) continue;
|
|
112
|
+
|
|
113
|
+
let kind: JobKind | null = null;
|
|
114
|
+
if (FILL_RULES.has(finding.rule)) kind = 'fill';
|
|
115
|
+
else if (REPAIRABLE_RULES.has(finding.rule)) kind = 'repair';
|
|
116
|
+
else if (finding.rule === 'stale') kind = 'refresh';
|
|
117
|
+
if (!kind) continue;
|
|
118
|
+
|
|
119
|
+
const id = `${finding.locale}\u0000${finding.key}`;
|
|
120
|
+
const draft = drafts.get(id);
|
|
121
|
+
const problem = `${finding.rule}: ${finding.detail}`;
|
|
122
|
+
|
|
123
|
+
if (!draft) {
|
|
124
|
+
drafts.set(id, {
|
|
125
|
+
locale: finding.locale,
|
|
126
|
+
key: finding.key,
|
|
127
|
+
kind,
|
|
128
|
+
problems: kind === 'repair' ? [problem] : [],
|
|
129
|
+
});
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// One key can be broken several ways at once; collect every reason.
|
|
134
|
+
if (kind === 'repair') draft.problems.push(problem);
|
|
135
|
+
// A defect outranks mere staleness, and a missing string outranks both.
|
|
136
|
+
if (RANK[kind] < RANK[draft.kind]) draft.kind = kind;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const targets = new Map<string, LocaleBundle>();
|
|
140
|
+
const targetFor = (locale: string): LocaleBundle => {
|
|
141
|
+
let bundle = targets.get(locale);
|
|
142
|
+
if (!bundle) {
|
|
143
|
+
bundle = loadBundle(config, locale);
|
|
144
|
+
targets.set(locale, bundle);
|
|
145
|
+
}
|
|
146
|
+
return bundle;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const jobs: Job[] = [];
|
|
150
|
+
for (const draft of drafts.values()) {
|
|
151
|
+
if (wantedKind && !wantedKind.has(draft.kind)) continue;
|
|
152
|
+
|
|
153
|
+
const sourceLeaf = source.leaves.get(draft.key);
|
|
154
|
+
if (!sourceLeaf || sourceLeaf.value.trim() === '') continue;
|
|
155
|
+
|
|
156
|
+
// A group finding such as `item_*` names no real key and is skipped above,
|
|
157
|
+
// which is why only per-string plural defects reach a repair job.
|
|
158
|
+
const previous =
|
|
159
|
+
draft.kind === 'fill' ? null : (targetFor(draft.locale).leaves.get(draft.key)?.value ?? null);
|
|
160
|
+
|
|
161
|
+
jobs.push({
|
|
162
|
+
locale: draft.locale,
|
|
163
|
+
key: draft.key,
|
|
164
|
+
source: sourceLeaf.value,
|
|
165
|
+
kind: draft.kind,
|
|
166
|
+
previous: previous === '' ? null : previous,
|
|
167
|
+
problems: draft.problems,
|
|
168
|
+
placeholders: [...extractPlaceholders(sourceLeaf.value, config.placeholderSyntaxes).keys()],
|
|
169
|
+
glossary: glossary
|
|
170
|
+
? expectationsFor(glossary, draft.locale, sourceLeaf.value).map((e) => ({
|
|
171
|
+
term: e.term.source,
|
|
172
|
+
accepted: e.accepted,
|
|
173
|
+
}))
|
|
174
|
+
: [],
|
|
175
|
+
doNotTranslate: glossary
|
|
176
|
+
? glossary.doNotTranslate.filter((token) =>
|
|
177
|
+
containsTerm(sourceLeaf.value, token, 'exact', true),
|
|
178
|
+
)
|
|
179
|
+
: [],
|
|
180
|
+
maxWidth: limits ? limitFor(limits, draft.key) : null,
|
|
181
|
+
isIcuPlural: looksLikeIcuPlural(sourceLeaf.value),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return jobs;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** What `validate` needs to judge a string; a full Job satisfies it. */
|
|
189
|
+
export type Constraints = Pick<Job, 'source' | 'locale' | 'maxWidth'>;
|
|
190
|
+
|
|
191
|
+
/** The same rules the linter enforces, applied to one proposed string. */
|
|
192
|
+
export function validate(
|
|
193
|
+
config: Config,
|
|
194
|
+
job: Constraints,
|
|
195
|
+
proposed: string,
|
|
196
|
+
glossary: Glossary | null,
|
|
197
|
+
): string[] {
|
|
198
|
+
const problems: string[] = [];
|
|
199
|
+
|
|
200
|
+
if (proposed.trim() === '') return ['empty translation'];
|
|
201
|
+
|
|
202
|
+
const lost = diffPlaceholders(
|
|
203
|
+
extractPlaceholders(job.source, config.placeholderSyntaxes),
|
|
204
|
+
extractPlaceholders(proposed, config.placeholderSyntaxes),
|
|
205
|
+
);
|
|
206
|
+
if (lost.missing.length > 0) problems.push(`placeholders lost: ${lost.missing.join(', ')}`);
|
|
207
|
+
if (lost.extra.length > 0) problems.push(`placeholders invented: ${lost.extra.join(', ')}`);
|
|
208
|
+
|
|
209
|
+
const categories = categoriesFor(job.locale);
|
|
210
|
+
const scan = scanIcu(proposed);
|
|
211
|
+
if (scan.error) {
|
|
212
|
+
problems.push(`malformed ICU: ${scan.error}`);
|
|
213
|
+
} else if (categories) {
|
|
214
|
+
for (const block of scan.blocks) {
|
|
215
|
+
const absent = [...categories].filter((c) => !block.categories.has(c));
|
|
216
|
+
if (absent.length > 0) {
|
|
217
|
+
problems.push(`plural forms missing for ${job.locale}: ${absent.join('/')}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (glossary) {
|
|
223
|
+
const verbatim = missingVerbatim(glossary, job.source, proposed);
|
|
224
|
+
if (verbatim.length > 0) problems.push(`must stay verbatim: ${verbatim.join(', ')}`);
|
|
225
|
+
|
|
226
|
+
for (const { term, accepted } of expectationsFor(glossary, job.locale, job.source)) {
|
|
227
|
+
const ok = accepted.some((form) =>
|
|
228
|
+
containsTerm(proposed, form, term.match, term.caseSensitive),
|
|
229
|
+
);
|
|
230
|
+
if (!ok) {
|
|
231
|
+
problems.push(`"${term.source}" must be rendered as ${accepted.join(' or ')}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (job.maxWidth !== null && measurable(proposed)) {
|
|
237
|
+
const width = renderedWidth(proposed, config.placeholderSyntaxes.includes('laravel'));
|
|
238
|
+
if (width > job.maxWidth) {
|
|
239
|
+
problems.push(`${width} columns, limit ${job.maxWidth}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return problems;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// The model call
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
const SYSTEM = `You translate user-interface strings for software localisation.
|
|
251
|
+
|
|
252
|
+
Rules, in order of priority:
|
|
253
|
+
1. Every placeholder in the source must appear in the translation, spelled exactly
|
|
254
|
+
as given. Never translate, reorder away, or invent placeholders.
|
|
255
|
+
2. If the source is an ICU message, keep its structure and supply every plural
|
|
256
|
+
category the target language requires.
|
|
257
|
+
3. Glossary terms must be rendered with one of the approved forms, inflected as
|
|
258
|
+
the sentence requires.
|
|
259
|
+
4. Do-not-translate tokens must appear character for character.
|
|
260
|
+
5. Stay within the width limit when one is given; prefer the shortest natural
|
|
261
|
+
wording that is still idiomatic.
|
|
262
|
+
6. Match the register and tone of interface copy: concise, direct, no added
|
|
263
|
+
punctuation or explanation.
|
|
264
|
+
|
|
265
|
+
A string that arrives with \`current_translation\` and \`problems_to_fix\` is a
|
|
266
|
+
repair, not a fresh translation. Someone already chose that wording; keep it
|
|
267
|
+
wherever it is right and change only what the listed problems require.
|
|
268
|
+
|
|
269
|
+
Return only the translations. Do not comment on them.`;
|
|
270
|
+
|
|
271
|
+
const ProposalSchema = z.object({
|
|
272
|
+
translations: z.array(
|
|
273
|
+
z.object({
|
|
274
|
+
id: z.number().int().describe('The id of the string being translated.'),
|
|
275
|
+
text: z.string().describe('The translated string.'),
|
|
276
|
+
}),
|
|
277
|
+
),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
function describeJob(job: Job, index: number): Record<string, unknown> {
|
|
281
|
+
const spec: Record<string, unknown> = {
|
|
282
|
+
id: index,
|
|
283
|
+
key: job.key,
|
|
284
|
+
source: job.source,
|
|
285
|
+
};
|
|
286
|
+
if (job.placeholders.length > 0) spec['placeholders_that_must_survive'] = job.placeholders;
|
|
287
|
+
if (job.isIcuPlural) {
|
|
288
|
+
const categories = categoriesFor(job.locale);
|
|
289
|
+
spec['icu_plural'] = true;
|
|
290
|
+
if (categories) spec['required_plural_categories'] = [...categories];
|
|
291
|
+
}
|
|
292
|
+
if (job.glossary.length > 0) {
|
|
293
|
+
spec['glossary'] = job.glossary.map((g) => ({ source_term: g.term, use_one_of: g.accepted }));
|
|
294
|
+
}
|
|
295
|
+
if (job.doNotTranslate.length > 0) spec['do_not_translate'] = job.doNotTranslate;
|
|
296
|
+
if (job.maxWidth !== null) spec['max_display_columns'] = job.maxWidth;
|
|
297
|
+
if (job.previous) {
|
|
298
|
+
spec[job.kind === 'repair' ? 'current_translation' : 'outdated_translation'] = job.previous;
|
|
299
|
+
}
|
|
300
|
+
if (job.problems.length > 0) spec['problems_to_fix'] = job.problems;
|
|
301
|
+
return spec;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface TranslateOptions {
|
|
305
|
+
model: string;
|
|
306
|
+
effort: 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
307
|
+
client?: Anthropic;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The model declined one batch on content grounds; other batches may still run. */
|
|
311
|
+
export class TranslationRefused extends Error {}
|
|
312
|
+
|
|
313
|
+
/** One request for one locale's batch. Returns text keyed by job index. */
|
|
314
|
+
export async function requestBatch(
|
|
315
|
+
jobs: Job[],
|
|
316
|
+
locale: string,
|
|
317
|
+
options: TranslateOptions,
|
|
318
|
+
feedback?: Map<number, string[]>,
|
|
319
|
+
): Promise<Map<number, string>> {
|
|
320
|
+
const client = options.client ?? new Anthropic();
|
|
321
|
+
|
|
322
|
+
const payload = {
|
|
323
|
+
target_locale: locale,
|
|
324
|
+
strings: jobs.map((job, index) => {
|
|
325
|
+
const spec = describeJob(job, index);
|
|
326
|
+
const failures = feedback?.get(index);
|
|
327
|
+
if (failures) spec['previous_attempt_was_rejected_because'] = failures;
|
|
328
|
+
return spec;
|
|
329
|
+
}),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const response = await client.messages.parse({
|
|
333
|
+
model: options.model,
|
|
334
|
+
max_tokens: 16000,
|
|
335
|
+
system: SYSTEM,
|
|
336
|
+
thinking: { type: 'adaptive' },
|
|
337
|
+
output_config: {
|
|
338
|
+
effort: options.effort,
|
|
339
|
+
format: zodOutputFormat(ProposalSchema),
|
|
340
|
+
},
|
|
341
|
+
messages: [{ role: 'user', content: JSON.stringify(payload, null, 2) }],
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
if (response.stop_reason === 'refusal') {
|
|
345
|
+
const category = response.stop_details?.category ?? 'unspecified';
|
|
346
|
+
throw new TranslationRefused(`the model declined this batch (${category})`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const parsed = response.parsed_output;
|
|
350
|
+
if (!parsed) throw new Error('the model returned no parseable translations');
|
|
351
|
+
|
|
352
|
+
const byIndex = new Map<number, string>();
|
|
353
|
+
for (const item of parsed.translations) {
|
|
354
|
+
if (item.id >= 0 && item.id < jobs.length) byIndex.set(item.id, item.text);
|
|
355
|
+
}
|
|
356
|
+
return byIndex;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export interface TranslationRun {
|
|
360
|
+
proposals: Proposal[];
|
|
361
|
+
/**
|
|
362
|
+
* Why the run stopped early, if it did. Anything that is not a content
|
|
363
|
+
* refusal — no credentials, a rate limit, a network failure — will hit every
|
|
364
|
+
* remaining batch the same way, so the run stops instead of reporting the
|
|
365
|
+
* same infrastructure error once per string.
|
|
366
|
+
*/
|
|
367
|
+
aborted: string | null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export interface RunOptions extends TranslateOptions {
|
|
371
|
+
batchSize: number;
|
|
372
|
+
/** Called before each request so the caller can report progress. */
|
|
373
|
+
onBatch?: (locale: string, size: number, attempt: number) => void;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Translates a work list, validating every proposal and retrying once with the
|
|
378
|
+
* specific failures handed back to the model.
|
|
379
|
+
*/
|
|
380
|
+
export async function runTranslation(
|
|
381
|
+
config: Config,
|
|
382
|
+
jobs: Job[],
|
|
383
|
+
glossary: Glossary | null,
|
|
384
|
+
options: RunOptions,
|
|
385
|
+
): Promise<TranslationRun> {
|
|
386
|
+
const proposals: Proposal[] = [];
|
|
387
|
+
|
|
388
|
+
const byLocale = new Map<string, Job[]>();
|
|
389
|
+
for (const job of jobs) {
|
|
390
|
+
const list = byLocale.get(job.locale);
|
|
391
|
+
if (list) list.push(job);
|
|
392
|
+
else byLocale.set(job.locale, [job]);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
for (const [locale, localeJobs] of byLocale) {
|
|
396
|
+
for (let start = 0; start < localeJobs.length; start += options.batchSize) {
|
|
397
|
+
const batch = localeJobs.slice(start, start + options.batchSize);
|
|
398
|
+
|
|
399
|
+
options.onBatch?.(locale, batch.length, 1);
|
|
400
|
+
let results: Map<number, string>;
|
|
401
|
+
try {
|
|
402
|
+
results = await requestBatch(batch, locale, options);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
if (!(err instanceof TranslationRefused)) {
|
|
405
|
+
return { proposals, aborted: err instanceof Error ? err.message : String(err) };
|
|
406
|
+
}
|
|
407
|
+
for (const job of batch) {
|
|
408
|
+
proposals.push({
|
|
409
|
+
locale,
|
|
410
|
+
key: job.key,
|
|
411
|
+
kind: job.kind,
|
|
412
|
+
source: job.source,
|
|
413
|
+
value: '',
|
|
414
|
+
accepted: false,
|
|
415
|
+
rejections: [err.message],
|
|
416
|
+
attempts: 1,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const verdicts = batch.map((job, index) => {
|
|
423
|
+
const value = results.get(index) ?? '';
|
|
424
|
+
return { job, index, value, problems: value ? validate(config, job, value, glossary) : ['no translation returned'] };
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// One retry, telling the model exactly which rule each string broke.
|
|
428
|
+
const failed = verdicts.filter((v) => v.problems.length > 0);
|
|
429
|
+
if (failed.length > 0) {
|
|
430
|
+
const retryJobs = failed.map((v) => v.job);
|
|
431
|
+
const feedback = new Map<number, string[]>();
|
|
432
|
+
failed.forEach((v, position) => feedback.set(position, v.problems));
|
|
433
|
+
|
|
434
|
+
options.onBatch?.(locale, retryJobs.length, 2);
|
|
435
|
+
try {
|
|
436
|
+
const retried = await requestBatch(retryJobs, locale, options, feedback);
|
|
437
|
+
failed.forEach((v, position) => {
|
|
438
|
+
const value = retried.get(position);
|
|
439
|
+
if (!value) return;
|
|
440
|
+
const problems = validate(config, v.job, value, glossary);
|
|
441
|
+
if (problems.length === 0) {
|
|
442
|
+
v.value = value;
|
|
443
|
+
v.problems = [];
|
|
444
|
+
} else {
|
|
445
|
+
v.value = value;
|
|
446
|
+
v.problems = problems;
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
} catch (err) {
|
|
450
|
+
if (!(err instanceof TranslationRefused)) {
|
|
451
|
+
for (const verdict of verdicts) {
|
|
452
|
+
proposals.push({
|
|
453
|
+
locale,
|
|
454
|
+
key: verdict.job.key,
|
|
455
|
+
kind: verdict.job.kind,
|
|
456
|
+
source: verdict.job.source,
|
|
457
|
+
value: verdict.value,
|
|
458
|
+
accepted: verdict.problems.length === 0,
|
|
459
|
+
rejections: verdict.problems,
|
|
460
|
+
attempts: 2,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
return { proposals, aborted: err instanceof Error ? err.message : String(err) };
|
|
464
|
+
}
|
|
465
|
+
// A refusal on the retry keeps the first-attempt rejections, which
|
|
466
|
+
// are the more useful report.
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
for (const verdict of verdicts) {
|
|
471
|
+
proposals.push({
|
|
472
|
+
locale,
|
|
473
|
+
key: verdict.job.key,
|
|
474
|
+
kind: verdict.job.kind,
|
|
475
|
+
source: verdict.job.source,
|
|
476
|
+
value: verdict.value,
|
|
477
|
+
accepted: verdict.problems.length === 0,
|
|
478
|
+
rejections: verdict.problems,
|
|
479
|
+
attempts: failed.includes(verdict) ? 2 : 1,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return { proposals, aborted: null };
|
|
486
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export type Severity = 'error' | 'warning';
|
|
2
|
+
export type RuleSetting = Severity | 'off';
|
|
3
|
+
|
|
4
|
+
export const RULE_IDS = [
|
|
5
|
+
'missing_key',
|
|
6
|
+
'orphan_key',
|
|
7
|
+
'structure_mismatch',
|
|
8
|
+
'placeholder_missing',
|
|
9
|
+
'placeholder_extra',
|
|
10
|
+
'identical_to_source',
|
|
11
|
+
'stale',
|
|
12
|
+
'untracked',
|
|
13
|
+
'icu_syntax_error',
|
|
14
|
+
'plural_missing_category',
|
|
15
|
+
'plural_extra_category',
|
|
16
|
+
'plural_selector_lost',
|
|
17
|
+
'dnt_violation',
|
|
18
|
+
'glossary_violation',
|
|
19
|
+
'inconsistent_translation',
|
|
20
|
+
'length_over_max',
|
|
21
|
+
'length_overflow',
|
|
22
|
+
'unreadable_file',
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export type RuleId = (typeof RULE_IDS)[number];
|
|
26
|
+
|
|
27
|
+
/** Default severities. Anything that breaks at runtime is an error. */
|
|
28
|
+
export const DEFAULT_RULES: Record<RuleId, RuleSetting> = {
|
|
29
|
+
missing_key: 'error',
|
|
30
|
+
orphan_key: 'warning',
|
|
31
|
+
structure_mismatch: 'error',
|
|
32
|
+
placeholder_missing: 'error',
|
|
33
|
+
placeholder_extra: 'error',
|
|
34
|
+
// Correct far more often than not on real data — country names, product
|
|
35
|
+
// names, borrowed words — so it is asked for rather than assumed.
|
|
36
|
+
identical_to_source: 'off',
|
|
37
|
+
stale: 'warning',
|
|
38
|
+
// Every key is untracked until the first sync, so this one is opt-in.
|
|
39
|
+
untracked: 'off',
|
|
40
|
+
// Malformed ICU throws at format time; a missing category only renders the
|
|
41
|
+
// wrong grammar, which is bad but not fatal.
|
|
42
|
+
icu_syntax_error: 'error',
|
|
43
|
+
plural_missing_category: 'warning',
|
|
44
|
+
plural_extra_category: 'warning',
|
|
45
|
+
plural_selector_lost: 'warning',
|
|
46
|
+
dnt_violation: 'warning',
|
|
47
|
+
glossary_violation: 'warning',
|
|
48
|
+
// Reusing one wording for a repeated source string is often deliberate, so
|
|
49
|
+
// this is opt-in rather than noise by default.
|
|
50
|
+
inconsistent_translation: 'off',
|
|
51
|
+
length_over_max: 'warning',
|
|
52
|
+
// Ratio-based and therefore approximate; asked for rather than assumed.
|
|
53
|
+
length_overflow: 'off',
|
|
54
|
+
unreadable_file: 'error',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export interface Finding {
|
|
58
|
+
rule: RuleId;
|
|
59
|
+
severity: Severity;
|
|
60
|
+
locale: string;
|
|
61
|
+
key: string;
|
|
62
|
+
detail: string;
|
|
63
|
+
file: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type LeafKind = 'string' | 'number' | 'boolean' | 'null';
|
|
67
|
+
|
|
68
|
+
export interface Leaf {
|
|
69
|
+
value: string;
|
|
70
|
+
kind: LeafKind;
|
|
71
|
+
file: string;
|
|
72
|
+
/** gettext marks a translation whose source moved as fuzzy. */
|
|
73
|
+
fuzzy?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What a format reader fills in while reading one file. */
|
|
77
|
+
export interface ReadTarget {
|
|
78
|
+
/** Files that carry no translatable keys, with why they were passed over. */
|
|
79
|
+
skipped: Array<{ file: string; reason: string }>;
|
|
80
|
+
leaves: Map<string, Leaf>;
|
|
81
|
+
containers: Set<string>;
|
|
82
|
+
/** gettext plural entries: base key -> number of msgstr forms present. */
|
|
83
|
+
plurals: Map<string, number>;
|
|
84
|
+
/** nplurals declared by a .po header, when there is one. */
|
|
85
|
+
nplurals: number | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One locale, with every nested key flattened to dot notation. */
|
|
89
|
+
export interface LocaleBundle {
|
|
90
|
+
locale: string;
|
|
91
|
+
files: string[];
|
|
92
|
+
skipped: Array<{ file: string; reason: string }>;
|
|
93
|
+
/** Files that could not be parsed. One bad file must not hide the rest. */
|
|
94
|
+
unreadable: Array<{ file: string; message: string }>;
|
|
95
|
+
leaves: Map<string, Leaf>;
|
|
96
|
+
/** Keys that hold an object or array rather than a value. */
|
|
97
|
+
containers: Set<string>;
|
|
98
|
+
plurals: Map<string, number>;
|
|
99
|
+
nplurals: number | null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface Config {
|
|
103
|
+
root: string;
|
|
104
|
+
localesDir: string;
|
|
105
|
+
sourceLocale: string;
|
|
106
|
+
locales: string[];
|
|
107
|
+
layout: 'flat' | 'nested';
|
|
108
|
+
placeholderSyntaxes: string[];
|
|
109
|
+
/** Values legitimately identical to the source, e.g. "OK", "Email", brand names. */
|
|
110
|
+
ignoreIdentical: string[];
|
|
111
|
+
rules: Record<RuleId, RuleSetting>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface LocaleStat {
|
|
115
|
+
locale: string;
|
|
116
|
+
sourceKeys: number;
|
|
117
|
+
translated: number;
|
|
118
|
+
coverage: number;
|
|
119
|
+
missing: number;
|
|
120
|
+
orphan: number;
|
|
121
|
+
stale: number;
|
|
122
|
+
errors: number;
|
|
123
|
+
warnings: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface Report {
|
|
127
|
+
localesDir: string;
|
|
128
|
+
sourceLocale: string;
|
|
129
|
+
sourceKeys: number;
|
|
130
|
+
/** gettext carries its source text in every catalogue, not in one locale. */
|
|
131
|
+
sourceFromMsgid: boolean;
|
|
132
|
+
/** Whether a translation memory was available; without one, stale is unknowable. */
|
|
133
|
+
memoryLoaded: boolean;
|
|
134
|
+
stats: LocaleStat[];
|
|
135
|
+
findings: Finding[];
|
|
136
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The single place the version comes from.
|
|
7
|
+
*
|
|
8
|
+
* It used to be typed out in the CLI and again in the MCP server, and both
|
|
9
|
+
* still said 0.1.0 eleven releases later — a number nobody thinks to update
|
|
10
|
+
* because nothing breaks when they don't. Reading the manifest removes the
|
|
11
|
+
* choice.
|
|
12
|
+
*/
|
|
13
|
+
function readVersion(): string {
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
// dist/version.js -> the package root, which always ships package.json.
|
|
16
|
+
const manifest = join(here, '..', 'package.json');
|
|
17
|
+
try {
|
|
18
|
+
const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
19
|
+
const version = (parsed as { version?: unknown }).version;
|
|
20
|
+
if (typeof version === 'string' && version !== '') return version;
|
|
21
|
+
} catch {
|
|
22
|
+
// An install that lost its manifest still runs; it just cannot say which.
|
|
23
|
+
}
|
|
24
|
+
return 'unknown';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const VERSION = readVersion();
|