@ultimat3/i18n 5.0.0 → 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 +26 -8
- package/package.json +2 -2
- package/src/context.ts +51 -12
- package/src/define-catalogs.ts +3 -7
- package/src/errors.ts +51 -0
- package/src/framework.ts +17 -22
- package/src/index.ts +9 -5
- package/src/registration.ts +49 -0
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`
|
|
32
|
-
`
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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": "
|
|
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": "
|
|
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
|
-
*
|
|
165
|
-
*
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
*
|
|
175
|
-
*
|
|
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
|
|
179
|
-
|
|
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
|
-
/**
|
|
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
|
}
|
package/src/define-catalogs.ts
CHANGED
|
@@ -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
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
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
|
|
3
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
}
|