@ultimat3/i18n 5.0.1 → 6.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
@@ -12,7 +12,9 @@ Imported by every package that renders a string.
12
12
  | `define-catalogs.ts` | the app's one registration call. Boot only, composes the four below. |
13
13
  | `interpolate.ts` | `{var}` + CLDR plural selection. No catalog access (takes a `has` predicate). |
14
14
  | `locales.ts` | supported set, normalize, negotiate, RTL. No catalogs. |
15
- | `context.ts` | request locale via ALS, the registry, the ambient `t` and `useI18n`. |
15
+ | `context.ts` | request locale via ALS, the registry + the base layer under it, the ambient `t` and `useI18n`. |
16
+ | `framework.ts` | the framework's own strings, installed as the base layer at module scope. |
17
+ | `registration.ts` | are the catalogs an app SHIPS in the registry it READS? No file access. |
16
18
  | `extract.ts` | static scan + audit for `x verify`. |
17
19
  | `catalogs/en.json` | data, not code. |
18
20
 
@@ -28,13 +30,29 @@ Imported by every package that renders a string.
28
30
  negotiated `en` in a file that never mentioned locales. No partial call can widen the set back. The
29
31
  test harness restores it at each file boundary (`@ultimat3/testing`'s `registry-snapshot.ts`).
30
32
  - 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.
33
+ - **`FRAMEWORK_CATALOG` is the base layer**, installed by `framework.ts` at module scope —
34
+ importing `@ultimat3/i18n` is what registers it, so no app can forget it and `resetCatalogs()`
35
+ puts it back. It was `registerFrameworkCatalog()`, whose ONE caller was `defineCatalogs`, so an
36
+ app whose catalog module nothing imported served `⟦errors.notFound.title⟧` on its 404 page and
37
+ `⟦ui.*⟧` in every design-system control, with `x verify` green (issue #249). Under
38
+ `FRAMEWORK_CATALOG_LOCALE` (`en`) **only**: it ran per locale until 2026-08, so an `es`-only app
39
+ served `Page not found` with `isMiss` reading FALSE — the fallback chain the line above refuses,
40
+ wearing registration as a disguise. A non-`en` app translates the framework keys it renders into
41
+ its own catalog.
42
+ - **The base layer merges UNDER whatever `registerCatalog` seated, never over it**, so an app's
43
+ override of a framework key survives every later install and the install is idempotent by
44
+ construction rather than by a module flag `resetCatalogs()` would go stale against.
45
+ `registerBaseCatalog` is **framework packages only** — `@ultimat3/mail` calls it for `mail.*`,
46
+ which had the identical defect and the identical fix. It is not a second app path: everything
47
+ installed there loses to `registerCatalog` on the same key, so the strongest thing an app could
48
+ achieve by calling it is strings its own `defineCatalogs()` overrides. That the rule is
49
+ documented and not a build error is the one hole in this file; closing it is a `boundaries` rule
50
+ refusing the specifier outside `packages/*/src`.
51
+ - **A catalog on disk is not a registered catalog.** `catalogRegistrationGaps` /
52
+ `assertCatalogsRegistered` answer the runtime question per locale — `X_CATALOG_UNREGISTERED` —
53
+ and `x verify`'s `i18n` step is what asks it of an app. An app reads strings through its OWN
54
+ module (`useT()` from `packages/i18n/src/index.ts`), never `t` from `@ultimat3/i18n`, so that
55
+ registration is a consequence of rendering rather than something a boot has to remember.
38
56
  - Only an **own** property of `vars` is a variable — `interpolate` guards with `Object.hasOwn`.
39
57
  A plain object inherits `constructor`, `toString`, `valueOf` and `__proto__`, so a bare
40
58
  `vars[name]` rendered a function's source into the page for a template nobody wrote a variable
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/i18n",
3
- "version": "5.0.1",
3
+ "version": "6.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": "5.0.1"
34
+ "@ultimat3/core": "6.0.0"
35
35
  }
36
36
  }
package/src/context.ts CHANGED
@@ -161,22 +161,54 @@ const registry = new Map<Locale, Catalog>();
161
161
  const translators = new Map<Locale, Translator>();
