@xndrjs/i18n 0.4.0 → 0.5.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 (57) hide show
  1. package/README.md +156 -29
  2. package/dist/codegen/index.d.ts +85 -0
  3. package/dist/codegen/index.js +181 -0
  4. package/dist/codegen/index.js.map +1 -0
  5. package/dist/index.d.ts +34 -5
  6. package/dist/index.js +175 -74
  7. package/dist/index.js.map +1 -1
  8. package/dist/validation/index.d.ts +4 -0
  9. package/dist/validation/index.js.map +1 -1
  10. package/package.json +6 -1
  11. package/src/IcuTranslationProviderMulti.test.ts +47 -0
  12. package/src/IcuTranslationProviderMulti.ts +41 -61
  13. package/src/IcuTranslationProviderSingle.test.ts +67 -0
  14. package/src/IcuTranslationProviderSingle.ts +27 -51
  15. package/src/audit/audit-dictionaries.ts +4 -1
  16. package/src/audit/run-audit.test.ts +35 -1
  17. package/src/audit/run-audit.ts +15 -7
  18. package/src/codegen/codegen-config-schema.ts +68 -1
  19. package/src/codegen/config.test.ts +174 -39
  20. package/src/codegen/config.ts +14 -5
  21. package/src/codegen/constants.ts +8 -0
  22. package/src/codegen/delivery-artifacts.test.ts +142 -0
  23. package/src/codegen/delivery-artifacts.ts +124 -0
  24. package/src/codegen/emit/dictionary-file.test.ts +190 -0
  25. package/src/codegen/emit/dictionary-file.ts +163 -0
  26. package/src/codegen/emit/dictionary-schema-file.ts +5 -0
  27. package/src/codegen/emit/instance-file.ts +114 -28
  28. package/src/codegen/emit/namespace-loaders-file.test.ts +176 -0
  29. package/src/codegen/emit/namespace-loaders-file.ts +114 -6
  30. package/src/codegen/emit/types-file.test.ts +114 -0
  31. package/src/codegen/emit/types-file.ts +48 -11
  32. package/src/codegen/generate-i18n-types.test.ts +754 -16
  33. package/src/codegen/generate-i18n-types.ts +111 -47
  34. package/src/codegen/icu-analysis.ts +9 -2
  35. package/src/codegen/locale-fallback.ts +19 -10
  36. package/src/codegen/locale-policy.ts +4 -0
  37. package/src/codegen/paths.ts +9 -5
  38. package/src/codegen/project-locales-set-namespace.test.ts +5 -5
  39. package/src/codegen/read-dictionary.test.ts +474 -2
  40. package/src/codegen/read-dictionary.ts +164 -15
  41. package/src/codegen/write-file-if-changed.test.ts +42 -0
  42. package/src/codegen/write-file-if-changed.ts +20 -0
  43. package/src/codegen-config/build-config.ts +34 -0
  44. package/src/codegen-config/codegen-config.test.ts +32 -0
  45. package/src/codegen-config/index.ts +21 -0
  46. package/src/codegen-config/write-config.ts +8 -0
  47. package/src/deep-freeze.test.ts +13 -0
  48. package/src/deep-freeze.ts +5 -0
  49. package/src/format-core.ts +91 -0
  50. package/src/index.ts +8 -2
  51. package/src/project-locales.test.ts +129 -10
  52. package/src/project-locales.ts +90 -3
  53. package/src/setup/setup-i18n.test.ts +1 -1
  54. package/src/setup/setup-i18n.ts +6 -30
  55. package/src/types.ts +19 -4
  56. package/src/validation/normalize.ts +4 -0
  57. /package/src/{setup → codegen-config}/type-names.ts +0 -0
@@ -1,6 +1,11 @@
1
1
  import { GENERATED_FILE_BANNER, toModuleBasename, toRelativeModuleImport } from "../paths.js";
2
+ import type { DeliveryMode } from "../codegen-config-schema.js";
2
3
  import type { ImportExtension } from "../types.js";
3
4
 
