@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,277 +1,15 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import {
4
- loadConfig,
5
- resolveDeliveryOutputDir,
6
- resolveDictionaryOutputPath,
7
- resolveLoadOnInit,
8
- resolveNamespaces,
9
- } from "./config.js";
10
- import { formatDictionaryFile } from "./emit/dictionary-file.js";
11
- import {
12
- formatDictionarySchemaFile,
13
- formatDictionarySpecBlock,
14
- } from "./emit/dictionary-schema-file.js";
15
- import { formatInstanceFile } from "./emit/instance-file.js";
16
- import { formatNamespaceLoadersFile } from "./emit/namespace-loaders-file.js";
17
- import { formatTypesFile } from "./emit/types-file.js";
18
- import { analyzeDictionaries } from "./icu-analysis.js";
19
- import { getDeliveryArtifactsIssues, getDeliveryArtifactsTypeName } from "./delivery-artifacts.js";
20
- import { collectRequestLocales, getCodegenLocaleFallbackIssues } from "./locale-fallback.js";
21
- import { enrichLocaleFallback } from "./locale-policy.js";
22
- import { reportCodegenIssues, resolveImportExtension, toModuleBasename } from "./paths.js";
23
- import { prepareDictionaryEntries } from "./read-dictionary.js";
24
- import { writeFileIfChanged } from "./write-file-if-changed.js";
25
-
26
1
  /**
27
- * Codegen CLI entry point. Pipeline:
28
- * config → analyze (ICU + types input) → validate locale/delivery policy →
29
- * prepare artifacts (YAML→JSON, split) → resolve lazy/eager → emit generated .ts files.
2
+ * Codegen CLI entry point. Thin wrapper around {@link runCodegen}.
30
3
  */
