rosetta-i18n 0.2.1 → 1.0.0-beta.2

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 (42) hide show
  1. package/README.md +595 -141
  2. package/dist/esm/astro.js +103 -0
  3. package/dist/esm/astro.js.map +1 -0
  4. package/dist/esm/cli.js +2544 -104
  5. package/dist/esm/cli.js.map +1 -1
  6. package/dist/esm/config-BePhmTW6.js +497 -0
  7. package/dist/esm/config-BePhmTW6.js.map +1 -0
  8. package/dist/esm/config.js +4 -0
  9. package/dist/esm/config.js.map +1 -0
  10. package/dist/esm/index.js +1212 -83
  11. package/dist/esm/index.js.map +1 -1
  12. package/dist/esm/locale.js +95 -0
  13. package/dist/esm/locale.js.map +1 -0
  14. package/dist/esm/next.js +712 -84
  15. package/dist/esm/next.js.map +1 -1
  16. package/dist/esm/react.js +150 -0
  17. package/dist/esm/react.js.map +1 -0
  18. package/dist/types/cli.d.ts +28 -45
  19. package/dist/types/config.d.ts +2 -42
  20. package/dist/types/index.d.ts +8 -28
  21. package/dist/types/legacy.d.ts +50 -0
  22. package/dist/types/locale/astro.d.ts +33 -0
  23. package/dist/types/locale/core.d.ts +102 -0
  24. package/dist/types/locale/next.d.ts +56 -0
  25. package/dist/types/next.d.ts +4 -3
  26. package/dist/types/project/config.d.ts +78 -0
  27. package/dist/types/project/entries.d.ts +22 -0
  28. package/dist/types/project/glob.d.ts +18 -0
  29. package/dist/types/project/init.d.ts +36 -0
  30. package/dist/types/project/jsonc.d.ts +18 -0
  31. package/dist/types/project/lock.d.ts +13 -0
  32. package/dist/types/project/paths.d.ts +16 -0
  33. package/dist/types/project/plan.d.ts +45 -0
  34. package/dist/types/project/workflow.d.ts +112 -0
  35. package/dist/types/prompt.d.ts +11 -7
  36. package/dist/types/react.d.ts +59 -0
  37. package/dist/types/rosetta.d.ts +51 -0
  38. package/dist/types/types.d.ts +80 -0
  39. package/dist/types/validate.d.ts +29 -0
  40. package/package.json +29 -4
  41. package/schema/config.json +138 -0
  42. package/skills/rosetta/SKILL.md +319 -0
