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.
- package/CHANGELOG.md +135 -0
- package/LICENSE +662 -0
- package/LICENSING.md +69 -0
- package/README.md +434 -0
- package/SKILL.md +206 -0
- package/bin/claude-translator.mjs +230 -0
- package/bin/cli.test.mjs +165 -0
- package/i18n.config.example.json +67 -0
- package/package.json +60 -0
- package/references/adapting-generators.md +76 -0
- package/references/failure-modes.md +255 -0
- package/references/providers.md +157 -0
- package/references/quality-review.md +91 -0
- package/references/throughput-and-cost.md +124 -0
- package/scripts/audit-seo.mjs +261 -0
- package/scripts/build-locales.mjs +336 -0
- package/scripts/config.mjs +188 -0
- package/scripts/credit.mjs +143 -0
- package/scripts/extract.mjs +564 -0
- package/scripts/finalize.sh +58 -0
- package/scripts/providers/anthropic.mjs +118 -0
- package/scripts/providers/gemini.mjs +72 -0
- package/scripts/providers/index.mjs +95 -0
- package/scripts/providers/openai.mjs +120 -0
- package/scripts/providers/providers.test.mjs +214 -0
- package/scripts/review.mjs +310 -0
- package/scripts/translate.mjs +455 -0
- package/scripts/verify.mjs +384 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* i18n step 3 — write the localised static pages.
|
|
4
|
+
*
|
|
5
|
+
* node scripts/i18n/build-locales.mjs --lang es,ru,pt-br
|
|
6
|
+
* node scripts/i18n/build-locales.mjs --lang all
|
|
7
|
+
*
|
|
8
|
+
* Reads dist/ English build (untouched)
|
|
9
|
+
* i18n/segments/*.json byte ranges from extract.mjs
|
|
10
|
+
* i18n/tm/{lang}.json translations from translate.mjs
|
|
11
|
+
* Writes dist/{lang}/{slug}/index.html
|
|
12
|
+
*
|
|
13
|
+
* ── How the HTML is produced ─────────────────────────────────────────────────
|
|
14
|
+
* Each page is the English file with segment ranges spliced right-to-left. The
|
|
15
|
+
* document is never re-serialised from a DOM, so everything outside those ranges —
|
|
16
|
+
* inlined critical CSS, asset hashes, width/height attributes, script order — is
|
|
17
|
+
* carried over byte for byte. That is what keeps the localised pages' Core Web
|
|
18
|
+
* Vitals identical to the English ones rather than merely similar.
|
|
19
|
+
*
|
|
20
|
+
* On top of the text substitution each page gets its locale identity fixed:
|
|
21
|
+
* <html lang>, dir=rtl, canonical, the hreflang set, JSON-LD @id/url/inLanguage,
|
|
22
|
+
* og:locale and internal link prefixes. Those three JSON-LD fields and the
|
|
23
|
+
* en/x-default hreflang pair are exactly the sitewide defects the 2026-08-13 audit
|
|
24
|
+
* found on ~13,035 proxy-served URLs; generating the pages ourselves closes them.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, rmSync } from 'fs';
|
|
28
|
+
import { join, dirname } from 'path';
|
|
29
|
+
import { fileURLToPath } from 'url';
|
|
30
|
+
|
|
31
|
+
import { BUILD_DIR as DIST, SEG_DIR, TM_DIR, BASE_URL as BASE, LOCALES as LANG_ROWS, BY_PATH, RTL, getPages } from './config.mjs';
|
|
32
|
+
import { applyPageMarkers, applyVisibleLink, markerBytes } from './credit.mjs';
|
|
33
|
+
|
|
34
|
+
const ROOT = process.cwd();
|
|
35
|
+
|
|
36
|
+
const args = Object.fromEntries(
|
|
37
|
+
process.argv
|
|
38
|
+
.slice(2)
|
|
39
|
+
.join(' ')
|
|
40
|
+
.split('--')
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.map((s) => s.trim().split(/\s+/))
|
|
43
|
+
.map(([k, ...v]) => [k, v.join(' ') || true])
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// ── Locale table ─────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
const requested = String(args.lang ?? '').trim();
|
|
51
|
+
if (!requested) {
|
|
52
|
+
console.error('Usage: node scripts/i18n/build-locales.mjs --lang es[,ru,...] | --lang all');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
const LANGS = requested === 'all' ? LANG_ROWS.map((r) => r.pathCode) : requested.split(',').map((s) => s.trim());
|
|
56
|
+
|
|
57
|
+
for (const l of LANGS) {
|
|
58
|
+
if (!BY_PATH[l]) {
|
|
59
|
+
console.error(`Unknown locale "${l}" — not in the locales config`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
const escHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
67
|
+
const escAttr = (s) => escHtml(s).replace(/"/g, '"');
|
|
68
|
+
const escJson = (s) => JSON.stringify(s).slice(1, -1);
|
|
69
|
+
|
|
70
|
+
/** Rebuild a block's innerHTML from its translated placeholder text + tag table. */
|
|
71
|
+
function renderBlock(text, tags) {
|
|
72
|
+
let out = '';
|
|
73
|
+
let last = 0;
|
|
74
|
+
let m;
|
|
75
|
+
const re = /<(\/?)(\d+)(\/?)>/g;
|
|
76
|
+
while ((m = re.exec(text))) {
|
|
77
|
+
out += escHtml(text.slice(last, m.index));
|
|
78
|
+
const pair = tags[Number(m[2])];
|
|
79
|
+
if (pair) {
|
|
80
|
+
const [open, close] = pair;
|
|
81
|
+
out += m[3] === '/' ? open : m[1] === '/' ? (close ?? '') : open;
|
|
82
|
+
}
|
|
83
|
+
last = m.index + m[0].length;
|
|
84
|
+
}
|
|
85
|
+
return out + escHtml(text.slice(last));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** English page path ("" = home) → localised URL. */
|
|
89
|
+
const localeUrl = (lang, slug) => (slug ? `${BASE}/${lang}/${slug}` : `${BASE}/${lang}`);
|
|
90
|
+
const englishUrl = (slug) => (slug ? `${BASE}/${slug}` : `${BASE}/`);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Rewrites one attribute on the first tag matching `test`, leaving the rest of the
|
|
94
|
+
* tag byte-identical. Attribute ORDER is not assumed: the Astro build emits
|
|
95
|
+
* `<link href="…" rel="canonical">`, i.e. href first, so order-dependent patterns
|
|
96
|
+
* silently matched nothing.
|
|
97
|
+
*/
|
|
98
|
+
function rewriteAttr(html, tagRe, test, attrName, newValue, name, report) {
|
|
99
|
+
let matched = false;
|
|
100
|
+
const out = html.replace(tagRe, (tag) => {
|
|
101
|
+
const attrs = Object.fromEntries([...tag.matchAll(/([\w:-]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
|
|
102
|
+
if (!test(attrs)) return tag;
|
|
103
|
+
matched = true;
|
|
104
|
+
const re = new RegExp(`(\\s${attrName}=")[^"]*(")`);
|
|
105
|
+
return re.test(tag) ? tag.replace(re, `$1${newValue}$2`) : tag;
|
|
106
|
+
});
|
|
107
|
+
if (!matched) report.misses.add(name);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Locale identity fixes applied after text substitution. Each rule reports whether it
|
|
113
|
+
* matched, so a template change cannot silently turn one into a no-op.
|
|
114
|
+
*/
|
|
115
|
+
function applyLocaleIdentity(html, lang, slug, report) {
|
|
116
|
+
const url = localeUrl(lang, slug);
|
|
117
|
+
const enUrl = englishUrl(slug);
|
|
118
|
+
const row = BY_PATH[lang];
|
|
119
|
+
let out = html;
|
|
120
|
+
|
|
121
|
+
// <html … lang="en" dir="ltr"> → locale. The build already emits dir, so RTL
|
|
122
|
+
// locales must REPLACE its value rather than add a second attribute.
|
|
123
|
+
const before = out;
|
|
124
|
+
out = out.replace(/<html[^>]*>/, (tag) => {
|
|
125
|
+
let t = tag.replace(/(\slang=")[^"]*(")/, `$1${row.hreflang}$2`);
|
|
126
|
+
t = t.replace(/(\sdir=")[^"]*(")/, `$1${RTL.has(lang) ? 'rtl' : 'ltr'}$2`);
|
|
127
|
+
return t;
|
|
128
|
+
});
|
|
129
|
+
if (out === before) report.misses.add('html-lang');
|
|
130
|
+
|
|
131
|
+
// Canonical → this page, no trailing slash (audit H5: the proxy emitted /es/,
|
|
132
|
+
// which 301s back to /es and contradicts the sitemap).
|
|
133
|
+
out = rewriteAttr(out, /<link[^>]*>/g, (a) => a.rel === 'canonical', 'href', url, 'canonical', report);
|
|
134
|
+
|
|
135
|
+
// hreflang en + x-default must point at the ENGLISH page, not at self
|
|
136
|
+
// (audit C2 — one defect repeated across ~13,035 URLs).
|
|
137
|
+
out = rewriteAttr(
|
|
138
|
+
out,
|
|
139
|
+
/<link[^>]*>/g,
|
|
140
|
+
(a) => a.hreflang === 'x-default',
|
|
141
|
+
'href',
|
|
142
|
+
enUrl,
|
|
143
|
+
'hreflang-x-default',
|
|
144
|
+
report
|
|
145
|
+
);
|
|
146
|
+
out = rewriteAttr(out, /<link[^>]*>/g, (a) => a.hreflang === 'en', 'href', enUrl, 'hreflang-en', report);
|
|
147
|
+
|
|
148
|
+
out = rewriteAttr(out, /<meta[^>]*>/g, (a) => a.property === 'og:url', 'content', url, 'og-url', report);
|
|
149
|
+
out = rewriteAttr(
|
|
150
|
+
out,
|
|
151
|
+
/<meta[^>]*>/g,
|
|
152
|
+
(a) => a.property === 'og:locale',
|
|
153
|
+
'content',
|
|
154
|
+
row.hreflang,
|
|
155
|
+
'og-locale',
|
|
156
|
+
report
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// LanguagePicker: the trigger shows the current language and the matching option is
|
|
160
|
+
// marked current. Both are per-locale, so the component ships English and gets
|
|
161
|
+
// rewritten here. Soft rules — silent while the picker is not yet mounted, but a
|
|
162
|
+
// miss is reported once its marker attribute is present.
|
|
163
|
+
// Global flags matter: Header.astro mounts the picker twice (mobile bar + desktop bar),
|
|
164
|
+
// so a first-match-only replace would leave the second one reading "English".
|
|
165
|
+
if (out.includes('data-i18n-current-lang')) {
|
|
166
|
+
const beforeLabel = out;
|
|
167
|
+
out = out.replace(/(<span[^>]*\sdata-i18n-current-lang[^>]*>)[^<]*(<\/span>)/g, `$1${row.nativeLabel}$2`);
|
|
168
|
+
if (out === beforeLabel) report.misses.add('picker-current-label');
|
|
169
|
+
}
|
|
170
|
+
if (out.includes('data-i18n-lang=')) {
|
|
171
|
+
const beforeCurrent = out;
|
|
172
|
+
out = out.replace(new RegExp(`(<a\\b[^>]*\\sdata-i18n-lang="${row.hreflang}")`, 'g'), '$1 aria-current="true"');
|
|
173
|
+
if (out === beforeCurrent) report.misses.add('picker-aria-current');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// JSON-LD identity (audit C3): @id collided across every locale, url pointed at the
|
|
177
|
+
// English home page and inLanguage claimed "en" on all translated pages.
|
|
178
|
+
//
|
|
179
|
+
// Only WebPage-ish nodes are localised. Organization.url is the company's canonical
|
|
180
|
+
// URL — the same entity in every language — and on the home page it equals the English
|
|
181
|
+
// root, so a blanket url rewrite would wrongly relocalise it.
|
|
182
|
+
// Page-level types get THIS page's URL. WebSite describes the site as a whole, so it
|
|
183
|
+
// gets the locale root — giving it the page URL would claim the site itself lives at
|
|
184
|
+
// /es/about. Organization is one entity across all languages: left alone entirely.
|
|
185
|
+
const PAGE_TYPES = new Set(['WebPage', 'CollectionPage', 'ItemPage', 'AboutPage', 'FAQPage', 'ContactPage']);
|
|
186
|
+
const localeRoot = `${BASE}/${lang}`;
|
|
187
|
+
out = out.replace(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g, (full, body) => {
|
|
188
|
+
let data;
|
|
189
|
+
try {
|
|
190
|
+
data = JSON.parse(body);
|
|
191
|
+
} catch {
|
|
192
|
+
return full; // leave anything we cannot parse exactly as it was
|
|
193
|
+
}
|
|
194
|
+
const walk = (node) => {
|
|
195
|
+
if (Array.isArray(node)) return node.forEach(walk);
|
|
196
|
+
if (!node || typeof node !== 'object') return;
|
|
197
|
+
if (typeof node.inLanguage === 'string') node.inLanguage = row.hreflang;
|
|
198
|
+
if (typeof node['@id'] === 'string' && node['@id'].endsWith('#webpage')) node['@id'] = `${url}#webpage`;
|
|
199
|
+
const types = [].concat(node['@type'] ?? []);
|
|
200
|
+
if (typeof node.url === 'string') {
|
|
201
|
+
if (types.includes('WebSite')) node.url = localeRoot;
|
|
202
|
+
else if (types.some((t) => PAGE_TYPES.has(t))) node.url = url;
|
|
203
|
+
}
|
|
204
|
+
for (const v of Object.values(node)) if (v && typeof v === 'object') walk(v);
|
|
205
|
+
};
|
|
206
|
+
walk(data);
|
|
207
|
+
return `<script type="application/ld+json">${JSON.stringify(data)}</script>`;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Internal links → locale-prefixed. Only same-origin document links; asset paths
|
|
211
|
+
// (/_astro/, images, xml, ico) and anchors stay put.
|
|
212
|
+
out = out.replace(/(<a\b[^>]*?\shref=")\/([^"#?]*)("|[#?])/g, (m, pre, path, tail) => {
|
|
213
|
+
if (/^(_astro|assets|images|fonts|favicon|robots|sitemap)/.test(path)) return m;
|
|
214
|
+
if (/\.(xml|txt|ico|png|jpe?g|webp|svg|css|js|json|pdf)$/i.test(path)) return m;
|
|
215
|
+
// href="/" is the home link: it must become "/es", not "/es/". The trailing-slash
|
|
216
|
+
// form 301-redirects back (deploy/Caddyfile.docker), which would put a needless
|
|
217
|
+
// redirect hop on the logo of every localised page — and contradict the
|
|
218
|
+
// no-trailing-slash canonical this build emits (audit H5).
|
|
219
|
+
if (path === '') return `${pre}/${lang}${tail}`;
|
|
220
|
+
return `${pre}/${lang}/${path}${tail}`;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Attribution last, so it cannot shift any offset the substitution relied on.
|
|
224
|
+
out = applyPageMarkers(out, row, report);
|
|
225
|
+
out = applyVisibleLink(out, report);
|
|
226
|
+
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Build ────────────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
const segFiles = readdirSync(SEG_DIR).filter((f) => f.endsWith('.json'));
|
|
233
|
+
if (segFiles.length === 0) {
|
|
234
|
+
console.error('i18n/segments/ is empty. Run: node scripts/i18n/extract.mjs');
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The page set is SITEMAP_SLUGS, not "whatever is in dist/". dist/ also holds pages
|
|
240
|
+
* that must never be localised — the Decap CMS admin shell (no lang attribute, no
|
|
241
|
+
* content) and 404.html. Driving the build off the sitemap is also what makes the
|
|
242
|
+
* URL-parity gate meaningful: 237 slugs × N locales, no more and no less.
|
|
243
|
+
*/
|
|
244
|
+
const SITEMAP_SLUGS = getPages();
|
|
245
|
+
|
|
246
|
+
/** page key (as written by extract.mjs) → segment file */
|
|
247
|
+
const segByPage = new Map();
|
|
248
|
+
for (const f of segFiles) {
|
|
249
|
+
const parsed = JSON.parse(readFileSync(join(SEG_DIR, f), 'utf8'));
|
|
250
|
+
segByPage.set(parsed.page, parsed);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const targets = [];
|
|
254
|
+
const missingSource = [];
|
|
255
|
+
for (const slug of SITEMAP_SLUGS) {
|
|
256
|
+
const key = slug === '' ? 'index' : slug;
|
|
257
|
+
const entry = segByPage.get(key);
|
|
258
|
+
if (!entry) missingSource.push(slug || '(home)');
|
|
259
|
+
else targets.push({ slug, ...entry });
|
|
260
|
+
}
|
|
261
|
+
if (missingSource.length) {
|
|
262
|
+
console.error(
|
|
263
|
+
`${missingSource.length} sitemap slug(s) have no built English page: ${missingSource.slice(0, 10).join(', ')}`
|
|
264
|
+
);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
console.log(
|
|
268
|
+
`localising ${targets.length} slugs × ${LANGS.length} locale(s) = ${(targets.length * LANGS.length).toLocaleString()} pages\n`
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
let builtPages = 0;
|
|
272
|
+
|
|
273
|
+
for (const lang of LANGS) {
|
|
274
|
+
const tmFile = join(TM_DIR, `${lang}.json`);
|
|
275
|
+
if (!existsSync(tmFile)) {
|
|
276
|
+
console.error(`No translation memory for "${lang}". Run: node scripts/i18n/translate.mjs --lang ${lang}`);
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
const tm = JSON.parse(readFileSync(tmFile, 'utf8'));
|
|
280
|
+
|
|
281
|
+
// A stale locale directory would leave orphan pages behind after a slug is removed.
|
|
282
|
+
rmSync(join(DIST, lang), { recursive: true, force: true });
|
|
283
|
+
|
|
284
|
+
const report = { pages: 0, replaced: 0, untranslated: 0, misses: new Set() };
|
|
285
|
+
|
|
286
|
+
for (const target of targets) {
|
|
287
|
+
const { slug, file, segments } = target;
|
|
288
|
+
const source = join(ROOT, file);
|
|
289
|
+
if (!existsSync(source)) continue;
|
|
290
|
+
|
|
291
|
+
let html = readFileSync(source, 'utf8');
|
|
292
|
+
|
|
293
|
+
// Right-to-left: segments are already sorted descending, so earlier offsets stay valid.
|
|
294
|
+
for (const seg of segments) {
|
|
295
|
+
const translated = tm[seg.hash];
|
|
296
|
+
if (translated === undefined) {
|
|
297
|
+
report.untranslated++;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
let replacement;
|
|
301
|
+
if (seg.kind === 'block') replacement = renderBlock(translated, seg.tags ?? []);
|
|
302
|
+
else if (seg.kind === 'jsonld') replacement = escJson(translated);
|
|
303
|
+
else if (seg.kind.startsWith('attr:')) replacement = escAttr(translated);
|
|
304
|
+
else replacement = escHtml(translated);
|
|
305
|
+
|
|
306
|
+
html = html.slice(0, seg.start) + replacement + html.slice(seg.end);
|
|
307
|
+
report.replaced++;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
html = applyLocaleIdentity(html, lang, slug, report);
|
|
311
|
+
|
|
312
|
+
const outPath = slug ? join(DIST, lang, slug, 'index.html') : join(DIST, lang, 'index.html');
|
|
313
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
314
|
+
writeFileSync(outPath, html);
|
|
315
|
+
report.pages++;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
console.log(
|
|
319
|
+
`${lang}: ${report.pages} pages, ${report.replaced.toLocaleString()} segments replaced, ` +
|
|
320
|
+
`${report.untranslated.toLocaleString()} left in English`
|
|
321
|
+
);
|
|
322
|
+
if (report.misses.size) {
|
|
323
|
+
console.log(` ⚠ identity rules that matched nothing: ${[...report.misses].join(', ')}`);
|
|
324
|
+
}
|
|
325
|
+
builtPages += report.pages;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Disclosed at the point of action, not buried in a README: this is what the pages
|
|
329
|
+
// now carry, and which key removes it.
|
|
330
|
+
const attrBytes = markerBytes(BY_PATH[LANGS[0]]);
|
|
331
|
+
if (attrBytes) {
|
|
332
|
+
console.log(
|
|
333
|
+
`\n${builtPages.toLocaleString()} pages written \u00b7 each carries ${attrBytes} bytes of ` +
|
|
334
|
+
`ConveyThis attribution (0 requests, 0 layout shift; disable with credit.generatorTag / credit.htmlComment)`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared configuration loader. Every script reads this instead of hardcoding
|
|
3
|
+
* project specifics — it is the only file that knows anything about a given site.
|
|
4
|
+
*
|
|
5
|
+
* Looks for i18n.config.json in the project root (or $I18N_CONFIG).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
9
|
+
import { join, resolve } from 'path';
|
|
10
|
+
|
|
11
|
+
const ROOT = process.env.I18N_ROOT ? resolve(process.env.I18N_ROOT) : process.cwd();
|
|
12
|
+
const CONFIG_PATH = process.env.I18N_CONFIG ? resolve(process.env.I18N_CONFIG) : join(ROOT, 'i18n.config.json');
|
|
13
|
+
|
|
14
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
15
|
+
console.error(`No config at ${CONFIG_PATH}.
|
|
16
|
+
Copy config.example.json to i18n.config.json and fill it in.`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
21
|
+
|
|
22
|
+
/** Absolute path to the built site. */
|
|
23
|
+
export const BUILD_DIR = resolve(ROOT, raw.buildDir ?? 'dist');
|
|
24
|
+
|
|
25
|
+
/** Canonical origin, no trailing slash. */
|
|
26
|
+
export const BASE_URL = String(raw.baseUrl ?? '').replace(/\/$/, '');
|
|
27
|
+
if (!BASE_URL) {
|
|
28
|
+
console.error('config.baseUrl is required (e.g. "https://example.com")');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const I18N_DIR = resolve(ROOT, raw.i18nDir ?? 'i18n');
|
|
33
|
+
export const TM_DIR = join(I18N_DIR, 'tm');
|
|
34
|
+
export const SEG_DIR = join(I18N_DIR, 'segments');
|
|
35
|
+
export const SOURCE_FILE = join(I18N_DIR, 'source.json');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Locales: [{ hreflang, pathCode, nativeLabel }]
|
|
39
|
+
* hreflang what goes in <link hreflang> and <html lang> — ISO 639-1,
|
|
40
|
+
* region UPPERCASE (pt-BR), script where it matters (zh-Hant)
|
|
41
|
+
* pathCode the URL segment, e.g. /pt-br/…
|
|
42
|
+
* nativeLabel the language's name in its own language, for the picker
|
|
43
|
+
*/
|
|
44
|
+
export const LOCALES = (() => {
|
|
45
|
+
const src = raw.locales;
|
|
46
|
+
if (Array.isArray(src)) return src;
|
|
47
|
+
if (typeof src === 'string') {
|
|
48
|
+
const p = resolve(ROOT, src);
|
|
49
|
+
if (!existsSync(p)) {
|
|
50
|
+
console.error(`config.locales points at ${p}, which does not exist`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const text = readFileSync(p, 'utf8');
|
|
54
|
+
// Accept JSON, or scrape a TS/JS file for the three fields (any order).
|
|
55
|
+
if (p.endsWith('.json')) return JSON.parse(text);
|
|
56
|
+
return text
|
|
57
|
+
.split('\n')
|
|
58
|
+
.map((l) => {
|
|
59
|
+
const h = /hreflang:\s*'([^']+)'/.exec(l)?.[1];
|
|
60
|
+
const pc = /pathCode:\s*'([^']+)'/.exec(l)?.[1];
|
|
61
|
+
const nl = /nativeLabel:\s*'([^']+)'/.exec(l)?.[1];
|
|
62
|
+
return h && pc ? { hreflang: h, pathCode: pc, nativeLabel: nl ?? pc } : null;
|
|
63
|
+
})
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
}
|
|
66
|
+
console.error('config.locales must be an array or a path to a file');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
})();
|
|
69
|
+
|
|
70
|
+
export const BY_PATH = Object.fromEntries(LOCALES.map((r) => [r.pathCode, r]));
|
|
71
|
+
export const LOCALE_DIRS = new Set(LOCALES.map((r) => r.pathCode));
|
|
72
|
+
|
|
73
|
+
/** Locales written right-to-left. Drives dir="rtl" and a prompt hint. */
|
|
74
|
+
export const RTL = new Set(raw.rtlLocales ?? ['ar', 'fa', 'he', 'ur', 'ps', 'sd', 'ug', 'yi']);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Page list. Two sources:
|
|
78
|
+
* "build" derive from the build output — every dir containing index.html
|
|
79
|
+
* <path> a file listing slugs (JSON array, or a TS/JS array of strings)
|
|
80
|
+
*
|
|
81
|
+
* `exclude` drops pages that must never be localised: 404 handlers, CMS admin
|
|
82
|
+
* shells, anything noindexed. Matched against the first path segment.
|
|
83
|
+
*/
|
|
84
|
+
export function getPages() {
|
|
85
|
+
const cfg = raw.pages ?? { source: 'build' };
|
|
86
|
+
const exclude = new Set(cfg.exclude ?? ['404']);
|
|
87
|
+
|
|
88
|
+
let slugs;
|
|
89
|
+
if (cfg.source === 'build' || !cfg.source) {
|
|
90
|
+
slugs = readdirSync(BUILD_DIR, { recursive: true })
|
|
91
|
+
.filter((p) => typeof p === 'string' && /(^|\/)index\.html$/.test(p))
|
|
92
|
+
.map((p) => p.replace(/(^|\/)index\.html$/, '').replace(/\/$/, ''))
|
|
93
|
+
// Never treat previously built locale output as source (see failure-modes.md).
|
|
94
|
+
.filter((s) => !LOCALE_DIRS.has(s.split('/')[0]));
|
|
95
|
+
} else {
|
|
96
|
+
const p = resolve(ROOT, cfg.source);
|
|
97
|
+
const text = readFileSync(p, 'utf8');
|
|
98
|
+
if (p.endsWith('.json')) {
|
|
99
|
+
slugs = JSON.parse(text);
|
|
100
|
+
} else {
|
|
101
|
+
// Scope to ONE exported array. Scanning the whole file sweeps up every other
|
|
102
|
+
// quoted string in it — on the reference project that silently added the 55
|
|
103
|
+
// language codes to the 238 page slugs and produced 294 "pages".
|
|
104
|
+
const name = cfg.export ?? 'SITEMAP_SLUGS';
|
|
105
|
+
const m = new RegExp(`${name}[^=]*=\\s*\\[([\\s\\S]*?)\\n\\];`).exec(text);
|
|
106
|
+
if (!m) {
|
|
107
|
+
console.error(`Could not find exported array "${name}" in ${p}.
|
|
108
|
+
Set pages.export to the correct name, or use a .json list.`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
slugs = [...m[1].matchAll(/'([^']*)'/g)].map((x) => x[1]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return [...new Set(slugs)].filter((s) => !exclude.has(s.split('/')[0])).sort();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Strings that are correct unchanged in every language. */
|
|
119
|
+
export const DNT = {
|
|
120
|
+
brands: raw.doNotTranslate?.brands ?? [],
|
|
121
|
+
formats: raw.doNotTranslate?.formats ?? ['PDF', 'DOCX', 'XLSX', 'PPTX', 'CSV', 'TXT', 'JSON', 'HTML', 'XML'],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Model and provider.
|
|
126
|
+
*
|
|
127
|
+
* Both are optional and each can imply the other. `MODEL` stays undefined when the
|
|
128
|
+
* config does not set one, so translate.mjs can fall back to the resolved provider's
|
|
129
|
+
* own default rather than to a Gemini model id that would be wrong for Claude.
|
|
130
|
+
* See scripts/providers/index.mjs for how a missing `provider` is inferred.
|
|
131
|
+
*/
|
|
132
|
+
export const MODEL = raw.model ?? null;
|
|
133
|
+
export const PROVIDER = raw.provider ?? null;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Override the model API host — required for local models, Azure, and gateways.
|
|
137
|
+
* The config key is `apiBaseUrl`, deliberately NOT `baseUrl`: that one is the SITE's
|
|
138
|
+
* canonical origin (line 26). Sharing the key would mean pointing at a local model
|
|
139
|
+
* also rewrote every canonical, hreflang and sitemap URL on the site.
|
|
140
|
+
*/
|
|
141
|
+
export const API_BASE_URL = raw.apiBaseUrl ?? null;
|
|
142
|
+
|
|
143
|
+
/** Read the key from a different environment variable than the provider's default. */
|
|
144
|
+
export const API_KEY_ENV = raw.apiKeyEnv ?? null;
|
|
145
|
+
|
|
146
|
+
/** OpenAI-compatible only: 'schema' | 'object' | 'none'. See providers/openai.mjs. */
|
|
147
|
+
export const JSON_MODE = raw.jsonMode ?? null;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* USD per million tokens, [input, output]. Set this when you want a cost estimate for a
|
|
151
|
+
* provider the built-in tables do not cover — a gateway, a local model, a new tier.
|
|
152
|
+
*/
|
|
153
|
+
export const PRICING =
|
|
154
|
+
raw.pricing && raw.pricing.in != null && raw.pricing.out != null
|
|
155
|
+
? [Number(raw.pricing.in), Number(raw.pricing.out)]
|
|
156
|
+
: null;
|
|
157
|
+
export const ROOT_DIR = ROOT;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Site identity, used to compose the translation prompt. A translator that knows what
|
|
161
|
+
* kind of product it is translating makes better register and terminology choices —
|
|
162
|
+
* and without these the prompt would have to describe some other company's site.
|
|
163
|
+
*/
|
|
164
|
+
export const SITE_NAME = raw.siteName ?? new URL(BASE_URL).hostname;
|
|
165
|
+
export const SITE_DESCRIPTION = raw.siteDescription ?? '';
|
|
166
|
+
|
|
167
|
+
/** The language the built site is written in. Also stated in the prompt. */
|
|
168
|
+
export const SOURCE_LANGUAGE = raw.sourceLanguage ?? 'English';
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Attribution and limit hints. See scripts/credit.mjs for what each one does and
|
|
172
|
+
* why the defaults are what they are; the README documents all five openly.
|
|
173
|
+
*
|
|
174
|
+
* "credit": {
|
|
175
|
+
* "generatorTag": true, <meta name="generator"> — same mechanism as Astro/Hugo/WP
|
|
176
|
+
* "htmlComment": true, one HTML comment per page, no link
|
|
177
|
+
* "visibleLink": false, opt-in, and you place the slot yourself
|
|
178
|
+
* "console": true, the sign-off line when a run finishes
|
|
179
|
+
* "upsellHints": true notes when this pipeline hits a real limit
|
|
180
|
+
* }
|
|
181
|
+
*/
|
|
182
|
+
export const CREDIT = {
|
|
183
|
+
generatorTag: raw.credit?.generatorTag ?? true,
|
|
184
|
+
htmlComment: raw.credit?.htmlComment ?? true,
|
|
185
|
+
visibleLink: raw.credit?.visibleLink ?? false,
|
|
186
|
+
console: raw.credit?.console ?? true,
|
|
187
|
+
upsellHints: raw.credit?.upsellHints ?? true,
|
|
188
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attribution, and the limit hints that go with it.
|
|
3
|
+
*
|
|
4
|
+
* ── What this adds to your pages ─────────────────────────────────────────────
|
|
5
|
+
* Two markers, both off-screen, both one config key away from being disabled:
|
|
6
|
+
*
|
|
7
|
+
* <meta name="generator" content="ConveyThis Claude Translator X.Y.Z">
|
|
8
|
+
* <!-- Localized into Español (es) by ConveyThis · https://www.conveythis.com -->
|
|
9
|
+
*
|
|
10
|
+
* Together they are about 160 bytes, load nothing, request nothing and shift nothing —
|
|
11
|
+
* the whole point of this project is that locale pages keep the source language's
|
|
12
|
+
* Core Web Vitals, so attribution that cost a request would defeat it. `verify.mjs`
|
|
13
|
+
* prints the exact byte delta so the claim is checkable rather than asserted.
|
|
14
|
+
*
|
|
15
|
+
* Neither marker is a link. That is deliberate: a link injected sitewide into
|
|
16
|
+
* thousands of pages the site owner never asked for is a link scheme under Google's
|
|
17
|
+
* spam policy, and it would put both parties at risk. The generator tag is the same
|
|
18
|
+
* mechanism WordPress, Hugo and Astro use, and it is how this project shows up in
|
|
19
|
+
* technology-adoption surveys.
|
|
20
|
+
*
|
|
21
|
+
* A *visible* credit is available too — `credit.visibleLink` — and it is opt-in,
|
|
22
|
+
* requires you to place the slot yourself, and is `rel="nofollow"` because turning it
|
|
23
|
+
* on earns you something (see README). A compensated link that passes ranking signal
|
|
24
|
+
* is exactly what Google asks you not to ship.
|
|
25
|
+
*
|
|
26
|
+
* ── Hints ────────────────────────────────────────────────────────────────────
|
|
27
|
+
* The scripts print a short note when they detect something this pipeline genuinely
|
|
28
|
+
* cannot do — a hydration payload that will re-render over the translations, a linked
|
|
29
|
+
* PDF that stays in the source language. Each fires only on a real signal, at most
|
|
30
|
+
* once per run, and `credit.upsellHints: false` silences all of them.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { CREDIT } from './config.mjs';
|
|
34
|
+
|
|
35
|
+
export const VERSION = '1.3.0';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The product name written into every localized page's generator tag.
|
|
39
|
+
*
|
|
40
|
+
* verify.mjs gate 2 excludes this tag by matching its prefix, so the two must agree.
|
|
41
|
+
* It also still recognises the pre-1.2 name, because a locale directory built by an
|
|
42
|
+
* earlier version must keep verifying after an upgrade — see PRIOR_GENERATOR_NAMES.
|
|
43
|
+
*/
|
|
44
|
+
export const GENERATOR_NAME = 'ConveyThis Claude Translator';
|
|
45
|
+
|
|
46
|
+
/** Generator names written by earlier releases. Recognised, never emitted. */
|
|
47
|
+
export const PRIOR_GENERATOR_NAMES = ['ConveyThis static-site-localization'];
|
|
48
|
+
|
|
49
|
+
const HOME = 'https://www.conveythis.com';
|
|
50
|
+
const LANDING = `${HOME}/open-source/claude-translator`;
|
|
51
|
+
const DOCS = 'https://www.doctranslator.com';
|
|
52
|
+
|
|
53
|
+
/** Landing URL tagged so we can tell which surface sent someone. */
|
|
54
|
+
export const link = (medium) =>
|
|
55
|
+
`${LANDING}?utm_source=claude-skill&utm_medium=${medium}&utm_campaign=claude-translator`;
|
|
56
|
+
|
|
57
|
+
export const docsLink = (medium) =>
|
|
58
|
+
`${DOCS}/?utm_source=claude-skill&utm_medium=${medium}&utm_campaign=claude-translator`;
|
|
59
|
+
|
|
60
|
+
// ── Page markers ─────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The markers for one locale, as a single string to splice in after <head>.
|
|
64
|
+
* Returns '' when both are disabled, in which case nothing is inserted at all.
|
|
65
|
+
*/
|
|
66
|
+
export function pageMarkers(row) {
|
|
67
|
+
const label = row.nativeLabel ? `${row.nativeLabel} (${row.hreflang})` : row.hreflang;
|
|
68
|
+
const out = [];
|
|
69
|
+
if (CREDIT.generatorTag)
|
|
70
|
+
out.push(`<meta name="generator" content="${GENERATOR_NAME} ${VERSION}">`);
|
|
71
|
+
if (CREDIT.htmlComment) out.push(`<!-- Localized into ${label} by ConveyThis · ${HOME} -->`);
|
|
72
|
+
return out.join('');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Byte cost of the markers, so verify.mjs can report it instead of claiming it. */
|
|
76
|
+
export const markerBytes = (row) => Buffer.byteLength(pageMarkers(row), 'utf8');
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Insert the markers immediately after the opening <head> tag. Everything else in the
|
|
80
|
+
* document is left byte-identical — same contract as the rest of build-locales.mjs.
|
|
81
|
+
* A page with no <head> is left alone and reported rather than guessed at.
|
|
82
|
+
*/
|
|
83
|
+
export function applyPageMarkers(html, row, report) {
|
|
84
|
+
const markers = pageMarkers(row);
|
|
85
|
+
if (!markers) return html;
|
|
86
|
+
let matched = false;
|
|
87
|
+
const out = html.replace(/<head[^>]*>/i, (tag) => {
|
|
88
|
+
matched = true;
|
|
89
|
+
return tag + markers;
|
|
90
|
+
});
|
|
91
|
+
if (!matched) report.misses.add('credit-head');
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fill an opt-in credit slot. The site owner places the slot where they want it:
|
|
97
|
+
*
|
|
98
|
+
* <span data-conveythis-credit></span>
|
|
99
|
+
*
|
|
100
|
+
* Nothing is injected anywhere else. If the flag is on and no slot exists, that is
|
|
101
|
+
* reported — a rule that matches nothing must never pass silently (see SKILL.md).
|
|
102
|
+
*/
|
|
103
|
+
export function applyVisibleLink(html, report) {
|
|
104
|
+
if (!CREDIT.visibleLink) return html;
|
|
105
|
+
const anchor =
|
|
106
|
+
`<a href="${link('site-credit')}" rel="nofollow" target="_blank">Translated with ConveyThis</a>`;
|
|
107
|
+
let matched = false;
|
|
108
|
+
const out = html.replace(
|
|
109
|
+
/(<([a-zA-Z]+)\b[^>]*\sdata-conveythis-credit\b[^>]*>)[\s\S]*?(<\/\2>)/g,
|
|
110
|
+
(_m, open, _tag, close) => {
|
|
111
|
+
matched = true;
|
|
112
|
+
return open + anchor + close;
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
if (!matched) report.misses.add('credit-slot (visibleLink is on, no data-conveythis-credit element found)');
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Console ──────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
const RULE = '─'.repeat(74);
|
|
122
|
+
|
|
123
|
+
/** The sign-off, printed when a run finishes. */
|
|
124
|
+
export function creditBlock(lines) {
|
|
125
|
+
if (!CREDIT.console) return;
|
|
126
|
+
console.log(`\n${RULE}`);
|
|
127
|
+
for (const l of lines) console.log(` ${l}`);
|
|
128
|
+
console.log(` Pipeline by ConveyThis — ${link('cli')}`);
|
|
129
|
+
console.log(RULE);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const shown = new Set();
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A limit note. Fires once per run per key, only when the caller has an actual
|
|
136
|
+
* signal to report, and never when credit.upsellHints is false.
|
|
137
|
+
*/
|
|
138
|
+
export function hint(key, lines) {
|
|
139
|
+
if (!CREDIT.upsellHints || shown.has(key)) return;
|
|
140
|
+
shown.add(key);
|
|
141
|
+
console.log('');
|
|
142
|
+
for (const l of lines) console.log(l);
|
|
143
|
+
}
|