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,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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
67
+ const escAttr = (s) => escHtml(s).replace(/"/g, '&quot;');
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
+ }