@xndrjs/i18n 0.2.0-alpha.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/README.md +572 -0
  2. package/bin/codegen.mjs +17 -0
  3. package/bin/setup.mjs +14 -0
  4. package/dist/index.d.ts +124 -0
  5. package/dist/index.js +320 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/types-CFWVT2PP.d.ts +77 -0
  8. package/dist/validation/index.d.ts +44 -0
  9. package/dist/validation/index.js +490 -0
  10. package/dist/validation/index.js.map +1 -0
  11. package/package.json +65 -0
  12. package/src/IcuTranslationProviderMulti.test.ts +290 -0
  13. package/src/IcuTranslationProviderMulti.ts +195 -0
  14. package/src/IcuTranslationProviderSingle.test.ts +314 -0
  15. package/src/IcuTranslationProviderSingle.ts +155 -0
  16. package/src/codegen/config.ts +63 -0
  17. package/src/codegen/emit/dictionary-file.ts +73 -0
  18. package/src/codegen/emit/dictionary-schema-file.ts +129 -0
  19. package/src/codegen/emit/instance-file.ts +60 -0
  20. package/src/codegen/emit/namespace-loaders-file.ts +44 -0
  21. package/src/codegen/emit/types-file.ts +118 -0
  22. package/src/codegen/generate-i18n-types.test.ts +482 -0
  23. package/src/codegen/generate-i18n-types.ts +173 -0
  24. package/src/codegen/icu-analysis.ts +88 -0
  25. package/src/codegen/locale-fallback.ts +64 -0
  26. package/src/codegen/paths.ts +27 -0
  27. package/src/codegen/types.ts +30 -0
  28. package/src/deep-freeze.test.ts +27 -0
  29. package/src/deep-freeze.ts +17 -0
  30. package/src/ensure-namespace.integration.test.ts +66 -0
  31. package/src/ensure-namespace.test.ts +125 -0
  32. package/src/ensure-namespace.ts +70 -0
  33. package/src/icu/extract-variables.ts +87 -0
  34. package/src/icu/parse-template.ts +24 -0
  35. package/src/index.ts +32 -0
  36. package/src/resolve-locale.test.ts +91 -0
  37. package/src/resolve-locale.ts +88 -0
  38. package/src/setup/setup-i18n.test.ts +86 -0
  39. package/src/setup/setup-i18n.ts +179 -0
  40. package/src/setup/type-names.ts +20 -0
  41. package/src/types.ts +34 -0
  42. package/src/validation/create-args-schema.ts +76 -0
  43. package/src/validation/create-normalized-schema.ts +52 -0
  44. package/src/validation/errors.ts +41 -0
  45. package/src/validation/index.ts +90 -0
  46. package/src/validation/normalize.ts +158 -0
  47. package/src/validation/to-dictionary.ts +67 -0
  48. package/src/validation/types.ts +78 -0
  49. package/src/validation/validate-normalized.ts +91 -0
  50. package/src/validation/validation.test.ts +265 -0
