@blamejs/core 0.6.37 → 0.6.59

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.
@@ -0,0 +1,398 @@
1
+ "use strict";
2
+ /**
3
+ * i18n-messageformat — ICU MessageFormat parser + evaluator.
4
+ *
5
+ * The framework's `b.i18n` translation file format is JSON-shaped with
6
+ * CLDR plural keys at the JSON level (one / few / many / other under a
7
+ * key like `inbox.unread`). That covers the simple plural case but not
8
+ * the nested / inline patterns common in real-world translations:
9
+ * gendered selects, plural forms with embedded variables, ordinals,
10
+ * arguments inside cases.
11
+ *
12
+ * ICU MessageFormat is the standard syntax for those cases:
13
+ *
14
+ * You have {count, plural, =0 {no messages} =1 {# message} other {# messages}}.
15
+ * {gender, select, female {She} male {He} other {They}} liked your post.
16
+ * {n, plural, one {# day} other {# days}} until {event}.
17
+ *
18
+ * This module is a minimal-but-correct subset:
19
+ *
20
+ * - `{argName}` — simple replacement
21
+ * - `{argName, plural, =N {...} cat {...} other {...}}` (with optional
22
+ * `offset:N` and `#` placeholder for the plural arg minus offset)
23
+ * - `{argName, selectordinal, =N {...} ... other {...}}` (CLDR ordinal)
24
+ * - `{argName, select, caseA {...} caseB {...} other {...}}`
25
+ * - Nested arguments inside any case body
26
+ * - `'{'` / `'}'` literal escapes (ICU spec — single-quote pair is the
27
+ * escape mechanism; `''` renders as a literal apostrophe)
28
+ * - CLDR cardinal categories via `Intl.PluralRules` for `plural`
29
+ * - CLDR ordinal categories via `Intl.PluralRules({ type: "ordinal" })`
30
+ *
31
+ * Out of scope (operators wanting these reach for the full
32
+ * `messageformat` package and pre-format strings before storage):
33
+ *
34
+ * - Inline `number` / `date` / `time` formatters (use `formatNumber` /
35
+ * `formatDate` from b.i18n separately and inline the result)
36
+ * - Choice-format (deprecated by ICU in favor of plural)
37
+ * - Custom user-defined argument types
38
+ *
39
+ * Operator API:
40
+ *
41
+ * var msg = b.i18n.messageFormat.format(template, vars, locale?);
42
+ *
43
+ * Used by `b.i18n.t(key, vars, { messageFormat: true })` when the
44
+ * translation entry contains MessageFormat syntax. Existing plural-
45
+ * shaped JSON entries continue to work unchanged.
46
+ */
47
+ var lazyRequire = require("./lazy-require");
48
+ var { defineClass } = require("./framework-error");
49
+
50
+ var I18nMessageFormatError = defineClass("I18nMessageFormatError",
51
+ { alwaysPermanent: true });
52
+
53
+ // ---- Tokenizer ----
54
+ //
55
+ // ICU MessageFormat is a small enough grammar that hand-rolling a
56
+ // recursive-descent parser is cleaner than threading a tokenizer +
57
+ // parser layer. We do scan apostrophe-escapes up-front though — the
58
+ // `'{'` and `'}'` patterns flip the next character to literal,
59
+ // `''` renders as a literal apostrophe.
60
+
61
+ function _err(code, message) {
62
+ return new I18nMessageFormatError(code, message, true);
63
+ }
64
+
65
+ // ---- Parser ----
66
+ //
67
+ // AST node shapes:
68
+ // { type: "literal", value: string }
69
+ // { type: "argument", name: string } // {name}
70
+ // { type: "plural", name, offset, cases: { key: nodes[] } } // {n, plural, ...}
71
+ // { type: "select", name, cases: { key: nodes[] } }
72
+ // { type: "ordinal", name, offset, cases: { key: nodes[] } } // {n, selectordinal, ...}
73
+ // { type: "hash" } // # inside plural body
74
+
75
+ function parse(template) {
76
+ if (typeof template !== "string") {
77
+ throw _err("BAD_TEMPLATE",
78
+ "messageFormat.parse: template must be a string, got " + typeof template);
79
+ }
80
+ var state = { src: template, pos: 0 };
81
+ var nodes = _parseSequence(state, /* topLevel */ true);
82
+ if (state.pos < state.src.length) {
83
+ throw _err("BAD_TEMPLATE",
84
+ "messageFormat.parse: unexpected '" + state.src[state.pos] +
85
+ "' at position " + state.pos);
86
+ }
87
+ return nodes;
88
+ }
89
+
90
+ function _parseSequence(state, topLevel) {
91
+ var nodes = [];
92
+ var lit = "";
93
+ while (state.pos < state.src.length) {
94
+ var ch = state.src[state.pos];
95
+ if (ch === "}" && !topLevel) {
96
+ // Caller will consume the closing brace.
97
+ break;
98
+ }
99
+ if (ch === "{") {
100
+ if (lit.length > 0) { nodes.push({ type: "literal", value: lit }); lit = ""; }
101
+ nodes.push(_parseArgument(state));
102
+ continue;
103
+ }
104
+ if (ch === "#" && !topLevel) {
105
+ if (lit.length > 0) { nodes.push({ type: "literal", value: lit }); lit = ""; }
106
+ nodes.push({ type: "hash" });
107
+ state.pos += 1;
108
+ continue;
109
+ }
110
+ if (ch === "'") {
111
+ // ICU-spec apostrophe handling:
112
+ // '' → literal "'"
113
+ // '{' → literal "{"
114
+ // '}' → literal "}"
115
+ // '#' → literal "#" (only inside a plural body)
116
+ // 'X' → literal "'X" (X not a special char) — this is
117
+ // the `quoting` rule. We also handle '{...'
118
+ // sequences that quote an entire run.
119
+ state.pos += 1;
120
+ if (state.pos >= state.src.length) { lit += "'"; break; }
121
+ var next = state.src[state.pos];
122
+ if (next === "'") {
123
+ lit += "'";
124
+ state.pos += 1;
125
+ continue;
126
+ }
127
+ // If the next char is a special metachar, the quote runs to the
128
+ // next single-quote (or end of string).
129
+ if (next === "{" || next === "}" || next === "#" || next === "|") {
130
+ var endQuote = state.src.indexOf("'", state.pos);
131
+ if (endQuote === -1) {
132
+ lit += state.src.slice(state.pos);
133
+ state.pos = state.src.length;
134
+ } else {
135
+ lit += state.src.slice(state.pos, endQuote);
136
+ state.pos = endQuote + 1;
137
+ }
138
+ continue;
139
+ }
140
+ // Lone apostrophe — render literally.
141
+ lit += "'";
142
+ continue;
143
+ }
144
+ lit += ch;
145
+ state.pos += 1;
146
+ }
147
+ if (lit.length > 0) nodes.push({ type: "literal", value: lit });
148
+ return nodes;
149
+ }
150
+
151
+ function _parseArgument(state) {
152
+ // Consume opening '{'.
153
+ if (state.src[state.pos] !== "{") {
154
+ throw _err("BAD_TEMPLATE", "expected '{' at " + state.pos);
155
+ }
156
+ state.pos += 1;
157
+ _skipWs(state);
158
+ var name = _parseIdentifier(state);
159
+ if (!name) {
160
+ throw _err("BAD_TEMPLATE",
161
+ "missing argument name at position " + state.pos);
162
+ }
163
+ _skipWs(state);
164
+ var ch = state.src[state.pos];
165
+ if (ch === "}") {
166
+ state.pos += 1;
167
+ return { type: "argument", name: name };
168
+ }
169
+ if (ch !== ",") {
170
+ throw _err("BAD_TEMPLATE",
171
+ "expected ',' or '}' after argument name '" + name +
172
+ "' at position " + state.pos);
173
+ }
174
+ state.pos += 1;
175
+ _skipWs(state);
176
+ var typeName = _parseIdentifier(state);
177
+ if (!typeName) {
178
+ throw _err("BAD_TEMPLATE",
179
+ "missing argument type after ',' for '" + name + "'");
180
+ }
181
+ _skipWs(state);
182
+ if (typeName === "plural" || typeName === "selectordinal") {
183
+ return _parsePluralLike(state, name, typeName === "selectordinal" ? "ordinal" : "plural");
184
+ }
185
+ if (typeName === "select") {
186
+ return _parseSelect(state, name);
187
+ }
188
+ throw _err("BAD_TEMPLATE",
189
+ "unsupported argument type '" + typeName + "' (supported: plural, " +
190
+ "selectordinal, select)");
191
+ }
192
+
193
+ function _parsePluralLike(state, name, kind) {
194
+ // Optional ',' before the cases — the spec allows both `plural,
195
+ // offset:0 ...` and `plural, =0 ...` immediately after the type.
196
+ if (state.src[state.pos] === ",") { state.pos += 1; _skipWs(state); }
197
+ var offset = 0;
198
+ if (state.src.slice(state.pos, state.pos + 7) === "offset:") {
199
+ state.pos += 7;
200
+ offset = _parseInteger(state);
201
+ _skipWs(state);
202
+ }
203
+ var cases = {};
204
+ while (state.pos < state.src.length && state.src[state.pos] !== "}") {
205
+ var caseKey = _parseCaseKey(state);
206
+ _skipWs(state);
207
+ if (state.src[state.pos] !== "{") {
208
+ throw _err("BAD_TEMPLATE",
209
+ "expected '{' after plural case '" + caseKey +
210
+ "' at position " + state.pos);
211
+ }
212
+ state.pos += 1;
213
+ var body = _parseSequence(state, false);
214
+ if (state.src[state.pos] !== "}") {
215
+ throw _err("BAD_TEMPLATE",
216
+ "unclosed plural case body at position " + state.pos);
217
+ }
218
+ state.pos += 1;
219
+ cases[caseKey] = body;
220
+ _skipWs(state);
221
+ }
222
+ if (state.src[state.pos] !== "}") {
223
+ throw _err("BAD_TEMPLATE",
224
+ "unclosed plural argument for '" + name + "'");
225
+ }
226
+ if (!cases.other) {
227
+ throw _err("BAD_TEMPLATE",
228
+ "plural argument '" + name + "' missing required 'other' case");
229
+ }
230
+ state.pos += 1;
231
+ return { type: kind, name: name, offset: offset, cases: cases };
232
+ }
233
+
234
+ function _parseSelect(state, name) {
235
+ if (state.src[state.pos] === ",") { state.pos += 1; _skipWs(state); }
236
+ var cases = {};
237
+ while (state.pos < state.src.length && state.src[state.pos] !== "}") {
238
+ var caseKey = _parseIdentifier(state);
239
+ if (!caseKey) {
240
+ throw _err("BAD_TEMPLATE",
241
+ "expected select case identifier at position " + state.pos);
242
+ }
243
+ _skipWs(state);
244
+ if (state.src[state.pos] !== "{") {
245
+ throw _err("BAD_TEMPLATE",
246
+ "expected '{' after select case '" + caseKey +
247
+ "' at position " + state.pos);
248
+ }
249
+ state.pos += 1;
250
+ var body = _parseSequence(state, false);
251
+ if (state.src[state.pos] !== "}") {
252
+ throw _err("BAD_TEMPLATE",
253
+ "unclosed select case body at position " + state.pos);
254
+ }
255
+ state.pos += 1;
256
+ cases[caseKey] = body;
257
+ _skipWs(state);
258
+ }
259
+ if (state.src[state.pos] !== "}") {
260
+ throw _err("BAD_TEMPLATE",
261
+ "unclosed select argument for '" + name + "'");
262
+ }
263
+ if (!cases.other) {
264
+ throw _err("BAD_TEMPLATE",
265
+ "select argument '" + name + "' missing required 'other' case");
266
+ }
267
+ state.pos += 1;
268
+ return { type: "select", name: name, cases: cases };
269
+ }
270
+
271
+ function _parseIdentifier(state) {
272
+ var start = state.pos;
273
+ while (state.pos < state.src.length) {
274
+ var ch = state.src[state.pos];
275
+ // Identifiers per ICU: anything not whitespace or special char.
276
+ if (ch === "{" || ch === "}" || ch === "," || ch === "#" || ch === "'") break;
277
+ if (/\s/.test(ch)) break;
278
+ state.pos += 1;
279
+ }
280
+ return state.src.slice(start, state.pos);
281
+ }
282
+
283
+ function _parseCaseKey(state) {
284
+ // CLDR plural keys: zero / one / two / few / many / other, OR
285
+ // explicit `=N` literal-match keys.
286
+ if (state.src[state.pos] === "=") {
287
+ state.pos += 1;
288
+ var n = _parseInteger(state);
289
+ return "=" + n;
290
+ }
291
+ return _parseIdentifier(state);
292
+ }
293
+
294
+ function _parseInteger(state) {
295
+ var start = state.pos;
296
+ if (state.src[state.pos] === "-") state.pos += 1;
297
+ while (state.pos < state.src.length && /[0-9]/.test(state.src[state.pos])) {
298
+ state.pos += 1;
299
+ }
300
+ if (state.pos === start) {
301
+ throw _err("BAD_TEMPLATE", "expected integer at position " + state.pos);
302
+ }
303
+ return parseInt(state.src.slice(start, state.pos), 10);
304
+ }
305
+
306
+ function _skipWs(state) {
307
+ while (state.pos < state.src.length && /\s/.test(state.src[state.pos])) {
308
+ state.pos += 1;
309
+ }
310
+ }
311
+
312
+ // ---- Evaluator ----
313
+
314
+ var _pluralRulesCache = new Map();
315
+ function _pluralRules(locale, type) {
316
+ var key = locale + "\x1f" + type;
317
+ var pr = _pluralRulesCache.get(key);
318
+ if (!pr) {
319
+ pr = new Intl.PluralRules(locale, { type: type });
320
+ _pluralRulesCache.set(key, pr);
321
+ }
322
+ return pr;
323
+ }
324
+
325
+ function format(template, vars, locale) {
326
+ var nodes = parse(template);
327
+ return _renderSequence(nodes, vars || {}, locale || "en", null);
328
+ }
329
+
330
+ function _renderSequence(nodes, vars, locale, hashContext) {
331
+ var out = "";
332
+ for (var i = 0; i < nodes.length; i++) {
333
+ out += _renderNode(nodes[i], vars, locale, hashContext);
334
+ }
335
+ return out;
336
+ }
337
+
338
+ function _renderNode(node, vars, locale, hashContext) {
339
+ if (node.type === "literal") return node.value;
340
+ if (node.type === "hash") {
341
+ return hashContext != null ? String(hashContext) : "#";
342
+ }
343
+ if (node.type === "argument") {
344
+ var v = vars[node.name];
345
+ return v === undefined ? "" : (v === null ? "" : String(v));
346
+ }
347
+ if (node.type === "plural" || node.type === "ordinal") {
348
+ var raw = vars[node.name];
349
+ var n = Number(raw);
350
+ if (!Number.isFinite(n)) {
351
+ throw _err("BAD_VAR",
352
+ "plural arg '" + node.name + "' must be a number, got " +
353
+ typeof raw + " " + JSON.stringify(raw));
354
+ }
355
+ var adjusted = n - (node.offset || 0);
356
+ var exact = "=" + n;
357
+ var caseBody = node.cases[exact];
358
+ if (!caseBody) {
359
+ var pr = _pluralRules(locale, node.type === "ordinal" ? "ordinal" : "cardinal");
360
+ var category = pr.select(adjusted);
361
+ caseBody = node.cases[category] || node.cases.other;
362
+ }
363
+ return _renderSequence(caseBody, vars, locale, adjusted);
364
+ }
365
+ if (node.type === "select") {
366
+ var sv = vars[node.name];
367
+ var key = (sv === undefined || sv === null) ? "other" : String(sv);
368
+ var body = node.cases[key] || node.cases.other;
369
+ return _renderSequence(body, vars, locale, hashContext);
370
+ }
371
+ return "";
372
+ }
373
+
374
+ // ---- Detection helper for b.i18n.t() integration ----
375
+ //
376
+ // A string contains MessageFormat syntax if it has a `{...,...}` shape.
377
+ // Plain `{var}` interpolation is forwarded to the existing simple path
378
+ // (no plural / select / nested cases), matching backward compat. Used
379
+ // by `b.i18n.t(key, vars, { messageFormat: true })` to pick the
380
+ // renderer based on the entry.
381
+ function looksLikeMessageFormat(template) {
382
+ if (typeof template !== "string") return false;
383
+ // Cheap structural check — full-syntax detection comes from parse()
384
+ // throwing if it isn't valid MessageFormat.
385
+ return /\{[^}]+,\s*(plural|select|selectordinal)\b/.test(template);
386
+ }
387
+
388
+ module.exports = {
389
+ parse: parse,
390
+ format: format,
391
+ looksLikeMessageFormat: looksLikeMessageFormat,
392
+ I18nMessageFormatError: I18nMessageFormatError,
393
+ // Test-only — clear plural-rules cache between locale rotations.
394
+ _resetCacheForTest: function () { _pluralRulesCache.clear(); },
395
+ };
396
+
397
+ // Reserved for future expansion — keeps the require() side-effect-free.
398
+ void lazyRequire;
package/lib/i18n.js CHANGED
@@ -617,6 +617,16 @@ function create(opts) {
617
617
  return key;
618
618
  }
