@xndrjs/i18n 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +67 -872
  2. package/dist/codegen/index.d.ts +117 -51
  3. package/dist/codegen/index.js +1585 -93
  4. package/dist/codegen/index.js.map +1 -1
  5. package/dist/index.d.ts +100 -234
  6. package/dist/index.js +74 -638
  7. package/dist/index.js.map +1 -1
  8. package/dist/validation/index.d.ts +5 -0
  9. package/dist/validation/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/IcuTranslationProviderMulti.test.ts +18 -40
  12. package/src/IcuTranslationProviderMulti.ts +27 -37
  13. package/src/audit/audit-dictionaries.ts +1 -1
  14. package/src/audit/run-audit.test.ts +6 -7
  15. package/src/builder-load-registry.ts +26 -5
  16. package/src/builder-loaders.ts +8 -11
  17. package/src/builder-types.test.ts +11 -42
  18. package/src/builder.test.ts +82 -243
  19. package/src/builder.ts +8 -110
  20. package/src/codegen/codegen-config-schema.ts +82 -77
  21. package/src/codegen/config.test.ts +55 -143
  22. package/src/codegen/config.ts +10 -67
  23. package/src/codegen/dictionary-spec-contract.test.ts +28 -0
  24. package/src/codegen/dictionary-spec-contract.ts +138 -0
  25. package/src/codegen/emit/dictionary-schema-file.ts +7 -43
  26. package/src/codegen/emit/instance-file.test.ts +35 -52
  27. package/src/codegen/emit/instance-file.ts +170 -268
  28. package/src/codegen/emit/namespace-loaders-file.test.ts +72 -74
  29. package/src/codegen/emit/namespace-loaders-file.ts +147 -90
  30. package/src/codegen/emit/types-file.test.ts +20 -41
  31. package/src/codegen/emit/types-file.ts +48 -74
  32. package/src/codegen/generate-i18n-types.test.ts +163 -492
  33. package/src/codegen/generate-i18n-types.ts +7 -269
  34. package/src/codegen/paths.ts +0 -14
  35. package/src/codegen/project-locales-set-namespace.test.ts +44 -86
  36. package/src/codegen/read-dictionary.test.ts +27 -9
  37. package/src/codegen/read-dictionary.ts +18 -40
  38. package/src/codegen/regenerate-namespaces.ts +180 -0
  39. package/src/codegen/run-codegen.test.ts +230 -0
  40. package/src/codegen/run-codegen.ts +252 -0
  41. package/src/codegen/types.ts +0 -6
  42. package/src/codegen-config/build-config.ts +4 -23
  43. package/src/codegen-config/codegen-config.test.ts +10 -6
  44. package/src/codegen-config/index.ts +18 -3
  45. package/src/engine.ts +3 -27
  46. package/src/fetch-artifact.ts +16 -0
  47. package/src/i18n-handle.ts +202 -0
  48. package/src/index.ts +15 -34
  49. package/src/project-locales.ts +2 -2
  50. package/src/scope-multi.ts +1 -20
  51. package/src/scope-types.ts +14 -6
  52. package/src/scope.test.ts +3 -312
  53. package/src/serialized-state.test.ts +114 -0
  54. package/src/serialized-state.ts +28 -0
  55. package/src/setup/setup-i18n.test.ts +22 -33
  56. package/src/setup/setup-i18n.ts +22 -27
  57. package/src/types.ts +3 -3
  58. package/src/validation/index.ts +4 -0
  59. package/src/IcuTranslationProviderSingle.test.ts +0 -177
  60. package/src/IcuTranslationProviderSingle.ts +0 -114
  61. package/src/builder-multi.ts +0 -481
  62. package/src/codegen/emit/dictionary-file.test.ts +0 -215
  63. package/src/codegen/emit/dictionary-file.ts +0 -244
  64. package/src/deep-freeze.test.ts +0 -40
  65. package/src/deep-freeze.ts +0 -22
  66. package/src/patch-key.test.ts +0 -186
  67. package/src/patch-key.ts +0 -140
  68. package/src/scope-single.ts +0 -85
  69. package/src/single-builder.ts +0 -153
@@ -1,44 +1,45 @@
1
1
  import { z } from "zod";
2
2
  import path from "node:path";
