@ultimat3/i18n 3.0.0 → 4.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
@@ -22,7 +22,19 @@ Imported by every package that renders a string.
22
22
  `registerCatalog` / `configureLocales` are its internals — an app calling them is a second path.
23
23
  - Reading: `t('ns.key')` for one string, `useI18n<AppCatalog>()` where the keys must be typed.
24
24
  There is no `currentTranslator`; `useI18n` replaced it.
25
+ - **`configureLocales` is process-global and MERGES, so `resetLocaleConfig()` is the only way back.**
26
+ `defineCatalogs()` calls it at an app's module scope; a module evaluates once per `bun test` process,
27
+ so one file that loads an app narrowed `supported` for every file after it and `Accept-Language: de-DE`
28
+ negotiated `en` in a file that never mentioned locales. No partial call can widen the set back. The
29
+ test harness restores it at each file boundary (`@ultimat3/testing`'s `registry-snapshot.ts`).
25
30
  - A miss renders `⟦key⟧`. Never add a fallback locale chain — it hides gaps.
31
+ - **`FRAMEWORK_CATALOG` registers under `FRAMEWORK_CATALOG_LOCALE` (`en`) only**, and
32
+ `registerFrameworkCatalog()` takes no locale — passing one is a compile error. It ran per locale
33
+ until 2026-08, so an `es`-only app served `Page not found` and English `ui.*` chrome with
34
+ `isMiss` reading FALSE, which is the fallback chain the line above refuses, wearing registration
35
+ as a disguise. It is also a real no-op when the locale already has a catalog: `registerCatalog`
36
+ merges its ARGUMENT last, so a second call reverted every app override of a framework key.
37
+ A non-`en` app translates the framework keys it renders into its own catalog.
26
38
  - Only an **own** property of `vars` is a variable — `interpolate` guards with `Object.hasOwn`.
27
39
  A plain object inherits `constructor`, `toString`, `valueOf` and `__proto__`, so a bare
28
40
  `vars[name]` rendered a function's source into the page for a template nobody wrote a variable
package/README.md CHANGED
@@ -35,7 +35,7 @@ export const useT = (): Translator<AppCatalog> => useI18n<AppCatalog>();
35
35
  | One call | Because |
36
36
  |---|---|
37
37
  | validates + flattens every locale before registering any | a malformed catalog fails the boot whole, never half |
38
- | registers the framework catalog first, the app's second | later wins, so app strings override framework strings |
38
+ | registers the framework catalog under `en` first, the app's second | later wins, so app strings override framework strings |
39
39
  | `configureLocales({ supported, fallback })` | the locale set is declared once, not twice |
40
40
  | returns `{ default, locales, catalogs, keys() }` | the app's key space, for tests and tooling |
41
41
 
@@ -90,6 +90,13 @@ wins, which is how an app overrides `errors.notFound.title` without forking the
90
90
  `src/catalogs/en.json` ships the framework's own strings: `errors.*`, `auth.*`,
91
91
  `pagination.*`, `admin.*`, `validation.*`, `common.*`, `time.cron.*`.
92
92
 
93
+ **It is registered under `en` and no other locale** — the golden rule applies to framework strings
94
+ too. An app shipping `es` that has not translated `errors.notFound.title` renders
95
+ `⟦errors.notFound.title⟧` there, not `Page not found`: filling every locale with the English
96
+ catalog is a fallback chain, it reads as `isMiss === false`, and `assertCatalogsComplete` cannot
97
+ see it because `CatalogSet.catalogs` carries app strings only. Translate the framework keys your
98
+ app renders into your own catalog — that is the one path, and it is the same merge an override is.
99
+
93
100
  ## Enforcement
94
101
 
95
102
  `extractKeys()` scans source for `t('...')` calls; `auditCatalogs()` reports keys used,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/i18n",
3
- "version": "3.0.0",
3
+ "version": "4.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": "3.0.0"
34
+ "@ultimat3/core": "4.0.0"
35
35
  }
36
36
  }
package/src/context.ts CHANGED
@@ -19,15 +19,22 @@ import { createTranslator, type TranslateVars, type Translator } from './transla
19
19
  /** The cookie an explicit language switcher writes. */
20
20
  export const LOCALE_COOKIE = 'x_locale';
21
21
 
