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.
- package/README.md +595 -141
- package/dist/esm/astro.js +103 -0
- package/dist/esm/astro.js.map +1 -0
- package/dist/esm/cli.js +2544 -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/locale.js +95 -0
- package/dist/esm/locale.js.map +1 -0
- package/dist/esm/next.js +712 -84
- package/dist/esm/next.js.map +1 -1
- package/dist/esm/react.js +150 -0
- package/dist/esm/react.js.map +1 -0
- 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/locale/astro.d.ts +33 -0
- package/dist/types/locale/core.d.ts +102 -0
- package/dist/types/locale/next.d.ts +56 -0
- package/dist/types/next.d.ts +4 -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/react.d.ts +59 -0
- 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 +29 -4
- package/schema/config.json +138 -0
- package/skills/rosetta/SKILL.md +319 -0
package/README.md
CHANGED
|
@@ -1,26 +1,390 @@
|
|
|
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
|
+
- [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
|
-
|
|
32
|
-
|
|
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
|
|
72
|
-
| `brandVoice` | `BrandVoice` | — | **Required.**
|
|
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
|
-
| `
|
|
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` |
|
|
77
|
-
| `concurrency` | `number` | `4` |
|
|
78
|
-
| `retries` | `number` | `2` | Retries per
|
|
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
|
-
###
|
|
427
|
+
### Methods
|
|
81
428
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
the
|
|
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
|
-
|
|
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
|
-
|
|
441
|
+
The CLI commands are exported too:
|
|
100
442
|
|
|
101
|
-
|
|
102
|
-
|
|
443
|
+
```ts
|
|
444
|
+
import { check, loadConfig, push } from "rosetta-i18n";
|
|
103
445
|
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
instance, and Next's data
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 {
|
|
511
|
+
import { cachedTranslate } from "rosetta-i18n/next";
|
|
196
512
|
|
|
197
513
|
export async function Hero({ locale }: { locale: string }) {
|
|
198
|
-
const copy = await
|
|
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
|
|
243
|
-
catalog
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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)
|
|
253
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
|
278
|
-
|
|
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
|