162
162
 
163
163
  /**
164
- * Register a locale's catalog. Called with the framework catalog first and the app
165
- * catalog second so app strings override framework strings for the same key.
164
+ * The layer under every app catalog: strings this framework ships and no app can be asked to
165
+ * register. `framework.ts` installs its own at module scope, so importing `@ultimat3/i18n` at all
166
+ * is what puts `errors.*`, `auth.*` and `ui.*` in the registry — an app that never reaches
167
+ * `defineCatalogs()` still renders a 404 page in words, not in `⟦errors.notFound.title⟧`.
168
+ * Kept apart from `registry` for one reason: `resetCatalogs()` must be able to drop everything an
169
+ * app registered WITHOUT dropping strings the framework itself renders.
166
170
  */
167
- export function registerCatalog(locale: Locale, catalog: Catalog): void {
168
- const existing = registry.get(locale);
169
- registry.set(locale, existing === undefined ? catalog : mergeCatalogs(existing, catalog));
170
- translators.delete(locale);
171
+ const base = new Map<Locale, Catalog>();
172
+
173
+ /**
174
+ * Install a base layer. **Framework packages only** — a package whose own templates render strings
175
+ * an app never registers (`@ultimat3/mail`'s `mail.*`) calls this at module scope, so importing it
176
+ * is what installs them. An app has exactly one registration call, `defineCatalogs`, and this is
177
+ * not a second one: everything installed here loses to `registerCatalog` on the same key, so the
178
+ * strongest thing an app could achieve by calling it is strings that its own catalog overrides.
179
+ *
180
+ * More than one framework contributor is merged into the base itself rather than fighting over the
181
+ * locale, so a package whose strings arrive later cannot displace `framework.ts`'s.
182
+ */
183
+ export function registerBaseCatalog(locale: Locale, catalog: Catalog): void {
184
+ const existing = base.get(locale);
185
+ base.set(locale, existing === undefined ? catalog : mergeCatalogs(existing, catalog));
186
+ installBase();
187
+ }
188
+
189
+ /**
190
+ * Base UNDER app, never over it — `mergeCatalogs` takes the later argument, so the registered
191
+ * catalog goes second and an app's override of `errors.notFound.title` survives every install.
192
+ * Merging rather than skipping an occupied locale is what makes this order-independent: a base
193
+ * registered after an app's `defineCatalogs()` still lands, and a repeat is a no-op because the
194
+ * app's value already won the same key.
195
+ */
196
+ function installBase(): void {
197
+ for (const [locale, catalog] of base) {
198
+ const existing = registry.get(locale);
199
+ registry.set(locale, existing === undefined ? catalog : mergeCatalogs(catalog, existing));
200
+ translators.delete(locale);
201
+ }
171
202
  }
172
203
 
173
204
  /**
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.
205
+ * Register a locale's catalog. App strings arrive here and override the base layer for the same
206
+ * key, because the merge takes the argument last.
177
207
  */
178
- export function hasCatalog(locale: Locale): boolean {
179
- return registry.has(locale);
208
+ export function registerCatalog(locale: Locale, catalog: Catalog): void {
209
+ const existing = registry.get(locale);
210
+ registry.set(locale, existing === undefined ? catalog : mergeCatalogs(existing, catalog));
211
+ translators.delete(locale);
180
212
  }
181
213
 
182
214
  export function registeredLocales(): Locale[] {
@@ -225,8 +257,15 @@ export function resetLocaleConfig(): void {
225
257
  config = freshDefaultConfig();
226
258
  }
227
259
 
228
- /** Test/CLI seam: drop every registered catalog. */
260
+ /**
261
+ * Test/CLI seam: drop every catalog an APP registered, and put the base layer back.
262
+ *
263
+ * Not a full clear: the framework's own strings are installed by importing this package, once per
264
+ * process, so a clear that took them out could never restore them — every test after the first
265
+ * reset rendered `⟦errors.notFound.title⟧` for a string no app owns.
266
+ */
229
267
  export function resetCatalogs(): void {
230
268
  registry.clear();
231
269
  translators.clear();
270
+ installBase();
232
271
  }
@@ -7,7 +7,6 @@
7
7
  import { type Catalog, catalogKeys, loadCatalog, mergeCatalogs } from './catalog';
8
8
  import { configureLocales, registerCatalog } from './context';
9
9
  import { localeUnsupported } from './errors';
10
- import { registerFrameworkCatalog } from './framework';
11
10
 
12
11
  /** Locale tag → the catalog file's parsed contents, nested exactly as authored. */
13
12
  export type CatalogSources = Readonly<Record<string, unknown>>;
@@ -38,12 +37,9 @@ export function defineCatalogs<TLocales extends CatalogSources>(
38
37
  // whole, not leave half the locales live and the other half missing.
39
38
  const loaded = locales.map((locale) => [locale, loadCatalog(input.locales[locale])] as const);
40
39
 
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();
40
+ // No framework-catalog call here, and that is deliberate: `framework.ts` installs it as the base
41
+ // layer at package import, so it is already under this app's strings whether or not THIS module
42
+ // ever runs. `registerCatalog` merges its argument last, so app strings still win the same keys.
47
43
  for (const [locale, catalog] of loaded) {
48
44
  registerCatalog(locale, catalog);
49
45
  }
package/src/errors.ts CHANGED
@@ -9,6 +9,7 @@ export const I18N_ERROR_CODES = [
9
9
  'X_LOCALE_UNSUPPORTED',
10
10
  'X_CATALOG_MISSING_KEYS',
11
11
  'X_CATALOG_INVALID',
12
+ 'X_CATALOG_UNREGISTERED',
12
13
  ] as const;
13
14
 
14
15
  export type I18nErrorCode = (typeof I18N_ERROR_CODES)[number];
@@ -17,6 +18,7 @@ export const I18N_ERROR_TITLES: Readonly<Record<I18nErrorCode, string>> = {
17
18
  X_LOCALE_UNSUPPORTED: 'locale is not in the supported set',
18
19
  X_CATALOG_MISSING_KEYS: 'a catalog is missing keys used in source',
19
20
  X_CATALOG_INVALID: 'a catalog entry is malformed',
21
+ X_CATALOG_UNREGISTERED: 'a shipped catalog never reached the runtime registry',
20
22
  };
21
23
 
22
24
  // Titles must be registered for format() to render the contract's first line. Every code above is
@@ -59,6 +61,55 @@ export function catalogMissingKeys(locale: string, keys: readonly string[]): I18
59
61
  });
60
62
  }
61
63
 
64
+ /**
65
+ * The keys, capped and comma-joined. Shared by both factories below because a `fix:` an agent can
66
+ * act on has to name keys, and a 300-key catalog rendered whole is a cause nobody reads.
67
+ */
68
+ function shownKeys(keys: readonly string[]): string {
69
+ const shown = keys.slice(0, 8);
70
+ const suffix = keys.length > shown.length ? `, +${keys.length - shown.length} more` : '';
71
+ return `${shown.join(', ')}${suffix}`;
72
+ }
73
+
74
+ /**
75
+ * The fix is an EDIT, not a command, because no command can wire an import: registration is a side
76
+ * effect of importing the module that calls `defineCatalogs()`, and the boot loads app modules by
77
+ * scanning `packages/<pkg>/src` and `apps/<app>/{site,app,api,shared}` — a catalog module outside
78
+ * that tree is never imported, and `x verify` stays green while every string renders a loud miss.
79
+ */
80
+ const REGISTRATION_FIX =
81
+ 'move the defineCatalogs() call into packages/i18n/src/index.ts (where `x new` puts it, inside the boot scan) and read strings through its useT(), never `t` from @ultimat3/i18n; then re-run: x i18n check --json';
82
+
83
+ /**
84
+ * A catalog the app ships that the running app cannot answer from. Issue #249: the file was on
85
+ * disk, its keys were used in source, `x i18n check` and `x verify` were both green, and every
86
+ * page rendered `⟦key⟧` because nothing had imported the module that registers it.
87
+ */
88
+ export function catalogUnregistered(input: {
89
+ locale: string;
90
+ shipped: number;
91
+ missing: readonly string[];
92
+ }): I18nError {
93
+ return new I18nError({
94
+ code: 'X_CATALOG_UNREGISTERED',
95
+ cause: `packages/i18n/catalogs/${input.locale}.json defines ${input.shipped} key(s) and ${input.missing.length} of them are not in the runtime registry after the app's own modules loaded: ${shownKeys(input.missing)} — each renders \u27e6key\u27e7`,
96
+ fix: REGISTRATION_FIX,
97
+ });
98
+ }
99
+
100
+ /**
101
+ * The same failure with nothing on disk to name: source calls `t()` and no catalog anywhere
102
+ * answers. Distinct cause, same code and same fix — an audit that compared a missing file against
103
+ * a missing catalog reported "no gaps", which is the vacuous green this code exists to refuse.
104
+ */
105
+ export function catalogsNeverRegistered(locale: string, unresolved: readonly string[]): I18nError {
106
+ return new I18nError({
107
+ code: 'X_CATALOG_UNREGISTERED',
108
+ cause: `no catalog is registered for "${locale}" and ${unresolved.length} key(s) used in source resolve to nothing: ${shownKeys(unresolved)} — each renders \u27e6key\u27e7`,
109
+ fix: `x i18n add ${locale} # then ${REGISTRATION_FIX}`.trim(),
110
+ });
111
+ }
112
+
62
113
  export function catalogInvalid(path: string, cause: string): I18nError {
63
114
  return new I18nError({
64
115
  code: 'X_CATALOG_INVALID',
package/src/framework.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * The framework's own strings, flattened once at import.
3
- * Register these before app catalogs so an app can override any framework key.
2
+ * The framework's own strings, flattened and installed as the base layer at import.
3
+ * Under every app catalog, so an app overrides any framework key and forgets none.
4
4
  */
5
5
 
6
6
  import { type Catalog, loadCatalog } from './catalog';
7
7
  import en from './catalogs/en.json';
8
- import { hasCatalog, registerCatalog } from './context';
8
+ import { registerBaseCatalog } from './context';
9
9
  import { DEFAULT_LOCALE, type Locale } from './locales';
10
10
 
11
11
  /**
@@ -17,25 +17,20 @@ import { DEFAULT_LOCALE, type Locale } from './locales';
17
17
  */
18
18
  export const FRAMEWORK_CATALOG: Catalog = loadCatalog(en);
19
19
 
20
- export const FRAMEWORK_CATALOG_LOCALE: Locale = DEFAULT_LOCALE;
21
-
22
20
  /**
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.
21
+ * The ONE locale this catalog is written in.
29
22
  *
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.
23
+ * Registering it under every locale was a fallback chain with no name: an app shipping only `es`
24
+ * served `Page not found` and English `ui.*` chrome with `isMiss` reading FALSE, so nothing
25
+ * downstream could see the gap — the one thing `⟦key⟧` exists to make impossible. A non-`en` app
26
+ * translates the framework keys it renders into its own catalog.
37
27
  */
38
- export function registerFrameworkCatalog(): void {
39
- if (hasCatalog(FRAMEWORK_CATALOG_LOCALE)) return;
40
- registerCatalog(FRAMEWORK_CATALOG_LOCALE, FRAMEWORK_CATALOG);
41
- }
28
+ export const FRAMEWORK_CATALOG_LOCALE: Locale = DEFAULT_LOCALE;
29
+
30
+ // At module scope, and that is the whole point: `index.ts` re-exports this file, so anything that
31
+ // can call `t()` has already imported the module that installs these strings. It was a function
32
+ // (`registerFrameworkCatalog`) that only `defineCatalogs` called until 5.1.0 — so an app whose
33
+ // catalog module nothing imported rendered `⟦errors.notFound.title⟧` on its 404 page, `⟦ui.*⟧` in
34
+ // every design-system control, and had no way to notice (issue #249). Registration that an app
35
+ // can forget is not registration; the framework's half of it is now unforgettable by construction.
36
+ registerBaseCatalog(FRAMEWORK_CATALOG_LOCALE, FRAMEWORK_CATALOG);
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  type LocaleSources,
24
24
  localeConfig,
25
25
  localeCookieOf,
26
+ registerBaseCatalog,
26
27
  registerCatalog,
27
28
  registeredLocales,
28
29
  resetCatalogs,
@@ -41,6 +42,8 @@ export {
41
42
  export {
42
43
  catalogInvalid,
43
44
  catalogMissingKeys,
45
+ catalogsNeverRegistered,
46
+ catalogUnregistered,
44
47
  I18N_ERROR_CODES,
45
48
  I18N_ERROR_TITLES,
46
49
  I18nError,
@@ -61,11 +64,7 @@ export {
61
64
  type LocaleAudit,
62
65
  mergeExtractions,
63
66
  } from './extract';
64
- export {
65
- FRAMEWORK_CATALOG,
66
- FRAMEWORK_CATALOG_LOCALE,
67
- registerFrameworkCatalog,
68
- } from './framework';
67
+ export { FRAMEWORK_CATALOG, FRAMEWORK_CATALOG_LOCALE } from './framework';
69
68
  export {
70
69
  type InterpolationValue,
71
70
  type InterpolationVars,
@@ -91,6 +90,11 @@ export {
91
90
  parseAcceptLanguage,
92
91
  SUPPORTED_LOCALES,
93
92
  } from './locales';
93
+ export {
94
+ assertCatalogsRegistered,
95
+ type CatalogRegistrationGap,
96
+ catalogRegistrationGaps,
97
+ } from './registration';
94
98
  export {
95
99
  createTranslator,
96
100
  isMiss,
@@ -0,0 +1,49 @@
1
+ // Whether the catalogs an app SHIPS are the catalogs the running app READS. The audit in
2
+ // `extract.ts` compares source against files on disk and can be green while the registry is empty
3
+ // (issue #249) — registration is a side effect of importing a module, so only the registry knows.
4
+
5
+ import { type Catalog, missingFrom } from './catalog';
6
+ import { catalogFor } from './context';
7
+ import { catalogUnregistered } from './errors';
8
+ import type { Locale } from './locales';
9
+
10
+ /** One locale's shipped catalog measured against the registry the running app reads. */
11
+ export interface CatalogRegistrationGap {
12
+ readonly locale: Locale;
13
+ /** How many keys the shipped catalog defines — the denominator the cause reports against. */
14
+ readonly shipped: number;
15
+ /** Shipped keys the registry cannot answer, sorted. Every one renders a loud miss. */
16
+ readonly missing: readonly string[];
17
+ }
18
+
19
+ /**
20
+ * Per locale, because the registry is: a key registered under `en` does not answer a request that
21
+ * resolved to `es`, and a set-of-all-keys check would call an `es`-only app wired.
22
+ *
23
+ * The caller supplies what the app ships — the parsed `packages/i18n/catalogs/*.json` for a CLI
24
+ * check, `CatalogSet.catalogs` for a boot assertion. This package never reads a file.
25
+ */
26
+ export function catalogRegistrationGaps(
27
+ shipped: Readonly<Record<Locale, Catalog>>,
28
+ ): readonly CatalogRegistrationGap[] {
29
+ const gaps: CatalogRegistrationGap[] = [];
30
+ for (const locale of Object.keys(shipped).sort()) {
31
+ const catalog = shipped[locale];
32
+ if (catalog === undefined) continue;
33
+ const missing = missingFrom(catalog, catalogFor(locale));
34
+ if (missing.length === 0) continue;
35
+ gaps.push({ locale, shipped: Object.keys(catalog).length, missing });
36
+ }
37
+ return gaps;
38
+ }
39
+
40
+ /**
41
+ * The boot-time half: throw on the first locale that did not reach the registry. One locale per
42
+ * throw and not a joined summary, because the `fix:` is the same edit for all of them and a cause
43
+ * naming five locales is one nobody reads to the end.
44
+ */
45
+ export function assertCatalogsRegistered(shipped: Readonly<Record<Locale, Catalog>>): void {
46
+ const gap = catalogRegistrationGaps(shipped)[0];
47
+ if (gap === undefined) return;
48
+ throw catalogUnregistered(gap);
49
+ }