@xndrjs/i18n 0.4.0 → 0.5.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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** Valid TypeScript identifier / filename segment for a delivery area id. */
|
|
2
|
+
export const DELIVERY_AREA_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
3
|
+
|
|
4
|
+
export type DeliveryArtifactsMap = Record<string, readonly string[]>;
|
|
5
|
+
|
|
6
|
+
export type DeliveryArtifactsIssue = {
|
|
7
|
+
path: (string | number)[];
|
|
8
|
+
message: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function getDeliveryArtifactsStructureIssues(
|
|
12
|
+
deliveryArtifacts: DeliveryArtifactsMap
|
|
13
|
+
): DeliveryArtifactsIssue[] {
|
|
14
|
+
const issues: DeliveryArtifactsIssue[] = [];
|
|
15
|
+
|
|
16
|
+
for (const [area, locales] of Object.entries(deliveryArtifacts)) {
|
|
17
|
+
if (!DELIVERY_AREA_NAME_PATTERN.test(area)) {
|
|
18
|
+
issues.push({
|
|
19
|
+
path: ["deliveryArtifacts", area],
|
|
20
|
+
message: `Invalid delivery area name "${area}": must match ${DELIVERY_AREA_NAME_PATTERN.source}`,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (locales.length === 0) {
|
|
25
|
+
issues.push({
|
|
26
|
+
path: ["deliveryArtifacts", area],
|
|
27
|
+
message: `Delivery area "${area}" must include at least one locale.`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const localeToArea = new Map<string, string>();
|
|
33
|
+
for (const [area, locales] of Object.entries(deliveryArtifacts)) {
|
|
34
|
+
for (const locale of locales) {
|
|
35
|
+
const previousArea = localeToArea.get(locale);
|
|
36
|
+
if (previousArea) {
|
|
37
|
+
issues.push({
|
|
38
|
+
path: ["deliveryArtifacts", area],
|
|
39
|
+
message: `Locale "${locale}" appears in both "${previousArea}" and "${area}".`,
|
|
40
|
+
});
|
|
41
|
+
} else {
|
|
42
|
+
localeToArea.set(locale, area);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return issues;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getDeliveryArtifactsPartitionIssues(
|
|
51
|
+
deliveryArtifacts: DeliveryArtifactsMap,
|
|
52
|
+
requestLocales: ReadonlySet<string>
|
|
53
|
+
): DeliveryArtifactsIssue[] {
|
|
54
|
+
const issues: DeliveryArtifactsIssue[] = [];
|
|
55
|
+
const artifactLocales = new Set<string>();
|
|
56
|
+
|
|
57
|
+
for (const locales of Object.values(deliveryArtifacts)) {
|
|
58
|
+
for (const locale of locales) {
|
|
59
|
+
artifactLocales.add(locale);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const missing = [...requestLocales].filter((locale) => !artifactLocales.has(locale)).sort();
|
|
64
|
+
if (missing.length > 0) {
|
|
65
|
+
issues.push({
|
|
66
|
+
path: ["deliveryArtifacts"],
|
|
67
|
+
message: `deliveryArtifacts is missing locales required by dictionaries and localeFallback: ${missing.join(", ")}`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const excess = [...artifactLocales].filter((locale) => !requestLocales.has(locale)).sort();
|
|
72
|
+
if (excess.length > 0) {
|
|
73
|
+
issues.push({
|
|
74
|
+
path: ["deliveryArtifacts"],
|
|
75
|
+
message: `deliveryArtifacts includes locales not required by dictionaries and localeFallback: ${excess.join(", ")}`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return issues;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Pre-emit validation gate for `deliveryArtifacts` in custom delivery mode. */
|
|
83
|
+
export function getDeliveryArtifactsIssues(
|
|
84
|
+
deliveryArtifacts: DeliveryArtifactsMap,
|
|
85
|
+
requestLocales: ReadonlySet<string>
|
|
86
|
+
): DeliveryArtifactsIssue[] {
|
|
87
|
+
return [
|
|
88
|
+
...getDeliveryArtifactsStructureIssues(deliveryArtifacts),
|
|
89
|
+
...getDeliveryArtifactsPartitionIssues(deliveryArtifacts, requestLocales),
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getDeliveryAreaNames(deliveryArtifacts: DeliveryArtifactsMap): string[] {
|
|
94
|
+
return Object.keys(deliveryArtifacts).sort();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getLocaleDeliveryAreaMap(
|
|
98
|
+
deliveryArtifacts: DeliveryArtifactsMap
|
|
99
|
+
): Record<string, string> {
|
|
100
|
+
const localeToArea: Record<string, string> = {};
|
|
101
|
+
|
|
102
|
+
for (const [area, locales] of Object.entries(deliveryArtifacts)) {
|
|
103
|
+
for (const locale of locales) {
|
|
104
|
+
localeToArea[locale] = area;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return localeToArea;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function formatLocaleDeliveryAreaBlock(
|
|
112
|
+
deliveryArtifacts: DeliveryArtifactsMap,
|
|
113
|
+
constName: string,
|
|
114
|
+
localeTypeName: string,
|
|
115
|
+
deliveryAreaTypeName: string
|
|
116
|
+
): string {
|
|
117
|
+
const localeToArea = getLocaleDeliveryAreaMap(deliveryArtifacts);
|
|
118
|
+
const lines = Object.keys(localeToArea)
|
|
119
|
+
.sort()
|
|
120
|
+
.map((locale) => ` ${JSON.stringify(locale)}: ${JSON.stringify(localeToArea[locale])},`)
|
|
121
|
+
.join("\n");
|
|
122
|
+
|
|
123
|
+
return `export const ${constName} = {\n${lines}\n} as const satisfies Record<${localeTypeName}, ${deliveryAreaTypeName}>;\n\n`;
|
|
124
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { formatDictionaryFile } from "./dictionary-file.js";
|
|
4
|
+
|
|
5
|
+
const projectRoot = "/project";
|
|
6
|
+
const dictionaryOutputPath = path.join(projectRoot, "src/i18n/dictionary.generated.ts");
|
|
7
|
+
const typesOutputPath = path.join(projectRoot, "src/i18n/i18n-types.generated.ts");
|
|
8
|
+
|
|
9
|
+
describe("formatDictionaryFile", () => {
|
|
10
|
+
it("emits defaultDictionary in canonical mode", () => {
|
|
11
|
+
const output = formatDictionaryFile({
|
|
12
|
+
isSingle: false,
|
|
13
|
+
hasLazy: true,
|
|
14
|
+
entries: [
|
|
15
|
+
{ namespace: "default", filePath: "src/i18n/translations/default.json" },
|
|
16
|
+
{ namespace: "billing", filePath: "src/i18n/translations/billing.json" },
|
|
17
|
+
],
|
|
18
|
+
eagerEntries: [{ namespace: "default", filePath: "src/i18n/translations/default.json" }],
|
|
19
|
+
projectRoot,
|
|
20
|
+
dictionaryOutputPath,
|
|
21
|
+
typesOutputPath,
|
|
22
|
+
schemaTypeName: "AppSchema",
|
|
23
|
+
localeTypeName: "AppLocale",
|
|
24
|
+
importExtension: "none",
|
|
25
|
+
delivery: "canonical",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(output).toContain("export const defaultDictionary: InitialSchema");
|
|
29
|
+
expect(output).toContain("defaultNs");
|
|
30
|
+
expect(output).not.toContain("defaultDictionaryFor");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("emits defaultDictionaryFor with per-locale imports 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: [{ namespace: "default", filePath: "src/i18n/translations/default.json" }],
|
|
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).toContain("import defaultEn from './generated/translations/default.en.json';");
|
|
63
|
+
expect(output).toContain("import defaultIt from './generated/translations/default.it.json';");
|
|
64
|
+
expect(output).toContain(
|
|
65
|
+
"const defaultByLocale = {\n en: defaultEn,\n it: defaultIt,\n} as const;"
|
|
66
|
+
);
|
|
67
|
+
expect(output).toContain(
|
|
68
|
+
"export function defaultDictionaryFor(locale: AppLocale): InitialSchema"
|
|
69
|
+
);
|
|
70
|
+
expect(output).toContain("return {\n default: defaultByLocale[locale],\n };");
|
|
71
|
+
expect(output).not.toContain("export const defaultDictionary");
|
|
72
|
+
expect(output).not.toContain("billingEn");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("emits defaultDictionaryFor with per-locale imports in split mode (single)", () => {
|
|
76
|
+
const output = formatDictionaryFile({
|
|
77
|
+
isSingle: true,
|
|
78
|
+
hasLazy: false,
|
|
79
|
+
entries: [{ namespace: "translations", filePath: "src/i18n/translations/translations.json" }],
|
|
80
|
+
eagerEntries: [
|
|
81
|
+
{ namespace: "translations", filePath: "src/i18n/translations/translations.json" },
|
|
82
|
+
],
|
|
83
|
+
projectRoot,
|
|
84
|
+
dictionaryOutputPath,
|
|
85
|
+
typesOutputPath,
|
|
86
|
+
schemaTypeName: "AppSchema",
|
|
87
|
+
localeTypeName: "AppLocale",
|
|
88
|
+
importExtension: "none",
|
|
89
|
+
delivery: "split-by-locale",
|
|
90
|
+
requestLocales: ["en", "it"],
|
|
91
|
+
splitPathsByNamespace: {
|
|
92
|
+
translations: {
|
|
93
|
+
en: "src/i18n/generated/translations/translations.en.json",
|
|
94
|
+
it: "src/i18n/generated/translations/translations.it.json",
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(output).toContain(
|
|
100
|
+
"import translationsEn from './generated/translations/translations.en.json';"
|
|
101
|
+
);
|
|
102
|
+
expect(output).toContain(
|
|
103
|
+
"import translationsIt from './generated/translations/translations.it.json';"
|
|
104
|
+
);
|
|
105
|
+
expect(output).toContain("export function defaultDictionaryFor(locale: AppLocale): AppSchema");
|
|
106
|
+
expect(output).toContain("return translationsByLocale[locale];");
|
|
107
|
+
expect(output).not.toContain("export const defaultDictionary");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("emits defaultDictionaryFor with per-area imports in custom mode (multi)", () => {
|
|
111
|
+
const output = formatDictionaryFile({
|
|
112
|
+
isSingle: false,
|
|
113
|
+
hasLazy: true,
|
|
114
|
+
entries: [
|
|
115
|
+
{ namespace: "default", filePath: "src/i18n/translations/default.json" },
|
|
116
|
+
{ namespace: "billing", filePath: "src/i18n/translations/billing.json" },
|
|
117
|
+
],
|
|
118
|
+
eagerEntries: [{ namespace: "default", filePath: "src/i18n/translations/default.json" }],
|
|
119
|
+
projectRoot,
|
|
120
|
+
dictionaryOutputPath,
|
|
121
|
+
typesOutputPath,
|
|
122
|
+
schemaTypeName: "AppSchema",
|
|
123
|
+
localeTypeName: "AppLocale",
|
|
124
|
+
importExtension: "none",
|
|
125
|
+
delivery: "custom",
|
|
126
|
+
deliveryAreaTypeName: "AppDeliveryArea",
|
|
127
|
+
deliveryAreaNames: ["eu", "us"],
|
|
128
|
+
splitPathsByNamespace: {
|
|
129
|
+
default: {
|
|
130
|
+
eu: "src/i18n/generated/translations/default.eu.json",
|
|
131
|
+
us: "src/i18n/generated/translations/default.us.json",
|
|
132
|
+
},
|
|
133
|
+
billing: {
|
|
134
|
+
eu: "src/i18n/generated/translations/billing.eu.json",
|
|
135
|
+
us: "src/i18n/generated/translations/billing.us.json",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(output).toContain("import defaultEu from './generated/translations/default.eu.json';");
|
|
141
|
+
expect(output).toContain("import defaultUs from './generated/translations/default.us.json';");
|
|
142
|
+
expect(output).toContain(
|
|
143
|
+
"const defaultByArea = {\n eu: defaultEu,\n us: defaultUs,\n} as const;"
|
|
144
|
+
);
|
|
145
|
+
expect(output).toContain(
|
|
146
|
+
"export function defaultDictionaryFor(area: AppDeliveryArea): InitialSchema"
|
|
147
|
+
);
|
|
148
|
+
expect(output).toContain("return {\n default: defaultByArea[area],\n };");
|
|
149
|
+
expect(output).not.toContain("export const defaultDictionary");
|
|
150
|
+
expect(output).not.toContain("billingEu");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("emits defaultDictionaryFor with per-area imports in custom mode (single)", () => {
|
|
154
|
+
const output = formatDictionaryFile({
|
|
155
|
+
isSingle: true,
|
|
156
|
+
hasLazy: false,
|
|
157
|
+
entries: [{ namespace: "translations", filePath: "src/i18n/translations/translations.json" }],
|
|
158
|
+
eagerEntries: [
|
|
159
|
+
{ namespace: "translations", filePath: "src/i18n/translations/translations.json" },
|
|
160
|
+
],
|
|
161
|
+
projectRoot,
|
|
162
|
+
dictionaryOutputPath,
|
|
163
|
+
typesOutputPath,
|
|
164
|
+
schemaTypeName: "AppSchema",
|
|
165
|
+
localeTypeName: "AppLocale",
|
|
166
|
+
importExtension: "none",
|
|
167
|
+
delivery: "custom",
|
|
168
|
+
deliveryAreaTypeName: "AppDeliveryArea",
|
|
169
|
+
deliveryAreaNames: ["eu", "us"],
|
|
170
|
+
splitPathsByNamespace: {
|
|
171
|
+
translations: {
|
|
172
|
+
eu: "src/i18n/generated/translations/translations.eu.json",
|
|
173
|
+
us: "src/i18n/generated/translations/translations.us.json",
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(output).toContain(
|
|
179
|
+
"import translationsEu from './generated/translations/translations.eu.json';"
|
|
180
|
+
);
|
|
181
|
+
expect(output).toContain(
|
|
182
|
+
"import translationsUs from './generated/translations/translations.us.json';"
|
|
183
|
+
);
|
|
184
|
+
expect(output).toContain(
|
|
185
|
+
"export function defaultDictionaryFor(area: AppDeliveryArea): AppSchema"
|
|
186
|
+
);
|
|
187
|
+
expect(output).toContain("return translationsByArea[area];");
|
|
188
|
+
expect(output).not.toContain("export const defaultDictionary");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -3,11 +3,17 @@ import {
|
|
|
3
3
|
GENERATED_FILE_BANNER,
|
|
4
4
|
toImportIdentifier,
|
|
5
5
|
toImportPath,
|
|
6
|
+
toLocaleObjectKey,
|
|
6
7
|
toModuleBasename,
|
|
7
8
|
toRelativeModuleImport,
|
|
8
9
|
} from "../paths.js";
|
|
10
|
+
import type { DeliveryMode } from "../codegen-config-schema.js";
|
|
9
11
|
import type { ImportExtension, NamespaceEntry } from "../types.js";
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Emits the generated `dictionary.generated.ts` module: eager translation imports
|
|
15
|
+
* wired into `defaultDictionary` or `defaultDictionaryFor`, depending on delivery mode.
|
|
16
|
+
*/
|
|
11
17
|
export interface DictionaryFileOptions {
|
|
12
18
|
isSingle: boolean;
|
|
13
19
|
hasLazy: boolean;
|
|
@@ -17,10 +23,167 @@ export interface DictionaryFileOptions {
|
|
|
17
23
|
dictionaryOutputPath: string;
|
|
18
24
|
typesOutputPath: string;
|
|
19
25
|
schemaTypeName: string;
|
|
26
|
+
localeTypeName: string;
|
|
20
27
|
importExtension: ImportExtension;
|
|
28
|
+
delivery?: DeliveryMode;
|
|
29
|
+
splitPathsByNamespace?: Record<string, Record<string, string>>;
|
|
30
|
+
requestLocales?: readonly string[];
|
|
31
|
+
deliveryAreaTypeName?: string;
|
|
32
|
+
deliveryAreaNames?: readonly string[];
|
|
21
33
|
}
|
|
22
34
|
|
|
35
|
+
function toArtifactIdentifierSuffix(artifactKey: string): string {
|
|
36
|
+
return artifactKey
|
|
37
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
40
|
+
.join("");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toSplitImportIdentifier(namespace: string, artifactKey: string): string {
|
|
44
|
+
const namespaceId = toImportIdentifier(namespace).replace(/Ns$/, "");
|
|
45
|
+
return `${namespaceId}${toArtifactIdentifierSuffix(artifactKey)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toNamespaceBaseIdentifier(namespace: string): string {
|
|
49
|
+
return toImportIdentifier(namespace).replace(/Ns$/, "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface PartitionedDictionaryParams {
|
|
53
|
+
partitionKeys: readonly string[];
|
|
54
|
+
paramName: string;
|
|
55
|
+
paramTypeName: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toPartitionSuffix(paramName: string): string {
|
|
59
|
+
return `By${paramName.charAt(0).toUpperCase()}${paramName.slice(1)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Emits dictionary source for split-by-locale and custom delivery.
|
|
64
|
+
* Imports one JSON file per namespace × partition key (locale or area) and
|
|
65
|
+
* exposes `defaultDictionaryFor(partition)` that selects the matching slice.
|
|
66
|
+
*/
|
|
67
|
+
function formatPartitionedDictionaryFile(
|
|
68
|
+
options: DictionaryFileOptions,
|
|
69
|
+
{ partitionKeys, paramName, paramTypeName }: PartitionedDictionaryParams
|
|
70
|
+
): string {
|
|
71
|
+
const {
|
|
72
|
+
isSingle,
|
|
73
|
+
hasLazy,
|
|
74
|
+
eagerEntries,
|
|
75
|
+
projectRoot,
|
|
76
|
+
dictionaryOutputPath,
|
|
77
|
+
typesOutputPath,
|
|
78
|
+
schemaTypeName,
|
|
79
|
+
importExtension,
|
|
80
|
+
splitPathsByNamespace = {},
|
|
81
|
+
} = options;
|
|
82
|
+
|
|
83
|
+
const partitionSuffix = toPartitionSuffix(paramName);
|
|
84
|
+
const typesModule = toModuleBasename(typesOutputPath);
|
|
85
|
+
const typesImport = toRelativeModuleImport(typesModule, importExtension);
|
|
86
|
+
const dictionaryTypeName = hasLazy ? "InitialSchema" : schemaTypeName;
|
|
87
|
+
const schemaTypeImport = hasLazy ? `, ${schemaTypeName}` : "";
|
|
88
|
+
|
|
89
|
+
const imports = eagerEntries
|
|
90
|
+
.flatMap((entry) =>
|
|
91
|
+
partitionKeys.map((partitionKey) => {
|
|
92
|
+
const splitRelativePath = splitPathsByNamespace[entry.namespace]?.[partitionKey];
|
|
93
|
+
if (!splitRelativePath) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`[Codegen Error] Missing split path for namespace "${entry.namespace}", ${paramName} "${partitionKey}".`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const importPath = toImportPath(
|
|
99
|
+
dictionaryOutputPath,
|
|
100
|
+
path.resolve(projectRoot, splitRelativePath)
|
|
101
|
+
);
|
|
102
|
+
const importId = toSplitImportIdentifier(entry.namespace, partitionKey);
|
|
103
|
+
return `import ${importId} from '${importPath}.json';`;
|
|
104
|
+
})
|
|
105
|
+
)
|
|
106
|
+
.join("\n");
|
|
107
|
+
|
|
108
|
+
if (isSingle) {
|
|
109
|
+
const entry = eagerEntries[0]!;
|
|
110
|
+
const baseId = toNamespaceBaseIdentifier(entry.namespace);
|
|
111
|
+
const partitionEntries = partitionKeys
|
|
112
|
+
.map(
|
|
113
|
+
(partitionKey) =>
|
|
114
|
+
` ${toLocaleObjectKey(partitionKey)}: ${toSplitImportIdentifier(entry.namespace, partitionKey)},`
|
|
115
|
+
)
|
|
116
|
+
.join("\n");
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
`${GENERATED_FILE_BANNER}` +
|
|
120
|
+
`${imports}\n` +
|
|
121
|
+
`import type { ${schemaTypeName}, ${paramTypeName} } from '${typesImport}';\n\n` +
|
|
122
|
+
`const ${baseId}${partitionSuffix} = {\n${partitionEntries}\n} as const;\n\n` +
|
|
123
|
+
`export function defaultDictionaryFor(${paramName}: ${paramTypeName}): ${schemaTypeName} {\n` +
|
|
124
|
+
` return ${baseId}${partitionSuffix}[${paramName}];\n` +
|
|
125
|
+
`}\n`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const byPartitionBlocks = eagerEntries
|
|
130
|
+
.map((entry) => {
|
|
131
|
+
const baseId = toNamespaceBaseIdentifier(entry.namespace);
|
|
132
|
+
const partitionEntries = partitionKeys
|
|
133
|
+
.map(
|
|
134
|
+
(partitionKey) =>
|
|
135
|
+
` ${toLocaleObjectKey(partitionKey)}: ${toSplitImportIdentifier(entry.namespace, partitionKey)},`
|
|
136
|
+
)
|
|
137
|
+
.join("\n");
|
|
138
|
+
return `const ${baseId}${partitionSuffix} = {\n${partitionEntries}\n} as const;`;
|
|
139
|
+
})
|
|
140
|
+
.join("\n\n");
|
|
141
|
+
|
|
142
|
+
const objectEntries = eagerEntries
|
|
143
|
+
.map((entry) => {
|
|
144
|
+
const baseId = toNamespaceBaseIdentifier(entry.namespace);
|
|
145
|
+
return ` ${entry.namespace}: ${baseId}${partitionSuffix}[${paramName}],`;
|
|
146
|
+
})
|
|
147
|
+
.join("\n");
|
|
148
|
+
|
|
149
|
+
const importsBlock = imports.length > 0 ? `${imports}\n` : "";
|
|
150
|
+
const byPartitionBlock = byPartitionBlocks.length > 0 ? `${byPartitionBlocks}\n\n` : "";
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
`${GENERATED_FILE_BANNER}` +
|
|
154
|
+
`${importsBlock}` +
|
|
155
|
+
`import type { ${dictionaryTypeName}, ${paramTypeName}${schemaTypeImport} } from '${typesImport}';\n\n` +
|
|
156
|
+
`${byPartitionBlock}` +
|
|
157
|
+
`export function defaultDictionaryFor(${paramName}: ${paramTypeName}): ${dictionaryTypeName} {\n` +
|
|
158
|
+
` return {\n${objectEntries}\n };\n` +
|
|
159
|
+
`}\n`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Builds `dictionary.generated.ts` for the configured delivery mode:
|
|
165
|
+
* canonical (single bundled JSON per namespace), split-by-locale, or custom areas.
|
|
166
|
+
*/
|
|
23
167
|
export function formatDictionaryFile(options: DictionaryFileOptions): string {
|
|
168
|
+
const delivery = options.delivery ?? "canonical";
|
|
169
|
+
if (delivery === "split-by-locale") {
|
|
170
|
+
return formatPartitionedDictionaryFile(options, {
|
|
171
|
+
partitionKeys: options.requestLocales ?? [],
|
|
172
|
+
paramName: "locale",
|
|
173
|
+
paramTypeName: options.localeTypeName,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (delivery === "custom") {
|
|
177
|
+
if (!options.deliveryAreaTypeName) {
|
|
178
|
+
throw new Error("[Codegen Error] deliveryAreaTypeName is required for custom delivery.");
|
|
179
|
+
}
|
|
180
|
+
return formatPartitionedDictionaryFile(options, {
|
|
181
|
+
partitionKeys: options.deliveryAreaNames ?? [],
|
|
182
|
+
paramName: "area",
|
|
183
|
+
paramTypeName: options.deliveryAreaTypeName,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
24
187
|
const {
|
|
25
188
|
isSingle,
|
|
26
189
|
hasLazy,
|
|
@@ -14,6 +14,7 @@ function formatVariableSpecObject(spec: VariableSpec): string {
|
|
|
14
14
|
return `{\n${lines.join("\n")}\n }`;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** Builds the `DICTIONARY_SPEC` constant from ICU analysis output (keys + variable args). */
|
|
17
18
|
export function formatDictionarySpecBlock(
|
|
18
19
|
isSingle: boolean,
|
|
19
20
|
entries: NamespaceEntry[],
|
|
@@ -66,6 +67,10 @@ export function formatDictionarySpecBlock(
|
|
|
66
67
|
);
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Emits the optional dictionary-schema module: `DICTIONARY_SPEC` plus thin wrappers
|
|
72
|
+
* around `@xndrjs/i18n/validation` for external dictionary ingestion.
|
|
73
|
+
*/
|
|
69
74
|
export function formatDictionarySchemaFile(
|
|
70
75
|
schemaTypeName: string,
|
|
71
76
|
typesModule: string,
|