4
+ import path from "node:path";
5
+ import { runCodegen } from "./run-codegen.js";
6
+
31
7
  function main() {
32
8
  const configArgIndex = process.argv.indexOf("--config");
33
- const configPath = path.resolve(
34
- process.cwd(),
35
- configArgIndex >= 0 ? process.argv[configArgIndex + 1]! : "i18n/i18n.codegen.json"
36
- );
37
- const projectRoot = path.dirname(configPath);
38
-
39
- if (!fs.existsSync(configPath)) {
40
- throw new Error(`[Codegen Error] Config file not found: ${configPath}`);
41
- }
42
-
43
- const config = loadConfig(configPath);
44
- const sourceEntries = resolveNamespaces(config);
45
- const deliveryOutputRelative = resolveDeliveryOutputDir(config);
46
- const delivery = config.delivery ?? "canonical";
47
- const isSingle = Boolean(config.dictionary);
48
-
49
- const analysisResult = analyzeDictionaries(projectRoot, sourceEntries);
50
- if (!analysisResult.ok) {
51
- process.exit(1);
52
- }
53
-
54
- const { paramsByNamespace, argsSpecByNamespace, locales, dictionariesByNamespace } =
55
- analysisResult.analysis;
56
-
57
- if (config.localeFallback) {
58
- const localeFallbackIssues = getCodegenLocaleFallbackIssues(config.localeFallback, locales);
59
- if (localeFallbackIssues.length > 0) {
60
- reportCodegenIssues(localeFallbackIssues);
61
- process.exit(1);
62
- }
63
- }
64
-
65
- const paramsTypeName = config.paramsTypeName;
66
- const schemaTypeName = config.schemaTypeName;
67
- const localeTypeName = config.localeTypeName ?? schemaTypeName.replace(/Schema$/, "Locale");
68
- const localeFallbackConstName = config.localeFallbackConstName ?? "LOCALE_FALLBACK";
69
- const localeFallbackTypeName = `${localeTypeName}Fallback`;
70
- const factoryName = config.factoryName ?? "createI18n";
71
- const importExtension = resolveImportExtension(config);
72
-
73
- const requestLocales = collectRequestLocales(locales, config.localeFallback);
74
- const requestLocalesList = [...requestLocales].sort();
75
-
76
- if (delivery === "custom" && config.deliveryArtifacts) {
77
- const deliveryArtifactsIssues = getDeliveryArtifactsIssues(
78
- config.deliveryArtifacts,
79
- requestLocales
80
- );
81
- if (deliveryArtifactsIssues.length > 0) {
82
- reportCodegenIssues(deliveryArtifactsIssues);
83
- process.exit(1);
84
- }
85
- }
86
-
87
- const {
88
- resolvedEntries: entries,
89
- splitPathsByNamespace,
90
- compiledFiles,
91
- } = prepareDictionaryEntries(projectRoot, sourceEntries, deliveryOutputRelative, {
92
- dictionariesByNamespace,
93
- delivery,
94
- localeFallback: config.localeFallback,
95
- requestLocales: delivery === "split-by-locale" ? requestLocalesList : undefined,
96
- deliveryArtifacts: delivery === "custom" ? config.deliveryArtifacts : undefined,
97
- });
98
-
99
- const { loadOnInitSet, lazyEntries, hasLazy } = resolveLoadOnInit(config, entries, isSingle);
100
-
101
- const typesOutputPath = path.resolve(projectRoot, config.typesOutput);
102
- const dictionaryOutputPath = path.resolve(projectRoot, resolveDictionaryOutputPath(config));
103
- const instanceOutputPath = path.resolve(projectRoot, config.instanceOutput);
104
- const typesModule = toModuleBasename(typesOutputPath);
105
-
106
- const requestLocaleUnion = requestLocalesList
107
- .sort()
108
- .map((locale) => `'${locale}'`)
109
- .join(" | ");
110
-
111
- const deliveryAreaNames =
112
- delivery === "custom" && config.deliveryArtifacts
113
- ? Object.keys(config.deliveryArtifacts).sort()
114
- : undefined;
115
- const deliveryAreaTypeName =
116
- delivery === "custom" ? schemaTypeName.replace(/Schema$/, "DeliveryArea") : undefined;
117
- const deliveryAreaUnion = deliveryAreaNames
118
- ? deliveryAreaNames.map((area) => `'${area}'`).join(" | ")
119
- : undefined;
120
-
121
- const eagerEntries = hasLazy
122
- ? entries.filter((entry) => loadOnInitSet.has(entry.namespace))
123
- : entries;
124
-
125
- const localeFallbackForEmit = config.localeFallback
126
- ? enrichLocaleFallback(locales, config.localeFallback)
127
- : undefined;
128
-
129
- const typesContent = formatTypesFile({
130
- isSingle,
131
- entries,
132
- projectRoot,
133
- typesOutputPath,
134
- paramsTypeName,
135
- schemaTypeName,
136
- localeTypeName,
137
- localeFallbackConstName,
138
- localeFallbackTypeName,
139
- localeFallback: localeFallbackForEmit,
140
- paramsByNamespace,
141
- requestLocaleUnion,
142
- ...(deliveryAreaTypeName && deliveryAreaUnion
143
- ? {
144
- deliveryAreaTypeName,
145
- deliveryAreaUnion,
146
- ...(delivery === "custom" && config.deliveryArtifacts
147
- ? { deliveryArtifacts: config.deliveryArtifacts }
148
- : {}),
149
- }
150
- : {}),
151
- hasLazy,
152
- loadOnInitSet,
153
- lazyEntries,
154
- });
155
-
156
- const dictionaryContent = formatDictionaryFile({
157
- isSingle,
158
- hasLazy,
159
- entries,
160
- eagerEntries,
161
- projectRoot,
162
- dictionaryOutputPath,
163
- typesOutputPath,
164
- schemaTypeName,
165
- localeTypeName,
166
- importExtension,
167
- delivery,
168
- splitPathsByNamespace,
169
- ...(delivery === "split-by-locale" ? { requestLocales: requestLocalesList } : {}),
170
- ...(delivery === "custom" && deliveryAreaTypeName && deliveryAreaNames
171
- ? { deliveryAreaTypeName, deliveryAreaNames }
172
- : {}),
173
- });
174
-
175
- const instanceContent = formatInstanceFile({
176
- isSingle,
177
- hasLazy,
178
- typesOutputPath,
179
- ...(hasLazy
180
- ? {
181
- namespaceLoadersOutputPath: path.resolve(
182
- projectRoot,
183
- config.namespaceLoadersOutput ??
184
- path.join(path.dirname(config.instanceOutput), "namespace-loaders.generated.ts")
185
- ),
186
- }
187
- : {}),
188
- paramsTypeName,
189
- schemaTypeName,
190
- localeTypeName,
191
- localeFallbackConstName,
192
- factoryName,
193
- hasLocaleFallback: Boolean(config.localeFallback),
194
- hasLocaleType: Boolean(requestLocaleUnion),
195
- namespaceNames: entries.map((entry) => entry.namespace),
196
- importExtension,
197
- delivery,
198
- ...(delivery === "custom" && deliveryAreaTypeName
199
- ? {
200
- deliveryAreaTypeName,
201
- deliveryArtifactsTypeName: getDeliveryArtifactsTypeName(deliveryAreaTypeName),
202
- }
203
- : {}),
204
- });
205
-
206
- writeFileIfChanged(typesOutputPath, typesContent);
207
-
208
- const generatedFiles = [
209
- path.relative(projectRoot, typesOutputPath),
210
- path.relative(projectRoot, instanceOutputPath),
211
- ];
212
-
213
- if (dictionaryContent !== null) {
214
- writeFileIfChanged(dictionaryOutputPath, dictionaryContent);
215
- generatedFiles.splice(1, 0, path.relative(projectRoot, dictionaryOutputPath));
216
- } else if (fs.existsSync(dictionaryOutputPath)) {
217
- fs.unlinkSync(dictionaryOutputPath);
218
- }
219
-
220
- writeFileIfChanged(instanceOutputPath, instanceContent);
221
-
222
- if (config.dictionarySchemaOutput) {
223
- const dictionarySchemaOutputPath = path.resolve(projectRoot, config.dictionarySchemaOutput);
224
- const dictionarySpecBlock = formatDictionarySpecBlock(isSingle, entries, argsSpecByNamespace);
225
- const dictionarySchemaContent = formatDictionarySchemaFile(
226
- schemaTypeName,
227
- typesModule,
228
- isSingle,
229
- dictionarySpecBlock,
230
- importExtension
231
- );
232
-
233
- writeFileIfChanged(dictionarySchemaOutputPath, dictionarySchemaContent);
234
- generatedFiles.push(path.relative(projectRoot, dictionarySchemaOutputPath));
235
- }
236
-
237
- if (hasLazy) {
238
- const namespaceLoadersOutputPath = path.resolve(
239
- projectRoot,
240
- config.namespaceLoadersOutput ??
241
- path.join(path.dirname(config.instanceOutput), "namespace-loaders.generated.ts")
242
- );
243
- const lazyEntriesWithPaths = lazyEntries.map((entry) => ({
244
- ...entry,
245
- absolutePath: path.resolve(projectRoot, entry.filePath),
246
- }));
247
- const namespaceLoadersContent = formatNamespaceLoadersFile({
248
- loadersOutputPath: namespaceLoadersOutputPath,
249
- lazyEntries: lazyEntriesWithPaths,
250
- schemaTypeName,
251
- paramsTypeName,
252
- localeTypeName,
253
- localeFallbackConstName,
254
- hasLocaleFallback: Boolean(config.localeFallback),
255
- typesModule,
256
- importExtension,
257
- projectRoot,
258
- isSingle,
259
- delivery,
260
- splitPathsByNamespace,
261
- ...(delivery === "split-by-locale" ? { requestLocales: requestLocalesList } : {}),
262
- ...(delivery === "custom" && deliveryAreaTypeName && deliveryAreaNames
263
- ? { deliveryAreaTypeName, deliveryAreaNames }
264
- : {}),
265
- });
266
-
267
- writeFileIfChanged(namespaceLoadersOutputPath, namespaceLoadersContent);
268
- generatedFiles.push(path.relative(projectRoot, namespaceLoadersOutputPath));
269
- }
9
+ const configPath =
10
+ configArgIndex >= 0 ? process.argv[configArgIndex + 1]! : "i18n/i18n.codegen.json";
270
11
 
271
- console.log(`✅ Generated: ${generatedFiles.join(", ")}`);
272
- if (compiledFiles.length > 0) {
273
- console.log(`✅ Compiled: ${compiledFiles.join(", ")}`);
274
- }
12
+ runCodegen(path.resolve(process.cwd(), configPath));
275
13
  }
