@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.
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
@@ -0,0 +1,153 @@
1
+ import { invokeNamespaceLoader, type NamespaceLoader } from "./builder-loaders.js";
2
+ import type { I18nEngineSingleImpl } from "./engine.js";
3
+ import type {
4
+ KeyDictionary,
5
+ LocaleFallbackMap,
6
+ LocaleOfSingle,
7
+ PartialKeyDictionary,
8
+ } from "./types.js";
9
+ import type { I18nScopeSingleForLocale, I18nScopeSingleImpl } from "./scope-single.js";
10
+
11
+ type SingleScopeBound<
12
+ Schema extends KeyDictionary,
13
+ Params,
14
+ Locale extends string,
15
+ > = I18nScopeSingleForLocale<Schema, Params & { [K in keyof Schema]: unknown }, Locale>;
16
+
17
+ export type I18nBuilderSingleOptions<
18
+ Schema extends KeyDictionary,
19
+ RequestLocales extends string = LocaleOfSingle<Schema>,
20
+ > = {
21
+ dictionaryLoader?: NamespaceLoader<PartialKeyDictionary<Schema, RequestLocales> | Schema>;
22
+ };
23
+
24
+ export interface I18nBuilderSingle<
25
+ Schema extends KeyDictionary,
26
+ Params extends { [K in keyof Schema]: unknown },
27
+ RequestLocales extends string = LocaleOfSingle<Schema>,
28
+ > {
29
+ withLocale<Locale extends RequestLocales>(
30
+ locale: Locale
31
+ ): I18nBuilderSingleForLocaleImpl<Schema, Params, RequestLocales, Locale>;
32
+
33
+ withDeliveryArea<Area extends string>(
34
+ area: Area
35
+ ): I18nBuilderSingle<Schema, Params, RequestLocales>;
36
+
37
+ load(): Promise<
38
+ I18nScopeSingleImpl<Schema, Params, RequestLocales, LocaleFallbackMap | undefined>
39
+ >;
40
+ }
41
+
42
+ export interface I18nBuilderSingleForLocale<
43
+ Schema extends KeyDictionary,
44
+ Params extends { [K in keyof Schema]: unknown },
45
+ Locale extends string,
46
+ > {
47
+ load(): Promise<SingleScopeBound<Schema, Params, Locale>>;
48
+ }
49
+
50
+ type SingleBuilderState<
51
+ Schema extends KeyDictionary,
52
+ RequestLocales extends string = LocaleOfSingle<Schema>,
53
+ > = {
54
+ locale?: RequestLocales;
55
+ deliveryArea?: string;
56
+ };
57
+
58
+ export class I18nBuilderSingleImpl<
59
+ Schema extends KeyDictionary,
60
+ Params extends { [K in keyof Schema]: unknown },
61
+ RequestLocales extends string = LocaleOfSingle<Schema>,
62
+ > implements I18nBuilderSingle<Schema, Params, RequestLocales> {
63
+ private state: SingleBuilderState<Schema, RequestLocales> = {};
64
+
65
+ constructor(
66
+ private readonly engine: I18nEngineSingleImpl<Schema, Params, RequestLocales>,
67
+ private readonly options?: I18nBuilderSingleOptions<Schema, RequestLocales>,
68
+ state?: SingleBuilderState<Schema, RequestLocales>
69
+ ) {
70
+ if (state !== undefined) {
71
+ this.state = {
72
+ ...(state.locale !== undefined ? { locale: state.locale } : {}),
73
+ ...(state.deliveryArea !== undefined ? { deliveryArea: state.deliveryArea } : {}),
74
+ };
75
+ }
76
+ }
77
+
78
+ private clone(): SingleBuilderState<Schema, RequestLocales> {
79
+ return {
80
+ ...(this.state.locale !== undefined ? { locale: this.state.locale } : {}),
81
+ ...(this.state.deliveryArea !== undefined ? { deliveryArea: this.state.deliveryArea } : {}),
82
+ };
83
+ }
84
+
85
+ withLocale<Locale extends RequestLocales>(
86
+ locale: Locale
87
+ ): I18nBuilderSingleForLocaleImpl<Schema, Params, RequestLocales, Locale> {
88
+ const { deliveryArea: _deliveryArea, ...rest } = this.clone();
89
+ return new I18nBuilderSingleForLocaleImpl<Schema, Params, RequestLocales, Locale>(
90
+ this.engine,
91
+ this.options,
92
+ { ...rest, locale }
93
+ );
94
+ }
95
+
96
+ withDeliveryArea<Area extends string>(
97
+ _area: Area
98
+ ): I18nBuilderSingle<Schema, Params, RequestLocales> {
99
+ const { locale: _locale, ...rest } = this.clone();
100
+ return new I18nBuilderSingleImpl(this.engine, this.options, {
101
+ ...rest,
102
+ deliveryArea: _area,
103
+ });
104
+ }
105
+
106
+ async load(): Promise<
107
+ I18nScopeSingleImpl<Schema, Params, RequestLocales, LocaleFallbackMap | undefined>
108
+ > {
109
+ await applySingleBuilderLoad(this.engine, this.options, this.state);
110
+ return this.engine.toScope();
111
+ }
112
+ }
113
+
114
+ export class I18nBuilderSingleForLocaleImpl<
115
+ Schema extends KeyDictionary,
116
+ Params extends { [K in keyof Schema]: unknown },
117
+ RequestLocales extends string = LocaleOfSingle<Schema>,
118
+ Locale extends RequestLocales = RequestLocales,
119
+ > implements I18nBuilderSingleForLocale<Schema, Params, Locale> {
120
+ constructor(
121
+ private readonly engine: I18nEngineSingleImpl<Schema, Params, RequestLocales>,
122
+ private readonly options: I18nBuilderSingleOptions<Schema, RequestLocales> | undefined,
123
+ private readonly state: SingleBuilderState<Schema, RequestLocales> & { locale: Locale }
124
+ ) {}
125
+
126
+ async load(): Promise<SingleScopeBound<Schema, Params, Locale>> {
127
+ await applySingleBuilderLoad(this.engine, this.options, this.state);
128
+ return this.engine.toScope({ locale: this.state.locale });
129
+ }
130
+ }
131
+
132
+ async function applySingleBuilderLoad<
133
+ Schema extends KeyDictionary,
134
+ Params extends { [K in keyof Schema]: unknown },
135
+ RequestLocales extends string,
136
+ >(
137
+ engine: I18nEngineSingleImpl<Schema, Params, RequestLocales>,
138
+ options: I18nBuilderSingleOptions<Schema, RequestLocales> | undefined,
139
+ state: SingleBuilderState<Schema, RequestLocales>
140
+ ): Promise<void> {
141
+ const partition = state.locale ?? state.deliveryArea;
142
+ const loader = options?.dictionaryLoader;
143
+
144
+ if (loader !== undefined) {
145
+ if (engine.hasBuilderResourceLoaded(partition)) {
146
+ return;
147
+ }
148
+
149
+ const data = await invokeNamespaceLoader(loader, partition);
150
+ engine.applyLoadMergeSingle(data);
151
+ engine.markBuilderResourceLoaded(partition);
152
+ }
153
+ }
package/src/types.ts CHANGED
@@ -16,6 +16,22 @@ export type LocaleOfMulti<Schema extends MultiDictionary> = {
16
16
  }[keyof Schema] &
