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.
Files changed (33) hide show
  1. package/.claude-plugin/plugin.json +14 -0
  2. package/CHANGELOG.md +249 -0
  3. package/PRIVACY.md +71 -0
  4. package/README.md +279 -44
  5. package/bin/claude-translator.mjs +11 -2
  6. package/bin/cli.test.mjs +20 -1
  7. package/glossary.example.json +23 -0
  8. package/i18n.config.example.json +7 -0
  9. package/package.json +9 -5
  10. package/scripts/audit-seo.mjs +6 -3
  11. package/scripts/build-locales.mjs +55 -6
  12. package/scripts/config.mjs +66 -0
  13. package/scripts/credit.mjs +12 -5
  14. package/scripts/extract.mjs +21 -6
  15. package/scripts/format-locale.mjs +290 -0
  16. package/scripts/format-locale.test.mjs +171 -0
  17. package/scripts/glossary.mjs +229 -0
  18. package/scripts/glossary.test.mjs +188 -0
  19. package/scripts/providers/openai.mjs +63 -3
  20. package/scripts/providers/providers.test.mjs +80 -0
  21. package/scripts/roles.mjs +142 -0
  22. package/scripts/roles.test.mjs +140 -0
  23. package/scripts/tqa-score.mjs +127 -0
  24. package/scripts/tqa-score.test.mjs +144 -0
  25. package/scripts/tqa.mjs +449 -0
  26. package/scripts/translate.mjs +104 -13
  27. package/scripts/verify.mjs +113 -5
  28. package/{SKILL.md → skills/translate-site/SKILL.md} +45 -13
  29. package/{references → skills/translate-site/references}/providers.md +16 -2
  30. package/{references → skills/translate-site/references}/quality-review.md +28 -0
  31. /package/{references → skills/translate-site/references}/adapting-generators.md +0 -0
  32. /package/{references → skills/translate-site/references}/failure-modes.md +0 -0
  33. /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
  [![Claude Skill](https://img.shields.io/badge/Claude-Skill-8A63D2.svg)](#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 — with correct `hreflang`, canonicals,
12
- `dir="rtl"`, per-locale JSON-LD and sitemaps — then proves the result with six gates and a
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 six scripts in this repo.
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 # six gates
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
- For every page, in every locale:
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
- - `<html lang>` and `dir="rtl"` where the script requires it
234
- - A self-referencing canonical — `https`, no trailing slash
235
- - A complete `hreflang` set: every locale, plus `x-default` and the source language
236
- pointing at the **original** page, not at self
237
- - `og:url` / `og:locale`
238
- - JSON-LD with per-locale `@id`, `url` and `inLanguage` — and `Organization` left alone,
239
- because a company is one entity in every language
240
- - Locale-prefixed internal links
241
- - Per-locale sitemaps
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` — six gates**
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-4o-mini` | `OPENAI_API_KEY` |
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, and paid for
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. In exchange we will credit free translation words to a ConveyThis
357
- account — [details here](https://www.conveythis.com/open-source/claude-translator?utm_source=claude-skill&utm_medium=readme-visible-credit&utm_campaign=claude-translator).
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"`. Because you are compensated for it, it is a paid link under
360
- Google's guidelines and must not pass ranking signal. It is worth referral traffic, not
361
- backlinks, and anyone telling you otherwise is selling you a penalty.
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 skill
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
- This repo doubles as a [Claude Code](https://claude.com/claude-code) skill:
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
- ~/.claude/skills/claude-translator
621
+ git clone https://github.com/ConveyThis/claude-translator.git
622
+ claude --plugin-dir ./claude-translator
393
623
  ```
394
624
 
395
- Then ask Claude to "localize this site". It follows `SKILL.md`, including the failure modes in
396
- `references/` and the routing rules for when this is the wrong tool entirely.
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 six gates
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`, 'six gates'],
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
+ ]
@@ -55,6 +55,13 @@
55
55
  "EPUB"
56
56
  ]
57
57
  },
58
+ "glossary": "glossary.json",
59
+ "localeFormat": {
60
+ "numbers": true,
61
+ "percent": true,
62
+ "currency": "format",
63
+ "units": "off"
64
+ },
58
65
  "provider": "anthropic",
59
66
  "model": "claude-haiku-4-5",
60
67
  "credit": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-translator",
3
- "version": "1.3.0",
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
- "references",
42
- "SKILL.md",
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
  }
@@ -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 { BUILD_DIR as DIST, BASE_URL as BASE, LOCALES as LANG_ROWS, BY_PATH, RTL, getPages, I18N_DIR } from './config.mjs';
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 = process.cwd();
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(ROOT, 'i18n/seo-audit.json'), JSON.stringify({ counts, findings }, null, 2));
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(