@xndrjs/i18n 0.6.1 → 0.7.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.
- package/README.md +154 -141
- package/dist/codegen/index.js.map +1 -1
- package/dist/index.d.ts +268 -69
- package/dist/index.js +572 -65
- package/dist/index.js.map +1 -1
- package/dist/validation/index.d.ts +23 -7
- package/dist/validation/index.js +196 -89
- package/dist/validation/index.js.map +1 -1
- package/package.json +1 -1
- package/src/IcuTranslationProviderMulti.test.ts +80 -234
- package/src/IcuTranslationProviderMulti.ts +101 -112
- package/src/IcuTranslationProviderSingle.test.ts +30 -309
- package/src/IcuTranslationProviderSingle.ts +42 -75
- package/src/builder-load-registry.ts +18 -0
- package/src/builder-loaders.ts +18 -0
- package/src/builder-multi.ts +481 -0
- package/src/builder-types.test.ts +80 -0
- package/src/builder-types.ts +24 -0
- package/src/builder.test.ts +421 -0
- package/src/builder.ts +112 -0
- package/src/codegen/delivery-artifacts.test.ts +2 -1
- package/src/codegen/delivery-artifacts.ts +2 -1
- package/src/codegen/emit/dictionary-file.test.ts +0 -7
- package/src/codegen/emit/dictionary-schema-file.ts +55 -42
- package/src/codegen/emit/instance-file.test.ts +88 -0
- package/src/codegen/emit/instance-file.ts +164 -17
- package/src/codegen/emit/namespace-loaders-file.test.ts +4 -18
- package/src/codegen/emit/namespace-loaders-file.ts +4 -54
- package/src/codegen/emit/types-file.test.ts +0 -2
- package/src/codegen/generate-i18n-types.test.ts +8 -303
- package/src/codegen/generate-i18n-types.ts +16 -1
- package/src/codegen/project-locales-set-namespace.test.ts +25 -32
- package/src/engine.ts +71 -0
- package/src/index.ts +45 -7
- package/src/patch-key.test.ts +186 -0
- package/src/patch-key.ts +140 -0
- package/src/project-locales.test.ts +16 -6
- package/src/project-locales.ts +17 -6
- package/src/scope-multi.ts +124 -0
- package/src/scope-single.ts +85 -0
- package/src/scope-types.ts +27 -0
- package/src/scope.test.ts +481 -0
- package/src/single-builder.ts +153 -0
- package/src/types.ts +16 -0
- package/src/validation/create-normalized-schema.ts +1 -1
- package/src/validation/errors.ts +2 -0
- package/src/validation/index.ts +135 -27
- package/src/validation/normalize.ts +176 -46
- package/src/validation/types.ts +4 -0
- package/src/validation/validate-normalized.ts +43 -1
- 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 {
|
|
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 {
|
|
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("
|
|
308
|
-
it("validates end-to-end", () => {
|
|
309
|
-
const result =
|
|
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("
|
|
317
|
-
const result =
|
|
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
|
+
});
|