@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,314 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { IcuTranslationProviderSingle } from "./IcuTranslationProviderSingle.js";
3
+ import type { LocaleOfSingle } from "./types.js";
4
+
5
+ type TestSchema = {
6
+ login_button: { en: string; it: string };
7
+ welcome: { en: string; it: string };
8
+ empty_label: { en: string };
9
+ broken: { en: string };
10
+ invoice_count: { en: string; it: string };
11
+ dashboard_status: { en: string; it: string };
12
+ inbox_owner: { en: string; it: string };
13
+ ranking_position: { en: string; it: string };
14
+ account_balance: { en: string; it: string };
15
+ appointment_summary: { en: string; it: string };
16
+ };
17
+
18
+ type TestParams = {
19
+ login_button: never;
20
+ welcome: { name: string };
21
+ empty_label: never;
22
+ broken: { name: string };
23
+ invoice_count: { count: number };
24
+ dashboard_status: { msgCount: number; chatCount: number };
25
+ inbox_owner: { gender: string; name: string };
26
+ ranking_position: { position: number };
27
+ account_balance: { amount: number };
28
+ appointment_summary: { dueDate: Date | number; startTime: Date | number };
29
+ };
30
+
31
+ const dictionary: TestSchema = {
32
+ login_button: { en: "Login", it: "Accedi" },
33
+ welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
34
+ empty_label: { en: "" },
35
+ broken: { en: "Hi {name" },
36
+ invoice_count: {
37
+ en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
38
+ it: "Hai {count, plural, one {1 fattura} other {{count} fatture}}",
39
+ },
40
+ dashboard_status: {
41
+ en: "You have {msgCount, plural, one {1 message} other {{msgCount} messages}} in {chatCount, plural, one {one chat} other {{chatCount} chats}}",
42
+ it: "Hai {msgCount, plural, one {1 messaggio} other {{msgCount} messaggi}} in {chatCount, plural, one {una chat} other {{chatCount} chat}}",
43
+ },
44
+ inbox_owner: {
45
+ en: "{gender, select, female {{name} owns her inbox} male {{name} owns his inbox} other {{name} owns their inbox}}",
46
+ it: "{gender, select, other {{name} gestisce la propria casella}}",
47
+ },
48
+ ranking_position: {
49
+ en: "You finished {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}",
50
+ it: "Hai concluso al {position, selectordinal, one {#°} other {#°}} posto",
51
+ },
52
+ account_balance: {
53
+ en: "Balance: {amount, number, ::currency/EUR}",
54
+ it: "Saldo: {amount, number, ::currency/EUR}",
55
+ },
56
+ appointment_summary: {
57
+ en: "Due {dueDate, date, short} at {startTime, time, short}",
58
+ it: "Scade il {dueDate, date, short} alle {startTime, time, short}",
59
+ },
60
+ };
61
+
62
+ describe("IcuTranslationProviderSingle", () => {
63
+ const provider = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary);
64
+
65
+ it("returns a static translation without params", () => {
66
+ expect(provider.get("login_button", "it")).toBe("Accedi");
67
+ });
68
+
69
+ it("interpolates ICU arguments", () => {
70
+ expect(provider.get("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
71
+ });
72
+
73
+ describe("ICU plural interpolation", () => {
74
+ it("formats a single numeric plural in English and Italian", () => {
75
+ expect(provider.get("invoice_count", "en", { count: 1 })).toBe("You have 1 invoice");
76
+ expect(provider.get("invoice_count", "en", { count: 5 })).toBe("You have 5 invoices");
77
+ expect(provider.get("invoice_count", "it", { count: 1 })).toBe("Hai 1 fattura");
78
+ expect(provider.get("invoice_count", "it", { count: 5 })).toBe("Hai 5 fatture");
79
+ });
80
+
81
+ it("formats nested numeric plurals with double-brace references", () => {
82
+ expect(provider.get("dashboard_status", "en", { msgCount: 1, chatCount: 1 })).toBe(
83
+ "You have 1 message in one chat"
84
+ );
85
+ expect(provider.get("dashboard_status", "en", { msgCount: 3, chatCount: 2 })).toBe(
86
+ "You have 3 messages in 2 chats"
87
+ );
88
+ expect(provider.get("dashboard_status", "it", { msgCount: 1, chatCount: 1 })).toBe(
89
+ "Hai 1 messaggio in una chat"
90
+ );
91
+ expect(provider.get("dashboard_status", "it", { msgCount: 3, chatCount: 2 })).toBe(
92
+ "Hai 3 messaggi in 2 chat"
93
+ );
94
+ });
95
+
96
+ it("throws when a required numeric plural parameter is missing", () => {
97
+ expect(() => provider.get("dashboard_status", "en", { msgCount: 1 } as never)).toThrow(
98
+ "[i18n Formatting Error]"
99
+ );
100
+ });
101
+ });
102
+
103
+ describe("additional ICU variants", () => {
104
+ it("formats select arguments as strings", () => {
105
+ expect(provider.get("inbox_owner", "en", { gender: "female", name: "Ada" })).toBe(
106
+ "Ada owns her inbox"
107
+ );
108
+ expect(provider.get("inbox_owner", "en", { gender: "unknown", name: "Sam" })).toBe(
109
+ "Sam owns their inbox"
110
+ );
111
+ });
112
+
113
+ it("formats Italian select with a single other branch and the same params", () => {
114
+ expect(provider.get("inbox_owner", "it", { gender: "female", name: "Ada" })).toBe(
115
+ "Ada gestisce la propria casella"
116
+ );
117
+ expect(provider.get("inbox_owner", "it", { gender: "male", name: "Luca" })).toBe(
118
+ "Luca gestisce la propria casella"
119
+ );
120
+ });
121
+
122
+ it("formats selectordinal arguments as numbers", () => {
123
+ expect(provider.get("ranking_position", "en", { position: 1 })).toBe("You finished 1st");
124
+ expect(provider.get("ranking_position", "en", { position: 2 })).toBe("You finished 2nd");
125
+ expect(provider.get("ranking_position", "en", { position: 3 })).toBe("You finished 3rd");
126
+ expect(provider.get("ranking_position", "en", { position: 4 })).toBe("You finished 4th");
127
+ });
128
+
129
+ it("formats number, date, and time arguments", () => {
130
+ const amount = 1234.5;
131
+ const when = new Date("2026-07-01T13:30:00Z");
132
+
133
+ const expectedAmount = new Intl.NumberFormat("en", {
134
+ style: "currency",
135
+ currency: "EUR",
136
+ }).format(amount);
137
+ const expectedDate = new Intl.DateTimeFormat("en", {
138
+ dateStyle: "short",
139
+ }).format(when);
140
+ const expectedTime = new Intl.DateTimeFormat("en", {
141
+ timeStyle: "short",
142
+ }).format(when);
143
+
144
+ expect(provider.get("account_balance", "en", { amount })).toBe(`Balance: ${expectedAmount}`);
145
+ expect(
146
+ provider.get("appointment_summary", "en", {
147
+ dueDate: when,
148
+ startTime: when,
149
+ })
150
+ ).toBe(`Due ${expectedDate} at ${expectedTime}`);
151
+ });
152
+ });
153
+
154
+ it("treats an empty string template as valid", () => {
155
+ expect(provider.get("empty_label", "en")).toBe("");
156
+ });
157
+
158
+ it("throws when the key or locale is missing", () => {
159
+ expect(() =>
160
+ provider.get("login_button", "fr" as unknown as LocaleOfSingle<TestSchema>)
161
+ ).toThrow('[i18n] Missing key or locale: "login_button" [fr] (fallback chain: fr)');
162
+ expect(() => provider.get("missing_key" as "login_button", "en")).toThrow(
163
+ '[i18n] Missing key or locale: "missing_key" [en] (fallback chain: en)'
164
+ );
165
+ });
166
+
167
+ it("throws on malformed ICU syntax", () => {
168
+ expect(() => provider.get("broken", "en", { name: "Ada" })).toThrow("[i18n ICU Syntax Error]");
169
+ });
170
+
171
+ it("throws when required parameters are missing", () => {
172
+ // @ts-expect-error
173
+ expect(() => provider.get("welcome", "en")).toThrow("[i18n Formatting Error]");
174
+ });
175
+
176
+ it("replaces the dictionary and invalidates cache on setAll", () => {
177
+ const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary);
178
+ expect(local.get("login_button", "en")).toBe("Login");
179
+
180
+ local.setAll({
181
+ ...dictionary,
182
+ login_button: { en: "Sign in", it: "Entra" },
183
+ });
184
+
185
+ expect(local.get("login_button", "en")).toBe("Sign in");
186
+ });
187
+
188
+ it("returns a deep-frozen snapshot from getAll", () => {
189
+ expect(provider.getAll()).toEqual(dictionary);
190
+ expect(provider.getAll()).not.toBe(dictionary);
191
+ expect(Object.isFrozen(provider.getAll())).toBe(true);
192
+ });
193
+
194
+ it("does not mutate the provider when getAll snapshot is modified", () => {
195
+ const snapshot = provider.getAll();
196
+ expect(() => {
197
+ snapshot.welcome.en = "Hacked";
198
+ }).toThrow(TypeError);
199
+ expect(provider.get("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
200
+ });
201
+
202
+ it("does not reflect external dictionary mutations after construction", () => {
203
+ const external = {
204
+ login_button: { en: "Login", it: "Accedi" },
205
+ welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
206
+ empty_label: { en: "" },
207
+ broken: { en: "Hi {name" },
208
+ invoice_count: {
209
+ en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
210
+ it: "Hai {count, plural, one {1 fattura} other {{count} fatture}}",
211
+ },
212
+ dashboard_status: dictionary.dashboard_status,
213
+ inbox_owner: dictionary.inbox_owner,
214
+ ranking_position: dictionary.ranking_position,
215
+ account_balance: dictionary.account_balance,
216
+ appointment_summary: dictionary.appointment_summary,
217
+ };
218
+ const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(external);
219
+ external.login_button.en = "Sign in";
220
+ expect(local.get("login_button", "en")).toBe("Login");
221
+ });
222
+
223
+ describe("forLocale", () => {
224
+ it("binds a locale so get() no longer requires it", () => {
225
+ const en = provider.forLocale("en");
226
+
227
+ expect(en.locale).toBe("en");
228
+ expect(en.get("login_button")).toBe("Login");
229
+ expect(en.get("welcome", { name: "Ada" })).toBe("Welcome Ada!");
230
+ });
231
+
232
+ it("uses the parent provider cache and fallback rules", () => {
233
+ const fallbackMap = {
234
+ en: null,
235
+ "de-CH": "en",
236
+ } as const;
237
+ const fallbackProvider = new IcuTranslationProviderSingle<
238
+ TestSchema,
239
+ TestParams,
240
+ keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
241
+ typeof fallbackMap
242
+ >(dictionary, { localeFallback: fallbackMap });
243
+ const deCh = fallbackProvider.forLocale("de-CH");
244
+
245
+ expect(deCh.get("login_button")).toBe("Login");
246
+ });
247
+ });
248
+
249
+ describe("locale fallback", () => {
250
+ const fallbackMap = {
251
+ en: null,
252
+ "de-DE": "en",
253
+ "de-CH": "de-DE",
254
+ it: "en",
255
+ } as const;
256
+
257
+ const fallbackProvider = new IcuTranslationProviderSingle<
258
+ TestSchema,
259
+ TestParams,
260
+ keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
261
+ typeof fallbackMap
262
+ >(dictionary, { localeFallback: fallbackMap });
263
+
264
+ it("resolves a locale through the fallback chain", () => {
265
+ expect(fallbackProvider.get("login_button", "de-CH")).toBe("Login");
266
+ });
267
+
268
+ it("uses an intermediate locale template when available", () => {
269
+ const withGerman = new IcuTranslationProviderSingle<
270
+ TestSchema & {
271
+ login_button: { en: string; it: string; "de-DE": string };
272
+ },
273
+ TestParams,
274
+ keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
275
+ typeof fallbackMap
276
+ >(
277
+ {
278
+ ...dictionary,
279
+ login_button: { en: "Login", it: "Accedi", "de-DE": "Anmelden" },
280
+ },
281
+ { localeFallback: fallbackMap }
282
+ );
283
+
284
+ expect(withGerman.get("login_button", "de-CH")).toBe("Anmelden");
285
+ });
286
+
287
+ it("throws when the fallback chain cannot resolve a template", () => {
288
+ const unresolvedFallback = {
289
+ en: null,
290
+ "de-CH": "fr",
291
+ fr: null,
292
+ } as const;
293
+ const unresolvedProvider = new IcuTranslationProviderSingle<
294
+ TestSchema,
295
+ TestParams,
296
+ keyof typeof unresolvedFallback | LocaleOfSingle<TestSchema>,
297
+ typeof unresolvedFallback
298
+ >(dictionary, { localeFallback: unresolvedFallback });
299
+
300
+ expect(() => unresolvedProvider.get("login_button", "de-CH")).toThrow(
301
+ '[i18n] Missing key or locale: "login_button" [de-CH] (fallback chain: de-CH → fr)'
302
+ );
303
+ });
304
+
305
+ it("rejects circular fallback maps at construction", () => {
306
+ expect(
307
+ () =>
308
+ new IcuTranslationProviderSingle(dictionary, {
309
+ localeFallback: { a: "b", b: "a" },
310
+ })
311
+ ).toThrow("[i18n] Circular locale fallback detected");
312
+ });
313
+ });
314
+ });
@@ -0,0 +1,155 @@
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
+ KeyDictionary,
11
+ LocaleFallbackMap,
12
+ LocaleOfSingle,
13
+ SingleCompiledCache,
14
+ } from "./types.js";
15
+
16
+ export interface TranslationProviderSingleForLocale<
17
+ Schema extends KeyDictionary,
18
+ Params extends { [K in keyof Schema]: unknown },
19
+ Locale extends string,
20
+ > {
21
+ readonly locale: Locale;
22
+ get<K extends keyof Schema & string>(
23
+ key: K,
24
+ ...params: Params[K] extends never ? [] : [params: Params[K]]
25
+ ): string;
26
+ }
27
+
28
+ export interface TranslationProviderSingle<
29
+ Schema extends KeyDictionary,
30
+ Params extends { [K in keyof Schema]: unknown },
31
+ RequestLocales extends string = LocaleOfSingle<Schema>,
32
+ > {
33
+ get<K extends keyof Schema & string>(
34
+ key: K,
35
+ locale: RequestLocales,
36
+ ...params: Params[K] extends never ? [] : [params: Params[K]]
37
+ ): string;
38
+ forLocale<Locale extends RequestLocales>(
39
+ locale: Locale
40
+ ): TranslationProviderSingleForLocale<Schema, Params, Locale>;
41
+ getAll(): Schema;
42
+ setAll(values: Schema): void;
43
+ }
44
+
45
+ export class IcuTranslationProviderSingleForLocale<
46
+ Schema extends KeyDictionary,
47
+ Params extends { [K in keyof Schema]: unknown },
48
+ RequestLocales extends string,
49
+ Locale extends RequestLocales,
50
+ Fallback extends LocaleFallbackMap | undefined,
51
+ > implements TranslationProviderSingleForLocale<Schema, Params, Locale> {
52
+ constructor(
53
+ private readonly provider: IcuTranslationProviderSingle<
54
+ Schema,
55
+ Params,
56
+ RequestLocales,
57
+ Fallback
58
+ >,
59
+ readonly locale: Locale
60
+ ) {}
61
+
62
+ get<K extends keyof Schema & string>(
63
+ key: K,
64
+ ...args: Params[K] extends never ? [] : [params: Params[K]]
65
+ ): string {
66
+ return (
67
+ this.provider.get as (
68
+ key: K,
69
+ locale: Locale,
70
+ ...params: Params[K] extends never ? [] : [params: Params[K]]
71
+ ) => string
72
+ )(key, this.locale, ...args);
73
+ }
74
+ }
75
+
76
+ export class IcuTranslationProviderSingle<
77
+ Schema extends KeyDictionary,
78
+ Params extends { [K in keyof Schema]: unknown },
79
+ RequestLocales extends string = LocaleOfSingle<Schema>,
80
+ Fallback extends LocaleFallbackMap | undefined = undefined,
81
+ > implements TranslationProviderSingle<Schema, Params, RequestLocales> {
82
+ private dictionary: Schema;
83
+ private compiledCache: SingleCompiledCache = {};
84
+ private readonly localeFallback?: Fallback;
85
+
86
+ constructor(dictionary: Schema, options?: IcuTranslationProviderOptions<Fallback>) {
87
+ this.dictionary = structuredClone(dictionary);
88
+ if (options?.localeFallback) {
89
+ validateLocaleFallback(options.localeFallback);
90
+ this.localeFallback = options.localeFallback;
91
+ }
92
+ }
93
+
94
+ get<K extends keyof Schema & string>(
95
+ key: K,
96
+ locale: RequestLocales,
97
+ ...args: Params[K] extends never ? [] : [params: Params[K]]
98
+ ): string {
99
+ const params = args[0] as Record<string, unknown> | undefined;
100
+ const resolved = resolveLocaleTemplate(this.dictionary[key], locale, this.localeFallback);
101
+
102
+ if (!resolved) {
103
+ const chain = formatLocaleFallbackChain(locale, this.localeFallback);
104
+ throw new Error(
105
+ `[i18n] Missing key or locale: "${String(key)}" [${locale}] (fallback chain: ${chain})`
106
+ );
107
+ }
108
+
109
+ const { template, resolvedLocale } = resolved;
110
+
111
+ if (!this.compiledCache[resolvedLocale]) {
112
+ this.compiledCache[resolvedLocale] = {};
113
+ }
114
+
115
+ const cacheKey = String(key);
116
+
117
+ if (!this.compiledCache[resolvedLocale][cacheKey]) {
118
+ try {
119
+ this.compiledCache[resolvedLocale][cacheKey] = new IntlMessageFormat(
120
+ template,
121
+ resolvedLocale
122
+ );
123
+ } catch (err) {
124
+ const message = err instanceof Error ? err.message : String(err);
125
+ throw new Error(
126
+ `[i18n ICU Syntax Error] Dictionary error for key "${String(key)}" [${resolvedLocale}]: ${message}`
127
+ );
128
+ }
129
+ }
130
+
131
+ try {
132
+ return this.compiledCache[resolvedLocale][cacheKey].format(params) as string;
133
+ } catch (err) {
134
+ const message = err instanceof Error ? err.message : String(err);
135
+ throw new Error(
136
+ `[i18n Formatting Error] Invalid or missing parameters for key "${String(key)}" [${resolvedLocale}]: ${message}`
137
+ );
138
+ }
139
+ }
140
+
141
+ forLocale<Locale extends RequestLocales>(
142
+ locale: Locale
143
+ ): IcuTranslationProviderSingleForLocale<Schema, Params, RequestLocales, Locale, Fallback> {
144
+ return new IcuTranslationProviderSingleForLocale(this, locale);
145
+ }
146
+
147
+ getAll(): Schema {
148
+ return cloneAndFreeze(this.dictionary);
149
+ }
150
+
151
+ setAll(values: Schema): void {
152
+ this.dictionary = structuredClone(values);
153
+ this.compiledCache = {};
154
+ }
155
+ }
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import type { CodegenConfig, LoadOnInitResolution, NamespaceEntry } from "./types.js";
3
+ import { fail } from "./paths.js";
4
+
5
+ export function loadConfig(configPath: string): CodegenConfig {
6
+ const raw = fs.readFileSync(configPath, "utf8");
7
+ return JSON.parse(raw) as CodegenConfig;
8
+ }
9
+
10
+ export function resolveNamespaces(config: CodegenConfig): NamespaceEntry[] {
11
+ const hasDictionary = Boolean(config.dictionary);
12
+ const hasNamespaces = Boolean(config.namespaces);
13
+
14
+ if (hasDictionary === hasNamespaces) {
15
+ throw new Error(
16
+ '[Codegen Error] Config must specify exactly one of "dictionary" or "namespaces".'
17
+ );
18
+ }
19
+
20
+ if (hasDictionary) {
21
+ return [
22
+ {
23
+ namespace: config.defaultNamespace ?? "default",
24
+ filePath: config.dictionary!,
25
+ },
26
+ ];
27
+ }
28
+
29
+ return Object.entries(config.namespaces!).map(([namespace, filePath]) => ({
30
+ namespace,
31
+ filePath,
32
+ }));
33
+ }
34
+
35
+ export function resolveLoadOnInit(
36
+ config: CodegenConfig,
37
+ entries: NamespaceEntry[],
38
+ isSingle: boolean
39
+ ): LoadOnInitResolution {
40
+ if (isSingle) {
41
+ if (config.loadOnInit) {
42
+ fail('[Codegen Error] "loadOnInit" is only supported in multi mode (namespaces config).');
43
+ }
44
+ const all = new Set(entries.map((entry) => entry.namespace));
45
+ return { loadOnInitSet: all, lazyEntries: [], hasLazy: false };
46
+ }
47
+
48
+ const allNamespaces = new Set(entries.map((entry) => entry.namespace));
49
+ const loadOnInitSet = config.loadOnInit
50
+ ? new Set(config.loadOnInit)
51
+ : new Set(entries.map((entry) => entry.namespace));
52
+
53
+ for (const namespace of loadOnInitSet) {
54
+ if (!allNamespaces.has(namespace)) {
55
+ fail(
56
+ `[Codegen Error] loadOnInit: namespace "${namespace}" is not defined in namespaces config.`
57
+ );
58
+ }
59
+ }
60
+
61
+ const lazyEntries = entries.filter((entry) => !loadOnInitSet.has(entry.namespace));
62
+ return { loadOnInitSet, lazyEntries, hasLazy: lazyEntries.length > 0 };
63
+ }
@@ -0,0 +1,73 @@
1
+ import path from "node:path";
2
+ import {
3
+ GENERATED_FILE_BANNER,
4
+ toImportIdentifier,
5
+ toImportPath,
6
+ toModuleBasename,
7
+ } from "../paths.js";
8
+ import type { NamespaceEntry } from "../types.js";
9
+
10
+ export interface DictionaryFileOptions {
11
+ isSingle: boolean;
12
+ hasLazy: boolean;
13
+ entries: NamespaceEntry[];
14
+ eagerEntries: NamespaceEntry[];
15
+ projectRoot: string;
16
+ dictionaryOutputPath: string;
17
+ typesOutputPath: string;
18
+ schemaTypeName: string;
19
+ }
20
+
21
+ export function formatDictionaryFile(options: DictionaryFileOptions): string {
22
+ const {
23
+ isSingle,
24
+ hasLazy,
25
+ entries,
26
+ eagerEntries,
27
+ projectRoot,
28
+ dictionaryOutputPath,
29
+ typesOutputPath,
30
+ schemaTypeName,
31
+ } = options;
32
+
33
+ const typesModule = toModuleBasename(typesOutputPath);
34
+
35
+ if (isSingle) {
36
+ const entry = entries[0]!;
37
+ const importPath = toImportPath(
38
+ dictionaryOutputPath,
39
+ path.resolve(projectRoot, entry.filePath)
40
+ );
41
+ const importId = toImportIdentifier(entry.namespace);
42
+
43
+ return (
44
+ `${GENERATED_FILE_BANNER}` +
45
+ `import ${importId} from '${importPath}.json';\n` +
46
+ `import type { ${schemaTypeName} } from './${typesModule}.js';\n\n` +
47
+ `export const dictionary: ${schemaTypeName} = ${importId};\n`
48
+ );
49
+ }
50
+
51
+ const dictionaryTypeName = hasLazy ? "InitialSchema" : schemaTypeName;
52
+
53
+ const imports = eagerEntries
54
+ .map((entry) => {
55
+ const importPath = toImportPath(
56
+ dictionaryOutputPath,
57
+ path.resolve(projectRoot, entry.filePath)
58
+ );
59
+ return `import ${toImportIdentifier(entry.namespace)} from '${importPath}.json';`;
60
+ })
61
+ .join("\n");
62
+
63
+ const objectEntries = eagerEntries
64
+ .map((entry) => ` ${entry.namespace}: ${toImportIdentifier(entry.namespace)},`)
65
+ .join("\n");
66
+
67
+ return (
68
+ `${GENERATED_FILE_BANNER}` +
69
+ `${imports}\n` +
70
+ `import type { ${dictionaryTypeName} } from './${typesModule}.js';\n\n` +
71
+ `export const dictionary: ${dictionaryTypeName} = {\n${objectEntries}\n};\n`
72
+ );
73
+ }