claude-translator 1.3.0 → 2.0.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/.claude-plugin/plugin.json +14 -0
- package/CHANGELOG.md +249 -0
- package/PRIVACY.md +71 -0
- package/README.md +279 -44
- package/bin/claude-translator.mjs +11 -2
- package/bin/cli.test.mjs +20 -1
- package/glossary.example.json +23 -0
- package/i18n.config.example.json +7 -0
- package/package.json +9 -5
- package/scripts/audit-seo.mjs +6 -3
- package/scripts/build-locales.mjs +55 -6
- package/scripts/config.mjs +66 -0
- package/scripts/credit.mjs +12 -5
- package/scripts/extract.mjs +21 -6
- package/scripts/format-locale.mjs +290 -0
- package/scripts/format-locale.test.mjs +171 -0
- package/scripts/glossary.mjs +229 -0
- package/scripts/glossary.test.mjs +188 -0
- package/scripts/providers/openai.mjs +63 -3
- package/scripts/providers/providers.test.mjs +80 -0
- package/scripts/roles.mjs +142 -0
- package/scripts/roles.test.mjs +140 -0
- package/scripts/tqa-score.mjs +127 -0
- package/scripts/tqa-score.test.mjs +144 -0
- package/scripts/tqa.mjs +449 -0
- package/scripts/translate.mjs +104 -13
- package/scripts/verify.mjs +113 -5
- package/{SKILL.md → skills/translate-site/SKILL.md} +45 -13
- package/{references → skills/translate-site/references}/providers.md +16 -2
- package/{references → skills/translate-site/references}/quality-review.md +28 -0
- /package/{references → skills/translate-site/references}/adapting-generators.md +0 -0
- /package/{references → skills/translate-site/references}/failure-modes.md +0 -0
- /package/{references → skills/translate-site/references}/throughput-and-cost.md +0 -0
package/scripts/translate.mjs
CHANGED
|
@@ -27,10 +27,12 @@ import { join } from 'path';
|
|
|
27
27
|
import { fileURLToPath } from 'url';
|
|
28
28
|
|
|
29
29
|
import {
|
|
30
|
-
SOURCE_FILE as SRC_FILE, TM_DIR, LOCALES, RTL, DNT, MODEL as CFG_MODEL,
|
|
30
|
+
SOURCE_FILE as SRC_FILE, TM_DIR, LOCALES, RTL, DNT, GLOSSARY, MODEL as CFG_MODEL,
|
|
31
31
|
ROOT_DIR as ROOT, SITE_NAME, SITE_DESCRIPTION, SOURCE_LANGUAGE,
|
|
32
32
|
PROVIDER as CFG_PROVIDER, API_BASE_URL, API_KEY_ENV, JSON_MODE, PRICING,
|
|
33
33
|
} from './config.mjs';
|
|
34
|
+
import { termsForBatch, glossaryPrompt, glossaryFingerprint, containsTerm } from './glossary.mjs';
|
|
35
|
+
import { roleOf, rolePrompt } from './roles.mjs';
|
|
34
36
|
import { hint, link } from './credit.mjs';
|
|
35
37
|
import { loadProvider, extractJson } from './providers/index.mjs';
|
|
36
38
|
|
|
@@ -115,10 +117,18 @@ const TECH_TOKENS = [
|
|
|
115
117
|
'OCR', 'GDPR', 'SSL', 'TLS', 'API', 'SDK', 'URL', 'HTTP', 'HTTPS', 'SEO', 'CSS', 'RSS',
|
|
116
118
|
];
|
|
117
119
|
|
|
118
|
-
|
|
120
|
+
/**
|
|
121
|
+
* The flat never-translate list that still goes into rule 2. The structured glossary
|
|
122
|
+
* (config.GLOSSARY) carries the same brands plus per-locale targets and is injected
|
|
123
|
+
* per batch; this line stays because format and protocol tokens are correct unchanged
|
|
124
|
+
* in every language and are cheap to state once.
|
|
125
|
+
*/
|
|
126
|
+
const DNT_NAMES = [...new Set([...DNT.brands, ...DNT.formats, ...TECH_TOKENS])];
|
|
119
127
|
|
|
120
|
-
function systemPrompt(langCode) {
|
|
128
|
+
function systemPrompt(langCode, batchTerms = [], batchRoles = []) {
|
|
121
129
|
const name = LANG_NAMES[langCode] ?? langCode;
|
|
130
|
+
const terminology = glossaryPrompt(batchTerms, langCode);
|
|
131
|
+
const context = rolePrompt(batchRoles);
|
|
122
132
|
return [
|
|
123
133
|
`You are a professional translator localising the website of ${SITE_NAME}${SITE_DESCRIPTION ? `, ${SITE_DESCRIPTION}` : ''}.`,
|
|
124
134
|
`Translate from ${SOURCE_LANGUAGE} into ${name} (${langCode}).`,
|
|
@@ -127,7 +137,9 @@ function systemPrompt(langCode) {
|
|
|
127
137
|
`1. Preserve every placeholder EXACTLY: <0>, </0>, <1/> and so on. Same count, same numbers.`,
|
|
128
138
|
` Placeholders wrap inline markup — move them so they wrap the equivalent words in your`,
|
|
129
139
|
` translation, but never drop, add, renumber or reorder their nesting.`,
|
|
130
|
-
`2. Never translate these names: ${
|
|
140
|
+
`2. Never translate these names: ${DNT_NAMES.join(', ')}.`,
|
|
141
|
+
` Match whole words, and respect capitalisation: a lowercase common noun that`,
|
|
142
|
+
` happens to spell a brand name is the common noun, and must be translated.`,
|
|
131
143
|
`3. This is marketing and product copy. Translate meaning and tone, not word for word.`,
|
|
132
144
|
` Keep it natural and idiomatic for a native reader.`,
|
|
133
145
|
`4. Keep numbers, prices, file sizes and counts unchanged (e.g. "120+", "1 GB", "10 MB").`,
|
|
@@ -135,6 +147,8 @@ function systemPrompt(langCode) {
|
|
|
135
147
|
` target language allows it. Headings stay headings; button labels stay short.`,
|
|
136
148
|
`6. Do not add explanations, notes or quotes around the result.`,
|
|
137
149
|
RTL.has(langCode) ? `7. ${name} is right-to-left. Write natural RTL text; do not insert directional marks.` : ``,
|
|
150
|
+
terminology,
|
|
151
|
+
context,
|
|
138
152
|
``,
|
|
139
153
|
`Return a JSON array. For each input item return { "id": <same id>, "text": "<translation>" }.`,
|
|
140
154
|
]
|
|
@@ -180,23 +194,29 @@ function validate(source, translated) {
|
|
|
180
194
|
* `usage` is normalised to { inTok, outTok } so the caller never sees a provider's
|
|
181
195
|
* own field names.
|
|
182
196
|
*/
|
|
183
|
-
async function callModel(langCode, items, attempt = 1, jsonMode = JSON_MODE) {
|
|
197
|
+
async function callModel(langCode, items, attempt = 1, jsonMode = JSON_MODE, drop = new Set()) {
|
|
198
|
+
// Recomputed per call, not per run, so a batch that gets halved on a safety block or a
|
|
199
|
+
// truncation carries exactly the terms its own half contains.
|
|
200
|
+
const batchTerms = termsForBatch(GLOSSARY, items.map((i) => i.text), langCode);
|
|
201
|
+
const batchRoles = items.map((i) => i.el).filter(Boolean);
|
|
202
|
+
|
|
184
203
|
const { url, headers, body } = PROVIDER.request({
|
|
185
204
|
model: MODEL,
|
|
186
|
-
system: systemPrompt(langCode),
|
|
205
|
+
system: systemPrompt(langCode, batchTerms, batchRoles),
|
|
187
206
|
items,
|
|
188
207
|
temperature: attempt === 1 ? 0.2 : 0.4,
|
|
189
208
|
key: KEY,
|
|
190
209
|
baseUrl: API_BASE_URL,
|
|
191
210
|
jsonMode,
|
|
211
|
+
drop,
|
|
192
212
|
});
|
|
193
213
|
|
|
194
214
|
const split = async (why, mode = jsonMode) => {
|
|
195
215
|
const mid = Math.ceil(items.length / 2);
|
|
196
216
|
process.stderr.write(` ${why} on ${items.length} units — splitting\n`);
|
|
197
217
|
const [a, b] = await Promise.all([
|
|
198
|
-
callModel(langCode, items.slice(0, mid), 1, mode),
|
|
199
|
-
callModel(langCode, items.slice(mid), 1, mode),
|
|
218
|
+
callModel(langCode, items.slice(0, mid), 1, mode, drop),
|
|
219
|
+
callModel(langCode, items.slice(mid), 1, mode, drop),
|
|
200
220
|
]);
|
|
201
221
|
return {
|
|
202
222
|
rows: [...a.rows, ...b.rows],
|
|
@@ -219,7 +239,7 @@ async function callModel(langCode, items, attempt = 1, jsonMode = JSON_MODE) {
|
|
|
219
239
|
` network error (${String(err.message ?? err).slice(0, 60)}), retry ${attempt} in ${wait}ms\n`
|
|
220
240
|
);
|
|
221
241
|
await new Promise((r) => setTimeout(r, wait));
|
|
222
|
-
return callModel(langCode, items, attempt + 1, jsonMode);
|
|
242
|
+
return callModel(langCode, items, attempt + 1, jsonMode, drop);
|
|
223
243
|
}
|
|
224
244
|
throw err;
|
|
225
245
|
}
|
|
@@ -236,16 +256,26 @@ async function callModel(langCode, items, attempt = 1, jsonMode = JSON_MODE) {
|
|
|
236
256
|
process.stderr.write(
|
|
237
257
|
` server rejected JSON mode "${jsonMode ?? 'schema'}" — retrying with "${next}"\n`
|
|
238
258
|
);
|
|
239
|
-
return callModel(langCode, items, attempt, next);
|
|
259
|
+
return callModel(langCode, items, attempt, next, drop);
|
|
240
260
|
}
|
|
241
261
|
}
|
|
242
262
|
|
|
263
|
+
// The same idea one level down: a server that rejects ONE parameter is telling us
|
|
264
|
+
// about its capabilities, not about a broken run. Drop that parameter and retry.
|
|
265
|
+
// `drop` only ever grows, so each parameter is dropped at most once and this cannot
|
|
266
|
+
// loop. Adapters that do not implement the hook are unaffected.
|
|
267
|
+
const bad = PROVIDER.unsupportedParam?.(res.status, text);
|
|
268
|
+
if (bad && !drop.has(bad)) {
|
|
269
|
+
process.stderr.write(` server rejected "${bad}" — retrying without it\n`);
|
|
270
|
+
return callModel(langCode, items, attempt, jsonMode, new Set(drop).add(bad));
|
|
271
|
+
}
|
|
272
|
+
|
|
243
273
|
const retryable = res.status === 429 || res.status >= 500;
|
|
244
274
|
if (retryable && attempt <= 5) {
|
|
245
275
|
const wait = Math.min(2 ** attempt * 1000, 30000);
|
|
246
276
|
process.stderr.write(` HTTP ${res.status}, retry ${attempt} in ${wait}ms\n`);
|
|
247
277
|
await new Promise((r) => setTimeout(r, wait));
|
|
248
|
-
return callModel(langCode, items, attempt + 1, jsonMode);
|
|
278
|
+
return callModel(langCode, items, attempt + 1, jsonMode, drop);
|
|
249
279
|
}
|
|
250
280
|
throw new Error(`${PROVIDER.label ?? PROVIDER.id} HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
251
281
|
}
|
|
@@ -297,6 +327,53 @@ async function translateLang(langCode, units) {
|
|
|
297
327
|
const tmFile = join(TM_DIR, `${langCode}${TAG}.json`);
|
|
298
328
|
const tm = existsSync(tmFile) ? JSON.parse(readFileSync(tmFile, 'utf8')) : {};
|
|
299
329
|
|
|
330
|
+
// ── Glossary invalidation ──────────────────────────────────────────────────
|
|
331
|
+
// The memory is keyed by the source hash alone, so editing a glossary target used to
|
|
332
|
+
// change nothing: every affected unit was already in the memory and got skipped, and
|
|
333
|
+
// the new terminology silently never shipped. The sidecar records the fingerprint the
|
|
334
|
+
// memory was built against; when it moves, the units containing an affected term are
|
|
335
|
+
// dropped so they re-translate. Only those — a term change must not cost a full locale.
|
|
336
|
+
//
|
|
337
|
+
// It is a SIDECAR, not a key inside the memory, because README documents
|
|
338
|
+
// i18n/tm/{lang}.json as a hand-editable hash -> string map and three other scripts
|
|
339
|
+
// iterate it. A `__meta` key would have made every coverage count off by one.
|
|
340
|
+
const metaFile = join(TM_DIR, `${langCode}${TAG}.meta.json`);
|
|
341
|
+
const meta = existsSync(metaFile) ? JSON.parse(readFileSync(metaFile, 'utf8')) : {};
|
|
342
|
+
const fingerprint = glossaryFingerprint(GLOSSARY);
|
|
343
|
+
|
|
344
|
+
if (typeof meta.glossary === 'string' && meta.glossary !== fingerprint) {
|
|
345
|
+
const before = new Set(meta.glossary.split('\n').filter(Boolean));
|
|
346
|
+
const after = new Set(fingerprint.split('\n').filter(Boolean));
|
|
347
|
+
// A term whose line is missing from either side has been added, removed or edited.
|
|
348
|
+
const changed = GLOSSARY.filter((t) => {
|
|
349
|
+
const line = glossaryFingerprint([t]);
|
|
350
|
+
return !before.has(line) || !after.has(line);
|
|
351
|
+
});
|
|
352
|
+
// Removed terms are gone from GLOSSARY, so recover them from the old fingerprint to
|
|
353
|
+
// re-translate units that were constrained by a rule the user has just deleted.
|
|
354
|
+
const removedSources = [...before]
|
|
355
|
+
.filter((line) => !after.has(line))
|
|
356
|
+
.map((line) => line.split('|')[2])
|
|
357
|
+
.filter(Boolean);
|
|
358
|
+
|
|
359
|
+
let dropped = 0;
|
|
360
|
+
for (const [hash, unit] of units) {
|
|
361
|
+
if (!(hash in tm)) continue;
|
|
362
|
+
const hit =
|
|
363
|
+
changed.some((t) => containsTerm(unit.text, t)) ||
|
|
364
|
+
removedSources.some((src) => containsTerm(unit.text, { source: src, matchCase: false }));
|
|
365
|
+
if (hit) {
|
|
366
|
+
delete tm[hash];
|
|
367
|
+
dropped++;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (dropped) {
|
|
371
|
+
process.stderr.write(
|
|
372
|
+
` glossary changed — re-translating ${dropped.toLocaleString()} affected unit(s)\n`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
300
377
|
const pending = units.filter(([hash]) => !(hash in tm));
|
|
301
378
|
|
|
302
379
|
// Re-run churn. The memory is keyed by source hash, so on an existing locale the
|
|
@@ -319,6 +396,11 @@ async function translateLang(langCode, units) {
|
|
|
319
396
|
|
|
320
397
|
if (pending.length === 0) {
|
|
321
398
|
console.log(`${langCode}: nothing to do (${Object.keys(tm).length} in memory)`);
|
|
399
|
+
// Nothing pending means nothing was dropped, so the memory on disk already matches
|
|
400
|
+
// this glossary — safe to record the fingerprint without a translation pass. The
|
|
401
|
+
// fingerprint is never written ahead of a TM flush: if a run dies mid-way, the next
|
|
402
|
+
// one must still see a stale fingerprint and re-drop the affected units.
|
|
403
|
+
writeFileSync(metaFile, JSON.stringify({ ...meta, glossary: fingerprint }, null, 2));
|
|
322
404
|
return;
|
|
323
405
|
}
|
|
324
406
|
|
|
@@ -341,6 +423,7 @@ async function translateLang(langCode, units) {
|
|
|
341
423
|
let sinceFlush = 0;
|
|
342
424
|
const flush = () => {
|
|
343
425
|
writeFileSync(tmFile, JSON.stringify(tm, null, 2));
|
|
426
|
+
writeFileSync(metaFile, JSON.stringify({ ...meta, glossary: fingerprint }, null, 2));
|
|
344
427
|
sinceFlush = 0;
|
|
345
428
|
};
|
|
346
429
|
|
|
@@ -351,7 +434,12 @@ async function translateLang(langCode, units) {
|
|
|
351
434
|
if (myIndex >= batches.length) return;
|
|
352
435
|
const batch = batches[myIndex];
|
|
353
436
|
|
|
354
|
-
|
|
437
|
+
// `el` is omitted for ordinary prose rather than sent as null: a site of nothing but
|
|
438
|
+
// paragraphs then produces a byte-identical payload to 1.x and costs not one extra token.
|
|
439
|
+
const items = batch.map(([, unit], i) => {
|
|
440
|
+
const el = roleOf(unit);
|
|
441
|
+
return el ? { id: i, text: unit.text, el } : { id: i, text: unit.text };
|
|
442
|
+
});
|
|
355
443
|
|
|
356
444
|
let rows;
|
|
357
445
|
let usage;
|
|
@@ -380,7 +468,10 @@ async function translateLang(langCode, units) {
|
|
|
380
468
|
|
|
381
469
|
for (const [hash, unit, problem] of retry) {
|
|
382
470
|
try {
|
|
383
|
-
const
|
|
471
|
+
const soloEl = roleOf(unit);
|
|
472
|
+
const solo = await callModel(langCode, [
|
|
473
|
+
soloEl ? { id: 0, text: unit.text, el: soloEl } : { id: 0, text: unit.text },
|
|
474
|
+
]);
|
|
384
475
|
const out = solo.rows.find((r) => r.id === 0)?.text;
|
|
385
476
|
inTok += solo.usage.inTok;
|
|
386
477
|
outTok += solo.usage.outTok;
|
package/scripts/verify.mjs
CHANGED
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
* inLanguage / @id / url — the three sitewide defects the
|
|
19
19
|
* 2026-08-13 audit found on the proxy-served pages.
|
|
20
20
|
* 5. Coverage share of segments still in English, per locale.
|
|
21
|
+
* 6. Never offered visible text the extractor never picked up. Coverage measures
|
|
22
|
+
* translated-of-EXTRACTED and is structurally blind to this.
|
|
23
|
+
* 7. Glossary terms that were supposed to survive, or to be rendered a
|
|
24
|
+
* particular way, and were not. REPORTS by default; --strict
|
|
25
|
+
* makes it gate, because inflection makes strictness noisy.
|
|
26
|
+
* 8. Numeric integrity numbers that changed value between source and translation.
|
|
27
|
+
* Gates by default: a silently rewritten price is a commercial
|
|
28
|
+
* problem, and no other gate can see it.
|
|
21
29
|
*/
|
|
22
30
|
|
|
23
31
|
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
@@ -27,11 +35,15 @@ import { parse } from 'parse5';
|
|
|
27
35
|
|
|
28
36
|
import {
|
|
29
37
|
BUILD_DIR as DIST, SEG_DIR, BASE_URL as BASE, LOCALES as LANG_ROWS,
|
|
30
|
-
BY_PATH, RTL, getPages, I18N_DIR, DNT,
|
|
38
|
+
BY_PATH, RTL, getPages, I18N_DIR, TM_DIR, SOURCE_FILE, ROOT_DIR, DNT, GLOSSARY,
|
|
31
39
|
} from './config.mjs';
|
|
32
40
|
import { creditBlock, markerBytes, GENERATOR_NAME, PRIOR_GENERATOR_NAMES } from './credit.mjs';
|
|
41
|
+
import { checkCompliance } from './glossary.mjs';
|
|
33
42
|
|
|
34
|
-
|
|
43
|
+
// ROOT_DIR honours $I18N_ROOT; process.cwd() did not, so a run from another
|
|
44
|
+
// directory read the wrong tree. Paths under i18n/ come from config so that
|
|
45
|
+
// `i18nDir` actually takes effect — it was imported here and then ignored.
|
|
46
|
+
const ROOT = ROOT_DIR;
|
|
35
47
|
|
|
36
48
|
/**
|
|
37
49
|
* Format, protocol and standards tokens that are correct unchanged in every language.
|
|
@@ -54,6 +66,9 @@ const args = Object.fromEntries(
|
|
|
54
66
|
.map(([k, ...v]) => [k, v.join(' ') || true])
|
|
55
67
|
);
|
|
56
68
|
|
|
69
|
+
/** Gate 7 reports by default; --strict makes a terminology violation fail the build. */
|
|
70
|
+
const STRICT = Boolean(args.strict);
|
|
71
|
+
|
|
57
72
|
|
|
58
73
|
const requested = String(args.lang ?? '').trim();
|
|
59
74
|
if (!requested) {
|
|
@@ -238,7 +253,7 @@ if (identityBad.length) fail('identity', `${identityBad.length} pages`);
|
|
|
238
253
|
console.log('\n[5] translation coverage');
|
|
239
254
|
const totalSegments = pages.reduce((n, p) => n + p.segmentCount, 0);
|
|
240
255
|
for (const lang of LANGS) {
|
|
241
|
-
const tmFile = join(
|
|
256
|
+
const tmFile = join(TM_DIR, `${lang}.json`);
|
|
242
257
|
if (!existsSync(tmFile)) {
|
|
243
258
|
console.log(` ${lang}: no memory`);
|
|
244
259
|
continue;
|
|
@@ -331,8 +346,8 @@ function extractedBlob(slug) {
|
|
|
331
346
|
return entry.segments.map((s) => (src[s.hash]?.text ?? '').replace(/<\/?\d+\/?>/g, ' ')).join(' ');
|
|
332
347
|
}
|
|
333
348
|
|
|
334
|
-
const SOURCE = existsSync(
|
|
335
|
-
? JSON.parse(readFileSync(
|
|
349
|
+
const SOURCE = existsSync(SOURCE_FILE)
|
|
350
|
+
? JSON.parse(readFileSync(SOURCE_FILE, 'utf8'))
|
|
336
351
|
: null;
|
|
337
352
|
|
|
338
353
|
console.log('\n[6] text never offered for translation');
|
|
@@ -365,6 +380,99 @@ for (const lang of LANGS) {
|
|
|
365
380
|
if (holes > 0) fail('extraction-hole', `${lang}: ${holes} strings never offered for translation`);
|
|
366
381
|
}
|
|
367
382
|
|
|
383
|
+
// ── Gate 7: glossary compliance ──────────────────────────────────────────────
|
|
384
|
+
// Terminology was previously unverifiable. A brand could be translated away and every
|
|
385
|
+
// gate still passed: gate 2 compares markup, gate 5 counts coverage, and gate 6 uses the
|
|
386
|
+
// brand list only to SUPPRESS false alarms. Nothing asserted a term survived.
|
|
387
|
+
//
|
|
388
|
+
// Reports by default and gates only under --strict, on purpose. Target languages inflect
|
|
389
|
+
// ("Panel de control" -> "del Panel de control"), compound ("Dashboard-Ansicht") and
|
|
390
|
+
// decline, so a strict test flags correct work. references/quality-review.md is explicit
|
|
391
|
+
// that a heuristic which over-flags is worse than none, and the remedy here —
|
|
392
|
+
// purge-and-retranslate — costs real money.
|
|
393
|
+
|
|
394
|
+
console.log('\n[7] glossary compliance');
|
|
395
|
+
if (GLOSSARY.length === 0) {
|
|
396
|
+
console.log(' no glossary configured \u2014 skipped');
|
|
397
|
+
} else {
|
|
398
|
+
for (const lang of LANGS) {
|
|
399
|
+
const tmFile = join(TM_DIR, `${lang}.json`);
|
|
400
|
+
if (!existsSync(tmFile)) {
|
|
401
|
+
console.log(` ${lang}: no memory`);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const tm = JSON.parse(readFileSync(tmFile, 'utf8'));
|
|
405
|
+
const violations = [];
|
|
406
|
+
for (const [hash, unit] of Object.entries(SOURCE)) {
|
|
407
|
+
const translated = tm[hash];
|
|
408
|
+
if (typeof translated !== 'string') continue;
|
|
409
|
+
for (const v of checkCompliance(unit.text, translated, GLOSSARY, lang)) {
|
|
410
|
+
violations.push({ hash, ...v, source: unit.text.slice(0, 60) , term: v.source });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const checked = Object.keys(tm).length;
|
|
414
|
+
const pct = checked ? (violations.length * 100) / checked : 0;
|
|
415
|
+
console.log(
|
|
416
|
+
` ${lang}: ${violations.length} violation(s) in ${checked.toLocaleString()} units (${pct.toFixed(2)}%)`
|
|
417
|
+
);
|
|
418
|
+
for (const v of violations.slice(0, 5)) {
|
|
419
|
+
console.log(` "${v.term}" \u2192 expected "${v.expected}" in: ${v.source}\u2026`);
|
|
420
|
+
}
|
|
421
|
+
if (violations.length > 5) console.log(` \u2026 and ${violations.length - 5} more`);
|
|
422
|
+
if (STRICT && violations.length) fail('glossary', `${lang}: ${violations.length} violation(s)`);
|
|
423
|
+
}
|
|
424
|
+
if (!STRICT) console.log(' (reporting only \u2014 pass --strict to gate on this)');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Gate 8: numeric integrity ────────────────────────────────────────────────
|
|
428
|
+
// A model that silently rewrites "$49/month" as "$39/month" passes every other gate:
|
|
429
|
+
// the markup is identical, the placeholder count matches, the length is plausible and
|
|
430
|
+
// the text is fluent target-language. Prices are commercial commitments, so this one
|
|
431
|
+
// DOES gate by default.
|
|
432
|
+
//
|
|
433
|
+
// Compares multisets, not sequences: languages legitimately reorder ("2 of 3" ->
|
|
434
|
+
// "3 dintre 2" never happens, but date and measurement order does move). Digit-group
|
|
435
|
+
// separators are stripped first, because locale formatting is applied at build time and
|
|
436
|
+
// is a correct difference, not a defect.
|
|
437
|
+
|
|
438
|
+
const NUM_RE = /\d[\d.,\u00a0\u202f ]*\d|\d/g;
|
|
439
|
+
|
|
440
|
+
/** Numbers reduced to a comparable form: separators stripped, trailing zeros normalised. */
|
|
441
|
+
function numeralMultiset(text) {
|
|
442
|
+
const found = String(text).match(NUM_RE) ?? [];
|
|
443
|
+
return found
|
|
444
|
+
.map((n) => n.replace(/[.,\u00a0\u202f ]/g, ''))
|
|
445
|
+
.filter((n) => n.length > 0)
|
|
446
|
+
.map((n) => n.replace(/^0+(?=\d)/, ''))
|
|
447
|
+
.sort();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
console.log('\n[8] numeric integrity');
|
|
451
|
+
for (const lang of LANGS) {
|
|
452
|
+
const tmFile = join(TM_DIR, `${lang}.json`);
|
|
453
|
+
if (!existsSync(tmFile)) {
|
|
454
|
+
console.log(` ${lang}: no memory`);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const tm = JSON.parse(readFileSync(tmFile, 'utf8'));
|
|
458
|
+
const drifted = [];
|
|
459
|
+
for (const [hash, unit] of Object.entries(SOURCE)) {
|
|
460
|
+
const translated = tm[hash];
|
|
461
|
+
if (typeof translated !== 'string') continue;
|
|
462
|
+
const a = numeralMultiset(unit.text);
|
|
463
|
+
const b = numeralMultiset(translated);
|
|
464
|
+
if (a.join('|') !== b.join('|')) {
|
|
465
|
+
drifted.push({ src: unit.text.slice(0, 70), out: translated.slice(0, 70), a, b });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
console.log(` ${lang}: ${drifted.length} unit(s) whose numbers changed`);
|
|
469
|
+
for (const d of drifted.slice(0, 5)) {
|
|
470
|
+
console.log(` [${d.a.join(', ')}] \u2192 [${d.b.join(', ')}] ${d.src}\u2026`);
|
|
471
|
+
}
|
|
472
|
+
if (drifted.length > 5) console.log(` \u2026 and ${drifted.length - 5} more`);
|
|
473
|
+
if (drifted.length) fail('numeric-drift', `${lang}: ${drifted.length} unit(s)`);
|
|
474
|
+
}
|
|
475
|
+
|
|
368
476
|
// ── Result ───────────────────────────────────────────────────────────────────
|
|
369
477
|
|
|
370
478
|
console.log('');
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
2
|
+
name: translate-site
|
|
3
3
|
description: >
|
|
4
4
|
Localize a static website into many languages by substituting translations into
|
|
5
|
-
already-built HTML, without re-rendering. Ships
|
|
6
|
-
translate, review, build, verify, SEO audit) plus the failure modes that cost real
|
|
5
|
+
already-built HTML, without re-rendering. Ships proven scripts (extract,
|
|
6
|
+
translate, review, build, verify, SEO audit, glossary, locale formatting, MQM quality scoring) plus the failure modes that cost real
|
|
7
7
|
money to discover. Use when the user says "translate the site", "localize",
|
|
8
8
|
"multi-language site", "i18n", "add languages", "translate all pages", or is
|
|
9
9
|
replacing a translation proxy (Weglot, Bablic, Localize, TranslatePress) with self-hosted pages.
|
|
@@ -14,7 +14,7 @@ argument-hint: "[project-dir]"
|
|
|
14
14
|
license: AGPL-3.0
|
|
15
15
|
metadata:
|
|
16
16
|
author: ConveyThis
|
|
17
|
-
version: "
|
|
17
|
+
version: "2.0.0"
|
|
18
18
|
category: i18n
|
|
19
19
|
---
|
|
20
20
|
|
|
@@ -82,7 +82,7 @@ write; where that is missing, say so rather than spending the user's time and AP
|
|
|
82
82
|
- **The user wants to edit translations in a UI, or needs human review** — there is neither
|
|
83
83
|
here. Editing means hand-editing a hash in `i18n/tm/{lang}.json`.
|
|
84
84
|
- **The user is wrapping a modified copy in a hosted service** — AGPL-3.0 §13 obliges them to
|
|
85
|
-
publish their modifications. See
|
|
85
|
+
publish their modifications. See `${CLAUDE_PLUGIN_ROOT}/LICENSING.md`; a commercial licence exists.
|
|
86
86
|
|
|
87
87
|
Running it unmodified, on their own sites, and shipping the output is unrestricted. Do not
|
|
88
88
|
warn them about the licence in that case — it does not apply.
|
|
@@ -114,16 +114,22 @@ do not take the same parameters and guessing costs money.
|
|
|
114
114
|
|
|
115
115
|
```bash
|
|
116
116
|
cd <project>
|
|
117
|
-
|
|
117
|
+
npx claude-translator init # works anywhere
|
|
118
118
|
npm install # parse5, the only dependency
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
+
Offline, or when the plugin is already installed, run the bundled copy instead of `npx`:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
node "${CLAUDE_PLUGIN_ROOT}"/bin/claude-translator.mjs init
|
|
125
|
+
```
|
|
126
|
+
|
|
121
127
|
That copies the pipeline into `scripts/i18n/`, writes `i18n.config.json`, declares
|
|
122
128
|
`parse5` and adds the derived paths to `.gitignore`. It never overwrites without
|
|
123
129
|
`--force`, so it is safe to re-run; `--dir <path>` puts the scripts elsewhere.
|
|
124
130
|
|
|
125
131
|
Scripts run **from inside the project** so `parse5` and relative paths resolve. Edit
|
|
126
|
-
`i18n.config.json` — five keys cover everything; see
|
|
132
|
+
`i18n.config.json` — five keys cover everything; see `${CLAUDE_PLUGIN_ROOT}/i18n.config.example.json`.
|
|
127
133
|
|
|
128
134
|
**Commit `i18n/tm/{lang}.json`.** The scaffolder deliberately does not ignore it: the
|
|
129
135
|
memory is the asset, and losing it means paying for a full re-translation. Everything
|
|
@@ -133,12 +139,13 @@ else under `i18n/` is derived and is ignored for you. Add `i18n` to `.prettierig
|
|
|
133
139
|
|
|
134
140
|
```bash
|
|
135
141
|
npm run build # source language only
|
|
136
|
-
node scripts/extract.mjs # → i18n/source.json + segments/
|
|
137
|
-
node scripts/translate.mjs --lang es,fr # → i18n/tm/{lang}.json (needs a provider key)
|
|
138
|
-
node scripts/review.mjs --lang es # quality flags
|
|
139
|
-
node scripts/build-locales.mjs --lang all # → dist/{lang}/…
|
|
140
|
-
node scripts/verify.mjs --lang all # six gates
|
|
141
|
-
node scripts/audit-seo.mjs # canonical/hreflang/JSON-LD/sitemaps
|
|
142
|
+
node scripts/i18n/extract.mjs # → i18n/source.json + segments/
|
|
143
|
+
node scripts/i18n/translate.mjs --lang es,fr # → i18n/tm/{lang}.json (needs a provider key)
|
|
144
|
+
node scripts/i18n/review.mjs --lang es # quality flags
|
|
145
|
+
node scripts/i18n/build-locales.mjs --lang all # → dist/{lang}/…
|
|
146
|
+
node scripts/i18n/verify.mjs --lang all # six gates
|
|
147
|
+
node scripts/i18n/audit-seo.mjs # canonical/hreflang/JSON-LD/sitemaps
|
|
148
|
+
node scripts/i18n/tqa.mjs --lang es # MQM quality score (optional, costs money)
|
|
142
149
|
```
|
|
143
150
|
|
|
144
151
|
`finalize.sh <locales…>` collapses the per-locale cycle (gap-fill → review → purge →
|
|
@@ -163,6 +170,31 @@ matches and warn on zero. Attribute order is not guaranteed — `<link href="…
|
|
|
163
170
|
rel="canonical">` is as valid as `rel` first — and an order-dependent regex silently
|
|
164
171
|
matches nothing while reporting success.
|
|
165
172
|
|
|
173
|
+
**Element context is a hint, never a constraint.** Units carry an `el` label (button,
|
|
174
|
+
heading, form label, meta description...) so the model can pick the right register. There is
|
|
175
|
+
deliberately no length enforcement: a unit that fails validation ships in the SOURCE
|
|
176
|
+
language, so gating on length would replace a slightly-long German button with an English
|
|
177
|
+
one. If a user asks for a character budget, explain that tradeoff first.
|
|
178
|
+
|
|
179
|
+
**Never convert a currency, and never offer to.** `localeFormat` reformats amounts and
|
|
180
|
+
the pipeline reports every one it saw to `i18n/locale-format.json`. Converting a price at
|
|
181
|
+
a build-time rate is how a translation tool starts publishing wrong offers, and there is
|
|
182
|
+
no config option for it. If the user asks for conversion, explain the report instead.
|
|
183
|
+
|
|
184
|
+
**Gate 8 gates; gate 7 reports.** A changed numeric value fails the build, because a
|
|
185
|
+
silently rewritten price passes every other check. Terminology only warns unless
|
|
186
|
+
`--strict` is passed, because target languages inflect pinned terms and a strict check
|
|
187
|
+
flags correct work — see `references/quality-review.md` on why over-flagging is worse
|
|
188
|
+
than nothing.
|
|
189
|
+
|
|
190
|
+
**This tool rewrites tags; it does not create them.** Every locale-identity rule replaces
|
|
191
|
+
an attribute value on a tag the template already emits — the only thing ever inserted is the
|
|
192
|
+
attribution marker. So the **full `hreflang` mesh and `sitemap.xml` must come from the user's
|
|
193
|
+
own build**: `build-locales.mjs` rewrites only the `x-default` and source-language hrefs, and
|
|
194
|
+
writes no sitemap at all. `audit-seo.mjs` checks both exhaustively, which is how a missing
|
|
195
|
+
mesh surfaces. If the user's template lacks the alternates, say so plainly and point at
|
|
196
|
+
`references/adapting-generators.md` — do not imply the pipeline will emit them.
|
|
197
|
+
|
|
166
198
|
**Verify server-side state, not exit codes.** Especially with rsync on macOS
|
|
167
199
|
(`openrsync` prints usage and exits **0** on an unsupported flag).
|
|
168
200
|
|
|
@@ -18,7 +18,7 @@ talks through a small adapter. Three ship with the project; anything else is one
|
|
|
18
18
|
| --- | --- | --- | --- |
|
|
19
19
|
| `anthropic` | `claude-haiku-4-5` | `ANTHROPIC_API_KEY` | The default. |
|
|
20
20
|
| `gemini` | `gemini-2.5-flash-lite` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | Roughly a tenth the cost — see [throughput-and-cost.md](throughput-and-cost.md). |
|
|
21
|
-
| `openai` | `gpt-
|
|
21
|
+
| `openai` | `gpt-5.6-luna` | `OPENAI_API_KEY` | Any OpenAI-compatible endpoint, including local ones. |
|
|
22
22
|
| `./my-provider.mjs` | — | yours | A path is imported directly. See below. |
|
|
23
23
|
|
|
24
24
|
`--provider` and `--model` on the command line override the config for one run.
|
|
@@ -98,7 +98,7 @@ export const envKeys = ['MY_PROVIDER_KEY']; // checked in order, env then .env
|
|
|
98
98
|
export const keyOptional = false; // true for local servers
|
|
99
99
|
|
|
100
100
|
/** Build one HTTP request. */
|
|
101
|
-
export function request({ model, system, items, temperature, key, baseUrl, jsonMode }) {
|
|
101
|
+
export function request({ model, system, items, temperature, key, baseUrl, jsonMode, drop }) {
|
|
102
102
|
return {
|
|
103
103
|
url: `${baseUrl ?? 'https://api.example.com/v1'}/translate`,
|
|
104
104
|
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
@@ -121,6 +121,20 @@ export const unwrap = (parsed) => parsed.translations ?? parsed;
|
|
|
121
121
|
|
|
122
122
|
/** Optional: USD per million tokens, [in, out]. Omit if unknown. */
|
|
123
123
|
export function pricing(model) { return [0.5, 1.5]; }
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Optional: name ONE request parameter the server rejected, so translate.mjs can retry
|
|
127
|
+
* without it. Return null for anything else. The parameter is added to a `drop` set
|
|
128
|
+
* that is passed back into request(), and each one is dropped at most once.
|
|
129
|
+
*
|
|
130
|
+
* Set the bar high. The error must read as a capability complaint AND name a parameter
|
|
131
|
+
* you actually send; otherwise a genuine 400 gets mistaken for a recoverable one and the
|
|
132
|
+
* run quietly strips its own request instead of telling the user what is wrong.
|
|
133
|
+
*/
|
|
134
|
+
export function unsupportedParam(status, errText) {
|
|
135
|
+
if (status !== 400) return null;
|
|
136
|
+
return /unsupported.*\btemperature\b/i.test(errText) ? 'temperature' : null;
|
|
137
|
+
}
|
|
124
138
|
```
|
|
125
139
|
|
|
126
140
|
Then:
|
|
@@ -71,6 +71,34 @@ are legitimate — postal addresses, image filenames used as alt text, proper no
|
|
|
71
71
|
|
|
72
72
|
---
|
|
73
73
|
|
|
74
|
+
## Where TQA fits
|
|
75
|
+
|
|
76
|
+
`review.mjs` finds *defects by shape* — a dropped placeholder, a wholesale source-language
|
|
77
|
+
return, a truncated string. It is cheap, offline, and blind to whether the text is any good.
|
|
78
|
+
|
|
79
|
+
`tqa.mjs` answers the other question, and costs money. It scores a seeded,
|
|
80
|
+
frequency-stratified sample against the MQM typology using a second model as judge.
|
|
81
|
+
|
|
82
|
+
| | `review.mjs` | `tqa.mjs` |
|
|
83
|
+
| --- | --- | --- |
|
|
84
|
+
| Cost | free | one API call per ~10 units |
|
|
85
|
+
| Finds | mechanical defects | mistranslation, register, terminology, awkwardness |
|
|
86
|
+
| Output | `{lang}.review.json` | `i18n/tqa/{lang}.json` + `scorecard.md` |
|
|
87
|
+
| Gates a build | no | no |
|
|
88
|
+
|
|
89
|
+
Run `review.mjs` on every locale, every time. Run `tqa.mjs` when you need a number to
|
|
90
|
+
compare — between locales, between models, or before and after a prompt change.
|
|
91
|
+
|
|
92
|
+
**The same over-flagging discipline applies to the score itself.** Three guards exist
|
|
93
|
+
because each of them failed once during development:
|
|
94
|
+
|
|
95
|
+
- the judge defaults to a *different provider* than the translator, since a model scores
|
|
96
|
+
its own work generously
|
|
97
|
+
- `--repeat` reports the gap between two runs on the same sample, so nobody reads a
|
|
98
|
+
decimal place that is really noise
|
|
99
|
+
- a unit the judge could not assess is **excluded**, never counted as clean. An early
|
|
100
|
+
version reported `100.00 / 100` from a sample where every unit had failed to parse
|
|
101
|
+
|
|
74
102
|
## Workflow
|
|
75
103
|
|
|
76
104
|
```bash
|
|
File without changes
|
|
File without changes
|
|
File without changes
|