3
- import {
4
- IDENTIFIER_NAME_PATTERN,
5
- IDENTIFIER_NAME_REQUIREMENT,
6
- SUPPORTED_IMPORT_EXTENSIONS,
7
- } from "./constants.js";
3
+ import { IDENTIFIER_NAME_PATTERN, IDENTIFIER_NAME_REQUIREMENT } from "./constants.js";
8
4
  import { getDeliveryArtifactsStructureIssues } from "./delivery-artifacts.js";
5
+ import { typeNamesForProject } from "../codegen-config/type-names.js";
9
6
 
10
7
  const localeFallbackSchema = z.record(z.string(), z.union([z.string(), z.null()]));
11
8
  const deliveryArtifactsSchema = z.record(z.string(), z.array(z.string().min(1)));
12
9
 
13
- export const DELIVERY_MODES = ["canonical", "split-by-locale", "custom"] as const;
10
+ export const DELIVERY_MODES = ["split-by-locale", "custom"] as const;
14
11
  export type DeliveryMode = (typeof DELIVERY_MODES)[number];
15
12
 
13
+ export const LOADER_STRATEGIES = ["import", "fetch"] as const;
14
+ export type LoaderStrategy = (typeof LOADER_STRATEGIES)[number];
15
+
16
+ const PROJECT_NAME_PATTERN = /^[A-Z][a-zA-Z0-9]*$/;
17
+
16
18
  const codegenConfigShape = {
17
- dictionary: z.string().min(1).optional(),
18
- namespaces: z.record(z.string(), z.string().min(1)).optional(),
19
- defaultNamespace: z.string().min(1).optional(),
20
- typesOutput: z.string().min(1),
21
- dictionaryOutput: z
19
+ /** PascalCase project id — generates `{project}Params`, `{project}Schema`, `{project}Locale`. */
20
+ projectName: z
22
21
  .string()
23
22
  .min(1)
24
- .optional()
25
- .describe(
26
- 'Output path for dictionary.generated.ts when emitted (canonical, or split/custom with eager namespaces). Omitted in split/custom when every namespace is lazy — defaults to "{dirname(typesOutput)}/dictionary.generated.ts" for stale-file cleanup only.'
27
- ),
28
- instanceOutput: z.string().min(1),
29
- dictionarySchemaOutput: z.string().min(1).optional(),
30
- loadOnInit: z.array(z.string().min(1)).optional(),
31
- namespaceLoadersOutput: z.string().min(1).optional(),
32
- importExtension: z.enum(SUPPORTED_IMPORT_EXTENSIONS).optional(),
33
- paramsTypeName: z.string().min(1),
34
- schemaTypeName: z.string().min(1),
35
- localeTypeName: z.string().min(1).optional(),
36
- localeFallbackConstName: z.string().min(1).optional(),
23
+ .regex(PROJECT_NAME_PATTERN, 'projectName must be PascalCase (e.g. "MyApp")'),
24
+ namespaces: z.record(z.string(), z.string().min(1)),
25
+ /**
26
+ * Directory for generated TypeScript modules:
27
+ * `i18n-types.generated.ts`, `instance.generated.ts`,
28
+ * `namespace-loaders.generated.ts`, `dictionary-schema.generated.ts`.
29
+ */
30
+ codegenPath: z.string().min(1),
37
31
  localeFallback: localeFallbackSchema.optional(),
38
- factoryName: z.string().min(1).optional(),
39
- delivery: z.enum(DELIVERY_MODES).optional().default("canonical"),
32
+ delivery: z.enum(DELIVERY_MODES).optional().default("split-by-locale"),
40
33
  deliveryArtifacts: deliveryArtifactsSchema.optional(),
41
- deliveryOutput: z.string().min(1).optional(),
34
+ /** Directory for delivery JSON (`translations/`). Defaults to {@link codegenPath}. */
35
+ artifactsPath: z.string().min(1).optional(),
36
+ /**
37
+ * How generated `namespaceLoaders` resolve artifacts.
38
+ * - `import` (default): dynamic `import()` — JSON is bundled; content updates need a rebuild.
39
+ * - `fetch`: runtime `fetchImpl({ locale, namespace, area? })` via required `createI18n({ fetchImpl })`.
40
+ * Codegen does not know URLs — mapping id → transport is an application concern.
41
+ */
42
+ loaderStrategy: z.enum(LOADER_STRATEGIES).optional().default("import"),
42
43
  };