package/README.md CHANGED
@@ -1,26 +1,390 @@
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
+ - [Set up with your AI agent](#set-up-with-your-ai-agent)
29
+ - [How it works](#how-it-works)
30
+ - [Configuration](#configuration)
31
+ - [Commands](#commands)
32
+ - [Validation](#validation)
33
+ - [Getting translations into your repo](#getting-translations-into-your-repo)
34
+ - [Coming from Lingo.dev](#coming-from-lingodev)
35
+ - [Library API](#library-api)
36
+ - [React & Next.js](#react--nextjs)
37
+ - [Locale routing & cookies](#locale-routing--cookies)
38
+ - [Development](#development)
39
+
17
40
  ## Quick start
18
41
 
42
+ Requires Node 20+.
43
+
44
+ ```bash
45
+ npm install --save-dev rosetta-i18n # or pnpm add -D / bun add -d
46
+ npx rosetta init
47
+ ```
48
+
49
+ `init` finds your source-locale files (`messages/en.json`, `locales/en/*.json`,
50
+ `pages.en.json`, …), infers target locales from the files next to them, and writes
51
+ `.rosetta/config.json`. Pass `--pattern`, `--target`, or `--model` to set them
52
+ explicitly, or `--from-lingo` to import a Lingo.dev config.
53
+
54
+ Then set a key and push:
55
+
56
+ ```bash
57
+ export OPENROUTER_API_KEY=sk-or-... # your model provider's key
58
+ npx rosetta push
59
+ ```
60
+
61
+ The key is your model provider's key (OpenRouter by default). Rosetta has no account,
62
+ server, or key of its own; set `engine.apiKeyEnv` to use another provider's variable,
63
+ e.g. `OPENAI_API_KEY`.
64
+
65
+ Commit `.rosetta/` together with the translated files. From now on the loop is:
66
+ **edit English → `rosetta push` → commit.** Add `rosetta check` to CI to catch
67
+ anything that was missed.
68
+
69
+ Already have translations? The first `push` **adopts** them — it records them in
70
+ the lockfile without retranslating, so it's free. Only strings that are missing,
71
+ changed, or broken go to the model.
72
+
73
+ ## Set up with your AI agent
74
+
75
+ Copy this into your coding agent (Claude Code, Cursor, Codex, opencode, …) from your
76
+ project root:
77
+
78
+ ```text
79
+ Set up Rosetta (npm: rosetta-i18n) for translations in this repo. Follow https://raw.githubusercontent.com/ian/rosetta/main/skills/rosetta/SKILL.md step by step: discover our locale files and current translation setup and report back, configure .rosetta/config.json, adopt our existing translations with `rosetta status` + `rosetta push` (list any translations it flags as broken), add tests and CI, remove the old translation tooling, document the workflow in AGENTS.md, and open one PR. Ask me before running `push --force`, before any large translation run, and before choosing how translations land if it's unclear.
80
+ ```
81
+
82
+ - For agents that can't fetch URLs, use the full, self-contained prompt in
83
+ [`docs/agent-prompt.md`](docs/agent-prompt.md).
84
+ - To give your agent Rosetta know-how for everyday requests ("add a German string",
85
+ "why is the i18n job red?"), install the skill:
86
+
87
+ ```bash
88
+ mkdir -p .claude/skills/rosetta && curl -fsSL https://raw.githubusercontent.com/ian/rosetta/main/skills/rosetta/SKILL.md -o .claude/skills/rosetta/SKILL.md
89
+ ```
90
+
91
+ Other agents: [`docs/agent-prompt.md#install-the-skill`](docs/agent-prompt.md#install-the-skill).
92
+
93
+ ## How it works
94
+
95
+ Rosetta keeps a lockfile, `.rosetta/lock.json`, that records — for every file,
96
+ locale, and key — a fingerprint of the source string each translation was made
97
+ from. On `push`, each key falls into exactly one bucket:
98
+
99
+ | Situation | What `push` does |
100
+ | --- | --- |
101
+ | New key, or the target is missing/empty | Translate it |
102
+ | **Source string changed** since it was translated | Retranslate it |
103
+ | Existing translation fails [validation](#validation) (e.g. a dropped `{placeholder}`) | Retranslate it |
104
+ | Source unchanged | Keep the translation — **including hand edits** |
105
+ | Translation exists but isn't in the lockfile yet | Adopt it (no model call) |
106
+ | Key removed from the source | Remove it from every target |
107
+ | Key in `lockedKeys`, or not translatable (URL, number, date, `{count}`…) | Copy the source value |
108
+ | Key in `preservedKeys` | Translate once, then never overwrite |
109
+ | Key in `ignoredKeys` | Leave it out of target files |
110
+
111
+ Guarantees:
112
+
113
+ - **Only changed strings cost tokens.** A 10,000-key catalog with 12 edits sends 12 keys.
114
+ - **Edited source strings are always picked up** — no force flags or manual cleanup.
115
+ - **No partial files.** Every translated value is validated; failing keys are retried
116
+ on their own. If a key still fails, that locale's file and lock entries are left
117
+ untouched and the error is reported. Other locales still land.
118
+ - **Output mirrors the source.** Target files keep the source's structure and key
119
+ order (2-space JSON, trailing newline), so diffs stay small and reviewable.
120
+
121
+ The full behavior is specified in [`docs/spec-v1.md`](docs/spec-v1.md).
122
+
123
+ ## Configuration
124
+
125
+ ```
126
+ .rosetta/
127
+ config.json # hand-edited (comments allowed)
128
+ lock.json # written by Rosetta — commit it, don't edit it
129
+ glossary.json # optional
130
+ voice/*.md # optional brand-voice files
131
+ ```
132
+
133
+ Rosetta finds the config by walking up from the current directory, so commands work
134
+ from any subdirectory. Paths in `files` are relative to the directory containing
135
+ `.rosetta/`.
136
+
137
+ ```jsonc
138
+ // .rosetta/config.json
139
+ {
140
+ "$schema": "https://unpkg.com/rosetta-i18n@1/schema/config.json",
141
+ "sourceLocale": "en",
142
+ "targetLocales": ["es", "ja", "zh-CN"],
143
+ "files": [
144
+ { "pattern": "messages/en.json", "context": "Web app UI (next-intl)." },
145
+ {
146
+ "pattern": "content/en/**/*.json",
147
+ "lockedKeys": ["**.href", "meta.version"],
148
+ "preservedKeys": ["legal"],
149
+ "ignoredKeys": ["internal"]
150
+ }
151
+ ],
152
+ "engine": {
153
+ "model": "anthropic/claude-sonnet-4.5",
154
+ "baseURL": "https://openrouter.ai/api/v1",
155
+ "brandVoice": { "*": "voice/default.md", "ja": "Polite and concise (です/ます)." },
156
+ "rules": ["Never translate the product name Acme."],
157
+ "glossary": "glossary.json"
158
+ }
159
+ }
160
+ ```
161
+
162
+ ### Top-level fields
163
+
164
+ | Field | Required | Description |
165
+ | --- | --- | --- |
166
+ | `sourceLocale` | yes | Locale your source files are written in, e.g. `en`. Source files are never written. |
167
+ | `targetLocales` | yes | Locales to translate into. |
168
+ | `files` | yes | Source files to translate (see below). |
169
+ | `engine` | for `push` | Model, endpoint, and prompt settings (see below). `check`/`status`/`purge` don't need it. |
170
+
171
+ ### `files[]`
172
+
173
+ | Field | Description |
174
+ | --- | --- |
175
+ | `pattern` | Source file path or glob (`*`, `**`). Must contain the source locale — see [target paths](#target-paths). |
176
+ | `format` | `json` or `jsonc` (default: from the extension). |
177
+ | `context` | Sent with every request for this file ("Checkout flow", "Marketing site"…). |
178
+ | `lockedKeys` | Copied from the source untranslated, kept in sync with it. |
179
+ | `preservedKeys` | Translated the first time, never overwritten afterwards (reviewed legal copy, etc.). |
180
+ | `ignoredKeys` | Omitted from target files. |
181
+
182
+ Key patterns use dotted paths: `auth.login` matches that key and everything under it
183
+ (but not `auth.loginUrl`), `*` matches one segment, and `**` matches any depth —
184
+ `**.href` matches every `href`. If a key matches more than one control, ignored wins
185
+ over locked, which wins over preserved.
186
+
187
+ ### Target paths
188
+
189
+ Target paths are derived by swapping the locale code into the source path:
190
+
191
+ | Source pattern | Target (for `es`) |
192
+ | --- | --- |
193
+ | `messages/en.json` | `messages/es.json` |
194
+ | `locales/en/**/*.json` | `locales/es/**/*.json` (subtree mirrored) |
195
+ | `lib/pages.en.json` | `lib/pages.es.json` |
196
+ | `l10n/app_en.json` | `l10n/app_es.json` |
197
+
198
+ If the source locale appears nowhere in the path, Rosetta refuses the pattern rather
199
+ than guessing where targets should go.
200
+
201
+ ### `engine`
202
+
203
+ | Field | Default | Description |
204
+ | --- | --- | --- |
205
+ | `model` | — | Model id. Overridden by `ROSETTA_MODEL`. |
206
+ | `baseURL` | `https://openrouter.ai/api/v1` | Any OpenAI-compatible endpoint, e.g. `http://localhost:11434/v1` for Ollama. Overridden by `ROSETTA_BASE_URL`. |
207
+ | `apiKeyEnv` | `OPENROUTER_API_KEY` | Env var holding your model provider's key, e.g. `OPENAI_API_KEY` when `baseURL` points at OpenAI. |
208
+ | `brandVoice` | — | Map of locale (or `*` fallback) to a voice briefing, inline or as a path to a `.md`/`.txt` file in `.rosetta/`. |
209
+ | `rules` | — | String or list of linguistic rules added to every prompt. |
210
+ | `glossary` | — | Inline object or path to a JSON/JSONC file: `{ "ja": { "workspace": "ワークスペース" }, "*": { "Acme": "Acme" } }`. Terms under `*` are never translated. |
211
+ | `temperature` | `0.3` | Sampling temperature. |
212
+ | `batchSize` | `25` | Max keys per request. |
213
+ | `concurrency` | `4` | Max requests in flight across all locales. |
214
+ | `retries` | `2` | Retries per request and per failing key. |
215
+ | `timeoutMs` | `120000` | Per-request timeout. |
216
+
217
+ The prompt applies, in order: brand voice → rules → glossary → output rules.
218
+
219
+ ### Translator notes (JSONC)
220
+
221
+ In `.jsonc` source files, a comment above a key — or at the end of its line — is sent
222
+ to the model as context for that key. A comment above an object applies to every key
223
+ inside it. Notes never appear in the output.
224
+
225
+ ```jsonc
226
+ {
227
+ // Top navigation — keep labels short
228
+ "nav": {
229
+ "light": "Light", // the theme, not weight
230
+ "records": "Records"
231
+ }
232
+ }
233
+ ```
234
+
235
+ ## Commands
236
+
237
+ | Command | What it does |
238
+ | --- | --- |
239
+ | `rosetta init` | Create `.rosetta/config.json`. Flags: `--pattern`, `--target`, `--source`, `--model`, `--from-lingo`, `--yes`. Never overwrites an existing config. |
240
+ | `rosetta push [patterns…]` | Translate new/changed strings, prune removed ones, write targets and the lockfile. |
241
+ | `rosetta check [patterns…]` | Exit 3 if any target is stale, missing, untracked, has extra keys, or fails validation. Never calls a model. |
242
+ | `rosetta status [patterns…]` | Show per-locale counts of what `push` would translate, adopt, and remove. `--exit-code` exits 3 if anything is pending. |
243
+ | `rosetta purge --locale <code>` | Delete a locale's files and lock entries so the next `push` redoes it. |
244
+ | `rosetta pull` | No-op, so Lingo.dev scripts keep working — Rosetta translates locally in `push`. |
245
+
246
+ `push` flags:
247
+
248
+ | Flag | Description |
249
+ | --- | --- |
250
+ | `--locale <code>` | Only these locales (repeatable or comma-separated). |
251
+ | `--key <pattern>` | Retranslate matching keys even if the source is unchanged — the cheap way to redo a few strings. |
252
+ | `--force` | Retranslate everything in scope (e.g. after changing model or glossary). Asks for confirmation. |
253
+ | `--estimate`, `--dry-run` | Print the plan and a rough token estimate; don't call the model. |
254
+ | `-y`, `--yes` | Skip confirmations. Implied when `CI` is set. |
255
+ | `--backfill-missing` | Accepted for Lingo.dev compatibility; missing files are always backfilled. |
256
+
257
+ Positional patterns (`rosetta push 'content/en/**'`) limit a run to matching source
258
+ files.
259
+
260
+ Global flags: `--config <path>`, `--json`, `--quiet`, `--help`, `--version`.
261
+
262
+ ### Scripting, agents, and CI
263
+
264
+ - **`--json`** on any command prints a single JSON result to stdout — including
265
+ failures: `{ "ok": false, "error": { "type": "ConfigError", "message": "…" } }`.
266
+ - **Exit codes** are stable:
267
+
268
+ | Code | Meaning |
269
+ | --- | --- |
270
+ | `0` | Success (for `check`: everything up to date) |
271
+ | `1` | One or more locales failed to translate and were not written |
272
+ | `2` | Usage or config error, including a missing API key |
273
+ | `3` | `check` / `status --exit-code` found stale or broken translations |
274
+
275
+ - **Never hangs:** outside a TTY, destructive commands (`push --force`, `purge`)
276
+ require `--yes` instead of prompting. `CI=true` implies `--yes`.
277
+ - **API key only when needed:** `push` asks for a key only if something actually
278
+ needs translating, so adopting or pruning works without one.
279
+
280
+ ### Environment variables
281
+
282
+ | Variable | Description |
283
+ | --- | --- |
284
+ | `OPENROUTER_API_KEY` | Your model provider's API key (or whatever variable `engine.apiKeyEnv` names). |
285
+ | `ROSETTA_MODEL` | Overrides `engine.model`. |
286
+ | `ROSETTA_BASE_URL` | Overrides `engine.baseURL`. |
287
+ | `CI` | Non-interactive mode; implies `--yes`. |
288
+
289
+ ## Validation
290
+
291
+ Every translation the model returns — and every existing translation, on `push` and
292
+ `check` — is checked against its source string:
293
+
294
+ - the same `{placeholders}`, with none dropped, renamed, or added;
295
+ - the same ICU arguments and types (`plural`, `select`, `selectordinal`, `number`,
296
+ `date`, `time`), the same `select` cases, and `plural` keeping its `other` case —
297
+ while allowing each language's own plural categories (`zero`/`few`/`many`…);
298
+ - the same rich-text tags (`<strong>…</strong>`, `<link/>`), balanced;
299
+ - a non-empty string.
300
+
301
+ Sources that aren't ICU (e.g. i18next `{{name}}`) fall back to comparing
302
+ placeholder-looking tokens and tag names. Rosetta also asks for JSON output, strips
303
+ code fences, retries with exponential backoff (honoring `Retry-After`), and times out
304
+ hung requests.
305
+
306
+ ## Getting translations into your repo
307
+
308
+ Rosetta doesn't prescribe how translations land. Pick what fits your team:
309
+
310
+ - **Locally or with a coding agent:** run `rosetta push` and commit the result with
311
+ the English change. Agents can use `rosetta status --json` and `rosetta push --json`.
312
+ - **CI gate:** run `rosetta check` on pull requests. It needs no API key, so forks
313
+ stay green.
314
+ - **Pull request bot:** after merges, translate what changed and open or update one
315
+ rolling PR.
316
+ - **Commit to the branch:** translate and commit straight to `main` (or to the PR
317
+ branch).
318
+
319
+ The GitHub Action covers the last three:
320
+
321
+ ```yaml
322
+ # Check on every PR (no secret needed)
323
+ - uses: ian/rosetta@v1
324
+ with:
325
+ mode: check
326
+
327
+ # After merges to main: translate + create/update a rolling PR
328
+ - uses: ian/rosetta@v1
329
+ with:
330
+ mode: pull-request # or: commit
331
+ token: ${{ steps.app.outputs.token }}
332
+ env:
333
+ OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
334
+ ```
335
+
336
+ It stages only translation files and the lockfile. It lands the locales that
337
+ succeeded even when others fail, lists failing keys in the PR body and job summary,
338
+ and exposes `changed`, `pr-url`, and `commit-sha` outputs.
339
+
340
+ GitHub doesn't run other workflows on commits or PRs made with the default
341
+ `GITHUB_TOKEN`. Use a GitHub App token or PAT if CI must run on the bot's changes.
342
+ [`docs/delivery.md`](docs/delivery.md) has complete workflows for each option, the
343
+ full Action reference, and the token details. [`examples/next-intl`](examples/next-intl)
344
+ is a working setup.
345
+
346
+ > Rosetta writes target files as 2-space JSON with a trailing newline, mirroring the
347
+ > source's key order. If a formatter in your repo rewrites them differently, exclude
348
+ > the generated locale files from it. Otherwise the formatter and `rosetta push` will
349
+ > keep producing formatting-only diffs. `check` compares values, so it isn't affected.
350
+
351
+ ## Coming from Lingo.dev
352
+
353
+ Rosetta mirrors the Lingo.dev CLI's layout and vocabulary, but everything their
354
+ hosted engine does runs locally from your config.
355
+
356
+ ```bash
357
+ npx rosetta init --from-lingo # imports .lingo/config.json or legacy i18n.json
358
+ ```
359
+
360
+ The step-by-step guide is [`docs/migrating-from-lingo.md`](docs/migrating-from-lingo.md).
361
+
362
+ | Lingo.dev | Rosetta |
363
+ | --- | --- |
364
+ | `.lingo/config.json` | `.rosetta/config.json` |
365
+ | `.lingo/lock.json` / `i18n.lock` | `.rosetta/lock.json` (per-key source hashes) |
366
+ | `sourceLocale`, `targetLocales`, `files[].pattern` | same |
367
+ | `lockedKeys`, `preservedKeys`, `ignoredKeys` | same (dotted paths; legacy `a/b` paths are converted) |
368
+ | `orgId` + `engineId` (hosted engine) | `engine` in the config: model, brand voice, rules, glossary |
369
+ | `lingo push` (`--key`, `--force`, `--estimate`) | `rosetta push` (same flags) |
370
+ | `lingo pull` | not needed — `push` writes the files directly |
371
+ | `lingo check` | `rosetta check` |
372
+ | `lingo purge --locale` | `rosetta purge --locale` |
373
+ | `LINGO_API_KEY` | none: your model provider's key (`OPENROUTER_API_KEY`) |
374
+
375
+ Rosetta v1 supports JSON and JSONC; other formats are skipped on import with a
376
+ warning. Markdown/MDX support is tracked in
377
+ [#1](https://github.com/ian/rosetta/issues/1).
378
+
379
+ ## Library API
380
+
381
+ The engine behind the CLI is also usable directly.
382
+
19
383
  ```ts
20
384
  import { Rosetta } from "rosetta-i18n";
21
385
 
22
386
  const rosetta = new Rosetta({
23
- apiKey: process.env.OPENROUTER_API_KEY,
387
+ apiKey: process.env.OPENROUTER_API_KEY!,
24
388
  model: "anthropic/claude-sonnet-4.5",
25
389
  brandVoice: {
26
390
  variations: {
@@ -28,139 +392,106 @@ const rosetta = new Rosetta({
28
392
  es: "Tono editorial de gastronomía, accesible. Tercera persona.",
29
393
  },
30
394
  },
31
- glossary: {
32
- es: { "award-winning": "premiado" },
33
- },
395
+ rules: ["Keep product names in English."],
396
+ glossary: { es: { "award-winning": "premiado" } },
34
397
  });
35
398
 
36
399
  const translated = await rosetta.translate(
37
- { hero: "Every awarded restaurant in the world" },
38
- { source: "en", target: "es" },
400
+ { hero: "Every awarded restaurant in the world", count: "{n, plural, one {# place} other {# places}}" },
401
+ { source: "en", target: "es", context: "home page hero" },
39
402
  );
40
- ```
41
403
 
42
- ### Single strings
43
-
44
- ```ts
45
404
  const text = await rosetta.translateText("Every awarded restaurant", {
46
405
  source: "en",
47
406
  target: "ja",
48
407
  });
49
408
  ```
50
409
 
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
410
  ### `new Rosetta(config)`
67
411
 
68
412
  | Option | Type | Default | Description |
69
413
  | --- | --- | --- | --- |
70
414
  | `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. |
415
+ | `model` | `string` | — | **Required.** Model id. |
416
+ | `brandVoice` | `BrandVoice` | — | **Required.** `{ variations: { [locale or "*"]: text } }`. |
73
417
  | `baseURL` | `string` | `https://openrouter.ai/api/v1` | Any OpenAI-compatible endpoint. |
74
- | `glossary` | `Glossary` | `{}` | Exact term mappings per locale. |
418
+ | `rules` | `string[]` | — | Rules added to every prompt. |
419
+ | `glossary` | `Glossary` | — | Exact term mappings per locale; `*` terms are never translated. |
75
420
  | `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. |
421
+ | `batchSize` | `number` | `25` | Max keys per request. |
422
+ | `concurrency` | `number` | `4` | Max requests in flight for this instance. |
423
+ | `retries` | `number` | `2` | Retries per request and per failing key. |
424
+ | `timeoutMs` | `number` | `120000` | Per-request timeout. |
425
+ | `onBatchError` | `"throw" \| "skip"` | `"throw"` | What `translate()` does when keys still fail. |
79
426
 
80
- ### `rosetta.translate(data, options)`
427
+ ### Methods
81
428
 
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.
429
+ - **`translate(data, options)`**: translates a key-value payload with validation and
430
+ targeted retries. Throws `RosettaValidationError` (with `.failures` and `.partial`)
431
+ if any key still fails. With `onBatchError: "skip"`, it logs failures and returns
432
+ only the keys that succeeded (the 0.x behavior). Options: `source`, `target`,
433
+ `context`, `notes` (per-key), `hints` (per-key breadcrumbs).
434
+ - **`translateEntries(data, options)`**: like `translate` but never throws for
435
+ per-key problems; it returns `{ translations, failures, usage }`.
436
+ - **`translateText(text, options)`**: translates one string and throws on a non-OK
437
+ response.
86
438
 
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
- ```
439
+ ### Programmatic workflow
98
440
 
99
- ### `rosetta.translateText(text, options)`
441
+ The CLI commands are exported too:
100
442
 
101
- Translates a single string. Throws on a non-OK response (unlike `translate`,
102
- which degrades gracefully at the batch level).
443
+ ```ts
444
+ import { check, loadConfig, push } from "rosetta-i18n";
103
445
 
104
- ## CLI
446
+ const project = loadConfig(); // finds .rosetta/config.json
447
+ const result = await push(project, { locales: ["es"] });
448
+ if (!result.ok) console.error(result.locales.flatMap((l) => l.errors));
449
+ console.log(check(project).ok);
450
+ ```
105
451
 
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.
452
+ `push` accepts a custom `translator` implementing `translateEntries`, which is
453
+ useful for tests or for routing through your own service. Also exported:
454
+ `plan`, `purge`, `validateTranslation`, `parseMessage`, `resolveTargetPath`.
109
455
 
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
- ```
456
+ ## React & Next.js
116
457
 
117
- Writes `messages/es.json` and `messages/pt-BR.json` (flattened keys preserved).
458
+ Rosetta calls an LLM with your API key, so it runs **server-side only**. Never import
459
+ it in a Client Component (`"use client"`). Keep the key out of client bundles with
460
+ [`server-only`](https://www.npmjs.com/package/server-only) and expose translations
461
+ through server code.
118
462
 
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.
463
+ ### Recommended: pre-translate message catalogs
134
464
 
135
- ## React & Next.js
465
+ For `next-intl` / `react-i18next`, translate the locale JSON with `rosetta push` and
466
+ ship it, so there's no LLM call in the request path:
136
467
 
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.
468
+ ```ts
469
+ // i18n/request.ts (next-intl)
470
+ import { getRequestConfig } from "next-intl/server";
471
+
472
+ export default getRequestConfig(async ({ locale }) => ({
473
+ messages: (await import(`../messages/${locale}.json`)).default,
474
+ }));
475
+ ```
141
476
 
142
477
  ### Server helpers (`rosetta-i18n/next`)
143
478
 
144
- The `next` entrypoint (optional `next` peer) wires env config, a memoized
145
- instance, and Next's data cache:
479
+ For dynamic content (CMS entries, user-generated text), the `next` entrypoint
480
+ (optional `next` peer) provides env config, a memoized instance, and Next's data
481
+ cache:
146
482
 
147
483
  ```ts
148
484
  // lib/rosetta.ts
149
485
  import "server-only";
150
486
  import { cachedTranslate, getRosetta } from "rosetta-i18n/next";
151
487
 
152
- // Memoized instance built from env (see table below).
153
488
  export const rosetta = getRosetta();
154
489
 
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
- });
490
+ export async function translateCopy(data: Record<string, string>, target: string) {
491
+ return cachedTranslate(data, { source: "en", target }, {
492
+ revalidate: 60 * 60 * 24,
493
+ tags: ["i18n"],
494
+ });
164
495
  }
165
496
  ```
166
497
 
@@ -171,31 +502,16 @@ export async function translateCopy(
171
502
  | `ROSETTA_BASE_URL` | no | `https://openrouter.ai/api/v1` |
172
503
  | `ROSETTA_BRAND_VOICE` | no | — (the `*` brand voice) |
173
504
 
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
- ```
505
+ `createRosetta(overrides)` is also exported if you'd rather pass config explicitly.
190
506
 
191
507
  ### Server Component (RSC)
192
508
 
193
509
  ```tsx
194
510
  // app/[locale]/hero.tsx
195
- import { getRosetta } from "rosetta-i18n/next";
511
+ import { cachedTranslate } from "rosetta-i18n/next";
196
512
 
197
513
  export async function Hero({ locale }: { locale: string }) {
198
- const copy = await getRosetta().translate(
514
+ const copy = await cachedTranslate(
199
515
  { hero: "Every awarded restaurant in the world" },
200
516
  { source: "en", target: locale, context: "home hero" },
201
517
  );
@@ -203,10 +519,6 @@ export async function Hero({ locale }: { locale: string }) {
203
519
  }
204
520
  ```
205
521
 
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
522
  ### Route Handler
211
523
 
212
524
  ```ts
@@ -216,11 +528,7 @@ import { rosetta } from "@/lib/rosetta";
216
528
 
217
529
  export async function POST(req: Request) {
218
530
  const { data, target, context } = await req.json();
219
- const translated = await rosetta.translate(data, {
220
- source: "en",
221
- target,
222
- context,
223
- });
531
+ const translated = await rosetta.translate(data, { source: "en", target, context });
224
532
  return NextResponse.json(translated);
225
533
  }
226
534
  ```
@@ -239,27 +547,168 @@ export async function translateBlurb(blurb: string, target: string) {
239
547
 
240
548
  ### Client Components
241
549
 
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.
550
+ Client Components consume already-translated strings through props, context, or the
551
+ catalog; they never import Rosetta. To trigger a translation from the browser, call
552
+ the route handler or Server Action above.
553
+
554
+ > **Edge runtime:** `rosetta-i18n/next` only uses `fetch`, so it works on the Edge
555
+ > runtime as long as your endpoint does. The main `rosetta-i18n` entry also exports the
556
+ > Node-only workflow (`push`, `check`, …, which read and write files), so prefer the
557
+ > `next` entrypoint in edge code.
558
+
559
+ ## Locale routing & cookies
560
+
561
+ Translating strings is half the job. Knowing *which* locale a request is in — and
562
+ switching cleanly — is the other half, and it's where most apps hand-roll fragile
563
+ middleware, server actions, and `router.refresh()` races. Rosetta ships the runtime
564
+ layer too: `rosetta-i18n/locale` (core), `/react`, `/next`, `/astro`.
565
+
566
+ The model is one-directional and needs no client-side guessing:
567
+
568
+ 1. a **locale-prefixed URL** (`/ja/pricing`) is authoritative,
569
+ 2. then a **forced header** (`x-rosetta-locale`, set by middleware),
570
+ 3. then the **cookie**,
571
+ 4. then the **default locale**.
572
+
573
+ **Switching writes the cookie and navigates to the localized URL**, so the URL and the
574
+ cookie always agree. A full-page navigation makes it a single request with no
575
+ refresh-after-push race.
245
576
 
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.
577
+ ### Config (plain data)
578
+
579
+ ```ts
580
+ // i18n/config.ts
581
+ import type { LocaleConfig } from "rosetta-i18n/locale";
582
+
583
+ export const localeConfig: LocaleConfig = {
584
+ locales: ["en", "ja", "de", "es"],
585
+ defaultLocale: "en",
586
+ // Only these paths have localized variants; everything else stays bare.
587
+ localizedPaths: ["/", "/pricing", "/agents"],
588
+ localizedPathPrefixes: ["/blog/", "/help/", "/vs/"],
589
+ };
590
+ ```
591
+
592
+ The core also exports the pure helpers (`resolveLocale`, `stripLocale`, `localizePath`,
593
+ `planSwitch`, `serializeLocaleCookie`, …) if you're wiring your own framework.
594
+
595
+ ### React
596
+
597
+ ```tsx
598
+ "use client";
599
+ import { LocaleProvider, LocaleSwitcher } from "rosetta-i18n/react";
600
+
601
+ export function App({ locale, children }: { locale: string; children: React.ReactNode }) {
602
+ return (
603
+ <LocaleProvider config={localeConfig} locale={locale}>
604
+ {/* or render={({ locales, locale, switchTo, labelFor }) => …} */}
605
+ <LocaleSwitcher />
606
+ {children}
607
+ </LocaleProvider>
608
+ );
609
+ }
610
+ ```
611
+
612
+ Hooks: `useLocale()`, `useLocales()`, `useLocaleSwitch()`, `useLocaleContext()`.
613
+ `switchTo(locale)` writes the cookie and navigates (full page by default; pass
614
+ `navigate` to hook a router, `persist` to save server-side).
615
+
616
+ ### Next.js (App Router)
617
+
618
+ ```ts
619
+ // middleware.ts
620
+ import { createLocaleMiddleware } from "rosetta-i18n/next";
621
+ import { localeConfig } from "./i18n/config";
622
+
623
+ export const middleware = createLocaleMiddleware(localeConfig);
624
+ export const config = { matcher: ["/((?!_next|favicon.ico|.*\\..*).*)"] };
625
+ ```
626
+
627
+ ```tsx
628
+ // app/layout.tsx
629
+ import { RosettaProvider } from "rosetta-i18n/next";
630
+ import { localeConfig } from "@/i18n/config";
631
+
632
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
633
+ return (
634
+ <html>
635
+ <body>
636
+ <RosettaProvider config={localeConfig}>{children}</RosettaProvider>
637
+ </body>
638
+ </html>
639
+ );
640
+ }
641
+ ```
642
+
643
+ `RosettaProvider` reads the locale the middleware forced (`getServerLocale(config)`)
644
+ and provides it to the client. Persist the choice for signed-in users with a server
645
+ action:
646
+
647
+ ```ts
648
+ // app/locale-actions.ts
649
+ "use server";
650
+ import { createSetLocaleAction } from "rosetta-i18n/next";
651
+ import { localeConfig } from "@/i18n/config";
652
+
653
+ export const setLocale = createSetLocaleAction(localeConfig, {
654
+ persist: (locale) => updateUserProfile(userId, { locale }),
655
+ });
656
+ ```
657
+
658
+ ### Astro
659
+
660
+ ```ts
661
+ // src/middleware.ts
662
+ import { createAstroLocaleMiddleware } from "rosetta-i18n/astro";
663
+ import { localeConfig } from "../i18n/config";
664
+
665
+ export const onRequest = createAstroLocaleMiddleware(localeConfig);
666
+ ```
667
+
668
+ The middleware sets `Astro.locals.locale` (read it with `getAstroLocale(Astro, config)`)
669
+ and rewrites prefixed URLs. For a switcher, render the React `<LocaleSwitcher>` as an
670
+ island wrapped in `<LocaleProvider locale={Astro.locals.locale} config={localeConfig}>`.
671
+
672
+ ## Legacy `translate-catalog`
673
+
674
+ The 0.x one-shot command still works but is deprecated and will be removed in v2:
675
+
676
+ ```bash
677
+ npx rosetta translate-catalog messages/en.json --target es --merge
678
+ ```
679
+
680
+ Replace it with a `.rosetta/config.json` (`rosetta init --pattern messages/en.json --target es`)
681
+ and `rosetta push`, which also retranslates edited strings, validates output, and
682
+ never writes partial files.
683
+
684
+ ## Migrating an existing app
685
+
686
+ - [`docs/migration-guide.md`](docs/migration-guide.md): a playbook for switching an app
687
+ from another translation stack (inventory → modes → verification).
688
+ - [`docs/migrating-from-lingo.md`](docs/migrating-from-lingo.md): the Lingo.dev-specific
689
+ path (config import, engine, CI).
690
+ - [`docs/agent-prompt.md`](docs/agent-prompt.md): a ready-to-run agent prompt that
691
+ executes the migration.
248
692
 
249
693
  ## Releasing
250
694
 
251
695
  Releases publish automatically from GitHub Actions via npm
252
- [trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC) — no npm
253
- token required.
696
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC), so no npm token
697
+ is required.
254
698
 
255
699
  ```bash
256
700
  pnpm bump patch # bump package.json, commit, tag, push
257
701
  pnpm release # create the GitHub Release -> triggers npm-publish
258
702
  ```
259
703
 
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`.
704
+ - `pnpm bump` accepts `patch`, `minor`, or `major` (default `patch`); `premajor`,
705
+ `preminor`, `prepatch`, or `prerelease` for a `-beta.N` prerelease; or an explicit
706
+ version such as `1.0.0-beta.1`.
707
+ - Prereleases publish under npm's `next` dist-tag (so `latest` doesn't move) and are
708
+ marked as prereleases on GitHub. Stable releases also move the `v<major>` tag that
709
+ `uses: ian/rosetta@v1` resolves to.
710
+ - `pnpm release` creates the GitHub Release for the latest tag with generated release
711
+ notes, which triggers `.github/workflows/npm-publish.yml`.
263
712
 
264
713
  One-time setup (first publish and Trusted Publisher) is documented in
265
714
  [`.github/workflows/npm-publish.yml`](.github/workflows/npm-publish.yml).
@@ -274,9 +723,14 @@ pnpm test
274
723
  pnpm build
275
724
  ```
276
725
 
277
- Source lives in `src/`. `rollup` produces `dist/esm`, `tsc` emits declarations
278
- to `dist/types`.
726
+ Source lives in `src/`: the translation engine in `src/rosetta.ts`, validation in
727
+ `src/validate.ts`, the config/lockfile/planner/workflow in `src/project/`, and the CLI
728
+ in `src/cli.ts`. `rollup` produces `dist/esm`; `tsc` emits declarations to
729
+ `dist/types`.
730
+
731
+ The website lives in `site/` (Astro, deployed to Cloudflare Pages); see
732
+ [`site/README.md`](site/README.md).
279
733
 
280
734
  ## License
281
735
 
282
- MIT © Ian Hunter
736
+ MIT © Ian Hunter