@xndrjs/i18n 0.6.1 → 0.7.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 (51) hide show
  1. package/README.md +154 -141
  2. package/dist/codegen/index.js.map +1 -1
  3. package/dist/index.d.ts +268 -69
  4. package/dist/index.js +572 -65
  5. package/dist/index.js.map +1 -1
  6. package/dist/validation/index.d.ts +23 -7
  7. package/dist/validation/index.js +196 -89
  8. package/dist/validation/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/IcuTranslationProviderMulti.test.ts +80 -234
  11. package/src/IcuTranslationProviderMulti.ts +101 -112
  12. package/src/IcuTranslationProviderSingle.test.ts +30 -309
  13. package/src/IcuTranslationProviderSingle.ts +42 -75
  14. package/src/builder-load-registry.ts +18 -0
  15. package/src/builder-loaders.ts +18 -0
  16. package/src/builder-multi.ts +481 -0
  17. package/src/builder-types.test.ts +80 -0
  18. package/src/builder-types.ts +24 -0
  19. package/src/builder.test.ts +421 -0
  20. package/src/builder.ts +112 -0
  21. package/src/codegen/delivery-artifacts.test.ts +2 -1
  22. package/src/codegen/delivery-artifacts.ts +2 -1
  23. package/src/codegen/emit/dictionary-file.test.ts +0 -7
  24. package/src/codegen/emit/dictionary-schema-file.ts +55 -42
  25. package/src/codegen/emit/instance-file.test.ts +88 -0
  26. package/src/codegen/emit/instance-file.ts +164 -17
  27. package/src/codegen/emit/namespace-loaders-file.test.ts +4 -18
  28. package/src/codegen/emit/namespace-loaders-file.ts +4 -54
  29. package/src/codegen/emit/types-file.test.ts +0 -2
  30. package/src/codegen/generate-i18n-types.test.ts +8 -303
  31. package/src/codegen/generate-i18n-types.ts +16 -1
  32. package/src/codegen/project-locales-set-namespace.test.ts +25 -32
  33. package/src/engine.ts +71 -0
  34. package/src/index.ts +45 -7
  35. package/src/patch-key.test.ts +186 -0
  36. package/src/patch-key.ts +140 -0
  37. package/src/project-locales.test.ts +16 -6
  38. package/src/project-locales.ts +17 -6
  39. package/src/scope-multi.ts +124 -0
  40. package/src/scope-single.ts +85 -0
  41. package/src/scope-types.ts +27 -0
  42. package/src/scope.test.ts +481 -0
  43. package/src/single-builder.ts +153 -0
  44. package/src/types.ts +16 -0
  45. package/src/validation/create-normalized-schema.ts +1 -1
  46. package/src/validation/errors.ts +2 -0
  47. package/src/validation/index.ts +135 -27
  48. package/src/validation/normalize.ts +176 -46
  49. package/src/validation/types.ts +4 -0
  50. package/src/validation/validate-normalized.ts +43 -1
  51. package/src/validation/validation.test.ts +200 -7
@@ -1,9 +1,13 @@
1
1
  import type { ZodError } from "zod";
2
2
  import { mapArgsSchemaError } from "./create-args-schema.js";
3
- import { createNormalizedDictionarySchema } from "./create-normalized-schema.js";
3
+ import {
4
+ createKeyDictionarySchema,
5
+ createNormalizedDictionarySchema,
6
+ } from "./create-normalized-schema.js";
4
7
  import type {
5
8
  DictionarySpec,
6
9
  NormalizedDictionary,
10
+ NormalizedKeyDictionary,
7
11
  ValidationIssue,
8
12
  ValidationResult,
9
13
  VariableSpec,
@@ -73,6 +77,44 @@ function mapZodError(
73
77
  });
74
78
  }
75
79
 
