@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
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { cloneAndFreeze } from "./deep-freeze.js";
|
|
2
|
+
import { BuilderLoadRegistry } from "./builder-load-registry.js";
|
|
3
|
+
import type { I18nEngineMulti } from "./engine.js";
|
|
2
4
|
import { resolveAndFormat } from "./format-core.js";
|
|
5
|
+
import {
|
|
6
|
+
assertPatchKeyMulti,
|
|
7
|
+
recordPreloadedKeysMulti,
|
|
8
|
+
seedPreloadedKeysMulti,
|
|
9
|
+
} from "./patch-key.js";
|
|
3
10
|
import { mergeNamespaceLocalesCore } from "./project-locales.js";
|
|
4
11
|
import { validateLocaleFallback } from "./resolve-locale.js";
|
|
5
12
|
import type {
|
|
@@ -9,85 +16,34 @@ import type {
|
|
|
9
16
|
MultiCompiledCache,
|
|
10
17
|
MultiDictionary,
|
|
11
18
|
OnMissingTranslation,
|
|
19
|
+
PartialKeyDictionary,
|
|
20
|
+
PartialMultiDictionary,
|
|
12
21
|
} from "./types.js";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
|
|
17
|
-
Locale extends string,
|
|
18
|
-
> {
|
|
19
|
-
readonly locale: Locale;
|
|
20
|
-
get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
|
|
21
|
-
namespace: NS,
|
|
22
|
-
key: K,
|
|
23
|
-
...params: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
|
|
24
|
-
): string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface TranslationProviderMulti<
|
|
28
|
-
Schema extends MultiDictionary,
|
|
29
|
-
Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
|
|
30
|
-
RequestLocales extends string = LocaleOfMulti<Schema>,
|
|
31
|
-
> {
|
|
32
|
-
get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
|
|
33
|
-
namespace: NS,
|
|
34
|
-
key: K,
|
|
35
|
-
locale: RequestLocales,
|
|
36
|
-
...params: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
|
|
37
|
-
): string;
|
|
38
|
-
forLocale<Locale extends RequestLocales>(
|
|
39
|
-
locale: Locale
|
|
40
|
-
): TranslationProviderMultiForLocale<Schema, Params, Locale>;
|
|
41
|
-
getAll(): Schema;
|
|
42
|
-
hasNamespace<NS extends keyof Schema & string>(namespace: NS): boolean;
|
|
43
|
-
setAll(values: Schema): void;
|
|
44
|
-
mergeAll(values: Partial<Schema>): void;
|
|
45
|
-
setNamespace<NS extends keyof Schema>(namespace: NS, values: Schema[NS]): void;
|
|
46
|
-
mergeNamespace<NS extends keyof Schema>(namespace: NS, values: Schema[NS]): void;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export class IcuTranslationProviderMultiForLocale<
|
|
50
|
-
Schema extends MultiDictionary,
|
|
51
|
-
Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
|
|
52
|
-
RequestLocales extends string,
|
|
53
|
-
Locale extends RequestLocales,
|
|
54
|
-
Fallback extends LocaleFallbackMap | undefined,
|
|
55
|
-
> implements TranslationProviderMultiForLocale<Schema, Params, Locale> {
|
|
56
|
-
constructor(
|
|
57
|
-
private readonly provider: IcuTranslationProviderMulti<
|
|
58
|
-
Schema,
|
|
59
|
-
Params,
|
|
60
|
-
RequestLocales,
|
|
61
|
-
Fallback
|
|
62
|
-
>,
|
|
63
|
-
readonly locale: Locale
|
|
64
|
-
) {}
|
|
65
|
-
|
|
66
|
-
get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
|
|
67
|
-
namespace: NS,
|
|
68
|
-
key: K,
|
|
69
|
-
...args: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
|
|
70
|
-
): string {
|
|
71
|
-
const params = args[0] as Record<string, unknown> | undefined;
|
|
72
|
-
return this.provider.getWithLocale(String(namespace), String(key), this.locale, params);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
22
|
+
import { I18nScopeMultiForLocaleImpl, I18nScopeMultiImpl } from "./scope-multi.js";
|
|
23
|
+
import type { I18nScopeMulti, I18nScopeMultiForLocale } from "./scope-multi.js";
|
|
24
|
+
import type { MultiParams, ParamsForNamespaces, SchemaForNamespaces } from "./scope-types.js";
|
|
75
25
|
|
|
76
26
|
export class IcuTranslationProviderMulti<
|
|
77
27
|
Schema extends MultiDictionary,
|
|
78
|
-
Params extends
|
|
28
|
+
Params extends MultiParams<Schema>,
|
|
79
29
|
RequestLocales extends string = LocaleOfMulti<Schema>,
|
|
80
30
|
Fallback extends LocaleFallbackMap | undefined = undefined,
|
|
81
|
-
> implements
|
|
31
|
+
> implements I18nEngineMulti<Schema, Params, RequestLocales> {
|
|
32
|
+
readonly __i18nEngineMode = "multi" as const;
|
|
33
|
+
|
|
82
34
|
private dictionary: Schema;
|
|
83
35
|
private compiledCache: MultiCompiledCache = {};
|
|
36
|
+
private readonly preloadedKeys = new Set<string>();
|
|
37
|
+
private readonly builderLoadRegistry = new BuilderLoadRegistry();
|
|
84
38
|
private readonly localeFallback?: Fallback;
|
|
85
39
|
private readonly onMissing: OnMissingTranslation;
|
|
86
|
-
private loadedNamespaces: Set<string>;
|
|
87
40
|
|
|
88
|
-
constructor(
|
|
41
|
+
constructor(
|
|
42
|
+
dictionary: PartialMultiDictionary<Schema, RequestLocales>,
|
|
43
|
+
options?: IcuTranslationProviderOptions<Fallback>
|
|
44
|
+
) {
|
|
89
45
|
this.dictionary = structuredClone(dictionary) as Schema;
|
|
90
|
-
this.
|
|
46
|
+
seedPreloadedKeysMulti(this.dictionary, this.preloadedKeys);
|
|
91
47
|
if (options?.localeFallback) {
|
|
92
48
|
validateLocaleFallback(options.localeFallback);
|
|
93
49
|
this.localeFallback = options.localeFallback;
|
|
@@ -95,28 +51,12 @@ export class IcuTranslationProviderMulti<
|
|
|
95
51
|
this.onMissing = options?.onMissing ?? "throw";
|
|
96
52
|
}
|
|
97
53
|
|
|
98
|
-
get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
|
|
99
|
-
namespace: NS,
|
|
100
|
-
key: K,
|
|
101
|
-
locale: RequestLocales,
|
|
102
|
-
...args: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
|
|
103
|
-
): string {
|
|
104
|
-
const params = args[0] as Record<string, unknown> | undefined;
|
|
105
|
-
return this.getWithLocale(String(namespace), String(key), locale, params);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
54
|
getWithLocale(
|
|
109
55
|
namespace: string,
|
|
110
56
|
key: string,
|
|
111
57
|
locale: string,
|
|
112
58
|
params?: Record<string, unknown>
|
|
113
59
|
): string {
|
|
114
|
-
if (!this.loadedNamespaces.has(namespace)) {
|
|
115
|
-
throw new Error(
|
|
116
|
-
`[i18n] Namespace not loaded: "${namespace}". Register it with setNamespace() before calling .get().`
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
60
|
const localeByKey = (
|
|
121
61
|
this.dictionary[namespace] as Record<string, Record<string, string>> | undefined
|
|
122
62
|
)?.[key];
|
|
@@ -144,54 +84,103 @@ export class IcuTranslationProviderMulti<
|
|
|
144
84
|
});
|
|
145
85
|
}
|
|
146
86
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
87
|
+
toScope<
|
|
88
|
+
NsList extends readonly (keyof Schema & string)[],
|
|
89
|
+
Locale extends RequestLocales,
|
|
90
|
+
>(options: {
|
|
91
|
+
namespaces: NsList;
|
|
92
|
+
locale: Locale;
|
|
93
|
+
}): I18nScopeMultiForLocale<
|
|
94
|
+
SchemaForNamespaces<Schema, NsList>,
|
|
95
|
+
ParamsForNamespaces<Schema, Params, NsList>,
|
|
96
|
+
RequestLocales,
|
|
97
|
+
Locale
|
|
98
|
+
>;
|
|
99
|
+
toScope<NsList extends readonly (keyof Schema & string)[]>(options: {
|
|
100
|
+
namespaces: NsList;
|
|
101
|
+
}): I18nScopeMulti<
|
|
102
|
+
SchemaForNamespaces<Schema, NsList>,
|
|
103
|
+
ParamsForNamespaces<Schema, Params, NsList>,
|
|
104
|
+
RequestLocales
|
|
105
|
+
>;
|
|
106
|
+
toScope<
|
|
107
|
+
NsList extends readonly (keyof Schema & string)[],
|
|
108
|
+
Locale extends RequestLocales,
|
|
109
|
+
>(options: { namespaces: NsList; locale?: Locale }) {
|
|
110
|
+
if (options.locale !== undefined) {
|
|
111
|
+
return new I18nScopeMultiForLocaleImpl(this, options.locale) as I18nScopeMultiForLocale<
|
|
112
|
+
SchemaForNamespaces<Schema, NsList>,
|
|
113
|
+
ParamsForNamespaces<Schema, Params, NsList>,
|
|
114
|
+
RequestLocales,
|
|
115
|
+
Locale
|
|
116
|
+
>;
|
|
117
|
+
}
|
|
118
|
+
return new I18nScopeMultiImpl(this) as I18nScopeMulti<
|
|
119
|
+
SchemaForNamespaces<Schema, NsList>,
|
|
120
|
+
ParamsForNamespaces<Schema, Params, NsList>,
|
|
121
|
+
RequestLocales
|
|
122
|
+
>;
|
|
151
123
|
}
|
|
152
124
|
|
|
153
125
|
getAll(): Schema {
|
|
154
126
|
return cloneAndFreeze(this.dictionary);
|
|
155
127
|
}
|
|
156
128
|
|
|
157
|
-
|
|
158
|
-
return this.
|
|
129
|
+
hasBuilderResourceLoaded(namespace: string, partition: string | undefined): boolean {
|
|
130
|
+
return this.builderLoadRegistry.has(namespace, partition);
|
|
159
131
|
}
|
|
160
132
|
|
|
161
|
-
|
|
162
|
-
this.
|
|
163
|
-
this.compiledCache = {};
|
|
164
|
-
this.loadedNamespaces = new Set(Object.keys(values));
|
|
133
|
+
markBuilderResourceLoaded(namespace: string, partition: string | undefined): void {
|
|
134
|
+
this.builderLoadRegistry.mark(namespace, partition);
|
|
165
135
|
}
|
|
166
136
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
}
|
|
137
|
+
applyLoadMergeNamespace<NS extends keyof Schema>(
|
|
138
|
+
namespace: NS,
|
|
139
|
+
values: PartialKeyDictionary<Schema[NS], RequestLocales>
|
|
140
|
+
): void {
|
|
141
|
+
const existing = this.dictionary[namespace];
|
|
142
|
+
const merged = mergeNamespaceLocalesCore((existing ?? {}) as Schema[NS], values);
|
|
175
143
|
|
|
176
|
-
setNamespace<NS extends keyof Schema>(namespace: NS, values: Schema[NS]): void {
|
|
177
144
|
this.dictionary = {
|
|
178
145
|
...this.dictionary,
|
|
179
|
-
[namespace]:
|
|
146
|
+
[namespace]: merged,
|
|
180
147
|
};
|
|
181
|
-
|
|
148
|
+
recordPreloadedKeysMulti(namespace as string, values, this.preloadedKeys);
|
|
149
|
+
|
|
150
|
+
for (const [key, incomingLocales] of Object.entries(values)) {
|
|
151
|
+
if (incomingLocales === undefined || typeof incomingLocales !== "object") {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
182
154
|
|
|
183
|
-
|
|
184
|
-
|
|
155
|
+
for (const locale of Object.keys(incomingLocales)) {
|
|
156
|
+
delete this.compiledCache[locale]?.[namespace as string]?.[key];
|
|
157
|
+
}
|
|
185
158
|
}
|
|
186
159
|
}
|
|
187
160
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
161
|
+
applyLoadMergeAll(values: PartialMultiDictionary<Schema, RequestLocales>): void {
|
|
162
|
+
for (const namespace of Object.keys(values) as (keyof Schema & string)[]) {
|
|
163
|
+
const incoming = values[namespace];
|
|
164
|
+
if (incoming !== undefined) {
|
|
165
|
+
this.applyLoadMergeNamespace(namespace, incoming);
|
|
166
|
+
}
|
|
192
167
|
}
|
|
168
|
+
}
|
|
193
169
|
|
|
194
|
-
|
|
195
|
-
|
|
170
|
+
patchKeyMulti(namespace: string, key: string, locale: string, template: string): void {
|
|
171
|
+
const namespaceDictionary = this.dictionary[namespace] as
|
|
172
|
+
| Record<string, Record<string, string>>
|
|
173
|
+
| undefined;
|
|
174
|
+
const localeByKey = namespaceDictionary?.[key];
|
|
175
|
+
|
|
176
|
+
assertPatchKeyMulti(namespace, key, locale, template, this.preloadedKeys, localeByKey);
|
|
177
|
+
|
|
178
|
+
this.applyLoadMergeNamespace(
|
|
179
|
+
namespace as keyof Schema,
|
|
180
|
+
{ [key]: { [locale]: template } } as PartialKeyDictionary<
|
|
181
|
+
Schema[keyof Schema],
|
|
182
|
+
RequestLocales
|
|
183
|
+
>
|
|
184
|
+
);
|
|
196
185
|
}
|
|
197
186
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { IcuTranslationProviderSingle } from "./IcuTranslationProviderSingle.js";
|
|
3
|
-
import type { LocaleOfSingle } from "./types.js";
|
|
4
3
|
|
|
5
4
|
type TestSchema = {
|
|
6
5
|
login_button: { en: string; it: string };
|
|
@@ -82,160 +81,19 @@ const dictionary: TestSchema = {
|
|
|
82
81
|
};
|
|
83
82
|
|
|
84
83
|
describe("IcuTranslationProviderSingle", () => {
|
|
85
|
-
const
|
|
84
|
+
const engine = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary);
|
|
86
85
|
|
|
87
|
-
it("
|
|
88
|
-
expect(provider.get("login_button", "it")).toBe("Accedi");
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("interpolates ICU arguments", () => {
|
|
92
|
-
expect(provider.get("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
describe("ICU plural interpolation", () => {
|
|
96
|
-
it("formats a single numeric plural in English and Italian", () => {
|
|
97
|
-
expect(provider.get("invoice_count", "en", { count: 1 })).toBe("You have 1 invoice");
|
|
98
|
-
expect(provider.get("invoice_count", "en", { count: 5 })).toBe("You have 5 invoices");
|
|
99
|
-
expect(provider.get("invoice_count", "it", { count: 1 })).toBe("Hai 1 fattura");
|
|
100
|
-
expect(provider.get("invoice_count", "it", { count: 5 })).toBe("Hai 5 fatture");
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("uses =5 for an exact match; zero is a locale plural category, not exact match in English", () => {
|
|
104
|
-
// ICU "zero" is a plural rule category (e.g. Arabic); in English, 0 maps to "other".
|
|
105
|
-
expect(provider.get("item_count_zero", "en", { count: 0 })).toBe("0 items");
|
|
106
|
-
// "=5" is an exact-value selector and matches count === 5 in any locale.
|
|
107
|
-
expect(provider.get("item_count_exact", "en", { count: 0 })).toBe("0 items");
|
|
108
|
-
expect(provider.get("item_count_exact", "en", { count: 1 })).toBe("1 item");
|
|
109
|
-
expect(provider.get("item_count_exact", "en", { count: 5 })).toBe("five items");
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("formats nested numeric plurals with double-brace references", () => {
|
|
113
|
-
expect(provider.get("dashboard_status", "en", { msgCount: 1, chatCount: 1 })).toBe(
|
|
114
|
-
"You have 1 message in one chat"
|
|
115
|
-
);
|
|
116
|
-
expect(provider.get("dashboard_status", "en", { msgCount: 3, chatCount: 2 })).toBe(
|
|
117
|
-
"You have 3 messages in 2 chats"
|
|
118
|
-
);
|
|
119
|
-
expect(provider.get("dashboard_status", "it", { msgCount: 1, chatCount: 1 })).toBe(
|
|
120
|
-
"Hai 1 messaggio in una chat"
|
|
121
|
-
);
|
|
122
|
-
expect(provider.get("dashboard_status", "it", { msgCount: 3, chatCount: 2 })).toBe(
|
|
123
|
-
"Hai 3 messaggi in 2 chat"
|
|
124
|
-
);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("throws when a required numeric plural parameter is missing", () => {
|
|
128
|
-
expect(() => provider.get("dashboard_status", "en", { msgCount: 1 } as never)).toThrow(
|
|
129
|
-
"[i18n Formatting Error]"
|
|
130
|
-
);
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
describe("additional ICU variants", () => {
|
|
135
|
-
it("formats select arguments as strings", () => {
|
|
136
|
-
expect(provider.get("inbox_owner", "en", { gender: "female", name: "Ada" })).toBe(
|
|
137
|
-
"Ada owns her inbox"
|
|
138
|
-
);
|
|
139
|
-
expect(provider.get("inbox_owner", "en", { gender: "unknown", name: "Sam" })).toBe(
|
|
140
|
-
"Sam owns their inbox"
|
|
141
|
-
);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("formats Italian select with a single other branch and the same params", () => {
|
|
145
|
-
expect(provider.get("inbox_owner", "it", { gender: "female", name: "Ada" })).toBe(
|
|
146
|
-
"Ada gestisce la propria casella"
|
|
147
|
-
);
|
|
148
|
-
expect(provider.get("inbox_owner", "it", { gender: "male", name: "Luca" })).toBe(
|
|
149
|
-
"Luca gestisce la propria casella"
|
|
150
|
-
);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("formats selectordinal arguments as numbers", () => {
|
|
154
|
-
expect(provider.get("ranking_position", "en", { position: 1 })).toBe("You finished 1st");
|
|
155
|
-
expect(provider.get("ranking_position", "en", { position: 2 })).toBe("You finished 2nd");
|
|
156
|
-
expect(provider.get("ranking_position", "en", { position: 3 })).toBe("You finished 3rd");
|
|
157
|
-
expect(provider.get("ranking_position", "en", { position: 4 })).toBe("You finished 4th");
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it("formats number, date, and time arguments", () => {
|
|
161
|
-
const amount = 1234.5;
|
|
162
|
-
const when = new Date("2026-07-01T13:30:00Z");
|
|
163
|
-
|
|
164
|
-
const expectedAmount = new Intl.NumberFormat("en", {
|
|
165
|
-
style: "currency",
|
|
166
|
-
currency: "EUR",
|
|
167
|
-
}).format(amount);
|
|
168
|
-
const expectedDate = new Intl.DateTimeFormat("en", {
|
|
169
|
-
dateStyle: "short",
|
|
170
|
-
}).format(when);
|
|
171
|
-
const expectedTime = new Intl.DateTimeFormat("en", {
|
|
172
|
-
timeStyle: "short",
|
|
173
|
-
}).format(when);
|
|
174
|
-
|
|
175
|
-
expect(provider.get("account_balance", "en", { amount })).toBe(`Balance: ${expectedAmount}`);
|
|
176
|
-
expect(
|
|
177
|
-
provider.get("appointment_summary", "en", {
|
|
178
|
-
dueDate: when,
|
|
179
|
-
startTime: when,
|
|
180
|
-
})
|
|
181
|
-
).toBe(`Due ${expectedDate} at ${expectedTime}`);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it("formats ICU date and number skeletons (::yMMMMd, ::percent)", () => {
|
|
185
|
-
const when = new Date("2026-07-01T13:30:00Z");
|
|
186
|
-
const expectedLongDate = new Intl.DateTimeFormat("en", {
|
|
187
|
-
year: "numeric",
|
|
188
|
-
month: "long",
|
|
189
|
-
day: "numeric",
|
|
190
|
-
}).format(when);
|
|
191
|
-
const expectedPercent = new Intl.NumberFormat("en", {
|
|
192
|
-
style: "percent",
|
|
193
|
-
}).format(0.25);
|
|
194
|
-
|
|
195
|
-
expect(provider.get("invoice_due_long", "en", { dueDate: when })).toBe(
|
|
196
|
-
`Payment due on ${expectedLongDate}`
|
|
197
|
-
);
|
|
198
|
-
expect(provider.get("discount_rate", "en", { rate: 0.25 })).toBe(
|
|
199
|
-
`Save ${expectedPercent} today`
|
|
200
|
-
);
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it("treats an empty string template as valid", () => {
|
|
205
|
-
expect(provider.get("empty_label", "en")).toBe("");
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("throws when the key or locale is missing", () => {
|
|
209
|
-
expect(() =>
|
|
210
|
-
provider.get("login_button", "fr" as unknown as LocaleOfSingle<TestSchema>)
|
|
211
|
-
).toThrow('[i18n] Missing key or locale: "login_button" [fr] (fallback chain: fr)');
|
|
212
|
-
expect(() => provider.get("missing_key" as "login_button", "en")).toThrow(
|
|
213
|
-
'[i18n] Missing key or locale: "missing_key" [en] (fallback chain: en)'
|
|
214
|
-
);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it("throws on malformed ICU syntax", () => {
|
|
218
|
-
expect(() => provider.get("broken", "en", { name: "Ada" })).toThrow("[i18n ICU Syntax Error]");
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it("throws when required parameters are missing", () => {
|
|
222
|
-
// @ts-expect-error
|
|
223
|
-
expect(() => provider.get("welcome", "en")).toThrow("[i18n Formatting Error]");
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
it("replaces the dictionary and invalidates cache on setAll", () => {
|
|
86
|
+
it("patches a preloaded key and invalidates cache", () => {
|
|
227
87
|
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary);
|
|
228
|
-
expect(local.
|
|
88
|
+
expect(local.toScope().t("login_button", "en")).toBe("Login");
|
|
229
89
|
|
|
230
|
-
local.
|
|
231
|
-
...dictionary,
|
|
232
|
-
login_button: { en: "Sign in", it: "Entra" },
|
|
233
|
-
});
|
|
90
|
+
local.patchKey("login_button", "en", "Sign in");
|
|
234
91
|
|
|
235
|
-
expect(local.
|
|
92
|
+
expect(local.toScope().t("login_button", "en")).toBe("Sign in");
|
|
93
|
+
expect(local.toScope().t("login_button", "it")).toBe("Accedi");
|
|
236
94
|
});
|
|
237
95
|
|
|
238
|
-
it("merges locales per key with
|
|
96
|
+
it("merges locales per key with applyLoadMergeSingle without dropping existing locales", () => {
|
|
239
97
|
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>({
|
|
240
98
|
...dictionary,
|
|
241
99
|
// @ts-expect-error missing locale
|
|
@@ -244,30 +102,41 @@ describe("IcuTranslationProviderSingle", () => {
|
|
|
244
102
|
},
|
|
245
103
|
});
|
|
246
104
|
|
|
247
|
-
local.
|
|
248
|
-
// @ts-expect-error missing locale
|
|
105
|
+
local.applyLoadMergeSingle({
|
|
249
106
|
welcome: {
|
|
107
|
+
// @ts-expect-error adding locale via merge
|
|
250
108
|
it: "Benvenuto {name}!",
|
|
251
109
|
},
|
|
252
110
|
});
|
|
253
111
|
|
|
254
|
-
|
|
255
|
-
expect(
|
|
256
|
-
expect(
|
|
112
|
+
const view = local.toScope();
|
|
113
|
+
expect(view.t("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
|
|
114
|
+
expect(view.t("welcome", "it", { name: "Ada" })).toBe("Benvenuto Ada!");
|
|
115
|
+
expect(view.t("login_button", "en")).toBe("Login");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("throws when patching a key that was not preloaded", () => {
|
|
119
|
+
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>({
|
|
120
|
+
login_button: { en: "Login" },
|
|
121
|
+
} as TestSchema);
|
|
122
|
+
|
|
123
|
+
expect(() => local.patchKey("login_button", "it", "Accedi")).toThrow(
|
|
124
|
+
"[i18n] Key not preloaded: login_button (it)"
|
|
125
|
+
);
|
|
257
126
|
});
|
|
258
127
|
|
|
259
128
|
it("returns a deep-frozen snapshot from getAll", () => {
|
|
260
|
-
expect(
|
|
261
|
-
expect(
|
|
262
|
-
expect(Object.isFrozen(
|
|
129
|
+
expect(engine.getAll()).toEqual(dictionary);
|
|
130
|
+
expect(engine.getAll()).not.toBe(dictionary);
|
|
131
|
+
expect(Object.isFrozen(engine.getAll())).toBe(true);
|
|
263
132
|
});
|
|
264
133
|
|
|
265
|
-
it("does not mutate the
|
|
266
|
-
const snapshot =
|
|
134
|
+
it("does not mutate the engine when getAll snapshot is modified", () => {
|
|
135
|
+
const snapshot = engine.getAll();
|
|
267
136
|
expect(() => {
|
|
268
137
|
snapshot.welcome.en = "Hacked";
|
|
269
138
|
}).toThrow(TypeError);
|
|
270
|
-
expect(
|
|
139
|
+
expect(engine.toScope().t("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
|
|
271
140
|
});
|
|
272
141
|
|
|
273
142
|
it("does not reflect external dictionary mutations after construction", () => {
|
|
@@ -292,91 +161,10 @@ describe("IcuTranslationProviderSingle", () => {
|
|
|
292
161
|
};
|
|
293
162
|
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(external);
|
|
294
163
|
external.login_button.en = "Sign in";
|
|
295
|
-
expect(local.
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
describe("forLocale", () => {
|
|
299
|
-
it("binds a locale so get() no longer requires it", () => {
|
|
300
|
-
const en = provider.forLocale("en");
|
|
301
|
-
|
|
302
|
-
expect(en.locale).toBe("en");
|
|
303
|
-
expect(en.get("login_button")).toBe("Login");
|
|
304
|
-
expect(en.get("welcome", { name: "Ada" })).toBe("Welcome Ada!");
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it("uses the parent provider cache and fallback rules", () => {
|
|
308
|
-
const fallbackMap = {
|
|
309
|
-
en: null,
|
|
310
|
-
"de-CH": "en",
|
|
311
|
-
} as const;
|
|
312
|
-
const fallbackProvider = new IcuTranslationProviderSingle<
|
|
313
|
-
TestSchema,
|
|
314
|
-
TestParams,
|
|
315
|
-
keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
|
|
316
|
-
typeof fallbackMap
|
|
317
|
-
>(dictionary, { localeFallback: fallbackMap });
|
|
318
|
-
const deCh = fallbackProvider.forLocale("de-CH");
|
|
319
|
-
|
|
320
|
-
expect(deCh.get("login_button")).toBe("Login");
|
|
321
|
-
});
|
|
164
|
+
expect(local.toScope().t("login_button", "en")).toBe("Login");
|
|
322
165
|
});
|
|
323
166
|
|
|
324
167
|
describe("locale fallback", () => {
|
|
325
|
-
const fallbackMap = {
|
|
326
|
-
en: null,
|
|
327
|
-
"de-DE": "en",
|
|
328
|
-
"de-CH": "de-DE",
|
|
329
|
-
it: "en",
|
|
330
|
-
} as const;
|
|
331
|
-
|
|
332
|
-
const fallbackProvider = new IcuTranslationProviderSingle<
|
|
333
|
-
TestSchema,
|
|
334
|
-
TestParams,
|
|
335
|
-
keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
|
|
336
|
-
typeof fallbackMap
|
|
337
|
-
>(dictionary, { localeFallback: fallbackMap });
|
|
338
|
-
|
|
339
|
-
it("resolves a locale through the fallback chain", () => {
|
|
340
|
-
expect(fallbackProvider.get("login_button", "de-CH")).toBe("Login");
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it("uses an intermediate locale template when available", () => {
|
|
344
|
-
const withGerman = new IcuTranslationProviderSingle<
|
|
345
|
-
TestSchema & {
|
|
346
|
-
login_button: { en: string; it: string; "de-DE": string };
|
|
347
|
-
},
|
|
348
|
-
TestParams,
|
|
349
|
-
keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
|
|
350
|
-
typeof fallbackMap
|
|
351
|
-
>(
|
|
352
|
-
{
|
|
353
|
-
...dictionary,
|
|
354
|
-
login_button: { en: "Login", it: "Accedi", "de-DE": "Anmelden" },
|
|
355
|
-
},
|
|
356
|
-
{ localeFallback: fallbackMap }
|
|
357
|
-
);
|
|
358
|
-
|
|
359
|
-
expect(withGerman.get("login_button", "de-CH")).toBe("Anmelden");
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
it("throws when the fallback chain cannot resolve a template", () => {
|
|
363
|
-
const unresolvedFallback = {
|
|
364
|
-
en: null,
|
|
365
|
-
"de-CH": "fr",
|
|
366
|
-
fr: null,
|
|
367
|
-
} as const;
|
|
368
|
-
const unresolvedProvider = new IcuTranslationProviderSingle<
|
|
369
|
-
TestSchema,
|
|
370
|
-
TestParams,
|
|
371
|
-
keyof typeof unresolvedFallback | LocaleOfSingle<TestSchema>,
|
|
372
|
-
typeof unresolvedFallback
|
|
373
|
-
>(dictionary, { localeFallback: unresolvedFallback });
|
|
374
|
-
|
|
375
|
-
expect(() => unresolvedProvider.get("login_button", "de-CH")).toThrow(
|
|
376
|
-
'[i18n] Missing key or locale: "login_button" [de-CH] (fallback chain: de-CH → fr)'
|
|
377
|
-
);
|
|
378
|
-
});
|
|
379
|
-
|
|
380
168
|
it("rejects circular fallback maps at construction", () => {
|
|
381
169
|
expect(
|
|
382
170
|
() =>
|
|
@@ -386,71 +174,4 @@ describe("IcuTranslationProviderSingle", () => {
|
|
|
386
174
|
).toThrow("[i18n] Circular locale fallback detected");
|
|
387
175
|
});
|
|
388
176
|
});
|
|
389
|
-
|
|
390
|
-
describe("onMissing", () => {
|
|
391
|
-
it('throws on missing translations with onMissing: "throw" (explicit default)', () => {
|
|
392
|
-
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
|
|
393
|
-
onMissing: "throw",
|
|
394
|
-
});
|
|
395
|
-
expect(() => local.get("missing_key" as "login_button", "en")).toThrow(
|
|
396
|
-
'[i18n] Missing key or locale: "missing_key" [en] (fallback chain: en)'
|
|
397
|
-
);
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
it('returns the key itself with onMissing: "key"', () => {
|
|
401
|
-
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
|
|
402
|
-
onMissing: "key",
|
|
403
|
-
});
|
|
404
|
-
expect(local.get("missing_key" as "login_button", "en")).toBe("missing_key");
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
it("calls a custom handler with the missing-translation context", () => {
|
|
408
|
-
const contexts: unknown[] = [];
|
|
409
|
-
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
|
|
410
|
-
onMissing: (context) => {
|
|
411
|
-
contexts.push(context);
|
|
412
|
-
return `<missing:${context.key}>`;
|
|
413
|
-
},
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
expect(local.get("missing_key" as "login_button", "en")).toBe("<missing:missing_key>");
|
|
417
|
-
expect(contexts).toEqual([{ key: "missing_key", locale: "en", fallbackChain: "en" }]);
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
it("includes the walked fallback chain in the handler context", () => {
|
|
421
|
-
const fallbackMap = { en: null, "de-CH": "fr", fr: null } as const;
|
|
422
|
-
const contexts: { fallbackChain: string }[] = [];
|
|
423
|
-
const local = new IcuTranslationProviderSingle<
|
|
424
|
-
TestSchema,
|
|
425
|
-
TestParams,
|
|
426
|
-
keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
|
|
427
|
-
typeof fallbackMap
|
|
428
|
-
>(dictionary, {
|
|
429
|
-
localeFallback: fallbackMap,
|
|
430
|
-
onMissing: (context) => {
|
|
431
|
-
contexts.push(context);
|
|
432
|
-
return context.key;
|
|
433
|
-
},
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
expect(local.get("login_button", "de-CH")).toBe("login_button");
|
|
437
|
-
expect(contexts[0]?.fallbackChain).toBe("de-CH → fr");
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
it("still throws ICU syntax and formatting errors with a lenient onMissing", () => {
|
|
441
|
-
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
|
|
442
|
-
onMissing: "key",
|
|
443
|
-
});
|
|
444
|
-
expect(() => local.get("broken", "en", { name: "Ada" })).toThrow("[i18n ICU Syntax Error]");
|
|
445
|
-
// @ts-expect-error
|
|
446
|
-
expect(() => local.get("welcome", "en")).toThrow("[i18n Formatting Error]");
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
it("applies onMissing through forLocale wrappers", () => {
|
|
450
|
-
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
|
|
451
|
-
onMissing: "key",
|
|
452
|
-
});
|
|
453
|
-
expect(local.forLocale("en").get("missing_key" as "login_button")).toBe("missing_key");
|
|
454
|
-
});
|
|
455
|
-
});
|
|
456
177
|
});
|