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,384 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * i18n step 4 — gates. Exits non-zero on any failure so it can guard a deploy.
4
+ *
5
+ * node scripts/i18n/verify.mjs --lang es,ru,pt-br
6
+ * node scripts/i18n/verify.mjs --lang all
7
+ *
8
+ * Gates, in order of how badly a failure would hurt:
9
+ *
10
+ * 1. URL parity every English slug exists in every locale — a missing file is
11
+ * a dead URL, and these pages carry ~65% of organic clicks.
12
+ * 2. Structure the locale page's tag sequence is identical to the English
13
+ * page's. This is the real proof that substitution did not
14
+ * damage markup, and by extension that the critical CSS, the
15
+ * LCP element and the width/height attributes still apply.
16
+ * 3. Placeholder leak no <0> / </1> survived into shipped HTML.
17
+ * 4. Locale identity lang, canonical, hreflang en + x-default, JSON-LD
18
+ * inLanguage / @id / url — the three sitewide defects the
19
+ * 2026-08-13 audit found on the proxy-served pages.
20
+ * 5. Coverage share of segments still in English, per locale.
21
+ */
22
+
23
+ import { readFileSync, readdirSync, existsSync } from 'fs';
24
+ import { join } from 'path';
25
+ import { fileURLToPath } from 'url';
26
+ import { parse } from 'parse5';
27
+
28
+ import {
29
+ BUILD_DIR as DIST, SEG_DIR, BASE_URL as BASE, LOCALES as LANG_ROWS,
30
+ BY_PATH, RTL, getPages, I18N_DIR, DNT,
31
+ } from './config.mjs';
32
+ import { creditBlock, markerBytes, GENERATOR_NAME, PRIOR_GENERATOR_NAMES } from './credit.mjs';
33
+
34
+ const ROOT = process.cwd();
35
+
36
+ /**
37
+ * Format, protocol and standards tokens that are correct unchanged in every language.
38
+ * Site-specific brand names come from config (doNotTranslate.brands) and are merged in.
39
+ */
40
+ const TECH_TOKENS = [
41
+ 'PDF', 'DOCX', 'DOC', 'XLSX', 'XLS', 'PPTX', 'PPT', 'EPUB', 'CSV', 'TXT', 'JSON',
42
+ 'HTML', 'XML', 'IDML', 'INDD', 'PNG', 'JPG', 'JPEG', 'SVG', 'WEBP', 'MP4', 'ZIP',
43
+ 'OCR', 'GDPR', 'SSL', 'TLS', 'API', 'SDK', 'URL', 'HTTP', 'HTTPS', 'SEO', 'CSS', 'RSS',
44
+ ];
45
+
46
+
47
+ const args = Object.fromEntries(
48
+ process.argv
49
+ .slice(2)
50
+ .join(' ')
51
+ .split('--')
52
+ .filter(Boolean)
53
+ .map((s) => s.trim().split(/\s+/))
54
+ .map(([k, ...v]) => [k, v.join(' ') || true])
55
+ );
56
+
57
+
58
+ const requested = String(args.lang ?? '').trim();
59
+ if (!requested) {
60
+ console.error('Usage: node scripts/i18n/verify.mjs --lang es[,ru,...] | --lang all');
61
+ process.exit(1);
62
+ }
63
+ const LANGS = requested === 'all' ? LANG_ROWS.map((r) => r.pathCode) : requested.split(',').map((s) => s.trim());
64
+ const SAMPLE = args.sample ? Number(args.sample) : 25;
65
+
66
+ // ── Helpers ──────────────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Reads an attribute off the first tag satisfying `test`, without assuming attribute
70
+ * order — the build emits `<link href="…" rel="canonical">`, href first.
71
+ */
72
+ function attrOf(html, tagRe, test, attrName) {
73
+ for (const m of html.matchAll(tagRe)) {
74
+ const attrs = Object.fromEntries([...m[0].matchAll(/([\w:-]+)="([^"]*)"/g)].map((x) => [x[1], x[2]]));
75
+ if (test(attrs)) return attrs[attrName];
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ /**
81
+ * Flat sequence of element tag names, depth-first — the page's structural fingerprint.
82
+ *
83
+ * One node is excluded: the attribution <meta name="generator"> that build-locales.mjs
84
+ * inserts. It exists only on locale pages, so counting it would fail every page on this
85
+ * gate for a reason that has nothing to do with substitution — and this gate is the proof
86
+ * that substitution left the markup intact, so it must not be diluted by our own tag.
87
+ *
88
+ * The exclusion is narrow on purpose: it matches only a generator tag whose content we
89
+ * wrote. The site's own generator tag (Astro, Hugo, Jekyll) is present on both sides and
90
+ * is still compared, a second copy of ours would still fail, and every other difference
91
+ * fails exactly as before.
92
+ */
93
+ function tagSequence(html) {
94
+ const seq = [];
95
+ const attr = (n, name) => (n.attrs ?? []).find((a) => a.name === name)?.value;
96
+ // Both the current product name and the ones earlier releases wrote: upgrading must
97
+ // not invalidate a locale directory that is still on disk from a previous build.
98
+ const OURS = [GENERATOR_NAME, ...PRIOR_GENERATOR_NAMES];
99
+ const isOurCreditMeta = (n) =>
100
+ n.tagName === 'meta' &&
101
+ attr(n, 'name') === 'generator' &&
102
+ OURS.some((name) => (attr(n, 'content') ?? '').startsWith(name));
103
+ // Exactly one is skipped. build-locales.mjs inserts exactly one, so a second copy means
104
+ // something ran twice over its own output — which is a real defect and must still fail.
105
+ let skipped = 0;
106
+ const walk = (n) => {
107
+ if (n.tagName) {
108
+ if (isOurCreditMeta(n) && skipped === 0) {
109
+ skipped++;
110
+ return; // no children on a void element
111
+ }
112
+ seq.push(n.tagName);
113
+ }
114
+ for (const c of n.childNodes ?? []) walk(c);
115
+ };
116
+ walk(parse(html));
117
+ return seq;
118
+ }
119
+
120
+ const failures = [];
121
+ const fail = (gate, detail) => failures.push({ gate, detail });
122
+
123
+ // ── Inventory ────────────────────────────────────────────────────────────────
124
+
125
+ // Authority for "which URLs must exist" is SITEMAP_SLUGS — the same list build-locales
126
+ // builds from. dist/ additionally holds 404.html and the Decap CMS admin shell, which
127
+ // are deliberately not localised and must not count as missing.
128
+ const SITEMAP_SLUGS = getPages();
129
+
130
+ const segByPage = new Map();
131
+ for (const f of readdirSync(SEG_DIR).filter((x) => x.endsWith('.json'))) {
132
+ const parsed = JSON.parse(readFileSync(join(SEG_DIR, f), 'utf8'));
133
+ segByPage.set(parsed.page, parsed);
134
+ }
135
+
136
+ const pages = SITEMAP_SLUGS.map((slug) => {
137
+ const entry = segByPage.get(slug === '' ? 'index' : slug);
138
+ return entry && { slug, file: join(ROOT, entry.file), segmentCount: entry.segments.length };
139
+ })
140
+ .filter(Boolean)
141
+ .filter((p) => existsSync(p.file));
142
+
143
+ console.log(`English pages: ${pages.length}`);
144
+ console.log(`locales: ${LANGS.length}`);
145
+ console.log(`expected localised pages: ${(pages.length * LANGS.length).toLocaleString()}\n`);
146
+
147
+ // ── Gate 1: URL parity ───────────────────────────────────────────────────────
148
+
149
+ const missing = [];
150
+ for (const lang of LANGS) {
151
+ for (const p of pages) {
152
+ const out = p.slug ? join(DIST, lang, p.slug, 'index.html') : join(DIST, lang, 'index.html');
153
+ if (!existsSync(out)) missing.push(`${lang}/${p.slug || '(home)'}`);
154
+ }
155
+ }
156
+ if (missing.length) fail('url-parity', `${missing.length} missing: ${missing.slice(0, 8).join(', ')}`);
157
+ console.log(`[1] URL parity ${missing.length === 0 ? 'OK' : `FAIL — ${missing.length} missing`}`);
158
+
159
+ // ── Gates 2–4: per-page checks on a sample ───────────────────────────────────
160
+
161
+ let structChecked = 0;
162
+ let structBad = 0;
163
+ let leaks = 0;
164
+ const identityBad = [];
165
+
166
+ const PLACEHOLDER_RE = /<\/?\d+\/?>/;
167
+
168
+ for (const lang of LANGS) {
169
+ const row = BY_PATH[lang];
170
+ const sample = pages.slice(0, SAMPLE);
171
+
172
+ for (const p of sample) {
173
+ const out = p.slug ? join(DIST, lang, p.slug, 'index.html') : join(DIST, lang, 'index.html');
174
+ if (!existsSync(out)) continue;
175
+
176
+ const en = readFileSync(p.file, 'utf8');
177
+ const loc = readFileSync(out, 'utf8');
178
+
179
+ // 2 — structure
180
+ const a = tagSequence(en);
181
+ const b = tagSequence(loc);
182
+ structChecked++;
183
+ if (a.length !== b.length || a.some((t, i) => t !== b[i])) {
184
+ structBad++;
185
+ const at = a.findIndex((t, i) => t !== b[i]);
186
+ fail('structure', `${lang}/${p.slug} tags ${a.length} vs ${b.length}, first diff at ${at}: ${a[at]} vs ${b[at]}`);
187
+ }
188
+
189
+ // 3 — placeholder leak (strip scripts first: inline JS legitimately contains "<0")
190
+ const visible = loc.replace(/<script[\s\S]*?<\/script>/g, '');
191
+ if (PLACEHOLDER_RE.test(visible)) {
192
+ leaks++;
193
+ fail('placeholder-leak', `${lang}/${p.slug}`);
194
+ }
195
+
196
+ // 4 — locale identity
197
+ const url = p.slug ? `${BASE}/${lang}/${p.slug}` : `${BASE}/${lang}`;
198
+ const enUrl = p.slug ? `${BASE}/${p.slug}` : `${BASE}/`;
199
+ const problems = [];
200
+
201
+ if (!new RegExp(`<html[^>]*\\slang="${row.hreflang}"`).test(loc)) problems.push('html-lang');
202
+ if (RTL.has(lang) && !/<html[^>]*\sdir="rtl"/.test(loc)) problems.push('dir-rtl');
203
+
204
+ const LINK = /<link[^>]*>/g;
205
+ const canonical = attrOf(loc, LINK, (a) => a.rel === 'canonical', 'href');
206
+ if (canonical !== url) problems.push(`canonical=${canonical}`);
207
+ if (canonical?.endsWith('/') && canonical !== `${BASE}/`) problems.push('canonical-trailing-slash');
208
+
209
+ const xdef = attrOf(loc, LINK, (a) => a.hreflang === 'x-default', 'href');
210
+ if (xdef !== enUrl) problems.push(`x-default=${xdef}`);
211
+
212
+ const enHref = attrOf(loc, LINK, (a) => a.hreflang === 'en', 'href');
213
+ if (enHref !== enUrl) problems.push(`hreflang-en=${enHref}`);
214
+
215
+ const selfHref = attrOf(loc, LINK, (a) => a.hreflang === row.hreflang, 'href');
216
+ if (selfHref !== url) problems.push(`hreflang-self=${selfHref}`);
217
+
218
+ for (const m of loc.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)) {
219
+ const body = m[1];
220
+ if (/"inLanguage"\s*:\s*"en"/.test(body)) problems.push('jsonld-inLanguage-en');
221
+ if (new RegExp(`"@id"\\s*:\\s*"${BASE}/#webpage"`).test(body)) problems.push('jsonld-id-collision');
222
+ }
223
+
224
+ if (problems.length) identityBad.push(`${lang}/${p.slug || '(home)'}: ${[...new Set(problems)].join(', ')}`);
225
+ }
226
+ }
227
+
228
+ console.log(
229
+ `[2] structure ${structBad === 0 ? `OK (${structChecked} pages)` : `FAIL — ${structBad}/${structChecked}`}`
230
+ );
231
+ console.log(`[3] placeholder leak ${leaks === 0 ? 'OK' : `FAIL — ${leaks} pages`}`);
232
+ console.log(`[4] locale identity ${identityBad.length === 0 ? 'OK' : `FAIL — ${identityBad.length} pages`}`);
233
+ for (const line of identityBad.slice(0, 10)) console.log(` ${line}`);
234
+ if (identityBad.length) fail('identity', `${identityBad.length} pages`);
235
+
236
+ // ── Gate 5: coverage ─────────────────────────────────────────────────────────
237
+
238
+ console.log('\n[5] translation coverage');
239
+ const totalSegments = pages.reduce((n, p) => n + p.segmentCount, 0);
240
+ for (const lang of LANGS) {
241
+ const tmFile = join(ROOT, 'i18n/tm', `${lang}.json`);
242
+ if (!existsSync(tmFile)) {
243
+ console.log(` ${lang}: no memory`);
244
+ continue;
245
+ }
246
+ const tm = JSON.parse(readFileSync(tmFile, 'utf8'));
247
+ let covered = 0;
248
+ // Count over the SAME page set as totalSegments — the 238 sitemap pages. Counting
249
+ // every file in segments/ (which also holds 404 and the CMS shell) against a
250
+ // sitemap-only denominator reported 100.21%, and a coverage figure above 100% is
251
+ // a broken measurement, not a good result.
252
+ for (const p of pages) {
253
+ const entry = segByPage.get(p.slug === '' ? 'index' : p.slug);
254
+ if (!entry) continue;
255
+ for (const s of entry.segments) if (tm[s.hash] !== undefined) covered++;
256
+ }
257
+ const pct = (covered * 100) / totalSegments;
258
+ console.log(` ${lang}: ${pct.toFixed(2)}% of ${totalSegments.toLocaleString()} segments`);
259
+ if (pct < 99) fail('coverage', `${lang} at ${pct.toFixed(2)}%`);
260
+ }
261
+
262
+ // ── Gate 6: residual English in the OUTPUT ───────────────────────────────────
263
+ // Coverage (gate 5) measures translated-of-EXTRACTED and therefore cannot see text the
264
+ // extractor never picked up. It read 100% while "Translate a Document", "Sign Up Free"
265
+ // and every icon+label pair on the site were still English, because a bug abandoned any
266
+ // block containing an <svg>. This gate compares the rendered text of each locale page
267
+ // against its English original instead, so a hole in extraction shows up as text that
268
+ // simply never changed.
269
+
270
+ const DNT_OUTPUT = new Set([...DNT_LIKE()]);
271
+ function DNT_LIKE() {
272
+ const brands = [...DNT.brands, ...DNT.formats, ...TECH_TOKENS];
273
+ const natives = LANG_ROWS.map((l) => l.nativeLabel).filter(Boolean);
274
+ return [...brands, ...natives];
275
+ }
276
+
277
+ const TEXT_SKIP = new Set(['script', 'style', 'noscript', 'code', 'pre', 'template', 'svg']);
278
+
279
+ function visibleText(html) {
280
+ const out = [];
281
+ const walk = (n, skip) => {
282
+ if (n.nodeName === '#text' && !skip) {
283
+ const t = n.value.replace(/\s+/g, ' ').trim();
284
+ if (t) out.push(t);
285
+ }
286
+ const next = skip || TEXT_SKIP.has(n.tagName);
287
+ for (const c of n.childNodes ?? []) walk(c, next);
288
+ };
289
+ walk(parse(html), false);
290
+ return out;
291
+ }
292
+
293
+ // Mirrors extract.mjs: 'English' has no CONVEY_LANGS row (it is the source language) but
294
+ // LanguagePicker lists it, so "English (English)" is a switcher label like any other.
295
+ const NATIVE_SET = new Set([
296
+ 'English',
297
+ ...LANG_ROWS.map((l) => l.nativeLabel).filter(Boolean),
298
+ ]);
299
+
300
+ /**
301
+ * Worth translating: has letters, is not a bare number/acronym, is not a known name, and
302
+ * is not a language-switcher label. extract.mjs deliberately skips "Español (Spanish)"
303
+ * so the picker reads the same in all 55 locales; without the same rule here, those 1,621
304
+ * labels are reported as extraction holes.
305
+ */
306
+ function translatable(s) {
307
+ if (s.length < 3) return false;
308
+ if (!/\p{L}/u.test(s)) return false;
309
+ if (/^[\d\s.,:%+\-–—/()]+$/.test(s)) return false;
310
+ if (DNT_OUTPUT.has(s)) return false;
311
+ if (/^[A-Z0-9.+-]{2,8}$/.test(s)) return false;
312
+ if (/^\S+@\S+\.\S+$/.test(s)) return false; // e-mail — extract.mjs skips these too
313
+ if (/^(https?:\/\/|\/\/|mailto:|tel:|#|\/)\S*$/i.test(s)) return false;
314
+ const m = /^(.+?) \(.+\)$/.exec(s);
315
+ if (m && NATIVE_SET.has(m[1])) return false;
316
+ return true;
317
+ }
318
+
319
+ // A string that is identical in both languages is NOT automatically a defect: the model
320
+ // legitimately leaves "e-Learning", "PowerPoint (.PPT)" and "Google Translate PDF" alone,
321
+ // and flagging those produced a useless 22% "failure". What IS a defect is text the
322
+ // extractor never offered for translation at all. So the gate asks: does this unchanged
323
+ // string appear anywhere in the units extracted from that page? If not, it is a hole in
324
+ // extraction — exactly the <svg> bug — and the build must fail.
325
+
326
+ /** All text this page offered for translation, placeholders stripped, as one blob. */
327
+ function extractedBlob(slug) {
328
+ const entry = segByPage.get(slug === '' ? 'index' : slug);
329
+ if (!entry) return '';
330
+ const src = SOURCE ?? {};
331
+ return entry.segments.map((s) => (src[s.hash]?.text ?? '').replace(/<\/?\d+\/?>/g, ' ')).join('  ');
332
+ }
333
+
334
+ const SOURCE = existsSync(join(ROOT, 'i18n/source.json'))
335
+ ? JSON.parse(readFileSync(join(ROOT, 'i18n/source.json'), 'utf8'))
336
+ : null;
337
+
338
+ console.log('\n[6] text never offered for translation');
339
+ for (const lang of LANGS) {
340
+ let holes = 0;
341
+ let unchanged = 0;
342
+ let total = 0;
343
+ const examples = [];
344
+ for (const p of pages.slice(0, SAMPLE)) {
345
+ const out = p.slug ? join(DIST, lang, p.slug, 'index.html') : join(DIST, lang, 'index.html');
346
+ if (!existsSync(out)) continue;
347
+ const en = new Set(visibleText(readFileSync(p.file, 'utf8')).filter(translatable));
348
+ const blob = extractedBlob(p.slug);
349
+ for (const t of visibleText(readFileSync(out, 'utf8')).filter(translatable)) {
350
+ total++;
351
+ if (!en.has(t)) continue;
352
+ unchanged++;
353
+ if (!blob.includes(t)) {
354
+ holes++;
355
+ if (examples.length < 8 && !examples.includes(t)) examples.push(t);
356
+ }
357
+ }
358
+ }
359
+ const pct = total ? (holes * 100) / total : 0;
360
+ console.log(
361
+ ` ${lang}: ${holes} never extracted (${pct.toFixed(2)}%) · ` +
362
+ `${unchanged} identical but offered (model kept them) · ${total} strings`
363
+ );
364
+ for (const e of examples) console.log(` NOT EXTRACTED: ${JSON.stringify(e.slice(0, 70))}`);
365
+ if (holes > 0) fail('extraction-hole', `${lang}: ${holes} strings never offered for translation`);
366
+ }
367
+
368
+ // ── Result ───────────────────────────────────────────────────────────────────
369
+
370
+ console.log('');
371
+ if (failures.length === 0) {
372
+ console.log('ALL GATES PASSED');
373
+ creditBlock([
374
+ `${pages.length.toLocaleString()} slugs \u00d7 ${LANGS.length} locale(s) = ` +
375
+ `${(pages.length * LANGS.length).toLocaleString()} localized pages \u00b7 all gates passed`,
376
+ `attribution costs ${markerBytes(BY_PATH[LANGS[0]])} bytes per page and 0 extra requests \u2014 ` +
377
+ `gate 2 compares tag sequences with our generator tag excluded, so it still proves ` +
378
+ `nothing else in the markup moved`,
379
+ ]);
380
+ process.exit(0);
381
+ }
382
+ console.log(`FAILED: ${failures.length} problem(s)`);
383
+ for (const f of failures.slice(0, 20)) console.log(` [${f.gate}] ${f.detail}`);
384
+ process.exit(1);