claude-translator 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,564 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * i18n step 1 — extract translatable units from the built English site.
4
+ *
5
+ * Reads dist/ (English build) and produces:
6
+ *
7
+ * i18n/source.json unique units to translate: hash → { text, kind, count, sample }
8
+ * i18n/segments/{key}.json per-page replacement map: [{ start, end, hash, kind, tags }]
9
+ * i18n/manifest.json page inventory + counters
10
+ *
11
+ * ── Why block-level units, not text nodes ────────────────────────────────────
12
+ * 21.7% of this site's text nodes are split by inline markup:
13
+ *
14
+ * <p>With <strong>Acme</strong>, you'll get a streamlined flow.</p>
15
+ *
16
+ * Translating "With" and ", you'll get a streamlined flow." as separate strings
17
+ * produces broken grammar in any language that reorders or inflects — which is most
18
+ * of our 55. So the unit of translation is the BLOCK, with inline tags replaced by
19
+ * numbered placeholders the model must carry through:
20
+ *
21
+ * With <0>Acme</0>, you'll get a streamlined flow.
22
+ *
23
+ * build-locales.mjs restores the original tags by index.
24
+ *
25
+ * ── Why byte offsets ─────────────────────────────────────────────────────────
26
+ * Each segment records the byte range of the element's innerHTML in the ORIGINAL
27
+ * file. Substitution is a right-to-left splice on the raw string — nothing is ever
28
+ * re-serialised from a DOM, so the output stays byte-identical to the English build
29
+ * apart from the replaced ranges. That is what preserves the inlined critical CSS,
30
+ * the LCP element and the width/height attributes that keep CLS at 0.01.
31
+ *
32
+ * Run AFTER `npm run build`:
33
+ * node scripts/i18n/extract.mjs
34
+ */
35
+
36
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'fs';
37
+ import { createHash } from 'crypto';
38
+ import { join, relative } from 'path';
39
+ import { fileURLToPath } from 'url';
40
+ import { parse } from 'parse5';
41
+ import { BUILD_DIR as DIST, I18N_DIR as OUT_DIR, SEG_DIR, LOCALES, LOCALE_DIRS, DNT, ROOT_DIR as ROOT } from './config.mjs';
42
+ import { hint, link, docsLink } from './credit.mjs';
43
+
44
+ // ── Element classification ───────────────────────────────────────────────────
45
+
46
+ /** Inline elements become numbered placeholders inside a unit. */
47
+ const INLINE = new Set([
48
+ 'a',
49
+ 'abbr',
50
+ 'b',
51
+ 'bdi',
52
+ 'bdo',
53
+ 'br',
54
+ 'cite',
55
+ 'code',
56
+ 'data',
57
+ 'dfn',
58
+ 'em',
59
+ 'i',
60
+ 'kbd',
61
+ 'mark',
62
+ 'q',
63
+ 'rp',
64
+ 'rt',
65
+ 'ruby',
66
+ 's',
67
+ 'samp',
68
+ 'small',
69
+ 'span',
70
+ 'strong',
71
+ 'sub',
72
+ 'sup',
73
+ 'time',
74
+ 'u',
75
+ 'var',
76
+ 'wbr',
77
+ 'img',
78
+ 'picture',
79
+ 'source',
80
+ ]);
81
+
82
+ /** Never descended into — no user-visible prose inside. */
83
+ const SKIP_ELEMENTS = new Set(['script', 'style', 'noscript', 'code', 'pre', 'template', 'svg']);
84
+
85
+ /** Void elements have no closing tag. */
86
+ const VOID = new Set(['br', 'img', 'wbr', 'source', 'input', 'hr', 'meta', 'link']);
87
+
88
+ /** Attributes carrying user-visible text. */
89
+ const TEXT_ATTRS = new Set(['alt', 'title', 'placeholder', 'aria-label', 'aria-description']);
90
+
91
+ /** <meta> name/property values whose `content` is user-visible. */
92
+ const META_KEYS = new Set([
93
+ 'description',
94
+ 'og:title',
95
+ 'og:description',
96
+ 'og:site_name',
97
+ 'og:image:alt',
98
+ 'twitter:title',
99
+ 'twitter:description',
100
+ 'twitter:image:alt',
101
+ 'apple-mobile-web-app-title',
102
+ ]);
103
+
104
+ /** JSON-LD keys holding prose. Excludes @id / url / inLanguage / @type by design. */
105
+ const JSONLD_TEXT_KEYS = new Set(['name', 'description', 'headline', 'text', 'alternateName', 'caption', 'slogan']);
106
+
107
+ // ── Do-not-translate ─────────────────────────────────────────────────────────
108
+ // Matched against the WHOLE trimmed unit. A brand inside a sentence is the
109
+ // translator glossary's job, not this list's.
110
+
111
+ const NATIVE_LABELS = new Set([
112
+ // CONVEY_LANGS covers the 55 translated locales; English is the source language and so
113
+ // has no row there, but LanguagePicker lists it as an option like any other. Every
114
+ // standalone "English" in the build (956 of them) belongs to the picker — either an
115
+ // option label, which must stay in its own language, or the trigger label, which
116
+ // build-locales.mjs overwrites with the current locale's native name.
117
+ 'English',
118
+ ...LOCALES.map((l) => l.nativeLabel).filter(Boolean),
119
+ ]);
120
+
121
+ /**
122
+ * Format, protocol and standards tokens that are correct unchanged in every language.
123
+ * Site-specific brand names come from config (doNotTranslate.brands) and are merged in.
124
+ */
125
+ const TECH_TOKENS = [
126
+ 'PDF', 'DOCX', 'DOC', 'XLSX', 'XLS', 'PPTX', 'PPT', 'EPUB', 'CSV', 'TXT', 'JSON',
127
+ 'HTML', 'XML', 'IDML', 'INDD', 'PNG', 'JPG', 'JPEG', 'SVG', 'WEBP', 'MP4', 'ZIP',
128
+ 'OCR', 'GDPR', 'SSL', 'TLS', 'API', 'SDK', 'URL', 'HTTP', 'HTTPS', 'SEO', 'CSS', 'RSS',
129
+ ];
130
+
131
+ const DNT_EXACT = new Set([...TECH_TOKENS, ...DNT.brands, ...DNT.formats]);
132
+
133
+ const RE_URL = /^(https?:\/\/|\/\/|mailto:|tel:|#|\/)\S*$/i;
134
+ const RE_EMAIL = /^\S+@\S+\.\S+$/;
135
+ const RE_HAS_LETTER = /\p{L}/u;
136
+ const RE_ONLY_NUM = /^[\d\s.,:%+\-–—/()]+$/;
137
+ /** A unit that is nothing but placeholders, e.g. "<0></0>". */
138
+ const RE_ONLY_TAGS = /^(\s|<\/?\d+\/?>)*$/;
139
+
140
+ /**
141
+ * Language-switcher labels read "Native (EnglishName)" — e.g. "Español (Spanish)".
142
+ * A language picker shows every language in its OWN language, so these must stay
143
+ * identical in all 55 locales. Left to the model they drift per locale
144
+ * ("English (English)" → "Inglés (English)" while "Español (Spanish)" is kept),
145
+ * which would render the switcher differently on every page. 43 units, 20,010
146
+ * occurrences sitewide.
147
+ */
148
+ function isSwitcherLabel(s) {
149
+ const m = /^(.+?) \(.+\)$/.exec(s);
150
+ return Boolean(m && NATIVE_LABELS.has(m[1]));
151
+ }
152
+
153
+ function isTranslatable(s) {
154
+ if (!s || s.length < 2) return false;
155
+ if (!RE_HAS_LETTER.test(s)) return false;
156
+ if (RE_ONLY_NUM.test(s)) return false;
157
+ if (RE_ONLY_TAGS.test(s)) return false;
158
+ if (RE_URL.test(s)) return false;
159
+ if (RE_EMAIL.test(s)) return false;
160
+ if (DNT_EXACT.has(s)) return false;
161
+ if (NATIVE_LABELS.has(s)) return false;
162
+ if (isSwitcherLabel(s)) return false;
163
+ if (/^[A-Z0-9.+-]{2,8}$/.test(s)) return false;
164
+ return true;
165
+ }
166
+
167
+ const hashOf = (s) => createHash('sha1').update(s).digest('hex').slice(0, 16);
168
+
169
+ // ── Collection state ─────────────────────────────────────────────────────────
170
+
171
+ const sources = new Map();
172
+ let totalSegments = 0;
173
+
174
+ function record(text, kind, sample) {
175
+ const h = hashOf(text);
176
+ const hit = sources.get(h);
177
+ if (hit) hit.count++;
178
+ else sources.set(h, { text, kind, count: 1, sample });
179
+ return h;
180
+ }
181
+
182
+ const isElement = (n) => Boolean(n.tagName);
183
+ const hasText = (n) => n.nodeName === '#text' && n.value.trim().length > 0;
184
+
185
+ /**
186
+ * Builds the placeholder form of an element's children.
187
+ * Returns { text, tags } or null when the subtree cannot be tokenised safely.
188
+ * `tags` is an ordered list of the raw tag strings each placeholder index stands for.
189
+ */
190
+ function tokenize(node, html) {
191
+ const tags = [];
192
+ let text = '';
193
+ let failed = false;
194
+
195
+ const walk = (n) => {
196
+ for (const child of n.childNodes ?? []) {
197
+ if (failed) return;
198
+
199
+ if (child.nodeName === '#text') {
200
+ text += child.value; // parse5 has already decoded entities
201
+ continue;
202
+ }
203
+ if (child.nodeName === '#comment') continue;
204
+ if (!isElement(child)) continue;
205
+
206
+ const tag = child.tagName;
207
+ const loc = child.sourceCodeLocation;
208
+
209
+ // Opaque elements (icons above all) become ONE void placeholder carrying the whole
210
+ // element verbatim. Abandoning the block instead — the original behaviour — silently
211
+ // dropped every icon+label pair on the site from translation: "✓ Max. file size 1 GB",
212
+ // the "Translate a Document" button, and so on stayed English while coverage still
213
+ // reported 100%, because coverage measures translated-of-extracted, not
214
+ // extracted-of-translatable.
215
+ if (SKIP_ELEMENTS.has(tag)) {
216
+ if (!loc) {
217
+ failed = true;
218
+ return;
219
+ }
220
+ tags.push({ open: html.slice(loc.startOffset, loc.endOffset), close: null });
221
+ text += `<${tags.length - 1}/>`;
222
+ continue;
223
+ }
224
+
225
+ if (!loc?.startTag) {
226
+ failed = true;
227
+ return;
228
+ }
229
+
230
+ const idx = tags.length;
231
+ const openTag = html.slice(loc.startTag.startOffset, loc.startTag.endOffset);
232
+
233
+ if (VOID.has(tag) || !loc.endTag) {
234
+ tags.push({ open: openTag, close: null });
235
+ text += `<${idx}/>`;
236
+ continue;
237
+ }
238
+
239
+ tags.push({ open: openTag, close: html.slice(loc.endTag.startOffset, loc.endTag.endOffset) });
240
+ text += `<${idx}>`;
241
+ walk(child);
242
+ text += `</${idx}>`;
243
+ }
244
+ };
245
+
246
+ walk(node);
247
+ if (failed) return null;
248
+ return { text, tags };
249
+ }
250
+
251
+ /** parse5 decodes text nodes for us; loose raw slices need the common entities undone. */
252
+ function decodeEntities(s) {
253
+ return s
254
+ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
255
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
256
+ .replace(/&quot;/g, '"')
257
+ .replace(/&apos;/g, "'")
258
+ .replace(/&nbsp;/g, ' ')
259
+ .replace(/&lt;/g, '<')
260
+ .replace(/&gt;/g, '>')
261
+ .replace(/&amp;/g, '&');
262
+ }
263
+
264
+ function collectAttrs(node, html, segments, pageKey, deep = false) {
265
+ if (node.attrs?.length && node.sourceCodeLocation?.attrs) {
266
+ const attrLocs = node.sourceCodeLocation.attrs;
267
+ const attrMap = Object.fromEntries(node.attrs.map((a) => [a.name, a.value]));
268
+
269
+ for (const attr of node.attrs) {
270
+ let wanted = TEXT_ATTRS.has(attr.name);
271
+ if (!wanted && node.tagName === 'meta' && attr.name === 'content') {
272
+ wanted = META_KEYS.has(attrMap.name || attrMap.property);
273
+ }
274
+ if (!wanted) continue;
275
+
276
+ const value = attr.value.trim();
277
+ if (!isTranslatable(value)) continue;
278
+
279
+ const aLoc = attrLocs[attr.name];
280
+ if (!aLoc) continue;
281
+ const rawAttr = html.slice(aLoc.startOffset, aLoc.endOffset);
282
+ const idx = rawAttr.indexOf(attr.value);
283
+ if (idx === -1) continue;
284
+
285
+ segments.push({
286
+ start: aLoc.startOffset + idx,
287
+ end: aLoc.startOffset + idx + attr.value.length,
288
+ hash: record(value, `attr:${attr.name}`, pageKey),
289
+ kind: `attr:${attr.name}`,
290
+ tags: [],
291
+ });
292
+ }
293
+ }
294
+
295
+ if (deep) {
296
+ for (const child of node.childNodes ?? []) {
297
+ if (isElement(child) && !SKIP_ELEMENTS.has(child.tagName)) {
298
+ collectAttrs(child, html, segments, pageKey, true);
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ /**
305
+ * JSON-LD: match the full `"key": "value"` pair, never the bare value. A bare-value
306
+ * search matches substrings of longer siblings — `"name":"PDF Translator"` sits inside
307
+ * `"name":"AI PDF Translator"` — which produced overlapping segments and corrupt output.
308
+ */
309
+ function collectJsonLd(textNode, html, segments, pageKey) {
310
+ const loc = textNode.sourceCodeLocation;
311
+ const body = html.slice(loc.startOffset, loc.endOffset);
312
+
313
+ let data;
314
+ try {
315
+ data = JSON.parse(body.trim());
316
+ } catch {
317
+ return;
318
+ }
319
+
320
+ const pairs = [];
321
+ const seen = new Set();
322
+ const walk = (obj) => {
323
+ if (Array.isArray(obj)) return obj.forEach(walk);
324
+ if (!obj || typeof obj !== 'object') return;
325
+ for (const [k, v] of Object.entries(obj)) {
326
+ if (typeof v === 'string' && JSONLD_TEXT_KEYS.has(k) && isTranslatable(v.trim())) {
327
+ const id = k + '' + v;
328
+ if (!seen.has(id)) {
329
+ seen.add(id);
330
+ pairs.push({ key: k, value: v });
331
+ }
332
+ } else if (typeof v === 'object') walk(v);
333
+ }
334
+ };
335
+ walk(data);
336
+
337
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
338
+
339
+ for (const { key, value } of pairs) {
340
+ const escaped = JSON.stringify(value).slice(1, -1);
341
+ const re = new RegExp('("' + escapeRe(key) + '"\\s*:\\s*")' + escapeRe(escaped) + '(")', 'g');
342
+ for (const m of body.matchAll(re)) {
343
+ const valueStart = m.index + m[1].length;
344
+ segments.push({
345
+ start: loc.startOffset + valueStart,
346
+ end: loc.startOffset + valueStart + escaped.length,
347
+ hash: record(value.trim(), 'jsonld', pageKey),
348
+ kind: 'jsonld',
349
+ tags: [],
350
+ escaped: true,
351
+ });
352
+ }
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Walks the document emitting one segment per translatable block.
358
+ *
359
+ * A node is a translation unit when it holds text directly and every element child
360
+ * is inline. Mixed nodes (text alongside block children) recurse, and their loose
361
+ * text nodes are emitted individually — rare, but copy must never be silently dropped.
362
+ */
363
+ function collect(node, html, segments, pageKey) {
364
+ const tag = node.tagName;
365
+
366
+ if (tag === 'script') {
367
+ const type = node.attrs?.find((a) => a.name === 'type')?.value;
368
+ if (type === 'application/ld+json' && node.childNodes?.[0]?.sourceCodeLocation) {
369
+ collectJsonLd(node.childNodes[0], html, segments, pageKey);
370
+ }
371
+ return;
372
+ }
373
+ if (tag && SKIP_ELEMENTS.has(tag)) return;
374
+
375
+ const kids = node.childNodes ?? [];
376
+ const directText = kids.some(hasText);
377
+ const elementKids = kids.filter(isElement).filter((c) => !SKIP_ELEMENTS.has(c.tagName));
378
+ const allInline = elementKids.every((c) => INLINE.has(c.tagName));
379
+
380
+ const loc = node.sourceCodeLocation;
381
+ const canRangeInner = Boolean(tag && loc?.startTag && loc?.endTag);
382
+
383
+ if (directText && allInline && canRangeInner) {
384
+ const unit = tokenize(node, html);
385
+ if (unit) {
386
+ const trimmed = unit.text.trim();
387
+ if (isTranslatable(trimmed)) {
388
+ const innerStart = loc.startTag.endOffset;
389
+ const innerEnd = loc.endTag.startOffset;
390
+ const raw = html.slice(innerStart, innerEnd);
391
+ const lead = raw.length - raw.trimStart().length;
392
+ const trail = raw.length - raw.trimEnd().length;
393
+
394
+ segments.push({
395
+ start: innerStart + lead,
396
+ end: innerEnd - trail,
397
+ hash: record(trimmed, 'block', pageKey),
398
+ kind: 'block',
399
+ tags: unit.tags.map((t) => [t.open, t.close]),
400
+ });
401
+ }
402
+ // The block is one unit — do not descend, but its own and its inline
403
+ // children's attributes still need collecting.
404
+ collectAttrs(node, html, segments, pageKey, true);
405
+ return;
406
+ }
407
+ }
408
+
409
+ collectAttrs(node, html, segments, pageKey);
410
+
411
+ if (directText && !allInline) {
412
+ for (const child of kids) {
413
+ if (!hasText(child)) continue;
414
+ const cLoc = child.sourceCodeLocation;
415
+ if (!cLoc) continue;
416
+ const raw = html.slice(cLoc.startOffset, cLoc.endOffset);
417
+ const trimmed = raw.trim();
418
+ if (!isTranslatable(decodeEntities(trimmed))) continue;
419
+ const lead = raw.length - raw.trimStart().length;
420
+ segments.push({
421
+ start: cLoc.startOffset + lead,
422
+ end: cLoc.startOffset + lead + trimmed.length,
423
+ hash: record(decodeEntities(trimmed), 'text', pageKey),
424
+ kind: 'text',
425
+ tags: [],
426
+ });
427
+ }
428
+ }
429
+
430
+ for (const child of kids) collect(child, html, segments, pageKey);
431
+ }
432
+
433
+ // ── Main ─────────────────────────────────────────────────────────────────────
434
+
435
+ const files = readdirSync(DIST, { recursive: true })
436
+ .filter((p) => typeof p === 'string' && p.endsWith('.html'))
437
+ .filter((p) => !LOCALE_DIRS.has(p.split('/')[0]))
438
+ .map((p) => join(DIST, p))
439
+ .sort();
440
+
441
+ if (files.length === 0) {
442
+ console.error('No HTML found in dist/. Run `npm run build` first.');
443
+ process.exit(1);
444
+ }
445
+
446
+ rmSync(SEG_DIR, { recursive: true, force: true });
447
+ mkdirSync(SEG_DIR, { recursive: true });
448
+
449
+ const manifest = [];
450
+
451
+ /**
452
+ * Two things worth knowing about a site before you translate it, both cheap to count
453
+ * while the files are already open:
454
+ *
455
+ * hydration islands and framework payloads re-render on the client, over the top of
456
+ * whatever was substituted into the HTML. Static substitution cannot reach
457
+ * them — see references/adapting-generators.md.
458
+ * documents linked PDFs, DOCX and the like are not HTML and are never touched here,
459
+ * so they stay in the source language on an otherwise localized site.
460
+ */
461
+ const HYDRATION_MARKERS = [
462
+ ['astro-island', 'Astro island'],
463
+ ['__NEXT_DATA__', 'Next.js hydration payload'],
464
+ ['__NUXT__', 'Nuxt hydration payload'],
465
+ ['data-reactroot', 'React root'],
466
+ ['wp-json', 'WordPress REST payload'],
467
+ ];
468
+ const hydrationPages = new Set();
469
+ const hydrationKinds = new Set();
470
+ const linkedDocs = new Set();
471
+
472
+ for (const file of files) {
473
+ const html = readFileSync(file, 'utf8');
474
+
475
+ for (const [marker, label] of HYDRATION_MARKERS) {
476
+ if (html.includes(marker)) {
477
+ hydrationPages.add(file);
478
+ hydrationKinds.add(label);
479
+ }
480
+ }
481
+ for (const m of html.matchAll(/\shref="([^"]+\.(?:pdf|docx?|xlsx?|pptx?|epub))(?:[?#][^"]*)?"/gi)) {
482
+ linkedDocs.add(m[1]);
483
+ }
484
+
485
+ const pageKey =
486
+ relative(DIST, file)
487
+ .replace(/\/index\.html$/, '')
488
+ .replace(/\.html$/, '') || 'index';
489
+
490
+ const doc = parse(html, { sourceCodeLocationInfo: true });
491
+ const segments = [];
492
+ collect(doc, html, segments, pageKey);
493
+
494
+ segments.sort((a, b) => b.start - a.start);
495
+
496
+ for (let i = 1; i < segments.length; i++) {
497
+ if (segments[i].end > segments[i - 1].start) {
498
+ console.error(`Overlapping segments in ${pageKey} at offset ${segments[i].start}. Aborting.`);
499
+ console.error(' A:', JSON.stringify(html.slice(segments[i].start, segments[i].end).slice(0, 80)));
500
+ console.error(' B:', JSON.stringify(html.slice(segments[i - 1].start, segments[i - 1].end).slice(0, 80)));
501
+ process.exit(1);
502
+ }
503
+ }
504
+
505
+ writeFileSync(
506
+ join(SEG_DIR, `${pageKey.replace(/\//g, '__')}.json`),
507
+ JSON.stringify({ page: pageKey, file: relative(ROOT, file), segments })
508
+ );
509
+
510
+ manifest.push({ page: pageKey, file: relative(ROOT, file), segments: segments.length });
511
+ totalSegments += segments.length;
512
+ }
513
+
514
+ const sorted = [...sources.entries()].sort((a, b) => b[1].count - a[1].count);
515
+ mkdirSync(OUT_DIR, { recursive: true });
516
+ writeFileSync(join(OUT_DIR, 'source.json'), JSON.stringify(Object.fromEntries(sorted), null, 2));
517
+ writeFileSync(
518
+ join(OUT_DIR, 'manifest.json'),
519
+ JSON.stringify({ pages: manifest.length, totalSegments, pages_: manifest }, null, 2)
520
+ );
521
+
522
+ const words = [...sources.values()].reduce((n, v) => n + v.text.split(/\s+/).length, 0);
523
+ const byKind = {};
524
+ for (const v of sources.values()) byKind[v.kind] = (byKind[v.kind] ?? 0) + 1;
525
+
526
+ console.log(`pages scanned: ${files.length}`);
527
+ console.log(`segments: ${totalSegments.toLocaleString()}`);
528
+ console.log(`unique units: ${sources.size.toLocaleString()} (${words.toLocaleString()} words)`);
529
+ console.log(`by kind: ${JSON.stringify(byKind)}`);
530
+ console.log(`\nwrote i18n/source.json + i18n/segments/ (${manifest.length} files)`);
531
+
532
+ // ── What this pipeline cannot reach ──────────────────────────────────────────
533
+ // Each of these fires only on a signal actually found above. Silence them all with
534
+ // "credit": { "upsellHints": false } in i18n.config.json.
535
+
536
+ if (hydrationPages.size) {
537
+ hint('hydration', [
538
+ `\u26a0 ${hydrationPages.size} page(s) carry a client-side hydration payload ` +
539
+ `(${[...hydrationKinds].join(', ')}).`,
540
+ ' Those regions re-render in the browser and will revert to the source language no',
541
+ ' matter what is substituted into the HTML. Fix the component, or serve those pages',
542
+ ` through a runtime layer: ${link('hint-hydration')}`,
543
+ ]);
544
+ }
545
+
546
+ if (linkedDocs.size) {
547
+ hint('documents', [
548
+ `\u2139 ${linkedDocs.size} linked document(s) (PDF/DOCX/XLSX) stay in the source language \u2014`,
549
+ ' this pipeline only ever touches HTML.',
550
+ ` Translate the files themselves: ${docsLink('hint-documents')}`,
551
+ ]);
552
+ }
553
+
554
+ const localeCount = LOCALES.length;
555
+ if (localeCount > 0) {
556
+ const totalWords = words * localeCount;
557
+ hint('volume', [
558
+ `\u2139 ${words.toLocaleString()} source words \u00d7 ${localeCount} locale(s) = ` +
559
+ `${totalWords.toLocaleString()} words to translate.`,
560
+ ' You pay your own model provider for these, at whatever their rate is, and you own',
561
+ ' the result. For comparison, a managed plan covering this volume with a visual editor,',
562
+ ` human review and no build step: ${link('hint-volume')}`,
563
+ ]);
564
+ }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ # Finalise one or more locales whose translation memory is complete.
3
+ #
4
+ # ./scripts/finalize.sh fr id it # fill gaps, review, build
5
+ # ./scripts/finalize.sh --purge fr id it # ALSO purge + re-translate flagged units
6
+ #
7
+ # Per locale, in order:
8
+ # 1. fill any gaps left by failed batches (incremental — only missing hashes)
9
+ # 2. review and report the flag rate
10
+ # 3. [--purge only] purge flagged units and re-translate them at a smaller batch size
11
+ # 4. build the locale's pages
12
+ #
13
+ # ── Why --purge is opt-in ────────────────────────────────────────────────────
14
+ # Purging is destructive: it deletes translations and pays to regenerate them, and the
15
+ # replacement can be WORSE. Observed in practice — a handful of postal-address blocks were
16
+ # flagged as "untranslated / high-overlap" (false positives, since addresses are mostly
17
+ # proper nouns). Auto-purging them replaced correct localised text with the source
18
+ # language: "Correo electrónico" came back as "Email:". The review flags were right that
19
+ # the strings looked odd, and wrong that they needed fixing.
20
+ #
21
+ # So: READ the review output, confirm the flags are real, and only then pass --purge.
22
+ # See references/quality-review.md.
23
+ #
24
+ # Verification is deliberately NOT run here — verify.mjs is cheaper once over all
25
+ # locales at the end than once per locale.
26
+ set -uo pipefail
27
+
28
+ PURGE=0
29
+ if [ "${1:-}" = "--purge" ]; then
30
+ PURGE=1
31
+ shift
32
+ fi
33
+
34
+ if [ "$#" -eq 0 ]; then
35
+ echo "usage: finalize.sh [--purge] <locale>..." >&2
36
+ exit 1
37
+ fi
38
+
39
+ # Resolve sibling scripts regardless of where this lives in the project,
40
+ # and run from the project root so config + node_modules resolve.
41
+ DIR="$(cd "$(dirname "$0")" && pwd)"
42
+ cd "${I18N_ROOT:-$(cd "$DIR/../.." && pwd)}"
43
+
44
+ for lang in "$@"; do
45
+ echo "──────── $lang ────────"
46
+
47
+ node "$DIR"/translate.mjs --lang "$lang" --concurrency 4 2>&1 | tail -2
48
+
49
+ node "$DIR"/review.mjs --lang "$lang" 2>&1 | head -2 | tail -1
50
+
51
+ if [ "$PURGE" -eq 1 ]; then
52
+ node "$DIR"/review.mjs --lang "$lang" --purge 2>&1 | tail -1
53
+ node "$DIR"/translate.mjs --lang "$lang" --concurrency 3 --batch 6 2>&1 | tail -1
54
+ node "$DIR"/review.mjs --lang "$lang" 2>&1 | head -2 | tail -1
55
+ fi
56
+
57
+ node "$DIR"/build-locales.mjs --lang "$lang" 2>&1 | tail -1
58
+ done