@ultimat3/i18n 7.0.0 → 8.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
@@ -67,6 +67,25 @@ Imported by every package that renders a string.
67
67
  travels as data (`t(row.labelKey)`). Both halves are load-bearing: the guard in `translator.ts`
68
68
  makes it true of a catalog this package did not build, the null prototype makes `__proto__` an
69
69
  ordinary key instead of one the setter silently swallows. Never reintroduce either.
70
+ - **Both module-level caches are bounded and canonically keyed, through `@ultimat3/core`'s
71
+ `cachedFormatter` / `canonicalLocale`** — `context.ts`'s `translators` and `interpolate.ts`'s
72
+ `rulesCache`. `translatorFor` and `pluralCategory` are exported raw and `@ultimat3/mail` passes
73
+ an unnormalised value, so the key is a REQUEST value: keyed raw into an unbounded `Map`, 5,000
74
+ distinct-but-valid tags retained +108 MB after `Bun.gc(true)`, against +11 MB bounded. The bound
75
+ and the canonical key are two halves of ONE rule — `en-us` and `en-US` must not each buy an
76
+ entry, or the cap counts spellings instead of locales. Never re-key either map on the raw tag;
77
+ `context.test.ts`'s `the translator cache` and `interpolate.test.ts`'s `the plural-rules cache`
78
+ are what notice.
79
+ - **A tag `canonicalLocale` REFUSES shares one key, and `translatorFor` resolves the catalog
80
+ through that key.** Two holes in the rule above, both reachable from a header. `?? locale` put a
81
+ malformed tag back in as its own key, so a bounded cache was still one a client could flush —
82
+ they collapse onto `INVALID_LOCALE_KEY` (`rulesCache`: onto `en`, which is what they render as
83
+ anyway), and a REGISTERED tag ICU will not canonicalise is the one exception, keying on itself.
84
+ And `registry.has(locale) ? locale : key` read the registry under two spellings but never the
85
+ registered one: `registerCatalog('en-US', …)` then `translatorFor('en-us')` matched neither, so it
86
+ cached an EMPTY translator under `en-us` and the later `translatorFor('en-US')` was served it.
87
+ `registeredUnder` is what makes the catalog independent of which spelling arrived first — resolve
88
+ BEFORE `cachedFormatter`, never inside the branch that already missed.
70
89
  - Plural selection is `Intl.PluralRules`. Never `count === 1`. Variants are underscore suffixes on
71
90
  the leaf — a CLDR category (`_zero _one _two _few _many _other`), or `n` / `n_plural` as the
72
91
  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": "7.0.0",
3
+ "version": "8.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",
@@ -35,6 +35,6 @@
35
35
  "test": "bun test"
36
36
  },
37
37
  "dependencies": {
38
- "@ultimat3/core": "7.0.0"
38
+ "@ultimat3/core": "8.0.0"
39
39
  }
40
40
  }
package/src/context.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * No call site passes a locale by hand — `t()` here is the ambient translator.
4
4
  */
5
5
 
6
- import { tryUseContext } from '@ultimat3/core';
6
+ import { cachedFormatter, canonicalLocale, tryUseContext } from '@ultimat3/core';
7
7
  import { type Catalog, mergeCatalogs } from './catalog';
