@xndrjs/i18n 0.2.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 (50) hide show
  1. package/README.md +572 -0
  2. package/bin/codegen.mjs +17 -0
  3. package/bin/setup.mjs +14 -0
  4. package/dist/index.d.ts +124 -0
  5. package/dist/index.js +320 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/types-CFWVT2PP.d.ts +77 -0
  8. package/dist/validation/index.d.ts +44 -0
  9. package/dist/validation/index.js +490 -0
  10. package/dist/validation/index.js.map +1 -0
  11. package/package.json +65 -0
  12. package/src/IcuTranslationProviderMulti.test.ts +290 -0
  13. package/src/IcuTranslationProviderMulti.ts +195 -0
  14. package/src/IcuTranslationProviderSingle.test.ts +314 -0
  15. package/src/IcuTranslationProviderSingle.ts +155 -0
  16. package/src/codegen/config.ts +63 -0
  17. package/src/codegen/emit/dictionary-file.ts +73 -0
  18. package/src/codegen/emit/dictionary-schema-file.ts +129 -0
  19. package/src/codegen/emit/instance-file.ts +60 -0
  20. package/src/codegen/emit/namespace-loaders-file.ts +44 -0
  21. package/src/codegen/emit/types-file.ts +118 -0
  22. package/src/codegen/generate-i18n-types.test.ts +482 -0
  23. package/src/codegen/generate-i18n-types.ts +173 -0
  24. package/src/codegen/icu-analysis.ts +88 -0
  25. package/src/codegen/locale-fallback.ts +64 -0
  26. package/src/codegen/paths.ts +27 -0
  27. package/src/codegen/types.ts +30 -0
  28. package/src/deep-freeze.test.ts +27 -0
  29. package/src/deep-freeze.ts +17 -0
  30. package/src/ensure-namespace.integration.test.ts +66 -0
  31. package/src/ensure-namespace.test.ts +125 -0
  32. package/src/ensure-namespace.ts +70 -0
  33. package/src/icu/extract-variables.ts +87 -0
  34. package/src/icu/parse-template.ts +24 -0
  35. package/src/index.ts +32 -0
  36. package/src/resolve-locale.test.ts +91 -0
  37. package/src/resolve-locale.ts +88 -0
  38. package/src/setup/setup-i18n.test.ts +86 -0
  39. package/src/setup/setup-i18n.ts +179 -0
  40. package/src/setup/type-names.ts +20 -0
  41. package/src/types.ts +34 -0
  42. package/src/validation/create-args-schema.ts +76 -0
  43. package/src/validation/create-normalized-schema.ts +52 -0
  44. package/src/validation/errors.ts +41 -0
  45. package/src/validation/index.ts +90 -0
  46. package/src/validation/normalize.ts +158 -0
  47. package/src/validation/to-dictionary.ts +67 -0
  48. package/src/validation/types.ts +78 -0
  49. package/src/validation/validate-normalized.ts +91 -0
  50. package/src/validation/validation.test.ts +265 -0
