@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 +19 -0
- package/package.json +2 -2
- package/src/context.ts +63 -12
- package/src/errors.ts +1 -1
- package/src/interpolate.ts +22 -11
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": "
|
|
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": "
|
|
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,
|
|
151
|
-
//
|
|
152
|
-
//
|
|
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<
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
package/src/interpolate.ts
CHANGED
|
@@ -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
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
}
|