@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
|
@@ -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(
|
package/src/validation/errors.ts
CHANGED
|
@@ -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":
|
package/src/validation/index.ts
CHANGED
|
@@ -1,70 +1,178 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { normalizeDictionaryPartial, normalizeKeyDictionaryPartial } from "./normalize.js";
|
|
2
2
|
import { toDictionary, toNamespaceDictionary } from "./to-dictionary.js";
|
|
3
|
-
import type {
|
|
4
|
-
|
|
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
|
|
11
|
+
export function validateExternalDictionaryPartial<TSchema>(
|
|
7
12
|
input: unknown,
|
|
8
13
|
spec: DictionarySpec
|
|
9
|
-
): ValidationResult<TSchema
|
|
10
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 {
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
data: toDictionary({ mode: "multi", namespaces }) as Partial<TSchema>,
|
|
73
|
+
};
|
|
21
74
|
}
|
|
22
75
|
|
|
23
|
-
export function
|
|
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: "
|
|
87
|
+
message: "validateExternalNamespacePartial requires multi-mode dictionary spec",
|
|
35
88
|
},
|
|
36
89
|
],
|
|
37
90
|
};
|
|
38
91
|
}
|
|
39
92
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
[
|
|
45
|
-
}
|
|
46
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
120
|
+
data: toNamespaceDictionary(
|
|
121
|
+
{ mode: "multi", namespaces: { [namespace]: step2.data } },
|
|
122
|
+
namespace
|
|
123
|
+
) as Partial<TNamespaceSchema>,
|
|
64
124
|
};
|
|
65
125
|
}
|
|
66
126
|
|
|
67
|
-
export
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
for
|
|
83
|
-
|
|
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 (
|
|
97
|
-
issues.push(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/validation/types.ts
CHANGED
|
@@ -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[];
|