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.
Files changed (33) hide show
  1. package/.claude-plugin/plugin.json +14 -0
  2. package/CHANGELOG.md +249 -0
  3. package/PRIVACY.md +71 -0
  4. package/README.md +279 -44
  5. package/bin/claude-translator.mjs +11 -2
  6. package/bin/cli.test.mjs +20 -1
  7. package/glossary.example.json +23 -0
  8. package/i18n.config.example.json +7 -0
  9. package/package.json +9 -5
  10. package/scripts/audit-seo.mjs +6 -3
  11. package/scripts/build-locales.mjs +55 -6
  12. package/scripts/config.mjs +66 -0
  13. package/scripts/credit.mjs +12 -5
  14. package/scripts/extract.mjs +21 -6
  15. package/scripts/format-locale.mjs +290 -0
  16. package/scripts/format-locale.test.mjs +171 -0
  17. package/scripts/glossary.mjs +229 -0
  18. package/scripts/glossary.test.mjs +188 -0
  19. package/scripts/providers/openai.mjs +63 -3
  20. package/scripts/providers/providers.test.mjs +80 -0
  21. package/scripts/roles.mjs +142 -0
  22. package/scripts/roles.test.mjs +140 -0
  23. package/scripts/tqa-score.mjs +127 -0
  24. package/scripts/tqa-score.test.mjs +144 -0
  25. package/scripts/tqa.mjs +449 -0
  26. package/scripts/translate.mjs +104 -13
  27. package/scripts/verify.mjs +113 -5
  28. package/{SKILL.md → skills/translate-site/SKILL.md} +45 -13
  29. package/{references → skills/translate-site/references}/providers.md +16 -2
  30. package/{references → skills/translate-site/references}/quality-review.md +28 -0
  31. /package/{references → skills/translate-site/references}/adapting-generators.md +0 -0
  32. /package/{references → skills/translate-site/references}/failure-modes.md +0 -0
  33. /package/{references → skills/translate-site/references}/throughput-and-cost.md +0 -0
@@ -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
- const GLOSSARY = [...new Set([...DNT.brands, ...DNT.formats, ...TECH_TOKENS])];
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: ${GLOSSARY.join(', ')}.`,
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
- const items = batch.map(([, unit], i) => ({ id: i, text: unit.text }));
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 solo = await callModel(langCode, [{ id: 0, text: unit.text }]);
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;
@@ -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
- const ROOT = process.cwd();
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(ROOT, 'i18n/tm', `${lang}.json`);
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(join(ROOT, 'i18n/source.json'))
335
- ? JSON.parse(readFileSync(join(ROOT, 'i18n/source.json'), 'utf8'))
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: claude-translator
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 six proven scripts (extract,
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: "1.3.0"
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 `LICENSING.md`; a commercial licence exists.
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
- node ~/.claude/skills/claude-translator/bin/claude-translator.mjs init
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 `i18n.config.example.json`.
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-4o-mini` | `OPENAI_API_KEY` | Any OpenAI-compatible endpoint, including local ones. |
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