langsys-js-typescript 0.6.3 → 0.6.4

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 CHANGED
@@ -1,3 +1,25 @@
1
+ ## 0.6.4 - 2026-08-16
2
+
3
+ ### Fixed
4
+
5
+ - **A missing ICU argument no longer dumps the message source to the page.** `intl-messageformat` throws when an argument is absent, and the fallback ran the simple interpolator — whose pattern deliberately cannot match an ICU slot, so the entire construct passed through untouched. A phrase promoted to `{name_gender, select, …}` rendered as literal ICU source in front of users.
6
+
7
+ This is reachable with no caller mistake: langsys-ai's ICU promoter *introduces* a select argument the source phrase never had — a plain `{name}` becomes `{name_gender, select, …}` in gendered target locales. The application cannot supply `name_gender`; it doesn't exist in the phrase the developer wrote, and nothing tells them the target grew one. Every app translating into a gendered locale hits it.
8
+
9
+ Recovery, agreed with `langsys-php` so a shared catalog cannot render two different sentences for one input:
10
+
11
+ | slot | missing-argument behavior | outcome |
12
+ |---|---|---|
13
+ | `select` | take the `other` branch | a correct sentence — `other` is what an unknown gender should render |
14
+ | `plural` | take `other`, render `#` as `{argName}` | sentence survives with a visible gap; nothing can be inferred for a count |
15
+ | `{name}`, `{n, number}`, dates | render as `{argName}` | matches the simple path's rule that unknown keys stay visible |
16
+
17
+ The asymmetry is deliberate: `select` is genuinely recoverable, `plural` is only made less bad. Arguments that ARE supplied are untouched and recursed into, so a missing argument nested inside a satisfied branch still recovers. `plural`/`select` without an `other` branch is malformed ICU and is left alone, falling through to the simple path as before.
18
+
19
+ **A `null` argument counts as missing**, matching `langsys-php`. It doesn't throw — `intl-messageformat` coerces null to `0` — so `{count, plural, …}` rendered `0 items`. That is worse than it looks: a genuine `0` also renders `0 items`, so a bug and valid data became indistinguishable, in the output, in a screenshot, and in a bug report. A real `0` still renders `0 items`; only null and absent produce `{count} items`.
20
+
21
+ Found by `langsys-php` from a report against their SDK; both implementations had it, by different mechanisms. Verified against their shipped v1.3.1 behavior, including a nested case where an *unsatisfied* branch's missing argument correctly costs nothing (`{g, select, f {Ella} other {{n, plural …}}}` with `g='f'` renders `Ella`).
22
+
1
23
  ## 0.6.3 - 2026-08-16
2
24
 
3
25
  ### Fixed
package/dist/index.js CHANGED
@@ -289,14 +289,76 @@ function warnUnmatchedParams(source, texts, params, context) {
289
289
  }
290
290
  function interpolate(template, params, locale) {
291
291
  if (isICU(template)) {
292
- try {
293
- return new intlMessageformat.IntlMessageFormat(template, locale || "en").format(params);
294
- } catch {
295
- return simpleInterpolate(template, params, locale);
292
+ const resolved = locale || "en";
293
+ const hasNullParam = !!params && Object.values(params).some((value) => value === null);
294
+ if (!hasNullParam) {
295
+ try {
296
+ return new intlMessageformat.IntlMessageFormat(template, resolved).format(params);
297
+ } catch {
298
+ }
299
+ }
300
+ {
301
+ try {
302
+ const ast = new intlMessageformat.IntlMessageFormat(template, resolved).ast;
303
+ if (!Array.isArray(ast)) return simpleInterpolate(template, params, locale);
304
+ const recovered = _recoverMissingArgs(JSON.parse(JSON.stringify(ast)), params);
305
+ return new intlMessageformat.IntlMessageFormat(recovered, resolved).format(params);
306
+ } catch {
307
+ return simpleInterpolate(template, params, locale);
308
+ }
296
309
  }
297
310
  }
298
311
  return simpleInterpolate(template, params, locale);
299
312
  }
313
+ var ICU_LITERAL = 0;
314
+ var ICU_ARGUMENT = 1;
315
+ var ICU_NUMBER = 2;
316
+ var ICU_DATE = 3;
317
+ var ICU_TIME = 4;
318
+ var ICU_SELECT = 5;
319
+ var ICU_PLURAL = 6;
320
+ var ICU_POUND = 7;
321
+ var ICU_TAG = 8;
322
+ function _recoverMissingArgs(nodes, params) {
323
+ const out = [];
324
+ for (const node of nodes) {
325
+ const name = node.value;
326
+ const value = name !== void 0 && name in params ? params[name] : void 0;
327
+ const supplied = value !== void 0 && value !== null;
328
+ if (node.type === ICU_TAG) {
329
+ out.push({ ...node, children: _recoverMissingArgs(node.children ?? [], params) });
330
+ continue;
331
+ }
332
+ if (node.type === ICU_SELECT || node.type === ICU_PLURAL) {
333
+ if (supplied) {
334
+ const options = {};
335
+ for (const [key, branch2] of Object.entries(node.options ?? {})) {
336
+ options[key] = { value: _recoverMissingArgs(branch2.value, params) };
337
+ }
338
+ out.push({ ...node, options });
339
+ continue;
340
+ }
341
+ const other = node.options?.other;
342
+ if (!other) {
343
+ out.push(node);
344
+ continue;
345
+ }
346
+ const branch = _recoverMissingArgs(other.value, params);
347
+ out.push(
348
+ ...branch.map(
349
+ (child) => child.type === ICU_POUND ? { type: ICU_LITERAL, value: `{${name}}` } : child
350
+ )
351
+ );
352
+ continue;
353
+ }
354
+ if (!supplied && (node.type === ICU_ARGUMENT || node.type === ICU_NUMBER || node.type === ICU_DATE || node.type === ICU_TIME)) {
355
+ out.push({ type: ICU_LITERAL, value: `{${name}}` });
356
+ continue;
357
+ }
358
+ out.push(node);
359
+ }
360
+ return out;
361
+ }
300
362
  function simpleInterpolate(template, params, locale) {
301
363
  return template.replace(/\{([^{},]+)\}/g, (match, rawKey) => {
302
364
  const key = rawKey.trim();