@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/bin/i18nmd.mjs
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile, mkdir, readdir, stat, copyFile, rm } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { parseCatalog, serializeCatalog, parseLanguageFiles, languageFromFilename, renameToken, textOf, divisionOf, namespaceFor, accessorFor } from '../lib/catalog.mjs';
|
|
7
|
+
import { generateModule, generateModules, divisionFile, runtimeTypes } from '../lib/compiler.mjs';
|
|
8
|
+
import { extractSource, missingDivisionImports } from '../lib/extractor.mjs';
|
|
9
|
+
import { lockPathFor, findLock, namedRoot, readLock, serializeLock, report, syncLock, markCurrent } from '../lib/lock.mjs';
|
|
10
|
+
import { resolveLanguage, topLanguages, RANKED } from '../lib/languages.mjs';
|
|
11
|
+
import { llmConfig, translateLanguage } from '../lib/llm.mjs';
|
|
12
|
+
import { importMessages, exportMessages, FORMATS } from '../lib/interop.mjs';
|
|
13
|
+
|
|
14
|
+
const help = `i18nmd — one Markdown file per language
|
|
15
|
+
|
|
16
|
+
Translate with an LLM
|
|
17
|
+
i18nmd --add french [--add pirate ...] Create and translate new languages.
|
|
18
|
+
i18nmd --top 10 Add the 10 most widely spoken languages.
|
|
19
|
+
i18nmd translate [--only fr,de] Fill missing and outdated translations.
|
|
20
|
+
Endpoint: I18NMD_BASE_URL + I18NMD_API_KEY (+ I18NMD_MODEL), or ANTHROPIC_API_KEY,
|
|
21
|
+
or OPENAI_API_KEY. Flags: --base-url --model --provider anthropic|openai --batch 40
|
|
22
|
+
--dry-run (list the work without calling the API).
|
|
23
|
+
|
|
24
|
+
Keep files in shape
|
|
25
|
+
status Progress per language: done, missing, stale.
|
|
26
|
+
sync Update i18nmd.lock.json; drop tokens the source removed.
|
|
27
|
+
check [--strict] [--in src [--fix]] Validate; --strict fails on anything missing or stale;
|
|
28
|
+
--in checks code imports each division it calls and
|
|
29
|
+
lists tokens it never calls (--skip division,...).
|
|
30
|
+
rename <old> <new> [--in src] Rename a token everywhere, including source code calls.
|
|
31
|
+
join --out all.md / split <all.md> --out <dir>
|
|
32
|
+
One Markdown file with every language, and back.
|
|
33
|
+
|
|
34
|
+
Build
|
|
35
|
+
compile [--out src/i18n] [--target ts|js|json|python] [--skip division,...] [--eager]
|
|
36
|
+
extract <src...> [--out translations/<division>] [--in-place] [--runtime src/i18n/i18n]
|
|
37
|
+
[--source en] [--locale-expr locale]
|
|
38
|
+
|
|
39
|
+
Other tools
|
|
40
|
+
import <files.json...> --from ${FORMATS.join('|')} [--out translations] [--source en]
|
|
41
|
+
export --to ${FORMATS.join('|')} --out <dir>
|
|
42
|
+
import-python <module.py> --out <dir>
|
|
43
|
+
languages The ranked language list used by --top.
|
|
44
|
+
|
|
45
|
+
Commands find the translations directory (translations/, i18n/, locales/ or .) unless
|
|
46
|
+
--dir or a path is given. Subdirectories are divisions (ui/, marketing/, ...) with their
|
|
47
|
+
own i18n-<language>.md files; pass one to work on that division alone. A joined .md file
|
|
48
|
+
works wherever a directory does. The source language is recorded in i18nmd.lock.json;
|
|
49
|
+
--source overrides it. i18nmd --version prints the version.
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
function python(args) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const child = spawn('python3', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
55
|
+
let output = '', error = '';
|
|
56
|
+
child.stdout.on('data', data => { output += data; });
|
|
57
|
+
child.stderr.on('data', data => { error += data; });
|
|
58
|
+
child.on('error', reject);
|
|
59
|
+
child.on('close', code => code === 0 ? resolve(output) : reject(new Error(error.trim() || `Python exited ${code}.`)));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function filesAt(input) {
|
|
64
|
+
if (!(await stat(input)).isDirectory()) return /\.(?:[cm]?[jt]sx?)$/.test(input) ? [input] : [];
|
|
65
|
+
const files = [];
|
|
66
|
+
for (const entry of (await readdir(input, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
67
|
+
if (entry.name.startsWith('.') || ['node_modules', 'dist', 'build', 'vendor', 'coverage'].includes(entry.name)) continue;
|
|
68
|
+
const file = path.join(input, entry.name);
|
|
69
|
+
if (entry.isDirectory()) files.push(...await filesAt(file));
|
|
70
|
+
else if (entry.isFile() && /\.(?:[cm]?[jt]sx?)$/.test(file) && !/\.(test|spec)\.[jt]sx?$/.test(file) && !/\.d\.[cm]?ts$/.test(file)) files.push(file);
|
|
71
|
+
}
|
|
72
|
+
return files;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function save(file, text) { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, text); }
|
|
76
|
+
const exists = file => stat(file).then(() => true, () => false);
|
|
77
|
+
const isDirectory = file => stat(file).then(s => s.isDirectory(), () => false);
|
|
78
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
79
|
+
|
|
80
|
+
// Language files keyed by their path relative to dir; subdirectories are divisions.
|
|
81
|
+
async function readLanguageFiles(dir, prefix = '') {
|
|
82
|
+
const files = Object.create(null);
|
|
83
|
+
for (const entry of (await readdir(path.join(dir, prefix), { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
84
|
+
const name = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
85
|
+
if (entry.isFile() && /^i18n-.+\.md$/.test(entry.name)) files[name] = await readFile(path.join(dir, name), 'utf8');
|
|
86
|
+
else if (entry.isDirectory() && !entry.name.startsWith('.') && !['node_modules', 'dist', 'build', 'vendor', 'coverage'].includes(entry.name)) Object.assign(files, await readLanguageFiles(dir, name));
|
|
87
|
+
}
|
|
88
|
+
return files;
|
|
89
|
+
}
|
|
90
|
+
const fileFor = (division, locale) => `${division ? `${division}/` : ''}i18n-${locale}.md`;
|
|
91
|
+
|
|
92
|
+
async function findTranslations(required = true) {
|
|
93
|
+
for (const dir of ['translations', 'i18n', 'locales', '.']) {
|
|
94
|
+
if (await isDirectory(dir) && Object.keys(await readLanguageFiles(dir)).length) return dir;
|
|
95
|
+
}
|
|
96
|
+
if (required) throw new Error('No i18n-<language>.md files found. Pass a directory, or run i18nmd extract first.');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The division a directory is within the translations tree (the directory with
|
|
100
|
+
// the lock, or a translations/, i18n/ or locales/ directory), so its tokens keep
|
|
101
|
+
// their full names when it is opened alone: translations/ui → "ui".
|
|
102
|
+
async function divisionPrefix(dir, lockFile) {
|
|
103
|
+
for (const root of [path.dirname(lockFile), namedRoot(dir)]) {
|
|
104
|
+
if (!root) continue;
|
|
105
|
+
const relative = path.relative(path.resolve(root), path.resolve(dir));
|
|
106
|
+
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative.split(path.sep).join('/');
|
|
107
|
+
}
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The translation files, their lock, and how to write them back.
|
|
112
|
+
async function open(input, options) {
|
|
113
|
+
input ||= options.dir || await findTranslations();
|
|
114
|
+
const directory = await isDirectory(input);
|
|
115
|
+
const { file: lockFile, partial } = directory ? await findLock(input) : { file: lockPathFor(input, false), partial: false };
|
|
116
|
+
const lock = await readLock(lockFile);
|
|
117
|
+
const strict = !!options.strict;
|
|
118
|
+
const raw = directory ? await readLanguageFiles(input) : { [input]: await readFile(input, 'utf8') };
|
|
119
|
+
const catalog = directory
|
|
120
|
+
? parseLanguageFiles(raw, { source: options.source || lock.source, syntax: options.syntax || 'icu', strict, prefix: await divisionPrefix(input, lockFile) })
|
|
121
|
+
: parseCatalog(raw[input], { filename: input, syntax: options.syntax, allowIncomplete: !strict, lenient: !strict });
|
|
122
|
+
catalog.orphans ||= {};
|
|
123
|
+
catalog.divisions ||= [''];
|
|
124
|
+
let queue = Promise.resolve();
|
|
125
|
+
const ws = {
|
|
126
|
+
input, directory, lockFile, partial, lock, catalog, raw,
|
|
127
|
+
rows: () => report(catalog, lock),
|
|
128
|
+
// missing: false leaves out "N tokens missing" lines when a per-language summary follows.
|
|
129
|
+
warn: ({ missing = false } = {}) => { for (const warning of catalog.warnings || []) if (missing || !/ tokens? missing \(falls back/.test(warning)) console.warn(`i18nmd: ${warning}`); },
|
|
130
|
+
// Writes are queued so concurrent translations never interleave.
|
|
131
|
+
write: (locale, { dropOrphans = false } = {}) => queue = queue.then(async () => {
|
|
132
|
+
if (!directory) await save(input, serializeCatalog(catalog, { allowIncomplete: true }));
|
|
133
|
+
else for (const division of catalog.divisions) {
|
|
134
|
+
// Files hold short names; the division's namespace is implied by where they are.
|
|
135
|
+
const namespace = namespaceFor(division, catalog.prefix);
|
|
136
|
+
const local = list => list.filter(m => (m.division || '') === division).map(m => ({ ...m, key: m.key.slice(namespace.length) }));
|
|
137
|
+
const messages = local(catalog.messages);
|
|
138
|
+
const orphans = dropOrphans ? [] : local(catalog.orphans[locale] || []);
|
|
139
|
+
const name = fileFor(division, locale);
|
|
140
|
+
// A division gets a file for this language once it has something in it.
|
|
141
|
+
if (!Object.hasOwn(raw, name) && !orphans.length && !messages.some(m => textOf(m, locale) !== undefined)) continue;
|
|
142
|
+
let text = serializeCatalog({ ...catalog, messages }, { locale, allowIncomplete: true });
|
|
143
|
+
if (orphans.length) text += serializeCatalog({ source: locale, syntax: catalog.syntax, languages: { [locale]: catalog.languages[locale] }, messages: orphans }, { locale }).replace(/^# .*\n\n/, '');
|
|
144
|
+
await save(path.join(input, name), text);
|
|
145
|
+
}
|
|
146
|
+
lock.source = catalog.source;
|
|
147
|
+
await save(lockFile, serializeLock(lock));
|
|
148
|
+
}),
|
|
149
|
+
saveLock: () => queue = queue.then(() => { lock.source = catalog.source; return save(lockFile, serializeLock(lock)); }),
|
|
150
|
+
};
|
|
151
|
+
return ws;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function summary(row) {
|
|
155
|
+
const parts = [`${row.done}/${row.total} done`];
|
|
156
|
+
if (row.missing.length) parts.push(`${row.missing.length} missing`);
|
|
157
|
+
if (row.stale.length) parts.push(`${row.stale.length} stale`);
|
|
158
|
+
return `${row.name} (${row.locale}): ${parts.join(', ')}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function translate(ws, targets, options) {
|
|
162
|
+
const { catalog, lock } = ws;
|
|
163
|
+
const work = [];
|
|
164
|
+
for (const target of targets) {
|
|
165
|
+
if (target.code === catalog.source) continue;
|
|
166
|
+
const isNew = !Object.hasOwn(catalog.languages, target.code);
|
|
167
|
+
const row = isNew ? undefined : ws.rows().find(r => r.locale === target.code);
|
|
168
|
+
const keys = isNew ? catalog.messages.map(m => m.key) : [...row.missing, ...row.stale];
|
|
169
|
+
if (!isNew) target.name = catalog.languages[target.code];
|
|
170
|
+
if (keys.length) work.push({ target, keys, isNew });
|
|
171
|
+
else console.log(`${target.name} (${target.code}) is up to date.`);
|
|
172
|
+
}
|
|
173
|
+
if (!work.length) return;
|
|
174
|
+
for (const { target, keys, isNew } of work) console.log(`${target.name} (${target.code}): ${plural(keys.length, 'message')} to translate${isNew ? ' (new language)' : ''}`);
|
|
175
|
+
if (options['dry-run']) return;
|
|
176
|
+
const config = llmConfig(options);
|
|
177
|
+
console.log(`Using ${config.model} at ${config.baseUrl}.`);
|
|
178
|
+
const batchSize = Number(options.batch || 40);
|
|
179
|
+
if (!Number.isInteger(batchSize) || batchSize < 1) throw new Error('--batch must be a positive whole number.');
|
|
180
|
+
let failures = 0;
|
|
181
|
+
const run = async ({ target, keys, isNew }) => {
|
|
182
|
+
if (isNew) catalog.languages[target.code] = target.name;
|
|
183
|
+
let done = 0;
|
|
184
|
+
const failed = await translateLanguage(catalog, target, keys, config, {
|
|
185
|
+
batchSize,
|
|
186
|
+
onBatch: async ok => {
|
|
187
|
+
for (const message of catalog.messages) {
|
|
188
|
+
if (!Object.hasOwn(ok, message.key)) continue;
|
|
189
|
+
message.translations[target.code] = ok[message.key];
|
|
190
|
+
if (message.invalid) delete message.invalid[target.code];
|
|
191
|
+
markCurrent(catalog, lock, target.code, message);
|
|
192
|
+
done++;
|
|
193
|
+
}
|
|
194
|
+
if (Object.keys(ok).length) await ws.write(target.code);
|
|
195
|
+
console.log(` ${target.code}: ${done}/${keys.length}`);
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
for (const [key, error] of Object.entries(failed)) { failures++; console.warn(`i18nmd: ${target.code} ${key}: ${error}`); }
|
|
199
|
+
if (isNew && !done) delete catalog.languages[target.code];
|
|
200
|
+
};
|
|
201
|
+
// A few languages at a time keeps within typical rate limits.
|
|
202
|
+
const pending = [...work];
|
|
203
|
+
await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => { while (pending.length) await run(pending.shift()); }));
|
|
204
|
+
if (failures) { process.exitCode = 1; console.warn(`i18nmd: ${plural(failures, 'message')} could not be translated; they fall back to ${catalog.source}. Run i18nmd translate to retry.`); }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function localeFromJsonPath(file) {
|
|
208
|
+
const base = path.basename(file, '.json');
|
|
209
|
+
try { if (/^[a-z]{2,3}(?:[-_][A-Za-z0-9]+)*$/.test(base)) return { locale: Intl.getCanonicalLocales(base.replace(/_/g, '-'))[0] }; } catch { /* fall through */ }
|
|
210
|
+
return { locale: Intl.getCanonicalLocales(path.basename(path.dirname(file)).replace(/_/g, '-'))[0], namespace: base };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function main() {
|
|
214
|
+
let argv = process.argv.slice(2);
|
|
215
|
+
if (argv[0] === '--add') argv = ['add', ...argv.slice(1)];
|
|
216
|
+
if (argv[0] === '--top') argv = ['top', ...argv.slice(1)];
|
|
217
|
+
const [command, ...rest] = argv;
|
|
218
|
+
if (!command || ['help', '--help', '-h'].includes(command)) { console.log(help); return; }
|
|
219
|
+
if (['--version', '-v', 'version'].includes(command)) { console.log(JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version); return; }
|
|
220
|
+
const aliases = { locale: 'source', language: 'locale-expr' };
|
|
221
|
+
const valued = /^(skip|out|source|locale|language|locale-expr|dest|runtime|target|table|languages|syntax|in|from|to|model|base-url|provider|batch|only|dir)$/;
|
|
222
|
+
const options = Object.create(null), positional = [];
|
|
223
|
+
for (let i = 0; i < rest.length; i++) {
|
|
224
|
+
const arg = rest[i];
|
|
225
|
+
if (arg === '--add' && command === 'add') continue;
|
|
226
|
+
if (!arg.startsWith('--')) { positional.push(arg); continue; }
|
|
227
|
+
let flag = arg.slice(2);
|
|
228
|
+
if (!valued.test(flag) && !['merge', 'strict', 'dry-run', 'in-place', 'eager', 'fix'].includes(flag)) throw new Error(`Unknown option: ${arg}`);
|
|
229
|
+
flag = aliases[flag] || flag;
|
|
230
|
+
if (Object.hasOwn(options, flag)) throw new Error(`Duplicate option: ${arg}`);
|
|
231
|
+
if (!valued.test(flag)) options[flag] = true;
|
|
232
|
+
else { if (!rest[i + 1] || rest[i + 1].startsWith('--')) throw new Error(`Supply a value for ${arg}.`); options[flag] = rest[++i]; }
|
|
233
|
+
}
|
|
234
|
+
const one = () => { if (positional.length > 1) throw new Error(`${command} takes at most one path.`); return positional[0]; };
|
|
235
|
+
|
|
236
|
+
if (command === 'languages') {
|
|
237
|
+
RANKED.forEach((code, i) => { const l = resolveLanguage(code); console.log(`${String(i + 1).padStart(2)}. ${code.padEnd(4)} ${l.english} — ${l.name}`); });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (command === 'add' || command === 'top') {
|
|
241
|
+
if (!positional.length) throw new Error(command === 'add' ? 'Name a language: i18nmd --add french' : 'Give a number: i18nmd --top 10');
|
|
242
|
+
const ws = await open(undefined, options); ws.warn();
|
|
243
|
+
const targets = command === 'top' ? topLanguages(Number(positional[0])).map(resolveLanguage) : positional.map(resolveLanguage);
|
|
244
|
+
await translate(ws, targets, options); return;
|
|
245
|
+
}
|
|
246
|
+
if (command === 'translate') {
|
|
247
|
+
const ws = await open(one(), options); ws.warn();
|
|
248
|
+
const only = options.only?.split(',').map(s => resolveLanguage(s).code);
|
|
249
|
+
const targets = Object.keys(ws.catalog.languages).filter(l => l !== ws.catalog.source && (!only || only.includes(l))).map(code => ({ ...resolveLanguage(code), code, name: ws.catalog.languages[code] }));
|
|
250
|
+
await translate(ws, targets, options); return;
|
|
251
|
+
}
|
|
252
|
+
if (command === 'check') {
|
|
253
|
+
const ws = await open(one(), options); ws.warn();
|
|
254
|
+
if (options.strict) {
|
|
255
|
+
const pending = ws.rows().filter(row => row.missing.length || row.stale.length);
|
|
256
|
+
if (pending.length) throw new Error(pending.map(summary).join('; ') + '.');
|
|
257
|
+
}
|
|
258
|
+
console.log(`Validated ${ws.catalog.messages.length} tokens in ${Object.keys(ws.catalog.languages).length} languages.`);
|
|
259
|
+
// --in: code that calls a division imports that division's messages.
|
|
260
|
+
if (options.in) {
|
|
261
|
+
const accessors = Object.create(null), namespaces = Object.create(null);
|
|
262
|
+
for (const division of ws.catalog.divisions.filter(Boolean)) {
|
|
263
|
+
const namespace = namespaceFor(division, ws.catalog.prefix || '');
|
|
264
|
+
accessors[accessorFor(namespace)] = namespaces[namespace] = divisionFile(namespace.slice(0, -1).replace(/\./g, '/'));
|
|
265
|
+
}
|
|
266
|
+
let problems = 0, fixed = 0, dynamic = 0;
|
|
267
|
+
const used = new Set();
|
|
268
|
+
for (const file of await filesAt(options.in)) {
|
|
269
|
+
const text = await readFile(file, 'utf8');
|
|
270
|
+
const scan = missingDivisionImports(text, { accessors, namespaces, filename: file });
|
|
271
|
+
scan.used.forEach(token => used.add(token)); dynamic += scan.dynamic;
|
|
272
|
+
const { missing, fix } = scan;
|
|
273
|
+
if (!missing.length) continue;
|
|
274
|
+
if (options.fix) { await save(file, fix); fixed++; continue; }
|
|
275
|
+
problems += missing.length;
|
|
276
|
+
for (const m of missing) console.error(`${file}:${m.line}: uses the ${m.division} division without its messages; add ${m.statement}`);
|
|
277
|
+
}
|
|
278
|
+
if (fixed) console.log(`Added division imports to ${plural(fixed, 'file')}.`);
|
|
279
|
+
// Tokens no code calls by name: left behind when code changed or moved division.
|
|
280
|
+
// --skip: divisions other code uses (a server's replies), as in compile.
|
|
281
|
+
const skipped = (options.skip || '').split(',').map(d => d.trim().replace(/\/+$/, '')).filter(Boolean);
|
|
282
|
+
const unused = ws.catalog.messages.filter(m => !skipped.some(d => m.division === d || m.division?.startsWith(d + '/'))).map(m => m.key).filter(key => !used.has(key));
|
|
283
|
+
if (unused.length) console.warn(`i18nmd: ${plural(unused.length, 'token')} not called under ${options.in}${dynamic ? ` (${plural(dynamic, 'call')} there compute their token, so some may be in use)` : ''}: ${unused.length > 12 ? unused.slice(0, 12).join(', ') + ', …' : unused.join(', ')}`);
|
|
284
|
+
if (problems) throw new Error(`${plural(problems, 'missing division import')}. Run i18nmd check --in ${options.in} --fix to add them.`);
|
|
285
|
+
console.log(`Every i18nmd call under ${options.in} imports its division.`);
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (command === 'status') {
|
|
290
|
+
const ws = await open(one(), options);
|
|
291
|
+
const orphans = Object.entries(ws.catalog.orphans).filter(([, list]) => list.length);
|
|
292
|
+
console.log(`${ws.catalog.languages[ws.catalog.source]} (${ws.catalog.source}): ${ws.catalog.messages.length} tokens, source`);
|
|
293
|
+
for (const row of ws.rows()) {
|
|
294
|
+
console.log(summary(row));
|
|
295
|
+
for (const kind of ['stale', 'missing']) if (row[kind].length && row[kind].length <= 10) console.log(` ${kind}: ${row[kind].join(', ')}`);
|
|
296
|
+
}
|
|
297
|
+
for (const [locale, list] of orphans) console.log(`${locale}: ${list.map(m => m.key).join(', ')} not in the source; sync removes them.`);
|
|
298
|
+
if (ws.catalog.divisions.length > 1) {
|
|
299
|
+
console.log('\nBy division:');
|
|
300
|
+
for (const division of ws.catalog.divisions) {
|
|
301
|
+
const messages = ws.catalog.messages.filter(m => m.division === division);
|
|
302
|
+
const rows = report({ ...ws.catalog, messages }, ws.lock).map(row => `${row.locale} ${row.done}/${row.total}`);
|
|
303
|
+
console.log(` ${division || './'}: ${plural(messages.length, 'token')}${rows.length ? `; ${rows.join(', ')}` : ''}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (command === 'sync') {
|
|
309
|
+
const ws = await open(one(), options);
|
|
310
|
+
const changes = syncLock(ws.catalog, ws.lock, { partial: ws.partial });
|
|
311
|
+
let rewritten = 0;
|
|
312
|
+
for (const locale of Object.keys(ws.catalog.languages)) {
|
|
313
|
+
if (locale === ws.catalog.source) continue;
|
|
314
|
+
const removed = ws.catalog.orphans[locale] || [];
|
|
315
|
+
removed.forEach(m => changes.push(`${locale}: removed ${m.key}; the source no longer has it`));
|
|
316
|
+
const legacy = Object.entries(ws.raw).some(([name, text]) => (!ws.directory || path.basename(name) === `i18n-${locale}.md`) && /^(Status|Source)( [\w-]+)?: /m.test(text));
|
|
317
|
+
if (removed.length || legacy) { await ws.write(locale, { dropOrphans: true }); rewritten++; }
|
|
318
|
+
}
|
|
319
|
+
await ws.saveLock();
|
|
320
|
+
changes.forEach(change => console.log(change));
|
|
321
|
+
console.log(`Synced ${ws.lockFile}${rewritten ? ` and ${plural(rewritten, 'language file')}` : ''}; ${plural(changes.length, 'change')}.`);
|
|
322
|
+
for (const row of ws.rows()) if (row.missing.length || row.stale.length) console.log(summary(row));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (command === 'compile') {
|
|
326
|
+
options.out ||= 'src/i18n';
|
|
327
|
+
const ws = await open(one(), options); ws.warn({ missing: false });
|
|
328
|
+
// --skip leaves out divisions another program uses, such as server-only replies.
|
|
329
|
+
const skipped = (options.skip || '').split(',').map(d => d.trim().replace(/\/+$/, '')).filter(Boolean);
|
|
330
|
+
for (const division of skipped) if (!ws.catalog.divisions.some(d => d === division || d.startsWith(division + '/'))) throw new Error(`No division ${division} to skip.`);
|
|
331
|
+
const messages = ws.catalog.messages.filter(m => !skipped.some(d => m.division === d || m.division?.startsWith(d + '/')));
|
|
332
|
+
// A language ships once it has a translation here; one only skipped divisions have stays out.
|
|
333
|
+
const languages = Object.fromEntries(Object.entries(ws.catalog.languages).filter(([l]) => l === ws.catalog.source || messages.some(m => Object.hasOwn(m.translations, l))));
|
|
334
|
+
const catalog = { ...ws.catalog, languages, messages };
|
|
335
|
+
for (const row of report(catalog, ws.lock)) if (row.missing.length || row.stale.length) console.warn(`i18nmd: ${summary(row)}`);
|
|
336
|
+
const target = options.target || 'ts';
|
|
337
|
+
if (!['ts', 'js'].includes(target)) {
|
|
338
|
+
const filename = { json: 'messages.json', python: 'i18n.py' }[target];
|
|
339
|
+
if (!filename) throw new Error(`Unknown output target: ${target}`);
|
|
340
|
+
await save(path.join(options.out, filename), generateModule(catalog, target));
|
|
341
|
+
console.log(`Compiled ${plural(catalog.messages.length, 'token')} to ${path.join(options.out, filename)}.`); return;
|
|
342
|
+
}
|
|
343
|
+
const files = generateModules(catalog, { target, eager: !!options.eager });
|
|
344
|
+
files['runtime.mjs'] = '/* eslint-disable */\n' + await readFile(new URL('../lib/runtime.mjs', import.meta.url), 'utf8');
|
|
345
|
+
files['runtime.d.mts'] = runtimeTypes;
|
|
346
|
+
// Files an earlier compile wrote that this one does not (a renamed division, a
|
|
347
|
+
// removed language) would linger in the build; generated files say so on line 1.
|
|
348
|
+
const stale = [];
|
|
349
|
+
for (const dir of [options.out, path.join(options.out, 'languages')]) {
|
|
350
|
+
for (const entry of await readdir(dir).catch(() => [])) {
|
|
351
|
+
const file = path.join(dir, entry), name = path.relative(options.out, file).split(path.sep).join('/');
|
|
352
|
+
if (Object.hasOwn(files, name) || !/\.(?:ts|mjs)$/.test(entry)) continue;
|
|
353
|
+
if ((await readFile(file, 'utf8')).startsWith('// Generated from i18n.md.')) stale.push(file);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
for (const [name, text] of Object.entries(files)) await save(path.join(options.out, name), text);
|
|
357
|
+
for (const file of stale) await rm(file);
|
|
358
|
+
const divisions = Object.keys(files).filter(n => !/^(?:i18n|language|runtime)\.|^languages\//.test(n) && !n.endsWith('.d.mts')).length;
|
|
359
|
+
console.log(`Compiled ${plural(catalog.messages.length, 'token')} into ${options.out}: ${plural(divisions, 'division module')}, ${plural(Object.keys(catalog.languages).length - 1, 'language chunk')}${options.eager ? ' (eager)' : ''}.`); return;
|
|
360
|
+
}
|
|
361
|
+
if (command === 'join') {
|
|
362
|
+
if (!options.out || !/\.md$/.test(options.out)) throw new Error('Choose the joined Markdown file with --out, e.g. --out translations.md.');
|
|
363
|
+
const ws = await open(one(), options);
|
|
364
|
+
if (!ws.directory) throw new Error('join reads a directory of i18n-<language>.md files.');
|
|
365
|
+
if (ws.catalog.divisions.length > 1) throw new Error(`${ws.input} has several divisions (${ws.catalog.divisions.map(d => d || './').join(', ')}); join one at a time, e.g. i18nmd join ${path.join(ws.input, ws.catalog.divisions.find(Boolean))} --out all.md.`);
|
|
366
|
+
if (Object.values(ws.catalog.orphans).some(list => list.length)) throw new Error('Some languages have tokens the source lacks. Run sync or rename first so nothing is lost.');
|
|
367
|
+
await save(options.out, serializeCatalog(ws.catalog, { allowIncomplete: true }));
|
|
368
|
+
if (await exists(ws.lockFile)) await copyFile(ws.lockFile, lockPathFor(options.out, false));
|
|
369
|
+
console.log(`Joined ${plural(Object.keys(ws.catalog.languages).length, 'language')} into ${options.out}.`); return;
|
|
370
|
+
}
|
|
371
|
+
if (command === 'split') {
|
|
372
|
+
const input = one();
|
|
373
|
+
if (!input || !options.out) throw new Error('Usage: i18nmd split all.md --out <directory>');
|
|
374
|
+
const text = await readFile(input, 'utf8');
|
|
375
|
+
if (!text.startsWith('---')) throw new Error(`${input} is already a single-language file.`);
|
|
376
|
+
const catalog = parseCatalog(text, { allowIncomplete: true, lenient: true, syntax: options.syntax });
|
|
377
|
+
for (const locale of Object.keys(catalog.languages)) await save(path.join(options.out, `i18n-${locale}.md`), serializeCatalog(catalog, { locale, allowIncomplete: true }));
|
|
378
|
+
if (await exists(lockPathFor(input, false))) await copyFile(lockPathFor(input, false), lockPathFor(options.out, true));
|
|
379
|
+
console.log(`Split ${input} into ${plural(Object.keys(catalog.languages).length, 'file')} in ${options.out}.`); return;
|
|
380
|
+
}
|
|
381
|
+
if (command === 'rename') {
|
|
382
|
+
if (positional.length < 2 || positional.length > 3) throw new Error('Usage: i18nmd rename [dir] <old_token> <new_token>');
|
|
383
|
+
const [from, to] = positional.slice(-2);
|
|
384
|
+
const input = positional.length === 3 ? positional[0] : options.dir || await findTranslations();
|
|
385
|
+
const directory = await isDirectory(input);
|
|
386
|
+
const lockFile = directory ? (await findLock(input)).file : lockPathFor(input, false);
|
|
387
|
+
const prefix = directory ? await divisionPrefix(input, lockFile) : '';
|
|
388
|
+
const targets = [];
|
|
389
|
+
for (const name of directory ? Object.keys(await readLanguageFiles(input)) : [input]) {
|
|
390
|
+
const file = directory ? path.join(input, name) : input;
|
|
391
|
+
const text = await readFile(file, 'utf8');
|
|
392
|
+
const catalog = parseCatalog(text, { filename: file, syntax: options.syntax, allowIncomplete: true, lenient: true });
|
|
393
|
+
const namespace = directory ? namespaceFor(divisionOf(name), prefix) : '';
|
|
394
|
+
const keys = catalog.messages.map(m => namespace + m.key);
|
|
395
|
+
if (keys.includes(to)) throw new Error(`Token ${to} already exists in ${file}.`);
|
|
396
|
+
if (keys.includes(from)) targets.push({ file, text, catalog, namespace });
|
|
397
|
+
}
|
|
398
|
+
let renamed = 0;
|
|
399
|
+
for (const { file, text, catalog, namespace } of targets) {
|
|
400
|
+
if (!to.startsWith(namespace)) throw new Error(`${from} is in ${path.dirname(file)}; a new name must keep its ${namespace.slice(0, -1)} namespace. To move it to another division, move its blocks between files.`);
|
|
401
|
+
const joined = text.startsWith('---');
|
|
402
|
+
renameToken(catalog, from.slice(namespace.length), to.slice(namespace.length));
|
|
403
|
+
await save(file, serializeCatalog(catalog, joined ? { allowIncomplete: true } : { locale: catalog.source }));
|
|
404
|
+
renamed++;
|
|
405
|
+
}
|
|
406
|
+
if (!renamed) throw new Error(`No language file contains ${from}.`);
|
|
407
|
+
if (await exists(lockFile)) {
|
|
408
|
+
const lock = await readLock(lockFile);
|
|
409
|
+
for (const entries of Object.values(lock.translations)) if (Object.hasOwn(entries, from)) { entries[to] = entries[from]; delete entries[from]; }
|
|
410
|
+
await save(lockFile, serializeLock(lock));
|
|
411
|
+
}
|
|
412
|
+
let code = 0;
|
|
413
|
+
if (options.in) {
|
|
414
|
+
// Calls name the token by its full name, i18nmd("ui.save"), or by its short name
|
|
415
|
+
// on the division, i18nmd.ui("save") or i18nmd.in(locale).ui("save").
|
|
416
|
+
const { namespace } = targets[0];
|
|
417
|
+
const escape = text => text.replace(/[.\-$()]/g, '\\$&');
|
|
418
|
+
const forms = [['', from, to]];
|
|
419
|
+
if (namespace) forms.push([accessorFor(namespace), from.slice(namespace.length), to.slice(namespace.length)]);
|
|
420
|
+
const calls = forms.map(([accessor, old, renamed]) => [new RegExp(`(\\bi18nmd(?:\\.in\\([^()]*\\))?${escape(accessor)}\\(\\s*)(['"\`])${escape(old)}\\2`, 'g'), renamed]);
|
|
421
|
+
for (const file of await filesAt(options.in)) {
|
|
422
|
+
const source = await readFile(file, 'utf8');
|
|
423
|
+
let updated = source;
|
|
424
|
+
for (const [call, renamed] of calls) updated = updated.replace(call, (_, prefix, quote) => { code++; return `${prefix}${quote}${renamed}${quote}`; });
|
|
425
|
+
if (updated !== source) await save(file, updated);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
console.log(`Renamed ${from} to ${to} in ${plural(renamed, 'language file')}${options.in ? ` and ${plural(code, 'source call')}` : ''}.`); return;
|
|
429
|
+
}
|
|
430
|
+
if (command === 'import') {
|
|
431
|
+
if (!positional.length) throw new Error(`Usage: i18nmd import <files.json...> --from ${FORMATS.join('|')}`);
|
|
432
|
+
if (!options.from) throw new Error(`Choose the format with --from ${FORMATS.join('|')}.`);
|
|
433
|
+
const out = options.out || 'translations';
|
|
434
|
+
const warnings = [];
|
|
435
|
+
const byLocale = new Map();
|
|
436
|
+
const files = positional.map(file => ({ file, ...localeFromJsonPath(file) }));
|
|
437
|
+
const namespaces = new Set(files.map(f => f.namespace).filter(Boolean));
|
|
438
|
+
for (const { file, locale, namespace } of files) {
|
|
439
|
+
const prefix = namespaces.size > 1 && namespace ? `${namespace}.` : '';
|
|
440
|
+
const list = byLocale.get(locale) || [];
|
|
441
|
+
for (const message of importMessages(JSON.parse(await readFile(file, 'utf8')), options.from, warnings)) list.push({ ...message, key: prefix + message.key });
|
|
442
|
+
byLocale.set(locale, list);
|
|
443
|
+
}
|
|
444
|
+
const source = options.source || (byLocale.has('en') ? 'en' : [...byLocale.keys()][0]);
|
|
445
|
+
if (!byLocale.has(source)) throw new Error(`No ${source} file among the inputs; pick one with --source.`);
|
|
446
|
+
const languages = Object.fromEntries([source, ...[...byLocale.keys()].filter(l => l !== source)].map(l => [l, resolveLanguage(l).name]));
|
|
447
|
+
const messages = byLocale.get(source).map(m => ({ key: m.key, context: m.context, optional: [], translations: { [source]: m.text } }));
|
|
448
|
+
const index = new Map(messages.map(m => [m.key, m]));
|
|
449
|
+
for (const [locale, list] of byLocale) {
|
|
450
|
+
if (locale === source) continue;
|
|
451
|
+
for (const m of list) {
|
|
452
|
+
const target = index.get(m.key);
|
|
453
|
+
if (!target) warnings.push(`${locale}: ${m.key} is not in ${source}; skipped.`);
|
|
454
|
+
else { target.translations[locale] = m.text; target.context ||= m.context; }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const catalog = { title: 'Translations', source, syntax: 'icu', languages, messages };
|
|
458
|
+
const { validateCatalog } = await import('../lib/catalog.mjs');
|
|
459
|
+
validateCatalog(catalog, { allowIncomplete: true, lenient: true });
|
|
460
|
+
for (const locale of Object.keys(languages)) {
|
|
461
|
+
const file = path.join(out, `i18n-${locale}.md`);
|
|
462
|
+
if (await exists(file)) throw new Error(`${file} exists; import into an empty directory.`);
|
|
463
|
+
}
|
|
464
|
+
for (const locale of Object.keys(languages)) await save(path.join(out, `i18n-${locale}.md`), serializeCatalog(catalog, { locale, allowIncomplete: true }));
|
|
465
|
+
const lock = { source, translations: {} };
|
|
466
|
+
syncLock(catalog, lock);
|
|
467
|
+
await save(lockPathFor(out, true), serializeLock(lock));
|
|
468
|
+
[...warnings, ...(catalog.warnings || [])].forEach(w => console.warn(`i18nmd: ${w}`));
|
|
469
|
+
console.log(`Imported ${plural(messages.length, 'token')} in ${plural(Object.keys(languages).length, 'language')} into ${out}.`); return;
|
|
470
|
+
}
|
|
471
|
+
if (command === 'export') {
|
|
472
|
+
if (!options.to || !options.out) throw new Error(`Usage: i18nmd export --to ${FORMATS.join('|')} --out <directory>`);
|
|
473
|
+
const ws = await open(one(), options); ws.warn();
|
|
474
|
+
const warnings = [];
|
|
475
|
+
for (const locale of Object.keys(ws.catalog.languages)) {
|
|
476
|
+
const json = exportMessages(ws.catalog, locale, options.to, locale === ws.catalog.source ? warnings : []);
|
|
477
|
+
const file = options.to === 'i18next' ? path.join(options.out, locale, 'translation.json') : path.join(options.out, `${locale}.json`);
|
|
478
|
+
await save(file, JSON.stringify(json, null, 2) + '\n');
|
|
479
|
+
}
|
|
480
|
+
warnings.forEach(w => console.warn(`i18nmd: ${w}`));
|
|
481
|
+
console.log(`Exported ${plural(Object.keys(ws.catalog.languages).length, 'language')} for ${options.to} into ${options.out}.`); return;
|
|
482
|
+
}
|
|
483
|
+
if (command === 'import-python') {
|
|
484
|
+
if (!options.out) throw new Error('Choose the output directory with --out.');
|
|
485
|
+
const input = one();
|
|
486
|
+
const imported = JSON.parse(await python([fileURLToPath(new URL('../scripts/python_catalog.py', import.meta.url)), input, options.table || '_T', options.languages || 'LANGS']));
|
|
487
|
+
for (const locale of Object.keys(imported.languages)) await save(path.join(options.out, `i18n-${locale}.md`), serializeCatalog(imported, { locale }));
|
|
488
|
+
console.log(`Imported ${imported.messages.length} tokens in ${Object.keys(imported.languages).length} languages into ${options.out}.`); return;
|
|
489
|
+
}
|
|
490
|
+
if (command === 'extract') {
|
|
491
|
+
if (!positional.length) throw new Error('Usage: i18nmd extract <src...> [--out translations/<division>] [--in-place]');
|
|
492
|
+
options.out ||= (await findTranslations(false)) || 'translations';
|
|
493
|
+
const locale = options.source || (/\.md$/.test(options.out) ? languageFromFilename(options.out) : 'en');
|
|
494
|
+
const sourceFile = /\.md$/.test(options.out) ? options.out : path.join(options.out, `i18n-${locale}.md`);
|
|
495
|
+
const outDir = path.dirname(sourceFile);
|
|
496
|
+
const namespace = namespaceFor(await divisionPrefix(outDir, (await findLock(outDir)).file));
|
|
497
|
+
if (languageFromFilename(sourceFile) !== locale) throw new Error('The output filename must match --source.');
|
|
498
|
+
const files = [...new Set((await Promise.all(positional.map(filesAt))).flat())];
|
|
499
|
+
if (!files.length) throw new Error('No JavaScript or TypeScript source files found.');
|
|
500
|
+
// The deepest directory holding every input; converted copies keep their paths below it.
|
|
501
|
+
const dirs = await Promise.all(positional.map(async input => (await isDirectory(input)) ? path.resolve(input) : path.dirname(path.resolve(input))));
|
|
502
|
+
let root = dirs[0];
|
|
503
|
+
while (!dirs.every(dir => dir === root || dir.startsWith(root + path.sep))) root = path.dirname(root);
|
|
504
|
+
// --in-place rewrites the sources themselves, for code under version control.
|
|
505
|
+
if (options['in-place'] && options.dest) throw new Error('Use either --in-place or --dest.');
|
|
506
|
+
const destination = options['in-place'] ? root : path.resolve(options.dest || '.i18n/src');
|
|
507
|
+
if (!options['in-place'] && (destination === root || destination.startsWith(root + path.sep))) throw new Error('The destination must be outside the input tree so repeated extraction cannot read its own output. Use --in-place to rewrite the sources.');
|
|
508
|
+
// Code imports i18nmd from its division's generated module (src/i18n/ui), or from
|
|
509
|
+
// src/i18n/i18n for top-level tokens; --runtime names the latter.
|
|
510
|
+
// The default matches compile's default --out, src/i18n.
|
|
511
|
+
const main = path.resolve(options.runtime || 'src/i18n/i18n');
|
|
512
|
+
const runtime = namespace ? path.join(path.dirname(main), divisionFile(namespace.slice(0, -1).replace(/\./g, '/'))) : main;
|
|
513
|
+
// Existing tokens are always kept, so extraction can be repeated as code changes.
|
|
514
|
+
let catalog = await exists(sourceFile) ? parseCatalog(await readFile(sourceFile, 'utf8'), { filename: sourceFile }) : undefined;
|
|
515
|
+
const outputs = [];
|
|
516
|
+
let replacements = 0;
|
|
517
|
+
for (const file of files) {
|
|
518
|
+
const relative = path.relative(root, path.resolve(file));
|
|
519
|
+
const output = path.join(destination, relative);
|
|
520
|
+
let importPath = path.relative(path.dirname(output), runtime).split(path.sep).join('/');
|
|
521
|
+
if (!importPath.startsWith('.')) importPath = './' + importPath;
|
|
522
|
+
const result = extractSource(await readFile(file, 'utf8'), { filename: path.relative(process.cwd(), file), locale, languageExpression: options['locale-expr'], existing: catalog, importPath, namespace });
|
|
523
|
+
catalog = result.catalog; replacements += result.replacements;
|
|
524
|
+
result.diagnostics.forEach(message => console.warn(message));
|
|
525
|
+
if (result.replacements) outputs.push([output, result.source]);
|
|
526
|
+
}
|
|
527
|
+
// The lock marks the top of the translations tree, so divisions keep their names.
|
|
528
|
+
const lockFile = (await findLock(outDir)).file;
|
|
529
|
+
if (!(await exists(lockFile))) await save(lockFile, serializeLock({ source: locale, translations: {} }));
|
|
530
|
+
if (!replacements) { console.log(`Nothing new to extract from ${plural(files.length, 'file')}. Mark other display strings with /* i18n */.`); return; }
|
|
531
|
+
const markdown = serializeCatalog(catalog, { locale });
|
|
532
|
+
for (const [file, text] of outputs) await save(file, text);
|
|
533
|
+
await save(sourceFile, markdown);
|
|
534
|
+
console.log(`Extracted ${plural(replacements, 'string')} into ${sourceFile}; ${options['in-place'] ? `rewrote ${plural(outputs.length, 'file')}` : `converted copies are in ${destination}`}.\nRun i18nmd translate to fill the other languages, or i18nmd status to see what changed.`); return;
|
|
535
|
+
}
|
|
536
|
+
throw new Error(`Unknown command: ${command}. Run i18nmd --help.`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
main().catch(error => { console.error(`i18nmd: ${error.message}`); process.exitCode = 1; });
|