@likolabs/i18nmd 0.1.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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/PROMPT.md +36 -0
- package/README.md +377 -0
- package/bin/i18nmd.mjs +539 -0
- package/lib/catalog.mjs +296 -0
- package/lib/compiler.mjs +283 -0
- package/lib/extractor.mjs +362 -0
- package/lib/index.mjs +8 -0
- package/lib/interop.mjs +134 -0
- package/lib/languages.mjs +58 -0
- package/lib/llm.mjs +138 -0
- package/lib/lock.mjs +111 -0
- package/lib/messages.mjs +130 -0
- package/lib/runtime.mjs +162 -0
- package/package.json +58 -0
- package/scripts/python_catalog.py +49 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- Published to npm as `@likolabs/i18nmd` (`npm install --save-dev @likolabs/i18nmd`); the command is still `i18nmd`. npm 12 refuses GitHub and tarball URLs by default, so the 0.1.0 install instructions failed there.
|
|
6
|
+
- The `i18nmd` command is kept when publishing; npm 12 dropped the `./bin/…` form.
|
|
7
|
+
|
|
8
|
+
## 0.1.0
|
|
9
|
+
|
|
10
|
+
The first release.
|
|
11
|
+
|
|
12
|
+
- **Language files**: one Markdown file per language, with context lines, optional placeholders and ICU messages. `i18nmd.lock.json` tracks which translations are current.
|
|
13
|
+
- **Divisions**: subdirectories of `translations/` with namespaced tokens (`i18nmd.marketing('hero')`), each compiled to its own module so bundlers split strings by page.
|
|
14
|
+
- **Compiler**: typed TypeScript or JavaScript with lazy-loaded languages, `--eager` for servers and tests, JSON, and a dependency-free Python module with ICU plurals.
|
|
15
|
+
- **Runtime**: a current language that follows the reader's choice and browser, `setLanguage`, `onLanguageChange` and `ready`, rich-text tags, and fallbacks that never crash the page.
|
|
16
|
+
- **Extraction** from JavaScript and TypeScript: whole sentences with named placeholders, wording chosen in code, attributes, and `/* i18n */` strings, repeatable and in place.
|
|
17
|
+
- **LLM translation** through Anthropic or any OpenAI-compatible API, checked like hand-written translations.
|
|
18
|
+
- **Maintenance**: `status`, `check` (including `--in src` for division imports and unused tokens), `sync`, `rename`, `join` and `split`.
|
|
19
|
+
- **Interop**: import and export for FormatJS, next-intl and i18next, and import of Python translation tables.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Liko Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/PROMPT.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Move an app's interface strings into i18nmd
|
|
2
|
+
|
|
3
|
+
Give these instructions to a coding agent in the application's repository. They work as an agent skill too.
|
|
4
|
+
|
|
5
|
+
1. **Look first.** Find any existing translation setup and reuse what fits. Note the language the product is written in; it becomes the source language (`--source it` for Italian). Commit or stash work in progress, because extraction rewrites files.
|
|
6
|
+
|
|
7
|
+
2. **Choose divisions.** If different people own different areas (the app, help articles, marketing pages), give each a directory under `translations/`. Divisions also split the bundle, so put strings that every page loads, such as sign-in, loading screens and the router, in a small division of their own. Skip areas that won't be translated, such as admin pages. Keep a script that maps divisions to source files, so extraction can be repeated:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx i18nmd extract src/routes.tsx src/components/Login.tsx --out translations/account --in-place
|
|
11
|
+
npx i18nmd extract src/components --out translations/ui --in-place
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
3. **Review the extractor's work.** Read the diff. Then work through every diagnostic it printed:
|
|
15
|
+
- *looks like a count*: make the message an ICU plural, `{count, plural, one {# item} other {# items}}`.
|
|
16
|
+
- *text chosen in code*: move the wording into the message as an ICU `select` or plural, instead of passing English through a placeholder.
|
|
17
|
+
- *dynamic JSX text*: text built by concatenation or `charAt(0).toUpperCase()`. Give it a message of its own; an internal id shown as text is a bug to fix.
|
|
18
|
+
|
|
19
|
+
4. **Find what the extractor can't see**: strings in object properties (`{ label: "Save" }`), plain `.ts` files, validation errors, notifications, `document.title`, and server responses shown to users. Mark simple ones with `/* i18n */` and run extraction again, or write the call yourself. Leave identifiers, URLs, log messages, and anything sent to a machine.
|
|
20
|
+
|
|
21
|
+
5. **Write calls the way the extractor does.** Call `i18nmd.<division>(token, values)` and import `i18nmd` from that division's module. Pass values by name, and keep each sentence whole, with tags such as `<b>…</b>` for markup inside it. Never translate at module load: wrap constants in a function so a language change reaches them. Text sent to a server as the user's own words, such as suggested prompts for a chatbot, must be something the server understands in each language. Say so in its Context line.
|
|
22
|
+
|
|
23
|
+
6. **Write context.** Each message's `Context:` line says where it appears and anything ambiguous: whether "Close" is a verb, tone or formality, length limits, names not to translate.
|
|
24
|
+
|
|
25
|
+
7. **Compile and check.**
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npx i18nmd check --in src --fix # adds missing division imports, lists unused tokens
|
|
29
|
+
npx i18nmd compile
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Add `i18nmd compile` to the build. Run the type checker, linter and tests. Tests that search source files for wording should search `translations/` too.
|
|
33
|
+
|
|
34
|
+
8. **Report.** List changed files, strings left untranslated and why, and diagnostics you did not resolve. Don't claim complete coverage unless you checked every user-facing screen.
|
|
35
|
+
|
|
36
|
+
Keep everything in the repository. Don't send project strings or source code to outside services unless the user asked for LLM translation (`i18nmd translate`).
|
package/README.md
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# i18n.md
|
|
2
|
+
|
|
3
|
+
Keep your interface strings in Markdown: one file per language, readable and editable by translators, reviewers and LLMs. A compiler checks every language and generates typed code your app imports.
|
|
4
|
+
|
|
5
|
+
````md
|
|
6
|
+
# Français
|
|
7
|
+
|
|
8
|
+
## cart_items
|
|
9
|
+
|
|
10
|
+
Context: Item count in the cart header.
|
|
11
|
+
|
|
12
|
+
```icu
|
|
13
|
+
{count, plural, one {# article} other {# articles}}
|
|
14
|
+
```
|
|
15
|
+
````
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
<p>{i18nmd('cart_items', { count })}</p>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The files are the source of truth. They diff in pull requests, and anyone can hand one to a person or an LLM to translate. Everything i18nmd does is a change to those files or code generated from them.
|
|
22
|
+
|
|
23
|
+
- [Install](#install)
|
|
24
|
+
- [Quick start](#quick-start)
|
|
25
|
+
- [Language files](#language-files)
|
|
26
|
+
- [Divisions](#divisions)
|
|
27
|
+
- [Using the generated code](#using-the-generated-code)
|
|
28
|
+
- [Extracting strings from your code](#extracting-strings-from-your-code)
|
|
29
|
+
- [Translating with an LLM](#translating-with-an-llm)
|
|
30
|
+
- [Keeping translations current](#keeping-translations-current)
|
|
31
|
+
- [Python](#python)
|
|
32
|
+
- [Other formats and libraries](#other-formats-and-libraries)
|
|
33
|
+
- [Command reference](#command-reference)
|
|
34
|
+
- [Limits in 0.1](#limits-in-01)
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
i18nmd needs Node.js 22 or newer.
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npm install --save-dev @likolabs/i18nmd
|
|
42
|
+
npx i18nmd --version
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
**1. Extract the strings** from a React or other JSX/TSX codebase. With `--in-place`, i18nmd rewrites your sources, so commit first and review the diff.
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
npx i18nmd extract src --in-place
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Your strings land in `translations/i18n-en.md` (use `--source it` if your app is in Italian), and your code calls the translations instead:
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
// before
|
|
57
|
+
<p>Welcome back, {user.name}!</p>
|
|
58
|
+
// after
|
|
59
|
+
import { i18nmd } from '../i18n/i18n';
|
|
60
|
+
<p>{i18nmd('welcome_back', { name: user.name })}</p>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The extractor prints what it could not convert, such as text built in code or counts that should be plurals. [The extraction prompt](PROMPT.md) tells a coding agent how to finish the job.
|
|
64
|
+
|
|
65
|
+
**2. Add languages.** Ask an LLM through the CLI, or write the files yourself:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
export ANTHROPIC_API_KEY=… # or OPENAI_API_KEY, or I18NMD_* (see below)
|
|
69
|
+
npx i18nmd --add french --add german
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**3. Compile** into `src/i18n`. Add it to your build so every build uses the latest translations:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
npx i18nmd compile
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{ "scripts": { "prebuild": "i18nmd compile" } }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**4. Let readers choose a language.** Calls translate into the current language, which starts as the reader's browser language:
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
import { useSyncExternalStore } from 'react';
|
|
86
|
+
import { languages, getLanguage, setLanguage, onLanguageChange } from './i18n/language.mjs';
|
|
87
|
+
|
|
88
|
+
export function LanguagePicker() {
|
|
89
|
+
const language = useSyncExternalStore(onLanguageChange, getLanguage);
|
|
90
|
+
return (
|
|
91
|
+
<select value={language} onChange={e => void setLanguage(e.target.value)}>
|
|
92
|
+
{Object.entries(languages).map(([code, name]) => <option key={code} value={code} lang={code}>{name}</option>)}
|
|
93
|
+
</select>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**5. Keep it current.** After editing source text, `npx i18nmd status` shows what needs translating and `npx i18nmd translate` fills it in.
|
|
99
|
+
|
|
100
|
+
## Language files
|
|
101
|
+
|
|
102
|
+
`translations/i18n-fr.md` holds French and nothing else. The filename gives the language code (`fr`, `pt-BR`, or a custom name such as `pirate`). The first heading names the language in that language, and each `##` heading is a token:
|
|
103
|
+
|
|
104
|
+
````md
|
|
105
|
+
# Français
|
|
106
|
+
|
|
107
|
+
## cart_items
|
|
108
|
+
|
|
109
|
+
Context: Item count in the cart header.
|
|
110
|
+
|
|
111
|
+
```icu
|
|
112
|
+
{count, plural, one {# article} other {# articles}}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## greeting
|
|
116
|
+
|
|
117
|
+
Optional: a
|
|
118
|
+
|
|
119
|
+
```icu
|
|
120
|
+
Bonjour {name} !
|
|
121
|
+
```
|
|
122
|
+
````
|
|
123
|
+
|
|
124
|
+
- **Context** explains where a string appears and anything a translator needs: tone, length limits, what not to translate.
|
|
125
|
+
- **Optional** lists placeholders a translation may leave out, such as an English article (`{a}` for "a" or "an") that other languages don't need.
|
|
126
|
+
- The message is [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/): named placeholders `{name}`, `plural`, `selectordinal`, `select`, `number` (`integer`, `percent`, `::currency/EUR`), `date` and `time` with a style, and tags such as `<b>…</b>`.
|
|
127
|
+
- An apostrophe is just an apostrophe. To write a literal brace, or `<` before a letter, quote it: `'{'`, `'<'`.
|
|
128
|
+
|
|
129
|
+
That is the whole format. Bookkeeping lives beside the files in `i18nmd.lock.json`, which records the source language and which translations are current.
|
|
130
|
+
|
|
131
|
+
Every language file uses the same tokens and placeholders as the source. `i18nmd check` validates all of them: a translation with a missing or unknown placeholder, a broken plural or a tag that doesn't match is reported, and the build uses the source text until it is fixed. Builds never fail on missing or outdated translations.
|
|
132
|
+
|
|
133
|
+
## Divisions
|
|
134
|
+
|
|
135
|
+
A large app can split its strings into divisions, one subdirectory each, divided however suits the people editing them:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
translations/
|
|
139
|
+
i18nmd.lock.json
|
|
140
|
+
account/i18n-en.md account/i18n-fr.md
|
|
141
|
+
ui/i18n-en.md ui/i18n-fr.md
|
|
142
|
+
marketing/i18n-en.md marketing/i18n-fr.md
|
|
143
|
+
marketing/landing/i18n-en.md
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Each division has its own source file and translations, and its tokens are namespaced by its path. `## hero` in `marketing/i18n-en.md` is the token `marketing.hero`:
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
import { i18nmd } from './i18n/marketing';
|
|
150
|
+
import './i18n/marketing.landing';
|
|
151
|
+
import './i18n/knowledge-hub';
|
|
152
|
+
|
|
153
|
+
i18nmd.marketing('hero') // or i18nmd('marketing.hero')
|
|
154
|
+
i18nmd.marketing.landing('save') // marketing/landing/
|
|
155
|
+
i18nmd.knowledgeHub('search') // knowledge-hub/ becomes camelCase
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Files keep the short names, so two divisions can both have a `save`. Name division directories with letters, digits, `_` and `-`; names every function already has (`name`, `length`, `call`, …) and `in` are refused.
|
|
159
|
+
|
|
160
|
+
Divisions also split your bundle: each one compiles to its own module, which your bundler ships with the code that uses it. Put the strings every page needs, such as sign-in and loading screens, in a small division of their own, so the first download carries only those.
|
|
161
|
+
|
|
162
|
+
Commands on `translations/` cover every division, and `status` breaks progress down by division. Pass a division's directory to work on it alone, such as `i18nmd translate translations/marketing`. The lock stays at the top and is shared.
|
|
163
|
+
|
|
164
|
+
## Using the generated code
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
npx i18nmd compile # translations/ → src/i18n
|
|
168
|
+
npx i18nmd compile --out lib/i18n # elsewhere
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Calling translations
|
|
172
|
+
|
|
173
|
+
```tsx
|
|
174
|
+
import { i18nmd } from './i18n/i18n';
|
|
175
|
+
|
|
176
|
+
i18nmd('cart_items', { count: 3 }) // top-level token
|
|
177
|
+
i18nmd.ui('save') // a division's token
|
|
178
|
+
i18nmd.in('fr').ui('save') // a fixed language
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
TypeScript checks every token name and its values: a missing value, a wrong type or a token from another division is a compile error. A plain placeholder also accepts `null` or `undefined` and renders nothing, as JSX does.
|
|
182
|
+
|
|
183
|
+
Code that calls a division imports `i18nmd` from that division's module (`./i18n/ui`). A file that calls a second division also imports its module, `import './i18n/marketing'`. `extract` writes these imports. `npx i18nmd check --in src` fails with the line to add when one is missing, and `--fix` adds it.
|
|
184
|
+
|
|
185
|
+
Strings in code that runs once, such as a module-level constant, are translated at that moment. Wrap them in a function so a language change reaches them:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
export const steps = () => [i18nmd.ui('measure'), i18nmd.ui('cut')];
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### The current language
|
|
192
|
+
|
|
193
|
+
The current language starts as the reader's earlier choice, then their browser's languages, then the source language. `language.mjs` manages it and holds no messages, so your entry code can import it cheaply:
|
|
194
|
+
|
|
195
|
+
| Export | Does |
|
|
196
|
+
| --- | --- |
|
|
197
|
+
| `languages` | Every language code and its name in that language, for a picker. |
|
|
198
|
+
| `getLanguage()` | The current language. |
|
|
199
|
+
| `setLanguage(code)` | Loads the language, switches to it and remembers it. Accepts a close match: `pt` picks `pt-BR`. |
|
|
200
|
+
| `onLanguageChange(listener)` | Calls the listener after each switch; returns an unsubscribe function. |
|
|
201
|
+
| `ready` | Resolves once the reader's language has loaded. |
|
|
202
|
+
| `loadLanguage(code)` | Loads a language for `i18nmd.in(code)`, on a server or in tests. |
|
|
203
|
+
|
|
204
|
+
Each translation is its own chunk, so readers download only their language. Render after `ready` so a reader who chose French sees French from the first paint; for the source language it resolves at once:
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
import { ready, getLanguage, onLanguageChange } from './i18n/language.mjs';
|
|
208
|
+
|
|
209
|
+
function Localized() {
|
|
210
|
+
const language = useSyncExternalStore(onLanguageChange, getLanguage);
|
|
211
|
+
return <App key={language} />; // re-render everything in the new language
|
|
212
|
+
}
|
|
213
|
+
ready.then(() => createRoot(root).render(<Localized />));
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Language lookup tries the exact code (`fr-CA`), then the base language (`fr`), then the source language. Inside a message, a missing translation falls back to the source text.
|
|
217
|
+
|
|
218
|
+
### Rich text
|
|
219
|
+
|
|
220
|
+
Inline markup stays inside the sentence, so translators can reorder it:
|
|
221
|
+
|
|
222
|
+
```icu
|
|
223
|
+
Read <link>the guide</link> before <b>{date, date, long}</b>.
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
i18nmd('read_the_guide', {
|
|
228
|
+
date,
|
|
229
|
+
link: chunks => <a key="link" href="/guide">{chunks}</a>,
|
|
230
|
+
b: chunks => <b key="b">{chunks}</b>,
|
|
231
|
+
})
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Messages with tags return an array, which React renders directly.
|
|
235
|
+
|
|
236
|
+
### What compile writes
|
|
237
|
+
|
|
238
|
+
| File | Holds |
|
|
239
|
+
| --- | --- |
|
|
240
|
+
| `i18n.ts` | Types, `i18nmd`, and top-level tokens. |
|
|
241
|
+
| `<division>.ts` | One division's source-language messages. |
|
|
242
|
+
| `language.mjs` | The current language and loaders, without messages. |
|
|
243
|
+
| `languages/<code>.mjs` | Every message in one other language. |
|
|
244
|
+
| `runtime.mjs` | The formatter, which uses the browser's `Intl` for plurals, numbers and dates. |
|
|
245
|
+
|
|
246
|
+
Commit these or generate them in your build; compile removes files it generated earlier but no longer writes. `--eager` puts every message and language in `i18n.ts` instead, for servers, tests and small apps. `--target js` writes the same files as JavaScript, `--target json` writes one JSON file of message text, and `--skip <division>` leaves out divisions another program uses.
|
|
247
|
+
|
|
248
|
+
### Errors at runtime
|
|
249
|
+
|
|
250
|
+
An unknown token or a missing value never crashes the page. The runtime logs it and shows the token name or `{placeholder}` instead. A token from a division the page never imported says which import to add.
|
|
251
|
+
|
|
252
|
+
## Extracting strings from your code
|
|
253
|
+
|
|
254
|
+
```sh
|
|
255
|
+
npx i18nmd extract src --in-place # everything → translations/
|
|
256
|
+
npx i18nmd extract src/account src/routes.tsx --out translations/account --in-place
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`extract` reads JavaScript and TypeScript, with or without JSX, and moves these into the source language file:
|
|
260
|
+
|
|
261
|
+
- JSX text, keeping each sentence whole. Values inside a sentence become named placeholders: `{formatLength(kerf)}` becomes `{kerf}`, and `{items.length}` becomes `{itemsCount}`.
|
|
262
|
+
- Inline elements such as `<b>`, `<a href>` and `<Link to>`, which become tags.
|
|
263
|
+
- Wording chosen in code: `{busy ? "Saving…" : "Save"}` becomes two messages.
|
|
264
|
+
- Visible attributes: `alt`, `title`, `placeholder`, `label`, `aria-label`, `aria-description`.
|
|
265
|
+
- Any string marked `/* i18n */`, or `/* i18n:token_name */` to choose its token.
|
|
266
|
+
|
|
267
|
+
It leaves alone numbers, symbols and text without letters, and anything it can't convert safely; those are listed with their file and line. It also flags counts that should be plurals and wording built in code that should become an ICU `select`.
|
|
268
|
+
|
|
269
|
+
Token names come from the words of the message, such as `welcome_back`, and never change when you edit the text. Running extract again keeps every token and translation, and only adds new strings. Without `--in-place` it writes converted copies to `--dest` (default `.i18n/src`) and leaves your sources alone. `--out translations/<division>` extracts into a division.
|
|
270
|
+
|
|
271
|
+
The extractor handles the mechanical part. [PROMPT.md](PROMPT.md) is a prompt for a coding agent to do the rest: strings in plain `.ts` files and objects, sentences built in code, and checking every screen.
|
|
272
|
+
|
|
273
|
+
## Translating with an LLM
|
|
274
|
+
|
|
275
|
+
```sh
|
|
276
|
+
npx i18nmd --add french # also: Français, fr, pt-BR, "brazilian portuguese", klingon
|
|
277
|
+
npx i18nmd --add pirate # anything unrecognized becomes a custom style
|
|
278
|
+
npx i18nmd --top 10 # the 10 most widely spoken languages (i18nmd languages lists them)
|
|
279
|
+
npx i18nmd translate # fill every missing or outdated translation
|
|
280
|
+
npx i18nmd translate --only fr,de --dry-run
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Set one of these:
|
|
284
|
+
|
|
285
|
+
```sh
|
|
286
|
+
export ANTHROPIC_API_KEY=… # Claude
|
|
287
|
+
export OPENAI_API_KEY=… OPENAI_BASE_URL=… # OpenAI or any compatible API
|
|
288
|
+
export I18NMD_BASE_URL=… I18NMD_API_KEY=… I18NMD_MODEL=… # overrides both
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The provider is detected from the URL or key; `--provider`, `--base-url`, `--model` and `--batch` override it per run.
|
|
292
|
+
|
|
293
|
+
Messages go in batches, each with its context line, existing translations as a glossary, and any earlier translation of a changed message. Every reply is checked like a hand-written translation. A message that fails is retried once with the error, and anything still failing is reported and falls back to the source language. Progress is saved after every batch, so an interrupted run loses nothing.
|
|
294
|
+
|
|
295
|
+
## Keeping translations current
|
|
296
|
+
|
|
297
|
+
```sh
|
|
298
|
+
npx i18nmd status
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
English (en): 1847 tokens, source
|
|
303
|
+
Français (fr): 1790/1847 done, 40 missing, 17 stale
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
A translation is **stale** when its source text changed after it was written. Edit the translation and i18nmd counts it as updated; there are no markers to remove. `translate` fills missing and stale translations.
|
|
307
|
+
|
|
308
|
+
| Command | Does |
|
|
309
|
+
| --- | --- |
|
|
310
|
+
| `status` | Progress per language and division. |
|
|
311
|
+
| `check` | Validates every file. `--strict` also fails on anything missing or stale, for CI before a release. `--in src` checks your code imports the divisions it calls and lists tokens no code calls. |
|
|
312
|
+
| `sync` | Updates `i18nmd.lock.json` and removes tokens the source no longer has from other languages. |
|
|
313
|
+
| `rename <old> <new> --in src` | Renames a token in every language, the lock, and the calls in your code. |
|
|
314
|
+
| `join --out all.md` | Writes every language into one Markdown file, to review side by side or hand to an LLM. |
|
|
315
|
+
| `split all.md --out translations` | Splits a joined file back into language files. |
|
|
316
|
+
|
|
317
|
+
Commands find `translations/`, `i18n/`, `locales/` or the current directory on their own; pass a path or `--dir` for anywhere else. Every command also accepts a joined file in place of a directory.
|
|
318
|
+
|
|
319
|
+
## Python
|
|
320
|
+
|
|
321
|
+
```sh
|
|
322
|
+
npx i18nmd compile translations/server --target python --out app/i18n
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
from app.i18n.i18n import i18nmd, template, LANGS
|
|
327
|
+
|
|
328
|
+
i18nmd("server.parts", "de", n=3) # "3 Teile"
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
The Python module has no dependencies. It formats placeholders, plurals, ordinals, selects and numbers, with plural rules taken from your Node.js's `Intl` at compile time. `template(token, language)` returns the message with placeholders shown as `{name}`. `import-python <module.py> --out translations` converts an existing literal Python translation table.
|
|
332
|
+
|
|
333
|
+
## Other formats and libraries
|
|
334
|
+
|
|
335
|
+
```sh
|
|
336
|
+
npx i18nmd import locales/*/translation.json --from i18next
|
|
337
|
+
npx i18nmd import lang/en.json lang/fr.json --from formatjs
|
|
338
|
+
npx i18nmd export --to next-intl --out messages # messages/fr.json
|
|
339
|
+
npx i18nmd export --to formatjs --out lang # lang/fr.json, for react-intl
|
|
340
|
+
npx i18nmd export --to i18next --out locales # locales/fr/translation.json
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
FormatJS and next-intl already use ICU, so conversion is lossless, and FormatJS descriptions become context lines. For i18next, `{{name}}` becomes `{name}`, plural suffixes become ICU plurals, and nested keys become dotted tokens. Messages i18next can't express are kept as ICU strings for the i18next-icu plugin, with a warning. You can edit in i18nmd and ship whatever your app already loads.
|
|
344
|
+
|
|
345
|
+
## Command reference
|
|
346
|
+
|
|
347
|
+
| Command | |
|
|
348
|
+
| --- | --- |
|
|
349
|
+
| `extract <src…>` | `--in-place`, `--out translations/<division>`, `--source en`, `--runtime src/i18n/i18n`, `--dest .i18n/src`, `--locale-expr locale` (calls use `i18nmd.in(locale)`) |
|
|
350
|
+
| `compile` | `--out src/i18n`, `--target ts\|js\|json\|python`, `--eager`, `--skip <division,…>` |
|
|
351
|
+
| `--add <language>`, `--top <n>`, `translate` | `--only fr,de`, `--dry-run`, `--provider`, `--base-url`, `--model`, `--batch 40` |
|
|
352
|
+
| `status`, `check`, `sync` | `--strict`, `--in src`, `--fix`, `--skip` |
|
|
353
|
+
| `rename <old> <new>` | `--in src` |
|
|
354
|
+
| `join`, `split` | `--out` |
|
|
355
|
+
| `import`, `export`, `import-python` | `--from`, `--to`, `--out` |
|
|
356
|
+
| `languages` | The ranked list `--top` uses. |
|
|
357
|
+
|
|
358
|
+
Every command takes `--dir`, and `--source` to override the source language recorded in the lock. `npx i18nmd --help` lists everything.
|
|
359
|
+
|
|
360
|
+
## Limits in 0.1
|
|
361
|
+
|
|
362
|
+
- The extractor reads JavaScript and TypeScript. Strings in object properties (`{ label: "Save" }`) and plain `.ts` files need `/* i18n */` or the extraction prompt.
|
|
363
|
+
- Switching language loads that whole language at once, not per division.
|
|
364
|
+
- The Python target ignores tags and formats numbers without locale grouping; dates are passed through as given.
|
|
365
|
+
|
|
366
|
+
## Development
|
|
367
|
+
|
|
368
|
+
```sh
|
|
369
|
+
npm ci --ignore-scripts
|
|
370
|
+
npm test
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
The tests use a local stand-in for both LLM APIs, so they need no keys and make no network calls.
|
|
374
|
+
|
|
375
|
+
## License
|
|
376
|
+
|
|
377
|
+
MIT
|