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
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-translator",
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Translate a static website into dozens of languages as real static pages, by substituting into built HTML instead of re-rendering.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "AGPL-3.0-or-later",
|
|
7
|
+
"author": "ConveyThis (https://conveythis.com)",
|
|
8
|
+
"homepage": "https://github.com/ConveyThis/claude-translator",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ConveyThis/claude-translator.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/ConveyThis/claude-translator/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"i18n",
|
|
18
|
+
"l10n",
|
|
19
|
+
"localization",
|
|
20
|
+
"translation",
|
|
21
|
+
"static-site",
|
|
22
|
+
"hreflang",
|
|
23
|
+
"seo",
|
|
24
|
+
"astro",
|
|
25
|
+
"claude",
|
|
26
|
+
"anthropic",
|
|
27
|
+
"gemini",
|
|
28
|
+
"openai",
|
|
29
|
+
"ollama",
|
|
30
|
+
"multilingual"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
},
|
|
35
|
+
"bin": {
|
|
36
|
+
"claude-translator": "bin/claude-translator.mjs"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"bin",
|
|
40
|
+
"scripts",
|
|
41
|
+
"references",
|
|
42
|
+
"SKILL.md",
|
|
43
|
+
"LICENSING.md",
|
|
44
|
+
"CHANGELOG.md",
|
|
45
|
+
"i18n.config.example.json"
|
|
46
|
+
],
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"parse5": "^7.3.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"i18n:extract": "node scripts/extract.mjs",
|
|
52
|
+
"i18n:translate": "node scripts/translate.mjs",
|
|
53
|
+
"i18n:review": "node scripts/review.mjs",
|
|
54
|
+
"i18n:build": "node scripts/build-locales.mjs",
|
|
55
|
+
"i18n:verify": "node scripts/verify.mjs",
|
|
56
|
+
"i18n:audit": "node scripts/audit-seo.mjs",
|
|
57
|
+
"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"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Adapting to different generators
|
|
2
|
+
|
|
3
|
+
The method needs only three things:
|
|
4
|
+
|
|
5
|
+
1. A **directory of built HTML** where each page is `<slug>/index.html` (or `<slug>.html`)
|
|
6
|
+
2. A **list of pages** to localize
|
|
7
|
+
3. A **list of locales**
|
|
8
|
+
|
|
9
|
+
Nothing else is framework-specific. `config.mjs` is the only file that knows about a
|
|
10
|
+
given site.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Per generator
|
|
15
|
+
|
|
16
|
+
| Generator | `buildDir` | Page list | Notes |
|
|
17
|
+
| --- | --- | --- | --- |
|
|
18
|
+
| **Astro** (`output: 'static'`) | `dist` | `"build"`, or a slug array | Reference implementation. Clean-URL output already matches. |
|
|
19
|
+
| **Next.js** (`next export` / `output: 'export'`) | `out` | `"build"` | Check trailing-slash config — canonical rules must match what the server serves. |
|
|
20
|
+
| **Hugo** | `public` | `"build"` | Hugo has native i18n; use it if content lives in Markdown front matter. This skill wins when translating *rendered* pages. |
|
|
21
|
+
| **Eleventy** | `_site` | `"build"` | Straightforward. |
|
|
22
|
+
| **Jekyll** | `_site` | `"build"` | Straightforward. |
|
|
23
|
+
| **Gatsby** | `public` | `"build"` | Exclude `page-data/` and the JSON payloads. |
|
|
24
|
+
| **Plain HTML** | wherever | `"build"` | Works as-is. |
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{ "buildDir": "out", "pages": { "source": "build", "exclude": ["404", "admin"] } }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`pages.source: "build"` derives the list from the output directory. Point it at a file
|
|
31
|
+
instead when an explicit list exists (a sitemap slug array). For a `.ts`/`.js` file, set
|
|
32
|
+
`pages.export` to the exported const name — **scoping matters**: scanning the whole file
|
|
33
|
+
sweeps up every other quoted string in it. In one case that silently added the
|
|
34
|
+
language-code array to the page-slug array and inflated the page count.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## What to check on a new generator
|
|
39
|
+
|
|
40
|
+
**Clean URLs vs `.html`.** The scripts assume `<slug>/index.html`. If output is
|
|
41
|
+
`about.html`, adjust the path construction in `build-locales.mjs` and `verify.mjs`
|
|
42
|
+
together, and make canonicals match what the server actually serves.
|
|
43
|
+
|
|
44
|
+
**Trailing slashes.** Canonical, hreflang and sitemap must agree with the server's
|
|
45
|
+
redirect behaviour. A canonical pointing at a URL that 301-redirects is a real SEO defect
|
|
46
|
+
(a real defect found in production).
|
|
47
|
+
|
|
48
|
+
**Hydration payloads.** Frameworks that embed serialized state (`__NEXT_DATA__`,
|
|
49
|
+
Gatsby's `page-data`) duplicate visible text inside `<script>`. The extractor already skips
|
|
50
|
+
`<script>`, so those copies stay in the source language — **and the client may re-render
|
|
51
|
+
from them**, overwriting your translations. Test one interactive page early. If it happens,
|
|
52
|
+
either translate the payload too or exclude that route.
|
|
53
|
+
|
|
54
|
+
**Framework-native i18n.** If the source content is structured (Markdown + front matter)
|
|
55
|
+
and the generator has real i18n, prefer it — you get translated slugs and proper routing.
|
|
56
|
+
This skill is for the case where content is *already rendered*, or where re-rendering every
|
|
57
|
+
page is unacceptable (large sites, critical-CSS pipelines, proxy replacement).
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Locale identity to emit per page
|
|
62
|
+
|
|
63
|
+
Whatever the generator, each localized page needs:
|
|
64
|
+
|
|
65
|
+
| Element | Value |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `<html lang>` | the locale's `hreflang` |
|
|
68
|
+
| `<html dir>` | `rtl` for ar/fa/he/ur — **replace** any existing `dir`, don't append |
|
|
69
|
+
| canonical | the page's own URL, `https`, no trailing slash |
|
|
70
|
+
| `hreflang` self | identical to the canonical |
|
|
71
|
+
| `hreflang` en / x-default | the **source** page, not self |
|
|
72
|
+
| `og:url` / `og:locale` | page URL / locale code |
|
|
73
|
+
| JSON-LD | `@id` = `<page-url>#webpage`, `url` per type, `inLanguage` = locale |
|
|
74
|
+
|
|
75
|
+
`build-locales.mjs` does all of this and reports any rule that matched nothing — which is
|
|
76
|
+
how attribute-order bugs surface. `audit-seo.mjs` then verifies it across every page.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# Failure modes
|
|
2
|
+
|
|
3
|
+
Every one of these was hit in production on a 55-locale rollout. They are listed because
|
|
4
|
+
each **looked like working code**, and several produced wrong output that reported success.
|
|
5
|
+
|
|
6
|
+
Read this before modifying any script.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Extraction
|
|
11
|
+
|
|
12
|
+
### A skipped element inside a block silently drops the whole block
|
|
13
|
+
|
|
14
|
+
**Symptom:** coverage 100%, gates green, yet "Translate a Document", "Sign Up Free" and
|
|
15
|
+
every `✓ feature` bullet render in the source language.
|
|
16
|
+
|
|
17
|
+
**Cause:** the tokenizer treated `<svg>` as "skip", and skipping aborted the *entire*
|
|
18
|
+
containing block. Every icon+label pair on the site — buttons, feature bullets, badges —
|
|
19
|
+
was never offered for translation. **Thousands of segments, invisible.**
|
|
20
|
+
|
|
21
|
+
**Fix:** an opaque element becomes **one void placeholder** carrying the element verbatim.
|
|
22
|
+
Never abandon a unit because of a child.
|
|
23
|
+
|
|
24
|
+
**Detection:** gate 6 in `verify.mjs`. Coverage cannot see this class of bug at all.
|
|
25
|
+
|
|
26
|
+
### The extractor scans its own output
|
|
27
|
+
|
|
28
|
+
**Symptom:** unit and segment counts roughly double; the memory starts filling with
|
|
29
|
+
target-language strings keyed as source.
|
|
30
|
+
|
|
31
|
+
**Cause:** locale directories live inside the build directory, so a naive recursive scan
|
|
32
|
+
reads `dist/es/**` as if it were source.
|
|
33
|
+
|
|
34
|
+
**Fix:** exclude every configured `pathCode` from the scan (`config.mjs` does this).
|
|
35
|
+
|
|
36
|
+
### JSON-LD bare-value search matches substrings
|
|
37
|
+
|
|
38
|
+
**Symptom:** `Overlapping segments … Aborting.`
|
|
39
|
+
|
|
40
|
+
**Cause:** searching for a bare value finds it inside a longer sibling —
|
|
41
|
+
`"name":"PDF Translator"` sits within `"name":"AI PDF Translator"` — producing overlapping
|
|
42
|
+
byte ranges that would corrupt output.
|
|
43
|
+
|
|
44
|
+
**Fix:** match the full `"key"\s*:\s*"value"` pair, never the value alone.
|
|
45
|
+
|
|
46
|
+
### Language-switcher labels drift per locale
|
|
47
|
+
|
|
48
|
+
**Symptom:** the picker reads differently on every locale — `English (English)` becomes
|
|
49
|
+
`Inglés (English)` on Spanish while `Español (Spanish)` stays put.
|
|
50
|
+
|
|
51
|
+
**Cause:** a language picker shows every language in **its own** language. These are data,
|
|
52
|
+
not copy.
|
|
53
|
+
|
|
54
|
+
**Fix:** treat `Native (EnglishName)` strings and bare native labels as do-not-translate.
|
|
55
|
+
Apply the same rule in the verifier, or gate 6 will report them as holes.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Translation API
|
|
60
|
+
|
|
61
|
+
### Network exceptions bypass status-code retry
|
|
62
|
+
|
|
63
|
+
**Symptom:** a run finishes "successfully" with several hundred units missing.
|
|
64
|
+
|
|
65
|
+
**Cause:** `TypeError: fetch failed` (DNS, reset, socket timeout) throws **before** any
|
|
66
|
+
response exists, so `if (!res.ok)` never sees it. Cost: **hundreds of units** lost silently in a single run.
|
|
67
|
+
|
|
68
|
+
**Fix:** wrap the `fetch` itself and retry on the same backoff as 429/5xx.
|
|
69
|
+
|
|
70
|
+
### A batch that exceeds the output limit returns truncated JSON
|
|
71
|
+
|
|
72
|
+
**Symptom:** `SyntaxError: Unterminated string in JSON at position …`, 40 units gone.
|
|
73
|
+
|
|
74
|
+
**Fix:** catch the parse error, split the batch in half, recurse. Only a single oversized
|
|
75
|
+
unit can then be unrecoverable.
|
|
76
|
+
|
|
77
|
+
### The safety filter blocks the whole request
|
|
78
|
+
|
|
79
|
+
**Symptom:** `promptFeedback.blockReason: PROHIBITED_CONTENT`, no candidates, 40 units gone.
|
|
80
|
+
|
|
81
|
+
**Cause:** one string the filter dislikes takes the other 39 with it.
|
|
82
|
+
|
|
83
|
+
**Fix:** same split-and-recurse path.
|
|
84
|
+
|
|
85
|
+
### Memory written only at the end
|
|
86
|
+
|
|
87
|
+
**Symptom:** a 40-minute run is interrupted and *everything* is lost.
|
|
88
|
+
|
|
89
|
+
**Fix:** checkpoint every ~10 batches. This later saved thousands of units when a long run
|
|
90
|
+
had to be killed and restarted.
|
|
91
|
+
|
|
92
|
+
### The model silently returns the source language
|
|
93
|
+
|
|
94
|
+
Not an API error — a **quality** failure that every structural gate passes. See
|
|
95
|
+
`quality-review.md`; word-overlap detection is what catches it.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
### `output_config.effort` is rejected on Haiku
|
|
100
|
+
|
|
101
|
+
**Symptom.** Every Anthropic request fails with a 400 naming `effort`, on a model that
|
|
102
|
+
otherwise works.
|
|
103
|
+
|
|
104
|
+
**Cause.** Claude tiers do not take the same parameters. `claude-haiku-4-5` rejects
|
|
105
|
+
`output_config.effort` outright; the Opus and Sonnet tiers accept it.
|
|
106
|
+
|
|
107
|
+
**Fix.** `providers/anthropic.mjs` keeps a capability table and sends the parameter only
|
|
108
|
+
where it is accepted. Adding a model means adding it to `ACCEPTS_EFFORT`, not assuming.
|
|
109
|
+
|
|
110
|
+
### Adaptive thinking is on by default, and translation pays for it
|
|
111
|
+
|
|
112
|
+
**Symptom.** A run on Opus or Sonnet costs several times the estimate, with output token
|
|
113
|
+
counts far above what the translated text can account for.
|
|
114
|
+
|
|
115
|
+
**Cause.** Those models run adaptive thinking unless told otherwise. Segment translation
|
|
116
|
+
has no use for it, but it is billed all the same.
|
|
117
|
+
|
|
118
|
+
**Fix.** The adapter pins `output_config.effort: 'low'` on the tiers that support it.
|
|
119
|
+
Do **not** "fix" this by setting `thinking: {type: 'disabled'}` — on Opus 5 that has its
|
|
120
|
+
own failure modes, including writing a tool call into visible text.
|
|
121
|
+
|
|
122
|
+
### Local models wrap JSON in a code fence
|
|
123
|
+
|
|
124
|
+
**Symptom.** Works on a hosted provider, fails on Ollama or LM Studio with
|
|
125
|
+
`Unexpected token \`` on every batch.
|
|
126
|
+
|
|
127
|
+
**Cause.** Smaller models emit ` ```json … ``` ` regardless of instructions, and some
|
|
128
|
+
prepend a sentence of preamble.
|
|
129
|
+
|
|
130
|
+
**Fix.** `extractJson()` in `providers/index.mjs` strips fences and falls back to the
|
|
131
|
+
outermost bracket pair. Never assume the response body starts with `[`.
|
|
132
|
+
|
|
133
|
+
### A server rejects `response_format` and the run dies
|
|
134
|
+
|
|
135
|
+
**Symptom.** An OpenAI-compatible endpoint returns 400 mentioning `response_format` or
|
|
136
|
+
`json_schema`, and nothing translates.
|
|
137
|
+
|
|
138
|
+
**Cause.** The structured-output field is not universal across "OpenAI-compatible"
|
|
139
|
+
servers. Many implement `json_object` only; some implement neither.
|
|
140
|
+
|
|
141
|
+
**Fix.** `unsupportedJsonMode()` distinguishes that rejection from a genuine bad request,
|
|
142
|
+
and `translate.mjs` drops one rung — `json_schema` → `json_object` → prompt only — rather
|
|
143
|
+
than failing. Placeholder validation still guards the output, so the weaker mode costs
|
|
144
|
+
retries and not correctness. A real error (bad key, unknown model) must **not** match this
|
|
145
|
+
path, which is why the pattern is narrow.
|
|
146
|
+
|
|
147
|
+
### Locale pages built from a stale source build
|
|
148
|
+
|
|
149
|
+
**Symptom.** The locale pages' inlined critical CSS no longer matches the source page's, and
|
|
150
|
+
each carries rules for classes the other one uses. Tag sequence and `class` attributes still
|
|
151
|
+
match perfectly, so substitution clearly worked — but the two pages disagree about their CSS.
|
|
152
|
+
|
|
153
|
+
**Cause.** The source site was rebuilt *after* the locale pages were generated, and
|
|
154
|
+
critical-CSS extraction is not stable across builds. Observed live on a production site: two
|
|
155
|
+
builds three hours apart, identical markup, and the later build's critical CSS gained
|
|
156
|
+
`.bg-sky-500`, `.bg-slate-100`, `.object-cover` and `.scale-150` while losing `.aspect-video`.
|
|
157
|
+
Neither build's CSS was a correct match for its own markup.
|
|
158
|
+
|
|
159
|
+
This is the same instability that makes per-locale re-rendering a bad idea, showing up in a
|
|
160
|
+
different place — and it is a useful reminder that the tooling is the unreliable part, not the
|
|
161
|
+
substitution.
|
|
162
|
+
|
|
163
|
+
**Fix.** Re-run `build-locales.mjs` after any rebuild that changes markup. It is nearly free:
|
|
164
|
+
the translation memory is keyed by source text, so nothing is re-translated and no API call is
|
|
165
|
+
made. Cheap enough to put in the same CI step as the build itself.
|
|
166
|
+
|
|
167
|
+
**How to spot it.** Compare an inlined `<style>` block between a source page and its locale
|
|
168
|
+
twin. Substitution never touches `<style>` contents, so if they differ, the locale page was
|
|
169
|
+
built from a different snapshot than the one you are looking at.
|
|
170
|
+
|
|
171
|
+
### A model id sent to the wrong provider
|
|
172
|
+
|
|
173
|
+
**Symptom.** After upgrading to 1.2, an existing project fails with an unhelpful
|
|
174
|
+
"unknown model" from an API it never used before.
|
|
175
|
+
|
|
176
|
+
**Cause.** 1.2 changed the default provider to Anthropic. A config from 1.0/1.1 pins a
|
|
177
|
+
Gemini model id and has no `provider` key.
|
|
178
|
+
|
|
179
|
+
**Fix.** `inferProvider()` derives the provider from the model id when `provider` is
|
|
180
|
+
absent, so those configs keep working. Only a config with neither key gets the default.
|
|
181
|
+
The run prints which provider it resolved and whether it was inferred — read that line
|
|
182
|
+
before debugging anything else.
|
|
183
|
+
|
|
184
|
+
## Building locale pages
|
|
185
|
+
|
|
186
|
+
### Attribute order is not guaranteed
|
|
187
|
+
|
|
188
|
+
**Symptom:** canonical/hreflang/og rewrites do nothing, build reports success.
|
|
189
|
+
|
|
190
|
+
**Cause:** a builder emitted `<link href="…" rel="canonical">`; the regex assumed `rel`
|
|
191
|
+
first. Every identity rule silently no-opped.
|
|
192
|
+
|
|
193
|
+
**Fix:** parse attributes into a map rather than matching positionally, **and report any
|
|
194
|
+
rule that matched zero occurrences**. That report is what surfaced it.
|
|
195
|
+
|
|
196
|
+
### Not every schema `url` is the page URL
|
|
197
|
+
|
|
198
|
+
**Symptom:** `Organization.url` becomes `/es/about` — the company relocated to a subpage.
|
|
199
|
+
|
|
200
|
+
**Cause:** blanket rewriting of every `url` in JSON-LD.
|
|
201
|
+
|
|
202
|
+
**Fix:** only page-level types (`WebPage`, `AboutPage`, `FAQPage`, `ContactPage`, …) get
|
|
203
|
+
the page URL. `WebSite.url` gets the **locale root**. `Organization` is one entity across
|
|
204
|
+
all languages — leave it alone.
|
|
205
|
+
|
|
206
|
+
### The home link gains a trailing slash
|
|
207
|
+
|
|
208
|
+
`href="/"` naively prefixed becomes `/es/`, which 301-redirects back to `/es` — a
|
|
209
|
+
needless redirect on the logo of every page, contradicting the canonical. Special-case the
|
|
210
|
+
empty path.
|
|
211
|
+
|
|
212
|
+
### One component, two instances
|
|
213
|
+
|
|
214
|
+
A non-global regex patches only the first. If a component is mounted twice (mobile +
|
|
215
|
+
desktop header), every per-locale rewrite needs the `g` flag.
|
|
216
|
+
|
|
217
|
+
### Concurrent builds of the same locale race
|
|
218
|
+
|
|
219
|
+
Each build starts by deleting the locale directory, so two overlapping runs leave a
|
|
220
|
+
half-written tree — pages missing while both runs report success. Caught by URL parity.
|
|
221
|
+
Never build the same locale twice concurrently.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Verification
|
|
226
|
+
|
|
227
|
+
### Coverage above 100%
|
|
228
|
+
|
|
229
|
+
Numerator and denominator used different page sets (one counted every segment file,
|
|
230
|
+
the other only sitemap pages). A percentage that can exceed 100 is a broken measurement,
|
|
231
|
+
not a good result.
|
|
232
|
+
|
|
233
|
+
### Coverage is not completeness
|
|
234
|
+
|
|
235
|
+
The single most important lesson. Coverage answers *"how much of what I extracted did I
|
|
236
|
+
translate?"* — it is structurally incapable of seeing text the extractor never offered.
|
|
237
|
+
|
|
238
|
+
Gate 6 answers the real question by comparing built output against the source page and
|
|
239
|
+
failing on strings that were never translation candidates. It must **not** fail merely
|
|
240
|
+
because a string is identical in both languages: `e-Learning`, `PowerPoint (.PPT)` and
|
|
241
|
+
`Google Translate PDF` are correct unchanged. Report those separately as "identical but
|
|
242
|
+
offered".
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Operating the pipeline
|
|
247
|
+
|
|
248
|
+
- **`nohup … &` does not survive a tool-call shell.** Use the harness's background
|
|
249
|
+
mechanism. A killed run loses everything since the last checkpoint.
|
|
250
|
+
- **macOS ships `openrsync`**, which rejects `--info=progress2` by printing usage and
|
|
251
|
+
**exiting 0**. The sync silently transfers nothing. Verify destination state.
|
|
252
|
+
- **`pgrep -f 'script.sh'` matches the polling shell itself** → the waiter waits forever.
|
|
253
|
+
Poll a state file, or match interpreter + script path.
|
|
254
|
+
- **Running the same expensive command twice in one line** (once to count, once to grep)
|
|
255
|
+
doubles a 20-minute build. Redirect to a file and inspect the file.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Providers
|
|
2
|
+
|
|
3
|
+
The translation step is the only part of this pipeline that talks to a model, and it
|
|
4
|
+
talks through a small adapter. Three ship with the project; anything else is one file.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Choosing one
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"provider": "anthropic",
|
|
13
|
+
"model": "claude-haiku-4-5"
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| `provider` | Default model | Key | Notes |
|
|
18
|
+
| --- | --- | --- | --- |
|
|
19
|
+
| `anthropic` | `claude-haiku-4-5` | `ANTHROPIC_API_KEY` | The default. |
|
|
20
|
+
| `gemini` | `gemini-2.5-flash-lite` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | Roughly a tenth the cost — see [throughput-and-cost.md](throughput-and-cost.md). |
|
|
21
|
+
| `openai` | `gpt-4o-mini` | `OPENAI_API_KEY` | Any OpenAI-compatible endpoint, including local ones. |
|
|
22
|
+
| `./my-provider.mjs` | — | yours | A path is imported directly. See below. |
|
|
23
|
+
|
|
24
|
+
`--provider` and `--model` on the command line override the config for one run.
|
|
25
|
+
|
|
26
|
+
**If you omit `provider`, it is inferred from the model id** — `gemini-*` → `gemini`,
|
|
27
|
+
`claude-*` → `anthropic`, `gpt-*` → `openai`. This is what keeps configs written for
|
|
28
|
+
1.0 and 1.1 working after the 1.2 default changed to Claude. Omit both and you get
|
|
29
|
+
`claude-haiku-4-5`. The run prints which provider it resolved and whether it guessed.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Running on a local model
|
|
34
|
+
|
|
35
|
+
The `openai` adapter speaks `/v1/chat/completions`, which Ollama, LM Studio and vLLM
|
|
36
|
+
all implement. Point it at one and the whole pipeline runs on your own hardware for
|
|
37
|
+
nothing:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"provider": "openai",
|
|
42
|
+
"apiBaseUrl": "http://localhost:11434/v1",
|
|
43
|
+
"model": "qwen2.5:14b"
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
No key is needed — the adapter omits the `Authorization` header when there isn't one.
|
|
48
|
+
|
|
49
|
+
> `apiBaseUrl` is the **model** host. It is deliberately a different key from `baseUrl`,
|
|
50
|
+
> which is your **site's** canonical origin. Confusing them would point your canonical
|
|
51
|
+
> tags at a language model.
|
|
52
|
+
|
|
53
|
+
Two things to expect from smaller local models. They emit JSON wrapped in a
|
|
54
|
+
` ```json ` fence, which the parser strips. And they often do not implement
|
|
55
|
+
`response_format`; when a server rejects it, the adapter drops to the next rung —
|
|
56
|
+
`json_schema` → `json_object` → prompt only — and says so. Placeholder validation runs
|
|
57
|
+
regardless, so a weaker guarantee costs retries rather than correctness. Force a rung
|
|
58
|
+
with `"jsonMode": "object"` if you already know what your server supports.
|
|
59
|
+
|
|
60
|
+
The same adapter covers OpenAI, Azure OpenAI, Groq, DeepSeek, Mistral, OpenRouter,
|
|
61
|
+
Together and Fireworks. Set `apiBaseUrl` and `model`; some need `apiKeyEnv` to point at
|
|
62
|
+
a differently-named variable.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Which Claude model
|
|
67
|
+
|
|
68
|
+
`claude-haiku-4-5` is the default because bulk segment translation is a high-volume,
|
|
69
|
+
low-reasoning task and Haiku is the tier priced for it. Moving up is one line:
|
|
70
|
+
|
|
71
|
+
| Model | $/Mtok in/out | Relative cost |
|
|
72
|
+
| --- | --- | --- |
|
|
73
|
+
| `claude-haiku-4-5` | 1 / 5 | baseline |
|
|
74
|
+
| `claude-sonnet-5` | 3 / 15 | ~3x |
|
|
75
|
+
| `claude-opus-5` | 5 / 25 | ~5x |
|
|
76
|
+
|
|
77
|
+
Rates checked 2026-08-25. The larger models are better on idiom and register, which
|
|
78
|
+
matters for marketing copy and much less for UI strings.
|
|
79
|
+
|
|
80
|
+
One implementation detail worth knowing: the models that support adaptive thinking have
|
|
81
|
+
it **on by default**, and a translation run has no use for it. The adapter pins
|
|
82
|
+
`output_config.effort: "low"` on those tiers so you are not billed for reasoning you did
|
|
83
|
+
not ask for. Haiku takes no effort parameter at all — the API rejects it — so it is sent
|
|
84
|
+
nothing.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Writing your own
|
|
89
|
+
|
|
90
|
+
A provider is a module with two required exports.
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
// my-provider.mjs
|
|
94
|
+
export const id = 'my-provider';
|
|
95
|
+
export const label = 'My Provider'; // shown in logs and errors
|
|
96
|
+
export const defaultModel = 'my-model-v1';
|
|
97
|
+
export const envKeys = ['MY_PROVIDER_KEY']; // checked in order, env then .env
|
|
98
|
+
export const keyOptional = false; // true for local servers
|
|
99
|
+
|
|
100
|
+
/** Build one HTTP request. */
|
|
101
|
+
export function request({ model, system, items, temperature, key, baseUrl, jsonMode }) {
|
|
102
|
+
return {
|
|
103
|
+
url: `${baseUrl ?? 'https://api.example.com/v1'}/translate`,
|
|
104
|
+
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
105
|
+
body: { model, system, items, temperature },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read one response. */
|
|
110
|
+
export function parse(data) {
|
|
111
|
+
return {
|
|
112
|
+
text: data.output, // the raw JSON string, or null
|
|
113
|
+
usage: { inTok: data.in ?? 0, outTok: data.out ?? 0 },
|
|
114
|
+
retryable: null, // 'safety' | 'truncated' | null
|
|
115
|
+
detail: data.reason, // shown when splitting
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Optional: the units are nested in your response envelope. */
|
|
120
|
+
export const unwrap = (parsed) => parsed.translations ?? parsed;
|
|
121
|
+
|
|
122
|
+
/** Optional: USD per million tokens, [in, out]. Omit if unknown. */
|
|
123
|
+
export function pricing(model) { return [0.5, 1.5]; }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Then:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{ "provider": "./scripts/my-provider.mjs", "model": "my-model-v1" }
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`request` returns what to send; `parse` says what came back. Everything else —
|
|
133
|
+
exponential backoff, retrying network errors, halving a batch that was refused or
|
|
134
|
+
truncated, validating that placeholders survived, checkpointing the memory — is handled
|
|
135
|
+
by `translate.mjs` and applies to your adapter automatically.
|
|
136
|
+
|
|
137
|
+
**`retryable` is the important field.** Return `'safety'` when the provider refused the
|
|
138
|
+
whole request because of one string in it, and `'truncated'` when the output hit a
|
|
139
|
+
length limit. Both cause the batch to be halved and retried, which is what turns a lost
|
|
140
|
+
batch of forty units into one unusable unit. Return `null` and a failure takes all forty
|
|
141
|
+
with it.
|
|
142
|
+
|
|
143
|
+
Run `node --test scripts/providers/providers.test.mjs` — the contract tests there are
|
|
144
|
+
the clearest specification of what an adapter must do.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Switching providers mid-project
|
|
149
|
+
|
|
150
|
+
The translation memory is keyed by a hash of the **source** text, not by model. So
|
|
151
|
+
changing provider translates only what is missing and reuses everything already there.
|
|
152
|
+
That makes a cheap first pass and a selective re-run practical: delete the units you
|
|
153
|
+
want redone from `i18n/tm/{lang}.json` and translate again on a better model.
|
|
154
|
+
|
|
155
|
+
The flip side is that one locale can end up part-translated by two models, with a
|
|
156
|
+
visible seam in voice. If that matters, delete the whole memory for that locale rather
|
|
157
|
+
than topping it up.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Quality review
|
|
2
|
+
|
|
3
|
+
Structural gates prove the *plumbing* is correct — right pages, right markup, no broken
|
|
4
|
+
placeholders. They say nothing about whether the text is actually translated, or
|
|
5
|
+
translated into the right language. That is what `review.mjs` is for.
|
|
6
|
+
|
|
7
|
+
**The governing lesson: a heuristic that over-flags is worse than none.** The remedy for a
|
|
8
|
+
flag is purge-and-retranslate, which costs money and time. A noisy check either burns both
|
|
9
|
+
or trains you to ignore it.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Verdicts
|
|
14
|
+
|
|
15
|
+
| Check | Reliable? | Notes |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| Placeholder multiset mismatch | **Yes** — hard gate | Would corrupt HTML; validated at translate time and re-checked |
|
|
18
|
+
| Word overlap vs source | **Yes** | Catches wholesale-source returns *and* wrong-target-language |
|
|
19
|
+
| Length ratio | **Only script-aware** | See calibration below |
|
|
20
|
+
| Latin-script ratio | **Only brand-stripped** | Non-Latin locales only |
|
|
21
|
+
| Identical to source | Yes, with a content-word floor | Brand-only strings are correct unchanged |
|
|
22
|
+
| Adjacent word repetition | **No — disabled** | Three narrowing attempts, still mostly false positives |
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Calibration failures worth remembering
|
|
27
|
+
|
|
28
|
+
**Length ratio, flat floor → mass false positives on CJK.** A 0.4× floor flagged roughly
|
|
29
|
+
half of a Traditional Chinese locale, because CJK encodes far more meaning per character:
|
|
30
|
+
a 100-character English sentence is routinely ~30 characters of Chinese. Those units were
|
|
31
|
+
purged and re-translated for nothing.
|
|
32
|
+
|
|
33
|
+
Use script-aware bounds:
|
|
34
|
+
|
|
35
|
+
| Script group | Floor | Ceiling |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| CJK, Thai, Lao, Khmer, Burmese | 0.12 | 1.2 |
|
|
38
|
+
| Everything else | 0.4 | 2.5 |
|
|
39
|
+
|
|
40
|
+
**Latin-ratio counting brand names → dozens of false positives per non-Latin locale.**
|
|
41
|
+
A heading like `"BrandName: лучше, чем Competitor?"` is mostly Latin characters and
|
|
42
|
+
perfectly translated. Strip brand and format tokens *before* computing the ratio, require ≥6 content
|
|
43
|
+
words, and set the threshold at 0.8. The check is aimed at "the model returned the source
|
|
44
|
+
language wholesale", not at short brand-heavy labels.
|
|
45
|
+
|
|
46
|
+
**Tautology detection — abandoned.** The real defect looks like `prueba una prueba`
|
|
47
|
+
("try a try", from "try a 7-day trial"). Three successive narrowings were tried:
|
|
48
|
+
distance-1 only; require the source not to repeat the word; restrict to within a sentence.
|
|
49
|
+
It still could not separate that from `de inglés y de inglés a armenio`, which is correct
|
|
50
|
+
Spanish with an identical shape — the connectors between the repeats are shorter than the
|
|
51
|
+
token filter. Distinguishing them needs meaning, not shape. Shipped behind `--tautology`,
|
|
52
|
+
never gating.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## What review actually caught
|
|
57
|
+
|
|
58
|
+
These are real defects that **no structural gate could see**, because the text *was*
|
|
59
|
+
offered for translation and *was* returned:
|
|
60
|
+
|
|
61
|
+
- **Whole paragraphs returned in the source language**, with one word changed — a paragraph came back with only the language
|
|
62
|
+
name changed. Not byte-identical, so an equality check missed it; word overlap caught it.
|
|
63
|
+
- **Wrong target language.** `"Is Bahasa Melayu the same as Bahasa Indonesia?"` came back
|
|
64
|
+
in **Malay** on the Spanish locale — the model latched onto "Bahasa". One FAQ heading
|
|
65
|
+
among hundreds of pages; visual review would never have reached it.
|
|
66
|
+
- **Truncated output.** A 713-character Hebrew passage came back at 267 characters, cut
|
|
67
|
+
mid-word, because the batch exceeded the output limit. Coverage counted it as done.
|
|
68
|
+
|
|
69
|
+
Typical healthy flag rate after correction: **0.05–0.15% per locale**, and the remainder
|
|
70
|
+
are legitimate — postal addresses, image filenames used as alt text, proper nouns.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Workflow
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
node scripts/review.mjs --lang es # inspect first
|
|
78
|
+
node scripts/review.mjs --lang es --purge # only once you believe the flags
|
|
79
|
+
node scripts/translate.mjs --lang es --batch 6 # smaller batches for the awkward units
|
|
80
|
+
node scripts/review.mjs --lang es # confirm the rate dropped
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Always run the inspect step and **read the samples** before purging. Both known
|
|
84
|
+
purge disasters happened because purge followed detection automatically — which is why
|
|
85
|
+
`finalize.sh` requires an explicit `--purge` flag rather than doing it for you.
|
|
86
|
+
|
|
87
|
+
A concrete example of the danger: a set of postal-address blocks was flagged
|
|
88
|
+
"untranslated / high-overlap" (false positives — addresses are mostly proper nouns).
|
|
89
|
+
Purging and re-translating them replaced *correct* localised text with the source
|
|
90
|
+
language: `"Correo electrónico"` came back as `"Email:"`. The flags were right that the
|
|
91
|
+
strings looked unusual, and wrong that they needed fixing.
|