@@ -0,0 +1,265 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DictionarySpec } from "./types.js";
3
+ import { normalizeDictionary } from "./normalize.js";
4
+ import { validateNormalizedDictionary } from "./validate-normalized.js";
5
+ import { toDictionary } from "./to-dictionary.js";
6
+ import { validateExternalDictionary } from "./index.js";
7
+
8
+ const spec: DictionarySpec = {
9
+ mode: "single",
10
+ requiredKeys: ["login_button", "welcome", "invoice_count"],
11
+ argsByKey: {
12
+ login_button: {},
13
+ welcome: { name: "string" },
14
+ invoice_count: { count: "number" },
15
+ },
16
+ };
17
+
18
+ const validInput = {
19
+ login_button: { en: "Login", it: "Accedi" },
20
+ welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
21
+ invoice_count: {
22
+ en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
23
+ },
24
+ };
25
+
26
+ describe("normalizeDictionary", () => {
27
+ it("rejects non-object input", () => {
28
+ const result = normalizeDictionary(null, spec);
29
+ expect(result.ok).toBe(false);
30
+ if (!result.ok) {
31
+ expect(result.issues[0]?.kind).toBe("invalid_input");
32
+ }
33
+ });
34
+
35
+ it("rejects missing required keys", () => {
36
+ const result = normalizeDictionary({ login_button: { en: "Login" } }, spec);
37
+ expect(result.ok).toBe(false);
38
+ if (!result.ok) {
39
+ expect(result.issues.some((i) => i.kind === "missing_key")).toBe(true);
40
+ }
41
+ });
42
+
43
+ it("ignores extra keys", () => {
44
+ const result = normalizeDictionary({ ...validInput, extra_key: { en: "Extra" } }, spec);
45
+ expect(result.ok).toBe(true);
46
+ });
47
+
48
+ it("accepts partial locales", () => {
49
+ const result = normalizeDictionary(
50
+ {
51
+ login_button: { en: "Login" },
52
+ welcome: { en: "Welcome {name}!" },
53
+ invoice_count: { en: "You have {count, plural, one {1} other {{count}}}" },
54
+ },
55
+ spec
56
+ );
57
+ expect(result.ok).toBe(true);
58
+ if (result.ok && result.data.mode === "single") {
59
+ expect(Object.keys(result.data.keys.login_button!.locales)).toEqual(["en"]);
60
+ }
61
+ });
62
+
63
+ it("rejects ICU syntax errors", () => {
64
+ const result = normalizeDictionary(
65
+ {
66
+ ...validInput,
67
+ welcome: { en: "Hi {name" },
68
+ },
69
+ spec
70
+ );
71
+ expect(result.ok).toBe(false);
72
+ if (!result.ok) {
73
+ expect(result.issues[0]?.kind).toBe("icu_syntax_error");
74
+ }
75
+ });
76
+
77
+ it("rejects inconsistent args across locales", () => {
78
+ const result = normalizeDictionary(
79
+ {
80
+ ...validInput,
81
+ welcome: { en: "Welcome {name}!", it: "Benvenuto {nome}!" },
82
+ },
83
+ spec
84
+ );
85
+ expect(result.ok).toBe(false);
86
+ if (!result.ok) {
87
+ expect(result.issues[0]?.kind).toBe("locale_args_mismatch");
88
+ }
89
+ });
90
+ });
91
+
92
+ describe("validateNormalizedDictionary", () => {
93
+ it("accepts matching merged args", () => {
94
+ const normalized = normalizeDictionary(validInput, spec);
95
+ expect(normalized.ok).toBe(true);
96
+ if (!normalized.ok) return;
97
+
98
+ const result = validateNormalizedDictionary(normalized.data, spec);
99
+ expect(result.ok).toBe(true);
100
+ });
101
+
102
+ it("rejects variable mismatch vs spec", () => {
103
+ const normalized = normalizeDictionary(
104
+ {
105
+ login_button: { en: "Login" },
106
+ welcome: { en: "Welcome {name}!" },
107
+ invoice_count: {
108
+ en: "You have {total, plural, one {1} other {{total}}}",
109
+ },
110
+ },
111
+ spec
112
+ );
113
+ expect(normalized.ok).toBe(true);
114
+ if (!normalized.ok) return;
115
+
116
+ const result = validateNormalizedDictionary(normalized.data, spec);
117
+ expect(result.ok).toBe(false);
118
+ if (!result.ok) {
119
+ expect(
120
+ result.issues.some(
121
+ (i) => i.kind === "variable_mismatch" || i.kind === "variable_type_mismatch"
122
+ )
123
+ ).toBe(true);
124
+ }
125
+ });
126
+ });
127
+
128
+ describe("ICU variant argument extraction", () => {
129
+ const variantSpec: DictionarySpec = {
130
+ mode: "single",
131
+ requiredKeys: ["inbox_owner", "ranking_position", "account_balance", "appointment_summary"],
132
+ argsByKey: {
133
+ inbox_owner: { gender: "string", name: "string" },
134
+ ranking_position: { position: "number" },
135
+ account_balance: { amount: "number" },
136
+ appointment_summary: { dueDate: "date", startTime: "date" },
137
+ },
138
+ };
139
+
140
+ const variantInput = {
141
+ inbox_owner: {
142
+ en: "{gender, select, female {{name} owns her inbox} male {{name} owns his inbox} other {{name} owns their inbox}}",
143
+ },
144
+ ranking_position: {
145
+ en: "You finished {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}",
146
+ },
147
+ account_balance: {
148
+ en: "Balance: {amount, number, ::currency/EUR}",
149
+ },
150
+ appointment_summary: {
151
+ en: "Due {dueDate, date, short} at {startTime, time, short}",
152
+ },
153
+ };
154
+
155
+ it("normalizes select, selectordinal, number, date, and time arguments", () => {
156
+ const normalized = normalizeDictionary(variantInput, variantSpec);
157
+ expect(normalized.ok).toBe(true);
158
+ if (!normalized.ok || normalized.data.mode !== "single") return;
159
+
160
+ expect(normalized.data.keys.inbox_owner?.mergedArgs).toEqual({
161
+ gender: "string",
162
+ name: "string",
163
+ });
164
+ expect(normalized.data.keys.ranking_position?.mergedArgs).toEqual({
165
+ position: "number",
166
+ });
167
+ expect(normalized.data.keys.account_balance?.mergedArgs).toEqual({
168
+ amount: "number",
169
+ });
170
+ expect(normalized.data.keys.appointment_summary?.mergedArgs).toEqual({
171
+ dueDate: "date",
172
+ startTime: "date",
173
+ });
174
+ });
175
+
176
+ it("validates ICU variant args against the generated spec", () => {
177
+ const normalized = normalizeDictionary(variantInput, variantSpec);
178
+ expect(normalized.ok).toBe(true);
179
+ if (!normalized.ok) return;
180
+
181
+ const result = validateNormalizedDictionary(normalized.data, variantSpec);
182
+ expect(result.ok).toBe(true);
183
+ });
184
+ });
185
+
186
+ describe("toDictionary", () => {
187
+ it("reconstructs key-locale-template shape", () => {
188
+ const normalized = normalizeDictionary(validInput, spec);
189
+ expect(normalized.ok).toBe(true);
190
+ if (!normalized.ok) return;
191
+
192
+ const dictionary = toDictionary(normalized.data);
193
+ expect(dictionary).toEqual({
194
+ login_button: { en: "Login", it: "Accedi" },
195
+ welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
196
+ invoice_count: {
197
+ en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
198
+ },
199
+ });
200
+ });
201
+ });
202
+
203
+ describe("validateExternalDictionary", () => {
204
+ it("validates end-to-end", () => {
205
+ const result = validateExternalDictionary<typeof validInput>(validInput, spec);
206
+ expect(result.ok).toBe(true);
207
+ if (result.ok) {
208
+ expect((result.data as typeof validInput).login_button.en).toBe("Login");
209
+ }
210
+ });
211
+
212
+ it("fails on missing key before Zod phase", () => {
213
+ const result = validateExternalDictionary({ login_button: { en: "Login" } }, spec);
214
+ expect(result.ok).toBe(false);
215
+ });
216
+ });
217
+
218
+ describe("normalizeDictionary multi mode", () => {
219
+ const multiSpec: DictionarySpec = {
220
+ mode: "multi",
221
+ requiredKeys: {
222
+ default: ["welcome"],
223
+ billing: ["invoice_summary"],
224
+ },
225
+ argsByKey: {
226
+ default: { welcome: { name: "string" } },
227
+ billing: { invoice_summary: { count: "number" } },
228
+ },
229
+ };
230
+
231
+ it("validates namespace structure", () => {
232
+ const result = normalizeDictionary(
233
+ {
234
+ default: { welcome: { en: "Welcome {name}!" } },
235
+ billing: {
236
+ invoice_summary: {
237
+ en: "You have {count, plural, one {1} other {{count}}}",
238
+ },
239
+ },
240
+ },
241
+ multiSpec
242
+ );
243
+ expect(result.ok).toBe(true);
244
+ });
245
+
246
+ it("reports missing keys with namespace path", () => {
247
+ const result = normalizeDictionary(
248
+ {
249
+ default: { welcome: { en: "Welcome {name}!" } },
250
+ },
251
+ multiSpec
252
+ );
253
+ expect(result.ok).toBe(false);
254
+ if (!result.ok) {
255
+ expect(
256
+ result.issues.some(
257
+ (i) =>
258
+ i.kind === "missing_key" &&
259
+ "path" in i &&
260
+ i.path.join(".") === "billing.invoice_summary"
261
+ )
262
+ ).toBe(true);
263
+ }
264
+ });
265
+ });