80
+ export function validateNormalizedKeyDictionaryPartial(
81
+ keys: NormalizedKeyDictionary,
82
+ argsByKey: Readonly<Record<string, VariableSpec>>,
83
+ spec: DictionarySpec,
84
+ keyPathPrefix: readonly string[]
85
+ ): ValidationResult<NormalizedKeyDictionary> {
86
+ const presentKeys = Object.keys(keys);
87
+ const subsetArgsByKey = Object.fromEntries(
88
+ presentKeys.filter((key) => key in argsByKey).map((key) => [key, argsByKey[key]!])
89
+ );
90
+
91
+ if (presentKeys.length === 0) {
92
+ return { ok: true, data: keys };
93
+ }
94
+
95
+ const schema = createKeyDictionarySchema(subsetArgsByKey);
96
+ const result = schema.safeParse(keys);
97
+
98
+ if (result.success) {
99
+ return { ok: true, data: result.data };
100
+ }
101
+
102
+ const normalized: NormalizedDictionary =
103
+ spec.mode === "single"
104
+ ? { mode: "single", keys }
105
+ : {
106
+ mode: "multi",
107
+ namespaces: {
108
+ [keyPathPrefix[0] ?? ""]: keys,
109
+ },
110
+ };
111
+
112
+ return {
113
+ ok: false,
114
+ issues: mapZodError(result.error, spec, normalized),
115
+ };
116
+ }
117
+
76
118
  export function validateNormalizedDictionary(
77
119
  normalized: NormalizedDictionary,
78
120
  spec: DictionarySpec
@@ -1,9 +1,13 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { DictionarySpec } from "./types.js";
3
- import { normalizeDictionary } from "./normalize.js";
3
+ import { normalizeDictionary, normalizeKeyDictionaryPartial } from "./normalize.js";
4
4
  import { validateNormalizedDictionary } from "./validate-normalized.js";
5
5
  import { toDictionary } from "./to-dictionary.js";
6
- import { validateExternalDictionary } from "./index.js";
6
+ import {
7
+ validateExternalDictionaryPartial,
8
+ validateExternalKey,
9
+ validateExternalNamespacePartial,
10
+ } from "./index.js";
7
11
 
8
12
  const spec: DictionarySpec = {
9
13
  mode: "single",
@@ -304,18 +308,95 @@ describe("toDictionary", () => {
304
308
  });
305
309
  });
306
310
 
