@likolabs/i18nmd 0.2.0 → 0.3.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/README.haw.md +421 -0
- package/README.md +43 -6
- package/bin/i18nmd.mjs +31 -5
- package/lib/catalog.mjs +13 -8
- package/lib/compiler.mjs +154 -36
- 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.mjs +2 -0
- package/lib/runtime.mjs +24 -6
- package/package.json +8 -3
package/lib/catalog.mjs
CHANGED
|
@@ -45,7 +45,7 @@ export function parseCatalog(markdown, options = {}) {
|
|
|
45
45
|
if (!match || Object.hasOwn(metadata, match[1])) throw new Error(`Invalid or duplicate metadata: ${line}`);
|
|
46
46
|
metadata[match[1]] = match[1] === 'languages' ? JSON.parse(match[2]) : match[2].trim();
|
|
47
47
|
}
|
|
48
|
-
const catalog = { ...metadata, title: 'Translations', messages: [] };
|
|
48
|
+
const catalog = { ...metadata, title: 'Translations', messages: [], ...(options.formatters?.length && { formatters: options.formatters }) };
|
|
49
49
|
let message;
|
|
50
50
|
for (let i = end + 1; i < lines.length; i++) {
|
|
51
51
|
const line = lines[i];
|
|
@@ -84,6 +84,9 @@ export function parseCatalog(markdown, options = {}) {
|
|
|
84
84
|
export function validateCatalog(catalog, { allowIncomplete = false, lenient = false, single = false } = {}) {
|
|
85
85
|
catalog.warnings ||= [];
|
|
86
86
|
if (!['icu', 'python'].includes(catalog.syntax)) throw new Error('syntax must be icu or python.');
|
|
87
|
+
for (const name of catalog.formatters || []) {
|
|
88
|
+
if (!/^[a-zA-Z_]\w*$/.test(name) || ['number', 'date', 'time', 'plural', 'select', 'selectordinal'].includes(name)) throw new Error(`Formatter names are identifiers other than ICU's own types: ${name}`);
|
|
89
|
+
}
|
|
87
90
|
if (!catalog.languages || typeof catalog.languages !== 'object' || Array.isArray(catalog.languages)) throw new Error('languages must be a JSON object mapping codes to names.');
|
|
88
91
|
const locales = Object.keys(catalog.languages);
|
|
89
92
|
if (!locales.length || !Object.hasOwn(catalog.languages, catalog.source)) throw new Error('Declare the source language in languages.');
|
|
@@ -107,7 +110,7 @@ export function validateCatalog(catalog, { allowIncomplete = false, lenient = fa
|
|
|
107
110
|
for (const locale of locales) {
|
|
108
111
|
if (!Object.hasOwn(table, locale) && allowIncomplete && locale !== catalog.source) continue;
|
|
109
112
|
if (!Object.hasOwn(table, locale) || typeof table[locale] !== 'string' || !table[locale].trim()) throw new Error(`${message.key}: missing ${locale} translation.`);
|
|
110
|
-
try { parsed[locale] = argumentsFor(parseMessage(table[locale], catalog.syntax)); }
|
|
113
|
+
try { parsed[locale] = argumentsFor(parseMessage(table[locale], catalog.syntax, catalog.formatters)); }
|
|
111
114
|
catch (error) { throw new Error(`${message.key} [${locale}]: ${error.message}`); }
|
|
112
115
|
}
|
|
113
116
|
const sourceArgs = parsed[catalog.source];
|
|
@@ -121,7 +124,8 @@ export function validateCatalog(catalog, { allowIncomplete = false, lenient = fa
|
|
|
121
124
|
if (problem) break;
|
|
122
125
|
const base = sourceArgs[name];
|
|
123
126
|
if (!base) problem = `unknown placeholder ${type === 'tag' ? `<${name}>` : `{${name}}`}`;
|
|
124
|
-
|
|
127
|
+
// A translation may move {len, length} but not drop or change its formatter.
|
|
128
|
+
else if (base !== type && (base === 'tag' || type === 'tag' || base.startsWith('format:') || type.startsWith('format:') || (base !== 'string' && type !== 'string'))) problem = `incompatible type for {${name}}`;
|
|
125
129
|
}
|
|
126
130
|
if (!problem) continue;
|
|
127
131
|
if (lenient && locale !== catalog.source) {
|
|
@@ -175,7 +179,7 @@ const where = division => division ? `${division}/` : '';
|
|
|
175
179
|
|
|
176
180
|
// Properties a division function cannot take: they exist on every function,
|
|
177
181
|
// or (in) is i18nmd's own.
|
|
178
|
-
const RESERVED = new Set(['in', 'name', 'length', 'call', 'apply', 'bind', 'prototype', 'caller', 'arguments', 'constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__']);
|
|
182
|
+
const RESERVED = new Set(['in', 'has', 'name', 'length', 'call', 'apply', 'bind', 'prototype', 'caller', 'arguments', 'constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__']);
|
|
179
183
|
|
|
180
184
|
/** knowledge-hub → knowledgeHub: the property for a division directory. */
|
|
181
185
|
export function divisionProperty(segment) {
|
|
@@ -204,7 +208,8 @@ export function namespaceFor(division, prefix = '') {
|
|
|
204
208
|
}
|
|
205
209
|
|
|
206
210
|
/**
|
|
207
|
-
* Merge one-language files into a catalog.
|
|
211
|
+
* Merge one-language files into a catalog. formatters lists the argument types
|
|
212
|
+
* the project declares in its lock ({len, length}). Files may sit in subdirectories,
|
|
208
213
|
* one per division (ui/i18n-fr.md, marketing/i18n-fr.md); each division has its
|
|
209
214
|
* own source file, and its tokens are namespaced by its path: ## save in
|
|
210
215
|
* ui/i18n-en.md is the token ui.save. Every message records its division so it
|
|
@@ -213,7 +218,7 @@ export function namespaceFor(division, prefix = '') {
|
|
|
213
218
|
* no longer has are set aside in catalog.orphans with a warning. strict: every
|
|
214
219
|
* file must match its source exactly.
|
|
215
220
|
*/
|
|
216
|
-
export function parseLanguageFiles(files, { source, syntax = 'icu', strict = false, prefix = '' } = {}) {
|
|
221
|
+
export function parseLanguageFiles(files, { source, syntax = 'icu', strict = false, prefix = '', formatters = [] } = {}) {
|
|
217
222
|
const divisions = new Map();
|
|
218
223
|
for (const [filename, markdown] of Object.entries(files)) {
|
|
219
224
|
const locale = languageFromFilename(filename);
|
|
@@ -221,7 +226,7 @@ export function parseLanguageFiles(files, { source, syntax = 'icu', strict = fal
|
|
|
221
226
|
if (!divisions.has(division)) divisions.set(division, new Map());
|
|
222
227
|
const byLocale = divisions.get(division);
|
|
223
228
|
if (byLocale.has(locale)) throw new Error(`Duplicate language file: ${where(division)}${locale}`);
|
|
224
|
-
const parsed = parseCatalog(markdown, { locale, syntax });
|
|
229
|
+
const parsed = parseCatalog(markdown, { locale, syntax, formatters });
|
|
225
230
|
const namespace = namespaceFor(division, prefix);
|
|
226
231
|
for (const message of parsed.messages) message.key = namespace + message.key;
|
|
227
232
|
if (parsed.source !== locale || parsed.syntax !== syntax || Object.keys(parsed.languages).length !== 1) throw new Error(`Expected a single-language ${syntax} file for ${where(division)}i18n-${locale}.md.`);
|
|
@@ -230,7 +235,7 @@ export function parseLanguageFiles(files, { source, syntax = 'icu', strict = fal
|
|
|
230
235
|
const all = [...divisions.values()].flatMap(byLocale => [...byLocale.keys()]);
|
|
231
236
|
source ||= all.includes('en') ? 'en' : all[0];
|
|
232
237
|
if (!source) throw new Error('Include at least one i18n-<language>.md file.');
|
|
233
|
-
const catalog = { source, syntax, title: 'Translations', languages: Object.create(null), messages: [], orphans: Object.create(null), divisions: [...divisions.keys()], prefix };
|
|
238
|
+
const catalog = { source, syntax, title: 'Translations', languages: Object.create(null), messages: [], orphans: Object.create(null), divisions: [...divisions.keys()], prefix, ...(formatters.length && { formatters }) };
|
|
234
239
|
const owner = new Map();
|
|
235
240
|
for (const [division, byLocale] of divisions) {
|
|
236
241
|
const base = byLocale.get(source);
|
package/lib/compiler.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { parseCatalog, validateCatalog, namespaceFor, divisionProperty, accessorFor } from './catalog.mjs';
|
|
2
2
|
import { argumentsFor, parseMessage } from './messages.mjs';
|
|
3
|
+
import { numberOptions } from './runtime.mjs';
|
|
4
|
+
import PLURAL_RULES from './plural-rules.mjs';
|
|
3
5
|
|
|
4
6
|
export function compileCatalog(input) {
|
|
5
7
|
const catalog = typeof input === 'string' ? parseCatalog(input, { allowIncomplete: true }) : validateCatalog(input, { allowIncomplete: true });
|
|
6
8
|
const messages = Object.create(null);
|
|
7
9
|
for (const message of catalog.messages) {
|
|
8
|
-
messages[message.key] = Object.fromEntries(Object.entries(message.translations).map(([lang, text]) => [lang, parseMessage(text, catalog.syntax)]));
|
|
10
|
+
messages[message.key] = Object.fromEntries(Object.entries(message.translations).map(([lang, text]) => [lang, parseMessage(text, catalog.syntax, catalog.formatters)]));
|
|
9
11
|
}
|
|
10
12
|
return { source: catalog.source, languages: catalog.languages, messages };
|
|
11
13
|
}
|
|
@@ -26,41 +28,116 @@ function divisionTree(catalog) {
|
|
|
26
28
|
return tree;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
function
|
|
32
|
-
let
|
|
33
|
-
try {
|
|
34
|
-
const
|
|
35
|
-
|
|
31
|
+
// The CLDR plural rules Intl.PluralRules uses for locale, as Python: a function
|
|
32
|
+
// of the ICU operands n, i, v, f, t and e. Returns [function name, source].
|
|
33
|
+
function pluralFunction(locale, type) {
|
|
34
|
+
let resolved;
|
|
35
|
+
try { resolved = new Intl.PluralRules(locale, { type }).resolvedOptions().locale; } catch { resolved = 'en'; }
|
|
36
|
+
const table = PLURAL_RULES[type];
|
|
37
|
+
const parts = resolved.split('-');
|
|
38
|
+
while (parts.length && !Object.hasOwn(table, parts.join('-'))) parts.pop();
|
|
39
|
+
const key = parts.join('-') || 'root';
|
|
40
|
+
const relation = text => {
|
|
41
|
+
const match = /^([nivwftce])(?:\s*%\s*(\d+))?\s*(!?=)\s*([\d.,\s]+)$/.exec(text.trim());
|
|
42
|
+
if (!match) throw new Error(`Unsupported CLDR plural rule for ${key}: ${text}`);
|
|
43
|
+
const ranges = match[4].split(',').map(r => { const [low, high = low] = r.trim().split('..'); return `(${low}, ${high})`; });
|
|
44
|
+
return `${match[3] === '=' ? '' : 'not '}_in(${match[1]}${match[2] ? ` % ${match[2]}` : ''}, (${ranges.join(', ')},))`;
|
|
45
|
+
};
|
|
46
|
+
const name = `_${type}_${key.replace(/\W/g, '_')}`;
|
|
47
|
+
let source = `def ${name}(n: Decimal, i: int, v: int, f: int, t: int, e: int) -> str:\n`;
|
|
48
|
+
for (const [category, rule] of Object.entries(table[key] || {})) {
|
|
49
|
+
source += ` if ${rule.split(/\s+or\s+/).map(and => and.split(/\s+and\s+/).map(relation).join(' and ')).join(' or ')}:\n return "${category}"\n`;
|
|
50
|
+
}
|
|
51
|
+
return [name, source + ' return "other"\n'];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// How a language writes numbers, from Intl, for the Python formatter:
|
|
55
|
+
// [group mark, decimal mark, primary group size, secondary group size,
|
|
56
|
+
// digits before grouping starts, { style: [positive pattern, negative pattern,
|
|
57
|
+
// most fraction digits, fewest fraction digits] }], where {n} in a pattern is
|
|
58
|
+
// the grouped digits. styles lists the number styles the catalog uses.
|
|
59
|
+
function numberRules(locale, styles) {
|
|
60
|
+
const make = options => { try { return new Intl.NumberFormat(locale, options); } catch { return new Intl.NumberFormat('en', options); } };
|
|
61
|
+
const format = make();
|
|
62
|
+
const parts = format.formatToParts(1234567.891);
|
|
63
|
+
const integers = parts.filter(p => p.type === 'integer').map(p => p.value);
|
|
64
|
+
const primary = integers.length > 1 ? integers.at(-1).length : 0;
|
|
65
|
+
const secondary = integers.length > 2 ? integers.at(-2).length : primary;
|
|
66
|
+
const minimum = format.formatToParts(1234).some(p => p.type === 'group') ? 1 : 2;
|
|
67
|
+
const digits = new Set(['integer', 'group', 'decimal', 'fraction']);
|
|
68
|
+
const pattern = (f, value) => f.formatToParts(value).map((p, i, all) => !digits.has(p.type) ? p.value : digits.has(all[i - 1]?.type) ? '' : '{n}').join('');
|
|
69
|
+
const patterns = Object.fromEntries(styles.map(style => {
|
|
70
|
+
const f = make(numberOptions(style));
|
|
71
|
+
const { maximumFractionDigits, minimumFractionDigits } = f.resolvedOptions();
|
|
72
|
+
return [style, [pattern(f, 1), pattern(f, -1), maximumFractionDigits, minimumFractionDigits]];
|
|
73
|
+
}));
|
|
74
|
+
return [parts.find(p => p.type === 'group')?.value ?? '', parts.find(p => p.type === 'decimal')?.value ?? '.', primary, secondary, minimum, patterns];
|
|
36
75
|
}
|
|
37
76
|
|
|
38
77
|
const PYTHON_RUNTIME = `
|
|
39
|
-
_CATEGORY = {"z": "zero", "o": "one", "t": "two", "f": "few", "m": "many", "x": "other"}
|
|
40
78
|
_log = logging.getLogger("i18nmd")
|
|
79
|
+
_FORMATTERS: dict[str, Callable[[Any, str], str]] = {}
|
|
80
|
+
_CONTEXT = Context(prec=1000, rounding=ROUND_HALF_UP)
|
|
41
81
|
|
|
42
82
|
|
|
43
|
-
def _number(value: Any) ->
|
|
83
|
+
def _number(value: Any) -> float | None:
|
|
84
|
+
# Numbers are doubles, as in JavaScript, so both runtimes see the same value.
|
|
44
85
|
if isinstance(value, bool):
|
|
45
86
|
return None
|
|
46
|
-
if isinstance(value, (int, float)):
|
|
47
|
-
return value
|
|
48
87
|
try:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
except (TypeError, ValueError):
|
|
88
|
+
number = float(value if isinstance(value, (int, float)) else str(value).strip())
|
|
89
|
+
except (TypeError, ValueError, OverflowError):
|
|
52
90
|
return None
|
|
91
|
+
return number if math.isfinite(number) else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _decimal(number: float) -> Decimal:
|
|
95
|
+
# The shortest decimal that reads back as number, which is what Intl formats.
|
|
96
|
+
return Decimal(repr(float(number)))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _digits(number: Decimal, most: int, fewest: int = 0) -> tuple[str, str]:
|
|
100
|
+
# Whole and fraction digits of |number|, rounded half away from zero like Intl.
|
|
101
|
+
with localcontext(_CONTEXT):
|
|
102
|
+
rounded = abs(number).quantize(Decimal(1).scaleb(-most))
|
|
103
|
+
whole, _, fraction = format(rounded, "f").partition(".")
|
|
104
|
+
return whole, fraction.rstrip("0").ljust(fewest, "0")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _number_text(number: float, language: str, style: str = "") -> str:
|
|
108
|
+
group, decimal, primary, secondary, minimum, styles = _NUMBERS[language]
|
|
109
|
+
positive, negative, most, fewest = styles[style]
|
|
110
|
+
value = _decimal(number)
|
|
111
|
+
if style == "percent":
|
|
112
|
+
value = value.scaleb(2)
|
|
113
|
+
whole, fraction = _digits(value, most, fewest)
|
|
114
|
+
if primary and len(whole) >= primary + minimum:
|
|
115
|
+
groups = [whole[-primary:]]
|
|
116
|
+
whole = whole[:-primary]
|
|
117
|
+
while len(whole) > secondary:
|
|
118
|
+
groups.insert(0, whole[-secondary:])
|
|
119
|
+
whole = whole[:-secondary]
|
|
120
|
+
whole = group.join([whole] + groups if whole else groups)
|
|
121
|
+
text = whole + (decimal + fraction if fraction else "")
|
|
122
|
+
return (negative if value.is_signed() else positive).replace("{n}", text)
|
|
53
123
|
|
|
54
124
|
|
|
55
|
-
def
|
|
56
|
-
return
|
|
125
|
+
def _in(value: Any, ranges: tuple[tuple[int, int], ...]) -> bool:
|
|
126
|
+
return value == int(value) and any(low <= value <= high for low, high in ranges)
|
|
57
127
|
|
|
58
128
|
|
|
59
|
-
def _category(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
129
|
+
def _category(rules: Callable[..., str], number: float) -> str:
|
|
130
|
+
# The CLDR operands of number as Intl.PluralRules reads it: rounded to at most
|
|
131
|
+
# 3 decimals; like ICU, n is that value as a double and i keeps 18 digits.
|
|
132
|
+
whole, fraction = _digits(_decimal(number), 3)
|
|
133
|
+
n = Decimal(float(whole + "." + fraction if fraction else whole))
|
|
134
|
+
with localcontext(_CONTEXT):
|
|
135
|
+
return rules(n, int(whole[-18:]), len(fraction), int(fraction or 0), int(fraction or 0), 0)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _exact(options: dict[str, Any], number: float) -> Any:
|
|
139
|
+
value = _decimal(number)
|
|
140
|
+
return next((branch for key, branch in options.items() if key.startswith("=") and Decimal(key[1:]) == value), None)
|
|
64
141
|
|
|
65
142
|
|
|
66
143
|
def _render(nodes: list[Any], language: str, values: dict[str, Any] | None, pound: Any = None) -> str:
|
|
@@ -71,7 +148,7 @@ def _render(nodes: list[Any], language: str, values: dict[str, Any] | None, poun
|
|
|
71
148
|
continue
|
|
72
149
|
kind = node["type"]
|
|
73
150
|
if kind == "pound":
|
|
74
|
-
out.append(
|
|
151
|
+
out.append(_number_text(pound, language))
|
|
75
152
|
continue
|
|
76
153
|
if kind == "tag":
|
|
77
154
|
inner = _render(node["children"], language, values, pound)
|
|
@@ -89,6 +166,20 @@ def _render(nodes: list[Any], language: str, values: dict[str, Any] | None, poun
|
|
|
89
166
|
value = values[name]
|
|
90
167
|
if kind == "argument":
|
|
91
168
|
out.append("" if value is None else str(value))
|
|
169
|
+
elif kind == "format":
|
|
170
|
+
fn = _FORMATTERS.get(node["format"])
|
|
171
|
+
if value is None:
|
|
172
|
+
_log.error("Missing value for {%s}.", name)
|
|
173
|
+
out.append("{" + name + "}")
|
|
174
|
+
elif fn is None:
|
|
175
|
+
_log.error("No formatter for {%s, %s}.", name, node["format"])
|
|
176
|
+
out.append(str(value))
|
|
177
|
+
else:
|
|
178
|
+
try:
|
|
179
|
+
out.append(str(fn(value, language)))
|
|
180
|
+
except Exception:
|
|
181
|
+
_log.exception("Formatter %s failed for {%s}.", node["format"], name)
|
|
182
|
+
out.append(str(value))
|
|
92
183
|
elif kind == "select":
|
|
93
184
|
options = node["options"]
|
|
94
185
|
out.append(_render(options.get(str(value), options["other"]), language, values, pound))
|
|
@@ -99,13 +190,12 @@ def _render(nodes: list[Any], language: str, values: dict[str, Any] | None, poun
|
|
|
99
190
|
out.append(str(value))
|
|
100
191
|
continue
|
|
101
192
|
options = node["options"]
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
chosen = exact or options.get(_category(table, number - node["offset"])) or options["other"]
|
|
193
|
+
rules = (_ORDINAL if kind == "selectordinal" else _CARDINAL)[language]
|
|
194
|
+
chosen = _exact(options, number) or options.get(_category(rules, number - node["offset"])) or options["other"]
|
|
105
195
|
out.append(_render(chosen, language, values, number - node["offset"]))
|
|
106
196
|
elif kind == "number":
|
|
107
197
|
number = _number(value)
|
|
108
|
-
out.append(str(value) if number is None else
|
|
198
|
+
out.append(str(value) if number is None else _number_text(number, language, node.get("style", "")))
|
|
109
199
|
else:
|
|
110
200
|
out.append(str(value))
|
|
111
201
|
return "".join(out)
|
|
@@ -132,6 +222,17 @@ def i18nmd(token: str, language: str | None = None, **values: Any) -> str:
|
|
|
132
222
|
return _render(entry[used], used, values)
|
|
133
223
|
|
|
134
224
|
|
|
225
|
+
def register_formatter(name: str, fn: Callable[[Any, str], str]) -> None:
|
|
226
|
+
# Supply the function for an argument type the project declares: {len, length}.
|
|
227
|
+
# fn(value, language) returns the text; keep it the same as the JavaScript one.
|
|
228
|
+
_FORMATTERS[name] = fn
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def has(token: str) -> bool:
|
|
232
|
+
# Whether the catalog has token, for text written after the last compile.
|
|
233
|
+
return token in _MESSAGES
|
|
234
|
+
|
|
235
|
+
|
|
135
236
|
def template(token: str, language: str | None = None) -> str:
|
|
136
237
|
# The message with each placeholder, plural or select shown as {name}.
|
|
137
238
|
entry = _MESSAGES[token]
|
|
@@ -142,11 +243,21 @@ def template(token: str, language: str | None = None) -> str:
|
|
|
142
243
|
// ICU messages as a dependency-free Python module: i18nmd(token, language, **values).
|
|
143
244
|
function pythonModule(catalog) {
|
|
144
245
|
const compiled = compileCatalog(catalog);
|
|
145
|
-
const
|
|
246
|
+
const languages = Object.keys(catalog.languages);
|
|
247
|
+
const styles = new Set(['']);
|
|
248
|
+
const visit = nodes => nodes.forEach(n => { if (n.type === 'number') styles.add(n.style); for (const branch of Object.values(n.options || {})) visit(branch); if (n.children) visit(n.children); });
|
|
249
|
+
for (const byLang of Object.values(compiled.messages)) Object.values(byLang).forEach(visit);
|
|
146
250
|
const literal = value => JSON.stringify(value, null, 1).replace(/\n\s*/g, ' ');
|
|
147
|
-
let text = '# Generated from i18n.md. Edit the Markdown source.\n# ruff: noqa\nfrom __future__ import annotations\n\nimport logging\nfrom typing import Any\n\n';
|
|
251
|
+
let text = '# Generated from i18n.md. Edit the Markdown source.\n# ruff: noqa\nfrom __future__ import annotations\n\nimport logging\nimport math\nfrom decimal import ROUND_HALF_UP, Context, Decimal, localcontext\nfrom typing import Any, Callable\n\n';
|
|
148
252
|
text += `SOURCE = ${JSON.stringify(catalog.source)}\nLANGS: dict[str, str] = ${literal(catalog.languages)}\n`;
|
|
149
|
-
|
|
253
|
+
// Plural rules from CLDR, the data Intl.PluralRules uses, as Python functions.
|
|
254
|
+
const functions = new Map();
|
|
255
|
+
const rules = type => Object.fromEntries(languages.map(l => { const [name, source] = pluralFunction(l, type); functions.set(name, source); return [l, name]; }));
|
|
256
|
+
const cardinal = rules('cardinal'), ordinal = rules('ordinal');
|
|
257
|
+
text += `\n\n${[...functions.values()].join('\n\n')}\n\n`;
|
|
258
|
+
const table = map => `{${Object.entries(map).map(([l, name]) => `${JSON.stringify(l)}: ${name}`).join(', ')}}`;
|
|
259
|
+
text += `_CARDINAL: dict[str, Callable[..., str]] = ${table(cardinal)}\n_ORDINAL: dict[str, Callable[..., str]] = ${table(ordinal)}\n`;
|
|
260
|
+
text += `_NUMBERS: dict[str, list[Any]] = ${JSON.stringify(Object.fromEntries(languages.map(l => [l, numberRules(l, [...styles])])), null, 1)}\n`;
|
|
150
261
|
// Compiled messages are JSON of strings, objects and integers, which is also Python.
|
|
151
262
|
text += `_MESSAGES: dict[str, dict[str, list[Any]]] = {\n${Object.entries(compiled.messages).map(([key, byLang]) => ` ${JSON.stringify(key)}: ${literal(byLang)},`).join('\n')}\n}\n`;
|
|
152
263
|
text += 'TOKENS: frozenset[str] = frozenset(_MESSAGES)\n';
|
|
@@ -169,8 +280,8 @@ function typesFor(catalog, tree) {
|
|
|
169
280
|
// Merge argument types across translations so a numeric source placeholder
|
|
170
281
|
// can acquire plural/number formatting in a translation without weakening types.
|
|
171
282
|
const params = Object.create(null);
|
|
172
|
-
for (const value of Object.values(message.translations)) argumentsFor(parseMessage(value, catalog.syntax), params);
|
|
173
|
-
types[message.key] = Object.entries(params).map(([name, type]) => `${JSON.stringify(name)}: ${type === 'number' ? 'number' : type === 'date' ? 'Date | number' : type === 'select' ? 'string' : type === 'tag' ? '(chunks: any[]) => any' : 'string | number | null | undefined'}`).join('; ');
|
|
283
|
+
for (const value of Object.values(message.translations)) argumentsFor(parseMessage(value, catalog.syntax, catalog.formatters), params);
|
|
284
|
+
types[message.key] = Object.entries(params).map(([name, type]) => `${JSON.stringify(name)}: ${type === 'number' ? 'number' : type === 'date' ? 'Date | number' : type === 'select' ? 'string' : type === 'tag' ? '(chunks: any[]) => any' : type.startsWith('format:') ? 'any' : 'string | number | null | undefined'}`).join('; ');
|
|
174
285
|
if (Object.values(params).includes('tag')) rich.push(message.key);
|
|
175
286
|
}
|
|
176
287
|
let text = 'import type { Language } from "./language.mjs";\nexport type { Language };\n';
|
|
@@ -181,7 +292,7 @@ function typesFor(catalog, tree) {
|
|
|
181
292
|
// A division function takes the tokens under its prefix by their short names.
|
|
182
293
|
text += 'type Short<P extends string> = Token extends infer K ? (K extends `${P}${infer S}` ? S : never) : never;\n';
|
|
183
294
|
text += 'type Full<P extends string, S extends string> = `${P}${S}` & Token;\n';
|
|
184
|
-
text += 'export interface Scope<P extends string> {\n <S extends Short<P>>(token: S, ...args: Values[Full<P, S>] extends undefined ? [values?: undefined] : [values: Values[Full<P, S>]]): Full<P, S> extends RichToken ? any[] : string;\n}\n';
|
|
295
|
+
text += 'export interface Scope<P extends string> {\n <S extends Short<P>>(token: S, ...args: Values[Full<P, S>] extends undefined ? [values?: undefined] : [values: Values[Full<P, S>]]): Full<P, S> extends RichToken ? any[] : string;\n /** Whether the compiled catalog has this token, for text written after the last compile. */\n has(token: string): token is Short<P>;\n}\n';
|
|
185
296
|
const scopeType = (prefix, node) => `Scope<${JSON.stringify(prefix)}>${Object.keys(node).length ? ` & { ${Object.entries(node).map(([property, [segment, children]]) => `readonly ${property}: ${scopeType(`${prefix}${segment}.`, children)}`).join('; ')} }` : ''}`;
|
|
186
297
|
text += `export type Translator = ${scopeType('', tree)};\n\n`;
|
|
187
298
|
return text;
|
|
@@ -241,7 +352,14 @@ export function generateModules(input, { target = 'ts', eager = false } = {}) {
|
|
|
241
352
|
if (target === 'ts') main += typesFor(catalog, tree);
|
|
242
353
|
const own = eager ? catalog.messages : catalog.messages.filter(m => !m.division);
|
|
243
354
|
if (own.length) main += `store.add(${table(own, catalog.source)});\n`;
|
|
244
|
-
|
|
355
|
+
const formatters = catalog.formatters || [];
|
|
356
|
+
if (formatters.length) {
|
|
357
|
+
main += 'const formatters = {};\n/** Supply the function for an argument type the project declares: {len, length}. */\n';
|
|
358
|
+
main += target === 'ts'
|
|
359
|
+
? `export function registerFormatter(name: ${formatters.map(f => JSON.stringify(f)).join(' | ')}, format: (value: any, language: Language) => string): void { formatters[name] = format; }\n`
|
|
360
|
+
: 'export function registerFormatter(name, format) { formatters[name] = format; }\n';
|
|
361
|
+
}
|
|
362
|
+
main += `const translator = createTranslator({ store, getLanguage }, ${JSON.stringify(tree)}${formatters.length ? ', { formatters }' : ''});\n`;
|
|
245
363
|
main += '/** Translate into the current language: i18nmd("token"), i18nmd.division("token", values), or i18nmd.in("fr")… for a given language. */\n';
|
|
246
364
|
main += target === 'ts' ? 'export const i18nmd = translator as Translator & { in(language: Language | (string & {})): Translator };\n' : 'export const i18nmd = translator;\n';
|
|
247
365
|
main += 'export { languages } from "./language.mjs";\nexport { ready, getLanguage, setLanguage, loadLanguage, onLanguageChange };\n';
|
|
@@ -273,10 +391,10 @@ export function generateModule(input, target = 'ts') {
|
|
|
273
391
|
export const runtimeTypes = `/* eslint-disable */
|
|
274
392
|
export interface MessageNode { type: string; name?: string; style?: string; offset?: number; options?: Record<string, (string | MessageNode)[]>; children?: (string | MessageNode)[] }
|
|
275
393
|
export interface CompiledCatalog { source: string; languages: Record<string, string>; messages: Record<string, Record<string, (string | MessageNode)[]>> }
|
|
276
|
-
export interface I18nOptions { onError?: (error: Error) => void }
|
|
394
|
+
export interface I18nOptions { onError?: (error: Error) => void; formatters?: Record<string, (value: any, language: string) => string> }
|
|
277
395
|
export interface LanguageOptions { load?: (language: string) => Record<string, (string | MessageNode)[]> | Promise<Record<string, (string | MessageNode)[]>>; divisions?: string[]; storageKey?: string | null; onError?: (error: Error) => void }
|
|
278
396
|
export interface LanguageState { store: CompiledCatalog & { add(table: Record<string, (string | MessageNode)[]>, language?: string): void }; ready: Promise<string>; getLanguage(): string; setLanguage(language: string): Promise<string>; loadLanguage(language: string): Promise<void>; onLanguageChange(listener: (language: string) => void): () => void }
|
|
279
|
-
export function createI18n(catalog: CompiledCatalog, options?: I18nOptions): (token: string, language?: string, values?: Record<string, unknown>) => string | any[];
|
|
397
|
+
export function createI18n(catalog: CompiledCatalog, options?: I18nOptions): ((token: string, language?: string, values?: Record<string, unknown>) => string | any[]) & { has(token: string): boolean };
|
|
280
398
|
export function matchLanguage(catalog: { languages: Record<string, string> }, requested: string | null | undefined): string | undefined;
|
|
281
399
|
export function createLanguage(languages: Record<string, string>, source: string, options?: LanguageOptions): LanguageState;
|
|
282
400
|
export function createTranslator(language: Pick<LanguageState, 'store' | 'getLanguage'>, divisions?: Record<string, unknown>, options?: I18nOptions): any;
|
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,2 @@
|
|
|
1
|
+
// Generated by scripts/plural-rules.mjs from cldr-core.
|
|
2
|
+
export default {"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":{}}};
|