@xndrjs/i18n 0.3.3 → 0.5.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +193 -44
  2. package/dist/codegen/index.d.ts +85 -0
  3. package/dist/codegen/index.js +181 -0
  4. package/dist/codegen/index.js.map +1 -0
  5. package/dist/index.d.ts +34 -18
  6. package/dist/index.js +174 -145
  7. package/dist/index.js.map +1 -1
  8. package/dist/validation/index.d.ts +86 -4
  9. package/dist/validation/index.js.map +1 -1
  10. package/package.json +6 -1
  11. package/src/IcuTranslationProviderMulti.test.ts +48 -1
  12. package/src/IcuTranslationProviderMulti.ts +41 -61
  13. package/src/IcuTranslationProviderSingle.test.ts +67 -0
  14. package/src/IcuTranslationProviderSingle.ts +27 -51
  15. package/src/audit/audit-dictionaries.ts +4 -1
  16. package/src/audit/run-audit.test.ts +35 -1
  17. package/src/audit/run-audit.ts +15 -7
  18. package/src/codegen/codegen-config-schema.ts +68 -1
  19. package/src/codegen/config.test.ts +174 -39
  20. package/src/codegen/config.ts +14 -5
  21. package/src/codegen/constants.ts +8 -0
  22. package/src/codegen/delivery-artifacts.test.ts +142 -0
  23. package/src/codegen/delivery-artifacts.ts +124 -0
  24. package/src/codegen/emit/dictionary-file.test.ts +190 -0
  25. package/src/codegen/emit/dictionary-file.ts +165 -2
  26. package/src/codegen/emit/dictionary-schema-file.ts +5 -0
  27. package/src/codegen/emit/instance-file.ts +119 -38
  28. package/src/codegen/emit/namespace-loaders-file.test.ts +176 -0
  29. package/src/codegen/emit/namespace-loaders-file.ts +118 -32
  30. package/src/codegen/emit/types-file.test.ts +114 -0
  31. package/src/codegen/emit/types-file.ts +48 -11
  32. package/src/codegen/generate-i18n-types.test.ts +765 -22
  33. package/src/codegen/generate-i18n-types.ts +111 -58
  34. package/src/codegen/icu-analysis.ts +9 -2
  35. package/src/codegen/locale-fallback.ts +19 -10
  36. package/src/codegen/locale-policy.ts +4 -0
  37. package/src/codegen/paths.ts +9 -5
  38. package/src/codegen/project-locales-set-namespace.test.ts +11 -9
  39. package/src/codegen/read-dictionary.test.ts +474 -2
  40. package/src/codegen/read-dictionary.ts +164 -15
  41. package/src/codegen/write-file-if-changed.test.ts +42 -0
  42. package/src/codegen/write-file-if-changed.ts +20 -0
  43. package/src/codegen-config/build-config.ts +34 -0
  44. package/src/codegen-config/codegen-config.test.ts +32 -0
  45. package/src/codegen-config/index.ts +21 -0
  46. package/src/codegen-config/write-config.ts +8 -0
  47. package/src/deep-freeze.test.ts +13 -0
  48. package/src/deep-freeze.ts +5 -0
  49. package/src/format-core.ts +91 -0
  50. package/src/index.ts +8 -5
  51. package/src/project-locales.test.ts +129 -10
  52. package/src/project-locales.ts +90 -3
  53. package/src/setup/setup-i18n.test.ts +1 -1
  54. package/src/setup/setup-i18n.ts +9 -32
  55. package/src/types.ts +19 -4
  56. package/src/validation/normalize.ts +4 -0
  57. package/dist/types-C1CpXVOJ.d.ts +0 -83
  58. package/src/ensure-namespace.integration.test.ts +0 -66
  59. package/src/ensure-namespace.test.ts +0 -125
  60. package/src/ensure-namespace.ts +0 -70
  61. /package/src/{setup → codegen-config}/type-names.ts +0 -0
@@ -1,8 +1,21 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
+ import { IDENTIFIER_NAME_PATTERN, IDENTIFIER_NAME_REQUIREMENT } from "./constants.js";
5
+ import type { DeliveryMode } from "./codegen-config-schema.js";
6
+ import type { DeliveryArtifactsMap } from "./delivery-artifacts.js";
7
+ import {
8
+ projectNamespaceForDeliveryAreaCore,
9
+ projectNamespaceLocalesCore,
10
+ } from "../project-locales.js";
11
+ import type { LocaleFallbackMap } from "../types.js";
4
12
  import type { DictionaryJson, NamespaceEntry } from "./types.js";
