@ultimat3/i18n 1.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -0
  3. package/package.json +35 -0
  4. package/src/catalog.d.ts +28 -0
  5. package/src/catalog.d.ts.map +1 -0
  6. package/src/catalog.js +81 -0
  7. package/src/catalog.js.map +1 -0
  8. package/src/catalog.ts +132 -0
  9. package/src/catalogs/en.json +212 -0
  10. package/src/context.d.ts +64 -0
  11. package/src/context.d.ts.map +1 -0
  12. package/src/context.js +132 -0
  13. package/src/context.js.map +1 -0
  14. package/src/context.ts +195 -0
  15. package/src/define-catalogs.ts +56 -0
  16. package/src/errors.d.ts +22 -0
  17. package/src/errors.d.ts.map +1 -0
  18. package/src/errors.js +52 -0
  19. package/src/errors.js.map +1 -0
  20. package/src/errors.ts +73 -0
  21. package/src/extract.d.ts +67 -0
  22. package/src/extract.d.ts.map +1 -0
  23. package/src/extract.js +106 -0
  24. package/src/extract.js.map +1 -0
  25. package/src/extract.ts +184 -0
  26. package/src/framework.d.ts +12 -0
  27. package/src/framework.d.ts.map +1 -0
  28. package/src/framework.js +16 -0
  29. package/src/framework.js.map +1 -0
  30. package/src/framework.ts +19 -0
  31. package/src/index.d.ts +10 -0
  32. package/src/index.d.ts.map +1 -0
  33. package/src/index.js +10 -0
  34. package/src/index.js.map +1 -0
  35. package/src/index.ts +101 -0
  36. package/src/interpolate.d.ts +36 -0
  37. package/src/interpolate.d.ts.map +1 -0
  38. package/src/interpolate.js +96 -0
  39. package/src/interpolate.js.map +1 -0
  40. package/src/interpolate.ts +103 -0
  41. package/src/locales.d.ts +36 -0
  42. package/src/locales.d.ts.map +1 -0
  43. package/src/locales.js +141 -0
  44. package/src/locales.js.map +1 -0
  45. package/src/locales.ts +166 -0
  46. package/src/translator.d.ts +25 -0
  47. package/src/translator.d.ts.map +1 -0
  48. package/src/translator.js +37 -0
  49. package/src/translator.js.map +1 -0
  50. package/src/translator.ts +87 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Resolve the request locale once, then read it from the ALS context.
