@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.
- package/README.md +156 -29
- package/dist/codegen/index.d.ts +85 -0
- package/dist/codegen/index.js +181 -0
- package/dist/codegen/index.js.map +1 -0
- package/dist/index.d.ts +34 -5
- package/dist/index.js +175 -74
- package/dist/index.js.map +1 -1
- package/dist/validation/index.d.ts +4 -0
- package/dist/validation/index.js.map +1 -1
- package/package.json +6 -1
- package/src/IcuTranslationProviderMulti.test.ts +47 -0
- package/src/IcuTranslationProviderMulti.ts +41 -61
- package/src/IcuTranslationProviderSingle.test.ts +67 -0
- package/src/IcuTranslationProviderSingle.ts +27 -51
- package/src/audit/audit-dictionaries.ts +4 -1
- package/src/audit/run-audit.test.ts +35 -1
- package/src/audit/run-audit.ts +15 -7
- package/src/codegen/codegen-config-schema.ts +68 -1
- package/src/codegen/config.test.ts +174 -39
- package/src/codegen/config.ts +14 -5
- package/src/codegen/constants.ts +8 -0
- package/src/codegen/delivery-artifacts.test.ts +142 -0
- package/src/codegen/delivery-artifacts.ts +124 -0
- package/src/codegen/emit/dictionary-file.test.ts +190 -0
- package/src/codegen/emit/dictionary-file.ts +163 -0
- package/src/codegen/emit/dictionary-schema-file.ts +5 -0
- package/src/codegen/emit/instance-file.ts +114 -28
- package/src/codegen/emit/namespace-loaders-file.test.ts +176 -0
- package/src/codegen/emit/namespace-loaders-file.ts +114 -6
- package/src/codegen/emit/types-file.test.ts +114 -0
- package/src/codegen/emit/types-file.ts +48 -11
- package/src/codegen/generate-i18n-types.test.ts +754 -16
- package/src/codegen/generate-i18n-types.ts +111 -47
- package/src/codegen/icu-analysis.ts +9 -2
- package/src/codegen/locale-fallback.ts +19 -10
- package/src/codegen/locale-policy.ts +4 -0
- package/src/codegen/paths.ts +9 -5
- package/src/codegen/project-locales-set-namespace.test.ts +5 -5
- package/src/codegen/read-dictionary.test.ts +474 -2
- package/src/codegen/read-dictionary.ts +164 -15
- package/src/codegen/write-file-if-changed.test.ts +42 -0
- package/src/codegen/write-file-if-changed.ts +20 -0
- package/src/codegen-config/build-config.ts +34 -0
- package/src/codegen-config/codegen-config.test.ts +32 -0
- package/src/codegen-config/index.ts +21 -0
- package/src/codegen-config/write-config.ts +8 -0
- package/src/deep-freeze.test.ts +13 -0
- package/src/deep-freeze.ts +5 -0
- package/src/format-core.ts +91 -0
- package/src/index.ts +8 -2
- package/src/project-locales.test.ts +129 -10
- package/src/project-locales.ts +90 -3
- package/src/setup/setup-i18n.test.ts +1 -1
- package/src/setup/setup-i18n.ts +6 -30
- package/src/types.ts +19 -4
- package/src/validation/normalize.ts +4 -0
- /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
|
|
55
|
-
const coreImports = isSingle
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
projectionBlock
|
|
85
92
|
);
|
|
86
93
|
}
|
|
87
94
|
|
|
88
|
-
function
|
|
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
|
-
|
|
144
|
+
fallbackArg: string,
|
|
145
|
+
emitDeliveryAreaHelpers: boolean
|
|
93
146
|
): string {
|
|
94
|
-
const
|
|
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
|
-
|
|
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
|
|
171
|
+
`export function projectDictionaryLocales(\n` +
|
|
107
172
|
` dictionary: ${schemaTypeName},\n` +
|
|
108
173
|
` locales: ${localesParamType},\n` +
|
|
109
174
|
`): ${schemaTypeName} {\n` +
|
|
110
|
-
` return
|
|
175
|
+
` return projectDictionaryLocalesCore(dictionary, locales${fallbackArg});\n` +
|
|
111
176
|
`}\n` +
|
|
112
177
|
`\n` +
|
|
113
|
-
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
+
});
|