@likolabs/i18nmd 0.1.1 → 0.3.0
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/PROMPT.md +2 -0
- package/README.haw.md +421 -0
- package/README.md +83 -8
- package/bin/i18nmd.mjs +91 -16
- package/lib/catalog.mjs +13 -8
- package/lib/compiler.mjs +156 -36
- package/lib/extractor.mjs +1 -1
- package/lib/html.mjs +309 -0
- package/lib/interop.mjs +4 -4
- package/lib/llm.mjs +1 -1
- package/lib/lock.mjs +18 -8
- package/lib/messages.mjs +12 -5
- package/lib/plural-rules.json +1 -0
- package/lib/runtime.mjs +24 -6
- package/package.json +10 -4
package/bin/i18nmd.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { spawn } from 'node:child_process';
|
|
|
6
6
|
import { parseCatalog, serializeCatalog, parseLanguageFiles, languageFromFilename, renameToken, textOf, divisionOf, namespaceFor, accessorFor } from '../lib/catalog.mjs';
|
|
7
7
|
import { generateModule, generateModules, divisionFile, runtimeTypes } from '../lib/compiler.mjs';
|
|
8
8
|
import { extractSource, missingDivisionImports } from '../lib/extractor.mjs';
|
|
9
|
+
import { extractHtml, renderHtml } from '../lib/html.mjs';
|
|
9
10
|
import { lockPathFor, findLock, namedRoot, readLock, serializeLock, report, syncLock, markCurrent } from '../lib/lock.mjs';
|
|
10
11
|
import { resolveLanguage, topLanguages, RANKED } from '../lib/languages.mjs';
|
|
11
12
|
import { llmConfig, translateLanguage } from '../lib/llm.mjs';
|
|
@@ -32,12 +33,16 @@ Keep files in shape
|
|
|
32
33
|
One Markdown file with every language, and back.
|
|
33
34
|
|
|
34
35
|
Build
|
|
36
|
+
render <site> [--out dist] [--url https://example.com]
|
|
37
|
+
Static HTML pages, one copy per language (/haw/…).
|
|
35
38
|
compile [--out src/i18n] [--target ts|js|json|python] [--skip division,...] [--eager]
|
|
36
39
|
extract <src...> [--out translations/<division>] [--in-place] [--runtime src/i18n/i18n]
|
|
37
40
|
[--source en] [--locale-expr locale]
|
|
38
41
|
|
|
39
42
|
Other tools
|
|
40
43
|
import <files.json...> --from ${FORMATS.join('|')} [--out translations] [--source en]
|
|
44
|
+
--merge Add source-language messages to the source file in
|
|
45
|
+
--out, keeping its tokens, translations and the lock.
|
|
41
46
|
export --to ${FORMATS.join('|')} --out <dir>
|
|
42
47
|
import-python <module.py> --out <dir>
|
|
43
48
|
languages The ranked language list used by --top.
|
|
@@ -60,14 +65,16 @@ function python(args) {
|
|
|
60
65
|
});
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
// Source files under input; html: also .html pages (for extract).
|
|
69
|
+
async function filesAt(input, { html = false } = {}) {
|
|
70
|
+
const wanted = html ? /\.(?:[cm]?[jt]sx?|html?)$/ : /\.(?:[cm]?[jt]sx?)$/;
|
|
71
|
+
if (!(await stat(input)).isDirectory()) return wanted.test(input) ? [input] : [];
|
|
65
72
|
const files = [];
|
|
66
73
|
for (const entry of (await readdir(input, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
67
74
|
if (entry.name.startsWith('.') || ['node_modules', 'dist', 'build', 'vendor', 'coverage'].includes(entry.name)) continue;
|
|
68
75
|
const file = path.join(input, entry.name);
|
|
69
|
-
if (entry.isDirectory()) files.push(...await filesAt(file));
|
|
70
|
-
else if (entry.isFile() &&
|
|
76
|
+
if (entry.isDirectory()) files.push(...await filesAt(file, { html }));
|
|
77
|
+
else if (entry.isFile() && wanted.test(file) && !/\.(test|spec)\.[jt]sx?$/.test(file) && !/\.d\.[cm]?ts$/.test(file)) files.push(file);
|
|
71
78
|
}
|
|
72
79
|
return files;
|
|
73
80
|
}
|
|
@@ -117,8 +124,8 @@ async function open(input, options) {
|
|
|
117
124
|
const strict = !!options.strict;
|
|
118
125
|
const raw = directory ? await readLanguageFiles(input) : { [input]: await readFile(input, 'utf8') };
|
|
119
126
|
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 });
|
|
127
|
+
? parseLanguageFiles(raw, { source: options.source || lock.source, syntax: options.syntax || 'icu', strict, prefix: await divisionPrefix(input, lockFile), formatters: lock.formatters })
|
|
128
|
+
: parseCatalog(raw[input], { filename: input, syntax: options.syntax, allowIncomplete: !strict, lenient: !strict, formatters: lock.formatters });
|
|
122
129
|
catalog.orphans ||= {};
|
|
123
130
|
catalog.divisions ||= [''];
|
|
124
131
|
let queue = Promise.resolve();
|
|
@@ -218,7 +225,7 @@ async function main() {
|
|
|
218
225
|
if (!command || ['help', '--help', '-h'].includes(command)) { console.log(help); return; }
|
|
219
226
|
if (['--version', '-v', 'version'].includes(command)) { console.log(JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version); return; }
|
|
220
227
|
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)$/;
|
|
228
|
+
const valued = /^(url|skip|out|source|locale|language|locale-expr|dest|runtime|target|table|languages|syntax|in|from|to|model|base-url|provider|batch|only|dir)$/;
|
|
222
229
|
const options = Object.create(null), positional = [];
|
|
223
230
|
for (let i = 0; i < rest.length; i++) {
|
|
224
231
|
const arg = rest[i];
|
|
@@ -358,6 +365,45 @@ async function main() {
|
|
|
358
365
|
const divisions = Object.keys(files).filter(n => !/^(?:i18n|language|runtime)\.|^languages\//.test(n) && !n.endsWith('.d.mts')).length;
|
|
359
366
|
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
367
|
}
|
|
368
|
+
if (command === 'render') {
|
|
369
|
+
// Static pages, one copy per language: the source language where the pages
|
|
370
|
+
// are, every other language under its code (/haw/), and every other file copied.
|
|
371
|
+
const site = one();
|
|
372
|
+
if (!site || !(await isDirectory(site))) throw new Error('Usage: i18nmd render <site directory> [--out dist] [--url https://example.com]');
|
|
373
|
+
const out = path.resolve(options.out || 'dist');
|
|
374
|
+
const root = path.resolve(site);
|
|
375
|
+
if (out === root) throw new Error('Render into a directory other than the site itself.');
|
|
376
|
+
const ws = await open(options.dir, options); ws.warn();
|
|
377
|
+
const { catalog } = ws;
|
|
378
|
+
const base = (options.url || '').replace(/\/+$/, '');
|
|
379
|
+
if (base && !/^https?:\/\//.test(base)) throw new Error('--url needs a full address, such as https://example.com.');
|
|
380
|
+
const codes = Object.keys(catalog.languages);
|
|
381
|
+
const tables = Object.fromEntries(codes.map(code => [code, Object.fromEntries(catalog.messages.filter(m => Object.hasOwn(m.translations, code)).map(m => [m.key, m.translations[code]]))]));
|
|
382
|
+
const files = [];
|
|
383
|
+
const walk = async dir => {
|
|
384
|
+
for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
385
|
+
const file = path.join(dir, entry.name);
|
|
386
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules' || file === out) continue;
|
|
387
|
+
if (entry.isDirectory()) await walk(file); else if (entry.isFile()) files.push(path.relative(root, file).split(path.sep).join('/'));
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
await walk(root);
|
|
391
|
+
let pages = 0;
|
|
392
|
+
for (const file of files) {
|
|
393
|
+
if (!/\.html?$/.test(file)) { await mkdir(path.dirname(path.join(out, file)), { recursive: true }); await copyFile(path.join(root, file), path.join(out, file)); continue; }
|
|
394
|
+
const html = await readFile(path.join(root, file), 'utf8');
|
|
395
|
+
const address = code => `${base}/${code === catalog.source ? '' : code + '/'}${file.replace(/(^|\/)index\.html?$/, '$1')}`;
|
|
396
|
+
const urls = Object.fromEntries(codes.map(code => [code, address(code)]));
|
|
397
|
+
for (const code of codes) {
|
|
398
|
+
const target = code === catalog.source ? file : `${code}/${file}`;
|
|
399
|
+
const relative = code === catalog.source ? '' : path.posix.relative(path.posix.dirname(target), path.posix.dirname(file)) + '/';
|
|
400
|
+
await save(path.join(out, target), renderHtml(html, { language: code, source: catalog.source, messages: tables[code], pages: urls, languages: catalog.languages, relative, absolute: !!base }));
|
|
401
|
+
pages++;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
for (const row of ws.rows()) if (row.missing.length || row.stale.length) console.warn(`i18nmd: ${summary(row)}; untranslated text stays in ${catalog.languages[catalog.source]}.`);
|
|
405
|
+
console.log(`Rendered ${plural(pages, 'page')} in ${plural(codes.length, 'language')} into ${path.relative(process.cwd(), out) || '.'}.`); return;
|
|
406
|
+
}
|
|
361
407
|
if (command === 'join') {
|
|
362
408
|
if (!options.out || !/\.md$/.test(options.out)) throw new Error('Choose the joined Markdown file with --out, e.g. --out translations.md.');
|
|
363
409
|
const ws = await open(one(), options);
|
|
@@ -432,6 +478,29 @@ async function main() {
|
|
|
432
478
|
if (!options.from) throw new Error(`Choose the format with --from ${FORMATS.join('|')}.`);
|
|
433
479
|
const out = options.out || 'translations';
|
|
434
480
|
const warnings = [];
|
|
481
|
+
// --merge adds source-language messages to the source file in --out: new tokens
|
|
482
|
+
// are appended, changed text replaces the old, and nothing is removed. Other
|
|
483
|
+
// languages and the lock are left alone, so status shows what to translate.
|
|
484
|
+
if (options.merge) {
|
|
485
|
+
const lock = await readLock((await findLock(out)).file);
|
|
486
|
+
const source = options.source || lock.source || 'en';
|
|
487
|
+
const file = path.join(out, `i18n-${source}.md`);
|
|
488
|
+
const catalog = await exists(file) ? parseCatalog(await readFile(file, 'utf8'), { filename: file, locale: source, formatters: lock.formatters }) : { title: 'Translations', source, syntax: 'icu', languages: { [source]: resolveLanguage(source).name }, messages: [] };
|
|
489
|
+
const index = new Map(catalog.messages.map(m => [m.key, m]));
|
|
490
|
+
let added = 0, changed = 0;
|
|
491
|
+
for (const input of positional) {
|
|
492
|
+
for (const m of importMessages(JSON.parse(await readFile(input, 'utf8')), options.from, warnings, lock.formatters)) {
|
|
493
|
+
const existing = index.get(m.key);
|
|
494
|
+
if (!existing) { const message = { key: m.key, context: m.context, optional: [], translations: { [source]: m.text } }; catalog.messages.push(message); index.set(m.key, message); added++; continue; }
|
|
495
|
+
if (existing.translations[source] !== m.text) { existing.translations[source] = m.text; changed++; }
|
|
496
|
+
if (m.context) existing.context = m.context;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
warnings.forEach(w => console.warn(`i18nmd: ${w}`));
|
|
500
|
+
if (!added && !changed) { console.log(`${file} already has every message.`); return; }
|
|
501
|
+
await save(file, serializeCatalog(catalog, { locale: source }));
|
|
502
|
+
console.log(`Added ${plural(added, 'token')} and updated ${plural(changed, 'token')} in ${file}. Run i18nmd translate to fill the other languages.`); return;
|
|
503
|
+
}
|
|
435
504
|
const byLocale = new Map();
|
|
436
505
|
const files = positional.map(file => ({ file, ...localeFromJsonPath(file) }));
|
|
437
506
|
const namespaces = new Set(files.map(f => f.namespace).filter(Boolean));
|
|
@@ -462,9 +531,10 @@ async function main() {
|
|
|
462
531
|
if (await exists(file)) throw new Error(`${file} exists; import into an empty directory.`);
|
|
463
532
|
}
|
|
464
533
|
for (const locale of Object.keys(languages)) await save(path.join(out, `i18n-${locale}.md`), serializeCatalog(catalog, { locale, allowIncomplete: true }));
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
534
|
+
// The lock that covers out, so a division shares the tree's lock and token names.
|
|
535
|
+
const ws = await open(out, { source });
|
|
536
|
+
syncLock(ws.catalog, ws.lock, { partial: ws.partial });
|
|
537
|
+
await ws.saveLock();
|
|
468
538
|
[...warnings, ...(catalog.warnings || [])].forEach(w => console.warn(`i18nmd: ${w}`));
|
|
469
539
|
console.log(`Imported ${plural(messages.length, 'token')} in ${plural(Object.keys(languages).length, 'language')} into ${out}.`); return;
|
|
470
540
|
}
|
|
@@ -495,8 +565,8 @@ async function main() {
|
|
|
495
565
|
const outDir = path.dirname(sourceFile);
|
|
496
566
|
const namespace = namespaceFor(await divisionPrefix(outDir, (await findLock(outDir)).file));
|
|
497
567
|
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
|
|
568
|
+
const files = [...new Set((await Promise.all(positional.map(input => filesAt(input, { html: true })))).flat())];
|
|
569
|
+
if (!files.length) throw new Error('No HTML, JavaScript or TypeScript files found.');
|
|
500
570
|
// The deepest directory holding every input; converted copies keep their paths below it.
|
|
501
571
|
const dirs = await Promise.all(positional.map(async input => (await isDirectory(input)) ? path.resolve(input) : path.dirname(path.resolve(input))));
|
|
502
572
|
let root = dirs[0];
|
|
@@ -513,21 +583,26 @@ async function main() {
|
|
|
513
583
|
// Existing tokens are always kept, so extraction can be repeated as code changes.
|
|
514
584
|
let catalog = await exists(sourceFile) ? parseCatalog(await readFile(sourceFile, 'utf8'), { filename: sourceFile }) : undefined;
|
|
515
585
|
const outputs = [];
|
|
516
|
-
let replacements = 0;
|
|
586
|
+
let replacements = 0, updated = 0;
|
|
517
587
|
for (const file of files) {
|
|
518
588
|
const relative = path.relative(root, path.resolve(file));
|
|
519
589
|
const output = path.join(destination, relative);
|
|
520
590
|
let importPath = path.relative(path.dirname(output), runtime).split(path.sep).join('/');
|
|
521
591
|
if (!importPath.startsWith('.')) importPath = './' + importPath;
|
|
522
|
-
const
|
|
523
|
-
|
|
592
|
+
const text = await readFile(file, 'utf8'), filename = path.relative(process.cwd(), file);
|
|
593
|
+
const result = /\.html?$/.test(file)
|
|
594
|
+
? extractHtml(text, { filename, locale, existing: catalog, namespace })
|
|
595
|
+
: extractSource(text, { filename, locale, languageExpression: options['locale-expr'], existing: catalog, importPath, namespace });
|
|
596
|
+
catalog = result.catalog; replacements += result.replacements; updated += result.updated || 0;
|
|
524
597
|
result.diagnostics.forEach(message => console.warn(message));
|
|
525
598
|
if (result.replacements) outputs.push([output, result.source]);
|
|
526
599
|
}
|
|
527
600
|
// The lock marks the top of the translations tree, so divisions keep their names.
|
|
528
601
|
const lockFile = (await findLock(outDir)).file;
|
|
529
602
|
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; }
|
|
603
|
+
if (!replacements && !updated) { console.log(`Nothing new to extract from ${plural(files.length, 'file')}. Mark other display strings with /* i18n */.`); return; }
|
|
604
|
+
// An HTML page holds its own English: an edited sentence updates the source file.
|
|
605
|
+
if (!replacements) { await save(sourceFile, serializeCatalog(catalog, { locale })); console.log(`Updated ${plural(updated, 'string')} in ${sourceFile} from the pages' English; run i18nmd status to see what to translate.`); return; }
|
|
531
606
|
const markdown = serializeCatalog(catalog, { locale });
|
|
532
607
|
for (const [file, text] of outputs) await save(file, text);
|
|
533
608
|
await save(sourceFile, markdown);
|
package/lib/catalog.mjs
CHANGED
|
@@ -45,7 +45,7 @@ export function parseCatalog(markdown, options = {}) {
|
|
|
45
45
|
if (!match || Object.hasOwn(metadata, match[1])) throw new Error(`Invalid or duplicate metadata: ${line}`);
|
|
46
46
|
metadata[match[1]] = match[1] === 'languages' ? JSON.parse(match[2]) : match[2].trim();
|
|
47
47
|
}
|
|
48
|
-
const catalog = { ...metadata, title: 'Translations', messages: [] };
|
|
48
|
+
const catalog = { ...metadata, title: 'Translations', messages: [], ...(options.formatters?.length && { formatters: options.formatters }) };
|
|
49
49
|
let message;
|
|
50
50
|
for (let i = end + 1; i < lines.length; i++) {
|
|
51
51
|
const line = lines[i];
|
|
@@ -84,6 +84,9 @@ export function parseCatalog(markdown, options = {}) {
|
|
|
84
84
|
export function validateCatalog(catalog, { allowIncomplete = false, lenient = false, single = false } = {}) {
|
|
85
85
|
catalog.warnings ||= [];
|
|
86
86
|
if (!['icu', 'python'].includes(catalog.syntax)) throw new Error('syntax must be icu or python.');
|
|
87
|
+
for (const name of catalog.formatters || []) {
|
|
88
|
+
if (!/^[a-zA-Z_]\w*$/.test(name) || ['number', 'date', 'time', 'plural', 'select', 'selectordinal'].includes(name)) throw new Error(`Formatter names are identifiers other than ICU's own types: ${name}`);
|
|
89
|
+
}
|
|
87
90
|
if (!catalog.languages || typeof catalog.languages !== 'object' || Array.isArray(catalog.languages)) throw new Error('languages must be a JSON object mapping codes to names.');
|
|
88
91
|
const locales = Object.keys(catalog.languages);
|
|
89
92
|
if (!locales.length || !Object.hasOwn(catalog.languages, catalog.source)) throw new Error('Declare the source language in languages.');
|
|
@@ -107,7 +110,7 @@ export function validateCatalog(catalog, { allowIncomplete = false, lenient = fa
|
|
|
107
110
|
for (const locale of locales) {
|
|
108
111
|
if (!Object.hasOwn(table, locale) && allowIncomplete && locale !== catalog.source) continue;
|
|
109
112
|
if (!Object.hasOwn(table, locale) || typeof table[locale] !== 'string' || !table[locale].trim()) throw new Error(`${message.key}: missing ${locale} translation.`);
|
|
110
|
-
try { parsed[locale] = argumentsFor(parseMessage(table[locale], catalog.syntax)); }
|
|
113
|
+
try { parsed[locale] = argumentsFor(parseMessage(table[locale], catalog.syntax, catalog.formatters)); }
|
|
111
114
|
catch (error) { throw new Error(`${message.key} [${locale}]: ${error.message}`); }
|
|
112
115
|
}
|
|
113
116
|
const sourceArgs = parsed[catalog.source];
|
|
@@ -121,7 +124,8 @@ export function validateCatalog(catalog, { allowIncomplete = false, lenient = fa
|
|
|
121
124
|
if (problem) break;
|
|
122
125
|
const base = sourceArgs[name];
|
|
123
126
|
if (!base) problem = `unknown placeholder ${type === 'tag' ? `<${name}>` : `{${name}}`}`;
|
|
124
|
-
|
|
127
|
+
// A translation may move {len, length} but not drop or change its formatter.
|
|
128
|
+
else if (base !== type && (base === 'tag' || type === 'tag' || base.startsWith('format:') || type.startsWith('format:') || (base !== 'string' && type !== 'string'))) problem = `incompatible type for {${name}}`;
|
|
125
129
|
}
|
|
126
130
|
if (!problem) continue;
|
|
127
131
|
if (lenient && locale !== catalog.source) {
|
|
@@ -175,7 +179,7 @@ const where = division => division ? `${division}/` : '';
|
|
|
175
179
|
|
|
176
180
|
// Properties a division function cannot take: they exist on every function,
|
|
177
181
|
// or (in) is i18nmd's own.
|
|
178
|
-
const RESERVED = new Set(['in', 'name', 'length', 'call', 'apply', 'bind', 'prototype', 'caller', 'arguments', 'constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__']);
|
|
182
|
+
const RESERVED = new Set(['in', 'has', 'name', 'length', 'call', 'apply', 'bind', 'prototype', 'caller', 'arguments', 'constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__']);
|
|
179
183
|
|
|
180
184
|
/** knowledge-hub → knowledgeHub: the property for a division directory. */
|
|
181
185
|
export function divisionProperty(segment) {
|
|
@@ -204,7 +208,8 @@ export function namespaceFor(division, prefix = '') {
|
|
|
204
208
|
}
|
|
205
209
|
|
|
206
210
|
/**
|
|
207
|
-
* Merge one-language files into a catalog.
|
|
211
|
+
* Merge one-language files into a catalog. formatters lists the argument types
|
|
212
|
+
* the project declares in its lock ({len, length}). Files may sit in subdirectories,
|
|
208
213
|
* one per division (ui/i18n-fr.md, marketing/i18n-fr.md); each division has its
|
|
209
214
|
* own source file, and its tokens are namespaced by its path: ## save in
|
|
210
215
|
* ui/i18n-en.md is the token ui.save. Every message records its division so it
|
|
@@ -213,7 +218,7 @@ export function namespaceFor(division, prefix = '') {
|
|
|
213
218
|
* no longer has are set aside in catalog.orphans with a warning. strict: every
|
|
214
219
|
* file must match its source exactly.
|
|
215
220
|
*/
|
|
216
|
-
export function parseLanguageFiles(files, { source, syntax = 'icu', strict = false, prefix = '' } = {}) {
|
|
221
|
+
export function parseLanguageFiles(files, { source, syntax = 'icu', strict = false, prefix = '', formatters = [] } = {}) {
|
|
217
222
|
const divisions = new Map();
|
|
218
223
|
for (const [filename, markdown] of Object.entries(files)) {
|
|
219
224
|
const locale = languageFromFilename(filename);
|
|
@@ -221,7 +226,7 @@ export function parseLanguageFiles(files, { source, syntax = 'icu', strict = fal
|
|
|
221
226
|
if (!divisions.has(division)) divisions.set(division, new Map());
|
|
222
227
|
const byLocale = divisions.get(division);
|
|
223
228
|
if (byLocale.has(locale)) throw new Error(`Duplicate language file: ${where(division)}${locale}`);
|
|
224
|
-
const parsed = parseCatalog(markdown, { locale, syntax });
|
|
229
|
+
const parsed = parseCatalog(markdown, { locale, syntax, formatters });
|
|
225
230
|
const namespace = namespaceFor(division, prefix);
|
|
226
231
|
for (const message of parsed.messages) message.key = namespace + message.key;
|
|
227
232
|
if (parsed.source !== locale || parsed.syntax !== syntax || Object.keys(parsed.languages).length !== 1) throw new Error(`Expected a single-language ${syntax} file for ${where(division)}i18n-${locale}.md.`);
|
|
@@ -230,7 +235,7 @@ export function parseLanguageFiles(files, { source, syntax = 'icu', strict = fal
|
|
|
230
235
|
const all = [...divisions.values()].flatMap(byLocale => [...byLocale.keys()]);
|
|
231
236
|
source ||= all.includes('en') ? 'en' : all[0];
|
|
232
237
|
if (!source) throw new Error('Include at least one i18n-<language>.md file.');
|
|
233
|
-
const catalog = { source, syntax, title: 'Translations', languages: Object.create(null), messages: [], orphans: Object.create(null), divisions: [...divisions.keys()], prefix };
|
|
238
|
+
const catalog = { source, syntax, title: 'Translations', languages: Object.create(null), messages: [], orphans: Object.create(null), divisions: [...divisions.keys()], prefix, ...(formatters.length && { formatters }) };
|
|
234
239
|
const owner = new Map();
|
|
235
240
|
for (const [division, byLocale] of divisions) {
|
|
236
241
|
const base = byLocale.get(source);
|
package/lib/compiler.mjs
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { parseCatalog, validateCatalog, namespaceFor, divisionProperty, accessorFor } from './catalog.mjs';
|
|
2
2
|
import { argumentsFor, parseMessage } from './messages.mjs';
|
|
3
|
+
import { numberOptions } from './runtime.mjs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
|
|
6
|
+
const PLURAL_RULES = createRequire(import.meta.url)('./plural-rules.json');
|
|
3
7
|
|
|
4
8
|
export function compileCatalog(input) {
|
|
5
9
|
const catalog = typeof input === 'string' ? parseCatalog(input, { allowIncomplete: true }) : validateCatalog(input, { allowIncomplete: true });
|
|
6
10
|
const messages = Object.create(null);
|
|
7
11
|
for (const message of catalog.messages) {
|
|
8
|
-
messages[message.key] = Object.fromEntries(Object.entries(message.translations).map(([lang, text]) => [lang, parseMessage(text, catalog.syntax)]));
|
|
12
|
+
messages[message.key] = Object.fromEntries(Object.entries(message.translations).map(([lang, text]) => [lang, parseMessage(text, catalog.syntax, catalog.formatters)]));
|
|
9
13
|
}
|
|
10
14
|
return { source: catalog.source, languages: catalog.languages, messages };
|
|
11
15
|
}
|
|
@@ -26,41 +30,116 @@ function divisionTree(catalog) {
|
|
|
26
30
|
return tree;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
function
|
|
32
|
-
let
|
|
33
|
-
try {
|
|
34
|
-
const
|
|
35
|
-
|
|
33
|
+
// The CLDR plural rules Intl.PluralRules uses for locale, as Python: a function
|
|
34
|
+
// of the ICU operands n, i, v, f, t and e. Returns [function name, source].
|
|
35
|
+
function pluralFunction(locale, type) {
|
|
36
|
+
let resolved;
|
|
37
|
+
try { resolved = new Intl.PluralRules(locale, { type }).resolvedOptions().locale; } catch { resolved = 'en'; }
|
|
38
|
+
const table = PLURAL_RULES[type];
|
|
39
|
+
const parts = resolved.split('-');
|
|
40
|
+
while (parts.length && !Object.hasOwn(table, parts.join('-'))) parts.pop();
|
|
41
|
+
const key = parts.join('-') || 'root';
|
|
42
|
+
const relation = text => {
|
|
43
|
+
const match = /^([nivwftce])(?:\s*%\s*(\d+))?\s*(!?=)\s*([\d.,\s]+)$/.exec(text.trim());
|
|
44
|
+
if (!match) throw new Error(`Unsupported CLDR plural rule for ${key}: ${text}`);
|
|
45
|
+
const ranges = match[4].split(',').map(r => { const [low, high = low] = r.trim().split('..'); return `(${low}, ${high})`; });
|
|
46
|
+
return `${match[3] === '=' ? '' : 'not '}_in(${match[1]}${match[2] ? ` % ${match[2]}` : ''}, (${ranges.join(', ')},))`;
|
|
47
|
+
};
|
|
48
|
+
const name = `_${type}_${key.replace(/\W/g, '_')}`;
|
|
49
|
+
let source = `def ${name}(n: Decimal, i: int, v: int, f: int, t: int, e: int) -> str:\n`;
|
|
50
|
+
for (const [category, rule] of Object.entries(table[key] || {})) {
|
|
51
|
+
source += ` if ${rule.split(/\s+or\s+/).map(and => and.split(/\s+and\s+/).map(relation).join(' and ')).join(' or ')}:\n return "${category}"\n`;
|
|
52
|
+
}
|
|
53
|
+
return [name, source + ' return "other"\n'];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// How a language writes numbers, from Intl, for the Python formatter:
|
|
57
|
+
// [group mark, decimal mark, primary group size, secondary group size,
|
|
58
|
+
// digits before grouping starts, { style: [positive pattern, negative pattern,
|
|
59
|
+
// most fraction digits, fewest fraction digits] }], where {n} in a pattern is
|
|
60
|
+
// the grouped digits. styles lists the number styles the catalog uses.
|
|
61
|
+
function numberRules(locale, styles) {
|
|
62
|
+
const make = options => { try { return new Intl.NumberFormat(locale, options); } catch { return new Intl.NumberFormat('en', options); } };
|
|
63
|
+
const format = make();
|
|
64
|
+
const parts = format.formatToParts(1234567.891);
|
|
65
|
+
const integers = parts.filter(p => p.type === 'integer').map(p => p.value);
|
|
66
|
+
const primary = integers.length > 1 ? integers.at(-1).length : 0;
|
|
67
|
+
const secondary = integers.length > 2 ? integers.at(-2).length : primary;
|
|
68
|
+
const minimum = format.formatToParts(1234).some(p => p.type === 'group') ? 1 : 2;
|
|
69
|
+
const digits = new Set(['integer', 'group', 'decimal', 'fraction']);
|
|
70
|
+
const pattern = (f, value) => f.formatToParts(value).map((p, i, all) => !digits.has(p.type) ? p.value : digits.has(all[i - 1]?.type) ? '' : '{n}').join('');
|
|
71
|
+
const patterns = Object.fromEntries(styles.map(style => {
|
|
72
|
+
const f = make(numberOptions(style));
|
|
73
|
+
const { maximumFractionDigits, minimumFractionDigits } = f.resolvedOptions();
|
|
74
|
+
return [style, [pattern(f, 1), pattern(f, -1), maximumFractionDigits, minimumFractionDigits]];
|
|
75
|
+
}));
|
|
76
|
+
return [parts.find(p => p.type === 'group')?.value ?? '', parts.find(p => p.type === 'decimal')?.value ?? '.', primary, secondary, minimum, patterns];
|
|
36
77
|
}
|
|
37
78
|
|
|
38
79
|
const PYTHON_RUNTIME = `
|
|
39
|
-
_CATEGORY = {"z": "zero", "o": "one", "t": "two", "f": "few", "m": "many", "x": "other"}
|
|
40
80
|
_log = logging.getLogger("i18nmd")
|
|
81
|
+
_FORMATTERS: dict[str, Callable[[Any, str], str]] = {}
|
|
82
|
+
_CONTEXT = Context(prec=1000, rounding=ROUND_HALF_UP)
|
|
41
83
|
|
|
42
84
|
|
|
43
|
-
def _number(value: Any) ->
|
|
85
|
+
def _number(value: Any) -> float | None:
|
|
86
|
+
# Numbers are doubles, as in JavaScript, so both runtimes see the same value.
|
|
44
87
|
if isinstance(value, bool):
|
|
45
88
|
return None
|
|
46
|
-
if isinstance(value, (int, float)):
|
|
47
|
-
return value
|
|
48
89
|
try:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
except (TypeError, ValueError):
|
|
90
|
+
number = float(value if isinstance(value, (int, float)) else str(value).strip())
|
|
91
|
+
except (TypeError, ValueError, OverflowError):
|
|
52
92
|
return None
|
|
93
|
+
return number if math.isfinite(number) else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _decimal(number: float) -> Decimal:
|
|
97
|
+
# The shortest decimal that reads back as number, which is what Intl formats.
|
|
98
|
+
return Decimal(repr(float(number)))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _digits(number: Decimal, most: int, fewest: int = 0) -> tuple[str, str]:
|
|
102
|
+
# Whole and fraction digits of |number|, rounded half away from zero like Intl.
|
|
103
|
+
with localcontext(_CONTEXT):
|
|
104
|
+
rounded = abs(number).quantize(Decimal(1).scaleb(-most))
|
|
105
|
+
whole, _, fraction = format(rounded, "f").partition(".")
|
|
106
|
+
return whole, fraction.rstrip("0").ljust(fewest, "0")
|
|
107
|
+
|
|
53
108
|
|
|
109
|
+
def _number_text(number: float, language: str, style: str = "") -> str:
|
|
110
|
+
group, decimal, primary, secondary, minimum, styles = _NUMBERS[language]
|
|
111
|
+
positive, negative, most, fewest = styles[style]
|
|
112
|
+
value = _decimal(number)
|
|
113
|
+
if style == "percent":
|
|
114
|
+
value = value.scaleb(2)
|
|
115
|
+
whole, fraction = _digits(value, most, fewest)
|
|
116
|
+
if primary and len(whole) >= primary + minimum:
|
|
117
|
+
groups = [whole[-primary:]]
|
|
118
|
+
whole = whole[:-primary]
|
|
119
|
+
while len(whole) > secondary:
|
|
120
|
+
groups.insert(0, whole[-secondary:])
|
|
121
|
+
whole = whole[:-secondary]
|
|
122
|
+
whole = group.join([whole] + groups if whole else groups)
|
|
123
|
+
text = whole + (decimal + fraction if fraction else "")
|
|
124
|
+
return (negative if value.is_signed() else positive).replace("{n}", text)
|
|
54
125
|
|
|
55
|
-
def _show(number: int | float) -> str:
|
|
56
|
-
return str(int(number)) if float(number).is_integer() else f"{number:g}"
|
|
57
126
|
|
|
127
|
+
def _in(value: Any, ranges: tuple[tuple[int, int], ...]) -> bool:
|
|
128
|
+
return value == int(value) and any(low <= value <= high for low, high in ranges)
|
|
58
129
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
n
|
|
63
|
-
|
|
130
|
+
|
|
131
|
+
def _category(rules: Callable[..., str], number: float) -> str:
|
|
132
|
+
# The CLDR operands of number as Intl.PluralRules reads it: rounded to at most
|
|
133
|
+
# 3 decimals; like ICU, n is that value as a double and i keeps 18 digits.
|
|
134
|
+
whole, fraction = _digits(_decimal(number), 3)
|
|
135
|
+
n = Decimal(float(whole + "." + fraction if fraction else whole))
|
|
136
|
+
with localcontext(_CONTEXT):
|
|
137
|
+
return rules(n, int(whole[-18:]), len(fraction), int(fraction or 0), int(fraction or 0), 0)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _exact(options: dict[str, Any], number: float) -> Any:
|
|
141
|
+
value = _decimal(number)
|
|
142
|
+
return next((branch for key, branch in options.items() if key.startswith("=") and Decimal(key[1:]) == value), None)
|
|
64
143
|
|
|
65
144
|
|
|
66
145
|
def _render(nodes: list[Any], language: str, values: dict[str, Any] | None, pound: Any = None) -> str:
|
|
@@ -71,7 +150,7 @@ def _render(nodes: list[Any], language: str, values: dict[str, Any] | None, poun
|
|
|
71
150
|
continue
|
|
72
151
|
kind = node["type"]
|
|
73
152
|
if kind == "pound":
|
|
74
|
-
out.append(
|
|
153
|
+
out.append(_number_text(pound, language))
|
|
75
154
|
continue
|
|
76
155
|
if kind == "tag":
|
|
77
156
|
inner = _render(node["children"], language, values, pound)
|
|
@@ -89,6 +168,20 @@ def _render(nodes: list[Any], language: str, values: dict[str, Any] | None, poun
|
|
|
89
168
|
value = values[name]
|
|
90
169
|
if kind == "argument":
|
|
91
170
|
out.append("" if value is None else str(value))
|
|
171
|
+
elif kind == "format":
|
|
172
|
+
fn = _FORMATTERS.get(node["format"])
|
|
173
|
+
if value is None:
|
|
174
|
+
_log.error("Missing value for {%s}.", name)
|
|
175
|
+
out.append("{" + name + "}")
|
|
176
|
+
elif fn is None:
|
|
177
|
+
_log.error("No formatter for {%s, %s}.", name, node["format"])
|
|
178
|
+
out.append(str(value))
|
|
179
|
+
else:
|
|
180
|
+
try:
|
|
181
|
+
out.append(str(fn(value, language)))
|
|
182
|
+
except Exception:
|
|
183
|
+
_log.exception("Formatter %s failed for {%s}.", node["format"], name)
|
|
184
|
+
out.append(str(value))
|
|
92
185
|
elif kind == "select":
|
|
93
186
|
options = node["options"]
|
|
94
187
|
out.append(_render(options.get(str(value), options["other"]), language, values, pound))
|
|
@@ -99,13 +192,12 @@ def _render(nodes: list[Any], language: str, values: dict[str, Any] | None, poun
|
|
|
99
192
|
out.append(str(value))
|
|
100
193
|
continue
|
|
101
194
|
options = node["options"]
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
chosen = exact or options.get(_category(table, number - node["offset"])) or options["other"]
|
|
195
|
+
rules = (_ORDINAL if kind == "selectordinal" else _CARDINAL)[language]
|
|
196
|
+
chosen = _exact(options, number) or options.get(_category(rules, number - node["offset"])) or options["other"]
|
|
105
197
|
out.append(_render(chosen, language, values, number - node["offset"]))
|
|
106
198
|
elif kind == "number":
|
|
107
199
|
number = _number(value)
|
|
108
|
-
out.append(str(value) if number is None else
|
|
200
|
+
out.append(str(value) if number is None else _number_text(number, language, node.get("style", "")))
|
|
109
201
|
else:
|
|
110
202
|
out.append(str(value))
|
|
111
203
|
return "".join(out)
|
|
@@ -132,6 +224,17 @@ def i18nmd(token: str, language: str | None = None, **values: Any) -> str:
|
|
|
132
224
|
return _render(entry[used], used, values)
|
|
133
225
|
|
|
134
226
|
|
|
227
|
+
def register_formatter(name: str, fn: Callable[[Any, str], str]) -> None:
|
|
228
|
+
# Supply the function for an argument type the project declares: {len, length}.
|
|
229
|
+
# fn(value, language) returns the text; keep it the same as the JavaScript one.
|
|
230
|
+
_FORMATTERS[name] = fn
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def has(token: str) -> bool:
|
|
234
|
+
# Whether the catalog has token, for text written after the last compile.
|
|
235
|
+
return token in _MESSAGES
|
|
236
|
+
|
|
237
|
+
|
|
135
238
|
def template(token: str, language: str | None = None) -> str:
|
|
136
239
|
# The message with each placeholder, plural or select shown as {name}.
|
|
137
240
|
entry = _MESSAGES[token]
|
|
@@ -142,11 +245,21 @@ def template(token: str, language: str | None = None) -> str:
|
|
|
142
245
|
// ICU messages as a dependency-free Python module: i18nmd(token, language, **values).
|
|
143
246
|
function pythonModule(catalog) {
|
|
144
247
|
const compiled = compileCatalog(catalog);
|
|
145
|
-
const
|
|
248
|
+
const languages = Object.keys(catalog.languages);
|
|
249
|
+
const styles = new Set(['']);
|
|
250
|
+
const visit = nodes => nodes.forEach(n => { if (n.type === 'number') styles.add(n.style); for (const branch of Object.values(n.options || {})) visit(branch); if (n.children) visit(n.children); });
|
|
251
|
+
for (const byLang of Object.values(compiled.messages)) Object.values(byLang).forEach(visit);
|
|
146
252
|
const literal = value => JSON.stringify(value, null, 1).replace(/\n\s*/g, ' ');
|
|
147
|
-
let text = '# Generated from i18n.md. Edit the Markdown source.\n# ruff: noqa\nfrom __future__ import annotations\n\nimport logging\nfrom typing import Any\n\n';
|
|
253
|
+
let text = '# Generated from i18n.md. Edit the Markdown source.\n# ruff: noqa\nfrom __future__ import annotations\n\nimport logging\nimport math\nfrom decimal import ROUND_HALF_UP, Context, Decimal, localcontext\nfrom typing import Any, Callable\n\n';
|
|
148
254
|
text += `SOURCE = ${JSON.stringify(catalog.source)}\nLANGS: dict[str, str] = ${literal(catalog.languages)}\n`;
|
|
149
|
-
|
|
255
|
+
// Plural rules from CLDR, the data Intl.PluralRules uses, as Python functions.
|
|
256
|
+
const functions = new Map();
|
|
257
|
+
const rules = type => Object.fromEntries(languages.map(l => { const [name, source] = pluralFunction(l, type); functions.set(name, source); return [l, name]; }));
|
|
258
|
+
const cardinal = rules('cardinal'), ordinal = rules('ordinal');
|
|
259
|
+
text += `\n\n${[...functions.values()].join('\n\n')}\n\n`;
|
|
260
|
+
const table = map => `{${Object.entries(map).map(([l, name]) => `${JSON.stringify(l)}: ${name}`).join(', ')}}`;
|
|
261
|
+
text += `_CARDINAL: dict[str, Callable[..., str]] = ${table(cardinal)}\n_ORDINAL: dict[str, Callable[..., str]] = ${table(ordinal)}\n`;
|
|
262
|
+
text += `_NUMBERS: dict[str, list[Any]] = ${JSON.stringify(Object.fromEntries(languages.map(l => [l, numberRules(l, [...styles])])), null, 1)}\n`;
|
|
150
263
|
// Compiled messages are JSON of strings, objects and integers, which is also Python.
|
|
151
264
|
text += `_MESSAGES: dict[str, dict[str, list[Any]]] = {\n${Object.entries(compiled.messages).map(([key, byLang]) => ` ${JSON.stringify(key)}: ${literal(byLang)},`).join('\n')}\n}\n`;
|
|
152
265
|
text += 'TOKENS: frozenset[str] = frozenset(_MESSAGES)\n';
|
|
@@ -169,8 +282,8 @@ function typesFor(catalog, tree) {
|
|
|
169
282
|
// Merge argument types across translations so a numeric source placeholder
|
|
170
283
|
// can acquire plural/number formatting in a translation without weakening types.
|
|
171
284
|
const params = Object.create(null);
|
|
172
|
-
for (const value of Object.values(message.translations)) argumentsFor(parseMessage(value, catalog.syntax), params);
|
|
173
|
-
types[message.key] = Object.entries(params).map(([name, type]) => `${JSON.stringify(name)}: ${type === 'number' ? 'number' : type === 'date' ? 'Date | number' : type === 'select' ? 'string' : type === 'tag' ? '(chunks: any[]) => any' : 'string | number | null | undefined'}`).join('; ');
|
|
285
|
+
for (const value of Object.values(message.translations)) argumentsFor(parseMessage(value, catalog.syntax, catalog.formatters), params);
|
|
286
|
+
types[message.key] = Object.entries(params).map(([name, type]) => `${JSON.stringify(name)}: ${type === 'number' ? 'number' : type === 'date' ? 'Date | number' : type === 'select' ? 'string' : type === 'tag' ? '(chunks: any[]) => any' : type.startsWith('format:') ? 'any' : 'string | number | null | undefined'}`).join('; ');
|
|
174
287
|
if (Object.values(params).includes('tag')) rich.push(message.key);
|
|
175
288
|
}
|
|
176
289
|
let text = 'import type { Language } from "./language.mjs";\nexport type { Language };\n';
|
|
@@ -181,7 +294,7 @@ function typesFor(catalog, tree) {
|
|
|
181
294
|
// A division function takes the tokens under its prefix by their short names.
|
|
182
295
|
text += 'type Short<P extends string> = Token extends infer K ? (K extends `${P}${infer S}` ? S : never) : never;\n';
|
|
183
296
|
text += 'type Full<P extends string, S extends string> = `${P}${S}` & Token;\n';
|
|
184
|
-
text += 'export interface Scope<P extends string> {\n <S extends Short<P>>(token: S, ...args: Values[Full<P, S>] extends undefined ? [values?: undefined] : [values: Values[Full<P, S>]]): Full<P, S> extends RichToken ? any[] : string;\n}\n';
|
|
297
|
+
text += 'export interface Scope<P extends string> {\n <S extends Short<P>>(token: S, ...args: Values[Full<P, S>] extends undefined ? [values?: undefined] : [values: Values[Full<P, S>]]): Full<P, S> extends RichToken ? any[] : string;\n /** Whether the compiled catalog has this token, for text written after the last compile. */\n has(token: string): token is Short<P>;\n}\n';
|
|
185
298
|
const scopeType = (prefix, node) => `Scope<${JSON.stringify(prefix)}>${Object.keys(node).length ? ` & { ${Object.entries(node).map(([property, [segment, children]]) => `readonly ${property}: ${scopeType(`${prefix}${segment}.`, children)}`).join('; ')} }` : ''}`;
|
|
186
299
|
text += `export type Translator = ${scopeType('', tree)};\n\n`;
|
|
187
300
|
return text;
|
|
@@ -241,7 +354,14 @@ export function generateModules(input, { target = 'ts', eager = false } = {}) {
|
|
|
241
354
|
if (target === 'ts') main += typesFor(catalog, tree);
|
|
242
355
|
const own = eager ? catalog.messages : catalog.messages.filter(m => !m.division);
|
|
243
356
|
if (own.length) main += `store.add(${table(own, catalog.source)});\n`;
|
|
244
|
-
|
|
357
|
+
const formatters = catalog.formatters || [];
|
|
358
|
+
if (formatters.length) {
|
|
359
|
+
main += 'const formatters = {};\n/** Supply the function for an argument type the project declares: {len, length}. */\n';
|
|
360
|
+
main += target === 'ts'
|
|
361
|
+
? `export function registerFormatter(name: ${formatters.map(f => JSON.stringify(f)).join(' | ')}, format: (value: any, language: Language) => string): void { formatters[name] = format; }\n`
|
|
362
|
+
: 'export function registerFormatter(name, format) { formatters[name] = format; }\n';
|
|
363
|
+
}
|
|
364
|
+
main += `const translator = createTranslator({ store, getLanguage }, ${JSON.stringify(tree)}${formatters.length ? ', { formatters }' : ''});\n`;
|
|
245
365
|
main += '/** Translate into the current language: i18nmd("token"), i18nmd.division("token", values), or i18nmd.in("fr")… for a given language. */\n';
|
|
246
366
|
main += target === 'ts' ? 'export const i18nmd = translator as Translator & { in(language: Language | (string & {})): Translator };\n' : 'export const i18nmd = translator;\n';
|
|
247
367
|
main += 'export { languages } from "./language.mjs";\nexport { ready, getLanguage, setLanguage, loadLanguage, onLanguageChange };\n';
|
|
@@ -273,10 +393,10 @@ export function generateModule(input, target = 'ts') {
|
|
|
273
393
|
export const runtimeTypes = `/* eslint-disable */
|
|
274
394
|
export interface MessageNode { type: string; name?: string; style?: string; offset?: number; options?: Record<string, (string | MessageNode)[]>; children?: (string | MessageNode)[] }
|
|
275
395
|
export interface CompiledCatalog { source: string; languages: Record<string, string>; messages: Record<string, Record<string, (string | MessageNode)[]>> }
|
|
276
|
-
export interface I18nOptions { onError?: (error: Error) => void }
|
|
396
|
+
export interface I18nOptions { onError?: (error: Error) => void; formatters?: Record<string, (value: any, language: string) => string> }
|
|
277
397
|
export interface LanguageOptions { load?: (language: string) => Record<string, (string | MessageNode)[]> | Promise<Record<string, (string | MessageNode)[]>>; divisions?: string[]; storageKey?: string | null; onError?: (error: Error) => void }
|
|
278
398
|
export interface LanguageState { store: CompiledCatalog & { add(table: Record<string, (string | MessageNode)[]>, language?: string): void }; ready: Promise<string>; getLanguage(): string; setLanguage(language: string): Promise<string>; loadLanguage(language: string): Promise<void>; onLanguageChange(listener: (language: string) => void): () => void }
|
|
279
|
-
export function createI18n(catalog: CompiledCatalog, options?: I18nOptions): (token: string, language?: string, values?: Record<string, unknown>) => string | any[];
|
|
399
|
+
export function createI18n(catalog: CompiledCatalog, options?: I18nOptions): ((token: string, language?: string, values?: Record<string, unknown>) => string | any[]) & { has(token: string): boolean };
|
|
280
400
|
export function matchLanguage(catalog: { languages: Record<string, string> }, requested: string | null | undefined): string | undefined;
|
|
281
401
|
export function createLanguage(languages: Record<string, string>, source: string, options?: LanguageOptions): LanguageState;
|
|
282
402
|
export function createTranslator(language: Pick<LanguageState, 'store' | 'getLanguage'>, divisions?: Record<string, unknown>, options?: I18nOptions): any;
|
package/lib/extractor.mjs
CHANGED
|
@@ -7,7 +7,7 @@ const sourcePlugins = ['jsx', 'typescript'];
|
|
|
7
7
|
|
|
8
8
|
// Readable tokens from the words of the message; a numeric suffix resolves collisions.
|
|
9
9
|
// Tokens never encode the text, so editing the source message keeps the token.
|
|
10
|
-
function tokenFor(text, taken) {
|
|
10
|
+
export function tokenFor(text, taken) {
|
|
11
11
|
const words = text.toLowerCase().replace(/\{[^}]+\}|<\/?[\w-]+>/g, ' ').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').split(/[^a-z0-9]+/).filter(Boolean);
|
|
12
12
|
let slug = '';
|
|
13
13
|
for (const word of words) { if (slug && slug.length + word.length >= 40) break; slug += (slug ? '_' : '') + word; }
|