3
+ * No call site passes a locale by hand — `t()` here is the ambient translator.
4
+ */
5
+ import { type Ctx } from '@ultimat3/core';
6
+ import { type Catalog } from './catalog';
7
+ import { type Direction, type Locale } from './locales';
8
+ import { type TranslateVars, type Translator } from './translator';
9
+ /** The cookie an explicit language switcher writes. */
10
+ export declare const LOCALE_COOKIE = "x_locale";
11
+ export interface LocaleSources {
12
+ /** Raw `Accept-Language` header value. */
13
+ header?: string | null;
14
+ /** Value of the `x_locale` cookie, already URL-decoded. */
15
+ cookie?: string | null;
16
+ /** `user.locale` from the authenticated user record. */
17
+ user?: string | null;
18
+ /** `?locale=es` — mobile and email preview links pass it per request. */
19
+ query?: string | null;
20
+ }
21
+ export type LocaleSourceName = keyof LocaleSources;
22
+ export interface LocaleResolution {
23
+ locale: Locale;
24
+ direction: Direction;
25
+ /** Which source won — surfaced in the request log so a wrong locale is debuggable. */
26
+ source: LocaleSourceName | 'default';
27
+ }
28
+ export interface LocaleConfig {
29
+ supported: readonly Locale[];
30
+ fallback: Locale;
31
+ /** Precedence, highest first. */
32
+ order: readonly LocaleSourceName[];
33
+ }
34
+ /** Called once at boot from `app.config.ts`. */
35
+ export declare function configureLocales(partial: Partial<LocaleConfig>): LocaleConfig;
36
+ export declare function localeConfig(): LocaleConfig;
37
+ /**
38
+ * Header → cookie → user record → query → default, per `config.order`.
39
+ * An unsupported tag is skipped rather than thrown: a stale cookie must not 500 a page.
40
+ */
41
+ export declare function resolveLocale(sources: LocaleSources, overrides?: Partial<LocaleConfig>): LocaleResolution;
42
+ /** Read `x_locale` out of a raw `Cookie` header. */
43
+ export declare function localeCookieOf(cookieHeader?: string | null): string | undefined;
44
+ /** The HTTP layer calls this once per request, before any render. */
45
+ export declare function attachLocale(ctx: Ctx, locale: Locale): Locale;
46
+ export declare function localeOf(ctx: Ctx): Locale;
47
+ /** Ambient locale for the in-flight request; the configured fallback outside one. */
48
+ export declare function currentLocale(): Locale;
49
+ export declare function currentDirection(): Direction;
50
+ /**
51
+ * Register a locale's catalog. Called with the framework catalog first and the app
52
+ * catalog second so app strings override framework strings for the same key.
53
+ */
54
+ export declare function registerCatalog(locale: Locale, catalog: Catalog): void;
55
+ export declare function registeredLocales(): Locale[];
56
+ export declare function catalogFor(locale: Locale): Catalog;
57
+ /** Memoized per locale — building a translator per render is pure waste. */
58
+ export declare function translatorFor(locale: Locale): Translator;
59
+ export declare function currentTranslator(): Translator;
60
+ /** The ambient `t` — what framework and app code calls. Never takes a locale. */
61
+ export declare function t(key: string, vars?: TranslateVars): string;
62
+ /** Test/CLI seam: drop every registered catalog. */
63
+ export declare function resetCatalogs(): void;
64
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["context.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,KAAK,GAAG,EAAc,MAAM,gBAAgB,CAAC;AACtD,OAAO,EAAE,KAAK,OAAO,EAAiB,MAAM,WAAW,CAAC;AACxD,OAAO,EAEL,KAAK,SAAS,EAEd,KAAK,MAAM,EAIZ,MAAM,WAAW,CAAC;AACnB,OAAO,EAAoB,KAAK,aAAa,EAAE,KAAK,UAAU,EAAE,MAAM,cAAc,CAAC;AAErF,uDAAuD;AACvD,eAAO,MAAM,aAAa,aAAa,CAAC;AAKxC,MAAM,WAAW,aAAa;IAC5B,0CAA0C;IAC1C,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,wDAAwD;IACxD,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,MAAM,gBAAgB,GAAG,MAAM,aAAa,CAAC;AAEnD,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,SAAS,CAAC;IACrB,sFAAsF;IACtF,MAAM,EAAE,gBAAgB,GAAG,SAAS,CAAC;CACtC;AAED,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,iCAAiC;IACjC,KAAK,EAAE,SAAS,gBAAgB,EAAE,CAAC;CACpC;AAUD,gDAAgD;AAChD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,CAAC,YAAY,CAAC,GAAG,YAAY,CAG7E;AAED,wBAAgB,YAAY,IAAI,YAAY,CAE3C;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,aAAa,EACtB,SAAS,GAAE,OAAO,CAAC,YAAY,CAAM,GACpC,gBAAgB,CAYlB;AAED,oDAAoD;AACpD,wBAAgB,cAAc,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,CAS/E;AAED,qEAAqE;AACrE,wBAAgB,YAAY,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAG7D;AAED,wBAAgB,QAAQ,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAEzC;AAED,qFAAqF;AACrF,wBAAgB,aAAa,IAAI,MAAM,CAGtC;AAED,wBAAgB,gBAAgB,IAAI,SAAS,CAE5C;AAKD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAItE;AAED,wBAAgB,iBAAiB,IAAI,MAAM,EAAE,CAE5C;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAElD;AAED,4EAA4E;AAC5E,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAMxD;AAED,wBAAgB,iBAAiB,IAAI,UAAU,CAE9C;AAED,iFAAiF;AACjF,wBAAgB,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,aAAa,GAAG,MAAM,CAE3D;AAED,oDAAoD;AACpD,wBAAgB,aAAa,IAAI,IAAI,CAGpC"}
package/src/context.js ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Resolve the request locale once, then read it from the ALS context.
3
+ * No call site passes a locale by hand — `t()` here is the ambient translator.
4
+ */
5
+ import { useContext } from '@ultimat3/core';
6
+ import { mergeCatalogs } from './catalog';
7
+ import { DEFAULT_LOCALE, directionOf, negotiateLocale, normalizeLocale, SUPPORTED_LOCALES, } from './locales';
8
+ import { createTranslator } from './translator';
9
+ /** The cookie an explicit language switcher writes. */
10
+ export const LOCALE_COOKIE = 'x_locale';
11
+ /** Field name on the ALS context. Core owns `Ctx`; it stays a plain string there. */
12
+ const CTX_LOCALE = 'locale';
13
+ const DEFAULT_ORDER = ['header', 'cookie', 'user', 'query'];
14
+ let config = {
15
+ supported: SUPPORTED_LOCALES,
16
+ fallback: DEFAULT_LOCALE,
17
+ order: DEFAULT_ORDER,
18
+ };
19
+ /** Called once at boot from `app.config.ts`. */
20
+ export function configureLocales(partial) {
21
+ config = { ...config, ...partial };
22
+ return config;
23
+ }
24
+ export function localeConfig() {
25
+ return config;
26
+ }
27
+ /**
28
+ * Header → cookie → user record → query → default, per `config.order`.
29
+ * An unsupported tag is skipped rather than thrown: a stale cookie must not 500 a page.
30
+ */
31
+ export function resolveLocale(sources, overrides = {}) {
32
+ const { supported, fallback, order } = { ...config, ...overrides };
33
+ for (const name of order) {
34
+ const raw = sources[name];
35
+ if (raw === undefined || raw === null || raw === '')
36
+ continue;
37
+ const candidate = name === 'header' ? negotiateLocale(raw, supported, '') : normalizeLocale(raw, supported, '');
38
+ if (candidate !== '') {
39
+ return { locale: candidate, direction: directionOf(candidate), source: name };
40
+ }
41
+ }
42
+ return { locale: fallback, direction: directionOf(fallback), source: 'default' };
43
+ }
44
+ /** Read `x_locale` out of a raw `Cookie` header. */
45
+ export function localeCookieOf(cookieHeader) {
46
+ if (!cookieHeader)
47
+ return undefined;
48
+ for (const pair of cookieHeader.split(';')) {
49
+ const index = pair.indexOf('=');
50
+ if (index === -1)
51
+ continue;
52
+ if (pair.slice(0, index).trim() !== LOCALE_COOKIE)
53
+ continue;
54
+ return decodeURIComponent(pair.slice(index + 1).trim());
55
+ }
56
+ return undefined;
57
+ }
58
+ /** The HTTP layer calls this once per request, before any render. */
59
+ export function attachLocale(ctx, locale) {
60
+ writeContextField(ctx, CTX_LOCALE, locale);
61
+ return locale;
62
+ }
63
+ export function localeOf(ctx) {
64
+ return readField(ctx, CTX_LOCALE) ?? config.fallback;
65
+ }
66
+ /** Ambient locale for the in-flight request; the configured fallback outside one. */
67
+ export function currentLocale() {
68
+ const ctx = tryContext();
69
+ return (ctx === undefined ? undefined : readField(ctx, CTX_LOCALE)) ?? config.fallback;
70
+ }
71
+ export function currentDirection() {
72
+ return directionOf(currentLocale());
73
+ }
74
+ const registry = new Map();
75
+ const translators = new Map();
76
+ /**
77
+ * Register a locale's catalog. Called with the framework catalog first and the app
78
+ * catalog second so app strings override framework strings for the same key.
79
+ */
80
+ export function registerCatalog(locale, catalog) {
81
+ const existing = registry.get(locale);
82
+ registry.set(locale, existing === undefined ? catalog : mergeCatalogs(existing, catalog));
83
+ translators.delete(locale);
84
+ }
85
+ export function registeredLocales() {
86
+ return [...registry.keys()].sort();
87
+ }
88
+ export function catalogFor(locale) {
89
+ return registry.get(locale) ?? {};
90
+ }
91
+ /** Memoized per locale — building a translator per render is pure waste. */
92
+ export function translatorFor(locale) {
93
+ const cached = translators.get(locale);
94
+ if (cached !== undefined)
95
+ return cached;
96
+ const translator = createTranslator(catalogFor(locale), locale);
97
+ translators.set(locale, translator);
98
+ return translator;
99
+ }
100
+ export function currentTranslator() {
101
+ return translatorFor(currentLocale());
102
+ }
103
+ /** The ambient `t` — what framework and app code calls. Never takes a locale. */
104
+ export function t(key, vars) {
105
+ return currentTranslator()(key, vars);
106
+ }
107
+ /** Test/CLI seam: drop every registered catalog. */
108
+ export function resetCatalogs() {
109
+ registry.clear();
110
+ translators.clear();
111
+ }
112
+ function tryContext() {
113
+ try {
114
+ return useContext();
115
+ }
116
+ catch {
117
+ // Outside a request scope (boot, a CLI command, a worker tick) — no ambient locale.
118
+ return undefined;
119
+ }
120
+ }
121
+ /**
122
+ * `Ctx` is owned by core (tier 0) and cannot reference i18n's `Locale`, so the field is
123
+ * read structurally. One place, one cast, validated on the way out.
124
+ */
125
+ function readField(ctx, field) {
126
+ const value = ctx[field];
127
+ return typeof value === 'string' && value !== '' ? value : undefined;
128
+ }
129
+ function writeContextField(ctx, field, value) {
130
+ ctx[field] = value;
131
+ }
132
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["context.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAY,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,EAAgB,aAAa,EAAE,MAAM,WAAW,CAAC;AACxD,OAAO,EACL,cAAc,EAEd,WAAW,EAEX,eAAe,EACf,eAAe,EACf,iBAAiB,GAClB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,gBAAgB,EAAuC,MAAM,cAAc,CAAC;AAErF,uDAAuD;AACvD,MAAM,CAAC,MAAM,aAAa,GAAG,UAAU,CAAC;AAExC,qFAAqF;AACrF,MAAM,UAAU,GAAG,QAAQ,CAAC;AA6B5B,MAAM,aAAa,GAAgC,CAAC,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;AAEzF,IAAI,MAAM,GAAiB;IACzB,SAAS,EAAE,iBAAiB;IAC5B,QAAQ,EAAE,cAAc;IACxB,KAAK,EAAE,aAAa;CACrB,CAAC;AAEF,gDAAgD;AAChD,MAAM,UAAU,gBAAgB,CAAC,OAA8B;IAC7D,MAAM,GAAG,EAAE,GAAG,MAAM,EAAE,GAAG,OAAO,EAAE,CAAC;IACnC,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,YAAY;IAC1B,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAC3B,OAAsB,EACtB,SAAS,GAA0B,EAAE;IAErC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,MAAM,EAAE,GAAG,SAAS,EAAE,CAAC;IACnE,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC1B,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,KAAK,IAAI,IAAI,GAAG,KAAK,EAAE;YAAE,SAAS;QAC9D,MAAM,SAAS,GACb,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,eAAe,CAAC,GAAG,EAAE,SAAS,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,eAAe,CAAC,GAAG,EAAE,SAAS,EAAE,EAAE,CAAC,CAAC;QAChG,IAAI,SAAS,KAAK,EAAE,EAAE,CAAC;YACrB,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,WAAW,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;QAChF,CAAC;IACH,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;AACnF,CAAC;AAED,oDAAoD;AACpD,MAAM,UAAU,cAAc,CAAC,YAA4B;IACzD,IAAI,CAAC,YAAY;QAAE,OAAO,SAAS,CAAC;IACpC,KAAK,MAAM,IAAI,IAAI,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QAC3C,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAChC,IAAI,KAAK,KAAK,CAAC,CAAC;YAAE,SAAS;QAC3B,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,IAAI,EAAE,KAAK,aAAa;YAAE,SAAS;QAC5D,OAAO,kBAAkB,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IAC1D,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,qEAAqE;AACrE,MAAM,UAAU,YAAY,CAAC,GAAQ,EAAE,MAAc;IACnD,iBAAiB,CAAC,GAAG,EAAE,UAAU,EAAE,MAAM,CAAC,CAAC;IAC3C,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,GAAQ;IAC/B,OAAO,SAAS,CAAC,GAAG,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC;AACvD,CAAC;AAED,qFAAqF;AACrF,MAAM,UAAU,aAAa;IAC3B,MAAM,GAAG,GAAG,UAAU,EAAE,CAAC;IACzB,OAAO,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,GAAG,EAAE,UAAU,CAAC,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC;AACzF,CAAC;AAED,MAAM,UAAU,gBAAgB;IAC9B,OAAO,WAAW,CAAC,aAAa,EAAE,CAAC,CAAC;AACtC,CAAC;AAED,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAmB,CAAC;AAC5C,MAAM,WAAW,GAAG,IAAI,GAAG,EAAsB,CAAC;AAElD;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,MAAc,EAAE,OAAgB;IAC9D,MAAM,QAAQ,GAAG,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IACtC,QAAQ,CAAC,GAAG,CAAC,MAAM,EAAE,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,aAAa,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC;IAC1F,WAAW,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,iBAAiB;IAC/B,OAAO,CAAC,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;AACrC,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,MAAc;IACvC,OAAO,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;AACpC,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,aAAa,CAAC,MAAc;IAC1C,MAAM,MAAM,GAAG,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IACvC,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC;IACxC,MAAM,UAAU,GAAG,gBAAgB,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC;IAChE,WAAW,CAAC,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACpC,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,MAAM,UAAU,iBAAiB;IAC/B,OAAO,aAAa,CAAC,aAAa,EAAE,CAAC,CAAC;AACxC,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,CAAC,CAAC,GAAW,EAAE,IAAoB;IACjD,OAAO,iBAAiB,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;AACxC,CAAC;AAED,oDAAoD;AACpD,MAAM,UAAU,aAAa;IAC3B,QAAQ,CAAC,KAAK,EAAE,CAAC;IACjB,WAAW,CAAC,KAAK,EAAE,CAAC;AACtB,CAAC;AAED,SAAS,UAAU;IACjB,IAAI,CAAC;QACH,OAAO,UAAU,EAAE,CAAC;IACtB,CAAC;IAAC,MAAM,CAAC;QACP,oFAAoF;QACpF,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,SAAS,SAAS,CAAC,GAAQ,EAAE,KAAa;IACxC,MAAM,KAAK,GAAI,GAA0C,CAAC,KAAK,CAAC,CAAC;IACjE,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AACvE,CAAC;AAED,SAAS,iBAAiB,CAAC,GAAQ,EAAE,KAAa,EAAE,KAAa;IAC9D,GAA0C,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC;AAC7D,CAAC"}
package/src/context.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Resolve the request locale once, then read it from the ALS context.
3
+ * No call site passes a locale by hand — `t()` here is the ambient translator.
4
+ */
5
+
6
+ import { type Ctx, useContext } from '@ultimat3/core';
7
+ import { type Catalog, mergeCatalogs } from './catalog';
8
+ import {
9
+ DEFAULT_LOCALE,
10
+ type Direction,
11
+ directionOf,
12
+ type Locale,
13
+ negotiateLocale,
14
+ normalizeLocale,
15
+ SUPPORTED_LOCALES,
16
+ } from './locales';
17
+ import { createTranslator, type TranslateVars, type Translator } from './translator';
18
+
19
+ /** The cookie an explicit language switcher writes. */
20
+ export const LOCALE_COOKIE = 'x_locale';
21
+
22
+ /** Field name on the ALS context. Core owns `Ctx`; it stays a plain string there. */
23
+ const CTX_LOCALE = 'locale';
24
+
25
+ export interface LocaleSources {
26
+ /** Raw `Accept-Language` header value. */
27
+ header?: string | null;
28
+ /** Value of the `x_locale` cookie, already URL-decoded. */
29
+ cookie?: string | null;
30
+ /** `user.locale` from the authenticated user record. */
31
+ user?: string | null;
32
+ /** `?locale=es` — mobile and email preview links pass it per request. */
33
+ query?: string | null;
34
+ }
35
+
36
+ export type LocaleSourceName = keyof LocaleSources;
37
+
38
+ export interface LocaleResolution {
39
+ locale: Locale;
40
+ direction: Direction;
41
+ /** Which source won — surfaced in the request log so a wrong locale is debuggable. */
42
+ source: LocaleSourceName | 'default';
43
+ }
44
+
45
+ export interface LocaleConfig {
46
+ supported: readonly Locale[];
47
+ fallback: Locale;
48
+ /** Precedence, highest first. */
49
+ order: readonly LocaleSourceName[];
50
+ }
51
+
52
+ const DEFAULT_ORDER: readonly LocaleSourceName[] = ['header', 'cookie', 'user', 'query'];
53
+
54
+ let config: LocaleConfig = {
55
+ supported: SUPPORTED_LOCALES,
56
+ fallback: DEFAULT_LOCALE,
57
+ order: DEFAULT_ORDER,
58
+ };
59
+
60
+ /** Called once at boot from `app.config.ts`. */
61
+ export function configureLocales(partial: Partial<LocaleConfig>): LocaleConfig {
62
+ config = { ...config, ...partial };
63
+ return config;
64
+ }
65
+
66
+ export function localeConfig(): LocaleConfig {
67
+ return config;
68
+ }
69
+
70
+ /**
71
+ * Header → cookie → user record → query → default, per `config.order`.
72
+ * An unsupported tag is skipped rather than thrown: a stale cookie must not 500 a page.
73
+ */
74
+ export function resolveLocale(
75
+ sources: LocaleSources,
76
+ overrides: Partial<LocaleConfig> = {},
77
+ ): LocaleResolution {
78
+ const { supported, fallback, order } = { ...config, ...overrides };
79
+ for (const name of order) {
80
+ const raw = sources[name];
81
+ if (raw === undefined || raw === null || raw === '') continue;
82
+ const candidate =
83
+ name === 'header' ? negotiateLocale(raw, supported, '') : normalizeLocale(raw, supported, '');
84
+ if (candidate !== '') {
85
+ return { locale: candidate, direction: directionOf(candidate), source: name };
86
+ }
87
+ }
88
+ return { locale: fallback, direction: directionOf(fallback), source: 'default' };
89
+ }
90
+
91
+ /** Read `x_locale` out of a raw `Cookie` header. */
92
+ export function localeCookieOf(cookieHeader?: string | null): string | undefined {
93
+ if (!cookieHeader) return undefined;
94
+ for (const pair of cookieHeader.split(';')) {
95
+ const index = pair.indexOf('=');
96
+ if (index === -1) continue;
97
+ if (pair.slice(0, index).trim() !== LOCALE_COOKIE) continue;
98
+ return decodeURIComponent(pair.slice(index + 1).trim());
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ /** The HTTP layer calls this once per request, before any render. */
104
+ export function attachLocale(ctx: Ctx, locale: Locale): Locale {
105
+ writeContextField(ctx, CTX_LOCALE, locale);
106
+ return locale;
107
+ }
108
+
109
+ export function localeOf(ctx: Ctx): Locale {
110
+ return readField(ctx, CTX_LOCALE) ?? config.fallback;
111
+ }
112
+
113
+ /** Ambient locale for the in-flight request; the configured fallback outside one. */
114
+ export function currentLocale(): Locale {
115
+ const ctx = tryContext();
116
+ return (ctx === undefined ? undefined : readField(ctx, CTX_LOCALE)) ?? config.fallback;
117
+ }
118
+
119
+ export function currentDirection(): Direction {
120
+ return directionOf(currentLocale());
121
+ }
122
+
123
+ const registry = new Map<Locale, Catalog>();
124
+ const translators = new Map<Locale, Translator>();
125
+
126
+ /**
127
+ * Register a locale's catalog. Called with the framework catalog first and the app
128
+ * catalog second so app strings override framework strings for the same key.
129
+ */
130
+ export function registerCatalog(locale: Locale, catalog: Catalog): void {
131
+ const existing = registry.get(locale);
132
+ registry.set(locale, existing === undefined ? catalog : mergeCatalogs(existing, catalog));
133
+ translators.delete(locale);
134
+ }
135
+
136
+ export function registeredLocales(): Locale[] {
137
+ return [...registry.keys()].sort();
138
+ }
139
+
140
+ export function catalogFor(locale: Locale): Catalog {
141
+ return registry.get(locale) ?? {};
142
+ }
143
+
144
+ /**
145
+ * Memoized per locale — building a translator per render is pure waste.
146
+ * `TCatalog` narrows the key type only; the runtime catalog is whatever is registered.
147
+ */
148
+ export function translatorFor<TCatalog = Catalog>(locale: Locale): Translator<TCatalog> {
149
+ let translator = translators.get(locale);
150
+ if (translator === undefined) {
151
+ translator = createTranslator(catalogFor(locale), locale);
152
+ translators.set(locale, translator);
153
+ }
154
+ // One cast, one place. `TCatalog` narrows the key parameter and nothing else, so the memoized
155
+ // object already is the right value — the registry cannot be keyed by an app's catalog type.
156
+ return translator as unknown as Translator<TCatalog>;
157
+ }
158
+
159
+ /** The ambient translator for the in-flight request. Pass the app's catalog type to get typed keys. */
160
+ export function useI18n<TCatalog = Catalog>(): Translator<TCatalog> {
161
+ return translatorFor<TCatalog>(currentLocale());
162
+ }
163
+
164
+ /** The ambient `t` — what framework and app code calls. Never takes a locale. */
165
+ export function t(key: string, vars?: TranslateVars): string {
166
+ return useI18n()(key, vars);
167
+ }
168
+
169
+ /** Test/CLI seam: drop every registered catalog. */
170
+ export function resetCatalogs(): void {
171
+ registry.clear();
172
+ translators.clear();
173
+ }
174
+
175
+ function tryContext(): Ctx | undefined {
176
+ try {
177
+ return useContext();
178
+ } catch {
179
+ // Outside a request scope (boot, a CLI command, a worker tick) — no ambient locale.
180
+ return undefined;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * `Ctx` is owned by core (tier 0) and cannot reference i18n's `Locale`, so the field is
186
+ * read structurally. One place, one cast, validated on the way out.
187
+ */
188
+ function readField(ctx: Ctx, field: string): string | undefined {
189
+ const value = (ctx as unknown as Record<string, unknown>)[field];
190
+ return typeof value === 'string' && value !== '' ? value : undefined;
191
+ }
192
+
193
+ function writeContextField(ctx: Ctx, field: string, value: string): void {
194
+ (ctx as unknown as Record<string, unknown>)[field] = value;
195
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The one call an app makes to declare its catalogs: validate and flatten every locale,
3
+ * register it under the framework's own strings, and configure the supported set.
4
+ * Boot-time only — nothing here runs per request.
5
+ */
6
+
7
+ import { type Catalog, catalogKeys, loadCatalog, mergeCatalogs } from './catalog';
8
+ import { configureLocales, registerCatalog } from './context';
9
+ import { localeUnsupported } from './errors';
10
+ import { registerFrameworkCatalog } from './framework';
11
+
12
+ /** Locale tag → the catalog file's parsed contents, nested exactly as authored. */
13
+ export type CatalogSources = Readonly<Record<string, unknown>>;
14
+
15
+ export interface DefineCatalogsInput<TLocales extends CatalogSources> {
16
+ /** The locale an unresolved request falls back to. Must be a key of `locales`. */
17
+ readonly default: keyof TLocales & string;
18
+ /** One entry per shipped locale — `{ en, es }` after importing the JSON files. */
19
+ readonly locales: TLocales;
20
+ }
21
+
22
+ export interface CatalogSet<TLocales extends CatalogSources> {
23
+ readonly default: keyof TLocales & string;
24
+ readonly locales: readonly (keyof TLocales & string)[];
25
+ /** Flattened app strings only. Framework strings are registered, never copied in here. */
26
+ readonly catalogs: Readonly<Record<keyof TLocales & string, Catalog>>;
27
+ /** Every dot-key across every locale, sorted — the app's key space. */
28
+ keys(): string[];
29
+ }
30
+
31
+ export function defineCatalogs<TLocales extends CatalogSources>(
32
+ input: DefineCatalogsInput<TLocales>,
33
+ ): CatalogSet<TLocales> {
34
+ const locales = Object.keys(input.locales) as (keyof TLocales & string)[];
35
+ if (!locales.includes(input.default)) throw localeUnsupported(input.default, locales);
36
+
37
+ // Load everything before registering anything: a malformed catalog must fail the boot
38
+ // whole, not leave half the locales live and the other half missing.
39
+ const loaded = locales.map((locale) => [locale, loadCatalog(input.locales[locale])] as const);
40
+
41
+ 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
+ registerCatalog(locale, catalog);
46
+ }
47
+ configureLocales({ supported: locales, fallback: input.default });
48
+
49
+ const catalogs = Object.fromEntries(loaded) as Readonly<Record<keyof TLocales & string, Catalog>>;
50
+ return {
51
+ default: input.default,
52
+ locales,
53
+ catalogs,
54
+ keys: () => catalogKeys(mergeCatalogs(...loaded.map(([, catalog]) => catalog))),
55
+ };
56
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The X_* error codes owned by @ultimat3/i18n.
3
+ * Every throw carries a stable code, a cause, and a command that fixes it.
4
+ */
5
+ import { UltimateError } from '@ultimat3/core';
6
+ export declare const I18N_ERROR_CODES: readonly ['X_LOCALE_UNSUPPORTED', 'X_CATALOG_MISSING_KEYS', 'X_CATALOG_INVALID'];
7
+ export type I18nErrorCode = (typeof I18N_ERROR_CODES)[number];
8
+ export declare class I18nError extends UltimateError {
9
+ constructor(init: {
10
+ code: I18nErrorCode;
11
+ cause: string;
12
+ fix: string;
13
+ });
14
+ }
15
+ export declare function localeUnsupported(tag: string, supported: readonly string[]): I18nError;
16
+ /**
17
+ * `fix` must name the locale file and the exact keys — an agent reading this
18
+ * error should not have to diff two catalogs to learn what to write.
19
+ */
20
+ export declare function catalogMissingKeys(locale: string, keys: readonly string[]): I18nError;
21
+ export declare function catalogInvalid(path: string, cause: string): I18nError;
22
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["errors.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAE/C,eAAO,MAAM,gBAAgB,YAC3B,sBAAsB,EACtB,wBAAwB,EACxB,mBAAmB,CACX,CAAC;AAEX,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE9D,qBAAa,SAAU,SAAQ,aAAa;IAC1C,YAAY,IAAI,EAAE;QAAE,IAAI,EAAE,aAAa,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,EAOpE;CACF;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAMtF;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAQrF;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,CAMrE"}
package/src/errors.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The X_* error codes owned by @ultimat3/i18n.
3
+ * Every throw carries a stable code, a cause, and a command that fixes it.
4
+ */
5
+ import { UltimateError } from '@ultimat3/core';
6
+ export const I18N_ERROR_CODES = [
7
+ 'X_LOCALE_UNSUPPORTED',
8
+ 'X_CATALOG_MISSING_KEYS',
9
+ 'X_CATALOG_INVALID',
10
+ ];
11
+ export class I18nError extends UltimateError {
12
+ constructor(init) {
13
+ super({
14
+ code: init.code,
15
+ cause: init.cause,
16
+ fix: init.fix,
17
+ docs: `https://ultimate.dev/errors/${init.code}`,
18
+ });
19
+ }
20
+ }
21
+ export function localeUnsupported(tag, supported) {
22
+ return new I18nError({
23
+ code: 'X_LOCALE_UNSUPPORTED',
24
+ cause: `locale "${tag}" is not in the supported set [${supported.join(', ')}]`,
25
+ fix: `x i18n add ${normalizeForFix(tag)}`,
26
+ });
27
+ }
28
+ /**
29
+ * `fix` must name the locale file and the exact keys — an agent reading this
30
+ * error should not have to diff two catalogs to learn what to write.
31
+ */
32
+ export function catalogMissingKeys(locale, keys) {
33
+ const shown = keys.slice(0, 12);
34
+ const suffix = keys.length > shown.length ? `, +${keys.length - shown.length} more` : '';
35
+ return new I18nError({
36
+ code: 'X_CATALOG_MISSING_KEYS',
37
+ cause: `packages/i18n/catalogs/${locale}.json is missing ${keys.length} key(s) used in source: ${shown.join(', ')}${suffix}`,
38
+ fix: `x i18n sync ${locale}`,
39
+ });
40
+ }
41
+ export function catalogInvalid(path, cause) {
42
+ return new I18nError({
43
+ code: 'X_CATALOG_INVALID',
44
+ cause: `catalog entry "${path}": ${cause}`,
45
+ fix: 'x i18n check --json',
46
+ });
47
+ }
48
+ function normalizeForFix(tag) {
49
+ const primary = tag.split('-')[0] ?? tag;
50
+ return primary.toLowerCase().replace(/[^a-z]/g, '') || 'xx';
51
+ }
52
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["errors.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAE/C,MAAM,CAAC,MAAM,gBAAgB,GAAG;IAC9B,sBAAsB;IACtB,wBAAwB;IACxB,mBAAmB;CACX,CAAC;AAIX,MAAM,OAAO,SAAU,SAAQ,aAAa;IAC1C,YAAY,IAAyD;QACnE,KAAK,CAAC;YACJ,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,GAAG,EAAE,IAAI,CAAC,GAAG;YACb,IAAI,EAAE,+BAA+B,IAAI,CAAC,IAAI,EAAE;SACjD,CAAC,CAAC;IACL,CAAC;CACF;AAED,MAAM,UAAU,iBAAiB,CAAC,GAAW,EAAE,SAA4B;IACzE,OAAO,IAAI,SAAS,CAAC;QACnB,IAAI,EAAE,sBAAsB;QAC5B,KAAK,EAAE,WAAW,GAAG,kCAAkC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG;QAC9E,GAAG,EAAE,cAAc,eAAe,CAAC,GAAG,CAAC,EAAE;KAC1C,CAAC,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,MAAc,EAAE,IAAuB;IACxE,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAChC,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;IACzF,OAAO,IAAI,SAAS,CAAC;QACnB,IAAI,EAAE,wBAAwB;QAC9B,KAAK,EAAE,0BAA0B,MAAM,oBAAoB,IAAI,CAAC,MAAM,2BAA2B,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,MAAM,EAAE;QAC5H,GAAG,EAAE,eAAe,MAAM,EAAE;KAC7B,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,IAAY,EAAE,KAAa;IACxD,OAAO,IAAI,SAAS,CAAC;QACnB,IAAI,EAAE,mBAAmB;QACzB,KAAK,EAAE,kBAAkB,IAAI,MAAM,KAAK,EAAE;QAC1C,GAAG,EAAE,qBAAqB;KAC3B,CAAC,CAAC;AACL,CAAC;AAED,SAAS,eAAe,CAAC,GAAW;IAClC,MAAM,OAAO,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC;IACzC,OAAO,OAAO,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,IAAI,IAAI,CAAC;AAC9D,CAAC"}
package/src/errors.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The X_* error codes owned by @ultimat3/i18n.
3
+ * Every throw carries a stable code, a cause, and a command that fixes it.
4
+ */
5
+
6
+ import { registerErrorCodes, UltimateError } from '@ultimat3/core';
7
+
8
+ export const I18N_ERROR_CODES = [
9
+ 'X_LOCALE_UNSUPPORTED',
10
+ 'X_CATALOG_MISSING_KEYS',
11
+ 'X_CATALOG_INVALID',
12
+ ] as const;
13
+
14
+ export type I18nErrorCode = (typeof I18N_ERROR_CODES)[number];
15
+
16
+ export const I18N_ERROR_TITLES: Readonly<Record<I18nErrorCode, string>> = {
17
+ X_LOCALE_UNSUPPORTED: 'locale is not in the supported set',
18
+ X_CATALOG_MISSING_KEYS: 'a catalog is missing keys used in source',
19
+ X_CATALOG_INVALID: 'a catalog entry is malformed',
20
+ };
21
+
22
+ // Titles must be registered for format() to render the contract's first line. Every code above is
23
+ // owned here and none is borrowed, so the call is unconditional: a second package claiming one has
24
+ // to fail as X_ERROR_CODE_DUPLICATE, not quietly keep whichever title was registered first.
25
+ registerErrorCodes(
26
+ Object.fromEntries(Object.entries(I18N_ERROR_TITLES).map(([code, title]) => [code, { title }])),
27
+ );
28
+
29
+ export class I18nError extends UltimateError {
30
+ constructor(init: { code: I18nErrorCode; cause: string; fix: string }) {
31
+ super({
32
+ code: init.code,
33
+ cause: init.cause,
34
+ fix: init.fix,
35
+ docs: `https://ultimate.dev/errors/${init.code}`,
36
+ });
37
+ }
38
+ }
39
+
40
+ export function localeUnsupported(tag: string, supported: readonly string[]): I18nError {
41
+ return new I18nError({
42
+ code: 'X_LOCALE_UNSUPPORTED',
43
+ cause: `locale "${tag}" is not in the supported set [${supported.join(', ')}]`,
44
+ fix: `x i18n add ${normalizeForFix(tag)}`,
45
+ });
46
+ }
47
+
48
+ /**
49
+ * `fix` must name the locale file and the exact keys — an agent reading this
50
+ * error should not have to diff two catalogs to learn what to write.
51
+ */
52
+ export function catalogMissingKeys(locale: string, keys: readonly string[]): I18nError {
53
+ const shown = keys.slice(0, 12);
54
+ const suffix = keys.length > shown.length ? `, +${keys.length - shown.length} more` : '';
55
+ return new I18nError({
56
+ code: 'X_CATALOG_MISSING_KEYS',
57
+ cause: `packages/i18n/catalogs/${locale}.json is missing ${keys.length} key(s) used in source: ${shown.join(', ')}${suffix}`,
58
+ fix: `x i18n sync ${locale}`,
59
+ });
60
+ }
61
+
62
+ export function catalogInvalid(path: string, cause: string): I18nError {
63
+ return new I18nError({
64
+ code: 'X_CATALOG_INVALID',
65
+ cause: `catalog entry "${path}": ${cause}`,
66
+ fix: 'x i18n check --json',
67
+ });
68
+ }
69
+
70
+ function normalizeForFix(tag: string): string {
71
+ const primary = tag.split('-')[0] ?? tag;
72
+ return primary.toLowerCase().replace(/[^a-z]/g, '') || 'xx';
73
+ }