@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/lib/html.mjs
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// Static HTML pages: extract marks each translatable element with
|
|
2
|
+
// data-i18n="token" and leaves the English in place; render writes one page per
|
|
3
|
+
// language from the Markdown files. English is authored in the HTML.
|
|
4
|
+
import { posix } from 'node:path';
|
|
5
|
+
import { parse } from 'parse5';
|
|
6
|
+
import { parse as parseScript } from '@babel/parser';
|
|
7
|
+
import { escapeMessageLiteral, parseMessage } from './messages.mjs';
|
|
8
|
+
import { languageName } from './catalog.mjs';
|
|
9
|
+
import { tokenFor } from './extractor.mjs';
|
|
10
|
+
|
|
11
|
+
// Elements that sit inside a sentence; they become ICU tags.
|
|
12
|
+
const INLINE = new Set(['a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'code', 'data', 'del', 'dfn', 'em', 'i', 'ins', 'kbd', 'mark', 'q', 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u', 'var']);
|
|
13
|
+
// Empty elements allowed at the start or end of a sentence, such as a logo before a name.
|
|
14
|
+
const VOID = new Set(['img', 'br', 'wbr', 'input', 'svg']);
|
|
15
|
+
const SKIP = new Set(['script', 'style', 'template', 'svg', 'math', 'noscript', 'pre', 'textarea', 'select', 'iframe', 'object', 'canvas']);
|
|
16
|
+
const ATTRIBUTES = ['alt', 'title', 'placeholder', 'aria-label', 'aria-description', 'label'];
|
|
17
|
+
const META = new Set(['description', 'og:title', 'og:description', 'og:image:alt', 'og:site_name', 'twitter:title', 'twitter:description', 'twitter:image:alt']);
|
|
18
|
+
const URL_ATTRIBUTES = ['href', 'src', 'srcset', 'action', 'poster', 'formaction'];
|
|
19
|
+
const WHITESPACE = /[ \t\n\r\f]+/g;
|
|
20
|
+
|
|
21
|
+
const attr = (node, name) => node.attrs?.find(a => a.name === name)?.value;
|
|
22
|
+
const isElement = node => !!node.tagName;
|
|
23
|
+
const isText = node => node.nodeName === '#text';
|
|
24
|
+
const letters = text => /\p{L}/u.test(text);
|
|
25
|
+
// Addresses read the same in every language: domains, emails and URLs.
|
|
26
|
+
const address = text => /^(?:[\w.+-]+@[\w-]+(?:\.[\w-]+)+|(?:https?:\/\/)?[\w-]+(?:\.[\w-]+)+(?:\/\S*)?)$/i.test(text.trim());
|
|
27
|
+
const translatable = text => letters(text) && !address(text);
|
|
28
|
+
const noTranslate = node => attr(node, 'translate') === 'no';
|
|
29
|
+
|
|
30
|
+
function describe(node) {
|
|
31
|
+
const tag = node.tagName;
|
|
32
|
+
const kind = /^h[1-6]$/.test(tag) ? 'Heading' : { p: 'Paragraph', li: 'List item', a: 'Link', button: 'Button', label: 'Form label', title: 'Page title', option: 'Choice in a list', caption: 'Table caption', figcaption: 'Figure caption', th: 'Table heading', td: 'Table cell', legend: 'Form section title', summary: 'Disclosure summary', dt: 'Term', dd: 'Definition' }[tag] || `<${tag}>`;
|
|
33
|
+
for (let up = node.parentNode; up; up = up.parentNode) if (isElement(up) && attr(up, 'id')) return `${kind} in #${attr(up, 'id')}`;
|
|
34
|
+
return kind;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A sentence: an element whose content is only text and inline elements, with
|
|
38
|
+
// any empty elements at its edges.
|
|
39
|
+
function sentence(node) {
|
|
40
|
+
if (!isElement(node) || SKIP.has(node.tagName) || noTranslate(node)) return null;
|
|
41
|
+
const children = node.childNodes.filter(c => c.nodeName !== '#comment');
|
|
42
|
+
let start = 0, end = children.length;
|
|
43
|
+
const blank = c => isText(c) && !c.value.trim();
|
|
44
|
+
const edge = c => blank(c) || (isElement(c) && VOID.has(c.tagName));
|
|
45
|
+
while (start < end && edge(children[start])) start++;
|
|
46
|
+
while (end > start && edge(children[end - 1])) end--;
|
|
47
|
+
const body = children.slice(start, end);
|
|
48
|
+
if (!body.length) return null;
|
|
49
|
+
const phrasing = c => isText(c) || c.nodeName === '#comment' || (isElement(c) && INLINE.has(c.tagName) && c.childNodes.every(phrasing));
|
|
50
|
+
if (!body.every(phrasing)) return null;
|
|
51
|
+
// Links side by side are separate items, not a sentence: a sentence has words of its own.
|
|
52
|
+
if (body.some(isElement) && !body.some(c => isText(c) && letters(c.value))) return null;
|
|
53
|
+
const text = body.map(function all(c) { return isText(c) ? c.value : (c.childNodes || []).map(all).join(''); }).join('');
|
|
54
|
+
return translatable(text) ? { body, lead: children.slice(0, start), trail: children.slice(end) } : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The message for a sentence, and the element each tag name stands for. Names
|
|
58
|
+
// come from the element (em, a, a_2) and are rebuilt the same way when rendering.
|
|
59
|
+
function messageFor(body) {
|
|
60
|
+
const tags = new Map(), counts = new Map();
|
|
61
|
+
const walk = nodes => nodes.map(node => {
|
|
62
|
+
if (isText(node)) return escapeMessageLiteral(node.value.replace(WHITESPACE, ' '));
|
|
63
|
+
if (!isElement(node)) return '';
|
|
64
|
+
const n = (counts.get(node.tagName) || 0) + 1;
|
|
65
|
+
counts.set(node.tagName, n);
|
|
66
|
+
const name = n === 1 ? node.tagName : `${node.tagName}_${n}`;
|
|
67
|
+
tags.set(name, node);
|
|
68
|
+
return `<${name}>${walk(node.childNodes)}</${name}>`;
|
|
69
|
+
}).join('');
|
|
70
|
+
return { text: walk(body).trim(), tags };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every translatable spot in a page: sentences, attributes and annotated script strings. */
|
|
74
|
+
function units(html) {
|
|
75
|
+
const document = parse(html, { sourceCodeLocationInfo: true });
|
|
76
|
+
const found = [], diagnostics = [];
|
|
77
|
+
const attributes = node => {
|
|
78
|
+
for (const name of ATTRIBUTES) if (translatable(attr(node, name) || '')) found.push({ kind: 'attribute', node, name, text: escapeMessageLiteral(attr(node, name).replace(WHITESPACE, ' ').trim()), context: `${name} text of ${describe(node)}` });
|
|
79
|
+
};
|
|
80
|
+
const visit = node => {
|
|
81
|
+
if (!isElement(node)) { (node.childNodes || []).forEach(visit); return; }
|
|
82
|
+
if (noTranslate(node)) return;
|
|
83
|
+
const meta = node.tagName === 'meta' && (attr(node, 'name') || attr(node, 'property'));
|
|
84
|
+
if (meta && META.has(meta) && translatable(attr(node, 'content') || '')) found.push({ kind: 'attribute', node, name: 'content', text: escapeMessageLiteral(attr(node, 'content')), context: `Page ${meta.replace(/^(og|twitter):/, '')} for search and link previews` });
|
|
85
|
+
attributes(node);
|
|
86
|
+
if (node.tagName === 'input' && ['submit', 'button', 'reset'].includes(attr(node, 'type')) && letters(attr(node, 'value') || '')) found.push({ kind: 'attribute', node, name: 'value', text: escapeMessageLiteral(attr(node, 'value')), context: `Button in ${describe(node)}` });
|
|
87
|
+
if (node.tagName === 'script' && !attr(node, 'src')) { scriptStrings(node, found, diagnostics, html); return; }
|
|
88
|
+
if (SKIP.has(node.tagName)) return;
|
|
89
|
+
const unit = sentence(node);
|
|
90
|
+
if (unit) {
|
|
91
|
+
const { text, tags } = messageFor(unit.body);
|
|
92
|
+
found.push({ kind: 'sentence', node, ...unit, text, tags, context: describe(node) });
|
|
93
|
+
// Elements inside a sentence keep their own attributes, such as an icon's alt text.
|
|
94
|
+
const inside = n => { for (const c of n.childNodes || []) if (isElement(c) && !noTranslate(c)) { attributes(c); inside(c); } };
|
|
95
|
+
inside(node);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const child of node.childNodes || []) {
|
|
99
|
+
if (isText(child) && translatable(child.value)) diagnostics.push(`line ${child.sourceCodeLocation?.startLine}: text beside other elements in <${node.tagName}> ("${child.value.trim().slice(0, 40)}"); wrap it in an element such as <span> to translate it.`);
|
|
100
|
+
else visit(child);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
visit(document);
|
|
104
|
+
return { document, found, diagnostics };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Script strings that read like words for people: a capitalised word and a space or
|
|
108
|
+
// sentence punctuation, unlike ids, selectors, URLs and JSON keys.
|
|
109
|
+
const shown = text => /^\s*[\p{Lu}]/u.test(text) && /[\p{L}][\s.…!?]|[.…!?]$/u.test(text.trim()) && !address(text) && !/^[\w-]+$/.test(text.trim());
|
|
110
|
+
|
|
111
|
+
// Strings in an inline script marked /* i18n */ or /* i18n:token */.
|
|
112
|
+
function scriptStrings(node, found, diagnostics, html) {
|
|
113
|
+
const text = node.childNodes[0];
|
|
114
|
+
if (!text?.sourceCodeLocation) return;
|
|
115
|
+
const offset = text.sourceCodeLocation.startOffset;
|
|
116
|
+
let ast;
|
|
117
|
+
try { ast = parseScript(text.value, { sourceType: 'unambiguous', errorRecovery: true }); } catch { return; }
|
|
118
|
+
const visit = n => {
|
|
119
|
+
if (!n || typeof n.type !== 'string') return;
|
|
120
|
+
const comment = n.leadingComments?.findLast(c => /^\s*i18n(?:\s*$|:)/.test(c.value));
|
|
121
|
+
if (comment && (n.type === 'StringLiteral' || (n.type === 'TemplateLiteral' && !n.expressions.length))) {
|
|
122
|
+
const value = n.type === 'StringLiteral' ? n.value : n.quasis[0].value.cooked;
|
|
123
|
+
found.push({ kind: 'script', start: offset + n.start, end: offset + n.end, commentStart: offset + comment.start, commentEnd: offset + comment.end, explicit: /i18n:\s*([\w.-]+)/.exec(comment.value)?.[1], text: escapeMessageLiteral(value), context: 'Text a script on the page shows' });
|
|
124
|
+
} else if (!comment && (n.type === 'StringLiteral' || n.type === 'TemplateLiteral') && shown(n.value ?? n.quasis.map(q => q.value.cooked).join(''))) {
|
|
125
|
+
diagnostics.push(`line ${node.sourceCodeLocation.startLine + (n.loc?.start.line || 1) - 1}: a script string looks like text people read ("${(n.value ?? n.quasis[0].value.cooked).slice(0, 40)}"); mark it /* i18n */ to translate it.`);
|
|
126
|
+
} else if (comment) diagnostics.push(`line ${node.sourceCodeLocation.startLine + (n.loc?.start.line || 1) - 1}: /* i18n */ marks a string that isn't plain; use a string without \${…}.`);
|
|
127
|
+
for (const [key, value] of Object.entries(n)) {
|
|
128
|
+
if (key === 'loc' || key.endsWith('Comments')) continue;
|
|
129
|
+
if (Array.isArray(value)) value.forEach(visit); else if (value && typeof value.type === 'string') visit(value);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
visit(ast.program);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const markerOf = unit => unit.kind === 'sentence' ? 'data-i18n' : unit.kind === 'attribute' ? `data-i18n-${unit.name}` : null;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Mark a page's translatable text with data-i18n attributes (and /* i18n:token *\/
|
|
139
|
+
* comments in scripts), adding new messages to the catalog. An element already
|
|
140
|
+
* marked keeps its token, and the page's English replaces the catalog's.
|
|
141
|
+
*/
|
|
142
|
+
export function extractHtml(html, { existing, locale = 'en', namespace = '', filename = 'index.html' } = {}) {
|
|
143
|
+
const catalog = existing ? structuredClone(existing) : { title: 'Site', source: locale, syntax: 'icu', languages: { [locale]: languageName(locale) }, messages: [] };
|
|
144
|
+
const byKey = new Map(catalog.messages.map(m => [m.key, m]));
|
|
145
|
+
const byText = new Map(catalog.messages.map(m => [m.translations[locale], m]));
|
|
146
|
+
const { found, diagnostics } = units(html);
|
|
147
|
+
const edits = [];
|
|
148
|
+
let marked = 0, updated = 0;
|
|
149
|
+
for (const unit of found) {
|
|
150
|
+
const marker = markerOf(unit);
|
|
151
|
+
const given = unit.kind === 'script' ? unit.explicit : attr(unit.node, marker);
|
|
152
|
+
const short = given?.startsWith(namespace) ? given.slice(namespace.length) : given;
|
|
153
|
+
let message = short ? byKey.get(short) : byText.get(unit.text);
|
|
154
|
+
if (message && short && message.translations[locale] !== unit.text) {
|
|
155
|
+
// The page is where the English is written: take its text.
|
|
156
|
+
if (byText.get(message.translations[locale]) === message) byText.delete(message.translations[locale]);
|
|
157
|
+
message.translations[locale] = unit.text; byText.set(unit.text, message); updated++;
|
|
158
|
+
}
|
|
159
|
+
if (!message) {
|
|
160
|
+
const key = short || tokenFor(unit.text, byKey);
|
|
161
|
+
message = { key, context: unit.context, optional: [], translations: { [locale]: unit.text } };
|
|
162
|
+
catalog.messages.push(message); byKey.set(key, message); byText.set(unit.text, message);
|
|
163
|
+
}
|
|
164
|
+
if (given) continue;
|
|
165
|
+
const token = namespace + message.key;
|
|
166
|
+
marked++;
|
|
167
|
+
if (unit.kind === 'script') edits.push({ start: unit.commentStart, end: unit.commentEnd, text: `/* i18n:${token} */` });
|
|
168
|
+
else {
|
|
169
|
+
const tag = unit.node.sourceCodeLocation.startTag;
|
|
170
|
+
const close = html[tag.endOffset - 2] === '/' ? tag.endOffset - 2 : tag.endOffset - 1;
|
|
171
|
+
edits.push({ start: close, end: close, text: ` ${marker}="${token}"` });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
let source = html;
|
|
175
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) source = source.slice(0, edit.start) + edit.text + source.slice(edit.end);
|
|
176
|
+
return { source, catalog, diagnostics: diagnostics.map(d => `${filename}:${d.replace(/^line /, '')}`), replacements: marked, updated };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const escapeHtml = text => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\u00a0/g, ' ');
|
|
180
|
+
const escapeAttribute = text => escapeHtml(text).replace(/"/g, '"');
|
|
181
|
+
// The text of a message without markup, for attributes and scripts.
|
|
182
|
+
const plain = nodes => nodes.map(n => typeof n === 'string' ? n : n.type === 'tag' ? plain(n.children) : `{${n.name}}`).join('');
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* One page in one language. messages maps tokens to this language's message
|
|
186
|
+
* text (missing ones keep the page's English). pages lists every language's URL
|
|
187
|
+
* for this page, for hreflang links and the language switcher. relative: the
|
|
188
|
+
* path from this copy's directory to the original's ("../" for a page moved into
|
|
189
|
+
* haw/), put before the page's relative links. absolute: pages' URLs are full
|
|
190
|
+
* URLs, so og:url and canonical links can name this copy.
|
|
191
|
+
*/
|
|
192
|
+
export function renderHtml(html, { language, source, messages = {}, pages = {}, languages = {}, relative = '', absolute = false } = {}) {
|
|
193
|
+
// Two passes, since a sentence's content holds tags whose attributes change too:
|
|
194
|
+
// first every tag and attribute edit, then each sentence rebuilt from those tags.
|
|
195
|
+
const first = renderAttributes(html, { language, source, messages, pages, languages, relative, absolute });
|
|
196
|
+
return renderSentences(first, { language, source, messages });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function translationFor(language, source, messages, token) {
|
|
200
|
+
if (language === source || !token || !Object.hasOwn(messages, token)) return null;
|
|
201
|
+
try { return parseMessage(messages[token]); } catch { return null; }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function applyEdits(html, edits) {
|
|
205
|
+
// From the end; insertions at one spot keep their order.
|
|
206
|
+
let out = html;
|
|
207
|
+
edits.map((edit, index) => ({ ...edit, index })).sort((a, b) => b.start - a.start || b.end - a.end || b.index - a.index)
|
|
208
|
+
.forEach(edit => { out = out.slice(0, edit.start) + edit.text + out.slice(edit.end); });
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function attributeRemover(html, edits) {
|
|
213
|
+
return (node, name) => {
|
|
214
|
+
const loc = node.sourceCodeLocation.attrs?.[name];
|
|
215
|
+
if (!loc) return;
|
|
216
|
+
let start = loc.startOffset;
|
|
217
|
+
while (start > 0 && /\s/.test(html[start - 1])) start--;
|
|
218
|
+
edits.push({ start, end: loc.endOffset, text: '' });
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderSentences(html, { language, source, messages }) {
|
|
223
|
+
const { found } = units(html);
|
|
224
|
+
const edits = [], removeAttribute = attributeRemover(html, edits);
|
|
225
|
+
for (const unit of found) {
|
|
226
|
+
if (unit.kind !== 'sentence') continue;
|
|
227
|
+
const token = attr(unit.node, 'data-i18n');
|
|
228
|
+
if (token === undefined) continue;
|
|
229
|
+
removeAttribute(unit.node, 'data-i18n');
|
|
230
|
+
const nodes = translationFor(language, source, messages, token);
|
|
231
|
+
if (!nodes) continue;
|
|
232
|
+
const outer = node => html.slice(node.sourceCodeLocation.startOffset, node.sourceCodeLocation.endOffset);
|
|
233
|
+
const inner = node => html.slice(node.sourceCodeLocation.startTag.endOffset, node.sourceCodeLocation.endTag?.startOffset ?? node.sourceCodeLocation.startTag.endOffset);
|
|
234
|
+
const write = list => list.map(n => {
|
|
235
|
+
if (typeof n === 'string') return escapeHtml(n);
|
|
236
|
+
if (n.type !== 'tag') return escapeHtml(`{${n.name}}`);
|
|
237
|
+
const element = unit.tags.get(n.name);
|
|
238
|
+
if (!element) return write(n.children);
|
|
239
|
+
const loc = element.sourceCodeLocation;
|
|
240
|
+
// translate="no" content stays as written.
|
|
241
|
+
return html.slice(loc.startOffset, loc.startTag.endOffset) + (noTranslate(element) ? inner(element) : write(n.children)) + (loc.endTag ? html.slice(loc.endTag.startOffset, loc.endTag.endOffset) : '');
|
|
242
|
+
}).join('');
|
|
243
|
+
// The sentence's own spacing at its edges, such as the space after a leading icon.
|
|
244
|
+
const body = unit.body.map(outer).join('');
|
|
245
|
+
const [, before, after] = /^([ \t\n\r\f]*)[\s\S]*?([ \t\n\r\f]*)$/.exec(body);
|
|
246
|
+
const loc = unit.node.sourceCodeLocation;
|
|
247
|
+
edits.push({ start: loc.startTag.endOffset, end: loc.endTag.startOffset, text: unit.lead.map(outer).join('') + before + write(nodes) + after + unit.trail.map(outer).join('') });
|
|
248
|
+
}
|
|
249
|
+
return applyEdits(html, edits);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function renderAttributes(html, { language, source, messages, pages, languages, relative, absolute }) {
|
|
253
|
+
const { document, found } = units(html);
|
|
254
|
+
const edits = [];
|
|
255
|
+
const replace = (start, end, text) => edits.push({ start, end, text });
|
|
256
|
+
const removeAttribute = attributeRemover(html, edits);
|
|
257
|
+
for (const unit of found) {
|
|
258
|
+
if (unit.kind === 'sentence') continue;
|
|
259
|
+
const marker = markerOf(unit);
|
|
260
|
+
const token = unit.kind === 'script' ? unit.explicit : attr(unit.node, marker);
|
|
261
|
+
const nodes = translationFor(language, source, messages, token);
|
|
262
|
+
if (unit.kind === 'script') { if (nodes) replace(unit.start, unit.end, JSON.stringify(plain(nodes))); continue; }
|
|
263
|
+
if (token) removeAttribute(unit.node, marker);
|
|
264
|
+
if (nodes) { const loc = unit.node.sourceCodeLocation.attrs[unit.name]; replace(loc.startOffset, loc.endOffset, `${unit.name}="${escapeAttribute(plain(nodes))}"`); }
|
|
265
|
+
}
|
|
266
|
+
const root = document.childNodes.find(n => n.tagName === 'html');
|
|
267
|
+
const head = root?.childNodes.find(n => n.tagName === 'head');
|
|
268
|
+
const all = function* (node) { yield node; for (const child of node.childNodes || []) yield* all(child); };
|
|
269
|
+
let direction = 'ltr';
|
|
270
|
+
try { direction = new Intl.Locale(language).getTextInfo?.().direction || new Intl.Locale(language).textInfo?.direction || 'ltr'; } catch { /* custom languages */ }
|
|
271
|
+
if (root?.sourceCodeLocation?.startTag) {
|
|
272
|
+
const tag = root.sourceCodeLocation.startTag;
|
|
273
|
+
for (const name of ['lang', 'dir']) removeAttribute(root, name);
|
|
274
|
+
replace(tag.endOffset - 1, tag.endOffset - 1, ` lang="${language}"${direction === 'rtl' ? ' dir="rtl"' : ''}`);
|
|
275
|
+
}
|
|
276
|
+
for (const node of all(document)) {
|
|
277
|
+
if (!isElement(node) || !node.sourceCodeLocation) continue;
|
|
278
|
+
// Pages in a language directory sit deeper: relative links go up to the same files.
|
|
279
|
+
if (relative) for (const name of URL_ATTRIBUTES) {
|
|
280
|
+
const value = attr(node, name);
|
|
281
|
+
if (value === undefined || node.sourceCodeLocation.attrs?.[name] === undefined) continue;
|
|
282
|
+
const fix = part => {
|
|
283
|
+
if (!part || /^(?:[a-z][a-z0-9+.-]*:|\/|#|\?)/i.test(part)) return part;
|
|
284
|
+
const [, file, rest] = /^([^?#]*)(.*)$/.exec(part);
|
|
285
|
+
return posix.normalize(relative + file) + rest;
|
|
286
|
+
};
|
|
287
|
+
const next = name === 'srcset' ? value.split(',').map(s => s.trim().replace(/^\S+/, fix)).join(', ') : fix(value);
|
|
288
|
+
if (next !== value) { const loc = node.sourceCodeLocation.attrs[name]; replace(loc.startOffset, loc.endOffset, `${name}="${escapeAttribute(next)}"`); }
|
|
289
|
+
}
|
|
290
|
+
// Links that name this page's own address point at this language's copy.
|
|
291
|
+
if (absolute && ((node.tagName === 'meta' && attr(node, 'property') === 'og:url') || (node.tagName === 'link' && attr(node, 'rel') === 'canonical'))) {
|
|
292
|
+
const name = node.tagName === 'meta' ? 'content' : 'href';
|
|
293
|
+
const loc = node.sourceCodeLocation.attrs[name];
|
|
294
|
+
if (loc) replace(loc.startOffset, loc.endOffset, `${name}="${escapeAttribute(pages[language])}"`);
|
|
295
|
+
}
|
|
296
|
+
if (attr(node, 'data-i18n-languages') !== undefined) {
|
|
297
|
+
removeAttribute(node, 'data-i18n-languages');
|
|
298
|
+
const loc = node.sourceCodeLocation;
|
|
299
|
+
const links = Object.keys(pages).map(code => `<a href="${escapeAttribute(pages[code])}" hreflang="${code}" lang="${code}"${code === language ? ' aria-current="page"' : ''}>${escapeHtml(languages[code] || code)}</a>`).join(' ');
|
|
300
|
+
if (loc.endTag) replace(loc.startTag.endOffset, loc.endTag.startOffset, links);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (head?.sourceCodeLocation?.endTag && Object.keys(pages).length > 1) {
|
|
304
|
+
const alternates = Object.keys(pages).map(code => `<link rel="alternate" hreflang="${code}" href="${escapeAttribute(pages[code])}">`);
|
|
305
|
+
alternates.push(`<link rel="alternate" hreflang="x-default" href="${escapeAttribute(pages[source])}">`);
|
|
306
|
+
replace(head.sourceCodeLocation.endTag.startOffset, head.sourceCodeLocation.endTag.startOffset, alternates.join('\n') + '\n');
|
|
307
|
+
}
|
|
308
|
+
return applyEdits(html, edits);
|
|
309
|
+
}
|
package/lib/interop.mjs
CHANGED
|
@@ -50,7 +50,7 @@ function fromI18nextText(text, warnings, key, pound) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/** Read one locale's JSON into [{key, context, text}]. */
|
|
53
|
-
export function importMessages(json, format, warnings = []) {
|
|
53
|
+
export function importMessages(json, format, warnings = [], formatters = []) {
|
|
54
54
|
if (!FORMATS.includes(format)) throw new Error(`--from must be one of ${FORMATS.join(', ')}.`);
|
|
55
55
|
const flat = flatten(json);
|
|
56
56
|
const messages = [];
|
|
@@ -58,7 +58,7 @@ export function importMessages(json, format, warnings = []) {
|
|
|
58
58
|
for (const [key, value] of Object.entries(flat)) {
|
|
59
59
|
const text = typeof value === 'string' ? value : value?.defaultMessage;
|
|
60
60
|
if (typeof text !== 'string') { warnings.push(`${key}: not a message; skipped.`); continue; }
|
|
61
|
-
parseMessage(text);
|
|
61
|
+
parseMessage(text, 'icu', formatters);
|
|
62
62
|
messages.push({ key: checkToken(key), context: typeof value === 'object' && value.description ? String(value.description) : '', text });
|
|
63
63
|
}
|
|
64
64
|
return messages;
|
|
@@ -81,7 +81,7 @@ export function importMessages(json, format, warnings = []) {
|
|
|
81
81
|
if ('' in forms && !('one' in forms)) forms.one = forms[''];
|
|
82
82
|
text = `{count, plural, ${PLURAL.filter(c => c in forms).map(c => `${c === 'zero' ? '=0' : c} {${fromI18nextText(forms[c], warnings, key, true)}}`).join(' ')}}`;
|
|
83
83
|
}
|
|
84
|
-
parseMessage(text);
|
|
84
|
+
parseMessage(text, 'icu', formatters);
|
|
85
85
|
messages.push({ key: checkToken(key), context: '', text });
|
|
86
86
|
}
|
|
87
87
|
return messages;
|
|
@@ -109,7 +109,7 @@ export function exportMessages(catalog, locale, format, warnings = []) {
|
|
|
109
109
|
if (text === undefined) continue;
|
|
110
110
|
if (format === 'formatjs') { flat[message.key] = text; continue; }
|
|
111
111
|
if (format === 'next-intl') { flat[message.key] = text; continue; }
|
|
112
|
-
const nodes = parseMessage(text, catalog.syntax);
|
|
112
|
+
const nodes = parseMessage(text, catalog.syntax, catalog.formatters);
|
|
113
113
|
const simple = toI18nextText(nodes);
|
|
114
114
|
if (simple !== undefined) { flat[message.key] = simple; continue; }
|
|
115
115
|
const [only] = nodes;
|
package/lib/llm.mjs
CHANGED
|
@@ -75,7 +75,7 @@ function systemPrompt(catalog, target) {
|
|
|
75
75
|
return [
|
|
76
76
|
`You localize software interface strings from ${source} (${catalog.source}) into ${target.english || target.name} (${target.code}).`,
|
|
77
77
|
target.custom ? `"${target.english}" is not a standard language. Write in that style or variety consistently, keeping the text understandable.` : 'Write natural, idiomatic text a native speaker would expect in a polished product, using the conventional register for software in that language.',
|
|
78
|
-
'Each message is ICU MessageFormat. Keep placeholder names such as {name}, tag names such as <b>…</b>, and the plural/select/selectordinal keywords and select branch names exactly as they are; translate only the human text, including the text inside branches and tags. For plurals, provide the CLDR plural categories the target language needs and always keep "other". Keep # inside plural branches. Write a literal apostrophe as \'\' only when it sits next to { or }. Preserve leading and trailing spaces.',
|
|
78
|
+
'Each message is ICU MessageFormat. Keep placeholder names such as {name}, placeholder types such as {len, length} (the app formats those), tag names such as <b>…</b>, and the plural/select/selectordinal keywords and select branch names exactly as they are; translate only the human text, including the text inside branches and tags. For plurals, provide the CLDR plural categories the target language needs and always keep "other". Keep # inside plural branches. Write a literal apostrophe as \'\' only when it sits next to { or }. Preserve leading and trailing spaces.',
|
|
79
79
|
'Use the Context line to choose the right meaning and length. Interface text should be concise. Keep terminology consistent with the existing translations you are given.',
|
|
80
80
|
'Reply with Markdown only, in exactly the input format: for each token, a "## token" heading followed by an ```icu fenced block. Include every token you were given, in the same order, and nothing else.',
|
|
81
81
|
].join('\n\n');
|
package/lib/lock.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// i18nmd.lock.json keeps bookkeeping out of the Markdown. For every translation
|
|
2
2
|
// it records a fingerprint of the source text it was written against and of the
|
|
3
|
-
// translation itself. A translation is stale when its
|
|
4
|
-
//
|
|
3
|
+
// translation itself: "source:translation". A translation is stale when its
|
|
4
|
+
// source text changed. Editing it accepts it, but only once the change has been
|
|
5
|
+
// seen: the first sync after the source changes adds ":seen", the new source's
|
|
6
|
+
// fingerprint, and the translation as it was then. An edit made before the
|
|
7
|
+
// source changed (a hand correction, say) can't vouch for the new text.
|
|
5
8
|
import { readFile, access } from 'node:fs/promises';
|
|
6
9
|
import path from 'node:path';
|
|
7
10
|
import { hashMessage, textOf } from './catalog.mjs';
|
|
@@ -40,7 +43,8 @@ export async function findLock(dir) {
|
|
|
40
43
|
export async function readLock(file) {
|
|
41
44
|
try {
|
|
42
45
|
const lock = JSON.parse(await readFile(file, 'utf8'));
|
|
43
|
-
|
|
46
|
+
if (lock.formatters !== undefined && !(Array.isArray(lock.formatters) && lock.formatters.every(name => typeof name === 'string'))) throw new Error('formatters must be a list of names.');
|
|
47
|
+
return { source: lock.source, ...(lock.formatters && { formatters: lock.formatters }), translations: lock.translations || {} };
|
|
44
48
|
} catch (error) {
|
|
45
49
|
if (error.code === 'ENOENT') return { source: undefined, translations: {} };
|
|
46
50
|
throw new Error(`${file}: ${error.message}`);
|
|
@@ -49,7 +53,7 @@ export async function readLock(file) {
|
|
|
49
53
|
|
|
50
54
|
export function serializeLock(lock) {
|
|
51
55
|
const sorted = Object.fromEntries(Object.keys(lock.translations).sort().map(locale => [locale, Object.fromEntries(Object.entries(lock.translations[locale]).sort(([a], [b]) => a.localeCompare(b)))]));
|
|
52
|
-
return JSON.stringify({ source: lock.source, translations: sorted }, null, 2) + '\n';
|
|
56
|
+
return JSON.stringify({ source: lock.source, ...(lock.formatters && { formatters: lock.formatters }), translations: sorted }, null, 2) + '\n';
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
const fingerprint = (message, catalog, locale) => `${hashMessage(message.translations[catalog.source])}:${hashMessage(textOf(message, locale))}`;
|
|
@@ -57,8 +61,11 @@ const fingerprint = (message, catalog, locale) => `${hashMessage(message.transla
|
|
|
57
61
|
function stale(message, catalog, locale, entry) {
|
|
58
62
|
if (!Object.hasOwn(message.translations, locale) && message.invalid?.[locale] !== undefined) return true;
|
|
59
63
|
if (!entry) return false;
|
|
60
|
-
const [source, translation] = entry.split(':');
|
|
61
|
-
|
|
64
|
+
const [source, translation, seen] = entry.split(':');
|
|
65
|
+
const current = hashMessage(message.translations[catalog.source]);
|
|
66
|
+
if (source === current) return false;
|
|
67
|
+
// Edited since the change was seen: the translator updated it for the new text.
|
|
68
|
+
return !(seen === current && translation !== hashMessage(textOf(message, locale)));
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
/** Per-language progress: tokens missing, stale, and done. */
|
|
@@ -91,8 +98,11 @@ export function syncLock(catalog, lock, { partial = false } = {}) {
|
|
|
91
98
|
if (textOf(message, locale) === undefined) continue;
|
|
92
99
|
const entry = previous[message.key];
|
|
93
100
|
if (stale(message, catalog, locale, entry)) {
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
// Note the source text and the translation as they are now, so a later
|
|
102
|
+
// edit counts as an update to this text.
|
|
103
|
+
const now = hashMessage(message.translations[catalog.source]);
|
|
104
|
+
entries[message.key] = entry ? `${entry.split(':')[0]}:${hashMessage(textOf(message, locale))}:${now}` : fingerprint(message, catalog, locale);
|
|
105
|
+
if (!message.invalid?.[locale] && entry?.split(':')[2] !== now) changes.push(`${locale}: ${message.key} is stale; its source text changed`);
|
|
96
106
|
continue;
|
|
97
107
|
}
|
|
98
108
|
const current = fingerprint(message, catalog, locale);
|
package/lib/messages.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// The compiler parses a documented ICU subset. No parsing happens in the app.
|
|
2
|
-
|
|
2
|
+
// formatters names the argument types the project declares ({len, length}),
|
|
3
|
+
// which the application formats itself.
|
|
4
|
+
export function parseMessage(text, syntax = 'icu', formatters = []) {
|
|
3
5
|
if (typeof text !== 'string') throw new Error('Messages must be strings.');
|
|
4
6
|
if (syntax === 'python') {
|
|
5
7
|
const nodes = [];
|
|
@@ -100,7 +102,9 @@ export function parseMessage(text, syntax = 'icu') {
|
|
|
100
102
|
if (type === 'number' && !['', 'integer', 'percent'].includes(style) && !/^::currency\/[A-Z]{3}$/.test(style)) fail(`Unsupported number style ${style}`);
|
|
101
103
|
if (type !== 'number' && !['', 'short', 'medium', 'long', 'full'].includes(style)) fail(`Unsupported ${type} style ${style}`);
|
|
102
104
|
expect('}'); nodes.push({ type, name, style });
|
|
103
|
-
} else
|
|
105
|
+
} else if (formatters.includes(type)) {
|
|
106
|
+
expect('}'); nodes.push({ type: 'format', name, format: type });
|
|
107
|
+
} else fail(`Unsupported message type ${type}${/^[a-zA-Z_]\w*$/.test(type) ? ` (declare app formatters in i18nmd.lock.json: "formatters": ["${type}"])` : ''}`);
|
|
104
108
|
}
|
|
105
109
|
flush(); return nodes;
|
|
106
110
|
}
|
|
@@ -115,9 +119,12 @@ export function argumentsFor(nodes, out = Object.create(null)) {
|
|
|
115
119
|
out[n.name] = 'tag'; argumentsFor(n.children, out); continue;
|
|
116
120
|
}
|
|
117
121
|
if (out[n.name] === 'tag') throw new Error(`{${n.name}} is also used as a tag.`);
|
|
118
|
-
const type = ['number', 'plural', 'selectordinal'].includes(n.type) ? 'number' : ['date', 'time'].includes(n.type) ? 'date' : n.type === 'select' ? 'select' : 'string';
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
const type = n.type === 'format' ? `format:${n.format}` : ['number', 'plural', 'selectordinal'].includes(n.type) ? 'number' : ['date', 'time'].includes(n.type) ? 'date' : n.type === 'select' ? 'select' : 'string';
|
|
123
|
+
const previous = out[n.name];
|
|
124
|
+
// A formatted value may also choose a plural branch: {len, length} … {len, plural, …}.
|
|
125
|
+
const formatted = previous?.startsWith('format:') && type === 'number';
|
|
126
|
+
if (!formatted && previous && previous !== type && previous !== 'string' && type !== 'string' && !(previous === 'number' && type.startsWith('format:'))) throw new Error(`Conflicting types for {${n.name}}.`);
|
|
127
|
+
if (!formatted && (!previous || type !== 'string')) out[n.name] = type;
|
|
121
128
|
if (n.options) for (const branch of Object.values(n.options)) argumentsFor(branch, out);
|
|
122
129
|
}
|
|
123
130
|
return out;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"cldr":"48.2.0","cardinal":{"af":{"one":"n = 1"},"ak":{"one":"n = 0..1"},"am":{"one":"i = 0 or n = 1"},"an":{"one":"n = 1"},"ar":{"zero":"n = 0","one":"n = 1","two":"n = 2","few":"n % 100 = 3..10","many":"n % 100 = 11..99"},"ars":{"zero":"n = 0","one":"n = 1","two":"n = 2","few":"n % 100 = 3..10","many":"n % 100 = 11..99"},"as":{"one":"i = 0 or n = 1"},"asa":{"one":"n = 1"},"ast":{"one":"i = 1 and v = 0"},"az":{"one":"n = 1"},"bal":{"one":"n = 1"},"be":{"one":"n % 10 = 1 and n % 100 != 11","few":"n % 10 = 2..4 and n % 100 != 12..14","many":"n % 10 = 0 or n % 10 = 5..9 or n % 100 = 11..14"},"bem":{"one":"n = 1"},"bez":{"one":"n = 1"},"bg":{"one":"n = 1"},"bho":{"one":"n = 0..1"},"blo":{"zero":"n = 0","one":"n = 1"},"bm":{},"bn":{"one":"i = 0 or n = 1"},"bo":{},"br":{"one":"n % 10 = 1 and n % 100 != 11,71,91","two":"n % 10 = 2 and n % 100 != 12,72,92","few":"n % 10 = 3..4,9 and n % 100 != 10..19,70..79,90..99","many":"n != 0 and n % 1000000 = 0"},"brx":{"one":"n = 1"},"bs":{"one":"v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11","few":"v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14"},"ca":{"one":"i = 1 and v = 0","many":"e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5"},"ce":{"one":"n = 1"},"ceb":{"one":"v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9"},"cgg":{"one":"n = 1"},"chr":{"one":"n = 1"},"ckb":{"one":"n = 1"},"cs":{"one":"i = 1 and v = 0","few":"i = 2..4 and v = 0","many":"v != 0"},"csw":{"one":"n = 0..1"},"cv":{"zero":"n = 0","one":"n = 1"},"cy":{"zero":"n = 0","one":"n = 1","two":"n = 2","few":"n = 3","many":"n = 6"},"da":{"one":"n = 1 or t != 0 and i = 0,1"},"de":{"one":"i = 1 and v = 0"},"doi":{"one":"i = 0 or n = 1"},"dsb":{"one":"v = 0 and i % 100 = 1 or f % 100 = 1","two":"v = 0 and i % 100 = 2 or f % 100 = 2","few":"v = 0 and i % 100 = 3..4 or f % 100 = 3..4"},"dv":{"one":"n = 1"},"dz":{},"ee":{"one":"n = 1"},"el":{"one":"n = 1"},"en":{"one":"i = 1 and v = 0"},"eo":{"one":"n = 1"},"es":{"one":"n = 1","many":"e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5"},"et":{"one":"i = 1 and v = 0"},"eu":{"one":"n = 1"},"fa":{"one":"i = 0 or n = 1"},"ff":{"one":"i = 0,1"},"fi":{"one":"i = 1 and v = 0"},"fil":{"one":"v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9"},"fo":{"one":"n = 1"},"fr":{"one":"i = 0,1","many":"e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5"},"fur":{"one":"n = 1"},"fy":{"one":"i = 1 and v = 0"},"ga":{"one":"n = 1","two":"n = 2","few":"n = 3..6","many":"n = 7..10"},"gd":{"one":"n = 1,11","two":"n = 2,12","few":"n = 3..10,13..19"},"gl":{"one":"i = 1 and v = 0"},"gsw":{"one":"n = 1"},"gu":{"one":"i = 0 or n = 1"},"guw":{"one":"n = 0..1"},"gv":{"one":"v = 0 and i % 10 = 1","two":"v = 0 and i % 10 = 2","few":"v = 0 and i % 100 = 0,20,40,60,80","many":"v != 0"},"ha":{"one":"n = 1"},"haw":{"one":"n = 1"},"he":{"one":"i = 1 and v = 0 or i = 0 and v != 0","two":"i = 2 and v = 0"},"hi":{"one":"i = 0 or n = 1"},"hnj":{},"hr":{"one":"v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11","few":"v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14"},"hsb":{"one":"v = 0 and i % 100 = 1 or f % 100 = 1","two":"v = 0 and i % 100 = 2 or f % 100 = 2","few":"v = 0 and i % 100 = 3..4 or f % 100 = 3..4"},"hu":{"one":"n = 1"},"hy":{"one":"i = 0,1"},"ia":{"one":"i = 1 and v = 0"},"id":{},"ie":{"one":"i = 1 and v = 0"},"ig":{},"ii":{},"io":{"one":"i = 1 and v = 0"},"is":{"one":"t = 0 and i % 10 = 1 and i % 100 != 11 or t % 10 = 1 and t % 100 != 11"},"it":{"one":"i = 1 and v = 0","many":"e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5"},"iu":{"one":"n = 1","two":"n = 2"},"ja":{},"jbo":{},"jgo":{"one":"n = 1"},"jmc":{"one":"n = 1"},"jv":{},"jw":{},"ka":{"one":"n = 1"},"kab":{"one":"i = 0,1"},"kaj":{"one":"n = 1"},"kcg":{"one":"n = 1"},"kde":{},"kea":{},"kk":{"one":"n = 1"},"kkj":{"one":"n = 1"},"kl":{"one":"n = 1"},"km":{},"kn":{"one":"i = 0 or n = 1"},"ko":{},"kok":{"one":"i = 0 or n = 1"},"kok-Latn":{"one":"i = 0 or n = 1"},"ks":{"one":"n = 1"},"ksb":{"one":"n = 1"},"ksh":{"zero":"n = 0","one":"n = 1"},"ku":{"one":"n = 1"},"kw":{"zero":"n = 0","one":"n = 1","two":"n % 100 = 2,22,42,62,82 or n % 1000 = 0 and n % 100000 = 1000..20000,40000,60000,80000 or n != 0 and n % 1000000 = 100000","few":"n % 100 = 3,23,43,63,83","many":"n != 1 and n % 100 = 1,21,41,61,81"},"ky":{"one":"n = 1"},"lag":{"zero":"n = 0","one":"i = 0,1 and n != 0"},"lb":{"one":"n = 1"},"lg":{"one":"n = 1"},"lij":{"one":"i = 1 and v = 0"},"lkt":{},"lld":{"one":"i = 1 and v = 0","many":"e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5"},"ln":{"one":"n = 0..1"},"lo":{},"lt":{"one":"n % 10 = 1 and n % 100 != 11..19","few":"n % 10 = 2..9 and n % 100 != 11..19","many":"f != 0"},"lv":{"zero":"n % 10 = 0 or n % 100 = 11..19 or v = 2 and f % 100 = 11..19","one":"n % 10 = 1 and n % 100 != 11 or v = 2 and f % 10 = 1 and f % 100 != 11 or v != 2 and f % 10 = 1"},"mas":{"one":"n = 1"},"mg":{"one":"n = 0..1"},"mgo":{"one":"n = 1"},"mk":{"one":"v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11"},"ml":{"one":"n = 1"},"mn":{"one":"n = 1"},"mo":{"one":"i = 1 and v = 0","few":"v != 0 or n = 0 or n != 1 and n % 100 = 1..19"},"mr":{"one":"n = 1"},"ms":{},"mt":{"one":"n = 1","two":"n = 2","few":"n = 0 or n % 100 = 3..10","many":"n % 100 = 11..19"},"my":{},"nah":{"one":"n = 1"},"naq":{"one":"n = 1","two":"n = 2"},"nb":{"one":"n = 1"},"nd":{"one":"n = 1"},"ne":{"one":"n = 1"},"nl":{"one":"i = 1 and v = 0"},"nn":{"one":"n = 1"},"nnh":{"one":"n = 1"},"no":{"one":"n = 1"},"nqo":{},"nr":{"one":"n = 1"},"nso":{"one":"n = 0..1"},"ny":{"one":"n = 1"},"nyn":{"one":"n = 1"},"om":{"one":"n = 1"},"or":{"one":"n = 1"},"os":{"one":"n = 1"},"osa":{},"pa":{"one":"n = 0..1"},"pap":{"one":"n = 1"},"pcm":{"one":"i = 0 or n = 1"},"pl":{"one":"i = 1 and v = 0","few":"v = 0 and i % 10 = 2..4 and i % 100 != 12..14","many":"v = 0 and i != 1 and i % 10 = 0..1 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 12..14"},"prg":{"zero":"n % 10 = 0 or n % 100 = 11..19 or v = 2 and f % 100 = 11..19","one":"n % 10 = 1 and n % 100 != 11 or v = 2 and f % 10 = 1 and f % 100 != 11 or v != 2 and f % 10 = 1"},"ps":{"one":"n = 1"},"pt":{"one":"i = 0..1","many":"e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5"},"pt-PT":{"one":"i = 1 and v = 0","many":"e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5"},"rm":{"one":"n = 1"},"ro":{"one":"i = 1 and v = 0","few":"v != 0 or n = 0 or n != 1 and n % 100 = 1..19"},"rof":{"one":"n = 1"},"ru":{"one":"v = 0 and i % 10 = 1 and i % 100 != 11","few":"v = 0 and i % 10 = 2..4 and i % 100 != 12..14","many":"v = 0 and i % 10 = 0 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 11..14"},"rwk":{"one":"n = 1"},"sah":{},"saq":{"one":"n = 1"},"sat":{"one":"n = 1","two":"n = 2"},"sc":{"one":"i = 1 and v = 0"},"scn":{"one":"i = 1 and v = 0","many":"e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5"},"sd":{"one":"n = 1"},"sdh":{"one":"n = 1"},"se":{"one":"n = 1","two":"n = 2"},"seh":{"one":"n = 1"},"ses":{},"sg":{},"sgs":{"one":"n % 10 = 1 and n % 100 != 11","two":"n = 2","few":"n != 2 and n % 10 = 2..9 and n % 100 != 11..19","many":"f != 0"},"sh":{"one":"v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11","few":"v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14"},"shi":{"one":"i = 0 or n = 1","few":"n = 2..10"},"si":{"one":"n = 0,1 or i = 0 and f = 1"},"sk":{"one":"i = 1 and v = 0","few":"i = 2..4 and v = 0","many":"v != 0"},"sl":{"one":"v = 0 and i % 100 = 1","two":"v = 0 and i % 100 = 2","few":"v = 0 and i % 100 = 3..4 or v != 0"},"sma":{"one":"n = 1","two":"n = 2"},"smi":{"one":"n = 1","two":"n = 2"},"smj":{"one":"n = 1","two":"n = 2"},"smn":{"one":"n = 1","two":"n = 2"},"sms":{"one":"n = 1","two":"n = 2"},"sn":{"one":"n = 1"},"so":{"one":"n = 1"},"sq":{"one":"n = 1"},"sr":{"one":"v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11","few":"v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14"},"ss":{"one":"n = 1"},"ssy":{"one":"n = 1"},"st":{"one":"n = 1"},"su":{},"sv":{"one":"i = 1 and v = 0"},"sw":{"one":"i = 1 and v = 0"},"syr":{"one":"n = 1"},"ta":{"one":"n = 1"},"te":{"one":"n = 1"},"teo":{"one":"n = 1"},"th":{},"ti":{"one":"n = 0..1"},"tig":{"one":"n = 1"},"tk":{"one":"n = 1"},"tl":{"one":"v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9"},"tn":{"one":"n = 1"},"to":{},"tpi":{},"tr":{"one":"n = 1"},"ts":{"one":"n = 1"},"tzm":{"one":"n = 0..1 or n = 11..99"},"ug":{"one":"n = 1"},"uk":{"one":"v = 0 and i % 10 = 1 and i % 100 != 11","few":"v = 0 and i % 10 = 2..4 and i % 100 != 12..14","many":"v = 0 and i % 10 = 0 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 11..14"},"und":{},"ur":{"one":"i = 1 and v = 0"},"uz":{"one":"n = 1"},"ve":{"one":"n = 1"},"vec":{"one":"i = 1 and v = 0","many":"e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5"},"vi":{},"vo":{"one":"n = 1"},"vun":{"one":"n = 1"},"wa":{"one":"n = 0..1"},"wae":{"one":"n = 1"},"wo":{},"xh":{"one":"n = 1"},"xog":{"one":"n = 1"},"yi":{"one":"i = 1 and v = 0"},"yo":{},"yue":{},"zh":{},"zu":{"one":"i = 0 or n = 1"}},"ordinal":{"af":{},"am":{},"an":{},"ar":{},"as":{"one":"n = 1,5,7,8,9,10","two":"n = 2,3","few":"n = 4","many":"n = 6"},"ast":{},"az":{"one":"i % 10 = 1,2,5,7,8 or i % 100 = 20,50,70,80","few":"i % 10 = 3,4 or i % 1000 = 100,200,300,400,500,600,700,800,900","many":"i = 0 or i % 10 = 6 or i % 100 = 40,60,90"},"bal":{"one":"n = 1"},"be":{"few":"n % 10 = 2,3 and n % 100 != 12,13"},"bg":{},"blo":{"zero":"i = 0","one":"i = 1","few":"i = 2,3,4,5,6"},"bn":{"one":"n = 1,5,7,8,9,10","two":"n = 2,3","few":"n = 4","many":"n = 6"},"bs":{},"ca":{"one":"n = 1,3","two":"n = 2","few":"n = 4"},"ce":{},"cs":{},"cv":{},"cy":{"zero":"n = 0,7,8,9","one":"n = 1","two":"n = 2","few":"n = 3,4","many":"n = 5,6"},"da":{},"de":{},"dsb":{},"el":{},"en":{"one":"n % 10 = 1 and n % 100 != 11","two":"n % 10 = 2 and n % 100 != 12","few":"n % 10 = 3 and n % 100 != 13"},"es":{},"et":{},"eu":{},"fa":{},"fi":{},"fil":{"one":"n = 1"},"fr":{"one":"n = 1"},"fy":{},"ga":{"one":"n = 1"},"gd":{"one":"n = 1,11","two":"n = 2,12","few":"n = 3,13"},"gl":{},"gsw":{},"gu":{"one":"n = 1","two":"n = 2,3","few":"n = 4","many":"n = 6"},"he":{},"hi":{"one":"n = 1","two":"n = 2,3","few":"n = 4","many":"n = 6"},"hr":{},"hsb":{},"hu":{"one":"n = 1,5"},"hy":{"one":"n = 1"},"ia":{},"id":{},"ie":{},"is":{},"it":{"many":"n = 11,8,80,800"},"ja":{},"ka":{"one":"i = 1","many":"i = 0 or i % 100 = 2..20,40,60,80"},"kk":{"many":"n % 10 = 6 or n % 10 = 9 or n % 10 = 0 and n != 0"},"km":{},"kn":{},"ko":{},"kok":{"one":"n = 1","two":"n = 2,3","few":"n = 4"},"kok-Latn":{"one":"n = 1","two":"n = 2,3","few":"n = 4"},"kw":{"one":"n = 1..4 or n % 100 = 1..4,21..24,41..44,61..64,81..84","many":"n = 5 or n % 100 = 5"},"ky":{},"lij":{"many":"n = 11,8,80..89,800..899"},"lld":{"many":"n = 11,8,80,800"},"lo":{"one":"n = 1"},"lt":{},"lv":{},"mk":{"one":"i % 10 = 1 and i % 100 != 11","two":"i % 10 = 2 and i % 100 != 12","many":"i % 10 = 7,8 and i % 100 != 17,18"},"ml":{},"mn":{},"mo":{"one":"n = 1"},"mr":{"one":"n = 1","two":"n = 2,3","few":"n = 4"},"ms":{"one":"n = 1"},"my":{},"nb":{},"ne":{"one":"n = 1..4"},"nl":{},"no":{},"or":{"one":"n = 1,5,7..9","two":"n = 2,3","few":"n = 4","many":"n = 6"},"pa":{},"pl":{},"prg":{},"ps":{},"pt":{},"ro":{"one":"n = 1"},"ru":{},"sc":{"many":"n = 11,8,80,800"},"scn":{"many":"n = 11,8,80..89,800..899"},"sd":{},"sh":{},"si":{},"sk":{},"sl":{},"sq":{"one":"n = 1","many":"n % 10 = 4 and n % 100 != 14"},"sr":{},"sv":{"one":"n % 10 = 1,2 and n % 100 != 11,12"},"sw":{},"ta":{},"te":{},"th":{},"tk":{"few":"n % 10 = 6,9 or n = 10"},"tl":{"one":"n = 1"},"tpi":{},"tr":{},"uk":{"few":"n % 10 = 3 and n % 100 != 13"},"und":{},"ur":{},"uz":{},"vec":{"many":"n = 11,8,80,800"},"vi":{"one":"n = 1"},"yue":{},"zh":{},"zu":{}}}
|
package/lib/runtime.mjs
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** Intl.NumberFormat options for a {n, number, style} style. */
|
|
2
|
+
export function numberOptions(style = '') {
|
|
3
|
+
return style === 'integer' ? { maximumFractionDigits: 0 } : style === 'percent' ? { style: 'percent' } : style.startsWith('::currency/') ? { style: 'currency', currency: style.slice(11) } : {};
|
|
4
|
+
}
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
7
|
* Format a compiled catalog using the host's Intl implementation.
|
|
3
8
|
* Messages without tags return strings. Messages with rich-text tags call the
|
|
@@ -6,8 +11,11 @@
|
|
|
6
11
|
* onError receives problems such as an unknown token or a missing value. By
|
|
7
12
|
* default they are logged and the message degrades instead of crashing the UI;
|
|
8
13
|
* pass `error => { throw error; }` to fail fast in tests.
|
|
14
|
+
* formatters supplies the project's own argument types: { length: (value,
|
|
15
|
+
* language) => string } formats {len, length}. It is read on each call, so
|
|
16
|
+
* formatters may be added to the object later.
|
|
9
17
|
*/
|
|
10
|
-
export function createI18n(catalog, { onError = error => console.error(`i18nmd: ${error.message}`) } = {}) {
|
|
18
|
+
export function createI18n(catalog, { onError = error => console.error(`i18nmd: ${error.message}`), formatters = {} } = {}) {
|
|
11
19
|
function value(values, name) {
|
|
12
20
|
if (!Object.hasOwn(values, name)) { onError(new Error(`Missing value for {${name}}.`)); return undefined; }
|
|
13
21
|
return values[name];
|
|
@@ -33,8 +41,13 @@ export function createI18n(catalog, { onError = error => console.error(`i18nmd:
|
|
|
33
41
|
if (v == null) { onError(new Error(`Missing value for {${n.name}}.`)); push(`{${n.name}}`); continue; }
|
|
34
42
|
if (['number', 'plural', 'selectordinal'].includes(n.type) && (typeof v !== 'number' || !Number.isFinite(v))) { onError(new Error(`{${n.name}} must be a finite number.`)); push(String(v)); continue; }
|
|
35
43
|
if (n.type === 'number') {
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
push(new Intl.NumberFormat(locale, numberOptions(n.style)).format(v)); continue;
|
|
45
|
+
}
|
|
46
|
+
if (n.type === 'format') {
|
|
47
|
+
const format = Object.hasOwn(formatters, n.format) ? formatters[n.format] : undefined;
|
|
48
|
+
if (typeof format !== 'function') { onError(new Error(`No formatter for {${n.name}, ${n.format}}.`)); push(String(v)); continue; }
|
|
49
|
+
try { push(String(format(v, locale))); } catch (error) { onError(error); push(String(v)); }
|
|
50
|
+
continue;
|
|
38
51
|
}
|
|
39
52
|
if (n.type === 'date' || n.type === 'time') {
|
|
40
53
|
const date = v instanceof Date ? v : typeof v === 'number' ? new Date(v) : null;
|
|
@@ -48,7 +61,7 @@ export function createI18n(catalog, { onError = error => console.error(`i18nmd:
|
|
|
48
61
|
}
|
|
49
62
|
return parts.length === 0 ? '' : parts.length === 1 && typeof parts[0] === 'string' ? parts[0] : parts;
|
|
50
63
|
}
|
|
51
|
-
|
|
64
|
+
function i18nmd(token, language = catalog.source, values = {}) {
|
|
52
65
|
if (!Object.hasOwn(catalog.messages, token)) { onError(new Error(`Unknown translation token: ${token}`)); return token; }
|
|
53
66
|
let requested = language;
|
|
54
67
|
try { requested = Intl.getCanonicalLocales(language)[0]; } catch { /* Custom language names use source formatting. */ }
|
|
@@ -60,7 +73,10 @@ export function createI18n(catalog, { onError = error => console.error(`i18nmd:
|
|
|
60
73
|
try { Intl.getCanonicalLocales(formattingLocale); } catch { formattingLocale = catalog.source; }
|
|
61
74
|
try { Intl.getCanonicalLocales(formattingLocale); } catch { formattingLocale = 'en'; }
|
|
62
75
|
return render(entry[languageUsed], formattingLocale, values || {});
|
|
63
|
-
}
|
|
76
|
+
}
|
|
77
|
+
/** Whether the catalog has token, for text written after the last compile. */
|
|
78
|
+
i18nmd.has = token => Object.hasOwn(catalog.messages, token);
|
|
79
|
+
return i18nmd;
|
|
64
80
|
}
|
|
65
81
|
|
|
66
82
|
/** The catalog language that best matches a requested one: exact, then base language. */
|
|
@@ -138,7 +154,8 @@ export function createLanguage(languages, source, { load, divisions = [], storag
|
|
|
138
154
|
* The generated i18nmd function over a language's store. Divisions are
|
|
139
155
|
* properties: i18nmd.support("contact_us") is the token support.contact_us.
|
|
140
156
|
* i18nmd.in("fr") translates into a given (loaded) language instead, for
|
|
141
|
-
* servers, emails and tests.
|
|
157
|
+
* servers, emails and tests. i18nmd.has(token) (or i18nmd.support.has("contact_us"))
|
|
158
|
+
* says whether a token is among the messages loaded. divisions maps each property to [segment, children].
|
|
142
159
|
*/
|
|
143
160
|
export function createTranslator(language, divisions = {}, options = {}) {
|
|
144
161
|
const { store } = language;
|
|
@@ -151,6 +168,7 @@ export function createTranslator(language, divisions = {}, options = {}) {
|
|
|
151
168
|
} });
|
|
152
169
|
const build = (prefix, tree, fixed) => {
|
|
153
170
|
const scoped = (token, values) => translate(prefix + token, fixed ?? language.getLanguage(), values);
|
|
171
|
+
Object.defineProperty(scoped, 'has', { value: token => translate.has(prefix + token) });
|
|
154
172
|
for (const [property, [segment, children]] of Object.entries(tree)) {
|
|
155
173
|
Object.defineProperty(scoped, property, { value: build(`${prefix}${segment}.`, children, fixed), enumerable: true });
|
|
156
174
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@likolabs/i18nmd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Interface strings in Markdown, one file per language. A compiler checks every language and generates typed code your app imports.",
|
|
5
5
|
"author": "Liko Labs (https://likolabs.com)",
|
|
6
6
|
"type": "module",
|
|
@@ -19,19 +19,22 @@
|
|
|
19
19
|
"lib",
|
|
20
20
|
"scripts/python_catalog.py",
|
|
21
21
|
"README.md",
|
|
22
|
+
"README.haw.md",
|
|
22
23
|
"LICENSE",
|
|
23
24
|
"PROMPT.md",
|
|
24
25
|
"CHANGELOG.md"
|
|
25
26
|
],
|
|
26
27
|
"scripts": {
|
|
27
28
|
"test": "node --test tests/*.test.mjs",
|
|
28
|
-
"check": "npm test"
|
|
29
|
+
"check": "npm test",
|
|
30
|
+
"plural-rules": "node scripts/plural-rules.mjs"
|
|
29
31
|
},
|
|
30
32
|
"engines": {
|
|
31
33
|
"node": ">=22"
|
|
32
34
|
},
|
|
33
35
|
"dependencies": {
|
|
34
|
-
"@babel/parser": "7.29.0"
|
|
36
|
+
"@babel/parser": "7.29.0",
|
|
37
|
+
"parse5": "^8.0.1"
|
|
35
38
|
},
|
|
36
39
|
"repository": {
|
|
37
40
|
"type": "git",
|
|
@@ -54,5 +57,8 @@
|
|
|
54
57
|
"typescript",
|
|
55
58
|
"llm"
|
|
56
59
|
],
|
|
57
|
-
"license": "MIT"
|
|
60
|
+
"license": "MIT",
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"cldr-core": "48.2.0"
|
|
63
|
+
}
|
|
58
64
|
}
|