@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 +12 -0
- package/README.md +8 -1
- package/package.json +2 -2
- package/src/context.ts +58 -6
- package/src/define-catalogs.ts +6 -3
- package/src/framework.ts +20 -4
- package/src/index.ts +1 -0
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
|
+
"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": "
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/src/define-catalogs.ts
CHANGED
|
@@ -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
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
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
|
}
|