17
17
  string;
18
18
 
19
+ /** Subset of schema keys with any subset of project locales per key — valid input for merge operations. */
20
+ export type PartialKeyDictionary<
21
+ T extends KeyDictionary,
22
+ Locales extends string = LocaleOfSingle<T>,
23
+ > = {
24
+ [K in keyof T]?: Partial<Record<Locales, string>>;
25
+ };
26
+
27
+ /** Subset of schema namespaces with partial keys/locales — valid input for multi merge operations. */
28
+ export type PartialMultiDictionary<
29
+ T extends MultiDictionary,
30
+ Locales extends string = LocaleOfMulti<T>,
31
+ > = {
32
+ [NS in keyof T]?: PartialKeyDictionary<T[NS], Locales>;
33
+ };
34
+
19
35
  export type LocaleFallbackMap = Record<string, string | null>;
20
36
 
21
37
  export type MissingTranslationContext = {
@@ -13,7 +13,7 @@ const parsedKeyEntrySchema = z.object({
13
13
  mergedArgs: z.record(z.string(), variableTypeSchema),
14
14
  });
15
15
 
16
- function createKeyDictionarySchema(
16
+ export function createKeyDictionarySchema(
17
17
  argsByKey: Readonly<Record<string, VariableSpec>>
18
18
  ): z.ZodType<Record<string, ParsedKeyEntry>> {
19
19
  const keySchemas = Object.fromEntries(
@@ -4,6 +4,8 @@ function issueMessage(issue: ValidationIssue): string {
4
4
  switch (issue.kind) {
5
5
  case "missing_key":
6
6
  return `Missing required key at ${issue.path.join(".")}`;
7
+ case "unknown_key":
8
+ return `Unknown key at ${issue.path.join(".")}`;
7
9
  case "invalid_input":
8
10
  case "invalid_locale_value":
9
11
  case "icu_syntax_error":
@@ -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[];