@xndrjs/i18n 0.7.0 → 0.8.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 (69) hide show
  1. package/README.md +67 -872
  2. package/dist/codegen/index.d.ts +117 -51
  3. package/dist/codegen/index.js +1585 -93
  4. package/dist/codegen/index.js.map +1 -1
  5. package/dist/index.d.ts +100 -234
  6. package/dist/index.js +74 -638
  7. package/dist/index.js.map +1 -1
  8. package/dist/validation/index.d.ts +5 -0
  9. package/dist/validation/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/IcuTranslationProviderMulti.test.ts +18 -40
  12. package/src/IcuTranslationProviderMulti.ts +27 -37
  13. package/src/audit/audit-dictionaries.ts +1 -1
  14. package/src/audit/run-audit.test.ts +6 -7
  15. package/src/builder-load-registry.ts +26 -5
  16. package/src/builder-loaders.ts +8 -11
  17. package/src/builder-types.test.ts +11 -42
  18. package/src/builder.test.ts +82 -243
  19. package/src/builder.ts +8 -110
  20. package/src/codegen/codegen-config-schema.ts +82 -77
  21. package/src/codegen/config.test.ts +55 -143
  22. package/src/codegen/config.ts +10 -67
  23. package/src/codegen/dictionary-spec-contract.test.ts +28 -0
  24. package/src/codegen/dictionary-spec-contract.ts +138 -0
  25. package/src/codegen/emit/dictionary-schema-file.ts +7 -43
  26. package/src/codegen/emit/instance-file.test.ts +35 -52
  27. package/src/codegen/emit/instance-file.ts +170 -268
  28. package/src/codegen/emit/namespace-loaders-file.test.ts +72 -74
  29. package/src/codegen/emit/namespace-loaders-file.ts +147 -90
  30. package/src/codegen/emit/types-file.test.ts +20 -41
  31. package/src/codegen/emit/types-file.ts +48 -74
  32. package/src/codegen/generate-i18n-types.test.ts +163 -492
  33. package/src/codegen/generate-i18n-types.ts +7 -269
  34. package/src/codegen/paths.ts +0 -14
  35. package/src/codegen/project-locales-set-namespace.test.ts +44 -86
  36. package/src/codegen/read-dictionary.test.ts +27 -9
  37. package/src/codegen/read-dictionary.ts +18 -40
  38. package/src/codegen/regenerate-namespaces.ts +180 -0
  39. package/src/codegen/run-codegen.test.ts +230 -0
  40. package/src/codegen/run-codegen.ts +252 -0
  41. package/src/codegen/types.ts +0 -6
  42. package/src/codegen-config/build-config.ts +4 -23
  43. package/src/codegen-config/codegen-config.test.ts +10 -6
  44. package/src/codegen-config/index.ts +18 -3
  45. package/src/engine.ts +3 -27
  46. package/src/fetch-artifact.ts +16 -0
  47. package/src/i18n-handle.ts +202 -0
  48. package/src/index.ts +15 -34
  49. package/src/project-locales.ts +2 -2
  50. package/src/scope-multi.ts +1 -20
  51. package/src/scope-types.ts +14 -6
  52. package/src/scope.test.ts +3 -312
  53. package/src/serialized-state.test.ts +114 -0
  54. package/src/serialized-state.ts +28 -0
  55. package/src/setup/setup-i18n.test.ts +22 -33
  56. package/src/setup/setup-i18n.ts +22 -27
  57. package/src/types.ts +3 -3
  58. package/src/validation/index.ts +4 -0
  59. package/src/IcuTranslationProviderSingle.test.ts +0 -177
  60. package/src/IcuTranslationProviderSingle.ts +0 -114
  61. package/src/builder-multi.ts +0 -481
  62. package/src/codegen/emit/dictionary-file.test.ts +0 -215
  63. package/src/codegen/emit/dictionary-file.ts +0 -244
  64. package/src/deep-freeze.test.ts +0 -40
  65. package/src/deep-freeze.ts +0 -22
  66. package/src/patch-key.test.ts +0 -186
  67. package/src/patch-key.ts +0 -140
  68. package/src/scope-single.ts +0 -85
  69. package/src/single-builder.ts +0 -153