619
619
 
620
+ // ICU MessageFormat path — when the operator opts in via
621
+ // `messageFormat: true` OR the entry contains a `{name, plural,
622
+ // ...}` / `{name, select, ...}` / `{name, selectordinal, ...}`
623
+ // shape, evaluate via the parser. Otherwise fall back to the
624
+ // simple `{var}` interpolator (existing behaviour, unchanged).
625
+ var useMf = callerOpts.messageFormat === true ||
626
+ messageFormat.looksLikeMessageFormat(raw);
627
+ if (useMf) {
628
+ return messageFormat.format(raw, vars, found.foundIn);
629
+ }
620
630
  return _interpolate(raw, vars, interpolation);
621
631
  }
622
632
 
@@ -862,8 +872,15 @@ function create(opts) {
862
872
  };
863
873
  }
864
874
 
875
+ // ICU MessageFormat companion — top-level namespace so operators can
876
+ // pre-format strings outside the i18n instance (build pipeline, audit
877
+ // formatters, etc.). The instance returned by `create()` plumbs it
878
+ // through `t(key, vars, { messageFormat: true })`.
879
+ var messageFormat = require("./i18n-messageformat");
880
+
865
881
  module.exports = {
866
882
  create: create,
883
+ messageFormat: messageFormat,
867
884
  I18nError: I18nError,
868
885
  DEFAULTS: DEFAULTS,
869
886
  RTL_LANGUAGES: RTL_LANGUAGES,