@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.
- package/README.md +154 -134
- package/dist/codegen/index.js.map +1 -1
- package/dist/index.d.ts +276 -61
- package/dist/index.js +687 -126
- 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 +115 -215
- package/src/IcuTranslationProviderMulti.ts +107 -96
- package/src/IcuTranslationProviderSingle.test.ts +36 -294
- package/src/IcuTranslationProviderSingle.ts +55 -72
- 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 +35 -10
- package/src/codegen/emit/namespace-loaders-file.ts +11 -61
- 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 +47 -7
- package/src/patch-key.test.ts +186 -0
- package/src/patch-key.ts +140 -0
- package/src/project-locales.test.ts +76 -0
- package/src/project-locales.ts +58 -1
- 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
package/src/engine.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
|
|
2
|
+
import type { IcuTranslationProviderSingle } from "./IcuTranslationProviderSingle.js";
|
|
3
|
+
import type {
|
|
4
|
+
KeyDictionary,
|
|
5
|
+
LocaleFallbackMap,
|
|
6
|
+
LocaleOfMulti,
|
|
7
|
+
LocaleOfSingle,
|
|
8
|
+
MultiDictionary,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
import type { I18nScopeMulti, I18nScopeMultiForLocale } from "./scope-multi.js";
|
|
11
|
+
import type { I18nScopeSingle, I18nScopeSingleForLocale } from "./scope-single.js";
|
|
12
|
+
import type { MultiParams, ParamsForNamespaces, SchemaForNamespaces } from "./scope-types.js";
|
|
13
|
+
|
|
14
|
+
/** Mutable translation engine for a single-namespace dictionary. */
|
|
15
|
+
export interface I18nEngineSingle<
|
|
16
|
+
Schema extends KeyDictionary,
|
|
17
|
+
Params extends { [K in keyof Schema]: unknown },
|
|
18
|
+
RequestLocales extends string = LocaleOfSingle<Schema>,
|
|
19
|
+
> {
|
|
20
|
+
readonly __i18nEngineMode: "single";
|
|
21
|
+
getAll(): Schema;
|
|
22
|
+
toScope(): I18nScopeSingle<Schema, Params, RequestLocales>;
|
|
23
|
+
toScope<Locale extends RequestLocales>(options: {
|
|
24
|
+
locale: Locale;
|
|
25
|
+
}): I18nScopeSingleForLocale<Schema, Params, Locale>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Mutable translation engine for a multi-namespace dictionary. */
|
|
29
|
+
export interface I18nEngineMulti<
|
|
30
|
+
Schema extends MultiDictionary,
|
|
31
|
+
Params extends MultiParams<Schema>,
|
|
32
|
+
RequestLocales extends string = LocaleOfMulti<Schema>,
|
|
33
|
+
> {
|
|
34
|
+
readonly __i18nEngineMode: "multi";
|
|
35
|
+
getAll(): Schema;
|
|
36
|
+
toScope<NsList extends readonly (keyof Schema & string)[]>(options: {
|
|
37
|
+
namespaces: NsList;
|
|
38
|
+
}): I18nScopeMulti<
|
|
39
|
+
SchemaForNamespaces<Schema, NsList>,
|
|
40
|
+
ParamsForNamespaces<Schema, Params, NsList>,
|
|
41
|
+
RequestLocales
|
|
42
|
+
>;
|
|
43
|
+
toScope<
|
|
44
|
+
NsList extends readonly (keyof Schema & string)[],
|
|
45
|
+
Locale extends RequestLocales,
|
|
46
|
+
>(options: {
|
|
47
|
+
namespaces: NsList;
|
|
48
|
+
locale: Locale;
|
|
49
|
+
}): I18nScopeMultiForLocale<
|
|
50
|
+
SchemaForNamespaces<Schema, NsList>,
|
|
51
|
+
ParamsForNamespaces<Schema, Params, NsList>,
|
|
52
|
+
RequestLocales,
|
|
53
|
+
Locale
|
|
54
|
+
>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Alias for the default single-namespace engine implementation. */
|
|
58
|
+
export type I18nEngineSingleImpl<
|
|
59
|
+
Schema extends KeyDictionary,
|
|
60
|
+
Params extends { [K in keyof Schema]: unknown },
|
|
61
|
+
RequestLocales extends string = LocaleOfSingle<Schema>,
|
|
62
|
+
Fallback extends LocaleFallbackMap | undefined = undefined,
|
|
63
|
+
> = IcuTranslationProviderSingle<Schema, Params, RequestLocales, Fallback>;
|
|
64
|
+
|
|
65
|
+
/** Alias for the default multi-namespace engine implementation. */
|
|
66
|
+
export type I18nEngineMultiImpl<
|
|
67
|
+
Schema extends MultiDictionary,
|
|
68
|
+
Params extends MultiParams<Schema>,
|
|
69
|
+
RequestLocales extends string = LocaleOfMulti<Schema>,
|
|
70
|
+
Fallback extends LocaleFallbackMap | undefined = undefined,
|
|
71
|
+
> = IcuTranslationProviderMulti<Schema, Params, RequestLocales, Fallback>;
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,51 @@
|
|
|
1
1
|
export type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
DeliveryArtifactsMap,
|
|
3
|
+
LocalesForDeliveryArea,
|
|
4
|
+
LocalesForDeliveryAreaOrAll,
|
|
5
|
+
} from "./builder-types.js";
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
I18nEngineMulti,
|
|
9
|
+
I18nEngineMultiImpl,
|
|
10
|
+
I18nEngineSingle,
|
|
11
|
+
I18nEngineSingleImpl,
|
|
12
|
+
} from "./engine.js";
|
|
6
13
|
|
|
7
14
|
export type {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
15
|
+
CanonicalLoader,
|
|
16
|
+
I18nBuilderMulti,
|
|
17
|
+
I18nBuilderMultiForLocale,
|
|
18
|
+
I18nBuilderMultiInitial,
|
|
19
|
+
I18nBuilderMultiOptions,
|
|
20
|
+
I18nBuilderMultiPartitioned,
|
|
21
|
+
I18nBuilderMultiReady,
|
|
22
|
+
I18nBuilderSingle,
|
|
23
|
+
I18nBuilderSingleForLocale,
|
|
24
|
+
I18nBuilderSingleOptions,
|
|
25
|
+
NamespaceLoader,
|
|
26
|
+
PartitionedLoader,
|
|
27
|
+
} from "./builder.js";
|
|
28
|
+
export {
|
|
29
|
+
createI18nBuilder,
|
|
30
|
+
createI18nMultiBuilder,
|
|
31
|
+
createI18nSingleBuilder,
|
|
32
|
+
invokeNamespaceLoader,
|
|
33
|
+
} from "./builder.js";
|
|
34
|
+
|
|
35
|
+
export { IcuTranslationProviderSingle } from "./IcuTranslationProviderSingle.js";
|
|
36
|
+
|
|
11
37
|
export { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
|
|
12
38
|
|
|
39
|
+
export type { I18nScopeMulti, I18nScopeMultiForLocale } from "./scope-multi.js";
|
|
40
|
+
export type { I18nScopeSingle, I18nScopeSingleForLocale } from "./scope-single.js";
|
|
41
|
+
|
|
42
|
+
export type {
|
|
43
|
+
MultiParams,
|
|
44
|
+
ParamsForNamespaces,
|
|
45
|
+
SchemaForNamespaces,
|
|
46
|
+
SingleParams,
|
|
47
|
+
} from "./scope-types.js";
|
|
48
|
+
|
|
13
49
|
export type {
|
|
14
50
|
IcuTranslationProviderOptions,
|
|
15
51
|
KeyDictionary,
|
|
@@ -19,6 +55,8 @@ export type {
|
|
|
19
55
|
LocaleOfSingle,
|
|
20
56
|
MissingTranslationContext,
|
|
21
57
|
MultiDictionary,
|
|
58
|
+
PartialKeyDictionary,
|
|
59
|
+
PartialMultiDictionary,
|
|
22
60
|
OnMissingTranslation,
|
|
23
61
|
} from "./types.js";
|
|
24
62
|
|
|
@@ -30,6 +68,8 @@ export {
|
|
|
30
68
|
export type { ResolvedLocaleTemplate } from "./resolve-locale.js";
|
|
31
69
|
|
|
32
70
|
export {
|
|
71
|
+
mergeDictionaryLocalesCore,
|
|
72
|
+
mergeNamespaceLocalesCore,
|
|
33
73
|
projectDictionaryForDeliveryAreaCore,
|
|
34
74
|
projectDictionaryLocalesCore,
|
|
35
75
|
projectNamespaceForDeliveryAreaCore,
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
assertPatchKeyMulti,
|
|
4
|
+
assertPatchKeySingle,
|
|
5
|
+
preloadKeyMulti,
|
|
6
|
+
preloadKeySingle,
|
|
7
|
+
recordPreloadedKeysMulti,
|
|
8
|
+
recordPreloadedKeysSingle,
|
|
9
|
+
seedPreloadedKeysMulti,
|
|
10
|
+
seedPreloadedKeysSingle,
|
|
11
|
+
} from "./patch-key.js";
|
|
12
|
+
|
|
13
|
+
describe("preload key helpers", () => {
|
|
14
|
+
it("formats single preload keys as key:locale", () => {
|
|
15
|
+
expect(preloadKeySingle("welcome", "en")).toBe("welcome:en");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("formats multi preload keys as namespace:key:locale", () => {
|
|
19
|
+
expect(preloadKeyMulti("billing", "invoice_summary", "it")).toBe("billing:invoice_summary:it");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("seedPreloadedKeysSingle", () => {
|
|
24
|
+
it("seeds every key/locale pair from the initial dictionary", () => {
|
|
25
|
+
const preloadedKeys = new Set<string>();
|
|
26
|
+
seedPreloadedKeysSingle(
|
|
27
|
+
{
|
|
28
|
+
login_button: { en: "Login", it: "Accedi" },
|
|
29
|
+
welcome: { en: "Welcome {name}!" },
|
|
30
|
+
},
|
|
31
|
+
preloadedKeys
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(preloadedKeys).toEqual(new Set(["login_button:en", "login_button:it", "welcome:en"]));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("seedPreloadedKeysMulti", () => {
|
|
39
|
+
it("seeds every namespace/key/locale triple from the initial dictionary", () => {
|
|
40
|
+
const preloadedKeys = new Set<string>();
|
|
41
|
+
seedPreloadedKeysMulti(
|
|
42
|
+
{
|
|
43
|
+
default: { login_button: { en: "Login" } },
|
|
44
|
+
billing: { invoice_summary: { en: "One", it: "Uno" } },
|
|
45
|
+
},
|
|
46
|
+
preloadedKeys
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(preloadedKeys).toEqual(
|
|
50
|
+
new Set([
|
|
51
|
+
"default:login_button:en",
|
|
52
|
+
"billing:invoice_summary:en",
|
|
53
|
+
"billing:invoice_summary:it",
|
|
54
|
+
])
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("recordPreloadedKeysSingle", () => {
|
|
60
|
+
it("records locales from a load merge payload", () => {
|
|
61
|
+
const preloadedKeys = new Set<string>();
|
|
62
|
+
recordPreloadedKeysSingle(
|
|
63
|
+
{
|
|
64
|
+
welcome: { it: "Benvenuto {name}!" },
|
|
65
|
+
},
|
|
66
|
+
preloadedKeys
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(preloadedKeys).toEqual(new Set(["welcome:it"]));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("recordPreloadedKeysMulti", () => {
|
|
74
|
+
it("records locales from a namespace load merge payload", () => {
|
|
75
|
+
const preloadedKeys = new Set<string>();
|
|
76
|
+
recordPreloadedKeysMulti(
|
|
77
|
+
"billing",
|
|
78
|
+
{
|
|
79
|
+
invoice_summary: { it: "Hai {count} fatture" },
|
|
80
|
+
},
|
|
81
|
+
preloadedKeys
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(preloadedKeys).toEqual(new Set(["billing:invoice_summary:it"]));
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("assertPatchKeySingle", () => {
|
|
89
|
+
const preloadedKeys = new Set(["welcome:en", "welcome:it", "invoice_count:en"]);
|
|
90
|
+
|
|
91
|
+
it("accepts a valid patch for a preloaded key", () => {
|
|
92
|
+
expect(() =>
|
|
93
|
+
assertPatchKeySingle("welcome", "en", "Hello {name}!", preloadedKeys, {
|
|
94
|
+
en: "Welcome {name}!",
|
|
95
|
+
it: "Benvenuto {name}!",
|
|
96
|
+
})
|
|
97
|
+
).not.toThrow();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("rejects a key that was not preloaded", () => {
|
|
101
|
+
expect(() =>
|
|
102
|
+
assertPatchKeySingle("welcome", "fr", "Bonjour {name}!", preloadedKeys, {
|
|
103
|
+
en: "Welcome {name}!",
|
|
104
|
+
})
|
|
105
|
+
).toThrow("[i18n] Key not preloaded: welcome (fr)");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("rejects invalid ICU syntax", () => {
|
|
109
|
+
expect(() =>
|
|
110
|
+
assertPatchKeySingle("welcome", "en", "Hi {name", preloadedKeys, {
|
|
111
|
+
en: "Welcome {name}!",
|
|
112
|
+
})
|
|
113
|
+
).toThrow("[i18n] ICU syntax error on patch:");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("rejects incompatible ICU args against other locales for the same key", () => {
|
|
117
|
+
expect(() =>
|
|
118
|
+
assertPatchKeySingle(
|
|
119
|
+
"invoice_count",
|
|
120
|
+
"en",
|
|
121
|
+
"{count, select, other {{count}}}",
|
|
122
|
+
preloadedKeys,
|
|
123
|
+
{
|
|
124
|
+
en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
|
|
125
|
+
it: "Hai {count, plural, one {1 fattura} other {{count} fatture}}",
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
).toThrow("[i18n] ICU args mismatch on patch:");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("assertPatchKeyMulti", () => {
|
|
133
|
+
const preloadedKeys = new Set([
|
|
134
|
+
"billing:invoice_summary:en",
|
|
135
|
+
"billing:invoice_summary:it",
|
|
136
|
+
"default:login_button:en",
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
it("accepts a valid patch for a preloaded namespace key", () => {
|
|
140
|
+
expect(() =>
|
|
141
|
+
assertPatchKeyMulti(
|
|
142
|
+
"billing",
|
|
143
|
+
"invoice_summary",
|
|
144
|
+
"en",
|
|
145
|
+
"You have {count, plural, one {1 bill} other {{count} bills}} for {name}",
|
|
146
|
+
preloadedKeys,
|
|
147
|
+
{
|
|
148
|
+
en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
|
|
149
|
+
it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
).not.toThrow();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("rejects a namespace key that was not preloaded", () => {
|
|
156
|
+
expect(() =>
|
|
157
|
+
assertPatchKeyMulti("billing", "invoice_summary", "fr", "Facture {count}", preloadedKeys, {
|
|
158
|
+
en: "You have {count} invoices",
|
|
159
|
+
})
|
|
160
|
+
).toThrow("[i18n] Key not preloaded: billing.invoice_summary (fr)");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("rejects invalid ICU syntax", () => {
|
|
164
|
+
expect(() =>
|
|
165
|
+
assertPatchKeyMulti("default", "login_button", "en", "Sign {in", preloadedKeys, {
|
|
166
|
+
en: "Login",
|
|
167
|
+
})
|
|
168
|
+
).toThrow("[i18n] ICU syntax error on patch:");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("rejects incompatible ICU args against other locales for the same key", () => {
|
|
172
|
+
expect(() =>
|
|
173
|
+
assertPatchKeyMulti(
|
|
174
|
+
"billing",
|
|
175
|
+
"invoice_summary",
|
|
176
|
+
"en",
|
|
177
|
+
"{count, select, other {{count}}}",
|
|
178
|
+
preloadedKeys,
|
|
179
|
+
{
|
|
180
|
+
en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
|
|
181
|
+
it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
).toThrow("[i18n] ICU args mismatch on patch:");
|
|
185
|
+
});
|
|
186
|
+
});
|
package/src/patch-key.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { mergeVariableMetaAcrossLocales, type VariableMetaSpec } from "./icu/extract-variables.js";
|
|
2
|
+
import { parseTemplate } from "./icu/parse-template.js";
|
|
3
|
+
import type { KeyDictionary, PartialKeyDictionary } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export function preloadKeySingle(key: string, locale: string): string {
|
|
6
|
+
return `${key}:${locale}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function preloadKeyMulti(namespace: string, key: string, locale: string): string {
|
|
10
|
+
return `${namespace}:${key}:${locale}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function seedPreloadedKeysSingle(
|
|
14
|
+
dictionary: KeyDictionary,
|
|
15
|
+
preloadedKeys: Set<string>
|
|
16
|
+
): void {
|
|
17
|
+
for (const [key, localeByKey] of Object.entries(dictionary)) {
|
|
18
|
+
if (localeByKey === undefined || typeof localeByKey !== "object") {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
for (const locale of Object.keys(localeByKey)) {
|
|
22
|
+
preloadedKeys.add(preloadKeySingle(key, locale));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function seedPreloadedKeysMulti(
|
|
28
|
+
dictionary: Record<string, KeyDictionary>,
|
|
29
|
+
preloadedKeys: Set<string>
|
|
30
|
+
): void {
|
|
31
|
+
for (const [namespace, keyDictionary] of Object.entries(dictionary)) {
|
|
32
|
+
if (keyDictionary === undefined || typeof keyDictionary !== "object") {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
for (const [key, localeByKey] of Object.entries(keyDictionary)) {
|
|
36
|
+
if (localeByKey === undefined || typeof localeByKey !== "object") {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
for (const locale of Object.keys(localeByKey)) {
|
|
40
|
+
preloadedKeys.add(preloadKeyMulti(namespace, key, locale));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function recordPreloadedKeysSingle(
|
|
47
|
+
partial: PartialKeyDictionary<KeyDictionary, string>,
|
|
48
|
+
preloadedKeys: Set<string>
|
|
49
|
+
): void {
|
|
50
|
+
for (const [key, incomingLocales] of Object.entries(partial)) {
|
|
51
|
+
if (incomingLocales === undefined || typeof incomingLocales !== "object") {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
for (const locale of Object.keys(incomingLocales)) {
|
|
55
|
+
preloadedKeys.add(preloadKeySingle(key, locale));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function recordPreloadedKeysMulti(
|
|
61
|
+
namespace: string,
|
|
62
|
+
partial: PartialKeyDictionary<KeyDictionary, string>,
|
|
63
|
+
preloadedKeys: Set<string>
|
|
64
|
+
): void {
|
|
65
|
+
for (const [key, incomingLocales] of Object.entries(partial)) {
|
|
66
|
+
if (incomingLocales === undefined || typeof incomingLocales !== "object") {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
for (const locale of Object.keys(incomingLocales)) {
|
|
70
|
+
preloadedKeys.add(preloadKeyMulti(namespace, key, locale));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function collectLocaleMetasForKey(
|
|
76
|
+
localeByKey: Record<string, string>,
|
|
77
|
+
patchedLocale: string,
|
|
78
|
+
patchedMeta: VariableMetaSpec
|
|
79
|
+
): VariableMetaSpec[] {
|
|
80
|
+
const localeMetas: VariableMetaSpec[] = [patchedMeta];
|
|
81
|
+
|
|
82
|
+
for (const [locale, template] of Object.entries(localeByKey)) {
|
|
83
|
+
if (locale === patchedLocale) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const parsed = parseTemplate(template);
|
|
87
|
+
if (parsed.ok) {
|
|
88
|
+
localeMetas.push(parsed.meta);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return localeMetas;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function assertPatchKeySingle(
|
|
96
|
+
key: string,
|
|
97
|
+
locale: string,
|
|
98
|
+
template: string,
|
|
99
|
+
preloadedKeys: Set<string>,
|
|
100
|
+
localeByKey: Record<string, string> | undefined
|
|
101
|
+
): void {
|
|
102
|
+
if (!preloadedKeys.has(preloadKeySingle(key, locale))) {
|
|
103
|
+
throw new Error(`[i18n] Key not preloaded: ${key} (${locale})`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const parsed = parseTemplate(template);
|
|
107
|
+
if (!parsed.ok) {
|
|
108
|
+
throw new Error(`[i18n] ICU syntax error on patch: ${parsed.message}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const localeMetas = collectLocaleMetasForKey(localeByKey ?? {}, locale, parsed.meta);
|
|
112
|
+
const merged = mergeVariableMetaAcrossLocales(localeMetas);
|
|
113
|
+
if (!merged.ok) {
|
|
114
|
+
throw new Error(`[i18n] ICU args mismatch on patch: ${merged.message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function assertPatchKeyMulti(
|
|
119
|
+
namespace: string,
|
|
120
|
+
key: string,
|
|
121
|
+
locale: string,
|
|
122
|
+
template: string,
|
|
123
|
+
preloadedKeys: Set<string>,
|
|
124
|
+
localeByKey: Record<string, string> | undefined
|
|
125
|
+
): void {
|
|
126
|
+
if (!preloadedKeys.has(preloadKeyMulti(namespace, key, locale))) {
|
|
127
|
+
throw new Error(`[i18n] Key not preloaded: ${namespace}.${key} (${locale})`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const parsed = parseTemplate(template);
|
|
131
|
+
if (!parsed.ok) {
|
|
132
|
+
throw new Error(`[i18n] ICU syntax error on patch: ${parsed.message}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const localeMetas = collectLocaleMetasForKey(localeByKey ?? {}, locale, parsed.meta);
|
|
136
|
+
const merged = mergeVariableMetaAcrossLocales(localeMetas);
|
|
137
|
+
if (!merged.ok) {
|
|
138
|
+
throw new Error(`[i18n] ICU args mismatch on patch: ${merged.message}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
mergeDictionaryLocalesCore,
|
|
4
|
+
mergeNamespaceLocalesCore,
|
|
3
5
|
projectDictionaryForDeliveryAreaCore,
|
|
4
6
|
projectDictionaryLocalesCore,
|
|
5
7
|
projectNamespaceForDeliveryAreaCore,
|
|
@@ -202,3 +204,77 @@ describe("projectDictionaryLocalesCore", () => {
|
|
|
202
204
|
});
|
|
203
205
|
});
|
|
204
206
|
});
|
|
207
|
+
|
|
208
|
+
describe("mergeNamespaceLocalesCore", () => {
|
|
209
|
+
type TestDictionary = {
|
|
210
|
+
welcome: Partial<Record<"en" | "it", string>>;
|
|
211
|
+
login_button: Partial<Record<"en" | "it", string>>;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
it("merges locale maps per key without dropping existing locales", () => {
|
|
215
|
+
const existing: TestDictionary = {
|
|
216
|
+
welcome: { en: "Welcome {name}!" },
|
|
217
|
+
login_button: { en: "Login", it: "Accedi" },
|
|
218
|
+
};
|
|
219
|
+
const incoming = {
|
|
220
|
+
welcome: { it: "Benvenuto {name}!" },
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
expect(mergeNamespaceLocalesCore(existing, incoming)).toEqual({
|
|
224
|
+
welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
|
|
225
|
+
login_button: { en: "Login", it: "Accedi" },
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("overwrites the same locale when incoming provides a new template", () => {
|
|
230
|
+
const existing = {
|
|
231
|
+
welcome: { en: "Welcome {name}!" },
|
|
232
|
+
};
|
|
233
|
+
const incoming = {
|
|
234
|
+
welcome: { en: "Hello {name}!" },
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
expect(mergeNamespaceLocalesCore(existing, incoming)).toEqual({
|
|
238
|
+
welcome: { en: "Hello {name}!" },
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("mergeDictionaryLocalesCore", () => {
|
|
244
|
+
type TestMultiDictionary = {
|
|
245
|
+
default: {
|
|
246
|
+
welcome: Partial<Record<"en" | "it", string>>;
|
|
247
|
+
};
|
|
248
|
+
billing: {
|
|
249
|
+
invoice_summary: Partial<Record<"en" | "it", string>>;
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
it("merges each namespace without dropping existing locales", () => {
|
|
254
|
+
const existing: TestMultiDictionary = {
|
|
255
|
+
default: {
|
|
256
|
+
welcome: { en: "Welcome {name}!" },
|
|
257
|
+
},
|
|
258
|
+
billing: {
|
|
259
|
+
invoice_summary: { en: "{count} invoices" },
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
const incoming = {
|
|
263
|
+
default: {
|
|
264
|
+
welcome: { it: "Benvenuto {name}!" },
|
|
265
|
+
},
|
|
266
|
+
billing: {
|
|
267
|
+
invoice_summary: { it: "{count} fatture" },
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
expect(mergeDictionaryLocalesCore(existing, incoming)).toEqual({
|
|
272
|
+
default: {
|
|
273
|
+
welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
|
|
274
|
+
},
|
|
275
|
+
billing: {
|
|
276
|
+
invoice_summary: { en: "{count} invoices", it: "{count} fatture" },
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
});
|
package/src/project-locales.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { resolveLocaleTemplate } from "./resolve-locale.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
KeyDictionary,
|
|
4
|
+
LocaleFallbackMap,
|
|
5
|
+
LocaleOfMulti,
|
|
6
|
+
LocaleOfSingle,
|
|
7
|
+
MultiDictionary,
|
|
8
|
+
PartialKeyDictionary,
|
|
9
|
+
PartialMultiDictionary,
|
|
10
|
+
} from "./types.js";
|
|
3
11
|
|
|
4
12
|
function classifyAreaLocales(
|
|
5
13
|
areaLocales: readonly string[],
|
|
@@ -152,3 +160,52 @@ export function projectDictionaryLocalesCore<T extends MultiDictionary>(
|
|
|
152
160
|
|
|
153
161
|
return result;
|
|
154
162
|
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Merges locale entries per translation key without dropping keys or locales
|
|
166
|
+
* already present in `existing`. Incoming locales overwrite the same locale on conflict.
|
|
167
|
+
*/
|
|
168
|
+
export function mergeNamespaceLocalesCore<
|
|
169
|
+
T extends KeyDictionary,
|
|
170
|
+
Locales extends string = LocaleOfSingle<T>,
|
|
171
|
+
>(existing: T, incoming: PartialKeyDictionary<T, Locales>): T {
|
|
172
|
+
const merged = structuredClone(existing);
|
|
173
|
+
|
|
174
|
+
for (const [key, incomingLocales] of Object.entries(incoming)) {
|
|
175
|
+
if (incomingLocales === undefined || typeof incomingLocales !== "object") {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
merged[key as keyof T] = {
|
|
180
|
+
...(merged[key as keyof T] ?? {}),
|
|
181
|
+
...incomingLocales,
|
|
182
|
+
} as T[keyof T];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return merged;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Merges locale entries per key in every namespace present in `incoming`.
|
|
190
|
+
* Namespaces and keys not in `incoming` are preserved from `existing`.
|
|
191
|
+
*/
|
|
192
|
+
export function mergeDictionaryLocalesCore<
|
|
193
|
+
T extends MultiDictionary,
|
|
194
|
+
Locales extends string = LocaleOfMulti<T>,
|
|
195
|
+
>(existing: T, incoming: PartialMultiDictionary<T, Locales>): T {
|
|
196
|
+
const merged = structuredClone(existing) as T;
|
|
197
|
+
|
|
198
|
+
for (const namespace of Object.keys(incoming) as (keyof T & string)[]) {
|
|
199
|
+
const incomingNamespace = incoming[namespace];
|
|
200
|
+
if (incomingNamespace === undefined) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
merged[namespace] = mergeNamespaceLocalesCore(
|
|
205
|
+
(merged[namespace] ?? {}) as T[typeof namespace],
|
|
206
|
+
incomingNamespace as T[typeof namespace]
|
|
207
|
+
) as T[typeof namespace];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return merged;
|
|
211
|
+
}
|