@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
|
@@ -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,171 +81,62 @@ 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
|
-
|
|
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
|
-
});
|
|
86
|
+
it("patches a preloaded key and invalidates cache", () => {
|
|
87
|
+
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary);
|
|
88
|
+
expect(local.toScope().t("login_button", "en")).toBe("Login");
|
|
111
89
|
|
|
112
|
-
|
|
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
|
-
});
|
|
90
|
+
local.patchKey("login_button", "en", "Sign in");
|
|
126
91
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
"[i18n Formatting Error]"
|
|
130
|
-
);
|
|
131
|
-
});
|
|
92
|
+
expect(local.toScope().t("login_button", "en")).toBe("Sign in");
|
|
93
|
+
expect(local.toScope().t("login_button", "it")).toBe("Accedi");
|
|
132
94
|
});
|
|
133
95
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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");
|
|
96
|
+
it("merges locales per key with applyLoadMergeSingle without dropping existing locales", () => {
|
|
97
|
+
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>({
|
|
98
|
+
...dictionary,
|
|
99
|
+
// @ts-expect-error missing locale
|
|
100
|
+
welcome: {
|
|
101
|
+
en: "Welcome {name}!",
|
|
102
|
+
},
|
|
158
103
|
});
|
|
159
104
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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}`);
|
|
105
|
+
local.applyLoadMergeSingle({
|
|
106
|
+
welcome: {
|
|
107
|
+
// @ts-expect-error adding locale via merge
|
|
108
|
+
it: "Benvenuto {name}!",
|
|
109
|
+
},
|
|
182
110
|
});
|
|
183
111
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
});
|
|
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");
|
|
202
116
|
});
|
|
203
117
|
|
|
204
|
-
it("
|
|
205
|
-
|
|
206
|
-
|
|
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);
|
|
207
122
|
|
|
208
|
-
|
|
209
|
-
|
|
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)'
|
|
123
|
+
expect(() => local.patchKey("login_button", "it", "Accedi")).toThrow(
|
|
124
|
+
"[i18n] Key not preloaded: login_button (it)"
|
|
214
125
|
);
|
|
215
126
|
});
|
|
216
127
|
|
|
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", () => {
|
|
227
|
-
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary);
|
|
228
|
-
expect(local.get("login_button", "en")).toBe("Login");
|
|
229
|
-
|
|
230
|
-
local.setAll({
|
|
231
|
-
...dictionary,
|
|
232
|
-
login_button: { en: "Sign in", it: "Entra" },
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
expect(local.get("login_button", "en")).toBe("Sign in");
|
|
236
|
-
});
|
|
237
|
-
|
|
238
128
|
it("returns a deep-frozen snapshot from getAll", () => {
|
|
239
|
-
expect(
|
|
240
|
-
expect(
|
|
241
|
-
expect(Object.isFrozen(
|
|
129
|
+
expect(engine.getAll()).toEqual(dictionary);
|
|
130
|
+
expect(engine.getAll()).not.toBe(dictionary);
|
|
131
|
+
expect(Object.isFrozen(engine.getAll())).toBe(true);
|
|
242
132
|
});
|
|
243
133
|
|
|
244
|
-
it("does not mutate the
|
|
245
|
-
const snapshot =
|
|
134
|
+
it("does not mutate the engine when getAll snapshot is modified", () => {
|
|
135
|
+
const snapshot = engine.getAll();
|
|
246
136
|
expect(() => {
|
|
247
137
|
snapshot.welcome.en = "Hacked";
|
|
248
138
|
}).toThrow(TypeError);
|
|
249
|
-
expect(
|
|
139
|
+
expect(engine.toScope().t("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
|
|
250
140
|
});
|
|
251
141
|
|
|
252
142
|
it("does not reflect external dictionary mutations after construction", () => {
|
|
@@ -271,91 +161,10 @@ describe("IcuTranslationProviderSingle", () => {
|
|
|
271
161
|
};
|
|
272
162
|
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(external);
|
|
273
163
|
external.login_button.en = "Sign in";
|
|
274
|
-
expect(local.
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
describe("forLocale", () => {
|
|
278
|
-
it("binds a locale so get() no longer requires it", () => {
|
|
279
|
-
const en = provider.forLocale("en");
|
|
280
|
-
|
|
281
|
-
expect(en.locale).toBe("en");
|
|
282
|
-
expect(en.get("login_button")).toBe("Login");
|
|
283
|
-
expect(en.get("welcome", { name: "Ada" })).toBe("Welcome Ada!");
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it("uses the parent provider cache and fallback rules", () => {
|
|
287
|
-
const fallbackMap = {
|
|
288
|
-
en: null,
|
|
289
|
-
"de-CH": "en",
|
|
290
|
-
} as const;
|
|
291
|
-
const fallbackProvider = new IcuTranslationProviderSingle<
|
|
292
|
-
TestSchema,
|
|
293
|
-
TestParams,
|
|
294
|
-
keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
|
|
295
|
-
typeof fallbackMap
|
|
296
|
-
>(dictionary, { localeFallback: fallbackMap });
|
|
297
|
-
const deCh = fallbackProvider.forLocale("de-CH");
|
|
298
|
-
|
|
299
|
-
expect(deCh.get("login_button")).toBe("Login");
|
|
300
|
-
});
|
|
164
|
+
expect(local.toScope().t("login_button", "en")).toBe("Login");
|
|
301
165
|
});
|
|
302
166
|
|
|
303
167
|
describe("locale fallback", () => {
|
|
304
|
-
const fallbackMap = {
|
|
305
|
-
en: null,
|
|
306
|
-
"de-DE": "en",
|
|
307
|
-
"de-CH": "de-DE",
|
|
308
|
-
it: "en",
|
|
309
|
-
} as const;
|
|
310
|
-
|
|
311
|
-
const fallbackProvider = new IcuTranslationProviderSingle<
|
|
312
|
-
TestSchema,
|
|
313
|
-
TestParams,
|
|
314
|
-
keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
|
|
315
|
-
typeof fallbackMap
|
|
316
|
-
>(dictionary, { localeFallback: fallbackMap });
|
|
317
|
-
|
|
318
|
-
it("resolves a locale through the fallback chain", () => {
|
|
319
|
-
expect(fallbackProvider.get("login_button", "de-CH")).toBe("Login");
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
it("uses an intermediate locale template when available", () => {
|
|
323
|
-
const withGerman = new IcuTranslationProviderSingle<
|
|
324
|
-
TestSchema & {
|
|
325
|
-
login_button: { en: string; it: string; "de-DE": string };
|
|
326
|
-
},
|
|
327
|
-
TestParams,
|
|
328
|
-
keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
|
|
329
|
-
typeof fallbackMap
|
|
330
|
-
>(
|
|
331
|
-
{
|
|
332
|
-
...dictionary,
|
|
333
|
-
login_button: { en: "Login", it: "Accedi", "de-DE": "Anmelden" },
|
|
334
|
-
},
|
|
335
|
-
{ localeFallback: fallbackMap }
|
|
336
|
-
);
|
|
337
|
-
|
|
338
|
-
expect(withGerman.get("login_button", "de-CH")).toBe("Anmelden");
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
it("throws when the fallback chain cannot resolve a template", () => {
|
|
342
|
-
const unresolvedFallback = {
|
|
343
|
-
en: null,
|
|
344
|
-
"de-CH": "fr",
|
|
345
|
-
fr: null,
|
|
346
|
-
} as const;
|
|
347
|
-
const unresolvedProvider = new IcuTranslationProviderSingle<
|
|
348
|
-
TestSchema,
|
|
349
|
-
TestParams,
|
|
350
|
-
keyof typeof unresolvedFallback | LocaleOfSingle<TestSchema>,
|
|
351
|
-
typeof unresolvedFallback
|
|
352
|
-
>(dictionary, { localeFallback: unresolvedFallback });
|
|
353
|
-
|
|
354
|
-
expect(() => unresolvedProvider.get("login_button", "de-CH")).toThrow(
|
|
355
|
-
'[i18n] Missing key or locale: "login_button" [de-CH] (fallback chain: de-CH → fr)'
|
|
356
|
-
);
|
|
357
|
-
});
|
|
358
|
-
|
|
359
168
|
it("rejects circular fallback maps at construction", () => {
|
|
360
169
|
expect(
|
|
361
170
|
() =>
|
|
@@ -365,71 +174,4 @@ describe("IcuTranslationProviderSingle", () => {
|
|
|
365
174
|
).toThrow("[i18n] Circular locale fallback detected");
|
|
366
175
|
});
|
|
367
176
|
});
|
|
368
|
-
|
|
369
|
-
describe("onMissing", () => {
|
|
370
|
-
it('throws on missing translations with onMissing: "throw" (explicit default)', () => {
|
|
371
|
-
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
|
|
372
|
-
onMissing: "throw",
|
|
373
|
-
});
|
|
374
|
-
expect(() => local.get("missing_key" as "login_button", "en")).toThrow(
|
|
375
|
-
'[i18n] Missing key or locale: "missing_key" [en] (fallback chain: en)'
|
|
376
|
-
);
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
it('returns the key itself with onMissing: "key"', () => {
|
|
380
|
-
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
|
|
381
|
-
onMissing: "key",
|
|
382
|
-
});
|
|
383
|
-
expect(local.get("missing_key" as "login_button", "en")).toBe("missing_key");
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
it("calls a custom handler with the missing-translation context", () => {
|
|
387
|
-
const contexts: unknown[] = [];
|
|
388
|
-
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
|
|
389
|
-
onMissing: (context) => {
|
|
390
|
-
contexts.push(context);
|
|
391
|
-
return `<missing:${context.key}>`;
|
|
392
|
-
},
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
expect(local.get("missing_key" as "login_button", "en")).toBe("<missing:missing_key>");
|
|
396
|
-
expect(contexts).toEqual([{ key: "missing_key", locale: "en", fallbackChain: "en" }]);
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
it("includes the walked fallback chain in the handler context", () => {
|
|
400
|
-
const fallbackMap = { en: null, "de-CH": "fr", fr: null } as const;
|
|
401
|
-
const contexts: { fallbackChain: string }[] = [];
|
|
402
|
-
const local = new IcuTranslationProviderSingle<
|
|
403
|
-
TestSchema,
|
|
404
|
-
TestParams,
|
|
405
|
-
keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
|
|
406
|
-
typeof fallbackMap
|
|
407
|
-
>(dictionary, {
|
|
408
|
-
localeFallback: fallbackMap,
|
|
409
|
-
onMissing: (context) => {
|
|
410
|
-
contexts.push(context);
|
|
411
|
-
return context.key;
|
|
412
|
-
},
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
expect(local.get("login_button", "de-CH")).toBe("login_button");
|
|
416
|
-
expect(contexts[0]?.fallbackChain).toBe("de-CH → fr");
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
it("still throws ICU syntax and formatting errors with a lenient onMissing", () => {
|
|
420
|
-
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
|
|
421
|
-
onMissing: "key",
|
|
422
|
-
});
|
|
423
|
-
expect(() => local.get("broken", "en", { name: "Ada" })).toThrow("[i18n ICU Syntax Error]");
|
|
424
|
-
// @ts-expect-error
|
|
425
|
-
expect(() => local.get("welcome", "en")).toThrow("[i18n Formatting Error]");
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
it("applies onMissing through forLocale wrappers", () => {
|
|
429
|
-
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
|
|
430
|
-
onMissing: "key",
|
|
431
|
-
});
|
|
432
|
-
expect(local.forLocale("en").get("missing_key" as "login_button")).toBe("missing_key");
|
|
433
|
-
});
|
|
434
|
-
});
|
|
435
177
|
});
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { cloneAndFreeze } from "./deep-freeze.js";
|
|
2
|
+
import { BuilderLoadRegistry, SINGLE_BUILDER_RESOURCE } from "./builder-load-registry.js";
|
|
3
|
+
import type { I18nEngineSingle } from "./engine.js";
|
|
2
4
|
import { resolveAndFormat } from "./format-core.js";
|
|
5
|
+
import {
|
|
6
|
+
assertPatchKeySingle,
|
|
7
|
+
recordPreloadedKeysSingle,
|
|
8
|
+
seedPreloadedKeysSingle,
|
|
9
|
+
} from "./patch-key.js";
|
|
10
|
+
import { mergeNamespaceLocalesCore } from "./project-locales.js";
|
|
3
11
|
import { validateLocaleFallback } from "./resolve-locale.js";
|
|
4
12
|
import type {
|
|
5
13
|
IcuTranslationProviderOptions,
|
|
@@ -8,76 +16,28 @@ import type {
|
|
|
8
16
|
LocaleOfSingle,
|
|
9
17
|
OnMissingTranslation,
|
|
10
18
|
SingleCompiledCache,
|
|
19
|
+
PartialKeyDictionary,
|
|
11
20
|
} from "./types.js";
|
|
12
|
-
|
|
13
|
-
export interface TranslationProviderSingleForLocale<
|
|
14
|
-
Schema extends KeyDictionary,
|
|
15
|
-
Params extends { [K in keyof Schema]: unknown },
|
|
16
|
-
Locale extends string,
|
|
17
|
-
> {
|
|
18
|
-
readonly locale: Locale;
|
|
19
|
-
get<K extends keyof Schema & string>(
|
|
20
|
-
key: K,
|
|
21
|
-
...params: Params[K] extends never ? [] : [params: Params[K]]
|
|
22
|
-
): string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface TranslationProviderSingle<
|
|
26
|
-
Schema extends KeyDictionary,
|
|
27
|
-
Params extends { [K in keyof Schema]: unknown },
|
|
28
|
-
RequestLocales extends string = LocaleOfSingle<Schema>,
|
|
29
|
-
> {
|
|
30
|
-
get<K extends keyof Schema & string>(
|
|
31
|
-
key: K,
|
|
32
|
-
locale: RequestLocales,
|
|
33
|
-
...params: Params[K] extends never ? [] : [params: Params[K]]
|
|
34
|
-
): string;
|
|
35
|
-
forLocale<Locale extends RequestLocales>(
|
|
36
|
-
locale: Locale
|
|
37
|
-
): TranslationProviderSingleForLocale<Schema, Params, Locale>;
|
|
38
|
-
getAll(): Schema;
|
|
39
|
-
setAll(values: Schema): void;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export class IcuTranslationProviderSingleForLocale<
|
|
43
|
-
Schema extends KeyDictionary,
|
|
44
|
-
Params extends { [K in keyof Schema]: unknown },
|
|
45
|
-
RequestLocales extends string,
|
|
46
|
-
Locale extends RequestLocales,
|
|
47
|
-
Fallback extends LocaleFallbackMap | undefined,
|
|
48
|
-
> implements TranslationProviderSingleForLocale<Schema, Params, Locale> {
|
|
49
|
-
constructor(
|
|
50
|
-
private readonly provider: IcuTranslationProviderSingle<
|
|
51
|
-
Schema,
|
|
52
|
-
Params,
|
|
53
|
-
RequestLocales,
|
|
54
|
-
Fallback
|
|
55
|
-
>,
|
|
56
|
-
readonly locale: Locale
|
|
57
|
-
) {}
|
|
58
|
-
|
|
59
|
-
get<K extends keyof Schema & string>(
|
|
60
|
-
key: K,
|
|
61
|
-
...args: Params[K] extends never ? [] : [params: Params[K]]
|
|
62
|
-
): string {
|
|
63
|
-
const params = args[0] as Record<string, unknown> | undefined;
|
|
64
|
-
return this.provider.getWithLocale(String(key), this.locale, params);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
21
|
+
import { I18nScopeSingleForLocaleImpl, I18nScopeSingleImpl } from "./scope-single.js";
|
|
67
22
|
|
|
68
23
|
export class IcuTranslationProviderSingle<
|
|
69
24
|
Schema extends KeyDictionary,
|
|
70
25
|
Params extends { [K in keyof Schema]: unknown },
|
|
71
26
|
RequestLocales extends string = LocaleOfSingle<Schema>,
|
|
72
27
|
Fallback extends LocaleFallbackMap | undefined = undefined,
|
|
73
|
-
> implements
|
|
28
|
+
> implements I18nEngineSingle<Schema, Params, RequestLocales> {
|
|
29
|
+
readonly __i18nEngineMode = "single" as const;
|
|
30
|
+
|
|
74
31
|
private dictionary: Schema;
|
|
75
32
|
private compiledCache: SingleCompiledCache = {};
|
|
33
|
+
private readonly preloadedKeys = new Set<string>();
|
|
34
|
+
private readonly builderLoadRegistry = new BuilderLoadRegistry();
|
|
76
35
|
private readonly localeFallback?: Fallback;
|
|
77
36
|
private readonly onMissing: OnMissingTranslation;
|
|
78
37
|
|
|
79
38
|
constructor(dictionary: Schema, options?: IcuTranslationProviderOptions<Fallback>) {
|
|
80
39
|
this.dictionary = structuredClone(dictionary);
|
|
40
|
+
seedPreloadedKeysSingle(this.dictionary, this.preloadedKeys);
|
|
81
41
|
if (options?.localeFallback) {
|
|
82
42
|
validateLocaleFallback(options.localeFallback);
|
|
83
43
|
this.localeFallback = options.localeFallback;
|
|
@@ -85,15 +45,6 @@ export class IcuTranslationProviderSingle<
|
|
|
85
45
|
this.onMissing = options?.onMissing ?? "throw";
|
|
86
46
|
}
|
|
87
47
|
|
|
88
|
-
get<K extends keyof Schema & string>(
|
|
89
|
-
key: K,
|
|
90
|
-
locale: RequestLocales,
|
|
91
|
-
...args: Params[K] extends never ? [] : [params: Params[K]]
|
|
92
|
-
): string {
|
|
93
|
-
const params = args[0] as Record<string, unknown> | undefined;
|
|
94
|
-
return this.getWithLocale(String(key), locale, params);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
48
|
getWithLocale(key: string, locale: string, params?: Record<string, unknown>): string {
|
|
98
49
|
return resolveAndFormat({
|
|
99
50
|
localeByKey: this.dictionary[key],
|
|
@@ -114,18 +65,50 @@ export class IcuTranslationProviderSingle<
|
|
|
114
65
|
});
|
|
115
66
|
}
|
|
116
67
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
68
|
+
toScope(): I18nScopeSingleImpl<Schema, Params, RequestLocales, Fallback>;
|
|
69
|
+
toScope<Locale extends RequestLocales>(options: {
|
|
70
|
+
locale: Locale;
|
|
71
|
+
}): I18nScopeSingleForLocaleImpl<Schema, Params, RequestLocales, Locale, Fallback>;
|
|
72
|
+
toScope<Locale extends RequestLocales>(options?: { locale?: Locale }) {
|
|
73
|
+
if (options?.locale !== undefined) {
|
|
74
|
+
return new I18nScopeSingleForLocaleImpl(this, options.locale);
|
|
75
|
+
}
|
|
76
|
+
return new I18nScopeSingleImpl(this);
|
|
121
77
|
}
|
|
122
78
|
|
|
123
79
|
getAll(): Schema {
|
|
124
80
|
return cloneAndFreeze(this.dictionary);
|
|
125
81
|
}
|
|
126
82
|
|
|
127
|
-
|
|
128
|
-
this.
|
|
129
|
-
|
|
83
|
+
hasBuilderResourceLoaded(partition: string | undefined): boolean {
|
|
84
|
+
return this.builderLoadRegistry.has(SINGLE_BUILDER_RESOURCE, partition);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
markBuilderResourceLoaded(partition: string | undefined): void {
|
|
88
|
+
this.builderLoadRegistry.mark(SINGLE_BUILDER_RESOURCE, partition);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
applyLoadMergeSingle(values: PartialKeyDictionary<Schema, RequestLocales>): void {
|
|
92
|
+
this.dictionary = mergeNamespaceLocalesCore(this.dictionary, values);
|
|
93
|
+
recordPreloadedKeysSingle(values, this.preloadedKeys);
|
|
94
|
+
|
|
95
|
+
for (const [key, incomingLocales] of Object.entries(values)) {
|
|
96
|
+
if (incomingLocales === undefined || typeof incomingLocales !== "object") {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const locale of Object.keys(incomingLocales)) {
|
|
101
|
+
delete this.compiledCache[locale]?.[key];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
patchKey(key: string, locale: string, template: string): void {
|
|
107
|
+
assertPatchKeySingle(key, locale, template, this.preloadedKeys, this.dictionary[key]);
|
|
108
|
+
|
|
109
|
+
this.dictionary = mergeNamespaceLocalesCore(this.dictionary, {
|
|
110
|
+
[key]: { [locale]: template },
|
|
111
|
+
} as PartialKeyDictionary<Schema, RequestLocales>);
|
|
112
|
+
delete this.compiledCache[locale]?.[key];
|
|
130
113
|
}
|
|
131
114
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Stable key for a lazy-loaded builder resource (namespace + locale or delivery area). */
|
|
2
|
+
export function formatBuilderResourceKey(namespace: string, partition: string | undefined): string {
|
|
3
|
+
return `${namespace}\0${partition ?? ""}`;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const SINGLE_BUILDER_RESOURCE = "__dictionary__";
|
|
7
|
+
|
|
8
|
+
export class BuilderLoadRegistry {
|
|
9
|
+
private readonly loaded = new Set<string>();
|
|
10
|
+
|
|
11
|
+
has(namespace: string, partition: string | undefined): boolean {
|
|
12
|
+
return this.loaded.has(formatBuilderResourceKey(namespace, partition));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
mark(namespace: string, partition: string | undefined): void {
|
|
16
|
+
this.loaded.add(formatBuilderResourceKey(namespace, partition));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Canonical lazy loader — no locale or delivery-area partition. */
|
|
2
|
+
export type CanonicalLoader<T> = () => Promise<T> | T;
|
|
3
|
+
|
|
4
|
+
/** Split-by-locale or custom-area loader — partition key is locale or area name. */
|
|
5
|
+
export type PartitionedLoader<T> = (partition: string) => Promise<T> | T;
|
|
6
|
+
|
|
7
|
+
export type NamespaceLoader<T> = CanonicalLoader<T> | PartitionedLoader<T>;
|
|
8
|
+
|
|
9
|
+
export async function invokeNamespaceLoader<T>(
|
|
10
|
+
loader: NamespaceLoader<T>,
|
|
11
|
+
partition: string | undefined
|
|
12
|
+
): Promise<T> {
|
|
13
|
+
if (partition === undefined) {
|
|
14
|
+
return await (loader as CanonicalLoader<T>)();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return await (loader as PartitionedLoader<T>)(partition);
|
|
18
|
+
}
|