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,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);
|