276
14
 
277
15
  try {
@@ -1,7 +1,5 @@
1
1
  import path from "node:path";
2
2
  import type { ImportExtension } from "./types.js";
3
- import { SUPPORTED_IMPORT_EXTENSIONS } from "./constants.js";
4
- import { CodegenConfig } from "./codegen-config-schema.js";
5
3
 
6
4
  export const GENERATED_FILE_BANNER = "// Automatically generated code. Do not edit manually.\n";
7
5
  export const DEFAULT_IMPORT_EXTENSION: ImportExtension = "none";
@@ -22,18 +20,6 @@ export function toModuleBasename(filePath: string): string {
22
20
  return path.basename(filePath).replace(/\.ts$/, "");
23
21
  }
24
22
 
25
- export function resolveImportExtension(
26
- config: Pick<CodegenConfig, "importExtension">
27
- ): ImportExtension {
28
- const extension = config.importExtension ?? DEFAULT_IMPORT_EXTENSION;
29
- if (!SUPPORTED_IMPORT_EXTENSIONS.includes(extension)) {
30
- throw new Error(
31
- `[Codegen Error] importExtension must be "none", ".ts", or ".js", got ${JSON.stringify(extension)}.`
32
- );
33
- }
34
- return extension;
35
- }
36
-
37
23
  export function importExtensionSuffix(importExtension: ImportExtension): string {
38
24
  return importExtension === "none" ? "" : importExtension;
39
25
  }
@@ -3,14 +3,11 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
- import { afterEach, describe, expect, expectTypeOf, it } from "vitest";
7
- import billingDictionary from "./fixtures/billing-namespace.json";
6
+ import { afterEach, describe, expect, it } from "vitest";
8
7
 
9
8
  const codegenScript = fileURLToPath(new URL("./generate-i18n-types.ts", import.meta.url));
10
9
  const packageRoot = fileURLToPath(new URL("../../", import.meta.url));
11
10
 
12
- type BillingNamespace = typeof billingDictionary;
13
-
14
11
  function runCodegen(cwd: string) {
15
12
  return spawnSync("tsx", [codegenScript, "--config", "i18n.codegen.json"], {
16
13
  cwd,
@@ -49,15 +46,12 @@ function setupMultiCodegenFixture(tempDir: string) {
49
46
  writeFileSync(
50
47
  join(tempDir, "i18n.codegen.json"),
51
48
  JSON.stringify({
49
+ projectName: "App",
52
50
  namespaces: {
53
51
  default: "src/i18n/translations/default.json",
54
52
  billing: "src/i18n/translations/billing.json",
55
53
  },
56
- typesOutput: "src/i18n/i18n-types.generated.ts",
57
- dictionaryOutput: "src/i18n/dictionary.generated.ts",
58
- instanceOutput: "src/i18n/instance.generated.ts",
59
- paramsTypeName: "AppParams",
60
- schemaTypeName: "AppSchema",
54
+ codegenPath: "src/i18n",
61
55
  localeFallback: {
62
56
  en: null,
63
57
  it: "en",
@@ -85,6 +79,7 @@ function writeTscProject(tempDir: string) {
85
79
  skipLibCheck: true,
86
80
  paths: {
87
81
  "@xndrjs/i18n": [join(packageRoot, "src/index.ts")],
82
+ "@xndrjs/i18n/validation": [join(packageRoot, "src/validation/index.ts")],
88
83
  },
89
84
  },
90
85
  include: ["src/**/*.ts"],
@@ -95,82 +90,45 @@ function writeTscProject(tempDir: string) {
95
90
  );
96
91
  }
97
92
 
98
- describe("codegen projectNamespaceLocales + load + scope.set", () => {
99
- describe("generated instance output", () => {
100
- let tempDir: string;
101
-
102
- afterEach(() => {
103
- if (tempDir) {
104
- rmSync(tempDir, { recursive: true, force: true });
105
- }
106
- });
107
-
108
- it("emits projectNamespaceLocales for multi mode with schema-typed namespace payloads", () => {
109
- tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-project-locales-"));
110
- setupMultiCodegenFixture(tempDir);
111
-
112
- const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
113
- expect(factory).toContain('dictionary: AppSchema["billing"]');
114
- expect(factory).toContain("export function projectNamespaceLocales(");
115
- expect(factory).toContain(
116
- "projectDictionaryLocalesCore(dictionary, locales, LOCALE_FALLBACK)"
117
- );
118
- });
119
-
120
- it("passes tsc when hydrating with projectNamespaceLocales via load() and scope.set()", () => {
121
- tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-project-locales-"));
122
- setupMultiCodegenFixture(tempDir);
123
-
124
- writeFileSync(
125
- join(tempDir, "src/hydrate-billing.ts"),
126
- [
127
- `import { createI18nMultiBuilder, IcuTranslationProviderMulti } from "@xndrjs/i18n";`,
128
- `import { projectNamespaceLocales } from "./i18n/instance.generated.js";`,
129
- `import { defaultDictionary } from "./i18n/dictionary.generated.js";`,
130
- `import type { AppSchema, AppParams } from "./i18n/i18n-types.generated.js";`,
131
- `import billingDictionary from "./i18n/translations/billing.json";`,
132
- ``,
133
- `async function hydrateBilling() {`,
134
- ` const billingEn = projectNamespaceLocales(billingDictionary, ["en"]);`,
135
- ` const engine = new IcuTranslationProviderMulti<AppSchema, AppParams>(defaultDictionary);`,
136
- ` const view = await createI18nMultiBuilder(engine, {`,
137
- ` namespaceLoaders: {`,
138
- ` billing: async () => billingEn,`,
139
- ` },`,
140
- ` })`,
141
- ` .withNamespaces(["billing"])`,
142
- ` .withLocale("en")`,
143
- ` .load();`,
144
- ``,
145
- ` view.set(`,
146
- ` "billing",`,
147
- ` "invoice_summary",`,
148
- ` "You have {count, plural, one {1 invoice} other {{count} invoices}}"`,
149
- ` );`,
150
- ` return view.t("billing", "invoice_summary", { count: 1 });`,
151
- `}`,
152
- ``,
153
- `export { hydrateBilling };`,
154
- ].join("\n")
155
- );
156
-
157
- writeTscProject(tempDir);
158
-
159
- const tsc = runTsc(join(tempDir, "tsconfig.json"));
160
- expect(tsc.status, `${tsc.stdout}\n${tsc.stderr}`).toBe(0);
161
- });
162
-
163
- it("types projectNamespaceLocales result as the namespace schema", () => {
164
- type AppSchema = {
165
- billing: BillingNamespace;
166
- };
167
-
168
- type ProjectNamespaceLocalesBilling = (
169
- dictionary: AppSchema["billing"],
170
- locales: readonly ("en" | "it")[]
171
- ) => AppSchema["billing"];
172
-
173
- expectTypeOf<ReturnType<ProjectNamespaceLocalesBilling>>().toEqualTypeOf<BillingNamespace>();
174
- });
93
+ describe("codegen instance + load", () => {
94
+ let tempDir: string;
95
+
96
+ afterEach(() => {
97
+ if (tempDir) {
98
+ rmSync(tempDir, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ it("emits createI18n wired to namespaceLoaders", () => {
103
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-project-locales-"));
104
+ setupMultiCodegenFixture(tempDir);
105
+
106
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
107
+ expect(factory).toContain("export function createI18n(");
108
+ expect(factory).toContain("namespaceLoaders");
109
+ });
110
+
111
+ it("passes tsc when loading via generated namespaceLoaders", () => {
112
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-project-locales-"));
113
+ setupMultiCodegenFixture(tempDir);
114
+
115
+ writeFileSync(
116
+ join(tempDir, "src/hydrate-billing.ts"),
117
+ [
118
+ `import { createI18n } from "./i18n/instance.generated.js";`,
119
+ ``,
120
+ `async function hydrateBilling() {`,
121
+ ` const { t } = await createI18n().load({ namespaces: ["billing"], locale: "en" });`,
122
+ ` return t("billing", "invoice_summary", { count: 1 });`,
123
+ `}`,
124
+ ``,
125
+ `export { hydrateBilling };`,
126
+ ].join("\n")
127
+ );
128
+
129
+ writeTscProject(tempDir);
130
+
131
+ const tsc = runTsc(join(tempDir, "tsconfig.json"));
132
+ expect(tsc.status, `${tsc.stdout}\n${tsc.stderr}`).toBe(0);
175
133
  });
176
134
  });
@@ -105,7 +105,7 @@ describe("read-dictionary", () => {
105
105
  expect(() => readDictionaryFile(jsonPath)).not.toThrow();
106
106
  });
107
107
 
108
- it("compiles yaml sources to json under the generated output directory", () => {
108
+ it("compiles yaml sources into per-locale split files", () => {
109
109
  tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-read-dict-"));
110
110
  mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
111
111
  mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
@@ -121,6 +121,8 @@ describe("read-dictionary", () => {
121
121
  [{ namespace: "billing", filePath: "src/i18n/translations/billing.yaml" }],
122
122
  "src/i18n/generated",
123
123
  {
124
+ delivery: "split-by-locale",
125
+ requestLocales: ["en"],
124
126
  dictionariesByNamespace: {
125
127
  billing: readDictionaryFile(join(tempDir, "src/i18n/translations/billing.yaml")),
126
128
  },
@@ -128,20 +130,24 @@ describe("read-dictionary", () => {
128
130
  );
129
131
 
130
132
  expect(result.resolvedEntries).toEqual([
131
- { namespace: "billing", filePath: "src/i18n/generated/translations/billing.json" },
133
+ { namespace: "billing", filePath: "src/i18n/translations/billing.yaml" },
132
134
  ]);
133
- expect(result.splitPathsByNamespace).toEqual({});
135
+ expect(result.splitPathsByNamespace).toEqual({
136
+ billing: {
137
+ en: "src/i18n/generated/translations/billing.en.json",
138
+ },
139
+ });
134
140
  expect(result.compiledFiles).toEqual([
135
- "src/i18n/translations/billing.yaml → src/i18n/generated/translations/billing.json",
141
+ "src/i18n/translations/billing.yaml → src/i18n/generated/translations/billing.en.json",
136
142
  ]);
137
143
 
138
144
  const compiled = JSON.parse(
139
- readFileSync(join(tempDir, "src/i18n/generated/translations/billing.json"), "utf8")
145
+ readFileSync(join(tempDir, "src/i18n/generated/translations/billing.en.json"), "utf8")
140
146
  ) as Record<string, Record<string, string>>;
141
147
  expect(compiled.invoice_summary?.en).toBe("You have {count} invoices");
142
148
  });
143
149
 
144
- it("keeps json sources unchanged", () => {
150
+ it("splits json sources into per-locale files", () => {
145
151
  tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-read-dict-"));
146
152
  mkdirSync(join(tempDir, "translations"), { recursive: true });
147
153
  writeFileSync(
@@ -154,6 +160,8 @@ describe("read-dictionary", () => {
154
160
  [{ namespace: "default", filePath: "translations/default.json" }],
155
161
  "generated",
156
162
  {
163
+ delivery: "split-by-locale",
164
+ requestLocales: ["en"],
157
165
  dictionariesByNamespace: {
158
166
  default: readDictionaryFile(join(tempDir, "translations/default.json")),
159
167
  },
@@ -163,8 +171,14 @@ describe("read-dictionary", () => {
163
171
  expect(result.resolvedEntries).toEqual([
164
172
  { namespace: "default", filePath: "translations/default.json" },
165
173
  ]);
166
- expect(result.splitPathsByNamespace).toEqual({});
167
- expect(result.compiledFiles).toEqual([]);
174
+ expect(result.splitPathsByNamespace).toEqual({
175
+ default: {
176
+ en: "generated/translations/default.en.json",
177
+ },
178
+ });
179
+ expect(result.compiledFiles).toEqual([
180
+ "translations/default.json → generated/translations/default.en.json",
181
+ ]);
168
182
  });
169
183
 
170
184
  it("splits multiple namespaces into per-locale files", () => {
@@ -405,7 +419,11 @@ describe("read-dictionary", () => {
405
419
  tempDir,
406
420
  [{ namespace: "default", filePath: "translations/default.json" }],
407
421
  "generated",
408
- { dictionariesByNamespace: {} }
422
+ {
423
+ delivery: "split-by-locale",
424
+ requestLocales: ["en"],
425
+ dictionariesByNamespace: {},
426
+ }
409
427
  )
410
428
  ).toThrow('[Codegen Error] Missing parsed dictionary for namespace "default".');
411
429
  });
@@ -142,7 +142,7 @@ function writeCompiledJson(absoluteJsonPath: string, dictionary: DictionaryJson)
142
142
 
143
143
  export interface PrepareDictionaryEntriesOptions {
144
144
  dictionariesByNamespace: Record<string, DictionaryJson>;
145
- delivery?: DeliveryMode;
145
+ delivery: DeliveryMode;
146
146
  localeFallback?: LocaleFallbackMap | undefined;
147
147
  requestLocales?: readonly string[] | undefined;
148
148
  deliveryArtifacts?: DeliveryArtifactsMap | undefined;
@@ -155,8 +155,8 @@ export interface PrepareDictionariesResult {
155
155
  }
156
156
 
157
157
  /**
158
- * Phase 2 of codegen: materialize JSON artifacts on disk (YAML compile, locale/area split).
159
- * Returns `splitPathsByNamespace` consumed by dictionary and namespace-loader emitters.
158
+ * Phase 2 of codegen: materialize JSON artifacts on disk (locale/area split).
159
+ * Returns `splitPathsByNamespace` consumed by namespace-loader emitters.
160
160
  */
161
161
  export function prepareDictionaryEntries(
162
162
  projectRoot: string,
@@ -164,7 +164,7 @@ export function prepareDictionaryEntries(
164
164
  generatedDirRelative: string,
165
165
  options: PrepareDictionaryEntriesOptions
166
166
  ): PrepareDictionariesResult {
167
- const delivery = options.delivery ?? "canonical";
167
+ const delivery = options.delivery;
168
168
  const { dictionariesByNamespace, localeFallback, requestLocales, deliveryArtifacts } = options;
169
169
  const resolvedEntries: NamespaceEntry[] = [];
170
170
  const splitPathsByNamespace: Record<string, Record<string, string>> = {};
@@ -231,50 +231,28 @@ export function prepareDictionaryEntries(
231
231
  continue;
232
232
  }
233
233
 
234
- if (delivery === "custom") {
235
- const splitPaths: Record<string, string> = {};
236
- const dictionariesByArea = splitDictionaryByDeliveryArea(
237
- dictionary,
238
- deliveryArtifacts!,
239
- localeFallback
240
- );
241
-
242
- for (const area of Object.keys(deliveryArtifacts!).sort()) {
243
- const areaRelativePath = resolveAreaJsonPath(entry.filePath, area, generatedDirRelative);
244
- const areaAbsolutePath = path.resolve(projectRoot, areaRelativePath);
245
- const wroteFile = writeCompiledJson(areaAbsolutePath, dictionariesByArea[area]!);
246
-
247
- if (wroteFile) {
248
- compiledFiles.push(
249
- `${path.relative(projectRoot, sourceAbsolutePath)} → ${areaRelativePath}`
250
- );
251
- }
252
-
253
- splitPaths[area] = areaRelativePath;
254
- }
255
-
256
- splitPathsByNamespace[entry.namespace] = splitPaths;
257
- resolvedEntries.push(entry);
258
- continue;
259
- }
234
+ const splitPaths: Record<string, string> = {};
235
+ const dictionariesByArea = splitDictionaryByDeliveryArea(
236
+ dictionary,
237
+ deliveryArtifacts!,
238
+ localeFallback
239
+ );
260
240
 
261
- if (format === "yaml") {
262
- const compiledRelativePath = resolveCompiledJsonPath(entry.filePath, generatedDirRelative);
263
- const compiledAbsolutePath = path.resolve(projectRoot, compiledRelativePath);
264
- const wroteFile = writeCompiledJson(compiledAbsolutePath, dictionary);
241
+ for (const area of Object.keys(deliveryArtifacts!).sort()) {
242
+ const areaRelativePath = resolveAreaJsonPath(entry.filePath, area, generatedDirRelative);
243
+ const areaAbsolutePath = path.resolve(projectRoot, areaRelativePath);
244
+ const wroteFile = writeCompiledJson(areaAbsolutePath, dictionariesByArea[area]!);
265
245
 
266
246
  if (wroteFile) {
267
247
  compiledFiles.push(
268
- `${path.relative(projectRoot, sourceAbsolutePath)} → ${compiledRelativePath}`
248
+ `${path.relative(projectRoot, sourceAbsolutePath)} → ${areaRelativePath}`
269
249
  );
270
250
  }
271
- resolvedEntries.push({
272
- namespace: entry.namespace,
273
- filePath: compiledRelativePath,
274
- });
275
- continue;
251
+
252
+ splitPaths[area] = areaRelativePath;
276
253
  }
277
254
 
255
+ splitPathsByNamespace[entry.namespace] = splitPaths;
278
256
  resolvedEntries.push(entry);
279
257
  }
280
258