@@ -6,33 +6,6 @@ const projectRoot = "/project";
6
6
  const loadersOutputPath = path.join(projectRoot, "src/i18n/namespace-loaders.generated.ts");
7
7
 
8
8
  describe("formatNamespaceLoadersFile", () => {
9
- it("emits flat loaders in canonical mode", () => {
10
- const output = formatNamespaceLoadersFile({
11
- loadersOutputPath,
12
- lazyEntries: [
13
- {
14
- namespace: "billing",
15
- filePath: "src/i18n/translations/billing.json",
16
- absolutePath: path.join(projectRoot, "src/i18n/translations/billing.json"),
17
- },
18
- ],
19
- schemaTypeName: "AppSchema",
20
- paramsTypeName: "AppParams",
21
- localeTypeName: "AppLocale",
22
- localeFallbackConstName: "LOCALE_FALLBACK",
23
- hasLocaleFallback: false,
24
- typesModule: "i18n-types.generated",
25
- importExtension: "none",
26
- projectRoot,
27
- delivery: "canonical",
28
- });
29
-
30
- expect(output).toContain("[K in LazyNamespace]: () => Promise<AppSchema[K]>");
31
- expect(output).toContain(
32
- "billing: () => import('./translations/billing.json').then((m) => m.default),"
33
- );
34
- });
35
-
36
9
  it("emits ns(locale) loaders in split mode", () => {
37
10
  const output = formatNamespaceLoadersFile({
38
11
  loadersOutputPath,
@@ -54,14 +27,10 @@ describe("formatNamespaceLoadersFile", () => {
54
27
  },
55
28
  ],
56
29
  schemaTypeName: "AppSchema",
57
- paramsTypeName: "AppParams",
58
30
  localeTypeName: "AppLocale",
59
- localeFallbackConstName: "LOCALE_FALLBACK",
60
- hasLocaleFallback: true,
61
31
  typesModule: "i18n-types.generated",
62
32
  importExtension: "none",
63
33
  projectRoot,
64
- isSingle: false,
65
34
  delivery: "split-by-locale",
66
35
  requestLocales: ["en", "it", "de-CH"],
67
36
  splitPathsByNamespace: {
@@ -83,6 +52,9 @@ describe("formatNamespaceLoadersFile", () => {
83
52
  },
84
53
  });
85
54
 
55
+ expect(output).toContain(
56
+ "import type { AppSchema, LazyNamespace, AppLocale } from './i18n-types.generated';"
57
+ );
86
58
  expect(output).toContain("[K in LazyNamespace]: (locale: AppLocale) => Promise<AppSchema[K]>;");
87
59
  expect(output).toContain("billing: (locale) => {");
88
60
  expect(output).toContain('case "en":');
@@ -101,39 +73,6 @@ describe("formatNamespaceLoadersFile", () => {
101
73
  expect(output.match(/from '\.\/i18n-types\.generated'/g)?.length).toBe(1);
102
74
  });
103
75
 
104
- it("emits locale-scoped loaders for single mode in split delivery", () => {
105
- const output = formatNamespaceLoadersFile({
106
- loadersOutputPath,
107
- lazyEntries: [
108
- {
109
- namespace: "default",
110
- filePath: "src/i18n/translations/translations.json",
111
- absolutePath: path.join(projectRoot, "src/i18n/translations/translations.json"),
112
- },
113
- ],
114
- schemaTypeName: "AppSchema",
115
- paramsTypeName: "AppParams",
116
- localeTypeName: "AppLocale",
117
- localeFallbackConstName: "LOCALE_FALLBACK",
118
- hasLocaleFallback: true,
119
- typesModule: "i18n-types.generated",
120
- importExtension: "none",
121
- projectRoot,
122
- isSingle: true,
123
- delivery: "split-by-locale",
124
- requestLocales: ["en", "it"],
125
- splitPathsByNamespace: {
126
- default: {
127
- en: "src/i18n/generated/translations/translations.en.json",
128
- it: "src/i18n/generated/translations/translations.it.json",
129
- },
130
- },
131
- });
132
-
133
- expect(output).toContain("[K in LazyNamespace]: (locale: AppLocale) => Promise<AppSchema>;");
134
- expect(output).toContain('export const defaultLazyNamespaces = ["default"] as const;');
135
- });
136
-
137
76
  it("throws when a split path is missing", () => {
138
77
  expect(() =>
139
78
  formatNamespaceLoadersFile({
@@ -146,10 +85,7 @@ describe("formatNamespaceLoadersFile", () => {
146
85
  },
147
86
  ],
148
87
  schemaTypeName: "AppSchema",
149
- paramsTypeName: "AppParams",
150
88
  localeTypeName: "AppLocale",
151
- localeFallbackConstName: "LOCALE_FALLBACK",
152
- hasLocaleFallback: false,
153
89
  typesModule: "i18n-types.generated",
154
90
  importExtension: "none",
155
91
  projectRoot,
@@ -180,14 +116,10 @@ describe("formatNamespaceLoadersFile", () => {
180
116
  },
181
117
  ],
182
118
  schemaTypeName: "AppSchema",
183
- paramsTypeName: "AppParams",
184
119
  localeTypeName: "AppLocale",
185
- localeFallbackConstName: "LOCALE_FALLBACK",
186
- hasLocaleFallback: true,
187
120
  typesModule: "i18n-types.generated",
188
121
  importExtension: "none",
189
122
  projectRoot,
190
- isSingle: false,
191
123
  delivery: "custom",
192
124
  deliveryAreaTypeName: "AppDeliveryArea",
193
125
  deliveryAreaNames: ["eu", "us"],
@@ -203,6 +135,9 @@ describe("formatNamespaceLoadersFile", () => {
203
135
  },
204
136
  });
205
137
 
138
+ expect(output).toContain(
139
+ "import type { AppSchema, LazyNamespace, AppDeliveryArea } from './i18n-types.generated';"
140
+ );
206
141
  expect(output).toContain(
207
142
  "[K in LazyNamespace]: (area: AppDeliveryArea) => Promise<AppSchema[K]>;"
208
143
  );
@@ -232,10 +167,7 @@ describe("formatNamespaceLoadersFile", () => {
232
167
  },
233
168
  ],
234
169
  schemaTypeName: "AppSchema",
235
- paramsTypeName: "AppParams",
236
170
  localeTypeName: "AppLocale",
237
- localeFallbackConstName: "LOCALE_FALLBACK",
238
- hasLocaleFallback: false,
239
171
  typesModule: "i18n-types.generated",
240
172
  importExtension: "none",
241
173
  projectRoot,
@@ -250,4 +182,70 @@ describe("formatNamespaceLoadersFile", () => {
250
182
  })
251
183
  ).toThrow('[Codegen Error] Missing split path for namespace "billing", area "us".');
252
184
  });
185
+
186
+ it("emits fetch loaders that pass a resource id to fetchImpl", () => {
187
+ const output = formatNamespaceLoadersFile({
188
+ loadersOutputPath,
189
+ lazyEntries: [
190
+ {
191
+ namespace: "billing",
192
+ filePath: "src/i18n/translations/billing.json",
193
+ absolutePath: path.join(projectRoot, "src/i18n/translations/billing.json"),
194
+ },
195
+ ],
196
+ schemaTypeName: "AppSchema",
197
+ localeTypeName: "AppLocale",
198
+ typesModule: "i18n-types.generated",
199
+ importExtension: "none",
200
+ projectRoot,
201
+ delivery: "split-by-locale",
202
+ requestLocales: ["en", "it"],
203
+ loaderStrategy: "fetch",
204
+ splitPathsByNamespace: {
205
+ billing: {
206
+ en: "src/i18n/generated/translations/billing.en.json",
207
+ it: "src/i18n/generated/translations/billing.it.json",
208
+ },
209
+ },
210
+ });
211
+
212
+ expect(output).toContain("export function createNamespaceLoaders");
213
+ expect(output).toContain("import type { FetchArtifact }");
214
+ expect(output).toContain(
215
+ 'fetchImpl({ locale, namespace: "billing" }) as Promise<AppSchema["billing"]>'
216
+ );
217
+ });
218
+
219
+ it("emits custom-delivery fetch loaders with locale + area", () => {
220
+ const output = formatNamespaceLoadersFile({
221
+ loadersOutputPath,
222
+ lazyEntries: [
223
+ {
224
+ namespace: "billing",
225
+ filePath: "src/i18n/translations/billing.json",
226
+ absolutePath: path.join(projectRoot, "src/i18n/translations/billing.json"),
227
+ },
228
+ ],
229
+ schemaTypeName: "AppSchema",
230
+ localeTypeName: "AppLocale",
231
+ typesModule: "i18n-types.generated",
232
+ importExtension: "none",
233
+ projectRoot,
234
+ delivery: "custom",
235
+ deliveryAreaTypeName: "AppDeliveryArea",
236
+ deliveryAreaNames: ["eu", "us"],
237
+ loaderStrategy: "fetch",
238
+ splitPathsByNamespace: {
239
+ billing: {
240
+ eu: "src/i18n/generated/translations/billing.eu.json",
241
+ us: "src/i18n/generated/translations/billing.us.json",
242
+ },
243
+ },
244
+ });
245
+
246
+ expect(output).toContain("(area, { locale }) =>");
247
+ expect(output).toContain(
248
+ 'fetchImpl({ locale, namespace: "billing", area }) as Promise<AppSchema["billing"]>'
249
+ );
250
+ });
253
251
  });
@@ -1,29 +1,26 @@
1
1
  import path from "node:path";
2
- import type { DeliveryMode } from "../codegen-config-schema.js";
2
+ import type { DeliveryMode, LoaderStrategy } from "../codegen-config-schema.js";
3
3
  import { GENERATED_FILE_BANNER, toImportPath, toRelativeModuleImport } from "../paths.js";
4
4
  import type { ImportExtension, NamespaceEntry } from "../types.js";
5
5
 
6
6
  /**
7
7
  * Emits the generated `namespace-loaders.generated.ts` module: dynamic `import()`
8
- * functions for lazy-loaded namespaces, shaped by delivery mode.
8
+ * or injectable `fetchImpl(resourceId)` loaders, shaped by delivery mode.
9
9
  */
10
10
  export interface NamespaceLoadersFileOptions {
11
11
  loadersOutputPath: string;
12
12
  lazyEntries: (NamespaceEntry & { absolutePath: string })[];
13
13
  schemaTypeName: string;
14
- paramsTypeName: string;
15
14
  localeTypeName: string;
16
- localeFallbackConstName: string;
17
- hasLocaleFallback: boolean;
18
15
  typesModule: string;
19
16
  importExtension: ImportExtension;
20
17
  projectRoot: string;
21
- isSingle?: boolean;
22
- delivery?: DeliveryMode;
18
+ delivery: DeliveryMode;
23
19
  splitPathsByNamespace?: Record<string, Record<string, string>>;
24
20
  requestLocales?: readonly string[];
25
21
  deliveryAreaTypeName?: string;
26
22
  deliveryAreaNames?: readonly string[];
23
+ loaderStrategy?: LoaderStrategy;
27
24
  }
28
25
 
29
26
  interface PartitionedNamespaceLoadersParams {
@@ -44,28 +41,97 @@ function formatPartitionedTypesImport(
44
41
  options: NamespaceLoadersFileOptions,
45
42
  paramTypeName: string
46
43
  ): string {
47
- const { schemaTypeName, paramsTypeName, localeTypeName, typesModule, importExtension, isSingle } =
48
- options;
44
+ const { schemaTypeName, typesModule, importExtension } = options;
45
+ return `import type { ${schemaTypeName}, LazyNamespace, ${paramTypeName} } from '${toRelativeModuleImport(typesModule, importExtension)}';\n\n`;
46
+ }
49
47
 
50
- if (isSingle) {
51
- return `import type { ${schemaTypeName}, LazyNamespace, ${paramTypeName}, ${localeTypeName} } from '${toRelativeModuleImport(typesModule, importExtension)}';\n\n`;
52
- }
48
+ function formatImportCase(
49
+ loadersOutputPath: string,
50
+ projectRoot: string,
51
+ splitRelativePath: string
52
+ ): string {
53
+ const importPath = toImportPath(loadersOutputPath, path.resolve(projectRoot, splitRelativePath));
54
+ return `return import('${importPath}.json').then((m) => m.default);`;
55
+ }
53
56
 
54
- const paramTypeImport = paramTypeName === localeTypeName ? "" : `, ${paramTypeName}`;
55
- return `import type { ${localeTypeName}, ${paramsTypeName}, ${schemaTypeName}, LazyNamespace${paramTypeImport} } from '${toRelativeModuleImport(typesModule, importExtension)}';\n\n`;
57
+ function indentBlock(text: string, spaces: number): string {
58
+ const pad = " ".repeat(spaces);
59
+ return text
60
+ .split("\n")
61
+ .map((line) => (line.length === 0 ? line : `${pad}${line}`))
62
+ .join("\n");
56
63
  }
57
64
 
58
- function formatSwitchDefaultCase(namespace: string, paramName: string): string {
59
- return (
60
- ` default:\n` +
61
- ` throw new Error(\`[i18n] No translation artifact for namespace ${JSON.stringify(namespace)} and ${paramName} "\${String(${paramName})}".\`);\n`
65
+ function formatImportNamespaceLoaderEntry(options: {
66
+ namespace: string;
67
+ paramName: string;
68
+ partitionKeys: readonly string[];
69
+ splitPathsByNamespace: Record<string, Record<string, string>>;
70
+ loadersOutputPath: string;
71
+ projectRoot: string;
72
+ }): string {
73
+ const {
74
+ namespace,
75
+ paramName,
76
+ partitionKeys,
77
+ splitPathsByNamespace,
78
+ loadersOutputPath,
79
+ projectRoot,
80
+ } = options;
81
+
82
+ const caseBodies = partitionKeys.map((partitionKey) => {
83
+ const splitRelativePath = splitPathsByNamespace[namespace]?.[partitionKey];
84
+ if (!splitRelativePath) {
85
+ throw new Error(
86
+ `[Codegen Error] Missing split path for namespace "${namespace}", ${paramName} "${partitionKey}".`
87
+ );
88
+ }
89
+ const body = formatImportCase(loadersOutputPath, projectRoot, splitRelativePath);
90
+ return [`case ${JSON.stringify(partitionKey)}:`, ` ${body}`].join("\n");
91
+ });
92
+
93
+ const switchBody = [
94
+ ...caseBodies,
95
+ `default:`,
96
+ ` throw new Error(\`[i18n] No translation artifact for namespace ${JSON.stringify(namespace)} and ${paramName} "\${String(${paramName})}".\`);`,
97
+ ].join("\n");
98
+
99
+ const entry = [
100
+ `${namespace}: (${paramName}) => {`,
101
+ ` switch (${paramName}) {`,
102
+ indentBlock(switchBody, 4),
103
+ ` }`,
104
+ `},`,
105
+ ].join("\n");
106
+
107
+ return indentBlock(entry, 2);
108
+ }
109
+
110
+ /** Fetch loaders forward a resource id; URL mapping is left to `fetchImpl`. */
111
+ function formatFetchNamespaceLoaderEntry(options: {
112
+ namespace: string;
113
+ paramName: string;
114
+ schemaTypeName: string;
115
+ delivery: DeliveryMode;
116
+ }): string {
117
+ const { namespace, paramName, schemaTypeName, delivery } = options;
118
+ const nsLiteral = JSON.stringify(namespace);
119
+ const resourceId =
120
+ delivery === "custom"
121
+ ? `{ locale, namespace: ${nsLiteral}, area }`
122
+ : `{ locale, namespace: ${nsLiteral} }`;
123
+
124
+ // Custom: partition is area; locale comes from load() context.
125
+ // Split-by-locale: partition is locale (second arg unused).
126
+ const signature = delivery === "custom" ? `(${paramName}, { locale })` : `(${paramName})`;
127
+
128
+ return indentBlock(
129
+ `${namespace}: ${signature} =>\n` +
130
+ ` fetchImpl(${resourceId}) as Promise<${schemaTypeName}[${nsLiteral}]>,`,
131
+ 4
62
132
  );
63
133
  }
64
134
 
65
- /**
66
- * Each namespace gets a function that dynamic-imports the JSON for the given
67
- * partition key (locale or area) via a switch.
68
- */
69
135
  function formatPartitionedNamespaceLoadersFile(
70
136
  options: NamespaceLoadersFileOptions,
71
137
  params: PartitionedNamespaceLoadersParams
@@ -76,97 +142,88 @@ function formatPartitionedNamespaceLoadersFile(
76
142
  lazyEntries,
77
143
  schemaTypeName,
78
144
  projectRoot,
145
+ delivery,
79
146
  splitPathsByNamespace = {},
80
- isSingle = false,
81
147
  } = options;
148
+ const loaderStrategy = options.loaderStrategy ?? "import";
82
149
 
83
- const loaderValueType = isSingle ? schemaTypeName : `${schemaTypeName}[K]`;
84
-
150
+ const loaderValueType = `${schemaTypeName}[K]`;
85
151
  const typesImport = formatPartitionedTypesImport(options, paramTypeName);
86
- const loaderEntries = lazyEntries
87
- .map((entry) => {
88
- const switchCases = partitionKeys
89
- .map((partitionKey) => {
90
- const splitRelativePath = splitPathsByNamespace[entry.namespace]?.[partitionKey];
91
- if (!splitRelativePath) {
92
- throw new Error(
93
- `[Codegen Error] Missing split path for namespace "${entry.namespace}", ${paramName} "${partitionKey}".`
94
- );
95
- }
96
- const importPath = toImportPath(
97
- loadersOutputPath,
98
- path.resolve(projectRoot, splitRelativePath)
99
- );
100
- return (
101
- ` case ${JSON.stringify(partitionKey)}:\n` +
102
- ` return import('${importPath}.json').then((m) => m.default);`
103
- );
152
+
153
+ if (loaderStrategy === "fetch") {
154
+ // Fetch loaders ignore partition-key switches: the resource id is enough for the app.
155
+ const fetchLoadersType = `{\n [K in LazyNamespace]: (${
156
+ delivery === "custom"
157
+ ? `${paramName}: ${paramTypeName}, context: { locale: string }`
158
+ : `${paramName}: ${paramTypeName}`
159
+ }) => Promise<${loaderValueType}>;\n}`;
160
+
161
+ const loaderEntries = lazyEntries
162
+ .map((entry) =>
163
+ formatFetchNamespaceLoaderEntry({
164
+ namespace: entry.namespace,
165
+ paramName,
166
+ schemaTypeName,
167
+ delivery,
104
168
  })
105
- .join("\n");
106
-
107
- return (
108
- ` ${entry.namespace}: (${paramName}) => {\n` +
109
- ` switch (${paramName}) {\n${switchCases}\n` +
110
- `${formatSwitchDefaultCase(entry.namespace, paramName)}` +
111
- ` }\n` +
112
- ` },`
113
- );
114
- })
115
- .join("\n");
169
+ )
170
+ .join("\n");
116
171
 
117
- return (
118
- `${GENERATED_FILE_BANNER}` +
119
- typesImport +
120
- `export const namespaceLoaders: {\n` +
121
- ` [K in LazyNamespace]: (${paramName}: ${paramTypeName}) => Promise<${loaderValueType}>;\n` +
122
- `} = {\n${loaderEntries}\n};\n` +
123
- `\n` +
124
- `export const defaultLazyNamespaces = [${formatDefaultNamespacesLiteral(lazyEntries)}] as const;\n`
125
- );
126
- }
172
+ return (
173
+ `${GENERATED_FILE_BANNER}` +
174
+ `import type { FetchArtifact } from '@xndrjs/i18n';\n` +
175
+ typesImport +
176
+ `export type NamespaceLoaders = ${fetchLoadersType};\n\n` +
177
+ `/** Build loaders that resolve artifacts via the injected {@link FetchArtifact} (resource id only). */\n` +
178
+ `export function createNamespaceLoaders(fetchImpl: FetchArtifact): NamespaceLoaders {\n` +
179
+ ` return {\n${loaderEntries}\n };\n` +
180
+ `}\n\n` +
181
+ `export const defaultLazyNamespaces = [${formatDefaultNamespacesLiteral(lazyEntries)}] as const;\n`
182
+ );
183
+ }
127
184
 
128
- /** Emits lazy loaders for canonical delivery: one dynamic import per namespace. */
129
- function formatCanonicalNamespaceLoadersFile(options: NamespaceLoadersFileOptions): string {
130
- const { loadersOutputPath, lazyEntries, schemaTypeName, typesModule, importExtension } = options;
131
- const typesImport = toRelativeModuleImport(typesModule, importExtension);
132
185
  const loaderEntries = lazyEntries
133
- .map((entry) => {
134
- const importPath = toImportPath(loadersOutputPath, entry.absolutePath);
135
- return ` ${entry.namespace}: () => import('${importPath}.json').then((m) => m.default),`;
136
- })
186
+ .map((entry) =>
187
+ formatImportNamespaceLoaderEntry({
188
+ namespace: entry.namespace,
189
+ paramName,
190
+ partitionKeys,
191
+ splitPathsByNamespace,
192
+ loadersOutputPath,
193
+ projectRoot,
194
+ })
195
+ )
137
196
  .join("\n");
138
197
 
198
+ // Import loaders only use the partition key; context is accepted for a uniform NamespaceLoader shape.
199
+ const importLoadersType = `{\n [K in LazyNamespace]: (${paramName}: ${paramTypeName}) => Promise<${loaderValueType}>;\n}`;
200
+
139
201
  return (
140
202
  `${GENERATED_FILE_BANNER}` +
141
- `import type { ${schemaTypeName}, LazyNamespace } from '${typesImport}';\n\n` +
142
- `export const namespaceLoaders: {\n` +
143
- ` [K in LazyNamespace]: () => Promise<${schemaTypeName}[K]>;\n` +
144
- `} = {\n${loaderEntries}\n};\n`
203
+ typesImport +
204
+ `export const namespaceLoaders: ${importLoadersType} = {\n${loaderEntries}\n};\n` +
205
+ `\n` +
206
+ `export const defaultLazyNamespaces = [${formatDefaultNamespacesLiteral(lazyEntries)}] as const;\n`
145
207
  );
146
208
  }
147
209
 
148
210
  /**
149
- * Builds `namespace-loaders.generated.ts` for the configured delivery mode:
150
- * canonical, split-by-locale, or custom areas.
211
+ * Builds `namespace-loaders.generated.ts` for split-by-locale or custom delivery areas.
151
212
  */
152
213
  export function formatNamespaceLoadersFile(options: NamespaceLoadersFileOptions): string {
153
- const delivery = options.delivery ?? "canonical";
154
- if (delivery === "split-by-locale") {
214
+ if (options.delivery === "split-by-locale") {
155
215
  return formatPartitionedNamespaceLoadersFile(options, {
156
216
  partitionKeys: options.requestLocales ?? [],
157
217
  paramName: "locale",
158
218
  paramTypeName: options.localeTypeName,
159
219
  });
160
220
  }
161
- if (delivery === "custom") {
162
- if (!options.deliveryAreaTypeName) {
163
- throw new Error("[Codegen Error] deliveryAreaTypeName is required for custom delivery.");
164
- }
165
- return formatPartitionedNamespaceLoadersFile(options, {
166
- partitionKeys: options.deliveryAreaNames ?? [],
167
- paramName: "area",
168
- paramTypeName: options.deliveryAreaTypeName,
169
- });
221
+ if (!options.deliveryAreaTypeName) {
222
+ throw new Error("[Codegen Error] deliveryAreaTypeName is required for custom delivery.");
170
223
  }
171
- return formatCanonicalNamespaceLoadersFile(options);
224
+ return formatPartitionedNamespaceLoadersFile(options, {
225
+ partitionKeys: options.deliveryAreaNames ?? [],
226
+ paramName: "area",
227
+ paramTypeName: options.deliveryAreaTypeName,
228
+ });
172
229
  }
@@ -6,9 +6,8 @@ const projectRoot = "/project";
6
6
  const typesOutputPath = path.join(projectRoot, "src/i18n/generated/i18n-types.generated.ts");
7
7
 
8
8
  describe("formatTypesFile", () => {
9
- it("emits explicit Partial schema types for multi-namespace", () => {
9
+ it("emits explicit Partial schema types and lazy aliases", () => {
10
10
  const output = formatTypesFile({
11
- isSingle: false,
12
11
  entries: [
13
12
  { namespace: "default", filePath: "src/i18n/translations/default.json" },
14
13
  { namespace: "billing", filePath: "src/i18n/translations/billing.yaml" },
@@ -29,46 +28,23 @@ describe("formatTypesFile", () => {
29
28
  invoice_summary: "{ count: number }",
30
29
  },
31
30
  },
32
- requestLocaleUnion: "'en' | 'it'",
33
- hasLazy: true,
34
- loadOnInitSet: new Set(["default"]),
35
- lazyEntries: [{ namespace: "billing", filePath: "src/i18n/translations/billing.yaml" }],
31
+ requestLocales: ["en", "it"],
32
+ lazyEntries: [
33
+ { namespace: "default", filePath: "src/i18n/translations/default.json" },
34
+ { namespace: "billing", filePath: "src/i18n/translations/billing.yaml" },
35
+ ],
36
36
  });
37
37
 
38
38
  expect(output).toContain("login_button: Partial<Record<AppLocale, string>>;");
39
39
  expect(output).toContain("invoice_summary: Partial<Record<AppLocale, string>>;");
40
- });
41
-
42
- it("emits explicit Partial schema types for single-file mode", () => {
43
- const output = formatTypesFile({
44
- isSingle: true,
45
- entries: [{ namespace: "translations", filePath: "src/i18n/translations/translations.yaml" }],
46
- projectRoot,
47
- typesOutputPath,
48
- paramsTypeName: "AppParams",
49
- schemaTypeName: "AppSchema",
50
- localeTypeName: "AppLocale",
51
- localeFallbackConstName: "LOCALE_FALLBACK",
52
- localeFallbackTypeName: "AppLocaleFallback",
53
- paramsByNamespace: {
54
- translations: {
55
- welcome: "{ name: string }",
56
- },
57
- },
58
- requestLocaleUnion: "'en' | 'it'",
59
- hasLazy: false,
60
- loadOnInitSet: new Set(),
61
- lazyEntries: [],
62
- });
63
-
64
- expect(output).toContain(
65
- "export type AppSchema = {\n welcome: Partial<Record<AppLocale, string>>;\n};"
66
- );
40
+ expect(output).toContain('export const AppLocales = ["en", "it"] as const;');
41
+ expect(output).toContain("export type AppLocale = (typeof AppLocales)[number];");
42
+ expect(output).toContain("export type LazyNamespace = 'default' | 'billing'");
43
+ expect(output).toContain("export type InitialSchema = Record<string, never>");
67
44
  });
68
45
 
69
46
  it("emits delivery area type for custom delivery", () => {
70
47
  const output = formatTypesFile({
71
- isSingle: false,
72
48
  entries: [
73
49
  { namespace: "default", filePath: "src/i18n/translations/default.json" },
74
50
  { namespace: "billing", filePath: "src/i18n/translations/billing.yaml" },
@@ -89,20 +65,23 @@ describe("formatTypesFile", () => {
89
65
  default: { some_key: "never" },
90
66
  billing: { invoice_summary: "{ count: number }" },
91
67
  },
92
- requestLocaleUnion: "'en-US' | 'fr' | 'it'",
68
+ requestLocales: ["en-US", "fr", "it"],
93
69
  deliveryAreaTypeName: "AppDeliveryArea",
94
- deliveryAreaUnion: "'eu' | 'us'",
70
+ deliveryAreaNames: ["eu", "us"],
95
71
  deliveryArtifacts: {
96
72
  eu: ["it", "fr"],
97
73
  us: ["en-US"],
98
74
  },
99
- hasLazy: true,
100
- loadOnInitSet: new Set(["default"]),
101
- lazyEntries: [{ namespace: "billing", filePath: "src/i18n/translations/billing.yaml" }],
75
+ lazyEntries: [
76
+ { namespace: "default", filePath: "src/i18n/translations/default.json" },
77
+ { namespace: "billing", filePath: "src/i18n/translations/billing.yaml" },
78
+ ],
102
79
  });
103
80
 
104
- expect(output).toContain("export type AppLocale = 'en-US' | 'fr' | 'it';");
105
- expect(output).toContain("export type AppDeliveryArea = 'eu' | 'us';");
81
+ expect(output).toContain('export const AppLocales = ["en-US", "fr", "it"] as const;');
82
+ expect(output).toContain("export type AppLocale = (typeof AppLocales)[number];");
83
+ expect(output).toContain('export const AppDeliveryAreas = ["eu", "us"] as const;');
84
+ expect(output).toContain("export type AppDeliveryArea = (typeof AppDeliveryAreas)[number];");
106
85
  expect(output).toContain("export const DELIVERY_ARTIFACTS = {");
107
86
  expect(output).toContain('"eu": ["fr", "it"] as const');
108
87
  expect(output).toContain('"us": ["en-US"] as const');