@xndrjs/i18n 0.4.0 → 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 (57) hide show
  1. package/README.md +156 -29
  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 -5
  6. package/dist/index.js +175 -74
  7. package/dist/index.js.map +1 -1
  8. package/dist/validation/index.d.ts +4 -0
  9. package/dist/validation/index.js.map +1 -1
  10. package/package.json +6 -1
  11. package/src/IcuTranslationProviderMulti.test.ts +47 -0
  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 +163 -0
  26. package/src/codegen/emit/dictionary-schema-file.ts +5 -0
  27. package/src/codegen/emit/instance-file.ts +114 -28
  28. package/src/codegen/emit/namespace-loaders-file.test.ts +176 -0
  29. package/src/codegen/emit/namespace-loaders-file.ts +114 -6
  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 +754 -16
  33. package/src/codegen/generate-i18n-types.ts +111 -47
  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 +5 -5
  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 -2
  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 +6 -30
  55. package/src/types.ts +19 -4
  56. package/src/validation/normalize.ts +4 -0
  57. /package/src/{setup → codegen-config}/type-names.ts +0 -0
@@ -1,7 +1,12 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { projectLocales, projectNamespacesLocales } from "./project-locales.js";
2
+ import {
3
+ projectDictionaryForDeliveryAreaCore,
4
+ projectDictionaryLocalesCore,
5
+ projectNamespaceForDeliveryAreaCore,
6
+ projectNamespaceLocalesCore,
7
+ } from "./project-locales.js";
3
8
 
