@xndrjs/i18n 0.6.0 → 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 -134
  2. package/dist/codegen/index.js.map +1 -1
  3. package/dist/index.d.ts +276 -61
  4. package/dist/index.js +687 -126
  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 +115 -215
  11. package/src/IcuTranslationProviderMulti.ts +107 -96
  12. package/src/IcuTranslationProviderSingle.test.ts +36 -294
  13. package/src/IcuTranslationProviderSingle.ts +55 -72
  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 +35 -10
  28. package/src/codegen/emit/namespace-loaders-file.ts +11 -61
  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 +47 -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 +76 -0
  38. package/src/project-locales.ts +58 -1
  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,70 +1,178 @@
1
- import { normalizeDictionary } from "./normalize.js";
1
+ import { normalizeDictionaryPartial, normalizeKeyDictionaryPartial } from "./normalize.js";
2
2
  import { toDictionary, toNamespaceDictionary } from "./to-dictionary.js";
3
- import type { DictionarySpec, ValidationResult } from "./types.js";
4
- import { validateNormalizedDictionary } from "./validate-normalized.js";
3
+ import type {
4
+ DictionarySpec,
5
+ NormalizedKeyDictionary,
6
+ ValidationIssue,
7
+ ValidationResult,
8
+ } from "./types.js";
9
+ import { validateNormalizedKeyDictionaryPartial } from "./validate-normalized.js";
5
10
 
