@xndrjs/i18n 0.6.1 → 0.7.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 (51) hide show
  1. package/README.md +154 -141
  2. package/dist/codegen/index.js.map +1 -1
  3. package/dist/index.d.ts +268 -69
  4. package/dist/index.js +572 -65
  5. package/dist/index.js.map +1 -1
  6. package/dist/validation/index.d.ts +23 -7
  7. package/dist/validation/index.js +196 -89
  8. package/dist/validation/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/IcuTranslationProviderMulti.test.ts +80 -234
  11. package/src/IcuTranslationProviderMulti.ts +101 -112
  12. package/src/IcuTranslationProviderSingle.test.ts +30 -309
  13. package/src/IcuTranslationProviderSingle.ts +42 -75
  14. package/src/builder-load-registry.ts +18 -0
  15. package/src/builder-loaders.ts +18 -0
  16. package/src/builder-multi.ts +481 -0
  17. package/src/builder-types.test.ts +80 -0
  18. package/src/builder-types.ts +24 -0
  19. package/src/builder.test.ts +421 -0
  20. package/src/builder.ts +112 -0
  21. package/src/codegen/delivery-artifacts.test.ts +2 -1
  22. package/src/codegen/delivery-artifacts.ts +2 -1
  23. package/src/codegen/emit/dictionary-file.test.ts +0 -7
  24. package/src/codegen/emit/dictionary-schema-file.ts +55 -42
  25. package/src/codegen/emit/instance-file.test.ts +88 -0
  26. package/src/codegen/emit/instance-file.ts +164 -17
  27. package/src/codegen/emit/namespace-loaders-file.test.ts +4 -18
  28. package/src/codegen/emit/namespace-loaders-file.ts +4 -54
  29. package/src/codegen/emit/types-file.test.ts +0 -2
  30. package/src/codegen/generate-i18n-types.test.ts +8 -303
  31. package/src/codegen/generate-i18n-types.ts +16 -1
  32. package/src/codegen/project-locales-set-namespace.test.ts +25 -32
  33. package/src/engine.ts +71 -0
  34. package/src/index.ts +45 -7
  35. package/src/patch-key.test.ts +186 -0
  36. package/src/patch-key.ts +140 -0
  37. package/src/project-locales.test.ts +16 -6
  38. package/src/project-locales.ts +17 -6
  39. package/src/scope-multi.ts +124 -0
  40. package/src/scope-single.ts +85 -0
  41. package/src/scope-types.ts +27 -0
  42. package/src/scope.test.ts +481 -0
  43. package/src/single-builder.ts +153 -0
  44. package/src/types.ts +16 -0
  45. package/src/validation/create-normalized-schema.ts +1 -1
  46. package/src/validation/errors.ts +2 -0
  47. package/src/validation/index.ts +135 -27
  48. package/src/validation/normalize.ts +176 -46
  49. package/src/validation/types.ts +4 -0
  50. package/src/validation/validate-normalized.ts +43 -1
  51. package/src/validation/validation.test.ts +200 -7
@@ -27,7 +27,6 @@ describe("formatDictionaryFile", () => {
27
27
 
28
28
  expect(output).toContain("export const defaultDictionary: InitialSchema");
29
29
  expect(output).toContain("defaultNs");
30
- expect(output).not.toContain("defaultDictionaryFor");
31
30
  });
32
31
 