8
8
  import {
9
9
  DEFAULT_LOCALE,
@@ -147,9 +147,10 @@ export function currentLocale(): Locale {
147
147
  const locale = tryUseContext()?.locale;
148
148
  if (locale === undefined || locale === '') return config.fallback;
149
149
  // Normalised HERE, the same call `resolveLocale` makes for every source it reads. `Ctx.locale`
150
- // is a plain string core never validates, and every distinct one it holds bought a PERMANENT
151
- // `Translator` in `translators` plus a permanent `Intl.PluralRules` in `interpolate`'s
152
- // `rulesCache` — two unbounded module-level maps keyed by whatever a request carried.
150
+ // is a plain string core never validates, so this is the step that keeps a request's own
151
+ // spelling out of the catalog lookup entirely. `translators` and `interpolate`'s `rulesCache`
152
+ // are bounded now and no longer grow with it — but a bound EVICTS, and an ambient locale an app
153
+ // never registered would still answer every key with a loud miss.
153
154
  return normalizeLocale(locale, config.supported, config.fallback);
154
155
  }
155
156
 
@@ -158,7 +159,55 @@ export function currentDirection(): Direction {
158
159
  }
159
160
 
160
161
  const registry = new Map<Locale, Catalog>();
161
- const translators = new Map<Locale, Translator>();
162
+ const translators = new Map<string, Translator>();
163
+
164
+ /**
165
+ * The one key every unregistered tag that is not a locale at all shares. A `Symbol` cannot be a
166
+ * `Map<string, …>` key and a NUL cannot appear in a structurally valid tag, so nothing a caller
167
+ * sends can collide with it.
168
+ */
169
+ const INVALID_LOCALE_KEY = '\u0000invalid';
170
+
171
+ /**
172
+ * The one key `translatorFor` caches under and every registration evicts by.
173
+ *
174
+ * `translatorFor` is exported raw and `packages/mail/src/render.ts` hands it a value nothing
175
+ * normalised, so this map was keyed on whatever spelling a request carried — permanently, and
176
+ * `en-us` and `en-US` bought two `Translator`s for one locale. Canonicalising is what makes
177
+ * `cachedFormatter`'s bound a bound on LOCALES rather than on spellings; lowercasing is what keeps
178
+ * the key equal to the one an app registered (`zh-hant`, never ICU's `zh-Hant`), so a later
179
+ * `registerCatalog` still drops the entry a request built.
180
+ *
181
+ * A tag `canonicalLocale` refuses (`en_US`, `''`, `not a locale`) is not a spelling of anything, so
182
+ * it collapses onto ONE key rather than keying on itself: a bound that a request value can key into
183
+ * counts junk against the cap and evicts locales that are real. Registered spellings are the
184
+ * exception and key on themselves — an app that registered a tag ICU will not canonicalise must
185
+ * still be handed its own catalog, and `registerCatalog` stores before it evicts, so both sides of
186
+ * that decision read the same registry.
187
+ */
188
+ function translatorKey(locale: Locale): string {
189
+ const canonical = canonicalLocale(locale);
190
+ if (canonical !== undefined) return canonical.toLowerCase();
191
+ return registry.has(locale) ? locale.toLowerCase() : INVALID_LOCALE_KEY;
192
+ }
193
+
194
+ /**
195
+ * The registered spelling `key` stands for, or `undefined` when no catalog was registered for it.
196
+ *
197
+ * The caller's own spelling wins when it was registered, so an app that registered `en` keeps
198
+ * answering `en`. Otherwise the registry is read through the SAME canonical key the cache is
199
+ * keyed by: `registerCatalog('en-US', …)` followed by `translatorFor('en-us')` matched neither
200
+ * `registry.has(locale)` nor `catalogFor(key)`, so it cached an EMPTY translator under `en-us` —
201
+ * and the later `translatorFor('en-US')` was served that same empty translator from the cache.
202
+ * Which of the two spellings a request carried first decided whether the app's strings rendered.
203
+ */
204
+ function registeredUnder(locale: Locale, key: string): Locale | undefined {
205
+ if (registry.has(locale)) return locale;
206
+ for (const registered of registry.keys()) {
207
+ if (translatorKey(registered) === key) return registered;
208
+ }
209
+ return undefined;
210
+ }
162
211
 
163
212
  /**
164
213
  * The layer under every app catalog: strings this framework ships and no app can be asked to
@@ -197,7 +246,7 @@ function installBase(): void {
197
246
  for (const [locale, catalog] of base) {
198
247
  const existing = registry.get(locale);
199
248
  registry.set(locale, existing === undefined ? catalog : mergeCatalogs(catalog, existing));
200
- translators.delete(locale);
249
+ translators.delete(translatorKey(locale));
201
250
  }
202
251
  }
203
252
 
@@ -208,7 +257,7 @@ function installBase(): void {
208
257
  export function registerCatalog(locale: Locale, catalog: Catalog): void {
209
258
  const existing = registry.get(locale);
210
259
  registry.set(locale, existing === undefined ? catalog : mergeCatalogs(existing, catalog));
211
- translators.delete(locale);
260
+ translators.delete(translatorKey(locale));
212
261
  }
213
262
 
214
263
  export function registeredLocales(): Locale[] {
@@ -224,11 +273,13 @@ export function catalogFor(locale: Locale): Catalog {
224
273
  * `TCatalog` narrows the key type only; the runtime catalog is whatever is registered.
225
274
  */
226
275
  export function translatorFor<TCatalog = Catalog>(locale: Locale): Translator<TCatalog> {
227
- let translator = translators.get(locale);
228
- if (translator === undefined) {
229
- translator = createTranslator(catalogFor(locale), locale);
230
- translators.set(locale, translator);
231
- }
276
+ const key = translatorKey(locale);
277
+ // Resolved BEFORE the memo, so what lands under `key` is the catalog that key stands for and
278
+ // not whichever spelling asked first. The caller's own tag reaches `createTranslator` either
279
+ // way — the catalog is chosen by locale, the formatting by what the caller actually sent.
280
+ const translator = cachedFormatter(translators, key, () =>
281
+ createTranslator(catalogFor(registeredUnder(locale, key) ?? locale), locale),
282
+ );
232
283
  // One cast, one place. `TCatalog` narrows the key parameter and nothing else, so the memoized
233
284
  // object already is the right value — the registry cannot be keyed by an app's catalog type.
234
285
  return translator as unknown as Translator<TCatalog>;
package/src/errors.ts CHANGED
@@ -57,7 +57,7 @@ export function catalogMissingKeys(locale: string, keys: readonly string[]): I18
57
57
  return new I18nError({
58
58
  code: 'X_CATALOG_MISSING_KEYS',
59
59
  cause: `packages/i18n/catalogs/${locale}.json is missing ${keys.length} key(s) used in source: ${shown.join(', ')}${suffix}`,
60
- fix: `x i18n sync ${locale}`,
60
+ fix: `x i18n sync ${locale} # writes each key above as ⟦key⟧ — replace every one with the real string`,
61
61
  });
62
62
  }
63
63
 
@@ -3,6 +3,8 @@
3
3
  * Pure string work: it never reads a catalog and never resolves a locale.
4
4
  */
5
5
 
6
+ import { cachedFormatter, canonicalLocale } from '@ultimat3/core';
7
+
6
8
  export type InterpolationValue = string | number | boolean;
7
9
  export type InterpolationVars = Record<string, InterpolationValue>;
8
10
 
@@ -108,16 +110,25 @@ export function pluralVariantsOf(key: string): string[] {
108
110
 
109
111
  const rulesCache = new Map<string, Intl.PluralRules>();
110
112
 
113
+ /**
114
+ * Bounded and canonically keyed, because `locale` reaches here from an `Accept-Language` header
115
+ * through `t()`. Keyed raw into an unbounded `Map`, every distinct spelling a client sent bought a
116
+ * PERMANENT `Intl.PluralRules` — memory the client chooses. `canonicalLocale` collapses `EN-us`
117
+ * and `en-US` onto one key and `cachedFormatter` caps the rest; neither half is sufficient alone,
118
+ * which is why both come from the one place `@ultimat3/money` and `@ultimat3/time` read them from.
119
+ */
111
120
  function pluralRulesFor(locale: string): Intl.PluralRules {
112
- const cached = rulesCache.get(locale);
113
- if (cached !== undefined) return cached;
114
- // An unknown tag would throw inside Intl; degrade to `en` rather than break a render.
115
- let rules: Intl.PluralRules;
116
- try {
117
- rules = new Intl.PluralRules(locale);
118
- } catch {
119
- rules = new Intl.PluralRules('en');
120
- }
121
- rulesCache.set(locale, rules);
122
- return rules;
121
+ // The canonical tag is the key AND what reaches `Intl`, so the rules a key answers with never
122
+ // depend on which spelling of it arrived first. A tag `canonicalLocale` refuses is not a locale
123
+ // at all and this function already degrades to English for it — so `en` is the key too, rather
124
+ // than one bounded slot per junk string a client chose to send.
125
+ const tag = canonicalLocale(locale) ?? 'en';
126
+ return cachedFormatter(rulesCache, tag, () => {
127
+ try {
128
+ return new Intl.PluralRules(tag);
129
+ } catch {
130
+ // An unknown tag would throw inside Intl; degrade to `en` rather than break a render.
131
+ return new Intl.PluralRules('en');
132
+ }
133
+ });
123
134
  }