@xndrjs/i18n 0.8.1 → 0.8.2-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 +1 -2
- package/dist/cli/audit.js +739 -0
- package/dist/cli/audit.js.map +1 -0
- package/dist/cli/codegen.js +1471 -0
- package/dist/cli/codegen.js.map +1 -0
- package/dist/cli/setup.js +148 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/codegen/index.js +43 -40
- package/dist/codegen/index.js.map +1 -1
- package/package.json +6 -9
- package/bin/audit.mjs +0 -14
- package/bin/codegen.mjs +0 -17
- package/bin/setup.mjs +0 -14
- package/src/IcuTranslationProviderMulti.test.ts +0 -215
- package/src/IcuTranslationProviderMulti.ts +0 -188
- package/src/audit/audit-dictionaries.test.ts +0 -134
- package/src/audit/audit-dictionaries.ts +0 -178
- package/src/audit/run-audit.test.ts +0 -169
- package/src/audit/run-audit.ts +0 -94
- package/src/builder-load-registry.test.ts +0 -44
- package/src/builder-load-registry.ts +0 -78
- package/src/builder-loaders.ts +0 -15
- package/src/builder-types.test.ts +0 -57
- package/src/builder-types.ts +0 -24
- package/src/builder.test.ts +0 -303
- package/src/builder.ts +0 -15
- package/src/codegen/codegen-config-schema.ts +0 -156
- package/src/codegen/config.test.ts +0 -231
- package/src/codegen/config.ts +0 -42
- package/src/codegen/constants.ts +0 -9
- package/src/codegen/delivery-artifacts.test.ts +0 -167
- package/src/codegen/delivery-artifacts.ts +0 -154
- package/src/codegen/dictionary-spec-contract.test.ts +0 -28
- package/src/codegen/dictionary-spec-contract.ts +0 -138
- package/src/codegen/emit/dictionary-schema-file.ts +0 -113
- package/src/codegen/emit/instance-file.test.ts +0 -71
- package/src/codegen/emit/instance-file.ts +0 -257
- package/src/codegen/emit/namespace-loaders-file.test.ts +0 -251
- package/src/codegen/emit/namespace-loaders-file.ts +0 -229
- package/src/codegen/emit/types-file.test.ts +0 -96
- package/src/codegen/emit/types-file.ts +0 -142
- package/src/codegen/fixtures/billing-namespace.json +0 -6
- package/src/codegen/generate-i18n-types.test.ts +0 -1137
- package/src/codegen/generate-i18n-types.ts +0 -20
- package/src/codegen/icu-analysis.ts +0 -110
- package/src/codegen/locale-fallback.ts +0 -73
- package/src/codegen/locale-policy.test.ts +0 -35
- package/src/codegen/locale-policy.ts +0 -30
- package/src/codegen/paths.ts +0 -44
- package/src/codegen/project-locales-set-namespace.test.ts +0 -134
- package/src/codegen/read-dictionary.test.ts +0 -594
- package/src/codegen/read-dictionary.ts +0 -260
- package/src/codegen/regenerate-namespaces.ts +0 -180
- package/src/codegen/run-codegen.test.ts +0 -230
- package/src/codegen/run-codegen.ts +0 -252
- package/src/codegen/types.ts +0 -10
- package/src/codegen/write-file-if-changed.test.ts +0 -42
- package/src/codegen/write-file-if-changed.ts +0 -20
- package/src/codegen-config/build-config.ts +0 -15
- package/src/codegen-config/codegen-config.test.ts +0 -36
- package/src/codegen-config/index.ts +0 -37
- package/src/codegen-config/type-names.ts +0 -20
- package/src/codegen-config/write-config.ts +0 -8
- package/src/engine.ts +0 -47
- package/src/fetch-artifact.ts +0 -16
- package/src/format-core.ts +0 -91
- package/src/i18n-handle.ts +0 -216
- package/src/icu/extract-variables.test.ts +0 -199
- package/src/icu/extract-variables.ts +0 -227
- package/src/icu/parse-template.ts +0 -31
- package/src/index.ts +0 -63
- package/src/load-state.ts +0 -15
- package/src/project-locales.test.ts +0 -280
- package/src/project-locales.ts +0 -211
- package/src/resolve-locale.test.ts +0 -91
- package/src/resolve-locale.ts +0 -88
- package/src/scope-multi.ts +0 -105
- package/src/scope-types.ts +0 -35
- package/src/scope.test.ts +0 -172
- package/src/serialized-state.test.ts +0 -114
- package/src/serialized-state.ts +0 -28
- package/src/setup/setup-i18n.test.ts +0 -85
- package/src/setup/setup-i18n.ts +0 -156
- package/src/types.ts +0 -65
- package/src/validation/create-args-schema.ts +0 -76
- package/src/validation/create-normalized-schema.ts +0 -52
- package/src/validation/errors.ts +0 -43
- package/src/validation/index.ts +0 -202
- package/src/validation/normalize.ts +0 -299
- package/src/validation/to-dictionary.ts +0 -67
- package/src/validation/types.ts +0 -82
- package/src/validation/validate-normalized.ts +0 -133
- package/src/validation/validation.test.ts +0 -562
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { codegenConfigKeys, resolveCodegenPaths } from "./codegen-config-schema.js";
|
|
6
|
-
import { loadConfig, resolveArtifactsPath } from "./config.js";
|
|
7
|
-
|
|
8
|
-
function writeConfig(dir: string, config: Record<string, unknown>) {
|
|
9
|
-
const configPath = join(dir, "i18n.codegen.json");
|
|
10
|
-
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
11
|
-
return configPath;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const validMultiConfig = {
|
|
15
|
-
projectName: "App",
|
|
16
|
-
namespaces: {
|
|
17
|
-
default: "translations/default.json",
|
|
18
|
-
},
|
|
19
|
-
codegenPath: "generated",
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
describe("loadConfig", () => {
|
|
23
|
-
let tempDir: string;
|
|
24
|
-
|
|
25
|
-
afterEach(() => {
|
|
26
|
-
if (tempDir) {
|
|
27
|
-
rmSync(tempDir, { recursive: true, force: true });
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("accepts a valid multi-namespace config and defaults delivery to split-by-locale", () => {
|
|
32
|
-
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
33
|
-
const configPath = writeConfig(tempDir, validMultiConfig);
|
|
34
|
-
|
|
35
|
-
expect(loadConfig(configPath)).toEqual({
|
|
36
|
-
...validMultiConfig,
|
|
37
|
-
delivery: "split-by-locale",
|
|
38
|
-
loaderStrategy: "import",
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("accepts fetch loaderStrategy", () => {
|
|
43
|
-
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
44
|
-
const configPath = writeConfig(tempDir, {
|
|
45
|
-
...validMultiConfig,
|
|
46
|
-
loaderStrategy: "fetch",
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
expect(loadConfig(configPath)).toMatchObject({
|
|
50
|
-
loaderStrategy: "fetch",
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("rejects unknown config keys", () => {
|
|
55
|
-
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
56
|
-
const configPath = writeConfig(tempDir, {
|
|
57
|
-
...validMultiConfig,
|
|
58
|
-
notARealKey: true,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
expect(() => loadConfig(configPath)).toThrow();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("resolves code paths and type names from projectName + codegenPath", () => {
|
|
65
|
-
expect(resolveCodegenPaths(validMultiConfig)).toMatchObject({
|
|
66
|
-
typesOutput: "generated/i18n-types.generated.ts",
|
|
67
|
-
instanceOutput: "generated/instance.generated.ts",
|
|
68
|
-
namespaceLoadersOutput: "generated/namespace-loaders.generated.ts",
|
|
69
|
-
dictionarySchemaOutput: "generated/dictionary-schema.generated.ts",
|
|
70
|
-
artifactsPath: "generated",
|
|
71
|
-
paramsTypeName: "AppParams",
|
|
72
|
-
schemaTypeName: "AppSchema",
|
|
73
|
-
localeTypeName: "AppLocale",
|
|
74
|
-
factoryName: "createI18n",
|
|
75
|
-
localeFallbackConstName: "LOCALE_FALLBACK",
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("throws on invalid config JSON", () => {
|
|
80
|
-
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
81
|
-
const configPath = join(tempDir, "i18n.codegen.json");
|
|
82
|
-
writeFileSync(configPath, "{ not json");
|
|
83
|
-
|
|
84
|
-
expect(() => loadConfig(configPath)).toThrow(
|
|
85
|
-
`[Codegen Error] Failed to parse config JSON (${configPath})`
|
|
86
|
-
);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("throws on unknown top-level keys and reports allowed keys", () => {
|
|
90
|
-
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
91
|
-
const configPath = writeConfig(tempDir, {
|
|
92
|
-
...validMultiConfig,
|
|
93
|
-
typoKey: true,
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
const act = () => loadConfig(configPath);
|
|
97
|
-
expect(act).toThrow("Invalid i18n.codegen.json");
|
|
98
|
-
expect(act).toThrow("typoKey");
|
|
99
|
-
expect(act).toThrow(`Allowed keys: ${codegenConfigKeys.join(", ")}`);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("throws when required fields are missing", () => {
|
|
103
|
-
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
104
|
-
const configPath = writeConfig(tempDir, {
|
|
105
|
-
namespaces: validMultiConfig.namespaces,
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
const act = () => loadConfig(configPath);
|
|
109
|
-
expect(act).toThrow("projectName");
|
|
110
|
-
expect(act).toThrow("codegenPath");
|
|
111
|
-
expect(act).toThrow("Allowed keys:");
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("throws when projectName is not PascalCase", () => {
|
|
115
|
-
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
116
|
-
const configPath = writeConfig(tempDir, {
|
|
117
|
-
...validMultiConfig,
|
|
118
|
-
projectName: "my-app",
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
expect(() => loadConfig(configPath)).toThrow("projectName must be PascalCase");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("throws when a namespace name is not a valid identifier", () => {
|
|
125
|
-
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
126
|
-
const configPath = writeConfig(tempDir, {
|
|
127
|
-
...validMultiConfig,
|
|
128
|
-
namespaces: {
|
|
129
|
-
default: "translations/default.json",
|
|
130
|
-
"user-area": "translations/user-area.json",
|
|
131
|
-
},
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
expect(() => loadConfig(configPath)).toThrow(
|
|
135
|
-
'Invalid namespace name "user-area" (allowed: letters, digits, underscore; must not start with a digit).'
|
|
136
|
-
);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("accepts artifactsPath for custom json artifact directory", () => {
|
|
140
|
-
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
141
|
-
const configPath = writeConfig(tempDir, {
|
|
142
|
-
...validMultiConfig,
|
|
143
|
-
artifactsPath: "public/i18n",
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
expect(loadConfig(configPath)).toMatchObject({
|
|
147
|
-
artifactsPath: "public/i18n",
|
|
148
|
-
delivery: "split-by-locale",
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("resolveArtifactsPath defaults to codegenPath", () => {
|
|
153
|
-
expect(
|
|
154
|
-
resolveArtifactsPath({
|
|
155
|
-
codegenPath: "generated",
|
|
156
|
-
})
|
|
157
|
-
).toBe("generated");
|
|
158
|
-
expect(
|
|
159
|
-
resolveArtifactsPath({
|
|
160
|
-
codegenPath: "generated",
|
|
161
|
-
artifactsPath: "public/i18n",
|
|
162
|
-
})
|
|
163
|
-
).toBe("public/i18n");
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("accepts custom delivery with deliveryArtifacts", () => {
|
|
167
|
-
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
168
|
-
const configPath = writeConfig(tempDir, {
|
|
169
|
-
...validMultiConfig,
|
|
170
|
-
delivery: "custom",
|
|
171
|
-
deliveryArtifacts: {
|
|
172
|
-
eu: ["it", "fr"],
|
|
173
|
-
us: ["en-US"],
|
|
174
|
-
},
|
|
175
|
-
localeFallback: {
|
|
176
|
-
"en-US": null,
|
|
177
|
-
it: "en-US",
|
|
178
|
-
fr: null,
|
|
179
|
-
},
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
expect(loadConfig(configPath)).toMatchObject({
|
|
183
|
-
delivery: "custom",
|
|
184
|
-
deliveryArtifacts: {
|
|
185
|
-
eu: ["it", "fr"],
|
|
186
|
-
us: ["en-US"],
|
|
187
|
-
},
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("throws when delivery is custom without deliveryArtifacts", () => {
|
|
192
|
-
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
193
|
-
const configPath = writeConfig(tempDir, {
|
|
194
|
-
...validMultiConfig,
|
|
195
|
-
delivery: "custom",
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
expect(() => loadConfig(configPath)).toThrow(
|
|
199
|
-
'deliveryArtifacts is required when delivery is "custom"'
|
|
200
|
-
);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it("throws when deliveryArtifacts is set for non-custom delivery", () => {
|
|
204
|
-
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
205
|
-
const configPath = writeConfig(tempDir, {
|
|
206
|
-
...validMultiConfig,
|
|
207
|
-
delivery: "split-by-locale",
|
|
208
|
-
deliveryArtifacts: {
|
|
209
|
-
eu: ["it"],
|
|
210
|
-
},
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
expect(() => loadConfig(configPath)).toThrow(
|
|
214
|
-
'deliveryArtifacts is only allowed when delivery is "custom"'
|
|
215
|
-
);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it("throws when deliveryArtifacts has duplicate locales across areas", () => {
|
|
219
|
-
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
220
|
-
const configPath = writeConfig(tempDir, {
|
|
221
|
-
...validMultiConfig,
|
|
222
|
-
delivery: "custom",
|
|
223
|
-
deliveryArtifacts: {
|
|
224
|
-
eu: ["it", "fr"],
|
|
225
|
-
us: ["fr"],
|
|
226
|
-
},
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
expect(() => loadConfig(configPath)).toThrow('Locale "fr" appears in both "eu" and "us"');
|
|
230
|
-
});
|
|
231
|
-
});
|
package/src/codegen/config.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import type { NamespaceEntry } from "./types.js";
|
|
3
|
-
import {
|
|
4
|
-
CodegenConfig,
|
|
5
|
-
codegenConfigSchema,
|
|
6
|
-
formatCodegenConfigIssues,
|
|
7
|
-
} from "./codegen-config-schema.js";
|
|
8
|
-
|
|
9
|
-
export {
|
|
10
|
-
resolveCodegenPaths,
|
|
11
|
-
resolveArtifactsPath,
|
|
12
|
-
GENERATED_BASENAMES,
|
|
13
|
-
DEFAULT_FACTORY_NAME,
|
|
14
|
-
DEFAULT_LOCALE_FALLBACK_CONST_NAME,
|
|
15
|
-
} from "./codegen-config-schema.js";
|
|
16
|
-
|
|
17
|
-
/** Parses and validates `i18n.codegen.json`; first step of the codegen pipeline. */
|
|
18
|
-
export function loadConfig(configPath: string): CodegenConfig {
|
|
19
|
-
let raw: unknown;
|
|
20
|
-
|
|
21
|
-
try {
|
|
22
|
-
raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
23
|
-
} catch (error) {
|
|
24
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
25
|
-
throw new Error(`[Codegen Error] Failed to parse config JSON (${configPath}): ${message}`);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const result = codegenConfigSchema.safeParse(raw);
|
|
29
|
-
if (!result.success) {
|
|
30
|
-
throw new Error(formatCodegenConfigIssues(result.error));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return result.data;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Maps config namespaces to namespace entries. */
|
|
37
|
-
export function resolveNamespaces(config: CodegenConfig): NamespaceEntry[] {
|
|
38
|
-
return Object.entries(config.namespaces).map(([namespace, filePath]) => ({
|
|
39
|
-
namespace,
|
|
40
|
-
filePath,
|
|
41
|
-
}));
|
|
42
|
-
}
|
package/src/codegen/constants.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
export const SUPPORTED_IMPORT_EXTENSIONS = ["none", ".ts", ".js"] as const;
|
|
2
|
-
export type SupportedImportExtension = (typeof SUPPORTED_IMPORT_EXTENSIONS)[number];
|
|
3
|
-
|
|
4
|
-
// Translation keys and namespace names become TypeScript identifiers and
|
|
5
|
-
// template-literal type segments in the generated code, so they must be valid
|
|
6
|
-
// identifiers (e.g. "app.title" would emit the broken type `"app.title:..."`).
|
|
7
|
-
export const IDENTIFIER_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
8
|
-
export const IDENTIFIER_NAME_REQUIREMENT =
|
|
9
|
-
"allowed: letters, digits, underscore; must not start with a digit";
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
formatDeliveryArtifactsBlock,
|
|
4
|
-
formatLocaleDeliveryAreaBlock,
|
|
5
|
-
getDeliveryAreaNames,
|
|
6
|
-
getDeliveryArtifactsIssues,
|
|
7
|
-
getDeliveryArtifactsPartitionIssues,
|
|
8
|
-
getDeliveryArtifactsStructureIssues,
|
|
9
|
-
getDeliveryArtifactsTypeName,
|
|
10
|
-
getLocaleDeliveryAreaMap,
|
|
11
|
-
} from "./delivery-artifacts.js";
|
|
12
|
-
|
|
13
|
-
describe("delivery-artifacts", () => {
|
|
14
|
-
const validArtifacts = {
|
|
15
|
-
eu: ["it", "fr"],
|
|
16
|
-
us: ["en-US"],
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
it("accepts a valid structure", () => {
|
|
20
|
-
expect(getDeliveryArtifactsStructureIssues(validArtifacts)).toEqual([]);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("rejects invalid area names", () => {
|
|
24
|
-
const issues = getDeliveryArtifactsStructureIssues({
|
|
25
|
-
"1eu": ["it"],
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
expect(issues).toHaveLength(1);
|
|
29
|
-
expect(issues[0]?.message).toContain('Invalid delivery area name "1eu"');
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("rejects empty area locale lists", () => {
|
|
33
|
-
const issues = getDeliveryArtifactsStructureIssues({
|
|
34
|
-
eu: [],
|
|
35
|
-
us: ["en-US"],
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
expect(issues).toHaveLength(1);
|
|
39
|
-
expect(issues[0]?.message).toContain('Delivery area "eu" must include at least one locale');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("rejects duplicate locales across areas", () => {
|
|
43
|
-
const issues = getDeliveryArtifactsStructureIssues({
|
|
44
|
-
eu: ["it", "fr"],
|
|
45
|
-
us: ["fr", "en-US"],
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
expect(issues).toHaveLength(1);
|
|
49
|
-
expect(issues[0]?.message).toContain('Locale "fr" appears in both "eu" and "us"');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("requires an exhaustive partition of request locales", () => {
|
|
53
|
-
const requestLocales = new Set(["it", "fr", "en-US"]);
|
|
54
|
-
|
|
55
|
-
expect(getDeliveryArtifactsPartitionIssues(validArtifacts, requestLocales)).toEqual([]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("reports missing request locales", () => {
|
|
59
|
-
const issues = getDeliveryArtifactsPartitionIssues(
|
|
60
|
-
{ eu: ["it"] },
|
|
61
|
-
new Set(["it", "fr", "en-US"])
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
expect(issues).toHaveLength(1);
|
|
65
|
-
expect(issues[0]?.message).toContain("missing locales");
|
|
66
|
-
expect(issues[0]?.message).toContain("fr");
|
|
67
|
-
expect(issues[0]?.message).toContain("en-US");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("reports excess locales not in request set", () => {
|
|
71
|
-
const issues = getDeliveryArtifactsPartitionIssues(validArtifacts, new Set(["it"]));
|
|
72
|
-
|
|
73
|
-
expect(issues).toHaveLength(1);
|
|
74
|
-
expect(issues[0]?.message).toContain("includes locales not required");
|
|
75
|
-
expect(issues[0]?.message).toContain("fr");
|
|
76
|
-
expect(issues[0]?.message).toContain("en-US");
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("getDeliveryArtifactsIssues merges structure and partition errors", () => {
|
|
80
|
-
const issues = getDeliveryArtifactsIssues(
|
|
81
|
-
{
|
|
82
|
-
eu: [],
|
|
83
|
-
"1bad": ["it"],
|
|
84
|
-
us: ["it"],
|
|
85
|
-
},
|
|
86
|
-
new Set(["it", "en-US"])
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
expect(issues.map((issue) => issue.message)).toEqual(
|
|
90
|
-
expect.arrayContaining([
|
|
91
|
-
expect.stringContaining('Delivery area "eu" must include at least one locale'),
|
|
92
|
-
expect.stringContaining('Invalid delivery area name "1bad"'),
|
|
93
|
-
expect.stringContaining('Locale "it" appears in both "1bad" and "us"'),
|
|
94
|
-
expect.stringContaining("missing locales"),
|
|
95
|
-
expect.stringContaining("en-US"),
|
|
96
|
-
])
|
|
97
|
-
);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("returns sorted delivery area names", () => {
|
|
101
|
-
expect(
|
|
102
|
-
getDeliveryAreaNames({
|
|
103
|
-
us: ["en-US"],
|
|
104
|
-
eu: ["it", "fr"],
|
|
105
|
-
})
|
|
106
|
-
).toEqual(["eu", "us"]);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("builds locale to delivery area map", () => {
|
|
110
|
-
expect(
|
|
111
|
-
getLocaleDeliveryAreaMap({
|
|
112
|
-
amer: ["en-US", "es-AR"],
|
|
113
|
-
eu: ["en", "it", "fr", "es"],
|
|
114
|
-
})
|
|
115
|
-
).toEqual({
|
|
116
|
-
en: "eu",
|
|
117
|
-
"en-US": "amer",
|
|
118
|
-
"es-AR": "amer",
|
|
119
|
-
es: "eu",
|
|
120
|
-
fr: "eu",
|
|
121
|
-
it: "eu",
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("formats locale delivery area block for generated types", () => {
|
|
126
|
-
expect(
|
|
127
|
-
formatLocaleDeliveryAreaBlock(
|
|
128
|
-
{
|
|
129
|
-
eu: ["it", "fr"],
|
|
130
|
-
us: ["en-US"],
|
|
131
|
-
},
|
|
132
|
-
"LOCALE_DELIVERY_AREA",
|
|
133
|
-
"AppLocale",
|
|
134
|
-
"AppDeliveryArea"
|
|
135
|
-
)
|
|
136
|
-
).toBe(
|
|
137
|
-
"export const LOCALE_DELIVERY_AREA = {\n" +
|
|
138
|
-
' "en-US": "us",\n' +
|
|
139
|
-
' "fr": "eu",\n' +
|
|
140
|
-
' "it": "eu",\n' +
|
|
141
|
-
"} as const satisfies Record<AppLocale, AppDeliveryArea>;\n\n"
|
|
142
|
-
);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("formats delivery artifacts block for generated types", () => {
|
|
146
|
-
expect(getDeliveryArtifactsTypeName("AppDeliveryArea")).toBe("AppDeliveryArtifacts");
|
|
147
|
-
|
|
148
|
-
expect(
|
|
149
|
-
formatDeliveryArtifactsBlock(
|
|
150
|
-
{
|
|
151
|
-
eu: ["it", "fr"],
|
|
152
|
-
us: ["en-US"],
|
|
153
|
-
},
|
|
154
|
-
"DELIVERY_ARTIFACTS",
|
|
155
|
-
"AppLocale",
|
|
156
|
-
"AppDeliveryArea"
|
|
157
|
-
)
|
|
158
|
-
).toBe(
|
|
159
|
-
"export const DELIVERY_ARTIFACTS = {\n" +
|
|
160
|
-
' "eu": ["fr", "it"] as const,\n' +
|
|
161
|
-
' "us": ["en-US"] as const,\n' +
|
|
162
|
-
"} as const satisfies Record<AppDeliveryArea, readonly AppLocale[]>;\n\n" +
|
|
163
|
-
"export type AppDeliveryArtifacts = typeof DELIVERY_ARTIFACTS;\n\n" +
|
|
164
|
-
"export type LocalesForDeliveryArea<A extends AppDeliveryArea> = AppDeliveryArtifacts[A][number];\n\n"
|
|
165
|
-
);
|
|
166
|
-
});
|
|
167
|
-
});
|
|
@@ -1,154 +0,0 @@
|
|
|
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
|
-
}
|
|
125
|
-
|
|
126
|
-
export function getDeliveryArtifactsTypeName(deliveryAreaTypeName: string): string {
|
|
127
|
-
return deliveryAreaTypeName.endsWith("DeliveryArea")
|
|
128
|
-
? deliveryAreaTypeName.replace(/DeliveryArea$/, "DeliveryArtifacts")
|
|
129
|
-
: `${deliveryAreaTypeName}Artifacts`;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function formatDeliveryArtifactsBlock(
|
|
133
|
-
deliveryArtifacts: DeliveryArtifactsMap,
|
|
134
|
-
constName: string,
|
|
135
|
-
localeTypeName: string,
|
|
136
|
-
deliveryAreaTypeName: string
|
|
137
|
-
): string {
|
|
138
|
-
const deliveryArtifactsTypeName = getDeliveryArtifactsTypeName(deliveryAreaTypeName);
|
|
139
|
-
const areaEntries = getDeliveryAreaNames(deliveryArtifacts)
|
|
140
|
-
.map((area) => {
|
|
141
|
-
const locales = [...deliveryArtifacts[area]!]
|
|
142
|
-
.sort()
|
|
143
|
-
.map((locale) => JSON.stringify(locale))
|
|
144
|
-
.join(", ");
|
|
145
|
-
return ` ${JSON.stringify(area)}: [${locales}] as const,`;
|
|
146
|
-
})
|
|
147
|
-
.join("\n");
|
|
148
|
-
|
|
149
|
-
return (
|
|
150
|
-
`export const ${constName} = {\n${areaEntries}\n} as const satisfies Record<${deliveryAreaTypeName}, readonly ${localeTypeName}[]>;\n\n` +
|
|
151
|
-
`export type ${deliveryArtifactsTypeName} = typeof ${constName};\n\n` +
|
|
152
|
-
`export type LocalesForDeliveryArea<A extends ${deliveryAreaTypeName}> = ${deliveryArtifactsTypeName}[A][number];\n\n`
|
|
153
|
-
);
|
|
154
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
buildDictionarySpecFromAnalysis,
|
|
4
|
-
namespaceContractsMatch,
|
|
5
|
-
} from "./dictionary-spec-contract.js";
|
|
6
|
-
|
|
7
|
-
describe("dictionary-spec-contract", () => {
|
|
8
|
-
it("namespaceContractsMatch compares keys and VariableSpec", () => {
|
|
9
|
-
const entries = [{ namespace: "default", filePath: "translations/default.json" }];
|
|
10
|
-
const current = buildDictionarySpecFromAnalysis(entries, {
|
|
11
|
-
default: { welcome: { name: "string" }, login: {} },
|
|
12
|
-
});
|
|
13
|
-
const established = buildDictionarySpecFromAnalysis(entries, {
|
|
14
|
-
default: { welcome: { name: "string" }, login: {} },
|
|
15
|
-
});
|
|
16
|
-
expect(namespaceContractsMatch(["default"], current, established)).toBe(true);
|
|
17
|
-
|
|
18
|
-
const changedArgs = buildDictionarySpecFromAnalysis(entries, {
|
|
19
|
-
default: { welcome: { name: "string", extra: "string" }, login: {} },
|
|
20
|
-
});
|
|
21
|
-
expect(namespaceContractsMatch(["default"], changedArgs, established)).toBe(false);
|
|
22
|
-
|
|
23
|
-
const changedKeys = buildDictionarySpecFromAnalysis(entries, {
|
|
24
|
-
default: { welcome: { name: "string" } },
|
|
25
|
-
});
|
|
26
|
-
expect(namespaceContractsMatch(["default"], changedKeys, established)).toBe(false);
|
|
27
|
-
});
|
|
28
|
-
});
|