5
+ /**
6
+ * Emits the generated instance module: `createI18n` factory wrapping the ICU provider
7
+ * plus runtime projection helpers (`projectDictionaryLocales`, etc.).
8
+ */
4
9
  export interface InstanceFileOptions {
5
10
  isSingle: boolean;
6
11
  hasLazy: boolean;
@@ -14,6 +19,7 @@ export interface InstanceFileOptions {
14
19
  hasLocaleType: boolean;
15
20
  namespaceNames: string[];
16
21
  importExtension: ImportExtension;
22
+ delivery: DeliveryMode;
17
23
  }
18
24
 
19
25
  export function formatInstanceFile(options: InstanceFileOptions): string {
@@ -30,8 +36,10 @@ export function formatInstanceFile(options: InstanceFileOptions): string {
30
36
  hasLocaleType,
31
37
  namespaceNames,
32
38
  importExtension,
39
+ delivery,
33
40
  } = options;
34
41
 
42
+ const emitDeliveryAreaHelpers = delivery === "custom";
35
43
  const providerClass = isSingle ? "IcuTranslationProviderSingle" : "IcuTranslationProviderMulti";
36
44
  const typesModule = toModuleBasename(typesOutputPath);
37
45
  const typesImport = toRelativeModuleImport(typesModule, importExtension);
@@ -43,55 +51,100 @@ export function formatInstanceFile(options: InstanceFileOptions): string {
43
51
  ? `${schemaTypeName}, ${paramsTypeName}, ${localeTypeName}, typeof ${localeFallbackConstName}`
44
52
  : `${schemaTypeName}, ${paramsTypeName}`;
45
53
  const providerOptions = hasLocaleFallback
46
- ? `, {\n localeFallback: ${localeFallbackConstName},\n }`
47
- : "";
54
+ ? `, {\n localeFallback: ${localeFallbackConstName},\n ...options,\n }`
55
+ : `, options`;
48
56
  const typesImportLine = hasLocaleType
49
57
  ? hasLocaleFallback
50
58
  ? `import { ${localeFallbackConstName}, type ${localeTypeName} } from '${typesImport}';\n`
51
59
  : `import type { ${localeTypeName} } from '${typesImport}';\n`
52
60
  : "";
53
61
  const localesParamType = hasLocaleType ? `readonly ${localeTypeName}[]` : "readonly string[]";
54
- const projectLocalesFallbackArg = hasLocaleFallback ? `, ${localeFallbackConstName}` : "";
55
- const coreImports = isSingle
56
- ? "projectLocales as projectLocalesCore"
57
- : "projectLocales as projectLocalesCore, projectNamespacesLocales as projectNamespacesLocalesCore";
58
- const projectLocalesBlock = isSingle
59
- ? `\n` +
60
- `export function projectLocales(\n` +
61
- ` dictionary: ${schemaTypeName},\n` +
62
- ` locales: ${localesParamType},\n` +
63
- `): ${schemaTypeName} {\n` +
64
- ` return projectLocalesCore(dictionary, locales${projectLocalesFallbackArg});\n` +
65
- `}\n`
66
- : formatMultiProjectLocalesBlock(
62
+ const fallbackArg = hasLocaleFallback ? `, ${localeFallbackConstName}` : "";
63
+ const coreImports = formatCoreImports(isSingle, emitDeliveryAreaHelpers);
64
+ const projectionBlock = isSingle
65
+ ? formatSingleProjectionBlock(
66
+ schemaTypeName,
67
+ localesParamType,
68
+ fallbackArg,
69
+ emitDeliveryAreaHelpers
70
+ )
71
+ : formatMultiProjectionBlock(
67
72
  schemaTypeName,
68
73
  namespaceNames,
69
74
  localesParamType,
70
- projectLocalesFallbackArg
75
+ fallbackArg,
76
+ emitDeliveryAreaHelpers
71
77
  );
72
78
 
73
79
  return (
74
80
  `${GENERATED_FILE_BANNER}` +
75
- `import { ${providerClass}, ${coreImports} } from '@xndrjs/i18n';\n` +
81
+ `import { ${providerClass}, ${coreImports}, type OnMissingTranslation } from '@xndrjs/i18n';\n` +
76
82
  schemaTypesImport +
77
83
  typesImportLine +
78
84
  `\n` +
79
85
  `export function ${factoryName}(\n` +
80
86
  ` dictionary: ${dictionaryParamType},\n` +
87
+ ` options?: { onMissing?: OnMissingTranslation },\n` +
81
88
  `) {\n` +
82
89
  ` return new ${providerClass}<${providerTypeArgs}>(dictionary${providerOptions});\n` +
83
90
  `}\n` +
84
- projectLocalesBlock
91
+ projectionBlock
85
92
  );
86
93
  }
87
94
 
88
- function formatMultiProjectLocalesBlock(
95
+ function formatCoreImports(isSingle: boolean, emitDeliveryAreaHelpers: boolean): string {
96
+ if (isSingle) {
97
+ const imports = ["projectNamespaceLocalesCore"];
98
+ if (emitDeliveryAreaHelpers) {
99
+ imports.push("projectNamespaceForDeliveryAreaCore");
100
+ }
101
+ return imports.join(", ");
102
+ }
103
+
104
+ const imports = ["projectNamespaceLocalesCore", "projectDictionaryLocalesCore"];
105
+ if (emitDeliveryAreaHelpers) {
106
+ imports.push("projectNamespaceForDeliveryAreaCore", "projectDictionaryForDeliveryAreaCore");
107
+ }
108
+ return imports.join(", ");
109
+ }
110
+
111
+ function formatSingleProjectionBlock(
112
+ schemaTypeName: string,
113
+ localesParamType: string,
114
+ fallbackArg: string,
115
+ emitDeliveryAreaHelpers: boolean
116
+ ): string {
117
+ let block =
118
+ `\n` +
119
+ `export function projectDictionaryLocales(\n` +
120
+ ` dictionary: ${schemaTypeName},\n` +
121
+ ` locales: ${localesParamType},\n` +
122
+ `): ${schemaTypeName} {\n` +
123
+ ` return projectNamespaceLocalesCore(dictionary, locales${fallbackArg});\n` +
124
+ `}\n`;
125
+
126
+ if (emitDeliveryAreaHelpers) {
127
+ block +=
128
+ `\n` +
129
+ `export function projectDictionaryForDeliveryArea(\n` +
130
+ ` dictionary: ${schemaTypeName},\n` +
131
+ ` areaLocales: ${localesParamType},\n` +
132
+ `): ${schemaTypeName} {\n` +
133
+ ` return projectNamespaceForDeliveryAreaCore(dictionary, areaLocales${fallbackArg});\n` +
134
+ `}\n`;
135
+ }
136
+
137
+ return block;
138
+ }
139
+
140
+ function formatMultiProjectionBlock(
89
141
  schemaTypeName: string,
90
142
  namespaceNames: string[],
91
143
  localesParamType: string,
92
- projectLocalesFallbackArg: string
144
+ fallbackArg: string,
145
+ emitDeliveryAreaHelpers: boolean
93
146
  ): string {
94
- const overloads = namespaceNames
147
+ const namespaceLocaleOverloads = namespaceNames
95
148
  .map(
96
149
  (namespace) =>
97
150
  `export function projectNamespaceLocales(\n` +
@@ -101,22 +154,55 @@ function formatMultiProjectLocalesBlock(
101
154
  )
102
155
  .join("\n");
103
156
 
104
- return (
157
+ const namespaceDeliveryOverloads = emitDeliveryAreaHelpers
158
+ ? namespaceNames
159
+ .map(
160
+ (namespace) =>
161
+ `export function projectNamespaceForDeliveryArea(\n` +
162
+ ` dictionary: ${schemaTypeName}["${namespace}"],\n` +
163
+ ` areaLocales: ${localesParamType},\n` +
164
+ `): ${schemaTypeName}["${namespace}"];`
165
+ )
166
+ .join("\n")
167
+ : "";
168
+
169
+ let block =
105
170
  `\n` +
106
- `export function projectLocales(\n` +
171
+ `export function projectDictionaryLocales(\n` +
107
172
  ` dictionary: ${schemaTypeName},\n` +
108
173
  ` locales: ${localesParamType},\n` +
109
174
  `): ${schemaTypeName} {\n` +
110
- ` return projectNamespacesLocalesCore(dictionary, locales${projectLocalesFallbackArg});\n` +
175
+ ` return projectDictionaryLocalesCore(dictionary, locales${fallbackArg});\n` +
111
176
  `}\n` +
112
177
  `\n` +
113
- overloads +
178
+ namespaceLocaleOverloads +
114
179
  `\n` +
115
180
  `export function projectNamespaceLocales(\n` +
116
181
  ` dictionary: ${schemaTypeName}[keyof ${schemaTypeName}],\n` +
117
182
  ` locales: ${localesParamType},\n` +
118
183
  `): ${schemaTypeName}[keyof ${schemaTypeName}] {\n` +
119
- ` return projectLocalesCore(dictionary, locales${projectLocalesFallbackArg});\n` +
120
- `}\n`
121
- );
184
+ ` return projectNamespaceLocalesCore(dictionary, locales${fallbackArg});\n` +
185
+ `}\n`;
186
+
187
+ if (emitDeliveryAreaHelpers) {
188
+ block +=
189
+ `\n` +
190
+ `export function projectDictionaryForDeliveryArea(\n` +
191
+ ` dictionary: ${schemaTypeName},\n` +
192
+ ` areaLocales: ${localesParamType},\n` +
193
+ `): ${schemaTypeName} {\n` +
194
+ ` return projectDictionaryForDeliveryAreaCore(dictionary, areaLocales${fallbackArg});\n` +
195
+ `}\n` +
196
+ `\n` +
197
+ namespaceDeliveryOverloads +
198
+ `\n` +
199
+ `export function projectNamespaceForDeliveryArea(\n` +
200
+ ` dictionary: ${schemaTypeName}[keyof ${schemaTypeName}],\n` +
201
+ ` areaLocales: ${localesParamType},\n` +
202
+ `): ${schemaTypeName}[keyof ${schemaTypeName}] {\n` +
203
+ ` return projectNamespaceForDeliveryAreaCore(dictionary, areaLocales${fallbackArg});\n` +
204
+ `}\n`;
205
+ }
206
+
207
+ return block;
122
208
  }
@@ -0,0 +1,176 @@
1
+ import path from "node:path";
2
+ import { describe, expect, it } from "vitest";
3
+ import { formatNamespaceLoadersFile } from "./namespace-loaders-file.js";
4
+
5
+ const projectRoot = "/project";
6
+ const loadersOutputPath = path.join(projectRoot, "src/i18n/namespace-loaders.generated.ts");
7
+
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
+ localeTypeName: "AppLocale",
21
+ typesModule: "i18n-types.generated",
22
+ importExtension: "none",
23
+ projectRoot,
24
+ delivery: "canonical",
25
+ });
26
+
27
+ expect(output).toContain("[K in LazyNamespace]: () => Promise<AppSchema[K]>");
28
+ expect(output).toContain(
29
+ "billing: () => import('./translations/billing.json').then((m) => m.default),"
30
+ );
31
+ expect(output).not.toContain("AppLocale");
32
+ });
33
+
34
+ it("emits ns(locale) loaders in split mode", () => {
35
+ const output = formatNamespaceLoadersFile({
36
+ loadersOutputPath,
37
+ lazyEntries: [
38
+ {
39
+ namespace: "billing",
40
+ filePath: "src/i18n/translations/billing.json",
41
+ absolutePath: path.join(projectRoot, "src/i18n/translations/billing.json"),
42
+ },
43
+ {
44
+ namespace: "user",
45
+ filePath: "src/i18n/translations/user.json",
46
+ absolutePath: path.join(projectRoot, "src/i18n/translations/user.json"),
47
+ },
48
+ ],
49
+ schemaTypeName: "AppSchema",
50
+ localeTypeName: "AppLocale",
51
+ typesModule: "i18n-types.generated",
52
+ importExtension: "none",
53
+ projectRoot,
54
+ delivery: "split-by-locale",
55
+ requestLocales: ["en", "it", "de-CH"],
56
+ splitPathsByNamespace: {
57
+ billing: {
58
+ en: "src/i18n/generated/translations/billing.en.json",
59
+ it: "src/i18n/generated/translations/billing.it.json",
60
+ "de-CH": "src/i18n/generated/translations/billing.de-CH.json",
61
+ },
62
+ user: {
63
+ en: "src/i18n/generated/translations/user.en.json",
64
+ it: "src/i18n/generated/translations/user.it.json",
65
+ "de-CH": "src/i18n/generated/translations/user.de-CH.json",
66
+ },
67
+ },
68
+ });
69
+
70
+ expect(output).toContain("[K in LazyNamespace]: (locale: AppLocale) => Promise<AppSchema[K]>;");
71
+ expect(output).toContain("billing: (locale) => {");
72
+ expect(output).toContain('case "en":');
73
+ expect(output).toContain('case "it":');
74
+ expect(output).toContain('case "de-CH":');
75
+ expect(output).toContain(
76
+ "return import('./generated/translations/billing.en.json').then((m) => m.default);"
77
+ );
78
+ expect(output).toContain("user: (locale) => {");
79
+ expect(output).not.toContain("billing: {");
80
+ expect(output).not.toContain("import('./translations/billing.json')");
81
+ });
82
+
83
+ it("throws when a split path is missing", () => {
84
+ expect(() =>
85
+ formatNamespaceLoadersFile({
86
+ loadersOutputPath,
87
+ lazyEntries: [
88
+ {
89
+ namespace: "billing",
90
+ filePath: "src/i18n/translations/billing.json",
91
+ absolutePath: path.join(projectRoot, "src/i18n/translations/billing.json"),
92
+ },
93
+ ],
94
+ schemaTypeName: "AppSchema",
95
+ localeTypeName: "AppLocale",
96
+ typesModule: "i18n-types.generated",
97
+ importExtension: "none",
98
+ projectRoot,
99
+ delivery: "split-by-locale",
100
+ requestLocales: ["en", "it"],
101
+ splitPathsByNamespace: {
102
+ billing: {
103
+ en: "src/i18n/generated/translations/billing.en.json",
104
+ },
105
+ },
106
+ })
107
+ ).toThrow('[Codegen Error] Missing split path for namespace "billing", locale "it".');
108
+ });
109
+
110
+ it("emits ns(area) loaders in custom mode", () => {
111
+ const output = formatNamespaceLoadersFile({
112
+ loadersOutputPath,
113
+ lazyEntries: [
114
+ {
115
+ namespace: "billing",
116
+ filePath: "src/i18n/translations/billing.json",
117
+ absolutePath: path.join(projectRoot, "src/i18n/translations/billing.json"),
118
+ },
119
+ ],
120
+ schemaTypeName: "AppSchema",
121
+ localeTypeName: "AppLocale",
122
+ typesModule: "i18n-types.generated",
123
+ importExtension: "none",
124
+ projectRoot,
125
+ delivery: "custom",
126
+ deliveryAreaTypeName: "AppDeliveryArea",
127
+ deliveryAreaNames: ["eu", "us"],
128
+ splitPathsByNamespace: {
129
+ billing: {
130
+ eu: "src/i18n/generated/translations/billing.eu.json",
131
+ us: "src/i18n/generated/translations/billing.us.json",
132
+ },
133
+ },
134
+ });
135
+
136
+ expect(output).toContain(
137
+ "[K in LazyNamespace]: (area: AppDeliveryArea) => Promise<AppSchema[K]>;"
138
+ );
139
+ expect(output).toContain("billing: (area) => {");
140
+ expect(output).toContain('case "eu":');
141
+ expect(output).toContain('case "us":');
142
+ expect(output).toContain(
143
+ "return import('./generated/translations/billing.eu.json').then((m) => m.default);"
144
+ );
145
+ expect(output).not.toContain("AppLocale");
146
+ expect(output).not.toContain("import('./translations/billing.json')");
147
+ });
148
+
149
+ it("throws when a custom delivery split path is missing", () => {
150
+ expect(() =>
151
+ formatNamespaceLoadersFile({
152
+ loadersOutputPath,
153
+ lazyEntries: [
154
+ {
155
+ namespace: "billing",
156
+ filePath: "src/i18n/translations/billing.json",
157
+ absolutePath: path.join(projectRoot, "src/i18n/translations/billing.json"),
158
+ },
159
+ ],
160
+ schemaTypeName: "AppSchema",
161
+ localeTypeName: "AppLocale",
162
+ typesModule: "i18n-types.generated",
163
+ importExtension: "none",
164
+ projectRoot,
165
+ delivery: "custom",
166
+ deliveryAreaTypeName: "AppDeliveryArea",
167
+ deliveryAreaNames: ["eu", "us"],
168
+ splitPathsByNamespace: {
169
+ billing: {
170
+ eu: "src/i18n/generated/translations/billing.eu.json",
171
+ },
172
+ },
173
+ })
174
+ ).toThrow('[Codegen Error] Missing split path for namespace "billing", area "us".');
175
+ });
176
+ });
@@ -1,13 +1,95 @@
1
+ import path from "node:path";
2
+ import type { DeliveryMode } from "../codegen-config-schema.js";
1
3
  import { GENERATED_FILE_BANNER, toImportPath, toRelativeModuleImport } from "../paths.js";
