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,124 @@
1
+ # Throughput and cost
2
+
3
+ How to budget a run, and how to make it finish in hours rather than days.
4
+
5
+ ---
6
+
7
+ ## Where the savings come from
8
+
9
+ Both levers below apply whichever provider you use, and they matter more than the choice of
10
+ provider: deduplication routinely cuts the bill by an order of magnitude, which is a bigger
11
+ factor than the 10x between Gemini and Claude.
12
+
13
+ **Deduplication is the largest lever, by far.** Header, footer, navigation and cross-link
14
+ blocks repeat on every page — a single string can occur hundreds of times across a site.
15
+ Translate each unique string once and reuse it everywhere.
16
+
17
+ On a content site the collapse from *segment occurrences* to *unique units* is typically an
18
+ order of magnitude. `extract.mjs` prints both numbers; the ratio between them is the factor
19
+ by which your API bill shrinks.
20
+
21
+ **Incrementality is the second.** The memory is keyed by SHA-1 of the source unit, so
22
+ editing one page changes only the hashes it touched. A re-run after a content edit
23
+ translates those and reuses everything else — usually cents. A full re-translation happens
24
+ only if the memory is deleted, which is why `i18n/tm/` belongs in version control.
25
+
26
+ ## Budgeting
27
+
28
+ Estimate from **tokens**, not pages:
29
+
30
+ ```
31
+ source tokens ≈ unique-unit words × 1.3
32
+ output tokens ≈ source tokens × script factor
33
+ ```
34
+
35
+ Script factor, roughly:
36
+
37
+ | Target script | Output tokens vs source |
38
+ | --- | --- |
39
+ | Latin (es, fr, de, pt…) | 1.2 – 1.5× |
40
+ | Cyrillic, Greek, Arabic, Hebrew | 1.8 – 2.2× |
41
+ | Devanagari, Thai, Bengali, Tamil | 3 – 4× |
42
+ | CJK | 1.0 – 1.5× |
43
+
44
+ Then apply your provider's per-million rate. The default is Claude, which is **not** the
45
+ cheapest option — it is the one the project is named for, and the tradeoff is worth stating
46
+ plainly rather than burying.
47
+
48
+ Rates checked 2026-08-25, USD per million tokens:
49
+
50
+ | Provider / model | In | Out | A mid-sized site, 20 locales |
51
+ | --- | --- | --- | --- |
52
+ | `gemini-2.5-flash-lite` | 0.10 | 0.40 | **~$2.40** |
53
+ | `claude-haiku-4-5` *(default)* | 1 | 5 | **~$30** |
54
+ | `claude-sonnet-5` | 3 | 15 | ~$90 |
55
+ | `claude-opus-5` | 5 | 25 | ~$150 |
56
+ | a local model via Ollama | — | — | **$0** |
57
+
58
+ Those totals assume ~150k unique source words and Latin-script targets; scale by the table
59
+ above for other scripts. Treat them as order-of-magnitude, not a quote.
60
+
61
+ **So: money is a constraint here in a way it was not before 1.2.** If cost matters more
62
+ than the last few percent of idiom, one line moves you:
63
+
64
+ ```json
65
+ { "provider": "gemini", "model": "gemini-2.5-flash-lite" }
66
+ ```
67
+
68
+ and one line moves you to free, at the price of running the model yourself:
69
+
70
+ ```json
71
+ { "provider": "openai", "apiBaseUrl": "http://localhost:11434/v1", "model": "qwen2.5:14b" }
72
+ ```
73
+
74
+ Whatever you pick, **review time is still the real constraint** — the pilot in the
75
+ sequence below exists because layout problems and script-specific defects cost more to
76
+ find late than any of these numbers.
77
+
78
+ The Batch API halves cost on providers that offer one and runs asynchronously. Worth it
79
+ for a full rollout; not worth the added latency for a 3-locale pilot.
80
+
81
+ ## Batching
82
+
83
+ - Default 40 units per request.
84
+ - Split **only** on failure — truncated JSON or a safety block. Pre-emptively shrinking
85
+ batches just multiplies request count for no benefit.
86
+ - Use `--batch 6` when re-translating units that already failed once: those are the long,
87
+ awkward ones that hit output limits.
88
+ - Smaller and local models want smaller batches. 40 units is tuned for hosted frontier
89
+ models; a 7B running on a laptop is more reliable at 8–10.
90
+
91
+ ## Parallelism
92
+
93
+ Running one locale at a time is the slowest possible arrangement. Split the locale list
94
+ across several concurrent streams:
95
+
96
+ ```bash
97
+ node scripts/translate.mjs --lang es,it,pl,nl --concurrency 8 &
98
+ node scripts/translate.mjs --lang fr,de,tr,sv --concurrency 8 &
99
+ node scripts/translate.mjs --lang ja,ko,zh,th --concurrency 8 &
100
+ ```
101
+
102
+ Wall-clock drops close to linearly with stream count. Around 32 concurrent requests has run
103
+ cleanly without rate-limit errors on a paid tier; back off if you see 429s — the retry logic
104
+ handles them, but they waste time. A local model is the exception: concurrency past what the
105
+ GPU can hold makes it slower, not faster, so start at 2 and measure.
106
+
107
+ Two rules for splitting:
108
+
109
+ - **Round-robin, don't chunk.** Each stream should mix high- and low-value locales so a
110
+ partial run still covers what matters.
111
+ - **Order by traffic, not alphabetically.** If the run is interrupted at 60%, you want the
112
+ 60% that earns.
113
+
114
+ ## Suggested sequence
115
+
116
+ 1. **Pilot 3–4 locales** — one LTR, one RTL, one CJK, and one long-word language
117
+ (German, Finnish). This surfaces script-specific layout and heuristic problems while
118
+ they are still cheap to fix.
119
+ 2. Review, fix, get sign-off.
120
+ 3. **Bulk run** the remainder in parallel streams, traffic-ordered.
121
+ 4. `verify` and `audit-seo` **once** across everything at the end.
122
+
123
+ The pilot is not ceremony. RTL panel clipping, CJK length-ratio false positives and
124
+ font-fallback layout shift all appear only when those scripts are first built.
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Exhaustive SEO audit of the built site — hreflang, canonicals, schema, sitemaps.
4
+ *
5
+ * node scripts/i18n/audit-seo.mjs # every page, every locale
6
+ * node scripts/i18n/audit-seo.mjs --lang it # one locale
7
+ *
8
+ * verify.mjs samples pages to stay fast; this reads ALL 13,328 of them, because the
9
+ * failure modes here are per-page (a canonical pointing at the wrong slug, one locale
10
+ * missing from one page's alternate set) and sampling cannot prove their absence.
11
+ *
12
+ * Checks, per page:
13
+ * canonical exactly the page's own URL, https, no trailing slash
14
+ * hreflang 57 <link rel=alternate>: 55 locales + en + x-default, no duplicates,
15
+ * self-reference present and identical to the canonical, en and
16
+ * x-default both pointing at the English original, every href https
17
+ * return tags full mesh — every alternate URL must itself be a built page whose
18
+ * own set points back (guaranteed structurally, verified explicitly)
19
+ * og og:url matches canonical, og:locale matches the page's language
20
+ * JSON-LD parses; inLanguage is the page's language, never "en" on a locale
21
+ * page; WebPage @id is <page-url>#webpage and unique per page
22
+ * robots no accidental noindex on an indexable page
23
+ */
24
+
25
+ import { readFileSync, existsSync, writeFileSync } from 'fs';
26
+ import { join } from 'path';
27
+ import { fileURLToPath } from 'url';
28
+
29
+ import { BUILD_DIR as DIST, BASE_URL as BASE, LOCALES as LANG_ROWS, BY_PATH, RTL, getPages, I18N_DIR } from './config.mjs';
30
+ import { creditBlock } from './credit.mjs';
31
+
32
+ const ROOT = process.cwd();
33
+
34
+ const args = Object.fromEntries(
35
+ process.argv
36
+ .slice(2)
37
+ .join(' ')
38
+ .split('--')
39
+ .filter(Boolean)
40
+ .map((s) => s.trim().split(/\s+/))
41
+ .map(([k, ...v]) => [k, v.join(' ') || true])
42
+ );
43
+
44
+ // ── Locale table ─────────────────────────────────────────────────────────────
45
+
46
+
47
+ const EXPECTED_CODES = new Set([...LANG_ROWS.map((r) => r.hreflang), 'en', 'x-default']);
48
+
49
+ const SITEMAP_SLUGS = getPages();
50
+
51
+ const LOCALES = args.lang ? String(args.lang).split(',') : LANG_ROWS.map((r) => r.pathCode);
52
+
53
+ // ── Helpers ──────────────────────────────────────────────────────────────────
54
+
55
+ const attrs = (tag) => Object.fromEntries([...tag.matchAll(/([\w:-]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
56
+
57
+ function findTag(html, re, test) {
58
+ for (const m of html.matchAll(re)) {
59
+ const a = attrs(m[0]);
60
+ if (test(a)) return a;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ const LINK_RE = /<link[^>]*>/g;
66
+ const META_RE = /<meta[^>]*>/g;
67
+
68
+ const pageUrl = (lang, slug) =>
69
+ lang ? (slug ? `${BASE}/${lang}/${slug}` : `${BASE}/${lang}`) : slug ? `${BASE}/${slug}` : `${BASE}/`;
70
+
71
+ // ── Findings ─────────────────────────────────────────────────────────────────
72
+
73
+ const findings = [];
74
+ const add = (check, page, detail) => findings.push({ check, page, detail });
75
+ const counts = {};
76
+ const bump = (k) => (counts[k] = (counts[k] ?? 0) + 1);
77
+
78
+ // ── Per-page audit ───────────────────────────────────────────────────────────
79
+
80
+ function auditPage(lang, slug) {
81
+ const file = lang
82
+ ? slug
83
+ ? join(DIST, lang, slug, 'index.html')
84
+ : join(DIST, lang, 'index.html')
85
+ : slug
86
+ ? join(DIST, slug, 'index.html')
87
+ : join(DIST, 'index.html');
88
+
89
+ if (!existsSync(file)) {
90
+ add('missing-page', `${lang || 'en'}/${slug || '(home)'}`, 'file does not exist');
91
+ return;
92
+ }
93
+
94
+ const html = readFileSync(file, 'utf8');
95
+ const id = `${lang || 'en'}/${slug || '(home)'}`;
96
+ const self = pageUrl(lang, slug);
97
+ const enUrl = pageUrl(null, slug);
98
+ const row = lang ? BY_PATH[lang] : { hreflang: 'en' };
99
+ bump('pages');
100
+
101
+ // ── canonical ──
102
+ const canon = findTag(html, LINK_RE, (a) => a.rel === 'canonical');
103
+ if (!canon) add('canonical-missing', id, 'no rel=canonical');
104
+ else {
105
+ if (canon.href !== self) add('canonical-wrong', id, `${canon.href} ≠ ${self}`);
106
+ if (!canon.href?.startsWith('https://')) add('canonical-protocol', id, canon.href);
107
+ if (canon.href !== `${BASE}/` && canon.href?.endsWith('/')) add('canonical-trailing-slash', id, canon.href);
108
+ }
109
+
110
+ // ── hreflang set ──
111
+ const alts = [...html.matchAll(LINK_RE)].map((m) => attrs(m[0])).filter((a) => a.rel === 'alternate' && a.hreflang);
112
+ const codes = alts.map((a) => a.hreflang);
113
+ const byCode = Object.fromEntries(alts.map((a) => [a.hreflang, a.href]));
114
+
115
+ if (codes.length !== EXPECTED_CODES.size) {
116
+ add('hreflang-count', id, `${codes.length} tags, expected ${EXPECTED_CODES.size}`);
117
+ }
118
+ const dupes = codes.filter((c, i) => codes.indexOf(c) !== i);
119
+ if (dupes.length) add('hreflang-duplicate', id, [...new Set(dupes)].join(','));
120
+
121
+ for (const c of codes) if (!EXPECTED_CODES.has(c)) add('hreflang-unexpected-code', id, c);
122
+ for (const c of EXPECTED_CODES) if (!codes.includes(c)) add('hreflang-missing-code', id, c);
123
+
124
+ // self-reference must exist and equal the canonical
125
+ const selfHref = byCode[row.hreflang];
126
+ if (selfHref === undefined) add('hreflang-self-missing', id, row.hreflang);
127
+ else if (selfHref !== self) add('hreflang-self-wrong', id, `${row.hreflang}=${selfHref} ≠ ${self}`);
128
+ else if (canon && selfHref !== canon.href) add('hreflang-canonical-mismatch', id, `${selfHref} ≠ ${canon.href}`);
129
+
130
+ // en and x-default must point at the English original
131
+ if (byCode['en'] !== enUrl) add('hreflang-en-wrong', id, `${byCode['en']} ≠ ${enUrl}`);
132
+ if (byCode['x-default'] !== enUrl) add('hreflang-xdefault-wrong', id, `${byCode['x-default']} ≠ ${enUrl}`);
133
+
134
+ for (const [c, href] of Object.entries(byCode)) {
135
+ if (!href?.startsWith('https://')) add('hreflang-protocol', id, `${c}=${href}`);
136
+ if (href !== `${BASE}/` && href?.endsWith('/')) add('hreflang-trailing-slash', id, `${c}=${href}`);
137
+ }
138
+
139
+ // every alternate must be the URL this page's slug maps to in that locale
140
+ for (const r of LANG_ROWS) {
141
+ const want = pageUrl(r.pathCode, slug);
142
+ if (byCode[r.hreflang] && byCode[r.hreflang] !== want) {
143
+ add('hreflang-target-wrong', id, `${r.hreflang}=${byCode[r.hreflang]} ≠ ${want}`);
144
+ }
145
+ }
146
+
147
+ // ── og ──
148
+ const ogUrl = findTag(html, META_RE, (a) => a.property === 'og:url');
149
+ if (ogUrl && ogUrl.content !== self) add('og-url-wrong', id, `${ogUrl.content} ≠ ${self}`);
150
+ const ogLocale = findTag(html, META_RE, (a) => a.property === 'og:locale');
151
+ if (ogLocale && ogLocale.content !== row.hreflang)
152
+ add('og-locale-wrong', id, `${ogLocale.content} ≠ ${row.hreflang}`);
153
+
154
+ // ── robots ──
155
+ const robots = findTag(html, META_RE, (a) => a.name === 'robots');
156
+ if (robots && /noindex/i.test(robots.content ?? '')) add('noindex', id, robots.content);
157
+
158
+ // ── html lang ──
159
+ const htmlTag = /<html[^>]*>/.exec(html)?.[0] ?? '';
160
+ const htmlLang = /\slang="([^"]*)"/.exec(htmlTag)?.[1];
161
+ if (htmlLang !== row.hreflang) add('html-lang-wrong', id, `${htmlLang} ≠ ${row.hreflang}`);
162
+
163
+ // ── JSON-LD ──
164
+ let sawWebPage = false;
165
+ for (const m of html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)) {
166
+ let data;
167
+ try {
168
+ data = JSON.parse(m[1]);
169
+ } catch {
170
+ add('jsonld-invalid', id, m[1].slice(0, 60));
171
+ continue;
172
+ }
173
+ bump('jsonld-blocks');
174
+ const walk = (n) => {
175
+ if (Array.isArray(n)) return n.forEach(walk);
176
+ if (!n || typeof n !== 'object') return;
177
+ const types = [].concat(n['@type'] ?? []);
178
+ if (typeof n.inLanguage === 'string' && n.inLanguage !== row.hreflang) {
179
+ add('jsonld-inlanguage', id, `${types.join('/')}: ${n.inLanguage} ≠ ${row.hreflang}`);
180
+ }
181
+ if (types.includes('WebPage')) {
182
+ sawWebPage = true;
183
+ if (n['@id'] !== `${self}#webpage`) add('jsonld-webpage-id', id, `${n['@id']} ≠ ${self}#webpage`);
184
+ if (n.url && n.url !== self) add('jsonld-webpage-url', id, `${n.url} ≠ ${self}`);
185
+ }
186
+ for (const v of Object.values(n)) if (v && typeof v === 'object') walk(v);
187
+ };
188
+ walk(data);
189
+ }
190
+ if (!sawWebPage) bump('pages-without-webpage-schema');
191
+ }
192
+
193
+ // ── Run ──────────────────────────────────────────────────────────────────────
194
+
195
+ console.log(`auditing ${SITEMAP_SLUGS.length} slugs × (${LOCALES.length} locales + English)\n`);
196
+
197
+ for (const slug of SITEMAP_SLUGS) auditPage(null, slug);
198
+ for (const lang of LOCALES) for (const slug of SITEMAP_SLUGS) auditPage(lang, slug);
199
+
200
+ // ── Sitemap audit ────────────────────────────────────────────────────────────
201
+
202
+ console.log('── sitemaps ──');
203
+ const smIndex = join(DIST, 'sitemap.xml');
204
+ if (!existsSync(smIndex)) add('sitemap-index-missing', 'sitemap.xml', 'not built');
205
+ else {
206
+ const idx = readFileSync(smIndex, 'utf8');
207
+ const children = [...idx.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
208
+ console.log(` index lists ${children.length} sitemaps`);
209
+
210
+ let totalUrls = 0;
211
+ for (const child of children) {
212
+ const rel = child.replace(`${BASE}/`, '');
213
+ const f = join(DIST, rel);
214
+ if (!existsSync(f)) {
215
+ add('sitemap-child-missing', rel, 'listed in index but not built');
216
+ continue;
217
+ }
218
+ const urls = [...readFileSync(f, 'utf8').matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
219
+ totalUrls += urls.length;
220
+ if (urls.length !== SITEMAP_SLUGS.length) {
221
+ add('sitemap-url-count', rel, `${urls.length} urls, expected ${SITEMAP_SLUGS.length}`);
222
+ }
223
+ // every listed URL must exist as a built page
224
+ for (const u of urls) {
225
+ const path = u.replace(BASE, '').replace(/^\//, '');
226
+ const file = path === '' ? join(DIST, 'index.html') : join(DIST, path, 'index.html');
227
+ if (!existsSync(file)) add('sitemap-url-404', rel, u);
228
+ if (u !== `${BASE}/` && u.endsWith('/')) add('sitemap-trailing-slash', rel, u);
229
+ if (!u.startsWith('https://')) add('sitemap-protocol', rel, u);
230
+ }
231
+ }
232
+ console.log(` total URLs across sitemaps: ${totalUrls.toLocaleString()}`);
233
+ counts['sitemap-urls'] = totalUrls;
234
+ }
235
+
236
+ // ── Report ───────────────────────────────────────────────────────────────────
237
+
238
+ console.log('\n── coverage ──');
239
+ for (const [k, v] of Object.entries(counts)) console.log(` ${k}: ${v.toLocaleString()}`);
240
+
241
+ const byCheck = {};
242
+ for (const f of findings) (byCheck[f.check] ??= []).push(f);
243
+
244
+ console.log('\n── findings ──');
245
+ if (findings.length === 0) console.log(' none — all checks passed');
246
+ for (const [check, list] of Object.entries(byCheck).sort((a, b) => b[1].length - a[1].length)) {
247
+ console.log(` ${check}: ${list.length.toLocaleString()}`);
248
+ for (const f of list.slice(0, 3)) console.log(` ${f.page} — ${f.detail}`);
249
+ if (list.length > 3) console.log(` … and ${(list.length - 3).toLocaleString()} more`);
250
+ }
251
+
252
+ writeFileSync(join(ROOT, 'i18n/seo-audit.json'), JSON.stringify({ counts, findings }, null, 2));
253
+ console.log(`\nwrote i18n/seo-audit.json (${findings.length.toLocaleString()} findings)`);
254
+
255
+ creditBlock(
256
+ findings.length === 0
257
+ ? ['SEO audit clean \u2014 canonicals, hreflang mesh, og, JSON-LD and sitemaps all pass']
258
+ : [`SEO audit finished with ${findings.length.toLocaleString()} finding(s) \u2014 see i18n/seo-audit.json`]
259
+ );
260
+
261
+ process.exit(findings.length ? 1 : 0);