13
+ import { writeFileIfChanged } from "./write-file-if-changed.js";
5
14
 
15
+ /**
16
+ * Dictionary I/O for codegen and audit: read/validate source files (`readDictionaryFile`)
17
+ * and write compiled or split JSON artifacts (`prepareDictionaryEntries`).
18
+ */
6
19
  export const SUPPORTED_DICTIONARY_EXTENSIONS = [".json", ".yaml", ".yml"] as const;
7
20
  export type DictionaryFormat = "json" | "yaml";
8
21
 
@@ -22,12 +35,70 @@ export function resolveCompiledJsonPath(sourcePath: string, generatedDirRelative
22
35
  return path.join(generatedDirRelative, "translations", baseName).replace(/\\/g, "/");
23
36
  }
24
37
 
38
+ export function resolveSplitJsonPath(
39
+ sourcePath: string,
40
+ locale: string,
41
+ generatedDirRelative: string
42
+ ): string {
43
+ const baseName = path.basename(sourcePath, path.extname(sourcePath));
44
+ return path
45
+ .join(generatedDirRelative, "translations", `${baseName}.${locale}.json`)
46
+ .replace(/\\/g, "/");
47
+ }
48
+
49
+ export function resolveAreaJsonPath(
50
+ sourcePath: string,
51
+ area: string,
52
+ generatedDirRelative: string
53
+ ): string {
54
+ const baseName = path.basename(sourcePath, path.extname(sourcePath));
55
+ return path
56
+ .join(generatedDirRelative, "translations", `${baseName}.${area}.json`)
57
+ .replace(/\\/g, "/");
58
+ }
59
+
60
+ /** Projects a canonical dictionary into per-locale slices for split-by-locale delivery. */
61
+ export function splitDictionaryByLocale(
62
+ dictionary: DictionaryJson,
63
+ locales: readonly string[],
64
+ localeFallback?: LocaleFallbackMap
65
+ ): Record<string, DictionaryJson> {
66
+ const byLocale: Record<string, DictionaryJson> = {};
67
+
68
+ for (const locale of locales) {
69
+ byLocale[locale] = projectNamespaceLocalesCore(dictionary, [locale], localeFallback);
70
+ }
71
+
72
+ return byLocale;
73
+ }
74
+
75
+ /** Projects a canonical dictionary into per-area slices for custom delivery. */
76
+ export function splitDictionaryByDeliveryArea(
77
+ dictionary: DictionaryJson,
78
+ deliveryArtifacts: DeliveryArtifactsMap,
79
+ localeFallback?: LocaleFallbackMap
80
+ ): Record<string, DictionaryJson> {
81
+ const byArea: Record<string, DictionaryJson> = {};
82
+
83
+ for (const [area, areaLocales] of Object.entries(deliveryArtifacts)) {
84
+ byArea[area] = projectNamespaceForDeliveryAreaCore(dictionary, areaLocales, localeFallback);
85
+ }
86
+
87
+ return byArea;
88
+ }
89
+
25
90
  function assertDictionaryShape(value: unknown, context: string): asserts value is DictionaryJson {
26
91
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
27
92
  throw new Error(`[Codegen Error] ${context} must be a plain object.`);
28
93
  }
29
94
 
30
95
  for (const [key, localesByKey] of Object.entries(value)) {
96
+ if (!IDENTIFIER_NAME_PATTERN.test(key)) {
97
+ throw new Error(
98
+ `[Codegen Error] ${context}: invalid key "${key}" (${IDENTIFIER_NAME_REQUIREMENT}).`
99
+ );
100
+ }
101
+
31
102
  if (localesByKey === null || typeof localesByKey !== "object" || Array.isArray(localesByKey)) {
32
103
  throw new Error(`[Codegen Error] ${context}: key "${key}" must map locales to strings.`);
33
104
  }
@@ -42,6 +113,7 @@ function assertDictionaryShape(value: unknown, context: string): asserts value i
42
113
  }
43
114
  }
44
115
 
