@xndrjs/i18n 0.5.0 → 0.6.0-alpha.1

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.
@@ -30,6 +30,38 @@ describe("formatDictionaryFile", () => {
30
30
  expect(output).not.toContain("defaultDictionaryFor");
31
31
  });
32
32
 
33
+ it("emits an empty module when all namespaces are lazy in split mode (multi)", () => {
34
+ const output = formatDictionaryFile({
35
+ isSingle: false,
36
+ hasLazy: true,
37
+ entries: [
38
+ { namespace: "default", filePath: "src/i18n/translations/default.json" },
39
+ { namespace: "billing", filePath: "src/i18n/translations/billing.json" },
40
+ ],
41
+ eagerEntries: [],
42
+ projectRoot,
43
+ dictionaryOutputPath,
44
+ typesOutputPath,
45
+ schemaTypeName: "AppSchema",
46
+ localeTypeName: "AppLocale",
47
+ importExtension: "none",
48
+ delivery: "split-by-locale",
49
+ requestLocales: ["en", "it"],
50
+ splitPathsByNamespace: {
51
+ default: {
52
+ en: "src/i18n/generated/translations/default.en.json",
53
+ it: "src/i18n/generated/translations/default.it.json",
54
+ },
55
+ billing: {
56
+ en: "src/i18n/generated/translations/billing.en.json",
57
+ it: "src/i18n/generated/translations/billing.it.json",
58
+ },
59
+ },
60
+ });
61
+
62
+ expect(output).toBeNull();
63
+ });
64
+
33
65
  it("emits defaultDictionaryFor with per-locale imports in split mode (multi)", () => {
34
66
  const output = formatDictionaryFile({
35
67
  isSingle: false,
@@ -67,7 +67,7 @@ function toPartitionSuffix(paramName: string): string {
67
67
  function formatPartitionedDictionaryFile(
68
68
  options: DictionaryFileOptions,
69
69
  { partitionKeys, paramName, paramTypeName }: PartitionedDictionaryParams
70
- ): string {
70
+ ): string | null {
71
71
  const {
72
72
  isSingle,
73
73
  hasLazy,
@@ -86,6 +86,10 @@ function formatPartitionedDictionaryFile(
86
86
  const dictionaryTypeName = hasLazy ? "InitialSchema" : schemaTypeName;
87
87
  const schemaTypeImport = hasLazy ? `, ${schemaTypeName}` : "";
88
88
 
89
+ if (eagerEntries.length === 0) {
90
+ return null;
91
+ }
92
+
89
93
  const imports = eagerEntries
90
94
  .flatMap((entry) =>
91
95
  partitionKeys.map((partitionKey) => {
@@ -164,7 +168,7 @@ function formatPartitionedDictionaryFile(
164
168
  * Builds `dictionary.generated.ts` for the configured delivery mode:
165
169
  * canonical (single bundled JSON per namespace), split-by-locale, or custom areas.
166
170
  */
167
- export function formatDictionaryFile(options: DictionaryFileOptions): string {
171
+ export function formatDictionaryFile(options: DictionaryFileOptions): string | null {
168
172
  const delivery = options.delivery ?? "canonical";
169
173
  if (delivery === "split-by-locale") {
170
174
  return formatPartitionedDictionaryFile(options, {
@@ -81,14 +81,14 @@ export function formatDictionarySchemaFile(
81
81
  const typesImport = toRelativeModuleImport(typesModule, importExtension);
82
82
  const namespaceImport = isSingle
83
83
  ? ""
84
- : ` validateExternalNamespace as validateExternalNamespaceImpl,\n`;
84
+ : ` validateExternalNamespace as validateExternalNamespaceCore,\n`;
85
85
  const namespaceValidator = isSingle
86
86
  ? ""
87
87
  : `export function validateExternalNamespace<NS extends keyof ${schemaTypeName}>(\n` +
88
88
  ` namespace: NS,\n` +
89
89
  ` input: unknown,\n` +
90
90
  `) {\n` +
91
- ` return validateExternalNamespaceImpl<${schemaTypeName}[NS]>(\n` +
91
+ ` return validateExternalNamespaceCore<${schemaTypeName}[NS]>(\n` +
92
92
  ` namespace as string,\n` +
93
93
  ` input,\n` +
94
94
  ` DICTIONARY_SPEC,\n` +
@@ -17,7 +17,10 @@ describe("formatNamespaceLoadersFile", () => {
17
17
  },
18
18
  ],
19
19
  schemaTypeName: "AppSchema",
20
+ paramsTypeName: "AppParams",
20
21
  localeTypeName: "AppLocale",
22
+ localeFallbackConstName: "LOCALE_FALLBACK",
23
+ hasLocaleFallback: false,
21
24
  typesModule: "i18n-types.generated",
22
25
  importExtension: "none",
23
26
  projectRoot,
@@ -29,6 +32,7 @@ describe("formatNamespaceLoadersFile", () => {
29
32
  "billing: () => import('./translations/billing.json').then((m) => m.default),"
30
33
  );
31
34
  expect(output).not.toContain("AppLocale");
35
+ expect(output).not.toContain("ensureNamespacesLoadedForLocale");
32
36
  });
33
37
 
34
38
  it("emits ns(locale) loaders in split mode", () => {
@@ -40,6 +44,11 @@ describe("formatNamespaceLoadersFile", () => {
40
44
  filePath: "src/i18n/translations/billing.json",
41
45
  absolutePath: path.join(projectRoot, "src/i18n/translations/billing.json"),
42
46
  },
47
+ {
48
+ namespace: "default",
49
+ filePath: "src/i18n/translations/default.json",
50
+ absolutePath: path.join(projectRoot, "src/i18n/translations/default.json"),
51
+ },
43
52
  {
44
53
  namespace: "user",
45
54
  filePath: "src/i18n/translations/user.json",
@@ -47,10 +56,14 @@ describe("formatNamespaceLoadersFile", () => {
47
56
  },
48
57
  ],
49
58
  schemaTypeName: "AppSchema",
59
+ paramsTypeName: "AppParams",
50
60
  localeTypeName: "AppLocale",
61
+ localeFallbackConstName: "LOCALE_FALLBACK",
62
+ hasLocaleFallback: true,
51
63
  typesModule: "i18n-types.generated",
52
64
  importExtension: "none",
53
65
  projectRoot,
66
+ isSingle: false,
54
67
  delivery: "split-by-locale",
55
68
  requestLocales: ["en", "it", "de-CH"],
56
69
  splitPathsByNamespace: {
@@ -59,6 +72,11 @@ describe("formatNamespaceLoadersFile", () => {
59
72
  it: "src/i18n/generated/translations/billing.it.json",
60
73
  "de-CH": "src/i18n/generated/translations/billing.de-CH.json",
61
74
  },
75
+ default: {
76
+ en: "src/i18n/generated/translations/default.en.json",
77
+ it: "src/i18n/generated/translations/default.it.json",
78
+ "de-CH": "src/i18n/generated/translations/default.de-CH.json",
79
+ },
62
80
  user: {
63
81
  en: "src/i18n/generated/translations/user.en.json",
64
82
  it: "src/i18n/generated/translations/user.it.json",
@@ -72,12 +90,21 @@ describe("formatNamespaceLoadersFile", () => {
72
90
  expect(output).toContain('case "en":');
73
91
  expect(output).toContain('case "it":');
74
92
  expect(output).toContain('case "de-CH":');
93
+ expect(output).toContain(
94
+ 'throw new Error(`[i18n] No translation artifact for namespace "billing" and locale "${String(locale)}".`);'
95
+ );
75
96
  expect(output).toContain(
76
97
  "return import('./generated/translations/billing.en.json').then((m) => m.default);"
77
98
  );
78
99
  expect(output).toContain("user: (locale) => {");
79
100
  expect(output).not.toContain("billing: {");
80
101
  expect(output).not.toContain("import('./translations/billing.json')");
102
+ expect(output).toContain("export async function ensureNamespacesLoadedForLocale(");
103
+ expect(output).toContain("i18n: I18nMultiInstance,");
104
+ expect(output).toContain(
105
+ 'namespaces: readonly LazyNamespace[] = ["billing", "default", "user"] as const,'
106
+ );
107
+ expect(output.match(/from '\.\/i18n-types\.generated'/g)?.length).toBe(1);
81
108
  });
82
109
 
83
110
  it("throws when a split path is missing", () => {
@@ -92,7 +119,10 @@ describe("formatNamespaceLoadersFile", () => {
92
119
  },
93
120
  ],
94
121
  schemaTypeName: "AppSchema",
122
+ paramsTypeName: "AppParams",
95
123
  localeTypeName: "AppLocale",
124
+ localeFallbackConstName: "LOCALE_FALLBACK",
125
+ hasLocaleFallback: false,
96
126
  typesModule: "i18n-types.generated",
97
127
  importExtension: "none",
98
128
  projectRoot,
@@ -116,12 +146,21 @@ describe("formatNamespaceLoadersFile", () => {
116
146
  filePath: "src/i18n/translations/billing.json",
117
147
  absolutePath: path.join(projectRoot, "src/i18n/translations/billing.json"),
118
148
  },
149
+ {
150
+ namespace: "default",
151
+ filePath: "src/i18n/translations/default.json",
152
+ absolutePath: path.join(projectRoot, "src/i18n/translations/default.json"),
153
+ },
119
154
  ],
120
155
  schemaTypeName: "AppSchema",
156
+ paramsTypeName: "AppParams",
121
157
  localeTypeName: "AppLocale",
158
+ localeFallbackConstName: "LOCALE_FALLBACK",
159
+ hasLocaleFallback: true,
122
160
  typesModule: "i18n-types.generated",
123
161
  importExtension: "none",
124
162
  projectRoot,
163
+ isSingle: false,
125
164
  delivery: "custom",
126
165
  deliveryAreaTypeName: "AppDeliveryArea",
127
166
  deliveryAreaNames: ["eu", "us"],
@@ -130,6 +169,10 @@ describe("formatNamespaceLoadersFile", () => {
130
169
  eu: "src/i18n/generated/translations/billing.eu.json",
131
170
  us: "src/i18n/generated/translations/billing.us.json",
132
171
  },
172
+ default: {
173
+ eu: "src/i18n/generated/translations/default.eu.json",
174
+ us: "src/i18n/generated/translations/default.us.json",
175
+ },
133
176
  },
134
177
  });
135
178
 
@@ -139,11 +182,17 @@ describe("formatNamespaceLoadersFile", () => {
139
182
  expect(output).toContain("billing: (area) => {");
140
183
  expect(output).toContain('case "eu":');
141
184
  expect(output).toContain('case "us":');
185
+ expect(output).toContain(
186
+ 'throw new Error(`[i18n] No translation artifact for namespace "billing" and area "${String(area)}".`);'
187
+ );
142
188
  expect(output).toContain(
143
189
  "return import('./generated/translations/billing.eu.json').then((m) => m.default);"
144
190
  );
145
- expect(output).not.toContain("AppLocale");
146
191
  expect(output).not.toContain("import('./translations/billing.json')");
192
+ expect(output).toContain("export async function ensureNamespacesLoadedForArea(");
193
+ expect(output).toContain(
194
+ 'namespaces: readonly LazyNamespace[] = ["billing", "default"] as const,'
195
+ );
147
196
  });
148
197
 
149
198
  it("throws when a custom delivery split path is missing", () => {
@@ -158,7 +207,10 @@ describe("formatNamespaceLoadersFile", () => {
158
207
  },
159
208
  ],
160
209
  schemaTypeName: "AppSchema",
210
+ paramsTypeName: "AppParams",
161
211
  localeTypeName: "AppLocale",
212
+ localeFallbackConstName: "LOCALE_FALLBACK",
213
+ hasLocaleFallback: false,
162
214
  typesModule: "i18n-types.generated",
163
215
  importExtension: "none",
164
216
  projectRoot,
@@ -11,10 +11,14 @@ export interface NamespaceLoadersFileOptions {
11
11
  loadersOutputPath: string;
12
12
  lazyEntries: (NamespaceEntry & { absolutePath: string })[];
13
13
  schemaTypeName: string;
14
+ paramsTypeName: string;
14
15
  localeTypeName: string;
16
+ localeFallbackConstName: string;
17
+ hasLocaleFallback: boolean;
15
18
  typesModule: string;
16
19
  importExtension: ImportExtension;
17
20
  projectRoot: string;
21
+ isSingle?: boolean;
18
22
  delivery?: DeliveryMode;
19
23
  splitPathsByNamespace?: Record<string, Record<string, string>>;
20
24
  requestLocales?: readonly string[];
@@ -26,28 +30,108 @@ interface PartitionedNamespaceLoadersParams {
26
30
  partitionKeys: readonly string[];
27
31
  paramName: string;
28
32
  paramTypeName: string;
33
+ loadNamespacesFunctionName: string;
34
+ }
35
+
36
+ function formatI18nMultiInstanceType(options: NamespaceLoadersFileOptions): string {
37
+ const {
38
+ schemaTypeName,
39
+ paramsTypeName,
40
+ localeTypeName,
41
+ localeFallbackConstName,
42
+ hasLocaleFallback,
43
+ } = options;
44
+
45
+ const typeArgs = hasLocaleFallback
46
+ ? `${schemaTypeName}, ${paramsTypeName}, ${localeTypeName}, typeof ${localeFallbackConstName}`
47
+ : `${schemaTypeName}, ${paramsTypeName}`;
48
+
49
+ return `IcuTranslationProviderMulti<${typeArgs}>`;
50
+ }
51
+
52
+ function formatDefaultNamespacesLiteral(lazyEntries: NamespaceEntry[]): string {
53
+ return [...lazyEntries]
54
+ .map((entry) => entry.namespace)
55
+ .sort()
56
+ .map((namespace) => JSON.stringify(namespace))
57
+ .join(", ");
58
+ }
59
+
60
+ function formatLoadNamespacesHelper(
61
+ options: NamespaceLoadersFileOptions,
62
+ { paramName, paramTypeName, loadNamespacesFunctionName }: PartitionedNamespaceLoadersParams
63
+ ): string {
64
+ const { lazyEntries } = options;
65
+ const defaultNamespaces = formatDefaultNamespacesLiteral(lazyEntries);
66
+
67
+ return (
68
+ `\ntype I18nMultiInstance = ${formatI18nMultiInstanceType(options)};\n\n` +
69
+ `export async function ${loadNamespacesFunctionName}(\n` +
70
+ ` i18n: I18nMultiInstance,\n` +
71
+ ` ${paramName}: ${paramTypeName},\n` +
72
+ ` namespaces: readonly LazyNamespace[] = [${defaultNamespaces}] as const,\n` +
73
+ `): Promise<void> {\n` +
74
+ ` await Promise.all(\n` +
75
+ ` namespaces.map(async (namespace) => {\n` +
76
+ ` i18n.setNamespace(namespace, await namespaceLoaders[namespace](${paramName}));\n` +
77
+ ` }),\n` +
78
+ ` );\n` +
79
+ `}\n`
80
+ );
81
+ }
82
+
83
+ function formatPartitionedTypesImport(
84
+ options: NamespaceLoadersFileOptions,
85
+ paramTypeName: string
86
+ ): string {
87
+ const {
88
+ schemaTypeName,
89
+ paramsTypeName,
90
+ localeTypeName,
91
+ localeFallbackConstName,
92
+ hasLocaleFallback,
93
+ typesModule,
94
+ importExtension,
95
+ isSingle,
96
+ } = options;
97
+
98
+ if (isSingle) {
99
+ return `import type { ${schemaTypeName}, LazyNamespace, ${paramTypeName} } from '${toRelativeModuleImport(typesModule, importExtension)}';\n\n`;
100
+ }
101
+
102
+ const fallbackImport = hasLocaleFallback ? `${localeFallbackConstName}, ` : "";
103
+ const paramTypeImport = paramTypeName === localeTypeName ? "" : `, type ${paramTypeName}`;
104
+ return (
105
+ `import { IcuTranslationProviderMulti } from '@xndrjs/i18n';\n` +
106
+ `import { ${fallbackImport}type ${localeTypeName}, type ${paramsTypeName}, type ${schemaTypeName}, type LazyNamespace${paramTypeImport} } from '${toRelativeModuleImport(typesModule, importExtension)}';\n\n`
107
+ );
108
+ }
109
+
110
+ function formatSwitchDefaultCase(namespace: string, paramName: string): string {
111
+ return (
112
+ ` default:\n` +
113
+ ` throw new Error(\`[i18n] No translation artifact for namespace ${JSON.stringify(namespace)} and ${paramName} "\${String(${paramName})}".\`);\n`
114
+ );
29
115
  }
30
116
 
31
117
  /**
32
- * Emits lazy loaders for split-by-locale and custom delivery.
33
118
  * Each namespace gets a function that dynamic-imports the JSON for the given
34
119
  * partition key (locale or area) via a switch.
35
120
  */
36
121
  function formatPartitionedNamespaceLoadersFile(
37
122
  options: NamespaceLoadersFileOptions,
38
- { partitionKeys, paramName, paramTypeName }: PartitionedNamespaceLoadersParams
123
+ params: PartitionedNamespaceLoadersParams
39
124
  ): string {
125
+ const { partitionKeys, paramName, paramTypeName } = params;
40
126
  const {
41
127
  loadersOutputPath,
42
128
  lazyEntries,
43
129
  schemaTypeName,
44
- typesModule,
45
- importExtension,
46
130
  projectRoot,
47
131
  splitPathsByNamespace = {},
48
132
  } = options;
49
133
 
50
- const typesImport = toRelativeModuleImport(typesModule, importExtension);
134
+ const typesImport = formatPartitionedTypesImport(options, paramTypeName);
51
135
  const loaderEntries = lazyEntries
52
136
  .map((entry) => {
53
137
  const switchCases = partitionKeys
@@ -72,6 +156,7 @@ function formatPartitionedNamespaceLoadersFile(
72
156
  return (
73
157
  ` ${entry.namespace}: (${paramName}) => {\n` +
74
158
  ` switch (${paramName}) {\n${switchCases}\n` +
159
+ `${formatSwitchDefaultCase(entry.namespace, paramName)}` +
75
160
  ` }\n` +
76
161
  ` },`
77
162
  );
@@ -80,10 +165,11 @@ function formatPartitionedNamespaceLoadersFile(
80
165
 
81
166
  return (
82
167
  `${GENERATED_FILE_BANNER}` +
83
- `import type { ${schemaTypeName}, LazyNamespace, ${paramTypeName} } from '${typesImport}';\n\n` +
168
+ typesImport +
84
169
  `export const namespaceLoaders: {\n` +
85
170
  ` [K in LazyNamespace]: (${paramName}: ${paramTypeName}) => Promise<${schemaTypeName}[K]>;\n` +
86
- `} = {\n${loaderEntries}\n};\n`
171
+ `} = {\n${loaderEntries}\n};\n` +
172
+ (options.isSingle ? "" : formatLoadNamespacesHelper(options, params))
87
173
  );
88
174
  }
89
175
 
@@ -118,6 +204,7 @@ export function formatNamespaceLoadersFile(options: NamespaceLoadersFileOptions)
118
204
  partitionKeys: options.requestLocales ?? [],
119
205
  paramName: "locale",
120
206
  paramTypeName: options.localeTypeName,
207
+ loadNamespacesFunctionName: "ensureNamespacesLoadedForLocale",
121
208
  });
122
209
  }
123
210
  if (delivery === "custom") {
@@ -128,6 +215,7 @@ export function formatNamespaceLoadersFile(options: NamespaceLoadersFileOptions)
128
215
  partitionKeys: options.deliveryAreaNames ?? [],
129
216
  paramName: "area",
130
217
  paramTypeName: options.deliveryAreaTypeName,
218
+ loadNamespacesFunctionName: "ensureNamespacesLoadedForArea",
131
219
  });
132
220
  }
133
221
  return formatCanonicalNamespaceLoadersFile(options);
@@ -105,6 +105,11 @@ describe("formatTypesFile", () => {
105
105
 
106
106
  expect(output).toContain("export type AppLocale = 'en-US' | 'fr' | 'it';");
107
107
  expect(output).toContain("export type AppDeliveryArea = 'eu' | 'us';");
108
+ expect(output).toContain("export const DELIVERY_ARTIFACTS = {");
109
+ expect(output).toContain('"eu": ["fr", "it"] as const');
110
+ expect(output).toContain('"us": ["en-US"] as const');
111
+ expect(output).toContain("} as const satisfies Record<AppDeliveryArea, readonly AppLocale[]>;");
112
+ expect(output).toContain("export type AppDeliveryArtifacts = typeof DELIVERY_ARTIFACTS;");
108
113
  expect(output).toContain("export const LOCALE_DELIVERY_AREA = {");
109
114
  expect(output).toContain('"it": "eu"');
110
115
  expect(output).toContain('"en-US": "us"');
@@ -1,4 +1,8 @@
1
- import { formatLocaleDeliveryAreaBlock, type DeliveryArtifactsMap } from "../delivery-artifacts.js";
1
+ import {
2
+ formatDeliveryArtifactsBlock,
3
+ formatLocaleDeliveryAreaBlock,
4
+ type DeliveryArtifactsMap,
5
+ } from "../delivery-artifacts.js";
2
6
  import { formatLocaleFallbackBlock } from "../locale-fallback.js";
3
7
  import { GENERATED_FILE_BANNER } from "../paths.js";
4
8
  import type { NamespaceEntry } from "../types.js";
@@ -12,10 +16,13 @@ export function formatLazyTypesBlock(
12
16
  lazyEntries: NamespaceEntry[],
13
17
  schemaTypeName: string
14
18
  ): string {
15
- const loadOnInitUnion = [...loadOnInitSet]
16
- .sort()
17
- .map((namespace) => `'${namespace}'`)
18
- .join(" | ");
19
+ const loadOnInitUnion =
20
+ loadOnInitSet.size > 0
21
+ ? [...loadOnInitSet]
22
+ .sort()
23
+ .map((namespace) => `'${namespace}'`)
24
+ .join(" | ")
25
+ : "never";
19
26
  const lazyUnion = lazyEntries.map((entry) => `'${entry.namespace}'`).join(" | ");
20
27
 
21
28
  return (
@@ -87,7 +94,13 @@ export function formatTypesFile(options: TypesFileOptions): string {
87
94
  deliveryAreaTypeName && deliveryAreaUnion
88
95
  ? `export type ${deliveryAreaTypeName} = ${deliveryAreaUnion};\n\n` +
89
96
  (deliveryArtifacts
90
- ? formatLocaleDeliveryAreaBlock(
97
+ ? formatDeliveryArtifactsBlock(
98
+ deliveryArtifacts,
99
+ "DELIVERY_ARTIFACTS",
100
+ localeTypeName,
101
+ deliveryAreaTypeName
102
+ ) +
103
+ formatLocaleDeliveryAreaBlock(
91
104
  deliveryArtifacts,
92
105
  localeDeliveryAreaConstName,
93
106
  localeTypeName,