4
- describe("projectLocales", () => {
9
+ describe("projectNamespaceLocalesCore", () => {
5
10
  const dictionary = {
6
11
  login_button: { en: "Login", it: "Accedi" },
7
12
  welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
@@ -16,7 +21,7 @@ describe("projectLocales", () => {
16
21
  } as const;
17
22
 
18
23
  it("keeps only the requested locales with direct templates", () => {
19
- expect(projectLocales(dictionary, ["en", "it"])).toEqual({
24
+ expect(projectNamespaceLocalesCore(dictionary, ["en", "it"])).toEqual({
20
25
  login_button: { en: "Login", it: "Accedi" },
21
26
  welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
22
27
  empty_label: { en: "" },
@@ -24,7 +29,7 @@ describe("projectLocales", () => {
24
29
  });
25
30
 
26
31
  it("drops locales not listed in the projection", () => {
27
- expect(projectLocales(dictionary, ["en"])).toEqual({
32
+ expect(projectNamespaceLocalesCore(dictionary, ["en"])).toEqual({
28
33
  login_button: { en: "Login" },
29
34
  welcome: { en: "Welcome {name}!" },
30
35
  empty_label: { en: "" },
@@ -32,7 +37,7 @@ describe("projectLocales", () => {
32
37
  });
33
38
 
34
39
  it("resolves fallback templates for missing direct locale entries", () => {
35
- expect(projectLocales(dictionary, ["de-CH"], fallback)).toEqual({
40
+ expect(projectNamespaceLocalesCore(dictionary, ["de-CH"], fallback)).toEqual({
36
41
  login_button: { "de-CH": "Login" },
37
42
  welcome: { "de-CH": "Welcome {name}!" },
38
43
  empty_label: { "de-CH": "" },
@@ -40,7 +45,7 @@ describe("projectLocales", () => {
40
45
  });
41
46
 
42
47
  it("projects multiple locales with mixed direct and fallback resolution", () => {
43
- expect(projectLocales(dictionary, ["it", "de-CH"], fallback)).toEqual({
48
+ expect(projectNamespaceLocalesCore(dictionary, ["it", "de-CH"], fallback)).toEqual({
44
49
  login_button: { it: "Accedi", "de-CH": "Login" },
45
50
  welcome: { it: "Benvenuto {name}!", "de-CH": "Welcome {name}!" },
46
51
  empty_label: { it: "", "de-CH": "" },
@@ -52,13 +57,13 @@ describe("projectLocales", () => {
52
57
  login_button: { it: "Accedi" },
53
58
  };
54
59
 
55
- expect(projectLocales(partial, ["en", "it"], fallback)).toEqual({
60
+ expect(projectNamespaceLocalesCore(partial, ["en", "it"], fallback)).toEqual({
56
61
  login_button: { it: "Accedi" },
57
62
  });
58
63
  });
59
64
 
60
65
  it("deduplicates locales in the projection list", () => {
61
- expect(projectLocales(dictionary, ["en", "en"])).toEqual({
66
+ expect(projectNamespaceLocalesCore(dictionary, ["en", "en"])).toEqual({
62
67
  login_button: { en: "Login" },
63
68
  welcome: { en: "Welcome {name}!" },
64
69
  empty_label: { en: "" },
@@ -66,7 +71,121 @@ describe("projectLocales", () => {
66
71
  });
67
72
  });
68
73
 
69
- describe("projectNamespacesLocales", () => {
74
+ describe("projectNamespaceForDeliveryAreaCore", () => {
75
+ const namespace = {
76
+ some_key: { it: "Ciao", "it-CH": "Ciao CH", fr: "Hallo", "en-US": "Hello" },
77
+ some_other_key: { "en-US": "Computer", fr: "Ordinateur" },
78
+ };
79
+
80
+ const localeFallback = {
81
+ "en-US": null,
82
+ it: "en-US",
83
+ "it-CH": "it",
84
+ fr: null,
85
+ } as const;
86
+
87
+ it("projects eu area with fallback resolution for full locales", () => {
88
+ expect(
89
+ projectNamespaceForDeliveryAreaCore(namespace, ["it", "fr", "it-CH"], localeFallback)
90
+ ).toEqual({
91
+ some_key: { it: "Ciao", fr: "Hallo", "it-CH": "Ciao CH" },
92
+ // it-CH is preserve (fallback it is in the area): copied only when present in the
93
+ // canonical dict; "it" is full (fallback en-US is outside the area) and resolves here.
94
+ some_other_key: { it: "Computer", fr: "Ordinateur" },
95
+ });
96
+ });
97
+
98
+ it("projects us area with single full locale", () => {
99
+ expect(projectNamespaceForDeliveryAreaCore(namespace, ["en-US"], localeFallback)).toEqual({
100
+ some_key: { "en-US": "Hello" },
101
+ some_other_key: { "en-US": "Computer" },
102
+ });
103
+ });
104
+
105
+ it("preserves canonical entries for in-area fallback locales without resolving", () => {
106
+ const withInAreaFallback = {
107
+ some_key: { it: "Ciao", "it-CH": "Ciao CH", fr: "Hallo", "en-US": "Hello" },
108
+ some_other_key: {
109
+ it: "Italiano",
110
+ "it-CH": "Svizzero",
111
+ "en-US": "Computer",
112
+ fr: "Ordinateur",
113
+ },
114
+ };
115
+
116
+ expect(
117
+ projectNamespaceForDeliveryAreaCore(
118
+ withInAreaFallback,
119
+ ["it", "it-CH", "en-US", "fr"],
120
+ localeFallback
121
+ )
122
+ ).toEqual({
123
+ some_key: { it: "Ciao", "it-CH": "Ciao CH", "en-US": "Hello", fr: "Hallo" },
124
+ some_other_key: {
125
+ it: "Italiano",
126
+ "it-CH": "Svizzero",
127
+ "en-US": "Computer",
128
+ fr: "Ordinateur",
129
+ },
130
+ });
131
+ });
132
+ });
133
+
134
+ describe("projectDictionaryForDeliveryAreaCore", () => {
135
+ const localeFallback = {
136
+ "en-US": null,
137
+ it: "en-US",
138
+ "it-CH": "it",
139
+ fr: null,
140
+ } as const;
141
+
142
+ it("projects each namespace independently for a multi-namespace dictionary", () => {
143
+ const dictionary = {
144
+ default: {
145
+ some_key: { it: "Ciao", "it-CH": "Ciao CH", fr: "Hallo", "en-US": "Hello" },
146
+ some_other_key: { "en-US": "Computer", fr: "Ordinateur" },
147
+ },
148
+ billing: {
149
+ invoice_summary: {
150
+ it: "{count} fatture",
151
+ "en-US": "{count} invoices",
152
+ fr: "{count} factures",
153
+ },
154
+ },
155
+ };
156
+
157
+ expect(
158
+ projectDictionaryForDeliveryAreaCore(dictionary, ["it", "fr", "it-CH"], localeFallback)
159
+ ).toEqual({
160
+ default: {
161
+ some_key: { it: "Ciao", fr: "Hallo", "it-CH": "Ciao CH" },
162
+ some_other_key: { it: "Computer", fr: "Ordinateur" },
163
+ },
164
+ billing: {
165
+ invoice_summary: { it: "{count} fatture", fr: "{count} factures" },
166
+ },
167
+ });
168
+ });
169
+
170
+ it("matches projectNamespaceForDeliveryAreaCore applied per namespace", () => {
171
+ const dictionary = {
172
+ default: {
173
+ some_key: { it: "Ciao", "en-US": "Hello" },
174
+ },
175
+ billing: {
176
+ invoice_summary: { it: "{count} fatture", "en-US": "{count} invoices" },
177
+ },
178
+ };
179
+ const areaLocales = ["en-US"] as const;
180
+
181
+ expect(projectDictionaryForDeliveryAreaCore(dictionary, areaLocales, localeFallback)).toEqual({
182
+ default: projectNamespaceForDeliveryAreaCore(dictionary.default, areaLocales, localeFallback),
183
+ billing: projectNamespaceForDeliveryAreaCore(dictionary.billing, areaLocales, localeFallback),
184
+ });
185
+ });
186
+ });
187
+
188
+ describe("projectDictionaryLocalesCore", () => {
70
189
  it("projects each namespace independently", () => {
71
190
  const dictionary = {
72
191
  default: {
@@ -77,7 +196,7 @@ describe("projectNamespacesLocales", () => {
77
196
  },
78
197
  };
79
198
 
80
- expect(projectNamespacesLocales(dictionary, ["en"])).toEqual({
199
+ expect(projectDictionaryLocalesCore(dictionary, ["en"])).toEqual({
81
200
  default: { login_button: { en: "Login" } },
82
201
  billing: { invoice_summary: { en: "{count} invoices" } },
83
202
  });
@@ -1,6 +1,26 @@
1
1
  import { resolveLocaleTemplate } from "./resolve-locale.js";
2
2
  import type { KeyDictionary, LocaleFallbackMap, MultiDictionary } from "./types.js";
3
3
 
4
+ function classifyAreaLocales(
5
+ areaLocales: readonly string[],
6
+ localeFallback?: LocaleFallbackMap
7
+ ): { full: string[]; preserve: string[] } {
8
+ const areaSet = new Set(areaLocales);
9
+ const full: string[] = [];
10
+ const preserve: string[] = [];
11
+
12
+ for (const locale of areaLocales) {
13
+ const fallback = localeFallback?.[locale];
14
+ if (fallback !== null && fallback !== undefined && areaSet.has(fallback)) {
15
+ preserve.push(locale);
16
+ } else {
17
+ full.push(locale);
18
+ }
19
+ }
20
+
21
+ return { full, preserve };
22
+ }
23
+
4
24
  function projectKeyDictionary<T extends KeyDictionary>(
5
25
  dictionary: T,
6
26
  locales: readonly string[],
@@ -28,11 +48,11 @@ function projectKeyDictionary<T extends KeyDictionary>(
28
48
  }
29
49
 
30
50
  /**
31
- * Builds a dictionary that contains only the requested locales.
51
+ * Builds a namespace dictionary that contains only the requested locales.
32
52
  * For each key and target locale, copies the direct template when present;
33
53
  * otherwise resolves through `localeFallback` (same rules as `.get()` at runtime).
34
54
  */
35
- export function projectLocales<T extends KeyDictionary>(
55
+ export function projectNamespaceLocalesCore<T extends KeyDictionary>(
36
56
  dictionary: T,
37
57
  locales: readonly string[],
38
58
  localeFallback?: LocaleFallbackMap
@@ -40,10 +60,77 @@ export function projectLocales<T extends KeyDictionary>(
40
60
  return projectKeyDictionary(dictionary, locales, localeFallback);
41
61
  }
42
62
 
63
+ /**
64
+ * Projects a single namespace for a custom delivery area.
65
+ * Full locales resolve through `localeFallback` (e.g. external chains like `it → en-US`);
66
+ * preserve locales copy only their direct canonical entry when present.
67
+ */
68
+ export function projectNamespaceForDeliveryAreaCore<T extends KeyDictionary>(
69
+ dictionary: T,
70
+ areaLocales: readonly string[],
71
+ localeFallback?: LocaleFallbackMap
72
+ ): T {
73
+ const { full, preserve } = classifyAreaLocales(areaLocales, localeFallback);
74
+ const fullProjection =
75
+ full.length > 0 ? projectKeyDictionary(dictionary, full, localeFallback) : undefined;
76
+ const result = {} as T;
77
+
78
+ for (const [key, localesByKey] of Object.entries(dictionary)) {
79
+ const projected: Record<string, string> = {};
80
+
81
+ if (fullProjection !== undefined) {
82
+ const fullEntry = fullProjection[key as keyof T];
83
+ if (fullEntry !== undefined) {
84
+ Object.assign(projected, fullEntry);
85
+ }
86
+ }
87
+
88
+ for (const locale of preserve) {
89
+ const template = localesByKey[locale];
90
+ if (template !== undefined) {
91
+ projected[locale] = template;
92
+ }
93
+ }
94
+
95
+ if (Object.keys(projected).length > 0) {
96
+ result[key as keyof T] = projected as T[keyof T];
97
+ }
98
+ }
99
+
100
+ return result;
101
+ }
102
+
103
+ /**
104
+ * Projects a multi-namespace dictionary for a custom delivery area.
105
+ * Each namespace uses the same delivery-area rules as `projectNamespaceForDeliveryAreaCore`.
106
+ */
107
+ export function projectDictionaryForDeliveryAreaCore<T extends MultiDictionary>(
108
+ dictionary: T,
109
+ areaLocales: readonly string[],
110
+ localeFallback?: LocaleFallbackMap
111
+ ): T {
112
+ const result = {} as T;
113
+
114
+ for (const namespace of Object.keys(dictionary) as (keyof T & string)[]) {
115
+ const namespaceDictionary = dictionary[namespace];
116
+ if (namespaceDictionary === undefined) {
117
+ continue;
118
+ }
119
+
120
+ result[namespace] = projectNamespaceForDeliveryAreaCore(
121
+ namespaceDictionary,
122
+ areaLocales,
123
+ localeFallback
124
+ ) as T[typeof namespace];
125
+ }
126
+
127
+ return result;
128
+ }
129
+
43
130
  /**
44
131
  * Projects every namespace in a multi-namespace dictionary.
45
132
  */
46
- export function projectNamespacesLocales<T extends MultiDictionary>(
133
+ export function projectDictionaryLocalesCore<T extends MultiDictionary>(
47
134
  dictionary: T,
48
135
  locales: readonly string[],
49
136
  localeFallback?: LocaleFallbackMap
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { describe, expect, it, afterEach } from "vitest";
5
5
  import { buildCodegenConfig, parseSetupArgs, runSetup } from "./setup-i18n.js";
6
- import { inferProjectName, typeNamesForProject } from "./type-names.js";
6
+ import { inferProjectName, typeNamesForProject } from "../codegen-config/type-names.js";
7
7
 
8
8
  describe("type-names", () => {
9
9
  it("derives PascalCase project names from directory names", () => {
@@ -1,10 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { buildCodegenConfig, type SetupMode } from "../codegen-config/build-config.js";
5
+ import { inferProjectName } from "../codegen-config/type-names.js";
6
+ import { writeCodegenConfig } from "../codegen-config/write-config.js";
4
7
  import { DEFAULT_IMPORT_EXTENSION, importExtensionSuffix } from "../codegen/paths.js";
5
- import { inferProjectName, typeNamesForProject } from "./type-names.js";
6
8
 
7
- export type SetupMode = "single" | "multi";
9
+ export type { SetupMode } from "../codegen-config/build-config.js";
10
+ export { buildCodegenConfig } from "../codegen-config/build-config.js";
8
11
 
9
12
  export interface SetupOptions {
10
13
  mode: SetupMode;
@@ -53,33 +56,6 @@ function relative(fromRoot: string, filePath: string): string {
53
56
  return path.relative(fromRoot, filePath).replace(/\\/g, "/");
54
57
  }
55
58
 
56
- export function buildCodegenConfig(mode: SetupMode, project: string): Record<string, unknown> {
57
- const typeNames = typeNamesForProject(project);
58
- const base = {
59
- typesOutput: `${GENERATED_DIR}/i18n-types.generated.ts`,
60
- dictionaryOutput: `${GENERATED_DIR}/dictionary.generated.ts`,
61
- instanceOutput: `${GENERATED_DIR}/instance.generated.ts`,
62
- paramsTypeName: typeNames.paramsTypeName,
63
- schemaTypeName: typeNames.schemaTypeName,
64
- localeTypeName: typeNames.localeTypeName,
65
- factoryName: "createI18n",
66
- };
67
-
68
- if (mode === "single") {
69
- return {
70
- dictionary: `${TRANSLATIONS_DIR}/translations.json`,
71
- ...base,
72
- };
73
- }
74
-
75
- return {
76
- namespaces: {
77
- default: `${TRANSLATIONS_DIR}/default.json`,
78
- },
79
- ...base,
80
- };
81
- }
82
-
83
59
  export function runSetup(options: SetupOptions): SetupResult {
84
60
  const targetDir = path.resolve(options.targetDir);
85
61
  const project =
@@ -100,7 +76,7 @@ export function runSetup(options: SetupOptions): SetupResult {
100
76
 
101
77
  const created: string[] = [];
102
78
 
103
- writeJson(configPath, buildCodegenConfig(options.mode, project));
79
+ writeCodegenConfig(configPath, buildCodegenConfig(options.mode, project));
104
80
  created.push(relative(targetDir, configPath));
105
81
 
106
82
  if (options.mode === "single") {
package/src/types.ts CHANGED
@@ -18,15 +18,30 @@ export type LocaleOfMulti<Schema extends MultiDictionary> = {
18
18
 
19
19
  export type LocaleFallbackMap = Record<string, string | null>;
20
20
 
21
- export type RequestLocale<
22
- DictionaryLocale extends string,
23
- Fallback extends LocaleFallbackMap | undefined = undefined,
24
- > = DictionaryLocale | (Fallback extends LocaleFallbackMap ? keyof Fallback & string : never);
21
+ export type MissingTranslationContext = {
22
+ /** Only set by the multi-namespace provider. */
23
+ namespace?: string;
24
+ key: string;
25
+ locale: string;
26
+ fallbackChain: string;
27
+ };
28
+
29
+ export type OnMissingTranslation =
30
+ | "throw"
31
+ | "key"
32
+ | ((context: MissingTranslationContext) => string);
25
33
 
26
34
  export type IcuTranslationProviderOptions<
27
35
  Fallback extends LocaleFallbackMap | undefined = undefined,
28
36
  > = {
29
37
  localeFallback?: Fallback;
38
+ /**
39
+ * Strategy when no template resolves for a key/locale (after walking the
40
+ * fallback chain). "throw" (default) raises an error, "key" returns the
41
+ * translation key itself, a function receives the context and returns the
42
+ * string to display. ICU syntax and formatting errors always throw.
43
+ */
44
+ onMissing?: OnMissingTranslation;
30
45
  };
31
46
 
32
47
  export type LocaleCache = Record<string, IntlMessageFormat>;
@@ -107,6 +107,10 @@ function normalizeKeyDictionary(
107
107
  return { ok: true, data: keys };
108
108
  }
109
109
 
110
+ /**
111
+ * Validation step 1: coerce external input into a normalized dictionary shape
112
+ * (keys, locales, ICU args) against a `DictionarySpec`.
113
+ */
110
114
  export function normalizeDictionary(
111
115
  input: unknown,
112
116
  spec: DictionarySpec
File without changes