claude-translator 1.3.0 → 2.0.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/.claude-plugin/plugin.json +14 -0
- package/CHANGELOG.md +249 -0
- package/PRIVACY.md +71 -0
- package/README.md +279 -44
- package/bin/claude-translator.mjs +11 -2
- package/bin/cli.test.mjs +20 -1
- package/glossary.example.json +23 -0
- package/i18n.config.example.json +7 -0
- package/package.json +9 -5
- package/scripts/audit-seo.mjs +6 -3
- package/scripts/build-locales.mjs +55 -6
- package/scripts/config.mjs +66 -0
- package/scripts/credit.mjs +12 -5
- package/scripts/extract.mjs +21 -6
- package/scripts/format-locale.mjs +290 -0
- package/scripts/format-locale.test.mjs +171 -0
- package/scripts/glossary.mjs +229 -0
- package/scripts/glossary.test.mjs +188 -0
- package/scripts/providers/openai.mjs +63 -3
- package/scripts/providers/providers.test.mjs +80 -0
- package/scripts/roles.mjs +142 -0
- package/scripts/roles.test.mjs +140 -0
- package/scripts/tqa-score.mjs +127 -0
- package/scripts/tqa-score.test.mjs +144 -0
- package/scripts/tqa.mjs +449 -0
- package/scripts/translate.mjs +104 -13
- package/scripts/verify.mjs +113 -5
- package/{SKILL.md → skills/translate-site/SKILL.md} +45 -13
- package/{references → skills/translate-site/references}/providers.md +16 -2
- package/{references → skills/translate-site/references}/quality-review.md +28 -0
- /package/{references → skills/translate-site/references}/adapting-generators.md +0 -0
- /package/{references → skills/translate-site/references}/failure-modes.md +0 -0
- /package/{references → skills/translate-site/references}/throughput-and-cost.md +0 -0
package/README.md
CHANGED
|
@@ -8,13 +8,14 @@ static pages — without re-rendering it, and without breaking Core Web Vitals.*
|
|
|
8
8
|
[](#using-it-as-a-claude-code-skill)
|
|
9
9
|
|
|
10
10
|
Point it at a built site, give it a list of locales and your own model API key, and it
|
|
11
|
-
produces a complete localized copy of every page —
|
|
12
|
-
`dir="rtl"
|
|
13
|
-
full SEO audit.
|
|
11
|
+
produces a complete localized copy of every page — correcting `hreflang`, canonicals,
|
|
12
|
+
`dir="rtl"` and per-locale JSON-LD as it goes — then proves the result with eight gates and
|
|
13
|
+
a full SEO audit. It holds your terminology, protects your brand names, and writes numbers
|
|
14
|
+
the way each locale writes them.
|
|
14
15
|
|
|
15
16
|
Built and maintained by **[ConveyThis](https://www.conveythis.com/open-source/claude-translator?utm_source=claude-skill&utm_medium=readme-byline&utm_campaign=claude-translator)**.
|
|
16
17
|
This is the pipeline that runs **www.conveythis.com itself** — a 238-page Astro site,
|
|
17
|
-
live in 55 languages, on the same
|
|
18
|
+
live in 55 languages, on the same scripts in this repo.
|
|
18
19
|
|
|
19
20
|
> Not affiliated with or endorsed by Anthropic. "Claude" is a trademark of Anthropic, PBC.
|
|
20
21
|
> This project is named for the model family it ships configured to use; it works just as
|
|
@@ -121,7 +122,8 @@ against each other rather than against a number in a README.
|
|
|
121
122
|
|
|
122
123
|
> This project ships **no API key and makes no calls on your behalf**. Your key is read
|
|
123
124
|
> from your environment, used to call your chosen provider directly from your machine, and
|
|
124
|
-
> never transmitted anywhere else. There is no telemetry.
|
|
125
|
+
> never transmitted anywhere else. There is no telemetry — see [PRIVACY.md](PRIVACY.md),
|
|
126
|
+
> which shows how to verify that against the source.
|
|
125
127
|
|
|
126
128
|
---
|
|
127
129
|
|
|
@@ -144,8 +146,9 @@ npm run build # your normal build
|
|
|
144
146
|
node scripts/i18n/extract.mjs # find translatable units
|
|
145
147
|
node scripts/i18n/translate.mjs --lang es,fr,de # translate (uses your key)
|
|
146
148
|
node scripts/i18n/build-locales.mjs --lang all # write localized pages
|
|
147
|
-
node scripts/i18n/verify.mjs --lang all #
|
|
149
|
+
node scripts/i18n/verify.mjs --lang all # eight gates
|
|
148
150
|
node scripts/i18n/audit-seo.mjs # full SEO audit
|
|
151
|
+
node scripts/i18n/tqa.mjs --lang es # MQM quality score (optional)
|
|
149
152
|
```
|
|
150
153
|
|
|
151
154
|
Deploy the resulting build directory exactly as you deploy it today.
|
|
@@ -215,6 +218,8 @@ read, and this is AGPL software whose point is that you can change them.
|
|
|
215
218
|
| `pages.source` | `"build"` to derive from output, or a path to a slug list |
|
|
216
219
|
| `pages.exclude` | First path segments never to localize — 404 pages, CMS admin shells |
|
|
217
220
|
| `doNotTranslate` | Brand names and formats that must survive unchanged |
|
|
221
|
+
| `glossary` | Term base — an array, or a path to a JSON file. See [Terminology](#terminology-and-brand-names) |
|
|
222
|
+
| `localeFormat` | Number, percent and currency **formatting**. See [Numbers and money](#numbers-and-money) |
|
|
218
223
|
| `provider` | `anthropic` (default), `gemini`, `openai`, or a path to your own adapter |
|
|
219
224
|
| `model` | Any model id for that provider — defaults to the provider's own |
|
|
220
225
|
| `apiBaseUrl` | Model API host. Set this for local models, Azure or a gateway. **Not** `baseUrl`, which is your site |
|
|
@@ -228,24 +233,163 @@ read, and this is AGPL software whose point is that you can change them.
|
|
|
228
233
|
|
|
229
234
|
## What you get
|
|
230
235
|
|
|
231
|
-
|
|
236
|
+
**Read this section as "corrected", not "created".** With one exception, this tool does not
|
|
237
|
+
add tags to your pages — it rewrites the values on tags your template already emits. That is
|
|
238
|
+
deliberate: inserting markup would break the byte-identical guarantee that
|
|
239
|
+
[Does it hold?](#does-it-hold) rests on. It also means **your template has to emit the tags
|
|
240
|
+
in the first place**, and the two lists below are the difference between what we fix and
|
|
241
|
+
what you must supply.
|
|
242
|
+
|
|
243
|
+
### Rewritten for you, on every page, in every locale
|
|
244
|
+
|
|
245
|
+
- `<html lang>`, and `dir` flipped to `rtl` where the script requires it — the `lang` and
|
|
246
|
+
`dir` **attributes must already be present** on `<html>`; the value is replaced, never added
|
|
247
|
+
- The canonical `href` → this locale's page, `https`, no trailing slash
|
|
248
|
+
- The `x-default` and source-language `hreflang` hrefs → pointed at the **original** page,
|
|
249
|
+
not at self. These two are the ones people get wrong; they are also **the only two
|
|
250
|
+
`hreflang` links this tool touches**
|
|
251
|
+
- `og:url` / `og:locale` `content` values
|
|
252
|
+
- JSON-LD `@id`, `url` and `inLanguage`, per locale — with `Organization` left alone,
|
|
253
|
+
because a company is one entity in every language, and anything unparseable returned
|
|
254
|
+
byte-for-byte
|
|
255
|
+
- Internal links, locale-prefixed — assets and anchors left alone
|
|
256
|
+
- Your language picker's current-language label and `aria-current`, if it ships the
|
|
257
|
+
`data-i18n-current-lang` / `data-i18n-lang` markers
|
|
258
|
+
|
|
259
|
+
The only thing ever *inserted* is the ~150-byte attribution described under
|
|
260
|
+
[Attribution](#attribution), and you can turn it off.
|
|
261
|
+
|
|
262
|
+
### Your template must supply these — we audit them, we do not generate them
|
|
263
|
+
|
|
264
|
+
- **The full `hreflang` mesh.** One `<link rel="alternate">` per locale, plus `x-default`
|
|
265
|
+
and the source language. We rewrite two of those hrefs and check all of them; we emit
|
|
266
|
+
none of them. A site with no alternates gets no alternates.
|
|
267
|
+
- **`sitemap.xml`, and any per-locale sitemaps it indexes.** `audit-seo.mjs` validates that
|
|
268
|
+
every sitemap lists the right URLs, that they are `https`, and that each resolves to a
|
|
269
|
+
built page — but nothing in this repo writes a sitemap file.
|
|
270
|
+
- **`robots.txt`.** Never read, never written.
|
|
271
|
+
- **`og:image`, `twitter:card`, `twitter:site`.** Left untouched. The *text* fields
|
|
272
|
+
(`og:title`, `og:description`, `twitter:title`, image `alt`) are translated.
|
|
273
|
+
|
|
274
|
+
**Nothing here fails silently.** Every rewrite rule reports when it matched nothing, so a
|
|
275
|
+
template change turns into a printed miss rather than a quiet no-op, and `audit-seo.mjs`
|
|
276
|
+
checks the whole mesh on *every* page rather than a sample. If your template is missing the
|
|
277
|
+
alternates, you will hear about it on the first run — see
|
|
278
|
+
[`references/adapting-generators.md`](skills/translate-site/references/adapting-generators.md).
|
|
279
|
+
|
|
280
|
+
## Terminology and brand names
|
|
281
|
+
|
|
282
|
+
Two different problems, one file.
|
|
283
|
+
|
|
284
|
+
**Consistency.** Identical strings are already consistent for free: units are keyed by the
|
|
285
|
+
hash of the source text, so a header translated once is reused on every page and across
|
|
286
|
+
runs. What that cannot do is hold a *term inside varying sentences* — "Dashboard" in two
|
|
287
|
+
different paragraphs is two different units, in two different batches, in two stateless
|
|
288
|
+
requests. A glossary fixes that.
|
|
289
|
+
|
|
290
|
+
**Sense.** `Apple` the company must survive; `apple` the fruit must be translated. A flat
|
|
291
|
+
list of names cannot express the difference.
|
|
232
292
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
293
|
+
```json
|
|
294
|
+
// glossary.json
|
|
295
|
+
[
|
|
296
|
+
{ "source": "Acme", "rule": "keep", "matchCase": true },
|
|
297
|
+
{ "source": "Dashboard", "rule": "translate",
|
|
298
|
+
"targets": { "es": "Panel de control", "pt-br": "Painel" } }
|
|
299
|
+
]
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
| Field | Meaning |
|
|
303
|
+
| --- | --- |
|
|
304
|
+
| `rule: "keep"` | Leave it in the source language. Defaults to **case-sensitive** |
|
|
305
|
+
| `rule: "translate"` | Pin the wording per locale via `targets` |
|
|
306
|
+
| `matchCase` | Override the default. `true` means `Apple` is protected and `apple` is not |
|
|
307
|
+
| `note` | Free text, passed to the translator and to the quality judge as context |
|
|
308
|
+
|
|
309
|
+
Matching is **whole-word**, always — `Apple` never matches inside `Applesauce` or
|
|
310
|
+
`Appleton`. Only the terms that actually occur in a batch are sent to the model, so a
|
|
311
|
+
500-term glossary does not inflate the prompt of every request.
|
|
312
|
+
|
|
313
|
+
Your existing `doNotTranslate.brands` is folded in automatically as case-sensitive `keep`
|
|
314
|
+
rules, so upgrading gains you word-boundary matching and, for the first time, **gate 7**,
|
|
315
|
+
which checks the terms actually survived. Before 2.0 nothing verified that: a brand could
|
|
316
|
+
be translated away and every gate still passed.
|
|
317
|
+
|
|
318
|
+
Editing a glossary target re-translates **only** the units containing that term. A
|
|
319
|
+
fingerprint sidecar next to the memory records what it was built against.
|
|
320
|
+
|
|
321
|
+
## Register: buttons are not paragraphs
|
|
322
|
+
|
|
323
|
+
The prompt has always ended with *"Headings stay headings; button labels stay short."* Until
|
|
324
|
+
2.0 the model had no way to obey it — it received the string and nothing else, so a button
|
|
325
|
+
label and a body paragraph were indistinguishable.
|
|
326
|
+
|
|
327
|
+
The extractor always knew the answer and discarded it. Now each unit carries a short label
|
|
328
|
+
where the answer changes the translation:
|
|
329
|
+
|
|
330
|
+
| The string came from | It is told |
|
|
331
|
+
| --- | --- |
|
|
332
|
+
| `<button>`, or an `<a>` standing on its own | a control — keep it near the source length, no final period |
|
|
333
|
+
| `<h1>`–`<h6>` | a headline — do not expand it into a sentence |
|
|
334
|
+
| `<title>` | the tab and search-result title |
|
|
335
|
+
| `<label>`, `<th>`, `<option>` | short, nominal furniture |
|
|
336
|
+
| `alt`, `placeholder`, `aria-label` | described for a screen reader, or shown inside an empty field |
|
|
337
|
+
| `<meta name="description">` | search-result copy, roughly 155 characters |
|
|
338
|
+
|
|
339
|
+
Ordinary prose carries **no label at all**, so a site of nothing but paragraphs sends a
|
|
340
|
+
payload byte-identical to 1.x and pays nothing for the feature. The prompt describes only the
|
|
341
|
+
roles that actually appear in each batch.
|
|
342
|
+
|
|
343
|
+
Two things it deliberately does not do. It does **not** demand the imperative for buttons —
|
|
344
|
+
German UI prefers a verbal noun, French the infinitive, and ordering a literal command in
|
|
345
|
+
every language is the defect this exists to prevent; it tells the model to use whatever
|
|
346
|
+
construction that language puts on buttons. And when the same string appears as both a button
|
|
347
|
+
and a paragraph it **clears** the hint rather than guessing, because one hash means one
|
|
348
|
+
translation and a confident wrong answer is worse than none.
|
|
349
|
+
|
|
350
|
+
There is no length *enforcement* — no character budget, no retry on overflow. Failed units
|
|
351
|
+
ship in the source language, so a hard gate here would replace a slightly-long German button
|
|
352
|
+
with an English one. That is a worse page.
|
|
353
|
+
|
|
354
|
+
## Numbers and money
|
|
355
|
+
|
|
356
|
+
`1,234.56` is `1.234,56` in German and `1 234,56` in French. `$5` is `5,00 $US` in French.
|
|
357
|
+
Getting this wrong is one of the most visible marks of a machine translation, and models
|
|
358
|
+
are unreliable at it — so the model is told to leave numbers **alone** (rule 4), and the
|
|
359
|
+
formatting is applied deterministically afterwards with `Intl`.
|
|
360
|
+
|
|
361
|
+
```json
|
|
362
|
+
"localeFormat": { "numbers": true, "percent": true, "currency": "format", "units": "off" }
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
**Currency is formatted, never converted, and there is no option to convert it.** A price
|
|
366
|
+
is a commercial commitment. Converting one at a rate baked into a build — a rate that is
|
|
367
|
+
stale the day after it is written — is how a translation tool starts publishing wrong
|
|
368
|
+
offers. What you get instead is `i18n/locale-format.json`, listing every monetary amount
|
|
369
|
+
found, so a human can decide per market.
|
|
370
|
+
|
|
371
|
+
**Gate 8 backs this up**: if the *value* of a number changes between source and
|
|
372
|
+
translation, the build fails. A model that quietly ships `$39` where the source said `$49`
|
|
373
|
+
passes every other check — the markup is identical, the placeholders match, the length is
|
|
374
|
+
plausible and the Spanish is fluent.
|
|
375
|
+
|
|
376
|
+
Things it deliberately leaves alone, because a "fix" here is a corruption: version numbers
|
|
377
|
+
(`Node 20.5.1`), times (`10:30`), IP addresses, ISO dates, phone numbers, fractions, and
|
|
378
|
+
any ungrouped number. Only unambiguous quantities are touched.
|
|
379
|
+
|
|
380
|
+
Unit conversion (in→cm, °F→°C) is **not implemented**. The `units` key is accepted and
|
|
381
|
+
ignored so a config written today keeps parsing when it lands.
|
|
382
|
+
|
|
383
|
+
> One thing that looks like a bug and is not: Spanish does not group four-digit numbers,
|
|
384
|
+
> so `1,234.50` correctly becomes `1234,50` in `es` and `1.234,50` in `de`. That is CLDR,
|
|
385
|
+
> and there is a test pinning it.
|
|
242
386
|
|
|
243
387
|
## Trusting the output
|
|
244
388
|
|
|
245
389
|
Machine translation at scale fails in ways that look like success. Two commands exist to
|
|
246
390
|
catch that.
|
|
247
391
|
|
|
248
|
-
**`verify.mjs` —
|
|
392
|
+
**`verify.mjs` — eight gates**
|
|
249
393
|
|
|
250
394
|
| # | Gate | Catches |
|
|
251
395
|
| --- | --- | --- |
|
|
@@ -255,6 +399,8 @@ catch that.
|
|
|
255
399
|
| 4 | Locale identity | wrong `lang`, canonical, `hreflang` or JSON-LD |
|
|
256
400
|
| 5 | Coverage | share of extracted segments present in the memory |
|
|
257
401
|
| 6 | **Never offered** | visible text the extractor never picked up |
|
|
402
|
+
| 7 | Glossary | a protected brand that got translated, or a pinned term rendered some other way |
|
|
403
|
+
| 8 | Numeric integrity | a number whose **value** changed — `$49` shipped as `$39` |
|
|
258
404
|
|
|
259
405
|
Gate 6 exists because **coverage is not completeness**. Coverage measures
|
|
260
406
|
translated-of-*extracted*, so it is structurally blind to extraction bugs. A single bug has
|
|
@@ -264,9 +410,45 @@ untranslated. Gate 6 compares built output against the source instead.
|
|
|
264
410
|
**`audit-seo.mjs`** then checks canonicals, the full hreflang mesh, `og` tags, JSON-LD and
|
|
265
411
|
sitemaps across *every* page — not a sample.
|
|
266
412
|
|
|
413
|
+
**`tqa.mjs` — a quality score you can reproduce**
|
|
414
|
+
|
|
415
|
+
The gates prove the plumbing. They say nothing about whether the Spanish is any *good*.
|
|
416
|
+
That is what `tqa.mjs` is for.
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
node scripts/i18n/tqa.mjs --lang es --dry # sample size and cost, no API call
|
|
420
|
+
node scripts/i18n/tqa.mjs --lang es,fr,de # writes i18n/tqa/{lang}.json + scorecard.md
|
|
421
|
+
node scripts/i18n/tqa.mjs --lang es --repeat # judge the same sample twice, report the gap
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
It scores a **stratified sample** — weighted by how often each string appears on the site,
|
|
425
|
+
so the header everyone reads counts for more than a one-off footnote — using the **MQM**
|
|
426
|
+
error typology the localization industry already uses:
|
|
427
|
+
|
|
428
|
+
```
|
|
429
|
+
score = 100 − (weighted error points ÷ words) × 100 minor 1 · major 5 · critical 10
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Four things make the number honest rather than decorative:
|
|
433
|
+
|
|
434
|
+
- **The judge defaults to a different provider than the translator.** Models prefer their
|
|
435
|
+
own output. If no second key is configured it says so, loudly, in the run and in the
|
|
436
|
+
report.
|
|
437
|
+
- **The sample is seeded.** `--seed` reproduces a score exactly. A quality figure nobody
|
|
438
|
+
can re-derive is a marketing figure.
|
|
439
|
+
- **`--repeat` reports the judge's own variance** by scoring the same sample twice. A score
|
|
440
|
+
quoted without its noise invites people to over-read a decimal place.
|
|
441
|
+
- **A unit the judge cannot assess is excluded, not counted as clean.** During development
|
|
442
|
+
a misconfigured endpoint failed every single unit and the run printed `100.00 / 100` from
|
|
443
|
+
an empty sample. It now refuses to report a score at all in that case.
|
|
444
|
+
|
|
445
|
+
Read it as a comparison — between locales, between models, between runs — and not as a
|
|
446
|
+
grade. It is one model's opinion of another's work, it is not a human review, and the
|
|
447
|
+
report says so on its face.
|
|
448
|
+
|
|
267
449
|
**`review.mjs`** flags likely translation defects: dropped placeholders, wholesale
|
|
268
450
|
source-language returns, wrong target language, truncated output. Read
|
|
269
|
-
[`references/quality-review.md`](references/quality-review.md) before acting on its output —
|
|
451
|
+
[`skills/translate-site/references/quality-review.md`](skills/translate-site/references/quality-review.md) before acting on its output —
|
|
270
452
|
purging is destructive and its heuristics have known blind spots.
|
|
271
453
|
|
|
272
454
|
---
|
|
@@ -276,16 +458,49 @@ purging is destructive and its heuristics have known blind spots.
|
|
|
276
458
|
The translation step is the only part that talks to a model, and it talks through a small
|
|
277
459
|
adapter. Three ship with the project, and anything else is one file.
|
|
278
460
|
|
|
279
|
-
| `provider` | Default model | Key |
|
|
280
|
-
| --- | --- | --- |
|
|
281
|
-
| `anthropic` *(default)* | `claude-haiku-4-5` | `ANTHROPIC_API_KEY` |
|
|
282
|
-
| `gemini` | `gemini-2.5-flash-lite` | `GEMINI_API_KEY` |
|
|
283
|
-
| `openai` | `gpt-
|
|
284
|
-
| `./my-provider.mjs` | — | yours |
|
|
461
|
+
| `provider` | Default model | Key | Last verified against the live API |
|
|
462
|
+
| --- | --- | --- | --- |
|
|
463
|
+
| `anthropic` *(default)* | `claude-haiku-4-5` | `ANTHROPIC_API_KEY` | 2026-08-25 |
|
|
464
|
+
| `gemini` | `gemini-2.5-flash-lite` | `GEMINI_API_KEY` | 2026-08-25 |
|
|
465
|
+
| `openai` | `gpt-5.6-luna` | `OPENAI_API_KEY` | 2026-08-25 |
|
|
466
|
+
| `./my-provider.mjs` | — | yours | — |
|
|
285
467
|
|
|
286
468
|
Omit `provider` and it is inferred from the model id, so configs written before 1.2 keep
|
|
287
469
|
working unchanged.
|
|
288
470
|
|
|
471
|
+
**What "verified" means in that last column.** On 2026-08-25 each of the three adapters
|
|
472
|
+
translated the same 98-unit, 1,381-word English site into Russian at its default model, as a
|
|
473
|
+
real billed API call, with the translation memory deleted between runs so no provider could
|
|
474
|
+
reuse another's work. Every run cleared all gates in `verify.mjs` and produced a clean
|
|
475
|
+
`audit-seo.mjs` — 0 findings — and no run had a single failed unit. Measured cost: $0.025
|
|
476
|
+
(Claude), $0.002 (Gemini), and 3,453/2,855 tokens on OpenAI, which the adapter deliberately
|
|
477
|
+
does not price because it points at dozens of endpoints, some of them free and local.
|
|
478
|
+
|
|
479
|
+
The three memories agreed on only 28–36% of units pairwise, and the ones all three agreed on
|
|
480
|
+
were short labels like "Три плана". That divergence is the evidence the runs were independent.
|
|
481
|
+
|
|
482
|
+
This column is a freshness marker, not a guarantee. Model ids get retired; if a default stops
|
|
483
|
+
working, that is what this date is for. The contract tests in `scripts/providers/` still run
|
|
484
|
+
on every commit with no network and no key — they catch a malformed request, not a rejected
|
|
485
|
+
one.
|
|
486
|
+
|
|
487
|
+
**Reasoning models and `temperature`.** The `openai` default is a reasoning model, and those
|
|
488
|
+
reject sampling parameters — GPT-5.x answers a `temperature: 0.2` with
|
|
489
|
+
`400 Unsupported value: 'temperature' … Only the default (1) value is supported`. The adapter
|
|
490
|
+
handles this twice over: it omits `temperature` and sends `reasoning_effort: 'none'` for model
|
|
491
|
+
ids it recognises as reasoning models, and if a server rejects a parameter anyway, the run drops
|
|
492
|
+
that one parameter and retries instead of failing. A pinned `gpt-4o-mini`, and every local model,
|
|
493
|
+
still get `temperature` exactly as before.
|
|
494
|
+
|
|
495
|
+
`reasoning_effort` is `'none'` because bulk segment translation is a low-reasoning task — the
|
|
496
|
+
same reason the Anthropic adapter pins its thinking tiers to `effort: 'low'`. Paying for a
|
|
497
|
+
reasoning pass on every batch of forty segments is the one cost here worth engineering away.
|
|
498
|
+
|
|
499
|
+
**Pricing.** `gpt-5.6-luna` is $0.20 in / $1.20 out per million tokens. The adapter reports no
|
|
500
|
+
cost, deliberately — `pricing()` cannot see `baseUrl`, so it cannot tell OpenAI itself from
|
|
501
|
+
OpenRouter or a local server offering the same model id. Set `pricing` in `i18n.config.json` to
|
|
502
|
+
get a figure in the run summary.
|
|
503
|
+
|
|
289
504
|
**The `openai` adapter is the interesting one**, because `/v1/chat/completions` is what
|
|
290
505
|
everything speaks. That one adapter covers OpenAI, Azure, Groq, DeepSeek, Mistral,
|
|
291
506
|
OpenRouter, Together and Fireworks — and Ollama, LM Studio and vLLM, which means the whole
|
|
@@ -303,9 +518,9 @@ No key, no quota, no request leaving the machine.
|
|
|
303
518
|
|
|
304
519
|
**Claude is the default, not a requirement.** It is roughly ten times the cost of the
|
|
305
520
|
Gemini option, which is a real difference on a large site and is spelled out in
|
|
306
|
-
[`references/throughput-and-cost.md`](references/throughput-and-cost.md). Changing it is
|
|
521
|
+
[`skills/translate-site/references/throughput-and-cost.md`](skills/translate-site/references/throughput-and-cost.md). Changing it is
|
|
307
522
|
one line. Writing your own adapter is about thirty — see
|
|
308
|
-
[`references/providers.md`](references/providers.md).
|
|
523
|
+
[`skills/translate-site/references/providers.md`](skills/translate-site/references/providers.md).
|
|
309
524
|
|
|
310
525
|
---
|
|
311
526
|
|
|
@@ -343,7 +558,7 @@ Turn either off in `i18n.config.json`:
|
|
|
343
558
|
Setting all five to `false` produces output with no trace of us in it, and nothing anywhere
|
|
344
559
|
in this repo checks whether you did.
|
|
345
560
|
|
|
346
|
-
### The visible credit is opt-in
|
|
561
|
+
### The visible credit is opt-in
|
|
347
562
|
|
|
348
563
|
If you *want* to show a credit, set `visibleLink: true` and place the slot yourself, wherever
|
|
349
564
|
you want it:
|
|
@@ -353,12 +568,14 @@ you want it:
|
|
|
353
568
|
```
|
|
354
569
|
|
|
355
570
|
Nothing is injected anywhere else, and if the flag is on and no slot exists the build tells
|
|
356
|
-
you rather than guessing.
|
|
357
|
-
|
|
571
|
+
you rather than guessing. Nothing is asked of you for it and nothing is given in return — it
|
|
572
|
+
exists because some people want to credit the tools they use, and for no other reason.
|
|
358
573
|
|
|
359
|
-
The link is `rel="nofollow"`.
|
|
360
|
-
|
|
361
|
-
|
|
574
|
+
The link is `rel="nofollow"`. Not because it is paid — it is not — but because it is a link a
|
|
575
|
+
build script would otherwise add across every page of a site, and sitewide links that appear
|
|
576
|
+
because of tooling rather than editorial choice are the shape Google's link-scheme guidance is
|
|
577
|
+
aimed at. It is worth referral traffic, not backlinks, and anyone telling you otherwise is
|
|
578
|
+
selling you a penalty.
|
|
362
579
|
|
|
363
580
|
---
|
|
364
581
|
|
|
@@ -383,17 +600,34 @@ needs a build to hook and files to write. Where that shape doesn't fit,
|
|
|
383
600
|
|
|
384
601
|
---
|
|
385
602
|
|
|
386
|
-
## Using it as a Claude Code
|
|
603
|
+
## Using it as a Claude Code plugin
|
|
604
|
+
|
|
605
|
+
This repo is also a self-contained [Claude Code](https://claude.com/claude-code) plugin, named
|
|
606
|
+
`conveythis-translator`. Once it is approved for the community directory:
|
|
607
|
+
|
|
608
|
+
```bash
|
|
609
|
+
/plugin marketplace add anthropics/claude-plugins-community
|
|
610
|
+
/plugin install conveythis-translator@claude-community
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Plugin skills are namespaced, so it is invoked as `/conveythis-translator:translate-site` — or
|
|
614
|
+
just ask Claude to "localize this site" and it will pick the skill up on its own. It follows
|
|
615
|
+
`skills/translate-site/SKILL.md`, including the failure modes in `references/` and the routing
|
|
616
|
+
rules for when this is the wrong tool entirely.
|
|
387
617
|
|
|
388
|
-
|
|
618
|
+
To run it before it is listed, clone the repo anywhere and point Claude Code at it:
|
|
389
619
|
|
|
390
620
|
```bash
|
|
391
|
-
git clone https://github.com/ConveyThis/claude-translator.git
|
|
392
|
-
|
|
621
|
+
git clone https://github.com/ConveyThis/claude-translator.git
|
|
622
|
+
claude --plugin-dir ./claude-translator
|
|
393
623
|
```
|
|
394
624
|
|
|
395
|
-
|
|
396
|
-
|
|
625
|
+
To keep it permanently available without installing, symlink the **skill directory itself** into
|
|
626
|
+
your personal skills folder — it is self-contained, references included:
|
|
627
|
+
|
|
628
|
+
```bash
|
|
629
|
+
ln -s "$PWD/claude-translator/skills/translate-site" ~/.claude/skills/translate-site
|
|
630
|
+
```
|
|
397
631
|
|
|
398
632
|
---
|
|
399
633
|
|
|
@@ -401,18 +635,19 @@ Then ask Claude to "localize this site". It follows `SKILL.md`, including the fa
|
|
|
401
635
|
|
|
402
636
|
| Document | Read it when |
|
|
403
637
|
| --- | --- |
|
|
404
|
-
| [`references/failure-modes.md`](references/failure-modes.md) | **Before modifying any script.** Every known bug: symptom → cause → fix |
|
|
405
|
-
| [`references/quality-review.md`](references/quality-review.md) | Before purging anything the reviewer flags |
|
|
406
|
-
| [`references/throughput-and-cost.md`](references/throughput-and-cost.md) | Budgeting a run, or making it faster |
|
|
407
|
-
| [`references/adapting-generators.md`](references/adapting-generators.md) | Using anything other than Astro |
|
|
408
|
-
| [`references/providers.md`](references/providers.md) | Changing model, running locally, or writing an adapter |
|
|
638
|
+
| [`skills/translate-site/references/failure-modes.md`](skills/translate-site/references/failure-modes.md) | **Before modifying any script.** Every known bug: symptom → cause → fix |
|
|
639
|
+
| [`skills/translate-site/references/quality-review.md`](skills/translate-site/references/quality-review.md) | Before purging anything the reviewer flags, or reading a TQA score |
|
|
640
|
+
| [`skills/translate-site/references/throughput-and-cost.md`](skills/translate-site/references/throughput-and-cost.md) | Budgeting a run, or making it faster |
|
|
641
|
+
| [`skills/translate-site/references/adapting-generators.md`](skills/translate-site/references/adapting-generators.md) | Using anything other than Astro |
|
|
642
|
+
| [`skills/translate-site/references/providers.md`](skills/translate-site/references/providers.md) | Changing model, running locally, or writing an adapter |
|
|
643
|
+
| [`PRIVACY.md`](PRIVACY.md) | You want to know exactly what leaves your machine, and how to check |
|
|
409
644
|
| [`LICENSING.md`](LICENSING.md) | You are wrapping a modified copy in a hosted service |
|
|
410
645
|
|
|
411
646
|
## Supported generators
|
|
412
647
|
|
|
413
648
|
Anything that emits static HTML: **Astro**, **Next.js** (`output: 'export'`), **Hugo**,
|
|
414
649
|
**Eleventy**, **Jekyll**, **Gatsby**, or hand-written HTML. See
|
|
415
|
-
[`references/adapting-generators.md`](references/adapting-generators.md) for per-generator
|
|
650
|
+
[`skills/translate-site/references/adapting-generators.md`](skills/translate-site/references/adapting-generators.md) for per-generator
|
|
416
651
|
notes — particularly around hydration payloads, which can re-render over your translations.
|
|
417
652
|
|
|
418
653
|
## Contributing
|
|
@@ -59,11 +59,13 @@ ${c.bold}OPTIONS${c.reset}
|
|
|
59
59
|
${c.bold}AFTER INIT${c.reset}
|
|
60
60
|
npm install install parse5, the only dependency
|
|
61
61
|
\$EDITOR i18n.config.json set baseUrl, locales, provider
|
|
62
|
+
\$EDITOR glossary.json protect brands, pin terminology
|
|
62
63
|
node <dir>/extract.mjs find translatable units
|
|
63
64
|
node <dir>/translate.mjs --lang es,fr translate
|
|
64
65
|
node <dir>/build-locales.mjs --lang all write the localized pages
|
|
65
|
-
node <dir>/verify.mjs --lang all
|
|
66
|
+
node <dir>/verify.mjs --lang all eight gates
|
|
66
67
|
node <dir>/audit-seo.mjs full SEO audit
|
|
68
|
+
node <dir>/tqa.mjs --lang es MQM quality score
|
|
67
69
|
|
|
68
70
|
${c.bold}DOCS${c.reset} https://github.com/ConveyThis/claude-translator
|
|
69
71
|
`;
|
|
@@ -132,17 +134,22 @@ for (const entry of readdirSync(srcDir)) {
|
|
|
132
134
|
const from = join(srcDir, entry);
|
|
133
135
|
if (statSync(from).isDirectory()) {
|
|
134
136
|
for (const sub of readdirSync(from)) {
|
|
137
|
+
if (/\.test\.mjs$/.test(sub)) continue;
|
|
135
138
|
place(join(from, sub), join(targetDir, entry, sub));
|
|
136
139
|
}
|
|
137
140
|
continue;
|
|
138
141
|
}
|
|
139
142
|
if (!/\.(mjs|sh)$/.test(entry)) continue;
|
|
143
|
+
// Contract tests belong to this repo, not to the user's project — they import a test
|
|
144
|
+
// runner and assert on our own internals.
|
|
145
|
+
if (/\.test\.mjs$/.test(entry)) continue;
|
|
140
146
|
place(from, join(targetDir, entry));
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
// ── 2. The config ────────────────────────────────────────────────────────────
|
|
144
150
|
|
|
145
151
|
place(join(PKG_ROOT, 'i18n.config.example.json'), join(CWD, 'i18n.config.json'), 'i18n.config.json');
|
|
152
|
+
place(join(PKG_ROOT, 'glossary.example.json'), join(CWD, 'glossary.json'), 'glossary.json');
|
|
146
153
|
|
|
147
154
|
// ── 3. The one dependency ────────────────────────────────────────────────────
|
|
148
155
|
// Written into package.json rather than installed here: running npm from inside npx is
|
|
@@ -209,13 +216,15 @@ if (skipped.length) {
|
|
|
209
216
|
const rows = [
|
|
210
217
|
...(needsInstall ? [['npm install', 'parse5, the only dependency']] : []),
|
|
211
218
|
['$EDITOR i18n.config.json', 'baseUrl, locales, provider'],
|
|
219
|
+
['$EDITOR glossary.json', 'brands to protect, terms to pin (optional)'],
|
|
212
220
|
[],
|
|
213
221
|
['npm run build', 'your normal build, source language only'],
|
|
214
222
|
[`node ${DIR}/extract.mjs`, 'find translatable units'],
|
|
215
223
|
[`node ${DIR}/translate.mjs --lang es,fr`, 'translate (needs a provider key)'],
|
|
216
224
|
[`node ${DIR}/build-locales.mjs --lang all`, 'write the localized pages'],
|
|
217
|
-
[`node ${DIR}/verify.mjs --lang all`, '
|
|
225
|
+
[`node ${DIR}/verify.mjs --lang all`, 'eight gates'],
|
|
218
226
|
[`node ${DIR}/audit-seo.mjs`, 'full SEO audit'],
|
|
227
|
+
[`node ${DIR}/tqa.mjs --lang es`, 'translation quality score (optional)'],
|
|
219
228
|
];
|
|
220
229
|
const width = Math.max(...rows.filter((r) => r.length).map(([cmd]) => cmd.length)) + 2;
|
|
221
230
|
|
package/bin/cli.test.mjs
CHANGED
|
@@ -90,7 +90,12 @@ test('init scaffolds a runnable pipeline', () => {
|
|
|
90
90
|
'scripts/i18n/credit.mjs',
|
|
91
91
|
'scripts/i18n/providers/index.mjs',
|
|
92
92
|
'scripts/i18n/providers/anthropic.mjs',
|
|
93
|
+
'scripts/i18n/glossary.mjs',
|
|
94
|
+
'scripts/i18n/format-locale.mjs',
|
|
95
|
+
'scripts/i18n/tqa.mjs',
|
|
96
|
+
'scripts/i18n/tqa-score.mjs',
|
|
93
97
|
'i18n.config.json',
|
|
98
|
+
'glossary.json',
|
|
94
99
|
]) {
|
|
95
100
|
assert.ok(existsSync(join(cwd, f)), `init did not write ${f}`);
|
|
96
101
|
assert.ok(stdout.includes(f.split('/').pop()), `init wrote ${f} without reporting it`);
|
|
@@ -157,9 +162,23 @@ test('an existing .gitignore is appended to, not replaced', () => {
|
|
|
157
162
|
|
|
158
163
|
test('package.json "files" ships everything init needs to copy', () => {
|
|
159
164
|
const root = resolve(dirname(CLI), '..');
|
|
160
|
-
for (const needed of ['bin', 'scripts', 'i18n.config.example.json']) {
|
|
165
|
+
for (const needed of ['bin', 'scripts', 'i18n.config.example.json', 'glossary.example.json']) {
|
|
161
166
|
assert.ok(PKG.files.includes(needed), `"files" omits ${needed}; npx would install a broken package`);
|
|
162
167
|
}
|
|
163
168
|
assert.equal(PKG.bin['claude-translator'], 'bin/claude-translator.mjs');
|
|
164
169
|
assert.ok(existsSync(join(root, PKG.bin['claude-translator'])), 'bin path does not exist');
|
|
165
170
|
});
|
|
171
|
+
|
|
172
|
+
test('init does not vendor our own contract tests into the user project', () => {
|
|
173
|
+
// scripts/*.test.mjs import node:test and assert on internals. Copying them into
|
|
174
|
+
// someone else's repo puts failing, irrelevant tests in their suite.
|
|
175
|
+
const { cwd } = run(['init']);
|
|
176
|
+
const leaked = [
|
|
177
|
+
'scripts/i18n/glossary.test.mjs',
|
|
178
|
+
'scripts/i18n/format-locale.test.mjs',
|
|
179
|
+
'scripts/i18n/tqa-score.test.mjs',
|
|
180
|
+
'scripts/i18n/providers/providers.test.mjs',
|
|
181
|
+
].filter((f) => existsSync(join(cwd, f)));
|
|
182
|
+
assert.deepEqual(leaked, [], `init leaked test files: ${leaked.join(', ')}`);
|
|
183
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
184
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"source": "Acme",
|
|
4
|
+
"rule": "keep",
|
|
5
|
+
"matchCase": true,
|
|
6
|
+
"note": "the company; lowercase 'acme' is an ordinary word and should be translated"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"source": "Acme Cloud Inc",
|
|
10
|
+
"rule": "keep",
|
|
11
|
+
"matchCase": true
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"source": "Dashboard",
|
|
15
|
+
"rule": "translate",
|
|
16
|
+
"targets": {
|
|
17
|
+
"es": "Panel de control",
|
|
18
|
+
"pt-br": "Painel",
|
|
19
|
+
"ar": "لوحة التحكم"
|
|
20
|
+
},
|
|
21
|
+
"note": "product surface — must read the same on every page"
|
|
22
|
+
}
|
|
23
|
+
]
|
package/i18n.config.example.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-translator",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Translate a static website into dozens of languages as real static pages, by substituting into built HTML instead of re-rendering.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -36,13 +36,16 @@
|
|
|
36
36
|
"claude-translator": "bin/claude-translator.mjs"
|
|
37
37
|
},
|
|
38
38
|
"files": [
|
|
39
|
+
".claude-plugin",
|
|
39
40
|
"bin",
|
|
40
41
|
"scripts",
|
|
41
|
-
|
|
42
|
-
"
|
|
42
|
+
|
|
43
|
+
"skills",
|
|
43
44
|
"LICENSING.md",
|
|
45
|
+
"PRIVACY.md",
|
|
44
46
|
"CHANGELOG.md",
|
|
45
|
-
"i18n.config.example.json"
|
|
47
|
+
"i18n.config.example.json",
|
|
48
|
+
"glossary.example.json"
|
|
46
49
|
],
|
|
47
50
|
"devDependencies": {
|
|
48
51
|
"parse5": "^7.3.0"
|
|
@@ -54,7 +57,8 @@
|
|
|
54
57
|
"i18n:build": "node scripts/build-locales.mjs",
|
|
55
58
|
"i18n:verify": "node scripts/verify.mjs",
|
|
56
59
|
"i18n:audit": "node scripts/audit-seo.mjs",
|
|
60
|
+
"i18n:tqa": "node scripts/tqa.mjs",
|
|
57
61
|
"check": "for f in scripts/*.mjs scripts/providers/*.mjs bin/*.mjs; do node --check \"$f\" || exit 1; done && echo 'all scripts parse'",
|
|
58
|
-
"test": "node --test scripts/providers/providers.test.mjs bin/cli.test.mjs"
|
|
62
|
+
"test": "node --test scripts/providers/providers.test.mjs scripts/glossary.test.mjs scripts/format-locale.test.mjs scripts/tqa-score.test.mjs scripts/roles.test.mjs bin/cli.test.mjs"
|
|
59
63
|
}
|
|
60
64
|
}
|
package/scripts/audit-seo.mjs
CHANGED
|
@@ -26,10 +26,13 @@ import { readFileSync, existsSync, writeFileSync } from 'fs';
|
|
|
26
26
|
import { join } from 'path';
|
|
27
27
|
import { fileURLToPath } from 'url';
|
|
28
28
|
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
BUILD_DIR as DIST, BASE_URL as BASE, LOCALES as LANG_ROWS,
|
|
31
|
+
BY_PATH, RTL, getPages, I18N_DIR, ROOT_DIR,
|
|
32
|
+
} from './config.mjs';
|
|
30
33
|
import { creditBlock } from './credit.mjs';
|
|
31
34
|
|
|
32
|
-
const ROOT =
|
|
35
|
+
const ROOT = ROOT_DIR;
|
|
33
36
|
|
|
34
37
|
const args = Object.fromEntries(
|
|
35
38
|
process.argv
|
|
@@ -249,7 +252,7 @@ for (const [check, list] of Object.entries(byCheck).sort((a, b) => b[1].length -
|
|
|
249
252
|
if (list.length > 3) console.log(` … and ${(list.length - 3).toLocaleString()} more`);
|
|
250
253
|
}
|
|
251
254
|
|
|
252
|
-
writeFileSync(join(
|
|
255
|
+
writeFileSync(join(I18N_DIR, 'seo-audit.json'), JSON.stringify({ counts, findings }, null, 2));
|
|
253
256
|
console.log(`\nwrote i18n/seo-audit.json (${findings.length.toLocaleString()} findings)`);
|
|
254
257
|
|
|
255
258
|
creditBlock(
|