@@ -0,0 +1,290 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
3
+ import type { LocaleOfMulti } from "./types.js";
4
+
5
+ type TestSchema = {
6
+ default: {
7
+ login_button: { en: string; it: string };
8
+ welcome: { en: string };
9
+ dashboard_status: { en: string; it: string };
10
+ inbox_owner: { en: string; it: string };
11
+ ranking_position: { en: string; it: string };
12
+ };
13
+ billing: {
14
+ invoice_summary: { en: string; it: string };
15
+ account_balance: { en: string; it: string };
16
+ appointment_summary: { en: string; it: string };
17
+ empty_label: { en: string };
18
+ };
19
+ };
20
+
21
+ type TestParams = {
22
+ default: {
23
+ login_button: never;
24
+ welcome: { name: string };
25
+ dashboard_status: { msgCount: number; chatCount: number };
26
+ inbox_owner: { gender: string; name: string };
27
+ ranking_position: { position: number };
28
+ };
29
+ billing: {
30
+ invoice_summary: { count: number; name: string };
31
+ account_balance: { amount: number };
32
+ appointment_summary: { dueDate: Date | number; startTime: Date | number };
33
+ empty_label: never;
34
+ };
35
+ };
36
+
37
+ const dictionary: TestSchema = {
38
+ default: {
39
+ login_button: { en: "Login", it: "Accedi" },
40
+ welcome: { en: "Welcome {name}!" },
41
+ dashboard_status: {
42
+ en: "You have {msgCount, plural, one {1 message} other {{msgCount} messages}} in {chatCount, plural, one {one chat} other {{chatCount} chats}}",
43
+ it: "Hai {msgCount, plural, one {1 messaggio} other {{msgCount} messaggi}} in {chatCount, plural, one {una chat} other {{chatCount} chat}}",
44
+ },
45
+ inbox_owner: {
46
+ en: "{gender, select, female {{name} owns her inbox} male {{name} owns his inbox} other {{name} owns their inbox}}",
47
+ it: "{gender, select, female {{name} gestisce la sua casella} male {{name} gestisce la sua casella} other {{name} gestisce la propria casella}}",
48
+ },
49
+ ranking_position: {
50
+ en: "You finished {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}",
51
+ it: "Hai concluso al {position, selectordinal, one {#°} other {#°}} posto",
52
+ },
53
+ },
54
+ billing: {
55
+ invoice_summary: {
56
+ en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
57
+ it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
58
+ },
59
+ account_balance: {
60
+ en: "Balance: {amount, number, ::currency/EUR}",
61
+ it: "Saldo: {amount, number, ::currency/EUR}",
62
+ },
63
+ appointment_summary: {
64
+ en: "Due {dueDate, date, short} at {startTime, time, short}",
65
+ it: "Scade il {dueDate, date, short} alle {startTime, time, short}",
66
+ },
67
+ empty_label: { en: "" },
68
+ },
69
+ };
70
+
71
+ describe("IcuTranslationProviderMulti", () => {
72
+ const provider = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary);
73
+
74
+ it("resolves translations by namespace and key", () => {
75
+ expect(provider.get("default", "login_button", "it")).toBe("Accedi");
76
+ expect(provider.get("default", "welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
77
+ });
78
+
79
+ it("formats ICU plurals with multiple parameters", () => {
80
+ expect(provider.get("billing", "invoice_summary", "en", { count: 1, name: "Ada" })).toBe(
81
+ "You have 1 invoice for Ada"
82
+ );
83
+ expect(provider.get("billing", "invoice_summary", "en", { count: 3, name: "Ada" })).toBe(
84
+ "You have 3 invoices for Ada"
85
+ );
86
+ expect(provider.get("billing", "invoice_summary", "it", { count: 1, name: "Ada" })).toBe(
87
+ "Hai 1 fattura per Ada"
88
+ );
89
+ expect(provider.get("billing", "invoice_summary", "it", { count: 3, name: "Ada" })).toBe(
90
+ "Hai 3 fatture per Ada"
91
+ );
92
+ });
93
+
94
+ it("formats nested numeric plurals with double-brace references", () => {
95
+ expect(provider.get("default", "dashboard_status", "en", { msgCount: 1, chatCount: 1 })).toBe(
96
+ "You have 1 message in one chat"
97
+ );
98
+ expect(provider.get("default", "dashboard_status", "en", { msgCount: 3, chatCount: 2 })).toBe(
99
+ "You have 3 messages in 2 chats"
100
+ );
101
+ expect(provider.get("default", "dashboard_status", "it", { msgCount: 1, chatCount: 1 })).toBe(
102
+ "Hai 1 messaggio in una chat"
103
+ );
104
+ expect(provider.get("default", "dashboard_status", "it", { msgCount: 3, chatCount: 2 })).toBe(
105
+ "Hai 3 messaggi in 2 chat"
106
+ );
107
+ });
108
+
109
+ it("formats select and selectordinal ICU variants", () => {
110
+ expect(provider.get("default", "inbox_owner", "en", { gender: "male", name: "Linus" })).toBe(
111
+ "Linus owns his inbox"
112
+ );
113
+ expect(provider.get("default", "ranking_position", "en", { position: 3 })).toBe(
114
+ "You finished 3rd"
115
+ );
116
+ });
117
+
118
+ it("formats number, date, and time ICU variants", () => {
119
+ const amount = 1234.5;
120
+ const when = new Date("2026-07-01T13:30:00Z");
121
+ const expectedAmount = new Intl.NumberFormat("en", {
122
+ style: "currency",
123
+ currency: "EUR",
124
+ }).format(amount);
125
+ const expectedDate = new Intl.DateTimeFormat("en", {
126
+ dateStyle: "short",
127
+ }).format(when);
128
+ const expectedTime = new Intl.DateTimeFormat("en", {
129
+ timeStyle: "short",
130
+ }).format(when);
131
+
132
+ expect(provider.get("billing", "account_balance", "en", { amount })).toBe(
133
+ `Balance: ${expectedAmount}`
134
+ );
135
+ expect(
136
+ provider.get("billing", "appointment_summary", "en", {
137
+ dueDate: when,
138
+ startTime: when,
139
+ })
140
+ ).toBe(`Due ${expectedDate} at ${expectedTime}`);
141
+ });
142
+
143
+ it("treats an empty string template as valid", () => {
144
+ expect(provider.get("billing", "empty_label", "en")).toBe("");
145
+ });
146
+
147
+ it("tracks loaded namespaces with hasNamespace", () => {
148
+ const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({
149
+ default: dictionary.default,
150
+ });
151
+
152
+ expect(partial.hasNamespace("default")).toBe(true);
153
+ expect(partial.hasNamespace("billing")).toBe(false);
154
+ });
155
+
156
+ it("throws when getting from an unloaded namespace", () => {
157
+ const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({
158
+ default: dictionary.default,
159
+ });
160
+
161
+ expect(() =>
162
+ partial.get("billing", "invoice_summary", "en", { count: 1, name: "Ada" })
163
+ ).toThrow(
164
+ '[i18n] Namespace not loaded: "billing". Call ensureNamespacesLoaded(i18n, ["billing"]) first.'
165
+ );
166
+ });
167
+
168
+ it("supports partial initialization", () => {
169
+ const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({
170
+ default: dictionary.default,
171
+ });
172
+
173
+ expect(partial.get("default", "login_button", "en")).toBe("Login");
174
+ });
175
+
176
+ it("throws when namespace, key, or locale is missing", () => {
177
+ expect(() =>
178
+ provider.get("default", "login_button", "fr" as unknown as LocaleOfMulti<TestSchema>)
179
+ ).toThrow(
180
+ '[i18n] Missing key or locale: namespace "default", key "login_button" [fr] (fallback chain: fr)'
181
+ );
182
+ expect(() => provider.get("billing", "login_button" as "empty_label", "en")).toThrow(
183
+ '[i18n] Missing key or locale: namespace "billing", key "login_button" [en] (fallback chain: en)'
184
+ );
185
+ });
186
+
187
+ it("throws when required parameters are missing", () => {
188
+ expect(() => provider.get("billing", "invoice_summary", "en", { count: 1 } as never)).toThrow(
189
+ "[i18n Formatting Error]"
190
+ );
191
+ });
192
+
193
+ it("patches a single namespace with setNamespace and invalidates its cache", () => {
194
+ const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary);
195
+ expect(local.get("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
196
+ "You have 2 invoices for Bob"
197
+ );
198
+
199
+ local.setNamespace("billing", {
200
+ ...dictionary.billing,
201
+ invoice_summary: {
202
+ en: "{count, plural, one {1 bill} other {{count} bills}}",
203
+ it: dictionary.billing.invoice_summary.it,
204
+ },
205
+ });
206
+
207
+ expect(local.get("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
208
+ "2 bills"
209
+ );
210
+ expect(local.get("default", "login_button", "en")).toBe("Login");
211
+ });
212
+
213
+ it("replaces the full dictionary on setAll", () => {
214
+ const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary);
215
+
216
+ local.setAll({
217
+ ...dictionary,
218
+ default: {
219
+ ...dictionary.default,
220
+ login_button: { en: "Sign in", it: "Entra" },
221
+ },
222
+ });
223
+
224
+ expect(local.get("default", "login_button", "en")).toBe("Sign in");
225
+ });
226
+
227
+ it("returns a deep-frozen snapshot from getAll", () => {
228
+ expect(provider.getAll()).toEqual(dictionary);
229
+ expect(provider.getAll()).not.toBe(dictionary);
230
+ expect(Object.isFrozen(provider.getAll().default)).toBe(true);
231
+ });
232
+
233
+ it("does not mutate the provider when getAll snapshot is modified", () => {
234
+ const snapshot = provider.getAll();
235
+ expect(() => {
236
+ snapshot.default.login_button.en = "Hacked";
237
+ }).toThrow(TypeError);
238
+ expect(provider.get("default", "login_button", "en")).toBe("Login");
239
+ });
240
+
241
+ describe("forLocale", () => {
242
+ it("binds a locale so get() no longer requires it", () => {
243
+ const it = provider.forLocale("it");
244
+
245
+ expect(it.locale).toBe("it");
246
+ expect(it.get("default", "login_button")).toBe("Accedi");
247
+ expect(it.get("billing", "invoice_summary", { count: 2, name: "Ada" })).toBe(
248
+ "Hai 2 fatture per Ada"
249
+ );
250
+ });
251
+ });
252
+
253
+ describe("locale fallback", () => {
254
+ const fallbackMap = {
255
+ en: null,
256
+ "de-DE": "en",
257
+ "de-CH": "de-DE",
258
+ it: "en",
259
+ } as const;
260
+
261
+ const fallbackProvider = new IcuTranslationProviderMulti<
262
+ TestSchema,
263
+ TestParams,
264
+ keyof typeof fallbackMap | LocaleOfMulti<TestSchema>,
265
+ typeof fallbackMap
266
+ >(dictionary, { localeFallback: fallbackMap });
267
+
268
+ it("resolves a locale through the fallback chain", () => {
269
+ expect(fallbackProvider.get("default", "login_button", "de-CH")).toBe("Login");
270
+ });
271
+
272
+ it("throws when the fallback chain cannot resolve a template", () => {
273
+ const unresolvedFallback = {
274
+ en: null,
275
+ "de-CH": "fr",
276
+ fr: null,
277
+ } as const;
278
+ const unresolvedProvider = new IcuTranslationProviderMulti<
279
+ TestSchema,
280
+ TestParams,
281
+ keyof typeof unresolvedFallback | LocaleOfMulti<TestSchema>,
282
+ typeof unresolvedFallback
283
+ >(dictionary, { localeFallback: unresolvedFallback });
284
+
285
+ expect(() => unresolvedProvider.get("default", "login_button", "de-CH")).toThrow(
286
+ '[i18n] Missing key or locale: namespace "default", key "login_button" [de-CH] (fallback chain: de-CH → fr)'
287
+ );
288
+ });
289
+ });
290
+ });
@@ -0,0 +1,195 @@
1
+ import { IntlMessageFormat } from "intl-messageformat";
2
+ import { cloneAndFreeze } from "./deep-freeze.js";
3
+ import {
4
+ formatLocaleFallbackChain,
5
+ resolveLocaleTemplate,
6
+ validateLocaleFallback,
7
+ } from "./resolve-locale.js";
8
+ import type {
9
+ IcuTranslationProviderOptions,
10
+ LocaleFallbackMap,
11
+ LocaleOfMulti,
12
+ MultiCompiledCache,
13
+ MultiDictionary,
14
+ } from "./types.js";
15
+
16
+ export interface TranslationProviderMultiForLocale<
17
+ Schema extends MultiDictionary,
18
+ Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
19
+ Locale extends string,
20
+ > {
21
+ readonly locale: Locale;
22
+ get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
23
+ namespace: NS,
24
+ key: K,
25
+ ...params: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
26
+ ): string;
27
+ }
28
+
29
+ export interface TranslationProviderMulti<
30
+ Schema extends MultiDictionary,
31
+ Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
32
+ RequestLocales extends string = LocaleOfMulti<Schema>,
33
+ > {
34
+ get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
35
+ namespace: NS,
36
+ key: K,
37
+ locale: RequestLocales,
38
+ ...params: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
39
+ ): string;
40
+ forLocale<Locale extends RequestLocales>(
41
+ locale: Locale
42
+ ): TranslationProviderMultiForLocale<Schema, Params, Locale>;
43
+ getAll(): Schema;
44
+ hasNamespace<NS extends keyof Schema & string>(namespace: NS): boolean;
45
+ setAll(values: Schema): void;
46
+ setNamespace<NS extends keyof Schema>(namespace: NS, values: Schema[NS]): void;
47
+ }
48
+
49
+ export class IcuTranslationProviderMultiForLocale<
50
+ Schema extends MultiDictionary,
51
+ Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
52
+ RequestLocales extends string,
53
+ Locale extends RequestLocales,
54
+ Fallback extends LocaleFallbackMap | undefined,
55
+ > implements TranslationProviderMultiForLocale<Schema, Params, Locale> {
56
+ constructor(
57
+ private readonly provider: IcuTranslationProviderMulti<
58
+ Schema,
59
+ Params,
60
+ RequestLocales,
61
+ Fallback
62
+ >,
63
+ readonly locale: Locale
64
+ ) {}
65
+
66
+ get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
67
+ namespace: NS,
68
+ key: K,
69
+ ...args: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
70
+ ): string {
71
+ return (
72
+ this.provider.get as <
73
+ NS extends keyof Schema,
74
+ K extends keyof Schema[NS] & keyof Params[NS] & string,
75
+ >(
76
+ namespace: NS,
77
+ key: K,
78
+ locale: Locale,
79
+ ...params: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
80
+ ) => string
81
+ )(namespace, key, this.locale, ...args);
82
+ }
83
+ }
84
+
85
+ export class IcuTranslationProviderMulti<
86
+ Schema extends MultiDictionary,
87
+ Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
88
+ RequestLocales extends string = LocaleOfMulti<Schema>,
89
+ Fallback extends LocaleFallbackMap | undefined = undefined,
90
+ > implements TranslationProviderMulti<Schema, Params, RequestLocales> {
91
+ private dictionary: Schema;
92
+ private compiledCache: MultiCompiledCache = {};
93
+ private readonly localeFallback?: Fallback;
94
+ private loadedNamespaces: Set<string>;
95
+
96
+ constructor(dictionary: Partial<Schema>, options?: IcuTranslationProviderOptions<Fallback>) {
97
+ this.dictionary = structuredClone(dictionary) as Schema;
98
+ this.loadedNamespaces = new Set(Object.keys(dictionary));
99
+ if (options?.localeFallback) {
100
+ validateLocaleFallback(options.localeFallback);
101
+ this.localeFallback = options.localeFallback;
102
+ }
103
+ }
104
+
105
+ get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
106
+ namespace: NS,
107
+ key: K,
108
+ locale: RequestLocales,
109
+ ...args: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
110
+ ): string {
111
+ if (!this.loadedNamespaces.has(namespace as string)) {
112
+ throw new Error(
113
+ `[i18n] Namespace not loaded: "${String(namespace)}". Call ensureNamespacesLoaded(i18n, ["${String(namespace)}"]) first.`
114
+ );
115
+ }
116
+
117
+ const params = args[0] as Record<string, unknown> | undefined;
118
+ const localeByKey = (
119
+ this.dictionary[namespace as string] as Record<string, Record<string, string>>
120
+ )?.[key];
121
+ const resolved = resolveLocaleTemplate(localeByKey, locale, this.localeFallback);
122
+
123
+ if (!resolved) {
124
+ const chain = formatLocaleFallbackChain(locale, this.localeFallback);
125
+ throw new Error(
126
+ `[i18n] Missing key or locale: namespace "${String(namespace)}", key "${String(key)}" [${locale}] (fallback chain: ${chain})`
127
+ );
128
+ }
129
+
130
+ const { template, resolvedLocale } = resolved;
131
+
132
+ if (!this.compiledCache[resolvedLocale]) {
133
+ this.compiledCache[resolvedLocale] = {};
134
+ }
135
+
136
+ if (!this.compiledCache[resolvedLocale][namespace as string]) {
137
+ this.compiledCache[resolvedLocale][namespace as string] = {};
138
+ }
139
+
140
+ const namespaceCache = this.compiledCache[resolvedLocale][namespace as string]!;
141
+ const cacheKey = String(key);
142
+
143
+ if (!namespaceCache[cacheKey]) {
144
+ try {
145
+ namespaceCache[cacheKey] = new IntlMessageFormat(template, resolvedLocale);
146
+ } catch (err) {
147
+ const message = err instanceof Error ? err.message : String(err);
148
+ throw new Error(
149
+ `[i18n ICU Syntax Error] namespace "${String(namespace)}", key "${String(key)}" [${resolvedLocale}]: ${message}`
150
+ );
151
+ }
152
+ }
153
+
154
+ try {
155
+ return namespaceCache[cacheKey].format(params) as string;
156
+ } catch (err) {
157
+ const message = err instanceof Error ? err.message : String(err);
158
+ throw new Error(
159
+ `[i18n Formatting Error] namespace "${String(namespace)}", key "${String(key)}" [${resolvedLocale}]: ${message}`
160
+ );
161
+ }
162
+ }
163
+
164
+ forLocale<Locale extends RequestLocales>(
165
+ locale: Locale
166
+ ): IcuTranslationProviderMultiForLocale<Schema, Params, RequestLocales, Locale, Fallback> {
167
+ return new IcuTranslationProviderMultiForLocale(this, locale);
168
+ }
169
+
170
+ getAll(): Schema {
171
+ return cloneAndFreeze(this.dictionary);
172
+ }
173
+
174
+ hasNamespace<NS extends keyof Schema & string>(namespace: NS): boolean {
175
+ return this.loadedNamespaces.has(namespace);
176
+ }
177
+
178
+ setAll(values: Schema): void {
179
+ this.dictionary = structuredClone(values);
180
+ this.compiledCache = {};
181
+ this.loadedNamespaces = new Set(Object.keys(values));
182
+ }
183
+
184
+ setNamespace<NS extends keyof Schema>(namespace: NS, values: Schema[NS]): void {
185
+ this.dictionary = {
186
+ ...this.dictionary,
187
+ [namespace]: structuredClone(values),
188
+ };
189
+ this.loadedNamespaces.add(namespace as string);
190
+
191
+ for (const locale of Object.keys(this.compiledCache)) {
192
+ delete this.compiledCache[locale]?.[namespace as string];
193
+ }
194
+ }
195
+ }