@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/lib/catalog.mjs
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { argumentsFor, parseMessage } from './messages.mjs';
|
|
2
|
+
|
|
3
|
+
/** Short, stable fingerprint of a source message, recorded beside each translation. */
|
|
4
|
+
export function hashMessage(text) {
|
|
5
|
+
let hash = 2166136261;
|
|
6
|
+
for (const c of text) { hash ^= c.codePointAt(0); hash = Math.imul(hash, 16777619); }
|
|
7
|
+
return (hash >>> 0).toString(36).padStart(7, '0');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A readable name for a language code, in that language when the host knows it. */
|
|
11
|
+
export function languageName(locale) {
|
|
12
|
+
try {
|
|
13
|
+
const name = new Intl.DisplayNames([locale], { type: 'language', fallback: 'none' }).of(locale);
|
|
14
|
+
if (name) return name[0].toLocaleUpperCase(locale) + name.slice(1);
|
|
15
|
+
} catch { /* Custom identifiers such as pirate have no display name. */ }
|
|
16
|
+
return locale;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseCatalog(markdown, options = {}) {
|
|
20
|
+
markdown = markdown.replace(/\r\n/g, '\n');
|
|
21
|
+
if (!markdown.startsWith('---')) {
|
|
22
|
+
const locale = options.locale || (options.filename && languageFromFilename(options.filename));
|
|
23
|
+
if (!locale) throw new Error('Supply the i18n-<language>.md filename for a language file.');
|
|
24
|
+
const name = /^# (.+)$/m.exec(markdown)?.[1] || locale;
|
|
25
|
+
const syntax = options.syntax || 'icu';
|
|
26
|
+
let open;
|
|
27
|
+
markdown = markdown.split('\n').map(line => {
|
|
28
|
+
if (open) { if (line === open) open = undefined; return line; }
|
|
29
|
+
const fence = /^(`{3,}|~{3,})([^\s]*)[ \t]*$/.exec(line);
|
|
30
|
+
if (!fence) return line;
|
|
31
|
+
if (fence[2] !== syntax && fence[2] !== locale) throw new Error(`Use a ${syntax} message block in ${locale}.`);
|
|
32
|
+
open = fence[1];
|
|
33
|
+
return open + locale;
|
|
34
|
+
}).join('\n');
|
|
35
|
+
// One file can't tell whether it is the source, so optional placeholders are checked once files are merged.
|
|
36
|
+
return parseCatalog(`---\nsource: ${locale}\nsyntax: ${syntax}\nlanguages: ${JSON.stringify({ [locale]: name })}\n---\n${markdown}`, { ...options, single: true });
|
|
37
|
+
}
|
|
38
|
+
const lines = markdown.split('\n');
|
|
39
|
+
if (lines[0] !== '---') throw new Error('Start the file with the i18n.md metadata block.');
|
|
40
|
+
const end = lines.indexOf('---', 1);
|
|
41
|
+
if (end < 0) throw new Error('The metadata block is not closed.');
|
|
42
|
+
const metadata = Object.create(null);
|
|
43
|
+
for (const line of lines.slice(1, end)) {
|
|
44
|
+
const match = /^(source|syntax|languages):\s*(.+)$/.exec(line);
|
|
45
|
+
if (!match || Object.hasOwn(metadata, match[1])) throw new Error(`Invalid or duplicate metadata: ${line}`);
|
|
46
|
+
metadata[match[1]] = match[1] === 'languages' ? JSON.parse(match[2]) : match[2].trim();
|
|
47
|
+
}
|
|
48
|
+
const catalog = { ...metadata, title: 'Translations', messages: [] };
|
|
49
|
+
let message;
|
|
50
|
+
for (let i = end + 1; i < lines.length; i++) {
|
|
51
|
+
const line = lines[i];
|
|
52
|
+
if (/^# /.test(line)) { catalog.title = line.slice(2); continue; }
|
|
53
|
+
if (/^## /.test(line)) {
|
|
54
|
+
const key = line.slice(3).trim();
|
|
55
|
+
if (!/^[a-zA-Z_]\w*(?:[.-]\w+)*$/.test(key)) throw new Error(`Invalid token: ${key}`);
|
|
56
|
+
message = { key, context: '', optional: [], translations: Object.create(null) }; catalog.messages.push(message); continue;
|
|
57
|
+
}
|
|
58
|
+
if (/^Context: /.test(line) && message) { message.context = line.slice(9); continue; }
|
|
59
|
+
if (/^Optional: /.test(line) && message) { message.optional = line.slice(10).split(',').map(v => v.trim()).filter(Boolean); continue; }
|
|
60
|
+
// Status/Source lines from earlier versions now live in i18nmd.lock.json; ignore them.
|
|
61
|
+
if (message && /^(Status|Source)(?: [\w-]+)?: \S+\s*$/.test(line)) continue;
|
|
62
|
+
const fence = /^(`{3,}|~{3,})([\w-]+)\s*$/.exec(line);
|
|
63
|
+
if (fence) {
|
|
64
|
+
if (!message) throw new Error('A translation needs a ## token heading.');
|
|
65
|
+
const locale = fence[2];
|
|
66
|
+
if (Object.hasOwn(message.translations, locale)) throw new Error(`Duplicate ${locale} translation for ${message.key}.`);
|
|
67
|
+
const content = [];
|
|
68
|
+
while (++i < lines.length && lines[i] !== fence[1]) content.push(lines[i]);
|
|
69
|
+
if (i === lines.length) throw new Error(`Unclosed translation fence for ${message.key}.`);
|
|
70
|
+
message.translations[locale] = content.join('\n');
|
|
71
|
+
} else if (/^(`{3,}|~{3,})/.test(line)) throw new Error(`Invalid translation fence: ${line}`);
|
|
72
|
+
}
|
|
73
|
+
validateCatalog(catalog, options); return catalog;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check every translation against the source message.
|
|
78
|
+
* allowIncomplete: other languages may lack tokens (the runtime falls back).
|
|
79
|
+
* lenient: a translation whose placeholders no longer match the source (usually
|
|
80
|
+
* because the source changed) is set aside with a warning and the source text is
|
|
81
|
+
* used, so an outdated translation never blocks a build. Its text is kept in
|
|
82
|
+
* message.invalid so it is written back unchanged.
|
|
83
|
+
*/
|
|
84
|
+
export function validateCatalog(catalog, { allowIncomplete = false, lenient = false, single = false } = {}) {
|
|
85
|
+
catalog.warnings ||= [];
|
|
86
|
+
if (!['icu', 'python'].includes(catalog.syntax)) throw new Error('syntax must be icu or python.');
|
|
87
|
+
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
|
+
const locales = Object.keys(catalog.languages);
|
|
89
|
+
if (!locales.length || !Object.hasOwn(catalog.languages, catalog.source)) throw new Error('Declare the source language in languages.');
|
|
90
|
+
for (const locale of locales) {
|
|
91
|
+
if (!/^[a-z][a-z0-9]*(?:-[a-zA-Z0-9]+)*$/.test(locale) || locale === '__proto__') throw new Error(`Invalid language identifier: ${locale}`);
|
|
92
|
+
try {
|
|
93
|
+
if (Intl.getCanonicalLocales(locale)[0] !== locale) throw new Error(`Use a canonical BCP 47 language code: ${locale}`);
|
|
94
|
+
} catch (error) { if (!(error instanceof RangeError)) throw error; }
|
|
95
|
+
if (typeof catalog.languages[locale] !== 'string' || !catalog.languages[locale].trim()) throw new Error(`Give ${locale} a language name.`);
|
|
96
|
+
}
|
|
97
|
+
if (!Array.isArray(catalog.messages) || !catalog.messages.length) throw new Error('The catalog has no messages.');
|
|
98
|
+
const keys = new Set();
|
|
99
|
+
for (const message of catalog.messages) {
|
|
100
|
+
if (!/^[a-zA-Z_]\w*(?:[.-]\w+)*$/.test(message.key) || message.key === '__proto__') throw new Error(`Invalid token: ${message.key}`);
|
|
101
|
+
if (keys.has(message.key)) throw new Error(`Duplicate token: ${message.key}`);
|
|
102
|
+
keys.add(message.key);
|
|
103
|
+
const table = message.translations;
|
|
104
|
+
if (!table || typeof table !== 'object') throw new Error(`${message.key}: missing translations.`);
|
|
105
|
+
for (const locale of Object.keys(table)) if (!Object.hasOwn(catalog.languages, locale)) throw new Error(`${message.key}: undeclared language ${locale}.`);
|
|
106
|
+
const parsed = Object.create(null);
|
|
107
|
+
for (const locale of locales) {
|
|
108
|
+
if (!Object.hasOwn(table, locale) && allowIncomplete && locale !== catalog.source) continue;
|
|
109
|
+
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)); }
|
|
111
|
+
catch (error) { throw new Error(`${message.key} [${locale}]: ${error.message}`); }
|
|
112
|
+
}
|
|
113
|
+
const sourceArgs = parsed[catalog.source];
|
|
114
|
+
const optional = message.optional || [];
|
|
115
|
+
if (!single) for (const name of optional) if (!Object.hasOwn(sourceArgs, name)) throw new Error(`${message.key}: optional {${name}} is absent from the source.`);
|
|
116
|
+
for (const locale of locales) {
|
|
117
|
+
if (!parsed[locale]) continue;
|
|
118
|
+
let problem;
|
|
119
|
+
for (const name of Object.keys(sourceArgs)) if (!problem && !Object.hasOwn(parsed[locale], name) && !optional.includes(name)) problem = `missing ${sourceArgs[name] === 'tag' ? `<${name}>` : `{${name}}`}`;
|
|
120
|
+
for (const [name, type] of Object.entries(parsed[locale])) {
|
|
121
|
+
if (problem) break;
|
|
122
|
+
const base = sourceArgs[name];
|
|
123
|
+
if (!base) problem = `unknown placeholder ${type === 'tag' ? `<${name}>` : `{${name}}`}`;
|
|
124
|
+
else if (base !== type && (base === 'tag' || type === 'tag' || (base !== 'string' && type !== 'string'))) problem = `incompatible type for {${name}}`;
|
|
125
|
+
}
|
|
126
|
+
if (!problem) continue;
|
|
127
|
+
if (lenient && locale !== catalog.source) {
|
|
128
|
+
(message.invalid ||= Object.create(null))[locale] = table[locale];
|
|
129
|
+
delete table[locale];
|
|
130
|
+
catalog.warnings.push(`${message.key} [${locale}]: ${problem}; using the ${catalog.source} text until it is updated.`);
|
|
131
|
+
} else throw new Error(`${message.key} [${locale}]: ${problem}.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return catalog;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function serializeCatalog(catalog, options = {}) {
|
|
138
|
+
validateCatalog(catalog, { allowIncomplete: true, single: Object.keys(catalog.languages || {}).length === 1, ...options });
|
|
139
|
+
const locale = options.locale;
|
|
140
|
+
if (locale && !Object.hasOwn(catalog.languages, locale)) throw new Error(`Unknown language: ${locale}`);
|
|
141
|
+
let text = locale ? `# ${catalog.languages[locale]}\n\n` : `---\nsource: ${catalog.source}\nsyntax: ${catalog.syntax}\nlanguages: ${JSON.stringify(catalog.languages)}\n---\n\n# ${catalog.title || 'Translations'}\n\n`;
|
|
142
|
+
if (!locale) text += 'Translate the language blocks below. Keep token names and placeholders intact. To add a language, add it to the metadata and add a block for every token. Context explains where each string appears. Optional placeholders may be omitted when the target language does not need them.\n\n';
|
|
143
|
+
for (const message of catalog.messages) {
|
|
144
|
+
const texts = Object.keys(catalog.languages).map(lang => [lang, textOf(message, lang)]).filter(([lang, value]) => value !== undefined && (!locale || lang === locale));
|
|
145
|
+
if (locale && !texts.length) continue;
|
|
146
|
+
text += `## ${message.key}\n\n`;
|
|
147
|
+
if (message.context) text += `Context: ${message.context.replace(/\r?\n/g, ' ')}\n\n`;
|
|
148
|
+
if (message.optional?.length) text += `Optional: ${message.optional.join(', ')}\n\n`;
|
|
149
|
+
for (const [lang, value] of texts) {
|
|
150
|
+
const max = Math.max(2, ...[...value.matchAll(/`+/g)].map(m => m[0].length));
|
|
151
|
+
const fence = '`'.repeat(max + 1);
|
|
152
|
+
text += `${fence}${locale ? catalog.syntax : lang}\n${value}\n${fence}\n\n`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return text.replace(/\n+$/, '\n');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The text written for a language, including one set aside as outdated. */
|
|
159
|
+
export function textOf(message, locale) {
|
|
160
|
+
return Object.hasOwn(message.translations, locale) ? message.translations[locale] : message.invalid?.[locale];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function languageFromFilename(filename) {
|
|
164
|
+
const match = /^i18n-([a-z][a-zA-Z0-9-]*)\.md$/.exec(filename.split(/[\\/]/).pop());
|
|
165
|
+
if (!match) throw new Error('Name language files i18n-<language>.md.');
|
|
166
|
+
return match[1];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The division a language file belongs to: its directory, '' at the top level. */
|
|
170
|
+
export function divisionOf(filename) {
|
|
171
|
+
return filename.split(/[\\/]/).slice(0, -1).join('/');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const where = division => division ? `${division}/` : '';
|
|
175
|
+
|
|
176
|
+
// Properties a division function cannot take: they exist on every function,
|
|
177
|
+
// 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__']);
|
|
179
|
+
|
|
180
|
+
/** knowledge-hub → knowledgeHub: the property for a division directory. */
|
|
181
|
+
export function divisionProperty(segment) {
|
|
182
|
+
const property = segment.replace(/[-_]+([a-zA-Z0-9])/g, (_, c) => c.toUpperCase());
|
|
183
|
+
if (RESERVED.has(property)) throw new Error(`A division cannot be called ${segment}: i18nmd.${property} is taken. Rename the directory.`);
|
|
184
|
+
return property;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The call for a namespace: "knowledge-hub.faq." → ".knowledgeHub.faq", so i18nmd.knowledgeHub.faq("…"). */
|
|
188
|
+
export function accessorFor(namespace) {
|
|
189
|
+
return namespace ? namespace.slice(0, -1).split('.').map(segment => '.' + divisionProperty(segment)).join('') : '';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The token prefix for a division: ui/settings → "ui.settings.". prefix is the
|
|
194
|
+
* division of the directory being read, when it is part of a larger tree.
|
|
195
|
+
*/
|
|
196
|
+
export function namespaceFor(division, prefix = '') {
|
|
197
|
+
const full = [prefix, division].filter(Boolean).join('/');
|
|
198
|
+
if (!full) return '';
|
|
199
|
+
for (const part of full.split('/')) {
|
|
200
|
+
if (!/^[a-zA-Z_]\w*(?:-\w+)*$/.test(part)) throw new Error(`Name division directories with letters, digits, _ and -: ${full}`);
|
|
201
|
+
divisionProperty(part);
|
|
202
|
+
}
|
|
203
|
+
return full.replace(/\//g, '.') + '.';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Merge one-language files into a catalog. Files may sit in subdirectories,
|
|
208
|
+
* one per division (ui/i18n-fr.md, marketing/i18n-fr.md); each division has its
|
|
209
|
+
* own source file, and its tokens are namespaced by its path: ## save in
|
|
210
|
+
* ui/i18n-en.md is the token ui.save. Every message records its division so it
|
|
211
|
+
* is written back to the same place, without the namespace. By default a
|
|
212
|
+
* language may lack tokens (it falls back to the source) and tokens the source
|
|
213
|
+
* no longer has are set aside in catalog.orphans with a warning. strict: every
|
|
214
|
+
* file must match its source exactly.
|
|
215
|
+
*/
|
|
216
|
+
export function parseLanguageFiles(files, { source, syntax = 'icu', strict = false, prefix = '' } = {}) {
|
|
217
|
+
const divisions = new Map();
|
|
218
|
+
for (const [filename, markdown] of Object.entries(files)) {
|
|
219
|
+
const locale = languageFromFilename(filename);
|
|
220
|
+
const division = divisionOf(filename);
|
|
221
|
+
if (!divisions.has(division)) divisions.set(division, new Map());
|
|
222
|
+
const byLocale = divisions.get(division);
|
|
223
|
+
if (byLocale.has(locale)) throw new Error(`Duplicate language file: ${where(division)}${locale}`);
|
|
224
|
+
const parsed = parseCatalog(markdown, { locale, syntax });
|
|
225
|
+
const namespace = namespaceFor(division, prefix);
|
|
226
|
+
for (const message of parsed.messages) message.key = namespace + message.key;
|
|
227
|
+
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.`);
|
|
228
|
+
byLocale.set(locale, parsed);
|
|
229
|
+
}
|
|
230
|
+
const all = [...divisions.values()].flatMap(byLocale => [...byLocale.keys()]);
|
|
231
|
+
source ||= all.includes('en') ? 'en' : all[0];
|
|
232
|
+
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 };
|
|
234
|
+
const owner = new Map();
|
|
235
|
+
for (const [division, byLocale] of divisions) {
|
|
236
|
+
const base = byLocale.get(source);
|
|
237
|
+
if (!base) throw new Error(`Include ${where(division)}i18n-${source}.md as the source file.`);
|
|
238
|
+
catalog.languages[source] ||= base.languages[source];
|
|
239
|
+
for (const message of base.messages) {
|
|
240
|
+
if (owner.has(message.key)) throw new Error(`Token ${message.key} is defined in both ${where(owner.get(message.key)) || './'} and ${where(division) || './'}.`);
|
|
241
|
+
owner.set(message.key, division);
|
|
242
|
+
message.division = division;
|
|
243
|
+
catalog.messages.push(message);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const warnings = [];
|
|
247
|
+
const messages = new Map(catalog.messages.map(message => [message.key, message]));
|
|
248
|
+
for (const [division, byLocale] of divisions) {
|
|
249
|
+
const local = [...messages.values()].filter(message => message.division === division);
|
|
250
|
+
for (const [locale, parsed] of byLocale) {
|
|
251
|
+
if (locale === source) continue;
|
|
252
|
+
catalog.languages[locale] ||= parsed.languages[locale];
|
|
253
|
+
const label = `${where(division)}${locale}`;
|
|
254
|
+
const seen = new Set();
|
|
255
|
+
for (const message of parsed.messages) {
|
|
256
|
+
const target = messages.get(message.key);
|
|
257
|
+
if (!target || target.division !== division) {
|
|
258
|
+
const hint = target ? `belongs in ${where(target.division) || './'}` : `is not in ${source}`;
|
|
259
|
+
if (strict) throw new Error(target ? `${label}: token ${message.key} ${hint}.` : `${label}: unknown token ${message.key}.`);
|
|
260
|
+
message.division = division;
|
|
261
|
+
(catalog.orphans[locale] ||= []).push(message);
|
|
262
|
+
warnings.push(`${label}: ${message.key} ${hint}; ignored. Run sync to remove it, or move or rename it to keep it.`);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
seen.add(message.key);
|
|
266
|
+
target.translations[locale] = message.translations[locale];
|
|
267
|
+
}
|
|
268
|
+
const missing = local.filter(message => !seen.has(message.key)).map(message => message.key);
|
|
269
|
+
if (missing.length) {
|
|
270
|
+
if (strict) throw new Error(`${label}: token set must match ${source}; missing ${missing.join(', ')}.`);
|
|
271
|
+
warnings.push(`${label}: ${missing.length} token${missing.length === 1 ? '' : 's'} missing (falls back to ${source}).`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// A language with no file in some division is simply untranslated there.
|
|
276
|
+
for (const locale of Object.keys(catalog.languages)) {
|
|
277
|
+
for (const [division, byLocale] of divisions) {
|
|
278
|
+
if (byLocale.has(locale)) continue;
|
|
279
|
+
const count = catalog.messages.filter(message => message.division === division).length;
|
|
280
|
+
if (strict) throw new Error(`${where(division)}i18n-${locale}.md is missing.`);
|
|
281
|
+
warnings.push(`${where(division)}${locale}: ${count} token${count === 1 ? '' : 's'} missing (falls back to ${source}).`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catalog.warnings = warnings;
|
|
285
|
+
return validateCatalog(catalog, { allowIncomplete: !strict, lenient: !strict });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Rename a token in every language, keeping its translations. */
|
|
289
|
+
export function renameToken(catalog, from, to) {
|
|
290
|
+
if (!/^[a-zA-Z_]\w*(?:[.-]\w+)*$/.test(to)) throw new Error(`Invalid token: ${to}`);
|
|
291
|
+
const message = catalog.messages.find(m => m.key === from);
|
|
292
|
+
if (!message) throw new Error(`Unknown token: ${from}`);
|
|
293
|
+
if (catalog.messages.some(m => m.key === to)) throw new Error(`Token ${to} already exists.`);
|
|
294
|
+
message.key = to;
|
|
295
|
+
return catalog;
|
|
296
|
+
}
|
package/lib/compiler.mjs
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { parseCatalog, validateCatalog, namespaceFor, divisionProperty, accessorFor } from './catalog.mjs';
|
|
2
|
+
import { argumentsFor, parseMessage } from './messages.mjs';
|
|
3
|
+
|
|
4
|
+
export function compileCatalog(input) {
|
|
5
|
+
const catalog = typeof input === 'string' ? parseCatalog(input, { allowIncomplete: true }) : validateCatalog(input, { allowIncomplete: true });
|
|
6
|
+
const messages = Object.create(null);
|
|
7
|
+
for (const message of catalog.messages) {
|
|
8
|
+
messages[message.key] = Object.fromEntries(Object.entries(message.translations).map(([lang, text]) => [lang, parseMessage(text, catalog.syntax)]));
|
|
9
|
+
}
|
|
10
|
+
return { source: catalog.source, languages: catalog.languages, messages };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The division tree, { property: [directory segment, children] }, from the
|
|
14
|
+
// namespace of every message.
|
|
15
|
+
function divisionTree(catalog) {
|
|
16
|
+
const tree = {};
|
|
17
|
+
for (const message of catalog.messages) {
|
|
18
|
+
const namespace = namespaceFor(message.division || '', catalog.prefix || '');
|
|
19
|
+
let node = tree;
|
|
20
|
+
for (const segment of namespace.split('.').slice(0, -1)) {
|
|
21
|
+
const property = divisionProperty(segment);
|
|
22
|
+
if (node[property] && node[property][0] !== segment) throw new Error(`Divisions ${node[property][0]} and ${segment} would both be i18nmd.${property}. Rename one.`);
|
|
23
|
+
node = (node[property] ||= [segment, {}])[1];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return tree;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Plural categories for 0–199 as one letter each. CLDR rules depend on n, n % 10
|
|
30
|
+
// and n % 100, so 100 + n % 100 stands in for any larger whole number.
|
|
31
|
+
function pluralTable(locale, type) {
|
|
32
|
+
let rules;
|
|
33
|
+
try { rules = new Intl.PluralRules(locale, { type }); } catch { rules = new Intl.PluralRules('en', { type }); }
|
|
34
|
+
const letter = { zero: 'z', one: 'o', two: 't', few: 'f', many: 'm', other: 'x' };
|
|
35
|
+
return Array.from({ length: 200 }, (_, n) => letter[rules.select(n)]).join('');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PYTHON_RUNTIME = `
|
|
39
|
+
_CATEGORY = {"z": "zero", "o": "one", "t": "two", "f": "few", "m": "many", "x": "other"}
|
|
40
|
+
_log = logging.getLogger("i18nmd")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _number(value: Any) -> int | float | None:
|
|
44
|
+
if isinstance(value, bool):
|
|
45
|
+
return None
|
|
46
|
+
if isinstance(value, (int, float)):
|
|
47
|
+
return value
|
|
48
|
+
try:
|
|
49
|
+
text = str(value).strip()
|
|
50
|
+
return float(text) if "." in text else int(text)
|
|
51
|
+
except (TypeError, ValueError):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _show(number: int | float) -> str:
|
|
56
|
+
return str(int(number)) if float(number).is_integer() else f"{number:g}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _category(table: str, number: int | float) -> str:
|
|
60
|
+
if not float(number).is_integer():
|
|
61
|
+
return "other"
|
|
62
|
+
n = abs(int(number))
|
|
63
|
+
return _CATEGORY[table[n if n < 200 else 100 + n % 100]]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _render(nodes: list[Any], language: str, values: dict[str, Any] | None, pound: Any = None) -> str:
|
|
67
|
+
out: list[str] = []
|
|
68
|
+
for node in nodes:
|
|
69
|
+
if isinstance(node, str):
|
|
70
|
+
out.append(node)
|
|
71
|
+
continue
|
|
72
|
+
kind = node["type"]
|
|
73
|
+
if kind == "pound":
|
|
74
|
+
out.append(_show(pound))
|
|
75
|
+
continue
|
|
76
|
+
if kind == "tag":
|
|
77
|
+
inner = _render(node["children"], language, values, pound)
|
|
78
|
+
wrap = values.get(node["name"])
|
|
79
|
+
out.append(str(wrap(inner)) if callable(wrap) else inner)
|
|
80
|
+
continue
|
|
81
|
+
name = node["name"]
|
|
82
|
+
if values is None: # template(): show the placeholder itself
|
|
83
|
+
out.append("{" + name + "}")
|
|
84
|
+
continue
|
|
85
|
+
if name not in values:
|
|
86
|
+
_log.error("Missing value for {%s}.", name)
|
|
87
|
+
out.append("{" + name + "}")
|
|
88
|
+
continue
|
|
89
|
+
value = values[name]
|
|
90
|
+
if kind == "argument":
|
|
91
|
+
out.append("" if value is None else str(value))
|
|
92
|
+
elif kind == "select":
|
|
93
|
+
options = node["options"]
|
|
94
|
+
out.append(_render(options.get(str(value), options["other"]), language, values, pound))
|
|
95
|
+
elif kind in ("plural", "selectordinal"):
|
|
96
|
+
number = _number(value)
|
|
97
|
+
if number is None:
|
|
98
|
+
_log.error("{%s} must be a number.", name)
|
|
99
|
+
out.append(str(value))
|
|
100
|
+
continue
|
|
101
|
+
options = node["options"]
|
|
102
|
+
exact = options.get("=" + _show(number))
|
|
103
|
+
table = (_ORDINAL if kind == "selectordinal" else _CARDINAL)[language]
|
|
104
|
+
chosen = exact or options.get(_category(table, number - node["offset"])) or options["other"]
|
|
105
|
+
out.append(_render(chosen, language, values, number - node["offset"]))
|
|
106
|
+
elif kind == "number":
|
|
107
|
+
number = _number(value)
|
|
108
|
+
out.append(str(value) if number is None else f"{number * 100:g}%" if node.get("style") == "percent" else _show(number))
|
|
109
|
+
else:
|
|
110
|
+
out.append(str(value))
|
|
111
|
+
return "".join(out)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _match(language: str | None) -> str:
|
|
115
|
+
if not language:
|
|
116
|
+
return SOURCE
|
|
117
|
+
code = language.replace("_", "-")
|
|
118
|
+
if code in LANGS:
|
|
119
|
+
return code
|
|
120
|
+
base = code.split("-")[0].lower()
|
|
121
|
+
return next((lang for lang in LANGS if lang.split("-")[0] == base), SOURCE)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def i18nmd(token: str, language: str | None = None, **values: Any) -> str:
|
|
125
|
+
# The message for token in language (or the closest one it has), with values filled in.
|
|
126
|
+
entry = _MESSAGES.get(token)
|
|
127
|
+
if entry is None:
|
|
128
|
+
_log.error("Unknown translation token: %s", token)
|
|
129
|
+
return token
|
|
130
|
+
chosen = _match(language)
|
|
131
|
+
used = chosen if chosen in entry else SOURCE
|
|
132
|
+
return _render(entry[used], used, values)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def template(token: str, language: str | None = None) -> str:
|
|
136
|
+
# The message with each placeholder, plural or select shown as {name}.
|
|
137
|
+
entry = _MESSAGES[token]
|
|
138
|
+
chosen = _match(language)
|
|
139
|
+
return _render(entry[chosen if chosen in entry else SOURCE], SOURCE, None)
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
// ICU messages as a dependency-free Python module: i18nmd(token, language, **values).
|
|
143
|
+
function pythonModule(catalog) {
|
|
144
|
+
const compiled = compileCatalog(catalog);
|
|
145
|
+
const tables = kind => Object.fromEntries(Object.keys(catalog.languages).map(l => [l, pluralTable(l, kind)]));
|
|
146
|
+
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';
|
|
148
|
+
text += `SOURCE = ${JSON.stringify(catalog.source)}\nLANGS: dict[str, str] = ${literal(catalog.languages)}\n`;
|
|
149
|
+
text += `_CARDINAL: dict[str, str] = ${JSON.stringify(tables('cardinal'), null, 1)}\n_ORDINAL: dict[str, str] = ${JSON.stringify(tables('ordinal'), null, 1)}\n`;
|
|
150
|
+
// Compiled messages are JSON of strings, objects and integers, which is also Python.
|
|
151
|
+
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
|
+
text += 'TOKENS: frozenset[str] = frozenset(_MESSAGES)\n';
|
|
153
|
+
return text + PYTHON_RUNTIME;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Output files a division may not be named after.
|
|
157
|
+
const RESERVED_FILES = new Set(['i18n', 'language', 'runtime', 'languages']);
|
|
158
|
+
|
|
159
|
+
/** The module for a division: ui → ui, marketing/landing → marketing.landing. */
|
|
160
|
+
export function divisionFile(division) {
|
|
161
|
+
const file = division.replace(/\//g, '.');
|
|
162
|
+
if (RESERVED_FILES.has(file)) throw new Error(`A division cannot be called ${division}: the compiler writes ${file}.* itself. Rename the directory.`);
|
|
163
|
+
return file;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function typesFor(catalog, tree) {
|
|
167
|
+
const types = Object.create(null), rich = [];
|
|
168
|
+
for (const message of catalog.messages) {
|
|
169
|
+
// Merge argument types across translations so a numeric source placeholder
|
|
170
|
+
// can acquire plural/number formatting in a translation without weakening types.
|
|
171
|
+
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('; ');
|
|
174
|
+
if (Object.values(params).includes('tag')) rich.push(message.key);
|
|
175
|
+
}
|
|
176
|
+
let text = 'import type { Language } from "./language.mjs";\nexport type { Language };\n';
|
|
177
|
+
text += `export interface Values {\n${Object.entries(types).map(([key, fields]) => ` ${JSON.stringify(key)}: ${fields ? `{ ${fields} }` : 'undefined'};`).join('\n')}\n}\n`;
|
|
178
|
+
text += 'export type Token = keyof Values;\n';
|
|
179
|
+
// Messages with <tags> return an array of strings and whatever the tag functions return.
|
|
180
|
+
text += `export type RichToken = ${rich.length ? rich.map(k => JSON.stringify(k)).join(' | ') : 'never'};\n`;
|
|
181
|
+
// A division function takes the tokens under its prefix by their short names.
|
|
182
|
+
text += 'type Short<P extends string> = Token extends infer K ? (K extends `${P}${infer S}` ? S : never) : never;\n';
|
|
183
|
+
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';
|
|
185
|
+
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
|
+
text += `export type Translator = ${scopeType('', tree)};\n\n`;
|
|
187
|
+
return text;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Every file the TypeScript or JavaScript target writes, by name:
|
|
192
|
+
* i18n.ts types, the i18nmd function, and top-level messages
|
|
193
|
+
* <division>.ts a division's source-language messages; code that calls
|
|
194
|
+
* i18nmd.<division>(…) imports i18nmd from here, so a bundler
|
|
195
|
+
* ships each division with the code that uses it
|
|
196
|
+
* language.mjs the current language and the loaders, with no messages, for
|
|
197
|
+
* an app's entry code (+ language.d.mts)
|
|
198
|
+
* languages/<code>.mjs every message in one other language, loaded by
|
|
199
|
+
* setLanguage before it switches
|
|
200
|
+
* eager: put every message in i18n.ts and load no languages lazily, for
|
|
201
|
+
* servers, tests and small apps.
|
|
202
|
+
*/
|
|
203
|
+
export function generateModules(input, { target = 'ts', eager = false } = {}) {
|
|
204
|
+
const catalog = typeof input === 'string' ? parseCatalog(input, { allowIncomplete: true }) : validateCatalog(input, { allowIncomplete: true });
|
|
205
|
+
if (!['ts', 'js'].includes(target)) throw new Error(`Unknown output target: ${target}`);
|
|
206
|
+
const compiled = compileCatalog(catalog);
|
|
207
|
+
const tree = divisionTree(catalog);
|
|
208
|
+
// Only i18n.ts needs lint turned off (its types use any); a disable comment
|
|
209
|
+
// with nothing to disable is itself a lint error in strict setups.
|
|
210
|
+
const head = '// Generated from i18n.md. Edit the Markdown source.\n';
|
|
211
|
+
const ext = target === 'ts' ? 'ts' : 'mjs';
|
|
212
|
+
const i18nImport = target === 'ts' ? './i18n' : './i18n.mjs';
|
|
213
|
+
const others = Object.keys(catalog.languages).filter(l => l !== catalog.source);
|
|
214
|
+
const divisions = [...new Set(catalog.messages.map(m => m.division || ''))].filter(Boolean).sort();
|
|
215
|
+
const table = (messages, language) => JSON.stringify(Object.fromEntries(messages.filter(m => Object.hasOwn(compiled.messages[m.key], language)).map(m => [m.key, compiled.messages[m.key][language]])), null, 1);
|
|
216
|
+
const files = {};
|
|
217
|
+
|
|
218
|
+
let language = head + 'import { createLanguage } from "./runtime.mjs";\n';
|
|
219
|
+
if (eager) others.forEach((code, i) => { language += `import language${i} from "./languages/${code}.mjs";\n`; });
|
|
220
|
+
language += `\nexport const languages = ${JSON.stringify(catalog.languages, null, 2)};\n`;
|
|
221
|
+
language += eager
|
|
222
|
+
? `const tables = { ${others.map((code, i) => `${JSON.stringify(code)}: language${i}`).join(', ')} };\nconst load = code => tables[code];\n`
|
|
223
|
+
: `// Each language is its own chunk, fetched when someone switches to it.\nconst load = code => ({\n${others.map(code => ` ${JSON.stringify(code)}: () => import("./languages/${code}.mjs"),`).join('\n')}\n})[code]().then(module => module.default);\n`;
|
|
224
|
+
language += `/** The current language; ready resolves once the reader's language has loaded. store is internal. */\nexport const { store, ready, getLanguage, setLanguage, loadLanguage, onLanguageChange } = createLanguage(languages, ${JSON.stringify(catalog.source)}, { load, divisions: ${JSON.stringify(divisions)} });\n`;
|
|
225
|
+
if (eager) language += 'for (const code of Object.keys(tables)) void loadLanguage(code);\n';
|
|
226
|
+
files['language.mjs'] = language;
|
|
227
|
+
if (target === 'ts') {
|
|
228
|
+
let types = head + `export type Language = ${Object.keys(catalog.languages).map(l => JSON.stringify(l)).join(' | ')};\n`;
|
|
229
|
+
types += `export declare const languages: ${JSON.stringify(catalog.languages)};\n`;
|
|
230
|
+
types += '/** Resolves once the reader\'s language has loaded; render after it to avoid a flash of the source language. */\nexport declare const ready: Promise<Language>;\n';
|
|
231
|
+
types += 'export declare function getLanguage(): Language;\n';
|
|
232
|
+
types += '/** Load a language, switch the interface to it (or its closest match) and remember it. */\nexport declare function setLanguage(language: Language | (string & {})): Promise<Language>;\n';
|
|
233
|
+
types += '/** Load a language for i18nmd.in(language), on a server or in tests. */\nexport declare function loadLanguage(language: Language | (string & {})): Promise<void>;\n';
|
|
234
|
+
types += 'export declare function onLanguageChange(listener: (language: Language) => void): () => void;\n';
|
|
235
|
+
types += '/** Internal: the messages loaded so far, which the generated modules add to. */\nexport declare const store: import("./runtime.mjs").LanguageState["store"];\n';
|
|
236
|
+
files['language.d.mts'] = types;
|
|
237
|
+
}
|
|
238
|
+
for (const code of others) files[`languages/${code}.mjs`] = head + `export default ${table(catalog.messages, code)};\n`;
|
|
239
|
+
|
|
240
|
+
let main = head + '/* eslint-disable */\nimport { createTranslator } from "./runtime.mjs";\nimport { store, ready, getLanguage, setLanguage, loadLanguage, onLanguageChange } from "./language.mjs";\n';
|
|
241
|
+
if (target === 'ts') main += typesFor(catalog, tree);
|
|
242
|
+
const own = eager ? catalog.messages : catalog.messages.filter(m => !m.division);
|
|
243
|
+
if (own.length) main += `store.add(${table(own, catalog.source)});\n`;
|
|
244
|
+
main += `const translator = createTranslator({ store, getLanguage }, ${JSON.stringify(tree)});\n`;
|
|
245
|
+
main += '/** Translate into the current language: i18nmd("token"), i18nmd.division("token", values), or i18nmd.in("fr")… for a given language. */\n';
|
|
246
|
+
main += target === 'ts' ? 'export const i18nmd = translator as Translator & { in(language: Language | (string & {})): Translator };\n' : 'export const i18nmd = translator;\n';
|
|
247
|
+
main += 'export { languages } from "./language.mjs";\nexport { ready, getLanguage, setLanguage, loadLanguage, onLanguageChange };\n';
|
|
248
|
+
files[`i18n.${ext}`] = main;
|
|
249
|
+
|
|
250
|
+
for (const division of divisions) {
|
|
251
|
+
const messages = catalog.messages.filter(m => m.division === division);
|
|
252
|
+
let text = head + `// The ${division} division: import i18nmd from here to call i18nmd${accessorFor(namespaceFor(division, catalog.prefix || ''))}(…).\nimport { store } from "./language.mjs";\n`;
|
|
253
|
+
if (!eager) text += `store.add(${table(messages, catalog.source)});\n`;
|
|
254
|
+
text += `export { i18nmd } from "${i18nImport}";\n`;
|
|
255
|
+
files[`${divisionFile(division)}.${ext}`] = text;
|
|
256
|
+
}
|
|
257
|
+
return files;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function generateModule(input, target = 'ts') {
|
|
261
|
+
const catalog = typeof input === 'string' ? parseCatalog(input, { allowIncomplete: true }) : validateCatalog(input, { allowIncomplete: true });
|
|
262
|
+
if (target === 'json') return JSON.stringify(Object.fromEntries(Object.keys(catalog.languages).map(lang => [lang, Object.fromEntries(catalog.messages.map(m => [m.key, m.translations[lang]]))])), null, 2) + '\n';
|
|
263
|
+
if (target === 'python' && catalog.syntax === 'icu') return pythonModule(catalog);
|
|
264
|
+
if (target === 'python') {
|
|
265
|
+
// These dictionaries contain strings only; JSON's quoted strings and object
|
|
266
|
+
// syntax are valid Python literals, with no true/false/null conversion needed.
|
|
267
|
+
const tables = generateModule(catalog, 'json');
|
|
268
|
+
return `# Generated from i18n.md. Edit the Markdown source.\n\nLANGS = ${JSON.stringify(catalog.languages, null, 2)}\n_T = ${tables}\ndef i18nmd(token_name, language_code="${catalog.source}", **values):\n table = _T.get(language_code) or _T["${catalog.source}"]\n message = table.get(token_name) or _T["${catalog.source}"][token_name]\n return message.format(**values) if values else message\n`;
|
|
269
|
+
}
|
|
270
|
+
return generateModules(catalog, { target })[`i18n.${target === 'ts' ? 'ts' : 'mjs'}`];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export const runtimeTypes = `/* eslint-disable */
|
|
274
|
+
export interface MessageNode { type: string; name?: string; style?: string; offset?: number; options?: Record<string, (string | MessageNode)[]>; children?: (string | MessageNode)[] }
|
|
275
|
+
export interface CompiledCatalog { source: string; languages: Record<string, string>; messages: Record<string, Record<string, (string | MessageNode)[]>> }
|
|
276
|
+
export interface I18nOptions { onError?: (error: Error) => void }
|
|
277
|
+
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
|
+
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[];
|
|
280
|
+
export function matchLanguage(catalog: { languages: Record<string, string> }, requested: string | null | undefined): string | undefined;
|
|
281
|
+
export function createLanguage(languages: Record<string, string>, source: string, options?: LanguageOptions): LanguageState;
|
|
282
|
+
export function createTranslator(language: Pick<LanguageState, 'store' | 'getLanguage'>, divisions?: Record<string, unknown>, options?: I18nOptions): any;
|
|
283
|
+
`;
|