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 +457 -141
- package/dist/esm/cli.js +2535 -104
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/config-BePhmTW6.js +497 -0
- package/dist/esm/config-BePhmTW6.js.map +1 -0
- package/dist/esm/config.js +4 -0
- package/dist/esm/config.js.map +1 -0
- package/dist/esm/index.js +1212 -83
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/next.js +600 -83
- package/dist/esm/next.js.map +1 -1
- package/dist/types/cli.d.ts +28 -45
- package/dist/types/config.d.ts +2 -42
- package/dist/types/index.d.ts +8 -28
- package/dist/types/legacy.d.ts +50 -0
- package/dist/types/next.d.ts +3 -3
- package/dist/types/project/config.d.ts +78 -0
- package/dist/types/project/entries.d.ts +22 -0
- package/dist/types/project/glob.d.ts +18 -0
- package/dist/types/project/init.d.ts +36 -0
- package/dist/types/project/jsonc.d.ts +18 -0
- package/dist/types/project/lock.d.ts +13 -0
- package/dist/types/project/paths.d.ts +16 -0
- package/dist/types/project/plan.d.ts +45 -0
- package/dist/types/project/workflow.d.ts +112 -0
- package/dist/types/prompt.d.ts +11 -7
- package/dist/types/rosetta.d.ts +51 -0
- package/dist/types/types.d.ts +80 -0
- package/dist/types/validate.d.ts +29 -0
- package/package.json +5 -3
- package/schema/config.json +138 -0
package/README.md
CHANGED
|
@@ -1,26 +1,368 @@
|
|
|
1
1
|
# Rosetta i18n
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**A free, open-source localization tool you run yourself — a DIY [Lingo.dev](https://lingo.dev).**
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
## Install
|
|
12
|
+
Named after the Rosetta Stone: one text, every language readable.
|
|
10
13
|
|
|
11
14
|
```bash
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
72
|
-
| `brandVoice` | `BrandVoice` | — | **Required.**
|
|
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
|
-
| `
|
|
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` |
|
|
77
|
-
| `concurrency` | `number` | `4` |
|
|
78
|
-
| `retries` | `number` | `2` | Retries per
|
|
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
|
-
###
|
|
405
|
+
### Methods
|
|
81
406
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
the
|
|
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
|
-
|
|
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
|
-
|
|
419
|
+
The CLI commands are exported too:
|
|
100
420
|
|
|
101
|
-
|
|
102
|
-
|
|
421
|
+
```ts
|
|
422
|
+
import { check, loadConfig, push } from "rosetta-i18n";
|
|
103
423
|
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
instance, and Next's data
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 {
|
|
489
|
+
import { cachedTranslate } from "rosetta-i18n/next";
|
|
196
490
|
|
|
197
491
|
export async function Hero({ locale }: { locale: string }) {
|
|
198
|
-
const copy = await
|
|
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
|
|
243
|
-
catalog
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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)
|
|
253
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
|
278
|
-
|
|
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
|