@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
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { parse, parseExpression } from '@babel/parser';
|
|
2
|
+
import { escapeMessageLiteral } from './messages.mjs';
|
|
3
|
+
import { languageName, accessorFor } from './catalog.mjs';
|
|
4
|
+
|
|
5
|
+
const attributes = new Set(['alt', 'title', 'placeholder', 'aria-label', 'aria-description', 'label']);
|
|
6
|
+
const sourcePlugins = ['jsx', 'typescript'];
|
|
7
|
+
|
|
8
|
+
// Readable tokens from the words of the message; a numeric suffix resolves collisions.
|
|
9
|
+
// Tokens never encode the text, so editing the source message keeps the token.
|
|
10
|
+
function tokenFor(text, taken) {
|
|
11
|
+
const words = text.toLowerCase().replace(/\{[^}]+\}|<\/?[\w-]+>/g, ' ').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').split(/[^a-z0-9]+/).filter(Boolean);
|
|
12
|
+
let slug = '';
|
|
13
|
+
for (const word of words) { if (slug && slug.length + word.length >= 40) break; slug += (slug ? '_' : '') + word; }
|
|
14
|
+
slug = slug.slice(0, 40) || 'message';
|
|
15
|
+
if (/^\d/.test(slug)) slug = 'n' + slug;
|
|
16
|
+
let key = slug;
|
|
17
|
+
for (let n = 2; taken.has(key); n++) key = `${slug}_${n}`;
|
|
18
|
+
return key;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const countLike = /^(?:n|num|count|total|qty|quantity|amount|size|length|\w*count|num\w*|\w*total)$/i;
|
|
22
|
+
const inlineAttributes = new Set(['className', 'class', 'href', 'to', 'target', 'rel', 'style', 'id', 'key', 'lang', 'dir']);
|
|
23
|
+
|
|
24
|
+
// A short element such as <b>, <a href>, or <Link to> inside a sentence becomes an ICU tag.
|
|
25
|
+
function inlineElement(child) {
|
|
26
|
+
if (child.type !== 'JSXElement' || child.openingElement.selfClosing || child.openingElement.name.type !== 'JSXIdentifier') return false;
|
|
27
|
+
if (!child.openingElement.attributes.every(a => a.type === 'JSXAttribute' && inlineAttributes.has(a.name?.name))) return false;
|
|
28
|
+
return child.children.length > 0 && child.children.every(c => c.type === 'JSXText' || c.type === 'JSXExpressionContainer' && (c.expression.type === 'StringLiteral' || valueExpression(c.expression)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function describe(node) {
|
|
32
|
+
const name = node?.type === 'JSXOpeningElement' ? node.name : node?.openingElement?.name;
|
|
33
|
+
return name?.type === 'JSXIdentifier' ? `<${name.name}>` : name ? 'element' : 'fragment';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// React's JSX line whitespace rules, applied before moving text to a catalog.
|
|
37
|
+
function jsxText(value) {
|
|
38
|
+
const lines = value.split(/\r\n|\n|\r/);
|
|
39
|
+
let last = 0;
|
|
40
|
+
for (let i = 0; i < lines.length; i++) if (/[^ \t]/.test(lines[i])) last = i;
|
|
41
|
+
return lines.map((line, i) => {
|
|
42
|
+
let text = line.replace(/\t/g, ' ');
|
|
43
|
+
if (i !== 0) text = text.replace(/^ +/, '');
|
|
44
|
+
if (i !== lines.length - 1) text = text.replace(/ +$/, '');
|
|
45
|
+
return text ? text + (i !== last ? ' ' : '') : '';
|
|
46
|
+
}).join('');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function safeExpression(node) {
|
|
50
|
+
return node?.type === 'Identifier' || node?.type === 'MemberExpression' && !node.computed && safeExpression(node.object) && node.property.type === 'Identifier';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function containsJsx(node) {
|
|
54
|
+
if (!node || typeof node !== 'object') return false;
|
|
55
|
+
if (node.type === 'JSXElement' || node.type === 'JSXFragment') return true;
|
|
56
|
+
for (const [key, value] of Object.entries(node)) {
|
|
57
|
+
if (key === 'loc' || key.endsWith('Comments')) continue;
|
|
58
|
+
if (Array.isArray(value) ? value.some(containsJsx) : value && typeof value.type === 'string' && containsJsx(value)) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// An expression inside a sentence that renders a value, not markup: it becomes a
|
|
64
|
+
// placeholder so the sentence stays whole. Anything that renders elements ends the sentence.
|
|
65
|
+
function valueExpression(node) {
|
|
66
|
+
return node && node.type !== 'JSXEmptyExpression' && node.type !== 'StringLiteral' && !containsJsx(node);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const formatting = /^(?:to(?:Lower|Upper|Locale(?:Lower|Upper)?)Case|toString|toFixed|toPrecision|toLocaleString|trim\w*|padStart|padEnd|join|format)$/;
|
|
70
|
+
|
|
71
|
+
// A placeholder name a translator can understand, from the expression that fills it:
|
|
72
|
+
// item.name → name, formatLength(kerf) → kerf, LABELS[item.kind].toLowerCase() → kind,
|
|
73
|
+
// rows.length → rowsCount.
|
|
74
|
+
function placeholderName(node) {
|
|
75
|
+
switch (node?.type) {
|
|
76
|
+
case 'Identifier': return node.name;
|
|
77
|
+
case 'MemberExpression': case 'OptionalMemberExpression':
|
|
78
|
+
// LABELS[item.kind] → kind; parts.split(" ")[0] → parts.
|
|
79
|
+
if (node.computed) return placeholderName(node.property) || placeholderName(node.object);
|
|
80
|
+
if (node.property.name === 'length' && placeholderName(node.object)) return `${placeholderName(node.object)}Count`;
|
|
81
|
+
return node.property.name;
|
|
82
|
+
case 'CallExpression': case 'OptionalCallExpression': {
|
|
83
|
+
const callee = node.callee;
|
|
84
|
+
if (callee.type?.endsWith('MemberExpression') && !callee.computed && (formatting.test(callee.property.name) || /^(?:split|slice|substring|replace\w*|at|get|find)$/.test(callee.property.name))) return placeholderName(callee.object);
|
|
85
|
+
if (node.arguments.length && placeholderName(node.arguments[0])) return placeholderName(node.arguments[0]);
|
|
86
|
+
return callee.type?.endsWith('MemberExpression') ? callee.property.name : placeholderName(callee);
|
|
87
|
+
}
|
|
88
|
+
case 'TSNonNullExpression': case 'TSAsExpression': case 'ParenthesizedExpression': return placeholderName(node.expression);
|
|
89
|
+
case 'LogicalExpression': return placeholderName(node.left);
|
|
90
|
+
case 'ConditionalExpression': return placeholderName(['StringLiteral', 'TemplateLiteral'].includes(node.consequent.type) ? node.test : node.consequent);
|
|
91
|
+
case 'BinaryExpression': return placeholderName(node.left);
|
|
92
|
+
case 'UnaryExpression': return placeholderName(node.argument);
|
|
93
|
+
default: return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Spaces and list separators (" · ", " | ", " — ") at a message's edges are layout,
|
|
98
|
+
// not wording: they stay in the code so translations need not reproduce them.
|
|
99
|
+
function splitEdges(text) {
|
|
100
|
+
const [, lead, body, trail] = /^(\s*(?:[·•|—–-]\s+)?)([\s\S]*?)((?:\s+[·•|—–-])?\s*)$/.exec(text);
|
|
101
|
+
return [lead, body, trail];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Concatenation is text only when a string takes part; {index + 1} is arithmetic.
|
|
105
|
+
const concatenatesText = node => node.type === 'BinaryExpression' && node.operator === '+' && [node.left, node.right].some(n => n.type === 'StringLiteral' || n.type === 'TemplateLiteral' || concatenatesText(n));
|
|
106
|
+
const textChoice = node => node.type === 'ConditionalExpression' && [node.consequent, node.alternate].some(n => n.type === 'StringLiteral' || n.type === 'TemplateLiteral') || node.type === 'TemplateLiteral' || concatenatesText(node);
|
|
107
|
+
|
|
108
|
+
export function extractSource(source, options = {}) {
|
|
109
|
+
const { filename = 'Component.tsx', locale = 'en', importPath = './i18n', languageExpression, existing, namespace = '' } = options;
|
|
110
|
+
// Without a language expression, calls use the current language (setLanguage).
|
|
111
|
+
if (languageExpression !== undefined) {
|
|
112
|
+
const languageNode = parseExpression(languageExpression, { plugins: sourcePlugins });
|
|
113
|
+
if (!safeExpression(languageNode) && languageNode.type !== 'StringLiteral') throw new Error('The language expression must be a string, variable, or property access.');
|
|
114
|
+
}
|
|
115
|
+
const callee = `i18nmd${languageExpression === undefined ? '' : `.in(${languageExpression})`}${accessorFor(namespace)}`;
|
|
116
|
+
const ast = parse(source, { sourceType: 'unambiguous', plugins: sourcePlugins });
|
|
117
|
+
const catalog = existing ? structuredClone(existing) : { title: 'Application strings', source: locale, syntax: 'icu', languages: { [locale]: languageName(locale) }, messages: [] };
|
|
118
|
+
if (catalog.syntax !== 'icu' || catalog.source !== locale) throw new Error('JSX extraction needs an ICU catalog with the matching source language.');
|
|
119
|
+
const byText = new Map(catalog.messages.map(m => [m.translations[locale], m]));
|
|
120
|
+
const byKey = new Map(catalog.messages.map(m => [m.key, m]));
|
|
121
|
+
const edits = [], diagnostics = [];
|
|
122
|
+
let needsImport = false;
|
|
123
|
+
const consumed = new WeakSet();
|
|
124
|
+
const covered = node => consumed.has(node);
|
|
125
|
+
const callFor = (text, params, node, explicit, where) => {
|
|
126
|
+
// /* i18n:ui.save */ in code is ## save in the ui division's file.
|
|
127
|
+
if (namespace && explicit?.startsWith(namespace)) explicit = explicit.slice(namespace.length);
|
|
128
|
+
let message = explicit ? byKey.get(explicit) : byText.get(text);
|
|
129
|
+
if (message && message.translations[locale] !== text) throw new Error(`Token ${message.key} already has different source text.`);
|
|
130
|
+
if (!message) {
|
|
131
|
+
const key = explicit || tokenFor(text, byKey);
|
|
132
|
+
if (byKey.has(key)) throw new Error(`Token collision for ${key}. Set an explicit token with /* i18n:token_name */.`);
|
|
133
|
+
message = { key, context: `${where} (${filename}:${node.loc.start.line})`, optional: [], translations: { [locale]: text } };
|
|
134
|
+
catalog.messages.push(message); byText.set(text, message); byKey.set(key, message);
|
|
135
|
+
}
|
|
136
|
+
needsImport = true;
|
|
137
|
+
for (const [name] of params) if (countLike.test(name) && !/\{\s*\w+\s*,\s*(plural|number|selectordinal)/.test(text)) diagnostics.push(`${filename}:${node.loc.start.line}: {${name}} looks like a count; consider {${name}, plural, one {…} other {…}} in ${message.key}.`);
|
|
138
|
+
const values = params.length ? `, { ${params.map(([name, value]) => `${name}: ${value}`).join(', ')} }` : '';
|
|
139
|
+
return `${callee}(${JSON.stringify(message.key)}${values})`;
|
|
140
|
+
};
|
|
141
|
+
// A template literal as a message: each ${…} becomes a named placeholder.
|
|
142
|
+
const templateMessage = node => {
|
|
143
|
+
const params = [];
|
|
144
|
+
const text = node.quasis.map((q, i) => {
|
|
145
|
+
if (q.value.cooked == null) throw new Error(`${filename}:${node.loc.start.line}: invalid template escape.`);
|
|
146
|
+
let part = escapeMessageLiteral(q.value.cooked);
|
|
147
|
+
if (i < node.expressions.length) {
|
|
148
|
+
const expression = node.expressions[i];
|
|
149
|
+
if (containsJsx(expression)) throw new Error(`${filename}:${node.loc.start.line}: a template placeholder cannot render elements.`);
|
|
150
|
+
let name = /^[a-zA-Z_]\w*$/.test(placeholderName(expression) || '') ? placeholderName(expression) : 'value';
|
|
151
|
+
while (params.some(p => p[0] === name)) name += '_';
|
|
152
|
+
params.push([name, source.slice(expression.start, expression.end)]);
|
|
153
|
+
part += `{${name}}`;
|
|
154
|
+
}
|
|
155
|
+
return part;
|
|
156
|
+
}).join('');
|
|
157
|
+
return { text, params };
|
|
158
|
+
};
|
|
159
|
+
// Wording chosen in code, {busy ? "Saving…" : "Save"}, becomes one message per
|
|
160
|
+
// branch; the condition stays in code. Returns false for text it cannot convert.
|
|
161
|
+
const convertText = (node, where) => {
|
|
162
|
+
switch (node.type) {
|
|
163
|
+
case 'StringLiteral': case 'TemplateLiteral': {
|
|
164
|
+
const literal = node.type === 'StringLiteral' ? node.value : node.quasis.map(q => q.value.cooked ?? '').join('');
|
|
165
|
+
if (!/\p{L}/u.test(literal)) return true;
|
|
166
|
+
let { text, params } = node.type === 'StringLiteral' ? { text: escapeMessageLiteral(node.value), params: [] } : templateMessage(node);
|
|
167
|
+
const [lead, body, trail] = splitEdges(text);
|
|
168
|
+
const call = callFor(body, params, node, undefined, where);
|
|
169
|
+
edits.push({ start: node.start, end: node.end, text: lead || trail ? `\`${lead}\${${call}}${trail}\`` : call });
|
|
170
|
+
consumed.add(node);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
case 'ConditionalExpression': return [convertText(node.consequent, where), convertText(node.alternate, where)].every(Boolean);
|
|
174
|
+
case 'LogicalExpression': return node.operator === '&&' ? convertText(node.right, where) : [convertText(node.left, where), convertText(node.right, where)].every(Boolean);
|
|
175
|
+
case 'TSAsExpression': case 'ParenthesizedExpression': return convertText(node.expression, where);
|
|
176
|
+
case 'BinaryExpression': return !concatenatesText(node);
|
|
177
|
+
default: return true;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
function walk(node, parent) {
|
|
181
|
+
if (!node || typeof node.type !== 'string' || covered(node)) return;
|
|
182
|
+
if (node.type === 'JSXElement' || node.type === 'JSXFragment') {
|
|
183
|
+
const children = node.children;
|
|
184
|
+
for (let i = 0; i < children.length;) {
|
|
185
|
+
const start = i;
|
|
186
|
+
let text = '', literalText = '', params = [], used = new Map();
|
|
187
|
+
const unique = base => { let name = /^[a-zA-Z_]\w*$/.test(base || '') && base !== '__proto__' ? base : 'value'; while (params.some(p => p[0] === name)) name += '_'; return name; };
|
|
188
|
+
const choices = [];
|
|
189
|
+
const piece = child => {
|
|
190
|
+
if (child.type === 'JSXText') { const value = jsxText(child.value); literalText += value; return escapeMessageLiteral(value); }
|
|
191
|
+
const expr = child.expression;
|
|
192
|
+
if (expr.type === 'StringLiteral') { literalText += expr.value; return escapeMessageLiteral(expr.value); }
|
|
193
|
+
const value = source.slice(expr.start, expr.end);
|
|
194
|
+
let name = used.get(value);
|
|
195
|
+
if (!name) {
|
|
196
|
+
name = unique(placeholderName(expr)); used.set(value, name); params.push([name, value]);
|
|
197
|
+
if (textChoice(expr)) choices.push(`${filename}:${expr.loc.start.line}: {${name}} is text chosen in code; move the wording into the message as {${name}, select, …} or a plural.`);
|
|
198
|
+
}
|
|
199
|
+
return `{${name}}`;
|
|
200
|
+
};
|
|
201
|
+
let outside = '';
|
|
202
|
+
while (i < children.length) {
|
|
203
|
+
const child = children[i];
|
|
204
|
+
if (child.type === 'JSXText' || child.type === 'JSXExpressionContainer' && (child.expression.type === 'StringLiteral' || valueExpression(child.expression))) {
|
|
205
|
+
const before = literalText.length; text += piece(child); outside += literalText.slice(before); i++; continue;
|
|
206
|
+
}
|
|
207
|
+
if (inlineElement(child)) {
|
|
208
|
+
const opening = child.openingElement, tag = unique(opening.name.name.toLowerCase().replace(/[^a-z0-9]/g, '') || 'tag');
|
|
209
|
+
const inner = child.children.map(piece).join('');
|
|
210
|
+
const open = source.slice(opening.start, opening.name.end) + (opening.attributes.some(a => a.name.name === 'key') ? '' : ` key=${JSON.stringify(tag)}`) + source.slice(opening.name.end, opening.end);
|
|
211
|
+
params.push([tag, `chunks => ${open}{chunks}${source.slice(child.closingElement.start, child.closingElement.end)}`]);
|
|
212
|
+
text += `<${tag}>${inner}</${tag}>`; i++; continue;
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
// Text with no letters (numbers, symbols, punctuation between values) is not translated.
|
|
217
|
+
if (i > start && /\p{L}/u.test(outside)) {
|
|
218
|
+
const first = children[start], last = children[i - 1];
|
|
219
|
+
// Spaces at the edges separate the message from neighbouring elements; they stay in the code.
|
|
220
|
+
const [lead, body, trail] = splitEdges(text);
|
|
221
|
+
const space = ws => ws ? `{${JSON.stringify(ws)}}` : '';
|
|
222
|
+
edits.push({ start: first.start, end: last.end, text: `${space(lead)}{${callFor(body, params, first, undefined, `Text in ${describe(node)}`)}}${space(trail)}` });
|
|
223
|
+
diagnostics.push(...choices);
|
|
224
|
+
for (let index = start; index < i; index++) consumed.add(children[index]);
|
|
225
|
+
}
|
|
226
|
+
// Wording chosen in code with nothing around it: each branch becomes its own message.
|
|
227
|
+
else if (choices.length) {
|
|
228
|
+
for (let index = start; index < i; index++) {
|
|
229
|
+
const child = children[index];
|
|
230
|
+
if (child.type !== 'JSXExpressionContainer' || !textChoice(child.expression)) continue;
|
|
231
|
+
if (!convertText(child.expression, `Text in ${describe(node)}`)) diagnostics.push(`${filename}:${child.loc.start.line}: dynamic JSX text needs an explicit /* i18n */ message.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (i === start) {
|
|
235
|
+
const child = children[i];
|
|
236
|
+
if (child.type === 'JSXExpressionContainer' && child.expression.type !== 'JSXEmptyExpression' && !safeExpression(child.expression) && !['JSXElement', 'JSXFragment'].includes(child.expression.type)) {
|
|
237
|
+
// Existing translation calls and structural expressions need no conversion.
|
|
238
|
+
if (child.expression.type === 'TemplateLiteral' || child.expression.type === 'BinaryExpression') diagnostics.push(`${filename}:${child.loc.start.line}: dynamic JSX text needs an explicit /* i18n */ message.`);
|
|
239
|
+
}
|
|
240
|
+
i++;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (node.type === 'JSXAttribute' && attributes.has(node.name?.name) && node.value?.type === 'StringLiteral' && /\p{L}/u.test(node.value.value)) {
|
|
245
|
+
edits.push({ start: node.value.start, end: node.value.end, text: `{${callFor(escapeMessageLiteral(node.value.value), [], node, undefined, `${node.name.name} attribute of ${describe(parent)}`)}}` });
|
|
246
|
+
consumed.add(node.value);
|
|
247
|
+
}
|
|
248
|
+
const comment = node.leadingComments?.find(c => /^\s*i18n(?:\s|:|$)/.test(c.value));
|
|
249
|
+
if (comment && ['StringLiteral', 'TemplateLiteral'].includes(node.type) && parent?.type !== 'JSXAttribute' && parent?.type !== 'JSXExpressionContainer') {
|
|
250
|
+
const { text, params } = node.type === 'StringLiteral' ? { text: escapeMessageLiteral(node.value), params: [] } : templateMessage(node);
|
|
251
|
+
const explicit = /i18n:\s*([\w.-]+)/.exec(comment.value)?.[1];
|
|
252
|
+
edits.push({ start: node.start, end: node.end, text: callFor(text, params, node, explicit, 'Annotated string') });
|
|
253
|
+
}
|
|
254
|
+
for (const [key, value] of Object.entries(node)) {
|
|
255
|
+
if (['loc', 'leadingComments', 'trailingComments', 'innerComments', 'comments', 'tokens'].includes(key)) continue;
|
|
256
|
+
if (Array.isArray(value)) for (const child of value) walk(child, node);
|
|
257
|
+
else if (value && typeof value === 'object') walk(value, node);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
walk(ast);
|
|
261
|
+
const pieces = [];
|
|
262
|
+
let cursor = 0;
|
|
263
|
+
edits.sort((a, b) => a.start - b.start);
|
|
264
|
+
for (const edit of edits) {
|
|
265
|
+
pieces.push(source.slice(cursor, edit.start), edit.text);
|
|
266
|
+
cursor = edit.end;
|
|
267
|
+
}
|
|
268
|
+
pieces.push(source.slice(cursor));
|
|
269
|
+
let rewritten = pieces.join('');
|
|
270
|
+
if (needsImport) {
|
|
271
|
+
// i18nmd may already come from another division's module beside ours; this
|
|
272
|
+
// division's messages then need a plain import of its module.
|
|
273
|
+
const sibling = source => source.replace(/[^/]*$/, '') === importPath.replace(/[^/]*$/, '');
|
|
274
|
+
let hasImport = false, otherDivision = false;
|
|
275
|
+
const imported = new Set(ast.program.body.filter(node => node.type === 'ImportDeclaration').map(node => node.source.value));
|
|
276
|
+
for (const node of ast.program.body) {
|
|
277
|
+
if (node.type === 'ImportDeclaration') for (const specifier of node.specifiers) {
|
|
278
|
+
if (specifier.local.name !== 'i18nmd') continue;
|
|
279
|
+
if (specifier.type !== 'ImportSpecifier' || specifier.imported.name !== 'i18nmd' || (node.source.value !== importPath && !sibling(node.source.value))) throw new Error('i18nmd is already imported from a different module.');
|
|
280
|
+
hasImport = true;
|
|
281
|
+
otherDivision = node.source.value !== importPath;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (otherDivision && !imported.has(importPath)) {
|
|
285
|
+
const last = ast.program.body.filter(node => node.type === 'ImportDeclaration').at(-1);
|
|
286
|
+
rewritten = rewritten.slice(0, last.end) + `\nimport ${JSON.stringify(importPath)};` + rewritten.slice(last.end);
|
|
287
|
+
}
|
|
288
|
+
// Refuse a local binding collision instead of silently changing its meaning.
|
|
289
|
+
function checkBindings(node) {
|
|
290
|
+
if (!node || typeof node !== 'object') return;
|
|
291
|
+
const declarations = node.type === 'VariableDeclarator' ? [node.id] : /Function/.test(node.type || '') ? [node.id, ...(node.params || [])] : node.type === 'ClassDeclaration' ? [node.id] : [];
|
|
292
|
+
const containsName = value => value && (value.type === 'Identifier' ? value.name === 'i18nmd' : Object.values(value).some(v => Array.isArray(v) ? v.some(containsName) : v && typeof v === 'object' && containsName(v)));
|
|
293
|
+
if (declarations.some(containsName)) throw new Error('A local i18nmd binding would shadow the generated translation import. Rename it first.');
|
|
294
|
+
for (const [key, value] of Object.entries(node)) if (!key.includes('Comments') && key !== 'loc') {
|
|
295
|
+
if (Array.isArray(value)) value.forEach(checkBindings); else if (value && typeof value === 'object') checkBindings(value);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
checkBindings(ast.program);
|
|
299
|
+
if (!hasImport) {
|
|
300
|
+
const position = ast.program.directives.at(-1)?.end ?? ast.program.interpreter?.end ?? 0;
|
|
301
|
+
rewritten = rewritten.slice(0, position) + `${position ? '\n' : ''}import { i18nmd } from ${JSON.stringify(importPath)};\n` + rewritten.slice(position);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Parse the result too; transformations must always produce valid TSX.
|
|
305
|
+
parse(rewritten, { sourceType: 'unambiguous', plugins: sourcePlugins });
|
|
306
|
+
return { source: rewritten, catalog, diagnostics, replacements: edits.length };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The divisions a file's i18nmd calls use but whose generated modules it does not
|
|
311
|
+
* import. accessors maps each division's property chain (".knowledgeHub.faq") to
|
|
312
|
+
* its module name ("knowledge-hub.faq"); namespaces maps token prefixes
|
|
313
|
+
* ("knowledge-hub.faq.") to the same. fix: the source with the missing imports
|
|
314
|
+
* added. used: the full tokens the file calls by name; dynamic: calls whose token
|
|
315
|
+
* is computed, so used may be incomplete.
|
|
316
|
+
*/
|
|
317
|
+
export function missingDivisionImports(source, { accessors, namespaces, filename = 'file.tsx' }) {
|
|
318
|
+
let ast;
|
|
319
|
+
try { ast = parse(source, { sourceType: 'unambiguous', plugins: sourcePlugins }); }
|
|
320
|
+
catch (error) { throw new Error(`${filename}: ${error.message}`); }
|
|
321
|
+
const imports = ast.program.body.filter(node => node.type === 'ImportDeclaration');
|
|
322
|
+
const from = imports.find(node => node.specifiers.some(s => s.local.name === 'i18nmd'));
|
|
323
|
+
const dir = from ? from.source.value.replace(/[^/]*$/, '') : '';
|
|
324
|
+
const present = new Set(imports.filter(node => dir && node.source.value.startsWith(dir)).map(node => node.source.value.slice(dir.length).replace(/\.(?:[cm]?[jt]s|tsx?)$/, '')));
|
|
325
|
+
const needed = new Map(), used = new Set();
|
|
326
|
+
let dynamic = 0;
|
|
327
|
+
const prefixOf = Object.fromEntries(Object.entries(namespaces).map(([prefix, file]) => [file, prefix]));
|
|
328
|
+
const visit = node => {
|
|
329
|
+
if (!node || typeof node.type !== 'string') return;
|
|
330
|
+
if (node.type === 'CallExpression') {
|
|
331
|
+
// i18nmd.a.b(…) or i18nmd.in(x).a.b(…): the chain after i18nmd (and .in(x)).
|
|
332
|
+
const chain = [];
|
|
333
|
+
let callee = node.callee;
|
|
334
|
+
while (callee?.type === 'MemberExpression' && !callee.computed) { chain.unshift(callee.property.name); callee = callee.object; }
|
|
335
|
+
if (callee?.type === 'CallExpression' && callee.callee.type === 'MemberExpression' && callee.callee.property.name === 'in') callee = callee.callee.object;
|
|
336
|
+
// i18nmd.in(locale) itself picks a language; the call on its result translates.
|
|
337
|
+
if (callee?.type === 'Identifier' && callee.name === 'i18nmd' && !(chain.length === 1 && chain[0] === 'in')) {
|
|
338
|
+
let division, prefix = '';
|
|
339
|
+
const first = node.arguments[0];
|
|
340
|
+
if (chain.length && chain[0] !== 'in') {
|
|
341
|
+
// The chain names the whole division only when every property is one.
|
|
342
|
+
division = accessors['.' + chain.join('.')];
|
|
343
|
+
prefix = division ? prefixOf[division] : '';
|
|
344
|
+
for (let n = chain.length - 1; n > 0 && !division; n--) division = accessors['.' + chain.slice(0, n).join('.')];
|
|
345
|
+
} else if (!chain.length && first?.type === 'StringLiteral') {
|
|
346
|
+
division = Object.keys(namespaces).filter(p => first.value.startsWith(p)).sort((a, b) => b.length - a.length).map(p => namespaces[p])[0];
|
|
347
|
+
}
|
|
348
|
+
if (first?.type === 'StringLiteral') used.add(prefix + first.value); else if (first) dynamic++;
|
|
349
|
+
if (division && !needed.has(division)) needed.set(division, node.loc.start.line);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const [key, value] of Object.entries(node)) {
|
|
353
|
+
if (key === 'loc' || key.endsWith('Comments')) continue;
|
|
354
|
+
if (Array.isArray(value)) value.forEach(visit); else if (value && typeof value.type === 'string') visit(value);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
visit(ast.program);
|
|
358
|
+
const missing = !from ? [] : [...needed].filter(([division]) => !present.has(division)).map(([division, line]) => ({ division, line, statement: `import ${JSON.stringify(dir + division)};` }));
|
|
359
|
+
const last = imports.at(-1);
|
|
360
|
+
const fix = missing.length ? source.slice(0, last.end) + missing.map(m => `\n${m.statement}`).join('') + source.slice(last.end) : source;
|
|
361
|
+
return { missing, fix, used, dynamic };
|
|
362
|
+
}
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { parseCatalog, serializeCatalog, validateCatalog, parseLanguageFiles, languageFromFilename, divisionOf, namespaceFor, renameToken } from './catalog.mjs';
|
|
2
|
+
export { compileCatalog, generateModule } from './compiler.mjs';
|
|
3
|
+
export { extractSource } from './extractor.mjs';
|
|
4
|
+
export { createI18n } from './runtime.mjs';
|
|
5
|
+
export { report, syncLock, readLock, serializeLock } from './lock.mjs';
|
|
6
|
+
export { llmConfig, translateLanguage } from './llm.mjs';
|
|
7
|
+
export { resolveLanguage, topLanguages, RANKED } from './languages.mjs';
|
|
8
|
+
export { importMessages, exportMessages } from './interop.mjs';
|
package/lib/interop.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Convert between i18nmd catalogs and the JSON message files other libraries use.
|
|
2
|
+
// formatjs – react-intl / FormatJS: flat {id: "ICU"} or {id: {defaultMessage, description}}
|
|
3
|
+
// next-intl – nested objects of ICU strings
|
|
4
|
+
// i18next – nested objects, {{placeholders}}, plural suffixes such as key_one / key_other
|
|
5
|
+
import { escapeMessageLiteral, parseMessage } from './messages.mjs';
|
|
6
|
+
|
|
7
|
+
export const FORMATS = ['formatjs', 'next-intl', 'i18next'];
|
|
8
|
+
const PLURAL = ['zero', 'one', 'two', 'few', 'many', 'other'];
|
|
9
|
+
const TOKEN = /^[a-zA-Z_]\w*(?:[.-]\w+)*$/;
|
|
10
|
+
|
|
11
|
+
function flatten(object, prefix = '', out = {}) {
|
|
12
|
+
for (const [key, value] of Object.entries(object)) {
|
|
13
|
+
const name = prefix ? `${prefix}.${key}` : key;
|
|
14
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && !('defaultMessage' in value)) flatten(value, name, out);
|
|
15
|
+
else out[name] = value;
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function nest(flat) {
|
|
21
|
+
const root = {};
|
|
22
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
23
|
+
const parts = key.split('.');
|
|
24
|
+
let node = root;
|
|
25
|
+
for (const part of parts.slice(0, -1)) {
|
|
26
|
+
if (typeof node[part] === 'string') throw new Error(`${key} conflicts with the message ${part}; use flat output.`);
|
|
27
|
+
node = node[part] ||= {};
|
|
28
|
+
}
|
|
29
|
+
node[parts.at(-1)] = value;
|
|
30
|
+
}
|
|
31
|
+
return root;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function checkToken(key) {
|
|
35
|
+
if (!TOKEN.test(key)) throw new Error(`"${key}" cannot be an i18nmd token. Tokens use letters, digits, _ . and -.`);
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// i18next "Hello {{name}}" → ICU "Hello {name}"; literal text is ICU-escaped.
|
|
40
|
+
function fromI18nextText(text, warnings, key, pound) {
|
|
41
|
+
let out = '', last = 0;
|
|
42
|
+
for (const match of text.matchAll(/\{\{\s*-?\s*([\w.]+)\s*(?:,[^}]*)?\}\}|\$t\(([^)]*)\)/g)) {
|
|
43
|
+
out += escapeMessageLiteral(text.slice(last, match.index));
|
|
44
|
+
if (match[2] !== undefined) { warnings.push(`${key}: nested $t(${match[2]}) is kept as text.`); out += escapeMessageLiteral(match[0]); }
|
|
45
|
+
else if (pound && match[1] === 'count') out += '#';
|
|
46
|
+
else { const name = match[1].replace(/\./g, '_'); if (name !== match[1]) warnings.push(`${key}: {{${match[1]}}} renamed {${name}}.`); out += `{${name}}`; }
|
|
47
|
+
last = match.index + match[0].length;
|
|
48
|
+
}
|
|
49
|
+
return out + escapeMessageLiteral(text.slice(last));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Read one locale's JSON into [{key, context, text}]. */
|
|
53
|
+
export function importMessages(json, format, warnings = []) {
|
|
54
|
+
if (!FORMATS.includes(format)) throw new Error(`--from must be one of ${FORMATS.join(', ')}.`);
|
|
55
|
+
const flat = flatten(json);
|
|
56
|
+
const messages = [];
|
|
57
|
+
if (format !== 'i18next') {
|
|
58
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
59
|
+
const text = typeof value === 'string' ? value : value?.defaultMessage;
|
|
60
|
+
if (typeof text !== 'string') { warnings.push(`${key}: not a message; skipped.`); continue; }
|
|
61
|
+
parseMessage(text);
|
|
62
|
+
messages.push({ key: checkToken(key), context: typeof value === 'object' && value.description ? String(value.description) : '', text });
|
|
63
|
+
}
|
|
64
|
+
return messages;
|
|
65
|
+
}
|
|
66
|
+
const groups = new Map();
|
|
67
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
68
|
+
if (typeof value !== 'string') { warnings.push(`${key}: not a string; skipped.`); continue; }
|
|
69
|
+
const plural = /^(.*)_(zero|one|two|few|many|other|plural)$/.exec(key);
|
|
70
|
+
const base = plural ? plural[1] : key;
|
|
71
|
+
if (!groups.has(base)) groups.set(base, {});
|
|
72
|
+
groups.get(base)[plural ? (plural[2] === 'plural' ? 'other' : plural[2]) : ''] = value;
|
|
73
|
+
}
|
|
74
|
+
for (const [key, forms] of groups) {
|
|
75
|
+
const categories = PLURAL.filter(c => c in forms);
|
|
76
|
+
let text;
|
|
77
|
+
if (!categories.length) text = fromI18nextText(forms[''], warnings, key);
|
|
78
|
+
else {
|
|
79
|
+
if (!('other' in forms)) forms.other = forms.one ?? forms[''] ?? forms[categories[0]];
|
|
80
|
+
// i18next v3 used key (singular) + key_plural; the bare key is then "one".
|
|
81
|
+
if ('' in forms && !('one' in forms)) forms.one = forms[''];
|
|
82
|
+
text = `{count, plural, ${PLURAL.filter(c => c in forms).map(c => `${c === 'zero' ? '=0' : c} {${fromI18nextText(forms[c], warnings, key, true)}}`).join(' ')}}`;
|
|
83
|
+
}
|
|
84
|
+
parseMessage(text);
|
|
85
|
+
messages.push({ key: checkToken(key), context: '', text });
|
|
86
|
+
}
|
|
87
|
+
return messages;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ICU nodes back to i18next text; returns undefined when i18next cannot express it.
|
|
91
|
+
function toI18nextText(nodes, pound) {
|
|
92
|
+
let out = '';
|
|
93
|
+
for (const n of nodes) {
|
|
94
|
+
if (typeof n === 'string') out += n;
|
|
95
|
+
else if (n.type === 'argument') out += `{{${n.name}}}`;
|
|
96
|
+
else if (n.type === 'pound' && pound) out += `{{${pound}}}`;
|
|
97
|
+
else if (n.type === 'tag') { const inner = toI18nextText(n.children, pound); if (inner === undefined) return; out += `<${n.name}>${inner}</${n.name}>`; }
|
|
98
|
+
else return;
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Turn one language of a catalog into the library's JSON object. */
|
|
104
|
+
export function exportMessages(catalog, locale, format, warnings = []) {
|
|
105
|
+
if (!FORMATS.includes(format)) throw new Error(`--to must be one of ${FORMATS.join(', ')}.`);
|
|
106
|
+
const flat = {};
|
|
107
|
+
for (const message of catalog.messages) {
|
|
108
|
+
const text = message.translations[locale];
|
|
109
|
+
if (text === undefined) continue;
|
|
110
|
+
if (format === 'formatjs') { flat[message.key] = text; continue; }
|
|
111
|
+
if (format === 'next-intl') { flat[message.key] = text; continue; }
|
|
112
|
+
const nodes = parseMessage(text, catalog.syntax);
|
|
113
|
+
const simple = toI18nextText(nodes);
|
|
114
|
+
if (simple !== undefined) { flat[message.key] = simple; continue; }
|
|
115
|
+
const [only] = nodes;
|
|
116
|
+
if (nodes.length === 1 && only.type === 'plural' && !only.offset) {
|
|
117
|
+
const forms = {};
|
|
118
|
+
for (const [category, branch] of Object.entries(only.options)) {
|
|
119
|
+
const suffix = category === '=0' ? 'zero' : PLURAL.includes(category) ? category : undefined;
|
|
120
|
+
const converted = suffix && toI18nextText(branch, only.name);
|
|
121
|
+
if (converted === undefined) { forms.failed = true; break; }
|
|
122
|
+
forms[suffix] = converted;
|
|
123
|
+
}
|
|
124
|
+
if (!forms.failed) {
|
|
125
|
+
if (only.name !== 'count') warnings.push(`${message.key}: i18next selects plurals by count; pass { count: ${only.name} }.`);
|
|
126
|
+
for (const [suffix, value] of Object.entries(forms)) flat[`${message.key}_${suffix}`] = value.replaceAll(`{{${only.name}}}`, '{{count}}');
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
warnings.push(`${message.key}: kept as ICU; load it with the i18next-icu plugin.`);
|
|
131
|
+
flat[message.key] = text;
|
|
132
|
+
}
|
|
133
|
+
return format === 'formatjs' ? flat : nest(flat);
|
|
134
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { languageName } from './catalog.mjs';
|
|
2
|
+
|
|
3
|
+
// Written interface languages ranked by total (first + second language) speakers,
|
|
4
|
+
// after Ethnologue estimates. Approximate; varieties that are rarely written in
|
|
5
|
+
// software interfaces are left out. Used by `i18nmd --top <n>`.
|
|
6
|
+
export const RANKED = [
|
|
7
|
+
'en', 'zh', 'hi', 'es', 'fr', 'ar', 'bn', 'pt', 'ru', 'ur',
|
|
8
|
+
'id', 'de', 'ja', 'pcm', 'mr', 'te', 'tr', 'ta', 'vi', 'fil',
|
|
9
|
+
'ko', 'fa', 'ha', 'sw', 'jv', 'it', 'pa', 'gu', 'th', 'kn',
|
|
10
|
+
'am', 'pl', 'yo', 'ml', 'or', 'my', 'uk', 'uz', 'ig', 'nl',
|
|
11
|
+
'ro', 'ne', 'si', 'az', 'ms', 'el', 'cs', 'hu', 'sv', 'zu',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const EXTRA = ['zh-Hans', 'zh-Hant', 'pt-BR', 'pt-PT', 'es-419', 'fr-CA', 'en-GB', 'yue', 'haw', 'nv', 'chr', 'tlh', 'mi', 'sm', 'to', 'qu', 'gn', 'ay', 'yi', 'eo', 'la'];
|
|
15
|
+
const fold = text => text.normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
16
|
+
|
|
17
|
+
let index;
|
|
18
|
+
function names() {
|
|
19
|
+
if (index) return index;
|
|
20
|
+
index = new Map();
|
|
21
|
+
const english = new Intl.DisplayNames(['en'], { type: 'language', fallback: 'none' });
|
|
22
|
+
const codes = new Set([...RANKED, ...EXTRA]);
|
|
23
|
+
for (let a = 97; a <= 122; a++) for (let b = 97; b <= 122; b++) codes.add(String.fromCharCode(a, b));
|
|
24
|
+
for (const code of codes) {
|
|
25
|
+
let name;
|
|
26
|
+
try { name = english.of(code); } catch { continue; }
|
|
27
|
+
if (!name || name === code) continue;
|
|
28
|
+
for (const key of [name, languageName(code)]) if (!index.has(fold(key))) index.set(fold(key), code);
|
|
29
|
+
}
|
|
30
|
+
// Common shorthand that Intl spells differently.
|
|
31
|
+
for (const [alias, code] of [['chinese', 'zh'], ['mandarin', 'zh'], ['cantonese', 'yue'], ['farsi', 'fa'], ['tagalog', 'fil'], ['filipino', 'fil'], ['brazilian portuguese', 'pt-BR'], ['latin american spanish', 'es-419'], ['nigerian pidgin', 'pcm'], ['hawaiian', 'haw'], ['navajo', 'nv'], ['klingon', 'tlh']]) index.set(alias, code);
|
|
32
|
+
return index;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve "french", "Français", "fr" or "pt-BR" to a language code and a name in
|
|
37
|
+
* that language. Anything unrecognized, such as "pirate", becomes a custom
|
|
38
|
+
* language whose code is its slug; the translator is asked to write in that style.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveLanguage(input) {
|
|
41
|
+
const raw = input.trim();
|
|
42
|
+
try {
|
|
43
|
+
const [canonical] = Intl.getCanonicalLocales(raw);
|
|
44
|
+
const name = new Intl.DisplayNames(['en'], { type: 'language', fallback: 'none' }).of(canonical);
|
|
45
|
+
if (name && name !== canonical && /^[a-z]{2,3}(?:-[A-Za-z0-9]+)*$/.test(raw)) return { code: canonical, name: languageName(canonical), english: name, custom: false };
|
|
46
|
+
} catch { /* Not a language tag; try names. */ }
|
|
47
|
+
const code = names().get(fold(raw));
|
|
48
|
+
if (code) return { code, name: languageName(code), english: new Intl.DisplayNames(['en'], { type: 'language' }).of(code), custom: false };
|
|
49
|
+
const slug = fold(raw).replace(/ /g, '-');
|
|
50
|
+
if (!/^[a-z][a-z0-9-]*$/.test(slug)) throw new Error(`Cannot name a language file for "${input}".`);
|
|
51
|
+
return { code: slug, name: raw[0].toUpperCase() + raw.slice(1), english: raw, custom: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function topLanguages(n) {
|
|
55
|
+
if (!Number.isInteger(n) || n < 1) throw new Error('--top needs a positive whole number.');
|
|
56
|
+
if (n > RANKED.length) throw new Error(`--top supports up to ${RANKED.length} languages.`);
|
|
57
|
+
return RANKED.slice(0, n);
|
|
58
|
+
}
|