2
4
  import type { ImportExtension, NamespaceEntry } from "../types.js";
3
5
 
4
- export function formatNamespaceLoadersFile(
5
- loadersOutputPath: string,
6
- lazyEntries: (NamespaceEntry & { absolutePath: string })[],
7
- schemaTypeName: string,
8
- typesModule: string,
9
- importExtension: ImportExtension
6
+ /**
7
+ * Emits the generated `namespace-loaders.generated.ts` module: dynamic `import()`
8
+ * functions for lazy-loaded namespaces, shaped by delivery mode.
9
+ */
10
+ export interface NamespaceLoadersFileOptions {
11
+ loadersOutputPath: string;
12
+ lazyEntries: (NamespaceEntry & { absolutePath: string })[];
13
+ schemaTypeName: string;
14
+ localeTypeName: string;
15
+ typesModule: string;
16
+ importExtension: ImportExtension;
17
+ projectRoot: string;
18
+ delivery?: DeliveryMode;
19
+ splitPathsByNamespace?: Record<string, Record<string, string>>;
20
+ requestLocales?: readonly string[];
21
+ deliveryAreaTypeName?: string;
22
+ deliveryAreaNames?: readonly string[];
23
+ }
24
+
25
+ interface PartitionedNamespaceLoadersParams {
26
+ partitionKeys: readonly string[];
27
+ paramName: string;
28
+ paramTypeName: string;
29
+ }
30
+
31
+ /**
32
+ * Emits lazy loaders for split-by-locale and custom delivery.
33
+ * Each namespace gets a function that dynamic-imports the JSON for the given
34
+ * partition key (locale or area) via a switch.
35
+ */
36
+ function formatPartitionedNamespaceLoadersFile(
37
+ options: NamespaceLoadersFileOptions,
38
+ { partitionKeys, paramName, paramTypeName }: PartitionedNamespaceLoadersParams
10
39
  ): string {
40
+ const {
41
+ loadersOutputPath,
42
+ lazyEntries,
43
+ schemaTypeName,
44
+ typesModule,
45
+ importExtension,
46
+ projectRoot,
47
+ splitPathsByNamespace = {},
48
+ } = options;
49
+
50
+ const typesImport = toRelativeModuleImport(typesModule, importExtension);
51
+ const loaderEntries = lazyEntries
52
+ .map((entry) => {
53
+ const switchCases = partitionKeys
54
+ .map((partitionKey) => {
55
+ const splitRelativePath = splitPathsByNamespace[entry.namespace]?.[partitionKey];
56
+ if (!splitRelativePath) {
57
+ throw new Error(
58
+ `[Codegen Error] Missing split path for namespace "${entry.namespace}", ${paramName} "${partitionKey}".`
59
+ );
60
+ }
61
+ const importPath = toImportPath(
62
+ loadersOutputPath,
63
+ path.resolve(projectRoot, splitRelativePath)
64
+ );
65
+ return (
66
+ ` case ${JSON.stringify(partitionKey)}:\n` +
67
+ ` return import('${importPath}.json').then((m) => m.default);`
68
+ );
69
+ })
70
+ .join("\n");
71
+
72
+ return (
73
+ ` ${entry.namespace}: (${paramName}) => {\n` +
74
+ ` switch (${paramName}) {\n${switchCases}\n` +
75
+ ` }\n` +
76
+ ` },`
77
+ );
78
+ })
79
+ .join("\n");
80
+
81
+ return (
82
+ `${GENERATED_FILE_BANNER}` +
83
+ `import type { ${schemaTypeName}, LazyNamespace, ${paramTypeName} } from '${typesImport}';\n\n` +
84
+ `export const namespaceLoaders: {\n` +
85
+ ` [K in LazyNamespace]: (${paramName}: ${paramTypeName}) => Promise<${schemaTypeName}[K]>;\n` +
86
+ `} = {\n${loaderEntries}\n};\n`
87
+ );
88
+ }
89
+
90
+ /** Emits lazy loaders for canonical delivery: one dynamic import per namespace. */
91
+ function formatCanonicalNamespaceLoadersFile(options: NamespaceLoadersFileOptions): string {
92
+ const { loadersOutputPath, lazyEntries, schemaTypeName, typesModule, importExtension } = options;
11
93
  const typesImport = toRelativeModuleImport(typesModule, importExtension);
12
94
  const loaderEntries = lazyEntries
13
95
  .map((entry) => {
@@ -24,3 +106,29 @@ export function formatNamespaceLoadersFile(
24
106
  `} = {\n${loaderEntries}\n};\n`
25
107
  );
26
108
  }
109
+
110
+ /**
111
+ * Builds `namespace-loaders.generated.ts` for the configured delivery mode:
112
+ * canonical, split-by-locale, or custom areas.
113
+ */
114
+ export function formatNamespaceLoadersFile(options: NamespaceLoadersFileOptions): string {
115
+ const delivery = options.delivery ?? "canonical";
116
+ if (delivery === "split-by-locale") {
117
+ return formatPartitionedNamespaceLoadersFile(options, {
118
+ partitionKeys: options.requestLocales ?? [],
119
+ paramName: "locale",
120
+ paramTypeName: options.localeTypeName,
121
+ });
122
+ }
123
+ if (delivery === "custom") {
124
+ if (!options.deliveryAreaTypeName) {
125
+ throw new Error("[Codegen Error] deliveryAreaTypeName is required for custom delivery.");
126
+ }
127
+ return formatPartitionedNamespaceLoadersFile(options, {
128
+ partitionKeys: options.deliveryAreaNames ?? [],
129
+ paramName: "area",
130
+ paramTypeName: options.deliveryAreaTypeName,
131
+ });
132
+ }
133
+ return formatCanonicalNamespaceLoadersFile(options);
134
+ }
@@ -0,0 +1,114 @@
1
+ import path from "node:path";
2
+ import { describe, expect, it } from "vitest";
3
+ import { formatTypesFile } from "./types-file.js";
4
+
5
+ const projectRoot = "/project";
6
+ const typesOutputPath = path.join(projectRoot, "src/i18n/generated/i18n-types.generated.ts");
7
+
8
+ describe("formatTypesFile", () => {
9
+ it("emits explicit Partial schema types for multi-namespace", () => {
10
+ const output = formatTypesFile({
11
+ isSingle: false,
12
+ entries: [
13
+ { namespace: "default", filePath: "src/i18n/translations/default.json" },
14
+ { namespace: "billing", filePath: "src/i18n/translations/billing.yaml" },
15
+ ],
16
+ projectRoot,
17
+ typesOutputPath,
18
+ paramsTypeName: "AppParams",
19
+ schemaTypeName: "AppSchema",
20
+ localeTypeName: "AppLocale",
21
+ localeFallbackConstName: "LOCALE_FALLBACK",
22
+ localeFallbackTypeName: "AppLocaleFallback",
23
+ paramsByNamespace: {
24
+ default: {
25
+ login_button: "never",
26
+ welcome: "{ name: string }",
27
+ },
28
+ billing: {
29
+ invoice_summary: "{ count: number }",
30
+ },
31
+ },
32
+ requestLocaleUnion: "'en' | 'it'",
33
+ hasLazy: true,
34
+ loadOnInitSet: new Set(["default"]),
35
+ lazyEntries: [{ namespace: "billing", filePath: "src/i18n/translations/billing.yaml" }],
36
+ });
37
+
38
+ expect(output).toContain("login_button: Partial<Record<AppLocale, string>>;");
39
+ expect(output).toContain("invoice_summary: Partial<Record<AppLocale, string>>;");
40
+ expect(output).not.toContain("typeof import");
41
+ });
42
+
43
+ it("emits explicit Partial schema types for single-file mode", () => {
44
+ const output = formatTypesFile({
45
+ isSingle: true,
46
+ entries: [{ namespace: "translations", filePath: "src/i18n/translations/translations.yaml" }],
47
+ projectRoot,
48
+ typesOutputPath,
49
+ paramsTypeName: "AppParams",
50
+ schemaTypeName: "AppSchema",
51
+ localeTypeName: "AppLocale",
52
+ localeFallbackConstName: "LOCALE_FALLBACK",
53
+ localeFallbackTypeName: "AppLocaleFallback",
54
+ paramsByNamespace: {
55
+ translations: {
56
+ welcome: "{ name: string }",
57
+ },
58
+ },
59
+ requestLocaleUnion: "'en' | 'it'",
60
+ hasLazy: false,
61
+ loadOnInitSet: new Set(),
62
+ lazyEntries: [],
63
+ });
64
+
65
+ expect(output).toContain(
66
+ "export type AppSchema = {\n welcome: Partial<Record<AppLocale, string>>;\n};"
67
+ );
68
+ expect(output).not.toContain("typeof import");
69
+ });
70
+
71
+ it("emits delivery area type for custom delivery", () => {
72
+ const output = formatTypesFile({
73
+ isSingle: false,
74
+ entries: [
75
+ { namespace: "default", filePath: "src/i18n/translations/default.json" },
76
+ { namespace: "billing", filePath: "src/i18n/translations/billing.yaml" },
77
+ ],
78
+ projectRoot,
79
+ typesOutputPath,
80
+ paramsTypeName: "AppParams",
81
+ schemaTypeName: "AppSchema",
82
+ localeTypeName: "AppLocale",
83
+ localeFallbackConstName: "LOCALE_FALLBACK",
84
+ localeFallbackTypeName: "AppLocaleFallback",
85
+ localeFallback: {
86
+ "en-US": null,
87
+ it: "en-US",
88
+ fr: null,
89
+ },
90
+ paramsByNamespace: {
91
+ default: { some_key: "never" },
92
+ billing: { invoice_summary: "{ count: number }" },
93
+ },
94
+ requestLocaleUnion: "'en-US' | 'fr' | 'it'",
95
+ deliveryAreaTypeName: "AppDeliveryArea",
96
+ deliveryAreaUnion: "'eu' | 'us'",
97
+ deliveryArtifacts: {
98
+ eu: ["it", "fr"],
99
+ us: ["en-US"],
100
+ },
101
+ hasLazy: true,
102
+ loadOnInitSet: new Set(["default"]),
103
+ lazyEntries: [{ namespace: "billing", filePath: "src/i18n/translations/billing.yaml" }],
104
+ });
105
+
106
+ expect(output).toContain("export type AppLocale = 'en-US' | 'fr' | 'it';");
107
+ expect(output).toContain("export type AppDeliveryArea = 'eu' | 'us';");
108
+ expect(output).toContain("export const LOCALE_DELIVERY_AREA = {");
109
+ expect(output).toContain('"it": "eu"');
110
+ expect(output).toContain('"en-US": "us"');
111
+ expect(output).toContain("} as const satisfies Record<AppLocale, AppDeliveryArea>;");
112
+ expect(output).toContain("some_key: Partial<Record<AppLocale, string>>;");
113
+ });
114
+ });