@ultimat3/i18n 2.0.0 → 3.0.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/CLAUDE.md CHANGED
@@ -29,6 +29,14 @@ Imported by every package that renders a string.
29
29
  for. Same reach `catalog.ts` shuts off by nesting into null-prototype nodes; never reintroduce
30
30
  either. The `interpolate` fast path must test **both** braces: `}}` un-escapes with no `{` in
31
31
  sight, and a `{`-only check gave one escape two meanings.
32
+ - **A catalog is read through `Object.hasOwn`, never a raw index, and every catalog this package
33
+ builds is `Object.create(null)`** — `flattenCatalog`, `mergeCatalogs` and `nestCatalog` all are.
34
+ On a `{}` catalog `catalog['valueOf']` resolved to the INHERITED function, so `t('valueOf')`
35
+ threw inside `interpolate`, `t('constructor')` returned a function through a signature typed
36
+ `string`, and `isMiss(t('__proto__'))` threw on an object — all three reachable wherever a key
37
+ travels as data (`t(row.labelKey)`). Both halves are load-bearing: the guard in `translator.ts`
38
+ makes it true of a catalog this package did not build, the null prototype makes `__proto__` an
39
+ ordinary key instead of one the setter silently swallows. Never reintroduce either.
32
40
  - Plural selection is `Intl.PluralRules`. Never `count === 1`. Variants are underscore suffixes on
33
41
  the leaf — a CLDR category (`_zero _one _two _few _many _other`), or `n` / `n_plural` as the
34
42
  two-form shortcut; pair `n_one` with `n_other`, never with `n_plural`. Never a nested
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/i18n",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Dependency-free translator, catalog flattening, locale negotiation and loud missing-key rendering",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,6 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "2.0.0"
34
+ "@ultimat3/core": "3.0.0"
35
35
  }
36
36
  }
package/src/catalog.ts CHANGED
@@ -16,9 +16,14 @@ const KEY_SEGMENT = /^[A-Za-z0-9_-]+$/;
16
16
  /**
17
17
  * Depth-first flatten. Throws `X_CATALOG_INVALID` on a non-string leaf (arrays and
18
18
  * numbers are the two mistakes translators actually make) or a duplicate flat key.
19
+ *
20
+ * `Object.create(null)`, for the reason `nestCatalog` states below: a catalog is untrusted input
21
+ * read off disk. On a `{}` literal `out['__proto__'] = 'Hello'` hits the `Object.prototype` SETTER
22
+ * and the key vanishes silently, and every other member of `Object.prototype` reads back as
23
+ * present when it is not.
19
24
  */
20
25
  export function flattenCatalog(source: NestedCatalog, prefix = ''): Catalog {
21
- const flat: Record<string, string> = {};
26
+ const flat = Object.create(null) as Record<string, string>;
22
27
  walk(source, prefix, flat);
23
28
  return flat;
24
29
  }
@@ -89,7 +94,9 @@ export function nestCatalog(catalog: Catalog): NestedCatalog {
89
94
  * app can override `errors.notFound.title` without forking the framework catalog.
90
95
  */
91
96
  export function mergeCatalogs(...catalogs: readonly Catalog[]): Catalog {
92
- const merged: Record<string, string> = {};
97
+ // Null-prototyped like every catalog this package builds — a merge must not hand back an
98
+ // object on which `catalog['toString']` reads as a function.
99
+ const merged = Object.create(null) as Record<string, string>;
93
100
  for (const catalog of catalogs) {
94
101
  for (const key of Object.keys(catalog)) {
95
102
  const value = catalog[key];
package/src/translator.ts CHANGED
@@ -64,7 +64,13 @@ export function createTranslator(catalog: Catalog, locale: Locale = DEFAULT_LOCA
64
64
  };
65
65
 
66
66
  const translate = (key: string, vars?: TranslateVars): string => {
67
- const template = catalog[resolveKey(key, vars)];
67
+ // Through `hasExact`, never a raw index: a key travels as data (`t(row.labelKey)`), and on a
68
+ // `{}`-prototyped catalog `catalog['valueOf']` resolves to the INHERITED function instead of
69
+ // reading as absent — so `interpolate` threw on a non-string, `t('constructor')` returned a
70
+ // function through a signature typed `string`, and `isMiss(t('__proto__'))` threw on an object.
71
+ // Catalogs are null-prototyped now; this guard is what makes that true of any catalog.
72
+ const resolved = resolveKey(key, vars);
73
+ const template = hasExact(resolved) ? catalog[resolved] : undefined;
68
74
  if (template === undefined) return `⟦${key}⟧`;
69
75
  return vars === undefined ? template : interpolate(template, vars);
70
76
  };
@@ -75,7 +81,7 @@ export function createTranslator(catalog: Catalog, locale: Locale = DEFAULT_LOCA
75
81
  hasExact(`${key}_other`) ||
76
82
  hasExact(`${key}_plural`) ||
77
83
  hasExact(`${key}_one`),
78
- raw: (key: string): string | undefined => catalog[key],
84
+ raw: (key: string): string | undefined => (hasExact(key) ? catalog[key] : undefined),
79
85
  keys: (): string[] => Object.keys(catalog).sort(),
80
86
  locale,
81
87
  });