116
+ /** Reads and shape-validates a dictionary file from disk (JSON or YAML). */
45
117
  export function readDictionaryFile(absolutePath: string): DictionaryJson {
46
118
  const format = getDictionaryFormat(absolutePath);
47
119
  if (!format) {
@@ -65,32 +137,52 @@ export function readDictionaryFile(absolutePath: string): DictionaryJson {
65
137
  }
66
138
 
67
139
  function writeCompiledJson(absoluteJsonPath: string, dictionary: DictionaryJson): boolean {
68
- const nextContent = `${JSON.stringify(dictionary, null, 2)}\n`;
69
- if (fs.existsSync(absoluteJsonPath)) {
70
- const currentContent = fs.readFileSync(absoluteJsonPath, "utf8");
71
- if (currentContent === nextContent) {
72
- return false;
73
- }
74
- }
140
+ return writeFileIfChanged(absoluteJsonPath, `${JSON.stringify(dictionary, null, 2)}\n`);
141
+ }
75
142
 
76
- fs.mkdirSync(path.dirname(absoluteJsonPath), { recursive: true });
77
- fs.writeFileSync(absoluteJsonPath, nextContent);
78
- return true;
143
+ export interface PrepareDictionaryEntriesOptions {
144
+ dictionariesByNamespace: Record<string, DictionaryJson>;
145
+ delivery?: DeliveryMode;
146
+ localeFallback?: LocaleFallbackMap | undefined;
147
+ requestLocales?: readonly string[] | undefined;
148
+ deliveryArtifacts?: DeliveryArtifactsMap | undefined;
79
149
  }
80
150
 
81
151
  export interface PrepareDictionariesResult {
82
152
  resolvedEntries: NamespaceEntry[];
153
+ splitPathsByNamespace: Record<string, Record<string, string>>;
83
154
  compiledFiles: string[];
84
155
  }
85
156
 
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.
160
+ */
86
161
  export function prepareDictionaryEntries(
87
162
  projectRoot: string,
88
163
  entries: NamespaceEntry[],
89
- generatedDirRelative: string
164
+ generatedDirRelative: string,
165
+ options: PrepareDictionaryEntriesOptions
90
166
  ): PrepareDictionariesResult {
167
+ const delivery = options.delivery ?? "canonical";
168
+ const { dictionariesByNamespace, localeFallback, requestLocales, deliveryArtifacts } = options;
91
169
  const resolvedEntries: NamespaceEntry[] = [];
170
+ const splitPathsByNamespace: Record<string, Record<string, string>> = {};
92
171
  const compiledFiles: string[] = [];
93
172
 
173
+ if (delivery === "split-by-locale" && (!requestLocales || requestLocales.length === 0)) {
174
+ throw new Error(
175
+ "[Codegen Error] split-by-locale delivery requires at least one request locale."
176
+ );
177
+ }
178
+
179
+ if (
180
+ delivery === "custom" &&
181
+ (!deliveryArtifacts || Object.keys(deliveryArtifacts).length === 0)
182
+ ) {
183
+ throw new Error("[Codegen Error] custom delivery requires deliveryArtifacts.");
184
+ }
185
+
94
186
  for (const entry of entries) {
95
187
  const sourceAbsolutePath = path.resolve(projectRoot, entry.filePath);
96
188
  const format = getDictionaryFormat(entry.filePath);
@@ -101,13 +193,70 @@ export function prepareDictionaryEntries(
101
193
  );
102
194
  }
103
195
 
104
- if (!fs.existsSync(sourceAbsolutePath)) {
196
+ const dictionary = dictionariesByNamespace[entry.namespace];
197
+ if (!dictionary) {
105
198
  throw new Error(
106
- `[Codegen Error] Dictionary file not found for namespace "${entry.namespace}": ${sourceAbsolutePath}`
199
+ `[Codegen Error] Missing parsed dictionary for namespace "${entry.namespace}".`
107
200
  );
108
201
  }
109
202
 
110
- const dictionary = readDictionaryFile(sourceAbsolutePath);
203
+ if (delivery === "split-by-locale") {
204
+ const splitPaths: Record<string, string> = {};
205
+ const dictionariesByLocale = splitDictionaryByLocale(
206
+ dictionary,
207
+ requestLocales!,
208
+ localeFallback
209
+ );
210
+
211
+ for (const locale of requestLocales!) {
212
+ const splitRelativePath = resolveSplitJsonPath(
213
+ entry.filePath,
214
+ locale,
215
+ generatedDirRelative
216
+ );
217
+ const splitAbsolutePath = path.resolve(projectRoot, splitRelativePath);
218
+ const wroteFile = writeCompiledJson(splitAbsolutePath, dictionariesByLocale[locale]!);
219
+
220
+ if (wroteFile) {
221
+ compiledFiles.push(
222
+ `${path.relative(projectRoot, sourceAbsolutePath)} → ${splitRelativePath}`
223
+ );
224
+ }
225
+
226
+ splitPaths[locale] = splitRelativePath;
227
+ }
228
+
229
+ splitPathsByNamespace[entry.namespace] = splitPaths;
230
+ resolvedEntries.push(entry);
231
+ continue;
232
+ }
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
+ }
111
260
 
112
261
  if (format === "yaml") {
113
262
  const compiledRelativePath = resolveCompiledJsonPath(entry.filePath, generatedDirRelative);
@@ -129,5 +278,5 @@ export function prepareDictionaryEntries(
129
278
  resolvedEntries.push(entry);
130
279
  }
131
280
 
132
- return { resolvedEntries, compiledFiles };
281
+ return { resolvedEntries, splitPathsByNamespace, compiledFiles };
133
282
  }
@@ -0,0 +1,42 @@
1
+ import { mkdtempSync, readFileSync, rmSync, statSync, 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 { writeFileIfChanged } from "./write-file-if-changed.js";
6
+
7
+ describe("writeFileIfChanged", () => {
8
+ let tempDir: string;
9
+
10
+ afterEach(() => {
11
+ if (tempDir) {
12
+ rmSync(tempDir, { recursive: true, force: true });
13
+ }
14
+ });
15
+
16
+ it("creates missing files together with their parent directories", () => {
17
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-write-"));
18
+ const filePath = join(tempDir, "nested/dir/output.ts");
19
+
20
+ expect(writeFileIfChanged(filePath, "export {};\n")).toBe(true);
21
+ expect(readFileSync(filePath, "utf8")).toBe("export {};\n");
22
+ });
23
+
24
+ it("skips the write when content is unchanged", () => {
25
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-write-"));
26
+ const filePath = join(tempDir, "output.ts");
27
+ writeFileSync(filePath, "export {};\n");
28
+ const mtimeBefore = statSync(filePath).mtimeMs;
29
+
30
+ expect(writeFileIfChanged(filePath, "export {};\n")).toBe(false);
31
+ expect(statSync(filePath).mtimeMs).toBe(mtimeBefore);
32
+ });
33
+
34
+ it("overwrites the file when content differs", () => {
35
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-write-"));
36
+ const filePath = join(tempDir, "output.ts");
37
+ writeFileSync(filePath, "export const a = 1;\n");
38
+
39
+ expect(writeFileIfChanged(filePath, "export const a = 2;\n")).toBe(true);
40
+ expect(readFileSync(filePath, "utf8")).toBe("export const a = 2;\n");
41
+ });
42
+ });
@@ -0,0 +1,20 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Writes `content` to `absolutePath` only when it differs from the current
6
+ * file content, keeping mtimes stable for downstream watchers/bundlers.
7
+ * Returns true when the file was actually written.
8
+ */
9
+ export function writeFileIfChanged(absolutePath: string, content: string): boolean {
10
+ if (fs.existsSync(absolutePath)) {
11
+ const currentContent = fs.readFileSync(absolutePath, "utf8");
12
+ if (currentContent === content) {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
18
+ fs.writeFileSync(absolutePath, content);
19
+ return true;
20
+ }
@@ -0,0 +1,34 @@
1
+ import type { CodegenConfigInput } from "../codegen/codegen-config-schema.js";
2
+ import { typeNamesForProject } from "./type-names.js";
3
+
4
+ export type SetupMode = "single" | "multi";
5
+
6
+ const GENERATED_DIR = "generated";
7
+ const TRANSLATIONS_DIR = "translations";
8
+
9
+ export function buildCodegenConfig(mode: SetupMode, project: string): CodegenConfigInput {
10
+ const typeNames = typeNamesForProject(project);
11
+ const base = {
12
+ typesOutput: `${GENERATED_DIR}/i18n-types.generated.ts`,
13
+ dictionaryOutput: `${GENERATED_DIR}/dictionary.generated.ts`,
14
+ instanceOutput: `${GENERATED_DIR}/instance.generated.ts`,
15
+ paramsTypeName: typeNames.paramsTypeName,
16
+ schemaTypeName: typeNames.schemaTypeName,
17
+ localeTypeName: typeNames.localeTypeName,
18
+ factoryName: "createI18n",
19
+ } satisfies CodegenConfigInput;
20
+
21
+ if (mode === "single") {
22
+ return {
23
+ dictionary: `${TRANSLATIONS_DIR}/translations.json`,
24
+ ...base,
25
+ };
26
+ }
27
+
28
+ return {
29
+ namespaces: {
30
+ default: `${TRANSLATIONS_DIR}/default.json`,
31
+ },
32
+ ...base,
33
+ };
34
+ }
@@ -0,0 +1,32 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it, afterEach } from "vitest";
5
+ import { buildCodegenConfig, type CodegenConfigInput, writeCodegenConfig } from "./index.js";
6
+
7
+ describe("codegen-config", () => {
8
+ let tempDir: string;
9
+
10
+ afterEach(() => {
11
+ if (tempDir) {
12
+ rmSync(tempDir, { recursive: true, force: true });
13
+ }
14
+ });
15
+
16
+ it("buildCodegenConfig returns a valid single-mode input shape", () => {
17
+ const config = buildCodegenConfig("single", "MyApp");
18
+ expect(config.dictionary).toBe("translations/translations.json");
19
+ expect(config.paramsTypeName).toBe("MyAppParams");
20
+ expect(config).not.toHaveProperty("namespaces");
21
+ });
22
+
23
+ it("writeCodegenConfig writes formatted JSON", () => {
24
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-config-"));
25
+ const configPath = join(tempDir, "i18n/i18n.codegen.json");
26
+ const config: CodegenConfigInput = buildCodegenConfig("multi", "Demo");
27
+
28
+ writeCodegenConfig(configPath, config);
29
+
30
+ expect(readFileSync(configPath, "utf8")).toBe(`${JSON.stringify(config, null, 2)}\n`);
31
+ });
32
+ });
@@ -0,0 +1,21 @@
1
+ export type {
2
+ CodegenConfig,
3
+ CodegenConfigInput,
4
+ DeliveryMode,
5
+ } from "../codegen/codegen-config-schema.js";
6
+ export {
7
+ codegenConfigKeys,
8
+ DELIVERY_MODES,
9
+ resolveDeliveryOutputDir,
10
+ } from "../codegen/codegen-config-schema.js";
11
+
12
+ export type { DeliveryArtifactsMap } from "../codegen/delivery-artifacts.js";
13
+
14
+ export {
15
+ SUPPORTED_IMPORT_EXTENSIONS,
16
+ type SupportedImportExtension,
17
+ } from "../codegen/constants.js";
18
+
19
+ export { buildCodegenConfig, type SetupMode } from "./build-config.js";
20
+ export { inferProjectName, typeNamesForProject } from "./type-names.js";
21
+ export { writeCodegenConfig } from "./write-config.js";
@@ -0,0 +1,8 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { CodegenConfigInput } from "../codegen/codegen-config-schema.js";
4
+
5
+ export function writeCodegenConfig(configPath: string, config: CodegenConfigInput): void {
6
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
7
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
8
+ }
@@ -12,6 +12,19 @@ describe("deepFreeze", () => {
12
12
  value.welcome.en = "Hacked";
13
13
  }).toThrow(TypeError);
14
14
  });
15
+
16
+ it("does not recurse forever on cyclic structures", () => {
17
+ const value: { name: string; self?: unknown; child: { parent?: unknown } } = {
18
+ name: "root",
19
+ child: {},
20
+ };
21
+ value.self = value;
22
+ value.child.parent = value;
23
+
24
+ expect(() => deepFreeze(value)).not.toThrow();
25
+ expect(Object.isFrozen(value)).toBe(true);
26
+ expect(Object.isFrozen(value.child)).toBe(true);
27
+ });
15
28
  });
16
29
 
17
30
  describe("cloneAndFreeze", () => {
@@ -3,6 +3,11 @@ export function deepFreeze<T>(value: T): T {
3
3
  return value;
4
4
  }
5
5
 
6
+ // Guards against infinite recursion on cyclic structures.
7
+ if (Object.isFrozen(value)) {
8
+ return value;
9
+ }
10
+
6
11
  Object.freeze(value);
7
12
 
8
13
  for (const child of Object.values(value)) {
@@ -0,0 +1,91 @@
1
+ import { IntlMessageFormat } from "intl-messageformat";
2
+ import { formatLocaleFallbackChain, resolveLocaleTemplate } from "./resolve-locale.js";
3
+ import type { LocaleCache, LocaleFallbackMap, OnMissingTranslation } from "./types.js";
4
+
5
+ export type FormatCoreContext = {
6
+ key: string;
7
+ namespace?: string | undefined;
8
+ locale: string;
9
+ localeFallback?: LocaleFallbackMap | undefined;
10
+ onMissing: OnMissingTranslation;
11
+ };
12
+
13
+ export type ResolveAndFormatOptions = {
14
+ localeByKey: Record<string, string | undefined> | undefined;
15
+ locale: string;
16
+ params?: Record<string, unknown> | undefined;
17
+ getCache: (resolvedLocale: string) => LocaleCache;
18
+ context: FormatCoreContext;
19
+ };
20
+
21
+ function formatErrorLabel(context: Pick<FormatCoreContext, "key" | "namespace">): string {
22
+ if (context.namespace !== undefined) {
23
+ return `namespace "${context.namespace}", key "${context.key}"`;
24
+ }
25
+ return `key "${context.key}"`;
26
+ }
27
+
28
+ function formatMissingError(context: FormatCoreContext): string {
29
+ const chain = formatLocaleFallbackChain(context.locale, context.localeFallback);
30
+ if (context.namespace !== undefined) {
31
+ return `[i18n] Missing key or locale: namespace "${context.namespace}", key "${context.key}" [${context.locale}] (fallback chain: ${chain})`;
32
+ }
33
+ return `[i18n] Missing key or locale: "${context.key}" [${context.locale}] (fallback chain: ${chain})`;
34
+ }
35
+
36
+ function resolveMissing(context: FormatCoreContext): string {
37
+ const chain = formatLocaleFallbackChain(context.locale, context.localeFallback);
38
+ if (context.onMissing === "key") {
39
+ if (context.namespace !== undefined) {
40
+ return `${context.namespace}.${context.key}`;
41
+ }
42
+ return context.key;
43
+ }
44
+ if (typeof context.onMissing === "function") {
45
+ return context.onMissing({
46
+ ...(context.namespace !== undefined ? { namespace: context.namespace } : {}),
47
+ key: context.key,
48
+ locale: context.locale,
49
+ fallbackChain: chain,
50
+ });
51
+ }
52
+ throw new Error(formatMissingError(context));
53
+ }
54
+
55
+ /**
56
+ * Shared runtime path for both ICU providers: resolve locale (with fallback) →
57
+ * compile template (cached) → format with params; handles missing keys via `onMissing`.
58
+ */
59
+ export function resolveAndFormat(options: ResolveAndFormatOptions): string {
60
+ const { localeByKey, locale, params, getCache, context } = options;
61
+ const resolved = resolveLocaleTemplate(localeByKey, locale, context.localeFallback);
62
+
63
+ if (!resolved) {
64
+ return resolveMissing(context);
65
+ }
66
+
67
+ const { template, resolvedLocale } = resolved;
68
+ const cache = getCache(resolvedLocale);
69
+ const cacheKey = context.key;
70
+ const label = formatErrorLabel(context);
71
+
72
+ if (!cache[cacheKey]) {
73
+ try {
74
+ cache[cacheKey] = new IntlMessageFormat(template, resolvedLocale);
75
+ } catch (err) {
76
+ const message = err instanceof Error ? err.message : String(err);
77
+ throw new Error(
78
+ `[i18n ICU Syntax Error] Dictionary error for ${label} [${resolvedLocale}]: ${message}`
79
+ );
80
+ }
81
+ }
82
+
83
+ try {
84
+ return cache[cacheKey].format(params) as string;
85
+ } catch (err) {
86
+ const message = err instanceof Error ? err.message : String(err);
87
+ throw new Error(
88
+ `[i18n Formatting Error] Invalid or missing parameters for ${label} [${resolvedLocale}]: ${message}`
89
+ );
90
+ }
91
+ }
package/src/index.ts CHANGED
@@ -17,8 +17,9 @@ export type {
17
17
  LocaleFallbackMap,
18
18
  LocaleOfMulti,
19
19
  LocaleOfSingle,
20
+ MissingTranslationContext,
20
21
  MultiDictionary,
21
- RequestLocale,
22
+ OnMissingTranslation,
22
23
  } from "./types.js";
23
24
 
24
25
  export {
@@ -28,7 +29,9 @@ export {
28
29
  } from "./resolve-locale.js";
29
30
  export type { ResolvedLocaleTemplate } from "./resolve-locale.js";
30
31
 
31
- export { projectLocales, projectNamespacesLocales } from "./project-locales.js";
32
-
33
- export { ensureNamespacesLoadedImpl } from "./ensure-namespace.js";
34
- export type { EnsureNamespacesLoadedOptions } from "./ensure-namespace.js";
32
+ export {
33
+ projectDictionaryForDeliveryAreaCore,
34
+ projectDictionaryLocalesCore,
35
+ projectNamespaceForDeliveryAreaCore,
36
+ projectNamespaceLocalesCore,
37
+ } from "./project-locales.js";