307
- describe("validateExternalDictionary", () => {
308
- it("validates end-to-end", () => {
309
- const result = validateExternalDictionary<typeof validInput>(validInput, spec);
311
+ describe("validateExternalDictionaryPartial", () => {
312
+ it("validates end-to-end with full snapshot payload", () => {
313
+ const result = validateExternalDictionaryPartial<typeof validInput>(validInput, spec);
310
314
  expect(result.ok).toBe(true);
311
315
  if (result.ok) {
312
316
  expect((result.data as typeof validInput).login_button.en).toBe("Login");
313
317
  }
314
318
  });
315
319
 
316
- it("fails on missing key before Zod phase", () => {
317
- const result = validateExternalDictionary({ login_button: { en: "Login" } }, spec);
320
+ it("accepts partial payload with one of N keys", () => {
321
+ const result = validateExternalDictionaryPartial({ login_button: { en: "Login" } }, spec);
322
+ expect(result.ok).toBe(true);
323
+ if (result.ok) {
324
+ expect(Object.keys(result.data)).toEqual(["login_button"]);
325
+ }
326
+ });
327
+
328
+ it("accepts empty payload", () => {
329
+ const result = validateExternalDictionaryPartial({}, spec);
330
+ expect(result.ok).toBe(true);
331
+ if (result.ok) {
332
+ expect(result.data).toEqual({});
333
+ }
334
+ });
335
+
336
+ it("rejects unknown keys", () => {
337
+ const result = validateExternalDictionaryPartial(
338
+ { login_button: { en: "Login" }, typo_key: { en: "Oops" } },
339
+ spec
340
+ );
341
+ expect(result.ok).toBe(false);
342
+ if (!result.ok) {
343
+ expect(result.issues.some((issue) => issue.kind === "unknown_key")).toBe(true);
344
+ }
345
+ });
346
+
347
+ it("rejects ICU errors on partial keys", () => {
348
+ const result = validateExternalDictionaryPartial({ welcome: { en: "Hi {name" } }, spec);
349
+ expect(result.ok).toBe(false);
350
+ if (!result.ok) {
351
+ expect(result.issues[0]?.kind).toBe("icu_syntax_error");
352
+ }
353
+ });
354
+ });
355
+
356
+ describe("validateExternalKey (single)", () => {
357
+ it("validates a single key and returns Pick type data", () => {
358
+ const result = validateExternalKey<{ welcome: (typeof validInput)["welcome"] }>(
359
+ "welcome",
360
+ { en: "Welcome {name}!" },
361
+ spec
362
+ );
363
+ expect(result.ok).toBe(true);
364
+ if (result.ok) {
365
+ expect(result.data.welcome.en).toBe("Welcome {name}!");
366
+ }
367
+ });
368
+
369
+ it("rejects unknown keys", () => {
370
+ const result = validateExternalKey("typo_key", { en: "Oops" }, spec);
371
+ expect(result.ok).toBe(false);
372
+ if (!result.ok) {
373
+ expect(result.issues[0]?.kind).toBe("unknown_key");
374
+ }
375
+ });
376
+ });
377
+
378
+ describe("normalizeKeyDictionaryPartial", () => {
379
+ it("accepts one of N keys without missing_key errors", () => {
380
+ const result = normalizeKeyDictionaryPartial(
381
+ { login_button: { en: "Login" } },
382
+ spec.requiredKeys,
383
+ spec.argsByKey,
384
+ []
385
+ );
386
+ expect(result.ok).toBe(true);
387
+ });
388
+
389
+ it("rejects keys with zero locales", () => {
390
+ const result = normalizeKeyDictionaryPartial(
391
+ { login_button: {} },
392
+ spec.requiredKeys,
393
+ spec.argsByKey,
394
+ []
395
+ );
318
396
  expect(result.ok).toBe(false);
397
+ if (!result.ok) {
398
+ expect(result.issues[0]?.kind).toBe("invalid_input");
399
+ }
319
400
  });
320
401
  });
321
402
 
@@ -367,3 +448,115 @@ describe("normalizeDictionary multi mode", () => {
367
448
  }
368
449
  });
369
450
  });
451
+
452
+ describe("validateExternalNamespacePartial", () => {
453
+ const multiSpec: DictionarySpec = {
454
+ mode: "multi",
455
+ requiredKeys: {
456
+ default: ["welcome"],
457
+ billing: ["invoice_summary"],
458
+ },
459
+ argsByKey: {
460
+ default: { welcome: { name: "string" } },
461
+ billing: { invoice_summary: { count: "number" } },
462
+ },
463
+ };
464
+
465
+ it("accepts one of N keys in a namespace", () => {
466
+ const result = validateExternalNamespacePartial(
467
+ "billing",
468
+ {
469
+ invoice_summary: {
470
+ en: "You have {count, plural, one {1} other {{count}}}",
471
+ },
472
+ },
473
+ multiSpec
474
+ );
475
+ expect(result.ok).toBe(true);
476
+ });
477
+
478
+ it("does not error when contract keys are absent from payload", () => {
479
+ const result = validateExternalNamespacePartial("billing", {}, multiSpec);
480
+ expect(result.ok).toBe(true);
481
+ });
482
+
483
+ it("validates full CMS snapshot when all keys are in payload", () => {
484
+ const result = validateExternalNamespacePartial(
485
+ "billing",
486
+ {
487
+ invoice_summary: {
488
+ en: "You have {count, plural, one {1} other {{count}}}",
489
+ },
490
+ },
491
+ multiSpec
492
+ );
493
+ expect(result.ok).toBe(true);
494
+ });
495
+
496
+ it("rejects unknown keys in namespace payload", () => {
497
+ const result = validateExternalNamespacePartial(
498
+ "billing",
499
+ { typo_key: { en: "Oops" } },
500
+ multiSpec
501
+ );
502
+ expect(result.ok).toBe(false);
503
+ if (!result.ok) {
504
+ expect(result.issues[0]?.kind).toBe("unknown_key");
505
+ }
506
+ });
507
+ });
508
+
509
+ describe("validateExternalKey (multi)", () => {
510
+ const multiSpec: DictionarySpec = {
511
+ mode: "multi",
512
+ requiredKeys: {
513
+ billing: ["invoice_summary"],
514
+ },
515
+ argsByKey: {
516
+ billing: { invoice_summary: { count: "number" } },
517
+ },
518
+ };
519
+
520
+ it("validates a single key in a namespace", () => {
521
+ const result = validateExternalKey(
522
+ "billing",
523
+ "invoice_summary",
524
+ { en: "You have {count, plural, one {1} other {{count}}}" },
525
+ multiSpec
526
+ );
527
+ expect(result.ok).toBe(true);
528
+ if (result.ok) {
529
+ expect((result.data as { invoice_summary: { en: string } }).invoice_summary.en).toContain(
530
+ "{count"
531
+ );
532
+ }
533
+ });
534
+ });
535
+
536
+ describe("validateExternalDictionaryPartial (multi)", () => {
537
+ const multiSpec: DictionarySpec = {
538
+ mode: "multi",
539
+ requiredKeys: {
540
+ default: ["welcome"],
541
+ billing: ["invoice_summary"],
542
+ },
543
+ argsByKey: {
544
+ default: { welcome: { name: "string" } },
545
+ billing: { invoice_summary: { count: "number" } },
546
+ },
547
+ };
548
+
549
+ it("accepts partial namespaces", () => {
550
+ const result = validateExternalDictionaryPartial(
551
+ {
552
+ billing: {
553
+ invoice_summary: {
554
+ en: "You have {count, plural, one {1} other {{count}}}",
555
+ },
556
+ },
557
+ },
558
+ multiSpec
559
+ );
560
+ expect(result.ok).toBe(true);
561
+ });
562
+ });