43
44
 
44
45
  export const codegenConfigKeys = Object.keys(
@@ -49,39 +50,16 @@ export const codegenConfigSchema = z
49
50
  .object(codegenConfigShape)
50
51
  .strict()
51
52
  .superRefine((config, ctx) => {
52
- const hasDictionary = config.dictionary !== undefined;
53
- const hasNamespaces = config.namespaces !== undefined;
54
-
55
- if (hasDictionary === hasNamespaces) {
56
- ctx.addIssue({
57
- code: "custom",
58
- message: 'Specify exactly one of "dictionary" or "namespaces".',
59
- });
60
- }
61
-
62
- if (config.namespaces) {
63
- for (const namespace of Object.keys(config.namespaces)) {
64
- if (!IDENTIFIER_NAME_PATTERN.test(namespace)) {
65
- ctx.addIssue({
66
- code: "custom",
67
- path: ["namespaces", namespace],
68
- message: `Invalid namespace name "${namespace}" (${IDENTIFIER_NAME_REQUIREMENT}).`,
69
- });
70
- }
53
+ for (const namespace of Object.keys(config.namespaces)) {
54
+ if (!IDENTIFIER_NAME_PATTERN.test(namespace)) {
55
+ ctx.addIssue({
56
+ code: "custom",
57
+ path: ["namespaces", namespace],
58
+ message: `Invalid namespace name "${namespace}" (${IDENTIFIER_NAME_REQUIREMENT}).`,
59
+ });
71
60
  }
72
61
  }
73
62
 
74
- if (
75
- config.defaultNamespace !== undefined &&
76
- !IDENTIFIER_NAME_PATTERN.test(config.defaultNamespace)
77
- ) {
78
- ctx.addIssue({
79
- code: "custom",
80
- path: ["defaultNamespace"],
81
- message: `Invalid namespace name "${config.defaultNamespace}" (${IDENTIFIER_NAME_REQUIREMENT}).`,
82
- });
83
- }
84
-
85
63
  if (config.delivery === "custom") {
86
64
  if (!config.deliveryArtifacts) {
87
65
  ctx.addIssue({
@@ -105,35 +83,62 @@ export const codegenConfigSchema = z
105
83
  message: 'deliveryArtifacts is only allowed when delivery is "custom".',
106
84
  });
107
85
  }
108
-
109
- if (config.loadOnInit !== undefined && config.delivery !== "canonical") {
110
- ctx.addIssue({
111
- code: "custom",
112
- path: ["loadOnInit"],
113
- message: 'loadOnInit is only allowed when delivery is "canonical".',
114
- });
115
- }
116
86
  });
117
87
 
118
88
  export type CodegenConfigInput = z.input<typeof codegenConfigSchema>;
119
89
  export type CodegenConfig = z.infer<typeof codegenConfigSchema>;
120
90
 
121
- export function resolveDeliveryOutputDir(
122
- config: Pick<CodegenConfig, "typesOutput" | "deliveryOutput">
123
- ): string {
124
- return config.deliveryOutput ?? path.dirname(config.typesOutput);
91
+ export const GENERATED_BASENAMES = {
92
+ types: "i18n-types.generated.ts",
93
+ instance: "instance.generated.ts",
94
+ namespaceLoaders: "namespace-loaders.generated.ts",
95
+ dictionarySchema: "dictionary-schema.generated.ts",
96
+ } as const;
97
+
98
+ export const DEFAULT_FACTORY_NAME = "createI18n";
99
+ export const DEFAULT_LOCALE_FALLBACK_CONST_NAME = "LOCALE_FALLBACK";
100
+
101
+ /** Derived paths and symbol names from `projectName` + `codegenPath`. */
102
+ export interface ResolvedCodegenPaths {
103
+ codegenPath: string;
104
+ typesOutput: string;
105
+ instanceOutput: string;
106
+ namespaceLoadersOutput: string;
107
+ dictionarySchemaOutput: string;
108
+ artifactsPath: string;
109
+ paramsTypeName: string;
110
+ schemaTypeName: string;
111
+ localeTypeName: string;
112
+ localeFallbackConstName: string;
113
+ factoryName: string;
114
+ deliveryAreaTypeName: string;
125
115
  }
126
116
 
127
- const DEFAULT_DICTIONARY_BASENAME = "dictionary.generated.ts";
117
+ export function resolveCodegenPaths(
118
+ config: Pick<CodegenConfig, "projectName" | "codegenPath" | "artifactsPath">
119
+ ): ResolvedCodegenPaths {
120
+ const typeNames = typeNamesForProject(config.projectName);
121
+ const codegenPath = config.codegenPath;
122
+ return {
123
+ codegenPath,
124
+ typesOutput: path.join(codegenPath, GENERATED_BASENAMES.types),
125
+ instanceOutput: path.join(codegenPath, GENERATED_BASENAMES.instance),
126
+ namespaceLoadersOutput: path.join(codegenPath, GENERATED_BASENAMES.namespaceLoaders),
127
+ dictionarySchemaOutput: path.join(codegenPath, GENERATED_BASENAMES.dictionarySchema),
128
+ artifactsPath: config.artifactsPath ?? codegenPath,
129
+ paramsTypeName: typeNames.paramsTypeName,
130
+ schemaTypeName: typeNames.schemaTypeName,
131
+ localeTypeName: typeNames.localeTypeName,
132
+ localeFallbackConstName: DEFAULT_LOCALE_FALLBACK_CONST_NAME,
133
+ factoryName: DEFAULT_FACTORY_NAME,
134
+ deliveryAreaTypeName: `${config.projectName}DeliveryArea`,
135
+ };
136
+ }
128
137
 
129
- /** Resolved path for dictionary.generated.ts (explicit config or default next to typesOutput). */
130
- export function resolveDictionaryOutputPath(
131
- config: Pick<CodegenConfig, "typesOutput" | "dictionaryOutput">
138
+ export function resolveArtifactsPath(
139
+ config: Pick<CodegenConfig, "codegenPath" | "artifactsPath">
132
140
  ): string {
133
- return (
134
- config.dictionaryOutput ??
135
- path.join(path.dirname(config.typesOutput), DEFAULT_DICTIONARY_BASENAME)
136
- );
141
+ return config.artifactsPath ?? config.codegenPath;
137
142
  }
138
143
 
139
144
  export function formatCodegenConfigIssues(error: z.ZodError): string {
@@ -2,9 +2,8 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { afterEach, describe, expect, it } from "vitest";
5
- import { codegenConfigKeys, resolveDictionaryOutputPath } from "./codegen-config-schema.js";
6
- import { loadConfig, resolveDeliveryOutputDir, resolveLoadOnInit } from "./config.js";
7
- import { resolveImportExtension } from "./paths.js";
5
+ import { codegenConfigKeys, resolveCodegenPaths } from "./codegen-config-schema.js";
6
+ import { loadConfig, resolveArtifactsPath } from "./config.js";
8
7
 
9
8
  function writeConfig(dir: string, config: Record<string, unknown>) {
10
9
  const configPath = join(dir, "i18n.codegen.json");
@@ -13,14 +12,11 @@ function writeConfig(dir: string, config: Record<string, unknown>) {
13
12
  }
14
13
 
15
14
  const validMultiConfig = {
15
+ projectName: "App",
16
16
  namespaces: {
17
17
  default: "translations/default.json",
18
18
  },
19
- typesOutput: "generated/i18n-types.generated.ts",
20
- dictionaryOutput: "generated/dictionary.generated.ts",
21
- instanceOutput: "generated/instance.generated.ts",
22
- paramsTypeName: "AppParams",
23
- schemaTypeName: "AppSchema",
19
+ codegenPath: "generated",
24
20
  };
25
21
 
26
22
  describe("loadConfig", () => {
@@ -32,43 +28,52 @@ describe("loadConfig", () => {
32
28
  }
33
29
  });
34
30
 
35
- it("accepts a valid multi-namespace config", () => {
31
+ it("accepts a valid multi-namespace config and defaults delivery to split-by-locale", () => {
36
32
  tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
37
33
  const configPath = writeConfig(tempDir, validMultiConfig);
38
34
 
39
35
  expect(loadConfig(configPath)).toEqual({
40
36
  ...validMultiConfig,
41
- delivery: "canonical",
37
+ delivery: "split-by-locale",
38
+ loaderStrategy: "import",
42
39
  });
43
40
  });
44
41
 
45
- it("accepts split-by-locale config without dictionaryOutput", () => {
42
+ it("accepts fetch loaderStrategy", () => {
46
43
  tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
47
- const { dictionaryOutput: _dictionaryOutput, ...configWithoutDictionaryOutput } =
48
- validMultiConfig;
49
44
  const configPath = writeConfig(tempDir, {
50
- ...configWithoutDictionaryOutput,
51
- delivery: "split-by-locale",
45
+ ...validMultiConfig,
46
+ loaderStrategy: "fetch",
52
47
  });
53
48
 
54
- expect(loadConfig(configPath)).toEqual({
55
- ...configWithoutDictionaryOutput,
56
- delivery: "split-by-locale",
49
+ expect(loadConfig(configPath)).toMatchObject({
50
+ loaderStrategy: "fetch",
57
51
  });
58
52
  });
59
53
 
60
- it("resolves dictionaryOutput next to typesOutput when omitted", () => {
61
- expect(
62
- resolveDictionaryOutputPath({
63
- typesOutput: "generated/i18n-types.generated.ts",
64
- })
65
- ).toBe("generated/dictionary.generated.ts");
66
- expect(
67
- resolveDictionaryOutputPath({
68
- typesOutput: "generated/i18n-types.generated.ts",
69
- dictionaryOutput: "custom/dictionary.generated.ts",
70
- })
71
- ).toBe("custom/dictionary.generated.ts");
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
+ });
72
77
  });
73
78
 
74
79
  it("throws on invalid config JSON", () => {
@@ -94,27 +99,26 @@ describe("loadConfig", () => {
94
99
  expect(act).toThrow(`Allowed keys: ${codegenConfigKeys.join(", ")}`);
95
100
  });
96
101
 
97
- it("throws when both dictionary and namespaces are present", () => {
102
+ it("throws when required fields are missing", () => {
98
103
  tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
99
104
  const configPath = writeConfig(tempDir, {
100
- ...validMultiConfig,
101
- dictionary: "translations/default.json",
105
+ namespaces: validMultiConfig.namespaces,
102
106
  });
103
107
 
104
- expect(() => loadConfig(configPath)).toThrow(
105
- 'Specify exactly one of "dictionary" or "namespaces".'
106
- );
108
+ const act = () => loadConfig(configPath);
109
+ expect(act).toThrow("projectName");
110
+ expect(act).toThrow("codegenPath");
111
+ expect(act).toThrow("Allowed keys:");
107
112
  });
108
113
 
109
- it("throws when required fields are missing", () => {
114
+ it("throws when projectName is not PascalCase", () => {
110
115
  tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
111
116
  const configPath = writeConfig(tempDir, {
112
- namespaces: validMultiConfig.namespaces,
117
+ ...validMultiConfig,
118
+ projectName: "my-app",
113
119
  });
114
120
 
115
- const act = () => loadConfig(configPath);
116
- expect(act).toThrow("typesOutput");
117
- expect(act).toThrow("Allowed keys:");
121
+ expect(() => loadConfig(configPath)).toThrow("projectName must be PascalCase");
118
122
  });
119
123
 
120
124
  it("throws when a namespace name is not a valid identifier", () => {
@@ -132,41 +136,29 @@ describe("loadConfig", () => {
132
136
  );
133
137
  });
134
138
 
135
- it("throws when defaultNamespace is not a valid identifier", () => {
139
+ it("accepts artifactsPath for custom json artifact directory", () => {
136
140
  tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
137
141
  const configPath = writeConfig(tempDir, {
138
142
  ...validMultiConfig,
139
- defaultNamespace: "1default",
140
- });
141
-
142
- expect(() => loadConfig(configPath)).toThrow(
143
- 'defaultNamespace: Invalid namespace name "1default"'
144
- );
145
- });
146
-
147
- it("accepts deliveryOutput for custom json artifact directory", () => {
148
- tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
149
- const configPath = writeConfig(tempDir, {
150
- ...validMultiConfig,
151
- deliveryOutput: "public/i18n",
143
+ artifactsPath: "public/i18n",
152
144
  });
153
145
 
154
146
  expect(loadConfig(configPath)).toMatchObject({
155
- deliveryOutput: "public/i18n",
156
- delivery: "canonical",
147
+ artifactsPath: "public/i18n",
148
+ delivery: "split-by-locale",
157
149
  });
158
150
  });
159
151
 
160
- it("resolveDeliveryOutputDir defaults to dirname(typesOutput)", () => {
152
+ it("resolveArtifactsPath defaults to codegenPath", () => {
161
153
  expect(
162
- resolveDeliveryOutputDir({
163
- typesOutput: "generated/i18n-types.generated.ts",
154
+ resolveArtifactsPath({
155
+ codegenPath: "generated",
164
156
  })
165
157
  ).toBe("generated");
166
158
  expect(
167
- resolveDeliveryOutputDir({
168
- typesOutput: "generated/i18n-types.generated.ts",
169
- deliveryOutput: "public/i18n",
159
+ resolveArtifactsPath({
160
+ codegenPath: "generated",
161
+ artifactsPath: "public/i18n",
170
162
  })
171
163
  ).toBe("public/i18n");
172
164
  });
@@ -236,84 +228,4 @@ describe("loadConfig", () => {
236
228
 
237
229
  expect(() => loadConfig(configPath)).toThrow('Locale "fr" appears in both "eu" and "us"');
238
230
  });
239
-
240
- it("throws when loadOnInit is set for split-by-locale delivery", () => {
241
- tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
242
- const configPath = writeConfig(tempDir, {
243
- ...validMultiConfig,
244
- delivery: "split-by-locale",
245
- loadOnInit: ["default"],
246
- });
247
-
248
- expect(() => loadConfig(configPath)).toThrow(
249
- 'loadOnInit is only allowed when delivery is "canonical".'
250
- );
251
- });
252
-
253
- it("throws when loadOnInit is set for custom delivery", () => {
254
- tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
255
- const configPath = writeConfig(tempDir, {
256
- ...validMultiConfig,
257
- delivery: "custom",
258
- deliveryArtifacts: {
259
- eu: ["it"],
260
- },
261
- loadOnInit: ["default"],
262
- });
263
-
264
- expect(() => loadConfig(configPath)).toThrow(
265
- 'loadOnInit is only allowed when delivery is "canonical".'
266
- );
267
- });
268
- });
269
-
270
- describe("resolveLoadOnInit", () => {
271
- const entries = [
272
- { namespace: "default", filePath: "translations/default.json" },
273
- { namespace: "billing", filePath: "translations/billing.json" },
274
- ];
275
-
276
- it("throws when loadOnInit is used in single mode", () => {
277
- const config = { ...validMultiConfig, delivery: "canonical" as const, loadOnInit: ["default"] };
278
-
279
- expect(() => resolveLoadOnInit(config, entries, true)).toThrow(
280
- '[Codegen Error] "loadOnInit" is only supported in multi mode (namespaces config).'
281
- );
282
- });
283
-
284
- it("throws when loadOnInit references an unknown namespace", () => {
285
- const config = { ...validMultiConfig, delivery: "canonical" as const, loadOnInit: ["missing"] };
286
-
287
- expect(() => resolveLoadOnInit(config, entries, false)).toThrow(
288
- '[Codegen Error] loadOnInit: namespace "missing" is not defined in namespaces config.'
289
- );
290
- });
291
-
292
- it("treats all namespaces as lazy when delivery is split-by-locale", () => {
293
- const config = { ...validMultiConfig, delivery: "split-by-locale" as const };
294
-
295
- expect(resolveLoadOnInit(config, entries, false)).toEqual({
296
- loadOnInitSet: new Set(),
297
- lazyEntries: entries,
298
- hasLazy: true,
299
- });
300
- });
301
-
302
- it("treats all namespaces as lazy when delivery is custom", () => {
303
- const config = { ...validMultiConfig, delivery: "custom" as const };
304
-
305
- expect(resolveLoadOnInit(config, entries, false)).toEqual({
306
- loadOnInitSet: new Set(),
307
- lazyEntries: entries,
308
- hasLazy: true,
309
- });
310
- });
311
- });
312
-
313
- describe("resolveImportExtension", () => {
314
- it("throws on unsupported import extensions", () => {
315
- expect(() => resolveImportExtension({ importExtension: ".mjs" as never })).toThrow(
316
- '[Codegen Error] importExtension must be "none", ".ts", or ".js", got ".mjs".'
317
- );
318
- });
319
231
  });
@@ -1,12 +1,18 @@
1
1
  import fs from "node:fs";
2
- import type { LoadOnInitResolution, NamespaceEntry } from "./types.js";
2
+ import type { NamespaceEntry } from "./types.js";
3
3
  import {
4
4
  CodegenConfig,
5
5
  codegenConfigSchema,
6
6
  formatCodegenConfigIssues,
7
7
  } from "./codegen-config-schema.js";
8
8
 
9
- export { resolveDeliveryOutputDir, resolveDictionaryOutputPath } from "./codegen-config-schema.js";
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";
10
16
 
11
17
  /** Parses and validates `i18n.codegen.json`; first step of the codegen pipeline. */
12
18
  export function loadConfig(configPath: string): CodegenConfig {
@@ -27,73 +33,10 @@ export function loadConfig(configPath: string): CodegenConfig {
27
33
  return result.data;
28
34
  }
29
35
 
30
- /** Maps config to namespace entries (`dictionary` single vs `namespaces` multi). */
36
+ /** Maps config namespaces to namespace entries. */
31
37
  export function resolveNamespaces(config: CodegenConfig): NamespaceEntry[] {
32
- const hasDictionary = Boolean(config.dictionary);
33
- const hasNamespaces = Boolean(config.namespaces);
34
-
35
- if (hasDictionary === hasNamespaces) {
36
- throw new Error(
37
- '[Codegen Error] Config must specify exactly one of "dictionary" or "namespaces".'
38
- );
39
- }
40
-
41
- if (hasDictionary) {
42
- return [
43
- {
44
- namespace: config.defaultNamespace ?? "default",
45
- filePath: config.dictionary!,
46
- },
47
- ];
48
- }
49
-
50
- return Object.entries(config.namespaces!).map(([namespace, filePath]) => ({
38
+ return Object.entries(config.namespaces).map(([namespace, filePath]) => ({
51
39
  namespace,
52
40
  filePath,
53
41
  }));
54
42
  }
55
-
56
- /**
57
- * Splits namespaces into eager (`loadOnInit`) and lazy sets.
58
- * Drives `InitialSchema`, `defaultDictionary`, and `namespace-loaders.generated.ts`.
59
- */
60
- export function resolveLoadOnInit(
61
- config: CodegenConfig,
62
- entries: NamespaceEntry[],
63
- isSingle: boolean
64
- ): LoadOnInitResolution {
65
- if (isSingle) {
66
- if (config.loadOnInit) {
67
- throw new Error(
68
- '[Codegen Error] "loadOnInit" is only supported in multi mode (namespaces config).'
69
- );
70
- }
71
- const all = new Set(entries.map((entry) => entry.namespace));
72
- return { loadOnInitSet: all, lazyEntries: [], hasLazy: false };
73
- }
74
-
75
- const delivery = config.delivery ?? "canonical";
76
- if (delivery !== "canonical") {
77
- return {
78
- loadOnInitSet: new Set(),
79
- lazyEntries: entries,
80
- hasLazy: entries.length > 0,
81
- };
82
- }
83
-
84
- const allNamespaces = new Set(entries.map((entry) => entry.namespace));
85
- const loadOnInitSet = config.loadOnInit
86
- ? new Set(config.loadOnInit)
87
- : new Set(entries.map((entry) => entry.namespace));
88
-
89
- for (const namespace of loadOnInitSet) {
90
- if (!allNamespaces.has(namespace)) {
91
- throw new Error(
92
- `[Codegen Error] loadOnInit: namespace "${namespace}" is not defined in namespaces config.`
93
- );
94
- }
95
- }
96
-
97
- const lazyEntries = entries.filter((entry) => !loadOnInitSet.has(entry.namespace));
98
- return { loadOnInitSet, lazyEntries, hasLazy: lazyEntries.length > 0 };
99
- }
@@ -0,0 +1,28 @@
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
+ });