@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
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
|
|
|
@@ -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
|
+
}
|
|
@@ -206,21 +206,23 @@ describe("projectDictionaryLocalesCore", () => {
|
|
|
206
206
|
});
|
|
207
207
|
|
|
208
208
|
describe("mergeNamespaceLocalesCore", () => {
|
|
209
|
+
type TestDictionary = {
|
|
210
|
+
welcome: Partial<Record<"en" | "it", string>>;
|
|
211
|
+
login_button: Partial<Record<"en" | "it", string>>;
|
|
212
|
+
};
|
|
213
|
+
|
|
209
214
|
it("merges locale maps per key without dropping existing locales", () => {
|
|
210
|
-
const existing = {
|
|
215
|
+
const existing: TestDictionary = {
|
|
211
216
|
welcome: { en: "Welcome {name}!" },
|
|
212
217
|
login_button: { en: "Login", it: "Accedi" },
|
|
213
218
|
};
|
|
214
219
|
const incoming = {
|
|
215
220
|
welcome: { it: "Benvenuto {name}!" },
|
|
216
|
-
invoice_summary: { en: "{count} invoices" },
|
|
217
221
|
};
|
|
218
222
|
|
|
219
|
-
// @ts-expect-error missing key in incoming
|
|
220
223
|
expect(mergeNamespaceLocalesCore(existing, incoming)).toEqual({
|
|
221
224
|
welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
|
|
222
225
|
login_button: { en: "Login", it: "Accedi" },
|
|
223
|
-
invoice_summary: { en: "{count} invoices" },
|
|
224
226
|
});
|
|
225
227
|
});
|
|
226
228
|
|
|
@@ -239,8 +241,17 @@ describe("mergeNamespaceLocalesCore", () => {
|
|
|
239
241
|
});
|
|
240
242
|
|
|
241
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
|
+
|
|
242
253
|
it("merges each namespace without dropping existing locales", () => {
|
|
243
|
-
const existing = {
|
|
254
|
+
const existing: TestMultiDictionary = {
|
|
244
255
|
default: {
|
|
245
256
|
welcome: { en: "Welcome {name}!" },
|
|
246
257
|
},
|
|
@@ -257,7 +268,6 @@ describe("mergeDictionaryLocalesCore", () => {
|
|
|
257
268
|
},
|
|
258
269
|
};
|
|
259
270
|
|
|
260
|
-
// @ts-expect-error missing locale
|
|
261
271
|
expect(mergeDictionaryLocalesCore(existing, incoming)).toEqual({
|
|
262
272
|
default: {
|
|
263
273
|
welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
|
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[],
|
|
@@ -157,7 +165,10 @@ export function projectDictionaryLocalesCore<T extends MultiDictionary>(
|
|
|
157
165
|
* Merges locale entries per translation key without dropping keys or locales
|
|
158
166
|
* already present in `existing`. Incoming locales overwrite the same locale on conflict.
|
|
159
167
|
*/
|
|
160
|
-
export function mergeNamespaceLocalesCore<
|
|
168
|
+
export function mergeNamespaceLocalesCore<
|
|
169
|
+
T extends KeyDictionary,
|
|
170
|
+
Locales extends string = LocaleOfSingle<T>,
|
|
171
|
+
>(existing: T, incoming: PartialKeyDictionary<T, Locales>): T {
|
|
161
172
|
const merged = structuredClone(existing);
|
|
162
173
|
|
|
163
174
|
for (const [key, incomingLocales] of Object.entries(incoming)) {
|
|
@@ -178,10 +189,10 @@ export function mergeNamespaceLocalesCore<T extends KeyDictionary>(existing: T,
|
|
|
178
189
|
* Merges locale entries per key in every namespace present in `incoming`.
|
|
179
190
|
* Namespaces and keys not in `incoming` are preserved from `existing`.
|
|
180
191
|
*/
|
|
181
|
-
export function mergeDictionaryLocalesCore<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
): T {
|
|
192
|
+
export function mergeDictionaryLocalesCore<
|
|
193
|
+
T extends MultiDictionary,
|
|
194
|
+
Locales extends string = LocaleOfMulti<T>,
|
|
195
|
+
>(existing: T, incoming: PartialMultiDictionary<T, Locales>): T {
|
|
185
196
|
const merged = structuredClone(existing) as T;
|
|
186
197
|
|
|
187
198
|
for (const namespace of Object.keys(incoming) as (keyof T & string)[]) {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
|
|
2
|
+
import type { LocaleFallbackMap, LocaleOfMulti, MultiDictionary } from "./types.js";
|
|
3
|
+
import type { MultiParams, ParamArgs } from "./scope-types.js";
|
|
4
|
+
|
|
5
|
+
/** Type-safe translation scope for a multi-namespace schema subset. */
|
|
6
|
+
export interface I18nScopeMulti<
|
|
7
|
+
Schema extends MultiDictionary,
|
|
8
|
+
Params,
|
|
9
|
+
RequestLocales extends string = LocaleOfMulti<Schema>,
|
|
10
|
+
> {
|
|
11
|
+
t<
|
|
12
|
+
const NS extends keyof Schema & keyof Params & string,
|
|
13
|
+
const K extends keyof Schema[NS] & keyof Params[NS] & string,
|
|
14
|
+
>(
|
|
15
|
+
namespace: NS,
|
|
16
|
+
key: K,
|
|
17
|
+
locale: RequestLocales,
|
|
18
|
+
...params: ParamArgs<Params[NS][K]>
|
|
19
|
+
): string;
|
|
20
|
+
forLocale<Locale extends RequestLocales>(
|
|
21
|
+
locale: Locale
|
|
22
|
+
): I18nScopeMultiForLocale<Schema, Params, RequestLocales, Locale>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Multi-namespace scope with a bound locale — `t(namespace, key)` and `set(...)` without a locale argument. */
|
|
26
|
+
export interface I18nScopeMultiForLocale<
|
|
27
|
+
Schema extends MultiDictionary,
|
|
28
|
+
Params,
|
|
29
|
+
RequestLocales extends string,
|
|
30
|
+
Locale extends RequestLocales,
|
|
31
|
+
> {
|
|
32
|
+
readonly locale: Locale;
|
|
33
|
+
t<
|
|
34
|
+
const NS extends keyof Schema & keyof Params & string,
|
|
35
|
+
const K extends keyof Schema[NS] & keyof Params[NS] & string,
|
|
36
|
+
>(
|
|
37
|
+
namespace: NS,
|
|
38
|
+
key: K,
|
|
39
|
+
...params: ParamArgs<Params[NS][K]>
|
|
40
|
+
): string;
|
|
41
|
+
set<
|
|
42
|
+
const NS extends keyof Schema & keyof Params & string,
|
|
43
|
+
const K extends keyof Schema[NS] & keyof Params[NS] & string,
|
|
44
|
+
>(
|
|
45
|
+
namespace: NS,
|
|
46
|
+
key: K,
|
|
47
|
+
template: string
|
|
48
|
+
): void;
|
|
49
|
+
forLocale<Next extends RequestLocales>(
|
|
50
|
+
locale: Next
|
|
51
|
+
): I18nScopeMultiForLocale<Schema, Params, RequestLocales, Next>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class I18nScopeMultiImpl<
|
|
55
|
+
Schema extends MultiDictionary,
|
|
56
|
+
Params extends MultiParams<Schema>,
|
|
57
|
+
RequestLocales extends string,
|
|
58
|
+
Fallback extends LocaleFallbackMap | undefined,
|
|
59
|
+
> implements I18nScopeMulti<Schema, Params, RequestLocales> {
|
|
60
|
+
constructor(
|
|
61
|
+
private readonly engine: IcuTranslationProviderMulti<Schema, Params, RequestLocales, Fallback>
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
t = <
|
|
65
|
+
const NS extends keyof Schema & keyof Params & string,
|
|
66
|
+
const K extends keyof Schema[NS] & keyof Params[NS] & string,
|
|
67
|
+
>(
|
|
68
|
+
namespace: NS,
|
|
69
|
+
key: K,
|
|
70
|
+
locale: RequestLocales,
|
|
71
|
+
...args: ParamArgs<Params[NS][K]>
|
|
72
|
+
): string => {
|
|
73
|
+
const params = args[0] as Record<string, unknown> | undefined;
|
|
74
|
+
return this.engine.getWithLocale(String(namespace), String(key), locale, params);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
forLocale = <Locale extends RequestLocales>(
|
|
78
|
+
locale: Locale
|
|
79
|
+
): I18nScopeMultiForLocaleImpl<Schema, Params, RequestLocales, Locale, Fallback> => {
|
|
80
|
+
return new I18nScopeMultiForLocaleImpl(this.engine, locale);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class I18nScopeMultiForLocaleImpl<
|
|
85
|
+
Schema extends MultiDictionary,
|
|
86
|
+
Params extends MultiParams<Schema>,
|
|
87
|
+
RequestLocales extends string,
|
|
88
|
+
Locale extends RequestLocales,
|
|
89
|
+
Fallback extends LocaleFallbackMap | undefined,
|
|
90
|
+
> implements I18nScopeMultiForLocale<Schema, Params, RequestLocales, Locale> {
|
|
91
|
+
constructor(
|
|
92
|
+
private readonly engine: IcuTranslationProviderMulti<Schema, Params, RequestLocales, Fallback>,
|
|
93
|
+
readonly locale: Locale
|
|
94
|
+
) {}
|
|
95
|
+
|
|
96
|
+
t = <
|
|
97
|
+
const NS extends keyof Schema & keyof Params & string,
|
|
98
|
+
const K extends keyof Schema[NS] & keyof Params[NS] & string,
|
|
99
|
+
>(
|
|
100
|
+
namespace: NS,
|
|
101
|
+
key: K,
|
|
102
|
+
...args: ParamArgs<Params[NS][K]>
|
|
103
|
+
): string => {
|
|
104
|
+
const params = args[0] as Record<string, unknown> | undefined;
|
|
105
|
+
return this.engine.getWithLocale(String(namespace), String(key), this.locale, params);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
set = <
|
|
109
|
+
const NS extends keyof Schema & keyof Params & string,
|
|
110
|
+
const K extends keyof Schema[NS] & keyof Params[NS] & string,
|
|
111
|
+
>(
|
|
112
|
+
namespace: NS,
|
|
113
|
+
key: K,
|
|
114
|
+
template: string
|
|
115
|
+
): void => {
|
|
116
|
+
this.engine.patchKeyMulti(String(namespace), String(key), this.locale, template);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
forLocale = <Next extends RequestLocales>(
|
|
120
|
+
locale: Next
|
|
121
|
+
): I18nScopeMultiForLocaleImpl<Schema, Params, RequestLocales, Next, Fallback> => {
|
|
122
|
+
return new I18nScopeMultiForLocaleImpl(this.engine, locale);
|
|
123
|
+
};
|
|
124
|
+
}
|