6
- export function validateExternalDictionary<TSchema>(
11
+ export function validateExternalDictionaryPartial<TSchema>(
7
12
  input: unknown,
8
13
  spec: DictionarySpec
9
- ): ValidationResult<TSchema> {
10
- const step1 = normalizeDictionary(input, spec);
14
+ ): ValidationResult<Partial<TSchema>> {
15
+ if (spec.mode === "single") {
16
+ const step1 = normalizeKeyDictionaryPartial(input, spec.requiredKeys, spec.argsByKey, []);
17
+ if (!step1.ok) {
18
+ return step1;
19
+ }
20
+
21
+ const step2 = validateNormalizedKeyDictionaryPartial(step1.data, spec.argsByKey, spec, []);
22
+ if (!step2.ok) {
23
+ return step2;
24
+ }
25
+
26
+ return {
27
+ ok: true,
28
+ data: toDictionary({ mode: "single", keys: step2.data }) as Partial<TSchema>,
29
+ };
30
+ }
31
+
32
+ const step1 = normalizeDictionaryPartial(input, spec);
11
33
  if (!step1.ok) {
12
34
  return step1;
13
35
  }
14
36
 
15
- const step2 = validateNormalizedDictionary(step1.data, spec);
16
- if (!step2.ok) {
17
- return step2;
37
+ if (step1.data.mode !== "multi") {
38
+ return {
39
+ ok: false,
40
+ issues: [
41
+ {
42
+ kind: "invalid_input",
43
+ message: "validateExternalDictionaryPartial requires multi-mode dictionary spec",
44
+ },
45
+ ],
46
+ };
47
+ }
48
+
49
+ const issues: ValidationIssue[] = [];
50
+ const namespaces: Record<string, NormalizedKeyDictionary> = {};
51
+
52
+ for (const [namespace, keys] of Object.entries(step1.data.namespaces)) {
53
+ const step2 = validateNormalizedKeyDictionaryPartial(
54
+ keys,
55
+ spec.argsByKey[namespace] ?? {},
56
+ spec,
57
+ [namespace]
58
+ );
59
+ if (!step2.ok) {
60
+ issues.push(...step2.issues);
61
+ } else {
62
+ namespaces[namespace] = step2.data;
63
+ }
64
+ }
65
+
66
+ if (issues.length > 0) {
67
+ return { ok: false, issues };
18
68
  }
19
69
 
20
- return { ok: true, data: toDictionary(step2.data) as TSchema };
70
+ return {
71
+ ok: true,
72
+ data: toDictionary({ mode: "multi", namespaces }) as Partial<TSchema>,
73
+ };
21
74
  }
22
75
 
23
- export function validateExternalNamespace<TNamespaceSchema>(
76
+ export function validateExternalNamespacePartial<TNamespaceSchema>(
24
77
  namespace: string,
25
78
  input: unknown,
26
79
  spec: DictionarySpec
27
- ): ValidationResult<TNamespaceSchema> {
80
+ ): ValidationResult<Partial<TNamespaceSchema>> {
28
81
  if (spec.mode !== "multi") {
29
82
  return {
30
83
  ok: false,
31
84
  issues: [
32
85
  {
33
86
  kind: "invalid_input",
34
- message: "validateExternalNamespace requires multi-mode dictionary spec",
87
+ message: "validateExternalNamespacePartial requires multi-mode dictionary spec",
35
88
  },
36
89
  ],
37
90
  };
38
91
  }
39
92
 
40
- const wrapped = { [namespace]: input };
41
- const namespaceSpec: DictionarySpec = {
42
- mode: "multi",
43
- requiredKeys: {
44
- [namespace]: spec.requiredKeys[namespace] ?? [],
45
- },
46
- argsByKey: {
47
- [namespace]: spec.argsByKey[namespace] ?? {},
48
- },
49
- };
93
+ const knownKeys = spec.requiredKeys[namespace];
94
+ if (!knownKeys) {
95
+ return {
96
+ ok: false,
97
+ issues: [{ kind: "unknown_key", path: [namespace] }],
98
+ };
99
+ }
50
100
 
51
- const step1 = normalizeDictionary(wrapped, namespaceSpec);
101
+ const step1 = normalizeKeyDictionaryPartial(input, knownKeys, spec.argsByKey[namespace] ?? {}, [
102
+ namespace,
103
+ ]);
52
104
  if (!step1.ok) {
53
105
  return step1;
54
106
  }
55
107
 
56
- const step2 = validateNormalizedDictionary(step1.data, namespaceSpec);
108
+ const step2 = validateNormalizedKeyDictionaryPartial(
109
+ step1.data,
110
+ spec.argsByKey[namespace] ?? {},
111
+ spec,
112
+ [namespace]
113
+ );
57
114
  if (!step2.ok) {
58
115
  return step2;
59
116
  }
60
117
 
61
118
  return {
62
119
  ok: true,
63
- data: toNamespaceDictionary(step2.data, namespace) as TNamespaceSchema,
120
+ data: toNamespaceDictionary(
121
+ { mode: "multi", namespaces: { [namespace]: step2.data } },
122
+ namespace
123
+ ) as Partial<TNamespaceSchema>,
64
124
  };
65
125
  }
66
126
 
67
- export { normalizeDictionary, validateNormalizedDictionary, toDictionary, toNamespaceDictionary };
127
+ export function validateExternalKey<TKeySlice>(
128
+ key: string,
129
+ input: unknown,
130
+ spec: Extract<DictionarySpec, { mode: "single" }>
131
+ ): ValidationResult<TKeySlice>;
132
+
133
+ export function validateExternalKey<TKeySlice>(
134
+ namespace: string,
135
+ key: string,
136
+ input: unknown,
137
+ spec: Extract<DictionarySpec, { mode: "multi" }>
138
+ ): ValidationResult<TKeySlice>;
139
+
140
+ export function validateExternalKey<TKeySlice>(
141
+ namespaceOrKey: string,
142
+ keyOrInput: string | unknown,
143
+ inputOrSpec?: unknown,
144
+ specMaybe?: DictionarySpec
145
+ ): ValidationResult<TKeySlice> {
146
+ if (specMaybe?.mode === "multi") {
147
+ const namespace = namespaceOrKey;
148
+ const key = keyOrInput as string;
149
+ const input = inputOrSpec;
150
+ const spec = specMaybe;
151
+
152
+ return validateExternalNamespacePartial<TKeySlice>(
153
+ namespace,
154
+ { [key]: input },
155
+ spec
156
+ ) as ValidationResult<TKeySlice>;
157
+ }
158
+
159
+ const key = namespaceOrKey;
160
+ const input = keyOrInput;
161
+ const spec = inputOrSpec as Extract<DictionarySpec, { mode: "single" }>;
162
+
163
+ return validateExternalDictionaryPartial<TKeySlice>(
164
+ { [key]: input },
165
+ spec
166
+ ) as ValidationResult<TKeySlice>;
167
+ }
168
+
169
+ export {
170
+ normalizeDictionaryPartial,
171
+ normalizeKeyDictionaryPartial,
172
+ toDictionary,
173
+ toNamespaceDictionary,
174
+ };
175
+ export { validateNormalizedKeyDictionaryPartial } from "./validate-normalized.js";
68
176
  export { createArgsSchema } from "./create-args-schema.js";
69
177
  export { createNormalizedDictionarySchema } from "./create-normalized-schema.js";
70
178
  export { DictionaryValidationError, assertValidDictionary, formatIssues } from "./errors.js";
@@ -1,5 +1,6 @@
1
1
  import { mergeVariableMetaAcrossLocales, type VariableMetaSpec } from "../icu/extract-variables.js";
2
2
  import { parseTemplate } from "../icu/parse-template.js";
3
+ import type { VariableSpec } from "../icu/extract-variables.js";
3
4
  import type {
4
5
  DictionarySpec,
5
6
  NormalizedDictionary,
@@ -21,6 +22,68 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
21
22
  return typeof value === "object" && value !== null && !Array.isArray(value);
22
23
  }
23
24
 
25
+ function normalizeSingleKey(
26
+ key: string,
27
+ keyValue: Record<string, unknown>,
28
+ keyPathPrefix: readonly string[]
29
+ ): { entry: ParsedKeyEntry; issues: ValidationIssue[] } {
30
+ const issues: ValidationIssue[] = [];
31
+ const locales: Record<string, ParsedKeyEntry["locales"][string]> = {};
32
+ const localeMetas: VariableMetaSpec[] = [];
33
+ let mergedArgs: ParsedKeyEntry["mergedArgs"] = {};
34
+ let localeArgsMismatch: ValidationIssue | undefined;
35
+
36
+ for (const [locale, template] of Object.entries(keyValue)) {
37
+ const localePath = [...keyPathPrefix, key, locale];
38
+
39
+ if (typeof template !== "string") {
40
+ issues.push({
41
+ kind: "invalid_locale_value",
42
+ path: localePath,
43
+ message: `expected string, got ${typeof template}`,
44
+ });
45
+ continue;
46
+ }
47
+
48
+ const parsed = parseTemplate(template);
49
+ if (!parsed.ok) {
50
+ issues.push({
51
+ kind: "icu_syntax_error",
52
+ path: localePath,
53
+ message: parsed.message,
54
+ });
55
+ continue;
56
+ }
57
+
58
+ locales[locale] = { template, args: parsed.args };
59
+ localeMetas.push(parsed.meta);
60
+ }
61
+
62
+ if (localeMetas.length > 0) {
63
+ const merged = mergeVariableMetaAcrossLocales(localeMetas);
64
+ if (!merged.ok) {
65
+ const localeArgs: Record<string, ParsedKeyEntry["mergedArgs"]> = {};
66
+ for (const [loc, entry] of Object.entries(locales)) {
67
+ localeArgs[loc] = entry.args;
68
+ }
69
+ localeArgsMismatch = {
70
+ kind: "locale_args_mismatch",
71
+ path: [...keyPathPrefix, key],
72
+ locales: localeArgs,
73
+ message: merged.message,
74
+ };
75
+ } else {
76
+ mergedArgs = merged.merged;
77
+ }
78
+ }
79
+
80
+ if (localeArgsMismatch) {
81
+ issues.push(localeArgsMismatch);
82
+ }
83
+
84
+ return { entry: { locales, mergedArgs }, issues };
85
+ }
86
+
24
87
  function normalizeKeyDictionary(
25
88
  input: Record<string, unknown>,
26
89
  requiredKeys: readonly string[],
@@ -44,60 +107,70 @@ function normalizeKeyDictionary(
44
107
  continue;
45
108
  }
46
109
 
47
- const locales: Record<string, ParsedKeyEntry["locales"][string]> = {};
48
- const localeMetas: VariableMetaSpec[] = [];
49
- let mergedArgs: ParsedKeyEntry["mergedArgs"] = {};
50
- let localeArgsMismatch: ValidationIssue | undefined;
51
-
52
- for (const [locale, template] of Object.entries(keyValue)) {
53
- const localePath = [...keyPathPrefix, key, locale];
54
-
55
- if (typeof template !== "string") {
56
- issues.push({
57
- kind: "invalid_locale_value",
58
- path: localePath,
59
- message: `expected string, got ${typeof template}`,
60
- });
61
- continue;
62
- }
110
+ const result = normalizeSingleKey(key, keyValue, keyPathPrefix);
111
+ issues.push(...result.issues);
112
+ keys[key] = result.entry;
113
+ }
63
114
 
64
- const parsed = parseTemplate(template);
65
- if (!parsed.ok) {
66
- issues.push({
67
- kind: "icu_syntax_error",
68
- path: localePath,
69
- message: parsed.message,
70
- });
71
- continue;
72
- }
115
+ if (issues.length > 0) {
116
+ return { ok: false, issues };
117
+ }
73
118
 
74
- locales[locale] = { template, args: parsed.args };
75
- localeMetas.push(parsed.meta);
119
+ return { ok: true, data: keys };
120
+ }
121
+
122
+ /**
123
+ * Validation step 1 (partial): coerce external input into a normalized key dictionary
124
+ * for only the keys present in the payload.
125
+ */
126
+ export function normalizeKeyDictionaryPartial(
127
+ input: unknown,
128
+ knownKeys: readonly string[],
129
+ _argsByKey: Readonly<Record<string, VariableSpec>>,
130
+ keyPathPrefix: readonly string[]
131
+ ): ValidationResult<NormalizedKeyDictionary> {
132
+ if (!isPlainObject(input)) {
133
+ return {
134
+ ok: false,
135
+ issues: [
136
+ {
137
+ kind: "invalid_input",
138
+ message: `Expected object, got ${input === null ? "null" : typeof input}`,
139
+ },
140
+ ],
141
+ };
142
+ }
143
+
144
+ const knownKeysSet = new Set(knownKeys);
145
+ const issues: ValidationIssue[] = [];
146
+ const keys: Record<string, ParsedKeyEntry> = {};
147
+
148
+ for (const key of Object.keys(input)) {
149
+ if (!knownKeysSet.has(key)) {
150
+ issues.push({ kind: "unknown_key", path: [...keyPathPrefix, key] });
151
+ continue;
76
152
  }
77
153
 
78
- if (localeMetas.length > 0) {
79
- const merged = mergeVariableMetaAcrossLocales(localeMetas);
80
- if (!merged.ok) {
81
- const localeArgs: Record<string, ParsedKeyEntry["mergedArgs"]> = {};
82
- for (const [loc, entry] of Object.entries(locales)) {
83
- localeArgs[loc] = entry.args;
84
- }
85
- localeArgsMismatch = {
86
- kind: "locale_args_mismatch",
87
- path: [...keyPathPrefix, key],
88
- locales: localeArgs,
89
- message: merged.message,
90
- };
91
- } else {
92
- mergedArgs = merged.merged;
93
- }
154
+ const keyValue = input[key];
155
+ if (!isPlainObject(keyValue)) {
156
+ issues.push({
157
+ kind: "invalid_input",
158
+ message: `Expected object for key "${key}", got ${typeof keyValue}`,
159
+ });
160
+ continue;
94
161
  }
95
162
 
96
- if (localeArgsMismatch) {
97
- issues.push(localeArgsMismatch);
163
+ if (Object.keys(keyValue).length === 0) {
164
+ issues.push({
165
+ kind: "invalid_input",
166
+ message: `Key "${key}" must include at least one locale`,
167
+ });
168
+ continue;
98
169
  }
99
170
 
100
- keys[key] = { locales, mergedArgs };
171
+ const result = normalizeSingleKey(key, keyValue, keyPathPrefix);
172
+ issues.push(...result.issues);
173
+ keys[key] = result.entry;
101
174
  }
102
175
 
103
176
  if (issues.length > 0) {
@@ -167,3 +240,60 @@ export function normalizeDictionary(
167
240
 
168
241
  return success({ mode: "multi", namespaces });
169
242
  }
243
+
244
+ /**
245
+ * Validation step 1 (partial, multi): coerce external input into normalized namespace
246
+ * slices for only the namespaces and keys present in the payload.
247
+ */
248
+ export function normalizeDictionaryPartial(
249
+ input: unknown,
250
+ spec: Extract<DictionarySpec, { mode: "multi" }>
251
+ ): ValidationResult<NormalizedDictionary> {
252
+ if (!isPlainObject(input)) {
253
+ return failure([
254
+ {
255
+ kind: "invalid_input",
256
+ message: `Expected object, got ${input === null ? "null" : typeof input}`,
257
+ },
258
+ ]);
259
+ }
260
+
261
+ const knownNamespaces = Object.keys(spec.requiredKeys);
262
+ const knownNamespacesSet = new Set(knownNamespaces);
263
+ const issues: ValidationIssue[] = [];
264
+ const namespaces: Record<string, NormalizedKeyDictionary> = {};
265
+
266
+ for (const namespace of Object.keys(input)) {
267
+ if (!knownNamespacesSet.has(namespace)) {
268
+ issues.push({ kind: "unknown_key", path: [namespace] });
269
+ continue;
270
+ }
271
+
272
+ const namespaceValue = input[namespace];
273
+ if (!isPlainObject(namespaceValue)) {
274
+ issues.push({
275
+ kind: "invalid_input",
276
+ message: `Expected object for namespace "${namespace}", got ${typeof namespaceValue}`,
277
+ });
278
+ continue;
279
+ }
280
+
281
+ const result = normalizeKeyDictionaryPartial(
282
+ namespaceValue,
283
+ spec.requiredKeys[namespace] ?? [],
284
+ spec.argsByKey[namespace] ?? {},
285
+ [namespace]
286
+ );
287
+ if (!result.ok) {
288
+ issues.push(...result.issues);
289
+ } else {
290
+ namespaces[namespace] = result.data;
291
+ }
292
+ }
293
+
294
+ if (issues.length > 0) {
295
+ return failure(issues);
296
+ }
297
+
298
+ return success({ mode: "multi", namespaces });
299
+ }
@@ -41,6 +41,10 @@ export type ValidationIssue =
41
41
  readonly kind: "missing_key";
42
42
  readonly path: readonly string[];
43
43
  }
44
+ | {
45
+ readonly kind: "unknown_key";
46
+ readonly path: readonly string[];
47
+ }
44
48
  | {
45
49
  readonly kind: "invalid_locale_value";
46
50
  readonly path: readonly string[];
@@ -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