33
32
  it("emits an empty module when all namespaces are lazy in split mode (multi)", () => {
@@ -100,8 +99,6 @@ describe("formatDictionaryFile", () => {
100
99
  "export function defaultDictionaryFor(locale: AppLocale): InitialSchema"
101
100
  );
102
101
  expect(output).toContain("return {\n default: defaultByLocale[locale],\n };");
103
- expect(output).not.toContain("export const defaultDictionary");
104
- expect(output).not.toContain("billingEn");
105
102
  });
106
103
 
107
104
  it("emits defaultDictionaryFor with per-locale imports in split mode (single)", () => {
@@ -136,7 +133,6 @@ describe("formatDictionaryFile", () => {
136
133
  );
137
134
  expect(output).toContain("export function defaultDictionaryFor(locale: AppLocale): AppSchema");
138
135
  expect(output).toContain("return translationsByLocale[locale];");
139
- expect(output).not.toContain("export const defaultDictionary");
140
136
  });
141
137
 
142
138
  it("emits defaultDictionaryFor with per-area imports in custom mode (multi)", () => {
@@ -178,8 +174,6 @@ describe("formatDictionaryFile", () => {
178
174
  "export function defaultDictionaryFor(area: AppDeliveryArea): InitialSchema"
179
175
  );
180
176
  expect(output).toContain("return {\n default: defaultByArea[area],\n };");
181
- expect(output).not.toContain("export const defaultDictionary");
182
- expect(output).not.toContain("billingEu");
183
177
  });
184
178
 
185
179
  it("emits defaultDictionaryFor with per-area imports in custom mode (single)", () => {
@@ -217,6 +211,5 @@ describe("formatDictionaryFile", () => {
217
211
  "export function defaultDictionaryFor(area: AppDeliveryArea): AppSchema"
218
212
  );
219
213
  expect(output).toContain("return translationsByArea[area];");
220
- expect(output).not.toContain("export const defaultDictionary");
221
214
  });
222
215
  });
@@ -79,58 +79,71 @@ export function formatDictionarySchemaFile(
79
79
  importExtension: ImportExtension
80
80
  ): string {
81
81
  const typesImport = toRelativeModuleImport(typesModule, importExtension);
82
- const namespaceImport = isSingle
83
- ? ""
84
- : ` validateExternalNamespace as validateExternalNamespaceCore,\n`;
85
- const namespaceValidator = isSingle
86
- ? ""
87
- : `export function validateExternalNamespace<NS extends keyof ${schemaTypeName}>(\n` +
88
- ` namespace: NS,\n` +
89
- ` input: unknown,\n` +
90
- `) {\n` +
91
- ` return validateExternalNamespaceCore<${schemaTypeName}[NS]>(\n` +
92
- ` namespace as string,\n` +
93
- ` input,\n` +
94
- ` DICTIONARY_SPEC,\n` +
95
- ` );\n` +
96
- `}\n\n`;
82
+ const partialImport = isSingle
83
+ ? ` validateExternalDictionaryPartial as validateExternalDictionaryPartialCore,\n` +
84
+ ` validateExternalKey as validateExternalKeyCore,\n`
85
+ : ` validateExternalDictionaryPartial as validateExternalDictionaryPartialCore,\n` +
86
+ ` validateExternalNamespacePartial as validateExternalNamespacePartialCore,\n` +
87
+ ` validateExternalKey as validateExternalKeyCore,\n`;
88
+
89
+ const singleKeyValidator =
90
+ `export function validateExternalKey<K extends keyof ${schemaTypeName}>(\n` +
91
+ ` key: K,\n` +
92
+ ` input: unknown,\n` +
93
+ `): ValidationResult<Pick<${schemaTypeName}, K>> {\n` +
94
+ ` return validateExternalKeyCore<Pick<${schemaTypeName}, K>>(\n` +
95
+ ` key as string,\n` +
96
+ ` input,\n` +
97
+ ` DICTIONARY_SPEC,\n` +
98
+ ` );\n` +
99
+ `}\n\n`;
100
+
101
+ const multiValidators =
102
+ `export function validateExternalNamespacePartial<NS extends keyof ${schemaTypeName}>(\n` +
103
+ ` namespace: NS,\n` +
104
+ ` input: unknown,\n` +
105
+ `): ValidationResult<Partial<${schemaTypeName}[NS]>> {\n` +
106
+ ` return validateExternalNamespacePartialCore<Partial<${schemaTypeName}[NS]>>(\n` +
107
+ ` namespace as string,\n` +
108
+ ` input,\n` +
109
+ ` DICTIONARY_SPEC,\n` +
110
+ ` );\n` +
111
+ `}\n\n` +
112
+ `export function validateExternalKey<\n` +
113
+ ` NS extends keyof ${schemaTypeName},\n` +
114
+ ` K extends keyof ${schemaTypeName}[NS],\n` +
115
+ `>(\n` +
116
+ ` namespace: NS,\n` +
117
+ ` key: K,\n` +
118
+ ` input: unknown,\n` +
119
+ `): ValidationResult<Pick<${schemaTypeName}[NS], K>> {\n` +
120
+ ` return validateExternalKeyCore<Pick<${schemaTypeName}[NS], K>>(\n` +
121
+ ` namespace as string,\n` +
122
+ ` key as string,\n` +
123
+ ` input,\n` +
124
+ ` DICTIONARY_SPEC,\n` +
125
+ ` );\n` +
126
+ `}\n\n`;
127
+
128
+ const keyValidator = isSingle ? singleKeyValidator : multiValidators;
97
129
 
98
130
  return (
99
131
  `${GENERATED_FILE_BANNER}` +
100
132
  `import {\n` +
101
- ` normalizeDictionary,\n` +
102
- ` validateNormalizedDictionary,\n` +
103
- namespaceImport +
104
- ` toDictionary,\n` +
133
+ partialImport +
105
134
  ` type DictionarySpec,\n` +
106
- ` type NormalizedDictionary,\n` +
107
135
  ` type ValidationResult,\n` +
108
136
  `} from '@xndrjs/i18n/validation';\n` +
109
137
  `import type { ${schemaTypeName} } from '${typesImport}';\n\n` +
110
138
  `${dictionarySpecBlock}\n` +
111
- `export function normalizeExternalDictionary(\n` +
112
- ` input: unknown,\n` +
113
- `): ValidationResult<NormalizedDictionary> {\n` +
114
- ` return normalizeDictionary(input, DICTIONARY_SPEC);\n` +
115
- `}\n\n` +
116
- `export function validateNormalizedExternalDictionary(\n` +
117
- ` normalized: NormalizedDictionary,\n` +
118
- `): ValidationResult<NormalizedDictionary> {\n` +
119
- ` return validateNormalizedDictionary(normalized, DICTIONARY_SPEC);\n` +
120
- `}\n\n` +
121
- `export function validateExternalDictionary(\n` +
139
+ `export function validateExternalDictionaryPartial(\n` +
122
140
  ` input: unknown,\n` +
123
- `): ValidationResult<${schemaTypeName}> {\n` +
124
- ` const step1 = normalizeExternalDictionary(input);\n` +
125
- ` if (!step1.ok) {\n` +
126
- ` return step1;\n` +
127
- ` }\n\n` +
128
- ` const step2 = validateNormalizedExternalDictionary(step1.data);\n` +
129
- ` if (!step2.ok) {\n` +
130
- ` return step2;\n` +
131
- ` }\n\n` +
132
- ` return { ok: true, data: toDictionary(step2.data) as ${schemaTypeName} };\n` +
141
+ `): ValidationResult<Partial<${schemaTypeName}>> {\n` +
142
+ ` return validateExternalDictionaryPartialCore<Partial<${schemaTypeName}>>(\n` +
143
+ ` input,\n` +
144
+ ` DICTIONARY_SPEC,\n` +
145
+ ` );\n` +
133
146
  `}\n\n` +
134
- namespaceValidator
147
+ keyValidator
135
148
  );
136
149
  }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatInstanceFile, type InstanceFileOptions } from "./instance-file.js";
3
+
4
+ function baseOptions(overrides: Partial<InstanceFileOptions> = {}): InstanceFileOptions {
5
+ return {
6
+ isSingle: false,
7
+ hasLazy: false,
8
+ typesOutputPath: "src/i18n/i18n-types.generated.ts",
9
+ paramsTypeName: "AppParams",
10
+ schemaTypeName: "AppSchema",
11
+ localeTypeName: "AppLocale",
12
+ localeFallbackConstName: "LOCALE_FALLBACK",
13
+ factoryName: "createI18n",
14
+ hasLocaleFallback: true,
15
+ hasLocaleType: true,
16
+ namespaceNames: ["default", "billing"],
17
+ importExtension: "none",
18
+ delivery: "canonical",
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ function packageImportBlock(output: string): string {
24
+ const match = output.match(/import \{([\s\S]*?)\} from '@xndrjs\/i18n'/);
25
+ return match?.[1] ?? "";
26
+ }
27
+
28
+ describe("formatInstanceFile package imports", () => {
29
+ it("emits only single-scope symbols for single eager projects", () => {
30
+ const imports = packageImportBlock(formatInstanceFile(baseOptions({ isSingle: true })));
31
+
32
+ expect(imports).toContain("IcuTranslationProviderSingle");
33
+ expect(imports).toContain("projectNamespaceLocalesCore");
34
+ expect(imports).toContain("type I18nScopeSingle");
35
+ expect(imports).toContain("type OnMissingTranslation");
36
+ expect(imports).not.toContain("createI18nBuilder");
37
+ expect(imports).not.toContain("createI18nMultiBuilder");
38
+ expect(imports).not.toContain("I18nBuilderMultiInitial");
39
+ expect(imports).not.toContain("I18nBuilderMultiOptions");
40
+ expect(imports).not.toContain("I18nBuilderMultiPartitioned");
41
+ expect(imports).not.toContain("I18nScopeMulti");
42
+ expect(imports).not.toContain("projectDictionaryLocalesCore");
43
+ });
44
+
45
+ it("emits multi eager scope symbols without builder imports", () => {
46
+ const imports = packageImportBlock(formatInstanceFile(baseOptions()));
47
+
48
+ expect(imports).toContain("IcuTranslationProviderMulti");
49
+ expect(imports).toContain("projectDictionaryLocalesCore");
50
+ expect(imports).toContain("type I18nScopeMulti");
51
+ expect(imports).not.toContain("createI18nMultiBuilder");
52
+ expect(imports).not.toContain("I18nBuilderMultiInitial");
53
+ expect(imports).not.toContain("I18nScopeSingle");
54
+ });
55
+
56
+ it("emits lazy multi builder symbols instead of I18nScopeMulti", () => {
57
+ const imports = packageImportBlock(
58
+ formatInstanceFile(
59
+ baseOptions({
60
+ hasLazy: true,
61
+ namespaceLoadersOutputPath: "src/i18n/namespace-loaders.generated.ts",
62
+ })
63
+ )
64
+ );
65
+
66
+ expect(imports).toContain("createI18nMultiBuilder");
67
+ expect(imports).toContain("type I18nBuilderMultiInitial");
68
+ expect(imports).toContain("type I18nBuilderMultiOptions");
69
+ expect(imports).not.toContain("type I18nScopeMulti");
70
+ expect(imports).not.toContain("createI18nBuilder");
71
+ expect(imports).not.toContain("I18nBuilderMultiPartitioned");
72
+ });
73
+
74
+ it("includes delivery-area projection helpers when delivery is custom", () => {
75
+ const imports = packageImportBlock(
76
+ formatInstanceFile(
77
+ baseOptions({
78
+ delivery: "custom",
79
+ deliveryAreaTypeName: "AppDeliveryArea",
80
+ deliveryArtifactsTypeName: "AppDeliveryArtifacts",
81
+ })
82
+ )
83
+ );
84
+
85
+ expect(imports).toContain("projectNamespaceForDeliveryAreaCore");
86
+ expect(imports).toContain("projectDictionaryForDeliveryAreaCore");
87
+ });
88
+ });
@@ -10,6 +10,7 @@ export interface InstanceFileOptions {
10
10
  isSingle: boolean;
11
11
  hasLazy: boolean;
12
12
  typesOutputPath: string;
13
+ namespaceLoadersOutputPath?: string;
13
14
  paramsTypeName: string;
14
15
  schemaTypeName: string;
15
16
  localeTypeName: string;
@@ -20,6 +21,8 @@ export interface InstanceFileOptions {
20
21
  namespaceNames: string[];
21
22
  importExtension: ImportExtension;
22
23
  delivery: DeliveryMode;
24
+ deliveryAreaTypeName?: string;
25
+ deliveryArtifactsTypeName?: string;
23
26
  }
24
27
 
25
28
  export function formatInstanceFile(options: InstanceFileOptions): string {
@@ -27,6 +30,7 @@ export function formatInstanceFile(options: InstanceFileOptions): string {
27
30
  isSingle,
28
31
  hasLazy,
29
32
  typesOutputPath,
33
+ namespaceLoadersOutputPath,
30
34
  paramsTypeName,
31
35
  schemaTypeName,
32
36
  localeTypeName,
@@ -37,16 +41,28 @@ export function formatInstanceFile(options: InstanceFileOptions): string {
37
41
  namespaceNames,
38
42
  importExtension,
39
43
  delivery,
44
+ deliveryAreaTypeName,
45
+ deliveryArtifactsTypeName,
40
46
  } = options;
41
47
 
42
48
  const emitDeliveryAreaHelpers = delivery === "custom";
43
49
  const providerClass = isSingle ? "IcuTranslationProviderSingle" : "IcuTranslationProviderMulti";
44
50
  const typesModule = toModuleBasename(typesOutputPath);
45
51
  const typesImport = toRelativeModuleImport(typesModule, importExtension);
52
+ const loadersModule = namespaceLoadersOutputPath
53
+ ? toModuleBasename(namespaceLoadersOutputPath)
54
+ : null;
55
+ const loadersImport =
56
+ loadersModule && namespaceLoadersOutputPath
57
+ ? toRelativeModuleImport(loadersModule, importExtension)
58
+ : null;
46
59
  const dictionaryParamType = hasLazy ? "InitialSchema" : schemaTypeName;
47
- const schemaTypesImport = hasLazy
48
- ? `import type { ${paramsTypeName}, ${schemaTypeName}, InitialSchema } from '${typesImport}';\n`
49
- : `import type { ${paramsTypeName}, ${schemaTypeName} } from '${typesImport}';\n`;
60
+ const schemaTypesImport =
61
+ hasLazy && delivery === "custom" && deliveryAreaTypeName && deliveryArtifactsTypeName
62
+ ? `import type { ${paramsTypeName}, ${schemaTypeName}, InitialSchema, ${deliveryAreaTypeName}, ${deliveryArtifactsTypeName} } from '${typesImport}';\n`
63
+ : hasLazy
64
+ ? `import type { ${paramsTypeName}, ${schemaTypeName}, InitialSchema } from '${typesImport}';\n`
65
+ : `import type { ${paramsTypeName}, ${schemaTypeName} } from '${typesImport}';\n`;
50
66
  const providerTypeArgs = hasLocaleFallback
51
67
  ? `${schemaTypeName}, ${paramsTypeName}, ${localeTypeName}, typeof ${localeFallbackConstName}`
52
68
  : `${schemaTypeName}, ${paramsTypeName}`;
@@ -60,7 +76,12 @@ export function formatInstanceFile(options: InstanceFileOptions): string {
60
76
  : "";
61
77
  const localesParamType = hasLocaleType ? `readonly ${localeTypeName}[]` : "readonly string[]";
62
78
  const fallbackArg = hasLocaleFallback ? `, ${localeFallbackConstName}` : "";
63
- const coreImports = formatCoreImports(isSingle, emitDeliveryAreaHelpers);
79
+ const packageImports = formatPackageImports(
80
+ providerClass,
81
+ isSingle,
82
+ hasLazy,
83
+ emitDeliveryAreaHelpers
84
+ );
64
85
  const projectionBlock = isSingle
65
86
  ? formatSingleProjectionBlock(
66
87
  schemaTypeName,
@@ -76,36 +97,162 @@ export function formatInstanceFile(options: InstanceFileOptions): string {
76
97
  emitDeliveryAreaHelpers
77
98
  );
78
99
 
100
+ const createFactoryBlock = formatCreateI18nFactory({
101
+ isSingle,
102
+ hasLazy,
103
+ hasLocaleFallback,
104
+ providerClass,
105
+ providerTypeArgs,
106
+ providerOptions,
107
+ factoryName,
108
+ dictionaryParamType,
109
+ schemaTypeName,
110
+ paramsTypeName,
111
+ localeTypeName,
112
+ namespaceNames,
113
+ loadersImport,
114
+ delivery,
115
+ ...(deliveryAreaTypeName !== undefined ? { deliveryAreaTypeName } : {}),
116
+ ...(deliveryArtifactsTypeName !== undefined ? { deliveryArtifactsTypeName } : {}),
117
+ });
118
+
79
119
  return (
80
120
  `${GENERATED_FILE_BANNER}` +
81
- `import { ${providerClass}, ${coreImports}, type OnMissingTranslation } from '@xndrjs/i18n';\n` +
121
+ `import {\n` +
122
+ ` ${packageImports},\n` +
123
+ `} from '@xndrjs/i18n';\n` +
82
124
  schemaTypesImport +
83
125
  typesImportLine +
126
+ (hasLazy && loadersImport ? `import { namespaceLoaders } from '${loadersImport}';\n` : "") +
84
127
  `\n` +
128
+ createFactoryBlock +
129
+ projectionBlock
130
+ );
131
+ }
132
+
133
+ function formatCreateI18nFactory(options: {
134
+ isSingle: boolean;
135
+ hasLazy: boolean;
136
+ hasLocaleFallback: boolean;
137
+ providerClass: string;
138
+ providerTypeArgs: string;
139
+ providerOptions: string;
140
+ factoryName: string;
141
+ dictionaryParamType: string;
142
+ schemaTypeName: string;
143
+ paramsTypeName: string;
144
+ localeTypeName: string;
145
+ namespaceNames: string[];
146
+ loadersImport: string | null;
147
+ delivery: DeliveryMode;
148
+ deliveryAreaTypeName?: string;
149
+ deliveryArtifactsTypeName?: string;
150
+ }): string {
151
+ const {
152
+ isSingle,
153
+ hasLazy,
154
+ providerClass,
155
+ providerTypeArgs,
156
+ providerOptions,
157
+ factoryName,
158
+ dictionaryParamType,
159
+ schemaTypeName,
160
+ paramsTypeName,
161
+ localeTypeName,
162
+ namespaceNames,
163
+ loadersImport,
164
+ delivery,
165
+ deliveryAreaTypeName,
166
+ deliveryArtifactsTypeName,
167
+ } = options;
168
+
169
+ if (isSingle) {
170
+ return (
171
+ `export function ${factoryName}(\n` +
172
+ ` dictionary: ${dictionaryParamType},\n` +
173
+ ` options?: { onMissing?: OnMissingTranslation },\n` +
174
+ `): I18nScopeSingle<${schemaTypeName}, ${paramsTypeName}, ${localeTypeName}> {\n` +
175
+ ` return new ${providerClass}<${providerTypeArgs}>(dictionary${providerOptions}).toScope();\n` +
176
+ `}\n`
177
+ );
178
+ }
179
+
180
+ if (!hasLazy) {
181
+ const namespacesLiteral = `[${namespaceNames
182
+ .slice()
183
+ .sort()
184
+ .map((ns) => JSON.stringify(ns))
185
+ .join(", ")}] as const`;
186
+ return (
187
+ `export function ${factoryName}(\n` +
188
+ ` dictionary: ${dictionaryParamType},\n` +
189
+ ` options?: { onMissing?: OnMissingTranslation },\n` +
190
+ `): I18nScopeMulti<${schemaTypeName}, ${paramsTypeName}, ${localeTypeName}> {\n` +
191
+ ` const engine = new ${providerClass}<${providerTypeArgs}>(dictionary${providerOptions});\n` +
192
+ ` return engine.toScope({ namespaces: ${namespacesLiteral} });\n` +
193
+ `}\n`
194
+ );
195
+ }
196
+
197
+ if (!loadersImport) {
198
+ throw new Error("[Codegen Error] namespaceLoadersOutputPath is required when hasLazy is true.");
199
+ }
200
+
201
+ const builderReturnType =
202
+ delivery === "custom" && deliveryAreaTypeName && deliveryArtifactsTypeName
203
+ ? `I18nBuilderMultiInitial<${schemaTypeName}, ${paramsTypeName}, ${localeTypeName}, ${localeTypeName}, ${deliveryAreaTypeName}, ${deliveryArtifactsTypeName}>`
204
+ : `I18nBuilderMultiInitial<${schemaTypeName}, ${paramsTypeName}, ${localeTypeName}>`;
205
+
206
+ const multiBuilderTypeArgs =
207
+ delivery === "custom" && deliveryAreaTypeName && deliveryArtifactsTypeName
208
+ ? `<${schemaTypeName}, ${paramsTypeName}, ${localeTypeName}, ${deliveryAreaTypeName}, ${deliveryArtifactsTypeName}>`
209
+ : `<${schemaTypeName}, ${paramsTypeName}, ${localeTypeName}>`;
210
+
211
+ return (
85
212
  `export function ${factoryName}(\n` +
86
213
  ` dictionary: ${dictionaryParamType},\n` +
87
214
  ` options?: { onMissing?: OnMissingTranslation },\n` +
88
- `) {\n` +
89
- ` return new ${providerClass}<${providerTypeArgs}>(dictionary${providerOptions});\n` +
90
- `}\n` +
91
- projectionBlock
215
+ `): ${builderReturnType} {\n` +
216
+ ` const engine = new ${providerClass}<${providerTypeArgs}>(dictionary${providerOptions});\n` +
217
+ ` return createI18nMultiBuilder${multiBuilderTypeArgs}(engine, {\n` +
218
+ ` namespaceLoaders,\n` +
219
+ ` } as I18nBuilderMultiOptions<${schemaTypeName}, ${localeTypeName}>);\n` +
220
+ `}\n`
92
221
  );
93
222
  }
94
223
 
95
- function formatCoreImports(isSingle: boolean, emitDeliveryAreaHelpers: boolean): string {
224
+ function formatPackageImports(
225
+ providerClass: string,
226
+ isSingle: boolean,
227
+ hasLazy: boolean,
228
+ emitDeliveryAreaHelpers: boolean
229
+ ): string {
230
+ const imports = [providerClass];
231
+
96
232
  if (isSingle) {
97
- const imports = ["projectNamespaceLocalesCore"];
233
+ imports.push("projectNamespaceLocalesCore");
98
234
  if (emitDeliveryAreaHelpers) {
99
235
  imports.push("projectNamespaceForDeliveryAreaCore");
100
236
  }
101
- return imports.join(", ");
237
+ imports.push("type I18nScopeSingle");
238
+ } else {
239
+ imports.push("projectNamespaceLocalesCore", "projectDictionaryLocalesCore");
240
+ if (emitDeliveryAreaHelpers) {
241
+ imports.push("projectNamespaceForDeliveryAreaCore", "projectDictionaryForDeliveryAreaCore");
242
+ }
243
+ if (hasLazy) {
244
+ imports.push(
245
+ "createI18nMultiBuilder",
246
+ "type I18nBuilderMultiInitial",
247
+ "type I18nBuilderMultiOptions"
248
+ );
249
+ } else {
250
+ imports.push("type I18nScopeMulti");
251
+ }
102
252
  }
103
253
 
104
- const imports = ["projectNamespaceLocalesCore", "projectDictionaryLocalesCore"];
105
- if (emitDeliveryAreaHelpers) {
106
- imports.push("projectNamespaceForDeliveryAreaCore", "projectDictionaryForDeliveryAreaCore");
107
- }
108
- return imports.join(", ");
254
+ imports.push("type OnMissingTranslation");
255
+ return imports.join(",\n ");
109
256
  }
110
257
 
111
258
  function formatSingleProjectionBlock(
@@ -31,8 +31,6 @@ describe("formatNamespaceLoadersFile", () => {
31
31
  expect(output).toContain(
32
32
  "billing: () => import('./translations/billing.json').then((m) => m.default),"
33
33
  );
34
- expect(output).not.toContain("AppLocale");
35
- expect(output).not.toContain("ensureNamespacesLoadedForLocale");
36
34
  });
37
35
 
38
36
  it("emits ns(locale) loaders in split mode", () => {
@@ -97,18 +95,13 @@ describe("formatNamespaceLoadersFile", () => {
97
95
  "return import('./generated/translations/billing.en.json').then((m) => m.default);"
98
96
  );
99
97
  expect(output).toContain("user: (locale) => {");
100
- expect(output).not.toContain("billing: {");
101
- expect(output).not.toContain("import('./translations/billing.json')");
102
- expect(output).toContain("export async function ensureNamespacesLoadedForLocale(");
103
- expect(output).toContain("i18n.mergeNamespace(namespace,");
104
- expect(output).toContain("i18n: I18nMultiInstance,");
105
98
  expect(output).toContain(
106
- 'namespaces: readonly LazyNamespace[] = ["billing", "default", "user"] as const,'
99
+ 'export const defaultLazyNamespaces = ["billing", "default", "user"] as const;'
107
100
  );
108
101
  expect(output.match(/from '\.\/i18n-types\.generated'/g)?.length).toBe(1);
109
102
  });
110
103
 
111
- it("emits mergeAll helper for single mode in split delivery", () => {
104
+ it("emits locale-scoped loaders for single mode in split delivery", () => {
112
105
  const output = formatNamespaceLoadersFile({
113
106
  loadersOutputPath,
114
107
  lazyEntries: [
@@ -138,11 +131,7 @@ describe("formatNamespaceLoadersFile", () => {
138
131
  });
139
132
 
140
133
  expect(output).toContain("[K in LazyNamespace]: (locale: AppLocale) => Promise<AppSchema>;");
141
- expect(output).toContain("import type { TranslationProviderSingle } from '@xndrjs/i18n';");
142
- expect(output).toContain("type I18nSingleInstance = TranslationProviderSingle<");
143
- expect(output).toContain("export async function ensureNamespacesLoadedForLocale(");
144
- expect(output).toContain("i18n.mergeAll(await namespaceLoaders[namespace](locale));");
145
- expect(output).not.toContain("mergeNamespace");
134
+ expect(output).toContain('export const defaultLazyNamespaces = ["default"] as const;');
146
135
  });
147
136
 
148
137
  it("throws when a split path is missing", () => {
@@ -226,11 +215,8 @@ describe("formatNamespaceLoadersFile", () => {
226
215
  expect(output).toContain(
227
216
  "return import('./generated/translations/billing.eu.json').then((m) => m.default);"
228
217
  );
229
- expect(output).not.toContain("import('./translations/billing.json')");
230
- expect(output).toContain("export async function ensureNamespacesLoadedForArea(");
231
- expect(output).toContain("i18n.mergeNamespace(namespace,");
232
218
  expect(output).toContain(
233
- 'namespaces: readonly LazyNamespace[] = ["billing", "default"] as const,'
219
+ 'export const defaultLazyNamespaces = ["billing", "default"] as const;'
234
220
  );
235
221
  });
236
222
 
@@ -30,49 +30,6 @@ interface PartitionedNamespaceLoadersParams {
30
30
  partitionKeys: readonly string[];
31
31
  paramName: string;
32
32
  paramTypeName: string;
33
- loadNamespacesFunctionName: string;
34
- }
35
-
36
- function formatI18nMultiInstanceType(options: NamespaceLoadersFileOptions): string {
37
- const { schemaTypeName, paramsTypeName, localeTypeName } = options;
38
-
39
- return `TranslationProviderMulti<${schemaTypeName}, ${paramsTypeName}, ${localeTypeName}>`;
40
- }
41
-
42
- function formatI18nSingleInstanceType(options: NamespaceLoadersFileOptions): string {
43
- const { schemaTypeName, paramsTypeName, localeTypeName } = options;
44
-
45
- return `TranslationProviderSingle<${schemaTypeName}, ${paramsTypeName}, ${localeTypeName}>`;
46
- }
47
-
48
- function formatLoadNamespacesHelper(
49
- options: NamespaceLoadersFileOptions,
50
- { paramName, paramTypeName, loadNamespacesFunctionName }: PartitionedNamespaceLoadersParams
51
- ): string {
52
- const { lazyEntries, isSingle } = options;
53
- const defaultNamespaces = formatDefaultNamespacesLiteral(lazyEntries);
54
- const i18nInstanceType = isSingle
55
- ? formatI18nSingleInstanceType(options)
56
- : formatI18nMultiInstanceType(options);
57
- const mergeCall = isSingle
58
- ? `i18n.mergeAll(await namespaceLoaders[namespace](${paramName}))`
59
- : `i18n.mergeNamespace(namespace, await namespaceLoaders[namespace](${paramName}))`;
60
- const i18nTypeAlias = isSingle ? "I18nSingleInstance" : "I18nMultiInstance";
61
-
62
- return (
63
- `\ntype ${i18nTypeAlias} = ${i18nInstanceType};\n\n` +
64
- `export async function ${loadNamespacesFunctionName}(\n` +
65
- ` i18n: ${i18nTypeAlias},\n` +
66
- ` ${paramName}: ${paramTypeName},\n` +
67
- ` namespaces: readonly LazyNamespace[] = [${defaultNamespaces}] as const,\n` +
68
- `): Promise<void> {\n` +
69
- ` await Promise.all(\n` +
70
- ` namespaces.map(async (namespace) => {\n` +
71
- ` ${mergeCall};\n` +
72
- ` }),\n` +
73
- ` );\n` +
74
- `}\n`
75
- );
76
33
  }
77
34
 
78
35
  function formatDefaultNamespacesLiteral(lazyEntries: NamespaceEntry[]): string {
@@ -91,17 +48,11 @@ function formatPartitionedTypesImport(
91
48
  options;
92
49
 
93
50
  if (isSingle) {
94
- return (
95
- `import type { TranslationProviderSingle } from '@xndrjs/i18n';\n` +
96
- `import type { ${schemaTypeName}, LazyNamespace, ${paramTypeName}, ${localeTypeName} } from '${toRelativeModuleImport(typesModule, importExtension)}';\n\n`
97
- );
51
+ return `import type { ${schemaTypeName}, LazyNamespace, ${paramTypeName}, ${localeTypeName} } from '${toRelativeModuleImport(typesModule, importExtension)}';\n\n`;
98
52
  }
99
53
 
100
54
  const paramTypeImport = paramTypeName === localeTypeName ? "" : `, ${paramTypeName}`;
101
- return (
102
- `import type { TranslationProviderMulti } from '@xndrjs/i18n';\n` +
103
- `import type { ${localeTypeName}, ${paramsTypeName}, ${schemaTypeName}, LazyNamespace${paramTypeImport} } from '${toRelativeModuleImport(typesModule, importExtension)}';\n\n`
104
- );
55
+ return `import type { ${localeTypeName}, ${paramsTypeName}, ${schemaTypeName}, LazyNamespace${paramTypeImport} } from '${toRelativeModuleImport(typesModule, importExtension)}';\n\n`;
105
56
  }
106
57
 
107
58
  function formatSwitchDefaultCase(namespace: string, paramName: string): string {
@@ -169,7 +120,8 @@ function formatPartitionedNamespaceLoadersFile(
169
120
  `export const namespaceLoaders: {\n` +
170
121
  ` [K in LazyNamespace]: (${paramName}: ${paramTypeName}) => Promise<${loaderValueType}>;\n` +
171
122
  `} = {\n${loaderEntries}\n};\n` +
172
- formatLoadNamespacesHelper(options, params)
123
+ `\n` +
124
+ `export const defaultLazyNamespaces = [${formatDefaultNamespacesLiteral(lazyEntries)}] as const;\n`
173
125
  );
174
126
  }
175
127
 
@@ -204,7 +156,6 @@ export function formatNamespaceLoadersFile(options: NamespaceLoadersFileOptions)
204
156
  partitionKeys: options.requestLocales ?? [],
205
157
  paramName: "locale",
206
158
  paramTypeName: options.localeTypeName,
207
- loadNamespacesFunctionName: "ensureNamespacesLoadedForLocale",
208
159
  });
209
160
  }
210
161
  if (delivery === "custom") {
@@ -215,7 +166,6 @@ export function formatNamespaceLoadersFile(options: NamespaceLoadersFileOptions)
215
166
  partitionKeys: options.deliveryAreaNames ?? [],
216
167
  paramName: "area",
217
168
  paramTypeName: options.deliveryAreaTypeName,
218
- loadNamespacesFunctionName: "ensureNamespacesLoadedForArea",
219
169
  });
220
170
  }
221
171
  return formatCanonicalNamespaceLoadersFile(options);
@@ -37,7 +37,6 @@ describe("formatTypesFile", () => {
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
- expect(output).not.toContain("typeof import");
41
40
  });
42
41
 
43
42
  it("emits explicit Partial schema types for single-file mode", () => {
@@ -65,7 +64,6 @@ describe("formatTypesFile", () => {
65
64
  expect(output).toContain(
66
65
  "export type AppSchema = {\n welcome: Partial<Record<AppLocale, string>>;\n};"
67
66
  );
68
- expect(output).not.toContain("typeof import");
69
67
  });
70
68
 
71
69
  it("emits delivery area type for custom delivery", () => {