22
+ /**
23
+ * Every source is `string | null | undefined`. Not decoration: `exactOptionalPropertyTypes` is on,
24
+ * so `?: string | null` refuses an EXPLICIT `undefined` — and `localeCookieOf`, this file's own
25
+ * cookie reader, answers `string | undefined`, so `resolveLocale({ cookie: localeCookieOf(h) })`
26
+ * (the composition the package exists to offer) did not typecheck. `resolveLocale` has skipped
27
+ * `undefined` since it was written; only the declaration disagreed.
28
+ */
22
29
  export interface LocaleSources {
23
30
  /** Raw `Accept-Language` header value. */
24
- header?: string | null;
31
+ header?: string | null | undefined;
25
32
  /** Value of the `x_locale` cookie, already URL-decoded. */
26
- cookie?: string | null;
33
+ cookie?: string | null | undefined;
27
34
  /** `user.locale` from the authenticated user record. */
28
- user?: string | null;
35
+ user?: string | null | undefined;
29
36
  /** `?locale=es` — mobile and email preview links pass it per request. */
30
- query?: string | null;
37
+ query?: string | null | undefined;
31
38
  }
32
39
 
33
40
  export type LocaleSourceName = keyof LocaleSources;
@@ -54,12 +61,30 @@ export interface LocaleConfig {
54
61
  */
55
62
  const DEFAULT_ORDER: readonly LocaleSourceName[] = ['query', 'cookie', 'user', 'header'];
56
63
 
57
- let config: LocaleConfig = {
64
+ /** Hoisted so `resetLocaleConfig` has one value to name, rather than a second literal to drift. */
65
+ const DEFAULT_LOCALE_CONFIG: LocaleConfig = {
58
66
  supported: SUPPORTED_LOCALES,
59
67
  fallback: DEFAULT_LOCALE,
60
68
  order: DEFAULT_ORDER,
61
69
  };
62
70
 
71
+ /**
72
+ * A fresh object AND fresh arrays, every time. Handing out `DEFAULT_LOCALE_CONFIG` itself made the
73
+ * live config the shipped default, so one caller writing through `localeConfig()` corrupted the
74
+ * value `resetLocaleConfig()` restores FROM — and the reset replayed the corruption for the rest of
75
+ * the process. `readonly` in the type stops a compiler, not a caller, and this seam exists because
76
+ * callers do what the types did not expect.
77
+ */
78
+ function freshDefaultConfig(): LocaleConfig {
79
+ return {
80
+ supported: [...DEFAULT_LOCALE_CONFIG.supported],
81
+ fallback: DEFAULT_LOCALE_CONFIG.fallback,
82
+ order: [...DEFAULT_LOCALE_CONFIG.order],
83
+ };
84
+ }
85
+
86
+ let config: LocaleConfig = freshDefaultConfig();
87
+
63
88
  /** Called once at boot from `app.config.ts`. */
64
89
  export function configureLocales(partial: Partial<LocaleConfig>): LocaleConfig {
65
90
  config = { ...config, ...partial };
@@ -120,7 +145,12 @@ export function localeCookieOf(cookieHeader?: string | null): string | undefined
120
145
  */
121
146
  export function currentLocale(): Locale {
122
147
  const locale = tryUseContext()?.locale;
123
- return locale === undefined || locale === '' ? config.fallback : locale;
148
+ if (locale === undefined || locale === '') return config.fallback;
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.
153
+ return normalizeLocale(locale, config.supported, config.fallback);
124
154
  }
125
155
 
126
156
  export function currentDirection(): Direction {
@@ -140,6 +170,15 @@ export function registerCatalog(locale: Locale, catalog: Catalog): void {
140
170
  translators.delete(locale);
141
171
  }
142
172
 
173
+ /**
174
+ * Whether this locale has a catalog at all. Distinct from `catalogFor`, which answers `{}` for
175
+ * "registered but empty" and for "never registered" alike — `registerFrameworkCatalog` needs to
176
+ * tell those apart to stay idempotent.
177
+ */
178
+ export function hasCatalog(locale: Locale): boolean {
179
+ return registry.has(locale);
180
+ }
181
+
143
182
  export function registeredLocales(): Locale[] {
144
183
  return [...registry.keys()].sort();
145
184
  }
@@ -173,6 +212,19 @@ export function t(key: string, vars?: TranslateVars): string {
173
212
  return useI18n()(key, vars);
174
213
  }
175
214
 
215
+ /**
216
+ * Test/CLI seam: back to the shipped supported set, fallback and precedence.
217
+ *
218
+ * `defineCatalogs()` calls `configureLocales()` at an APP's module scope, and a module evaluates
219
+ * once per `bun test` process — so one file that loads an app narrows `supported` for every file
220
+ * after it, and `Accept-Language: de-DE` negotiates `en` in a file that never mentioned locales.
221
+ * `configureLocales` MERGES, so no partial call can widen the set back; only a value the framework
222
+ * owns can, which is why this is a reset and not a documented "remember to restore it".
223
+ */
224
+ export function resetLocaleConfig(): void {
225
+ config = freshDefaultConfig();
226
+ }
227
+
176
228
  /** Test/CLI seam: drop every registered catalog. */
177
229
  export function resetCatalogs(): void {
178
230
  registry.clear();
@@ -38,10 +38,13 @@ export function defineCatalogs<TLocales extends CatalogSources>(
38
38
  // whole, not leave half the locales live and the other half missing.
39
39
  const loaded = locales.map((locale) => [locale, loadCatalog(input.locales[locale])] as const);
40
40
 
41
+ // Framework first, app second. `registerCatalog` merges and the later call wins, which is how an
42
+ // app overrides `errors.notFound.title` without forking the framework catalog. ONCE, not once
43
+ // per locale: the framework catalog is English, and registering it under every locale filled
44
+ // `es` with English for every key the app had not translated — `isMiss` false, gap invisible,
45
+ // the exact fallback chain `⟦key⟧` exists to refuse.
46
+ registerFrameworkCatalog();
41
47
  for (const [locale, catalog] of loaded) {
42
- // Framework first, app second. `registerCatalog` merges and the later call wins, which is
43
- // how an app overrides `errors.notFound.title` without forking the framework catalog.
44
- registerFrameworkCatalog(locale);
45
48
  registerCatalog(locale, catalog);
46
49
  }
47
50
  configureLocales({ supported: locales, fallback: input.default });
package/src/framework.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { type Catalog, loadCatalog } from './catalog';
7
7
  import en from './catalogs/en.json';
8
- import { registerCatalog } from './context';
8
+ import { hasCatalog, registerCatalog } from './context';
9
9
  import { DEFAULT_LOCALE, type Locale } from './locales';
10
10
 
11
11
  /**
@@ -19,7 +19,23 @@ export const FRAMEWORK_CATALOG: Catalog = loadCatalog(en);
19
19
 
20
20
  export const FRAMEWORK_CATALOG_LOCALE: Locale = DEFAULT_LOCALE;
21
21
 
22
- /** Called by the HTTP layer at boot. Idempotent. */
23
- export function registerFrameworkCatalog(locale: Locale = FRAMEWORK_CATALOG_LOCALE): void {
24
- registerCatalog(locale, FRAMEWORK_CATALOG);
22
+ /**
23
+ * Register the framework's own strings under the ONE locale they are written in.
24
+ *
25
+ * **No locale parameter, and that is the fix**: this catalog is English, so registering it under
26
+ * `es` was a fallback chain with no name. An app shipping only `es` served `Page not found` and
27
+ * English `ui.*` chrome with `isMiss` reading FALSE, so nothing downstream could see the gap —
28
+ * the one thing `⟦key⟧` exists to make impossible. A locale argument is now a compile error.
29
+ *
30
+ * **Idempotent by construction, not by comment**: a locale that already has a catalog is left
31
+ * alone. `registerCatalog` merges the existing entry FIRST and its argument second, so a second
32
+ * call after an app registered put the English string back on top of every framework key that app
33
+ * had overridden — silently, and only for the keys it cared enough to translate.
34
+ *
35
+ * `defineCatalogs` is the only production caller; the export exists for boot code that cannot
36
+ * know whether an app has registered yet, which is exactly the call this guard makes safe.
37
+ */
38
+ export function registerFrameworkCatalog(): void {
39
+ if (hasCatalog(FRAMEWORK_CATALOG_LOCALE)) return;
40
+ registerCatalog(FRAMEWORK_CATALOG_LOCALE, FRAMEWORK_CATALOG);
25
41
  }
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export {
26
26
  registerCatalog,
27
27
  registeredLocales,
28
28
  resetCatalogs,
29
+ resetLocaleConfig,
29
30
  resolveLocale,
30
31
  t,
31
32
  translatorFor,