rosetta-i18n 0.2.1 → 1.0.0-beta.1

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/README.md CHANGED
@@ -1,26 +1,368 @@
1
1
  # Rosetta i18n
2
2
 
3
- Self-hosted AI translation engine. One text, every language — translate key-value content across locales through any OpenAI-compatible LLM, with brand voice and glossary enforcement.
3
+ **A free, open-source localization tool you run yourself — a DIY [Lingo.dev](https://lingo.dev).**
4
4
 
5
- Named after the Rosetta Stone: one text, three scripts, every language readable.
5
+ Edit your source-language strings, run `rosetta push`, and every target locale gets
6
+ the new and changed strings translated by an LLM — with your brand voice, rules, and
7
+ glossary applied, and every placeholder, ICU plural, and `<tag>` validated before
8
+ anything is written. It runs on your machine or CI runner with **your own API key**
9
+ against any OpenAI-compatible model (OpenRouter, OpenAI, Ollama, …). No account, no
10
+ server, no per-seat SaaS — all state lives in your repo.
6
11
 
7
- No vendor lock-in and no per-seat translation SaaS — bring your own LLM endpoint and API key.
8
-
9
- ## Install
12
+ Named after the Rosetta Stone: one text, every language readable.
10
13
 
11
14
  ```bash
12
- pnpm add rosetta-i18n
13
- # or
14
- npm install rosetta-i18n
15
+ npm install --save-dev rosetta-i18n
16
+ npx rosetta init # once: writes .rosetta/config.json
17
+ npx rosetta push # translate what changed, write files + .rosetta/lock.json
18
+ npx rosetta check # CI gate: fails if anything is stale or broken (no API key)
15
19
  ```
16
20
 
21
+ > The npm package is **`rosetta-i18n`** and installs a `rosetta` command. Without a
22
+ > local install, use `npx rosetta-i18n <command>`. A bare `npx rosetta` would
23
+ > fetch an unrelated package.
24
+
25
+ ## Contents
26
+
27
+ - [Quick start](#quick-start)
28
+ - [How it works](#how-it-works)
29
+ - [Configuration](#configuration)
30
+ - [Commands](#commands)
31
+ - [Validation](#validation)
32
+ - [Getting translations into your repo](#getting-translations-into-your-repo)
33
+ - [Coming from Lingo.dev](#coming-from-lingodev)
34
+ - [Library API](#library-api)
35
+ - [React & Next.js](#react--nextjs)
36
+ - [Development](#development)
37
+
17
38
  ## Quick start
18
39
 
40
+ Requires Node 20+.
41
+
42
+ ```bash
43
+ npm install --save-dev rosetta-i18n # or pnpm add -D / bun add -d
44
+ npx rosetta init
45
+ ```
46
+
47
+ `init` finds your source-locale files (`messages/en.json`, `locales/en/*.json`,
48
+ `pages.en.json`, …), infers target locales from the files next to them, and writes
49
+ `.rosetta/config.json`. Pass `--pattern`, `--target`, or `--model` to set them
50
+ explicitly, or `--from-lingo` to import a Lingo.dev config.
51
+
52
+ Then set a key and push:
53
+
54
+ ```bash
55
+ export OPENROUTER_API_KEY=sk-or-... # your model provider's key
56
+ npx rosetta push
57
+ ```
58
+
59
+ The key is your model provider's key (OpenRouter by default). Rosetta has no account,
60
+ server, or key of its own; set `engine.apiKeyEnv` to use another provider's variable,
61
+ e.g. `OPENAI_API_KEY`.
62
+
63
+ Commit `.rosetta/` together with the translated files. From now on the loop is:
64
+ **edit English → `rosetta push` → commit.** Add `rosetta check` to CI to catch
65
+ anything that was missed.
66
+
67
+ Already have translations? The first `push` **adopts** them — it records them in
68
+ the lockfile without retranslating, so it's free. Only strings that are missing,
69
+ changed, or broken go to the model.
70
+
71
+ ## How it works
72
+
73
+ Rosetta keeps a lockfile, `.rosetta/lock.json`, that records — for every file,
74
+ locale, and key — a fingerprint of the source string each translation was made
75
+ from. On `push`, each key falls into exactly one bucket:
76
+
77
+ | Situation | What `push` does |
78
+ | --- | --- |
79
+ | New key, or the target is missing/empty | Translate it |
80
+ | **Source string changed** since it was translated | Retranslate it |
81
+ | Existing translation fails [validation](#validation) (e.g. a dropped `{placeholder}`) | Retranslate it |
82
+ | Source unchanged | Keep the translation — **including hand edits** |
83
+ | Translation exists but isn't in the lockfile yet | Adopt it (no model call) |
84
+ | Key removed from the source | Remove it from every target |
85
+ | Key in `lockedKeys`, or not translatable (URL, number, date, `{count}`…) | Copy the source value |
86
+ | Key in `preservedKeys` | Translate once, then never overwrite |
87
+ | Key in `ignoredKeys` | Leave it out of target files |
88
+
89
+ Guarantees:
90
+
91
+ - **Only changed strings cost tokens.** A 10,000-key catalog with 12 edits sends 12 keys.
92
+ - **Edited source strings are always picked up** — no force flags or manual cleanup.
93
+ - **No partial files.** Every translated value is validated; failing keys are retried
94
+ on their own. If a key still fails, that locale's file and lock entries are left
95
+ untouched and the error is reported. Other locales still land.
96
+ - **Output mirrors the source.** Target files keep the source's structure and key
97
+ order (2-space JSON, trailing newline), so diffs stay small and reviewable.
98
+
99
+ The full behavior is specified in [`docs/spec-v1.md`](docs/spec-v1.md).
100
+
101
+ ## Configuration
102
+
103
+ ```
104
+ .rosetta/
105
+ config.json # hand-edited (comments allowed)
106
+ lock.json # written by Rosetta — commit it, don't edit it
107
+ glossary.json # optional
108
+ voice/*.md # optional brand-voice files
109
+ ```
110
+
111
+ Rosetta finds the config by walking up from the current directory, so commands work
112
+ from any subdirectory. Paths in `files` are relative to the directory containing
113
+ `.rosetta/`.
114
+
115
+ ```jsonc
116
+ // .rosetta/config.json
117
+ {
118
+ "$schema": "https://unpkg.com/rosetta-i18n@1/schema/config.json",
119
+ "sourceLocale": "en",
120
+ "targetLocales": ["es", "ja", "zh-CN"],
121
+ "files": [
122
+ { "pattern": "messages/en.json", "context": "Web app UI (next-intl)." },
123
+ {
124
+ "pattern": "content/en/**/*.json",
125
+ "lockedKeys": ["**.href", "meta.version"],
126
+ "preservedKeys": ["legal"],
127
+ "ignoredKeys": ["internal"]
128
+ }
129
+ ],
130
+ "engine": {
131
+ "model": "anthropic/claude-sonnet-4.5",
132
+ "baseURL": "https://openrouter.ai/api/v1",
133
+ "brandVoice": { "*": "voice/default.md", "ja": "Polite and concise (です/ます)." },
134
+ "rules": ["Never translate the product name Acme."],
135
+ "glossary": "glossary.json"
136
+ }
137
+ }
138
+ ```
139
+
140
+ ### Top-level fields
141
+
142
+ | Field | Required | Description |
143
+ | --- | --- | --- |
144
+ | `sourceLocale` | yes | Locale your source files are written in, e.g. `en`. Source files are never written. |
145
+ | `targetLocales` | yes | Locales to translate into. |
146
+ | `files` | yes | Source files to translate (see below). |
147
+ | `engine` | for `push` | Model, endpoint, and prompt settings (see below). `check`/`status`/`purge` don't need it. |
148
+
149
+ ### `files[]`
150
+
151
+ | Field | Description |
152
+ | --- | --- |
153
+ | `pattern` | Source file path or glob (`*`, `**`). Must contain the source locale — see [target paths](#target-paths). |
154
+ | `format` | `json` or `jsonc` (default: from the extension). |
155
+ | `context` | Sent with every request for this file ("Checkout flow", "Marketing site"…). |
156
+ | `lockedKeys` | Copied from the source untranslated, kept in sync with it. |
157
+ | `preservedKeys` | Translated the first time, never overwritten afterwards (reviewed legal copy, etc.). |
158
+ | `ignoredKeys` | Omitted from target files. |
159
+
160
+ Key patterns use dotted paths: `auth.login` matches that key and everything under it
161
+ (but not `auth.loginUrl`), `*` matches one segment, and `**` matches any depth —
162
+ `**.href` matches every `href`. If a key matches more than one control, ignored wins
163
+ over locked, which wins over preserved.
164
+
165
+ ### Target paths
166
+
167
+ Target paths are derived by swapping the locale code into the source path:
168
+
169
+ | Source pattern | Target (for `es`) |
170
+ | --- | --- |
171
+ | `messages/en.json` | `messages/es.json` |
172
+ | `locales/en/**/*.json` | `locales/es/**/*.json` (subtree mirrored) |
173
+ | `lib/pages.en.json` | `lib/pages.es.json` |
174
+ | `l10n/app_en.json` | `l10n/app_es.json` |
175
+
176
+ If the source locale appears nowhere in the path, Rosetta refuses the pattern rather
177
+ than guessing where targets should go.
178
+
179
+ ### `engine`
180
+
181
+ | Field | Default | Description |
182
+ | --- | --- | --- |
183
+ | `model` | — | Model id. Overridden by `ROSETTA_MODEL`. |
184
+ | `baseURL` | `https://openrouter.ai/api/v1` | Any OpenAI-compatible endpoint, e.g. `http://localhost:11434/v1` for Ollama. Overridden by `ROSETTA_BASE_URL`. |
185
+ | `apiKeyEnv` | `OPENROUTER_API_KEY` | Env var holding your model provider's key, e.g. `OPENAI_API_KEY` when `baseURL` points at OpenAI. |
186
+ | `brandVoice` | — | Map of locale (or `*` fallback) to a voice briefing, inline or as a path to a `.md`/`.txt` file in `.rosetta/`. |
187
+ | `rules` | — | String or list of linguistic rules added to every prompt. |
188
+ | `glossary` | — | Inline object or path to a JSON/JSONC file: `{ "ja": { "workspace": "ワークスペース" }, "*": { "Acme": "Acme" } }`. Terms under `*` are never translated. |
189
+ | `temperature` | `0.3` | Sampling temperature. |
190
+ | `batchSize` | `25` | Max keys per request. |
191
+ | `concurrency` | `4` | Max requests in flight across all locales. |
192
+ | `retries` | `2` | Retries per request and per failing key. |
193
+ | `timeoutMs` | `120000` | Per-request timeout. |
194
+
195
+ The prompt applies, in order: brand voice → rules → glossary → output rules.
196
+
197
+ ### Translator notes (JSONC)
198
+
199
+ In `.jsonc` source files, a comment above a key — or at the end of its line — is sent
200
+ to the model as context for that key. A comment above an object applies to every key
201
+ inside it. Notes never appear in the output.
202
+
203
+ ```jsonc
204
+ {
205
+ // Top navigation — keep labels short
206
+ "nav": {
207
+ "light": "Light", // the theme, not weight
208
+ "records": "Records"
209
+ }
210
+ }
211
+ ```
212
+
213
+ ## Commands
214
+
215
+ | Command | What it does |
216
+ | --- | --- |
217
+ | `rosetta init` | Create `.rosetta/config.json`. Flags: `--pattern`, `--target`, `--source`, `--model`, `--from-lingo`, `--yes`. Never overwrites an existing config. |
218
+ | `rosetta push [patterns…]` | Translate new/changed strings, prune removed ones, write targets and the lockfile. |
219
+ | `rosetta check [patterns…]` | Exit 3 if any target is stale, missing, untracked, has extra keys, or fails validation. Never calls a model. |
220
+ | `rosetta status [patterns…]` | Show per-locale counts of what `push` would translate, adopt, and remove. `--exit-code` exits 3 if anything is pending. |
221
+ | `rosetta purge --locale <code>` | Delete a locale's files and lock entries so the next `push` redoes it. |
222
+ | `rosetta pull` | No-op, so Lingo.dev scripts keep working — Rosetta translates locally in `push`. |
223
+
224
+ `push` flags:
225
+
226
+ | Flag | Description |
227
+ | --- | --- |
228
+ | `--locale <code>` | Only these locales (repeatable or comma-separated). |
229
+ | `--key <pattern>` | Retranslate matching keys even if the source is unchanged — the cheap way to redo a few strings. |
230
+ | `--force` | Retranslate everything in scope (e.g. after changing model or glossary). Asks for confirmation. |
231
+ | `--estimate`, `--dry-run` | Print the plan and a rough token estimate; don't call the model. |
232
+ | `-y`, `--yes` | Skip confirmations. Implied when `CI` is set. |
233
+ | `--backfill-missing` | Accepted for Lingo.dev compatibility; missing files are always backfilled. |
234
+
235
+ Positional patterns (`rosetta push 'content/en/**'`) limit a run to matching source
236
+ files.
237
+
238
+ Global flags: `--config <path>`, `--json`, `--quiet`, `--help`, `--version`.
239
+
240
+ ### Scripting, agents, and CI
241
+
242
+ - **`--json`** on any command prints a single JSON result to stdout — including
243
+ failures: `{ "ok": false, "error": { "type": "ConfigError", "message": "…" } }`.
244
+ - **Exit codes** are stable:
245
+
246
+ | Code | Meaning |
247
+ | --- | --- |
248
+ | `0` | Success (for `check`: everything up to date) |
249
+ | `1` | One or more locales failed to translate and were not written |
250
+ | `2` | Usage or config error, including a missing API key |
251
+ | `3` | `check` / `status --exit-code` found stale or broken translations |
252
+
253
+ - **Never hangs:** outside a TTY, destructive commands (`push --force`, `purge`)
254
+ require `--yes` instead of prompting. `CI=true` implies `--yes`.
255
+ - **API key only when needed:** `push` asks for a key only if something actually
256
+ needs translating, so adopting or pruning works without one.
257
+
258
+ ### Environment variables
259
+
260
+ | Variable | Description |
261
+ | --- | --- |
262
+ | `OPENROUTER_API_KEY` | Your model provider's API key (or whatever variable `engine.apiKeyEnv` names). |
263
+ | `ROSETTA_MODEL` | Overrides `engine.model`. |
264
+ | `ROSETTA_BASE_URL` | Overrides `engine.baseURL`. |
265
+ | `CI` | Non-interactive mode; implies `--yes`. |
266
+
267
+ ## Validation
268
+
269
+ Every translation the model returns — and every existing translation, on `push` and
270
+ `check` — is checked against its source string:
271
+
272
+ - the same `{placeholders}`, with none dropped, renamed, or added;
273
+ - the same ICU arguments and types (`plural`, `select`, `selectordinal`, `number`,
274
+ `date`, `time`), the same `select` cases, and `plural` keeping its `other` case —
275
+ while allowing each language's own plural categories (`zero`/`few`/`many`…);
276
+ - the same rich-text tags (`<strong>…</strong>`, `<link/>`), balanced;
277
+ - a non-empty string.
278
+
279
+ Sources that aren't ICU (e.g. i18next `{{name}}`) fall back to comparing
280
+ placeholder-looking tokens and tag names. Rosetta also asks for JSON output, strips
281
+ code fences, retries with exponential backoff (honoring `Retry-After`), and times out
282
+ hung requests.
283
+
284
+ ## Getting translations into your repo
285
+
286
+ Rosetta doesn't prescribe how translations land. Pick what fits your team:
287
+
288
+ - **Locally or with a coding agent:** run `rosetta push` and commit the result with
289
+ the English change. Agents can use `rosetta status --json` and `rosetta push --json`.
290
+ - **CI gate:** run `rosetta check` on pull requests. It needs no API key, so forks
291
+ stay green.
292
+ - **Pull request bot:** after merges, translate what changed and open or update one
293
+ rolling PR.
294
+ - **Commit to the branch:** translate and commit straight to `main` (or to the PR
295
+ branch).
296
+
297
+ The GitHub Action covers the last three:
298
+
299
+ ```yaml
300
+ # Check on every PR (no secret needed)
301
+ - uses: ian/rosetta@v1
302
+ with:
303
+ mode: check
304
+
305
+ # After merges to main: translate + create/update a rolling PR
306
+ - uses: ian/rosetta@v1
307
+ with:
308
+ mode: pull-request # or: commit
309
+ token: ${{ steps.app.outputs.token }}
310
+ env:
311
+ OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
312
+ ```
313
+
314
+ It stages only translation files and the lockfile. It lands the locales that
315
+ succeeded even when others fail, lists failing keys in the PR body and job summary,
316
+ and exposes `changed`, `pr-url`, and `commit-sha` outputs.
317
+
318
+ GitHub doesn't run other workflows on commits or PRs made with the default
319
+ `GITHUB_TOKEN`. Use a GitHub App token or PAT if CI must run on the bot's changes.
320
+ [`docs/delivery.md`](docs/delivery.md) has complete workflows for each option, the
321
+ full Action reference, and the token details. [`examples/next-intl`](examples/next-intl)
322
+ is a working setup.
323
+
324
+ > Rosetta writes target files as 2-space JSON with a trailing newline, mirroring the
325
+ > source's key order. If a formatter in your repo rewrites them differently, exclude
326
+ > the generated locale files from it. Otherwise the formatter and `rosetta push` will
327
+ > keep producing formatting-only diffs. `check` compares values, so it isn't affected.
328
+
329
+ ## Coming from Lingo.dev
330
+
331
+ Rosetta mirrors the Lingo.dev CLI's layout and vocabulary, but everything their
332
+ hosted engine does runs locally from your config.
333
+
334
+ ```bash
335
+ npx rosetta init --from-lingo # imports .lingo/config.json or legacy i18n.json
336
+ ```
337
+
338
+ The step-by-step guide is [`docs/migrating-from-lingo.md`](docs/migrating-from-lingo.md).
339
+
340
+ | Lingo.dev | Rosetta |
341
+ | --- | --- |
342
+ | `.lingo/config.json` | `.rosetta/config.json` |
343
+ | `.lingo/lock.json` / `i18n.lock` | `.rosetta/lock.json` (per-key source hashes) |
344
+ | `sourceLocale`, `targetLocales`, `files[].pattern` | same |
345
+ | `lockedKeys`, `preservedKeys`, `ignoredKeys` | same (dotted paths; legacy `a/b` paths are converted) |
346
+ | `orgId` + `engineId` (hosted engine) | `engine` in the config: model, brand voice, rules, glossary |
347
+ | `lingo push` (`--key`, `--force`, `--estimate`) | `rosetta push` (same flags) |
348
+ | `lingo pull` | not needed — `push` writes the files directly |
349
+ | `lingo check` | `rosetta check` |
350
+ | `lingo purge --locale` | `rosetta purge --locale` |
351
+ | `LINGO_API_KEY` | none: your model provider's key (`OPENROUTER_API_KEY`) |
352
+
353
+ Rosetta v1 supports JSON and JSONC; other formats are skipped on import with a
354
+ warning. Markdown/MDX support is tracked in
355
+ [#1](https://github.com/ian/rosetta/issues/1).
356
+
357
+ ## Library API
358
+
359
+ The engine behind the CLI is also usable directly.
360
+
19
361
  ```ts
20
362
  import { Rosetta } from "rosetta-i18n";
21
363
 
22
364
  const rosetta = new Rosetta({
23
- apiKey: process.env.OPENROUTER_API_KEY,
365
+ apiKey: process.env.OPENROUTER_API_KEY!,
24
366
  model: "anthropic/claude-sonnet-4.5",
25
367
  brandVoice: {
26
368
  variations: {
@@ -28,139 +370,106 @@ const rosetta = new Rosetta({
28
370
  es: "Tono editorial de gastronomía, accesible. Tercera persona.",
29
371
  },
30
372
  },
31
- glossary: {
32
- es: { "award-winning": "premiado" },
33
- },
373
+ rules: ["Keep product names in English."],
374
+ glossary: { es: { "award-winning": "premiado" } },
34
375
  });
35
376
 
36
377
  const translated = await rosetta.translate(
37
- { hero: "Every awarded restaurant in the world" },
38
- { source: "en", target: "es" },
378
+ { hero: "Every awarded restaurant in the world", count: "{n, plural, one {# place} other {# places}}" },
379
+ { source: "en", target: "es", context: "home page hero" },
39
380
  );
40
- ```
41
-
42
- ### Single strings
43
381
 
44
- ```ts
45
382
  const text = await rosetta.translateText("Every awarded restaurant", {
46
383
  source: "en",
47
384
  target: "ja",
48
385
  });
49
386
  ```
50
387
 
51
- ## How it works
52
-
53
- Every request is sent to an OpenAI-compatible `/chat/completions` endpoint
54
- (OpenRouter by default). The system prompt layers three signals, in this
55
- precedence:
56
-
57
- 1. **Glossary** — exact term match, overrides model judgment
58
- 2. **Rules** — locale-specific conventions embedded in the brand voice
59
- 3. **Brand voice** — sets overall tone per locale
60
-
61
- The user prompt carries the payload plus optional broad context and per-key
62
- disambiguation hints.
63
-
64
- ## API
65
-
66
388
  ### `new Rosetta(config)`
67
389
 
68
390
  | Option | Type | Default | Description |
69
391
  | --- | --- | --- | --- |
70
392
  | `apiKey` | `string` | — | **Required.** Key for the LLM endpoint. |
71
- | `model` | `string` | — | **Required.** Model id, e.g. `anthropic/claude-sonnet-4.5`. |
72
- | `brandVoice` | `BrandVoice` | — | **Required.** Tone + conventions per locale. `*` is the fallback. |
393
+ | `model` | `string` | — | **Required.** Model id. |
394
+ | `brandVoice` | `BrandVoice` | — | **Required.** `{ variations: { [locale or "*"]: text } }`. |
73
395
  | `baseURL` | `string` | `https://openrouter.ai/api/v1` | Any OpenAI-compatible endpoint. |
74
- | `glossary` | `Glossary` | `{}` | Exact term mappings per locale. |
396
+ | `rules` | `string[]` | — | Rules added to every prompt. |
397
+ | `glossary` | `Glossary` | — | Exact term mappings per locale; `*` terms are never translated. |
75
398
  | `temperature` | `number` | `0.3` | Sampling temperature. |
76
- | `batchSize` | `number` | `25` | Keys per LLM request. |
77
- | `concurrency` | `number` | `4` | Parallel batch requests in flight. |
78
- | `retries` | `number` | `2` | Retries per batch on failure. |
399
+ | `batchSize` | `number` | `25` | Max keys per request. |
400
+ | `concurrency` | `number` | `4` | Max requests in flight for this instance. |
401
+ | `retries` | `number` | `2` | Retries per request and per failing key. |
402
+ | `timeoutMs` | `number` | `120000` | Per-request timeout. |
403
+ | `onBatchError` | `"throw" \| "skip"` | `"throw"` | What `translate()` does when keys still fail. |
79
404
 
80
- ### `rosetta.translate(data, options)`
405
+ ### Methods
81
406
 
82
- Translates a key-value payload. Keys are preserved; only values are translated.
83
- Batches run concurrently and failed batches are logged and skipped, so the
84
- result carries only successfully translated keys — the caller can fall back to
85
- the source locale for the rest.
407
+ - **`translate(data, options)`**: translates a key-value payload with validation and
408
+ targeted retries. Throws `RosettaValidationError` (with `.failures` and `.partial`)
409
+ if any key still fails. With `onBatchError: "skip"`, it logs failures and returns
410
+ only the keys that succeeded (the 0.x behavior). Options: `source`, `target`,
411
+ `context`, `notes` (per-key), `hints` (per-key breadcrumbs).
412
+ - **`translateEntries(data, options)`**: like `translate` but never throws for
413
+ per-key problems; it returns `{ translations, failures, usage }`.
414
+ - **`translateText(text, options)`**: translates one string and throws on a non-OK
415
+ response.
86
416
 
87
- ```ts
88
- const out = await rosetta.translate(
89
- { hero: "…", subhead: "…" },
90
- {
91
- source: "en",
92
- target: "pt-BR",
93
- context: "restaurant listing page",
94
- hints: { hero: ["home", "top banner"] },
95
- },
96
- );
97
- ```
417
+ ### Programmatic workflow
98
418
 
99
- ### `rosetta.translateText(text, options)`
419
+ The CLI commands are exported too:
100
420
 
101
- Translates a single string. Throws on a non-OK response (unlike `translate`,
102
- which degrades gracefully at the batch level).
421
+ ```ts
422
+ import { check, loadConfig, push } from "rosetta-i18n";
103
423
 
104
- ## CLI
424
+ const project = loadConfig(); // finds .rosetta/config.json
425
+ const result = await push(project, { locales: ["es"] });
426
+ if (!result.ok) console.error(result.locales.flatMap((l) => l.errors));
427
+ console.log(check(project).ok);
428
+ ```
105
429
 
106
- Translate a JSON catalog from the command line — run it in CI or as a release
107
- step. It reads a source catalog, translates it, and writes one file per target
108
- locale.
430
+ `push` accepts a custom `translator` implementing `translateEntries`, which is
431
+ useful for tests or for routing through your own service. Also exported:
432
+ `plan`, `purge`, `validateTranslation`, `parseMessage`, `resolveTargetPath`.
109
433
 
110
- ```bash
111
- OPENROUTER_API_KEY=sk-... npx rosetta-i18n translate-catalog messages/en.json \
112
- --target es --target pt-BR \
113
- --merge \
114
- --context "Next.js UI catalog"
115
- ```
434
+ ## React & Next.js
116
435
 
117
- Writes `messages/es.json` and `messages/pt-BR.json` (flattened keys preserved).
436
+ Rosetta calls an LLM with your API key, so it runs **server-side only**. Never import
437
+ it in a Client Component (`"use client"`). Keep the key out of client bundles with
438
+ [`server-only`](https://www.npmjs.com/package/server-only) and expose translations
439
+ through server code.
118
440
 
119
- | Flag | Description |
120
- | --- | --- |
121
- | `--target <locale>` | Target locale (repeatable, required) |
122
- | `--source <locale>` | Source locale (default `en`) |
123
- | `--out <dir>` | Output directory (default: the input file's directory) |
124
- | `--merge` | Only translate keys missing from an existing `<target>.json` |
125
- | `--context <text>` | Broad context passed to the model |
126
- | `--brand-voice <text>` | Brand voice briefing (default `ROSETTA_BRAND_VOICE`) |
127
- | `--glossary <file>` | JSON file of per-locale exact term mappings |
128
- | `--model <id>` / `--base-url <url>` | Override the endpoint/model |
129
- | `--concurrency` / `--batch-size` / `--retries` | Tune batching |
130
- | `--dry-run` | Print the plan without calling the model |
131
-
132
- `--merge` is the CI-friendly mode: add keys to `en.json`, rerun, and only the new
133
- keys hit the model — existing translations are preserved for diffing and review.
441
+ ### Recommended: pre-translate message catalogs
134
442
 
135
- ## React & Next.js
443
+ For `next-intl` / `react-i18next`, translate the locale JSON with `rosetta push` and
444
+ ship it, so there's no LLM call in the request path:
445
+
446
+ ```ts
447
+ // i18n/request.ts (next-intl)
448
+ import { getRequestConfig } from "next-intl/server";
136
449
 
137
- Rosetta calls an LLM with your API key, so it runs **server-side only**. Never
138
- import it in a Client Component (`"use client"`) — bundle the key out with
139
- [`server-only`](https://www.npmjs.com/package/server-only) and expose
140
- translations through server code.
450
+ export default getRequestConfig(async ({ locale }) => ({
451
+ messages: (await import(`../messages/${locale}.json`)).default,
452
+ }));
453
+ ```
141
454
 
142
455
  ### Server helpers (`rosetta-i18n/next`)
143
456
 
144
- The `next` entrypoint (optional `next` peer) wires env config, a memoized
145
- instance, and Next's data cache:
457
+ For dynamic content (CMS entries, user-generated text), the `next` entrypoint
458
+ (optional `next` peer) provides env config, a memoized instance, and Next's data
459
+ cache:
146
460
 
147
461
  ```ts
148
462
  // lib/rosetta.ts
149
463
  import "server-only";
150
464
  import { cachedTranslate, getRosetta } from "rosetta-i18n/next";
151
465
 
152
- // Memoized instance built from env (see table below).
153
466
  export const rosetta = getRosetta();
154
467
 
155
- // Cached translation — keyed on source/target/payload.
156
- export async function translateCopy(
157
- data: Record<string, string>,
158
- target: string,
159
- ) {
160
- return cachedTranslate(data, { source: "en", target }, {
161
- revalidate: 60 * 60 * 24,
162
- tags: ["i18n"],
163
- });
468
+ export async function translateCopy(data: Record<string, string>, target: string) {
469
+ return cachedTranslate(data, { source: "en", target }, {
470
+ revalidate: 60 * 60 * 24,
471
+ tags: ["i18n"],
472
+ });
164
473
  }
165
474
  ```
166
475
 
@@ -171,31 +480,16 @@ export async function translateCopy(
171
480
  | `ROSETTA_BASE_URL` | no | `https://openrouter.ai/api/v1` |
172
481
  | `ROSETTA_BRAND_VOICE` | no | — (the `*` brand voice) |
173
482
 
174
- `createRosetta(overrides)` is also exported if you'd rather pass config
175
- explicitly.
176
-
177
- ### Recommended: pre-translate message catalogs with the CLI
178
-
179
- For `next-intl` / `react-i18next`, translate the locale JSON once and ship it —
180
- no LLM call in the request path. Use the [CLI](#cli) above, then load the files:
181
-
182
- ```ts
183
- // i18n/request.ts (next-intl)
184
- import { getRequestConfig } from "next-intl/server";
185
-
186
- export default getRequestConfig(async ({ locale }) => ({
187
- messages: (await import(`../messages/${locale}.json`)).default,
188
- }));
189
- ```
483
+ `createRosetta(overrides)` is also exported if you'd rather pass config explicitly.
190
484
 
191
485
  ### Server Component (RSC)
192
486
 
193
487
  ```tsx
194
488
  // app/[locale]/hero.tsx
195
- import { getRosetta } from "rosetta-i18n/next";
489
+ import { cachedTranslate } from "rosetta-i18n/next";
196
490
 
197
491
  export async function Hero({ locale }: { locale: string }) {
198
- const copy = await getRosetta().translate(
492
+ const copy = await cachedTranslate(
199
493
  { hero: "Every awarded restaurant in the world" },
200
494
  { source: "en", target: locale, context: "home hero" },
201
495
  );
@@ -203,10 +497,6 @@ export async function Hero({ locale }: { locale: string }) {
203
497
  }
204
498
  ```
205
499
 
206
- Prefer `cachedTranslate` from `rosetta-i18n/next` (above) or React's `cache` so
207
- repeated renders reuse the result instead of re-calling the model.
208
-
209
-
210
500
  ### Route Handler
211
501
 
212
502
  ```ts
@@ -216,11 +506,7 @@ import { rosetta } from "@/lib/rosetta";
216
506
 
217
507
  export async function POST(req: Request) {
218
508
  const { data, target, context } = await req.json();
219
- const translated = await rosetta.translate(data, {
220
- source: "en",
221
- target,
222
- context,
223
- });
509
+ const translated = await rosetta.translate(data, { source: "en", target, context });
224
510
  return NextResponse.json(translated);
225
511
  }
226
512
  ```
@@ -239,27 +525,55 @@ export async function translateBlurb(blurb: string, target: string) {
239
525
 
240
526
  ### Client Components
241
527
 
242
- Client Components consume already-translated strings via props, context, or the
243
- catalog — they never import Rosetta. To trigger a translation from the browser,
244
- call the route handler or Server Action above.
528
+ Client Components consume already-translated strings through props, context, or the
529
+ catalog; they never import Rosetta. To trigger a translation from the browser, call
530
+ the route handler or Server Action above.
531
+
532
+ > **Edge runtime:** `rosetta-i18n/next` only uses `fetch`, so it works on the Edge
533
+ > runtime as long as your endpoint does. The main `rosetta-i18n` entry also exports the
534
+ > Node-only workflow (`push`, `check`, …, which read and write files), so prefer the
535
+ > `next` entrypoint in edge code.
536
+
537
+ ## Legacy `translate-catalog`
538
+
539
+ The 0.x one-shot command still works but is deprecated and will be removed in v2:
540
+
541
+ ```bash
542
+ npx rosetta translate-catalog messages/en.json --target es --merge
543
+ ```
544
+
545
+ Replace it with a `.rosetta/config.json` (`rosetta init --pattern messages/en.json --target es`)
546
+ and `rosetta push`, which also retranslates edited strings, validates output, and
547
+ never writes partial files.
548
+
549
+ ## Migrating an existing app
245
550
 
246
- > **Edge runtime:** Rosetta only uses `fetch`, so it works on the Edge runtime as
247
- > long as your endpoint does. Node runtime is recommended for large catalogs.
551
+ - [`docs/migration-guide.md`](docs/migration-guide.md): a playbook for switching an app
552
+ from another translation stack (inventory → modes → verification).
553
+ - [`docs/migrating-from-lingo.md`](docs/migrating-from-lingo.md): the Lingo.dev-specific
554
+ path (config import, engine, CI).
555
+ - [`docs/agent-prompt.md`](docs/agent-prompt.md): a ready-to-run agent prompt that
556
+ executes the migration.
248
557
 
249
558
  ## Releasing
250
559
 
251
560
  Releases publish automatically from GitHub Actions via npm
252
- [trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC) — no npm
253
- token required.
561
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC), so no npm token
562
+ is required.
254
563
 
255
564
  ```bash
256
565
  pnpm bump patch # bump package.json, commit, tag, push
257
566
  pnpm release # create the GitHub Release -> triggers npm-publish
258
567
  ```
259
568
 
260
- - `pnpm bump` accepts `patch`, `minor`, or `major` (default `patch`).
261
- - `pnpm release` creates the GitHub Release for the latest tag with generated
262
- release notes, which triggers `.github/workflows/npm-publish.yml`.
569
+ - `pnpm bump` accepts `patch`, `minor`, or `major` (default `patch`); `premajor`,
570
+ `preminor`, `prepatch`, or `prerelease` for a `-beta.N` prerelease; or an explicit
571
+ version such as `1.0.0-beta.1`.
572
+ - Prereleases publish under npm's `next` dist-tag (so `latest` doesn't move) and are
573
+ marked as prereleases on GitHub. Stable releases also move the `v<major>` tag that
574
+ `uses: ian/rosetta@v1` resolves to.
575
+ - `pnpm release` creates the GitHub Release for the latest tag with generated release
576
+ notes, which triggers `.github/workflows/npm-publish.yml`.
263
577
 
264
578
  One-time setup (first publish and Trusted Publisher) is documented in
265
579
  [`.github/workflows/npm-publish.yml`](.github/workflows/npm-publish.yml).
@@ -274,9 +588,11 @@ pnpm test
274
588
  pnpm build
275
589
  ```
276
590
 
277
- Source lives in `src/`. `rollup` produces `dist/esm`, `tsc` emits declarations
278
- to `dist/types`.
591
+ Source lives in `src/`: the translation engine in `src/rosetta.ts`, validation in
592
+ `src/validate.ts`, the config/lockfile/planner/workflow in `src/project/`, and the CLI
593
+ in `src/cli.ts`. `rollup` produces `dist/esm`; `tsc` emits declarations to
594
+ `dist/types`.
279
595
 
280
596
  ## License
281
597
 
282
- MIT © Ian Hunter
598
+ MIT © Ian Hunter