@xndrjs/i18n 0.7.0 → 0.8.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 +67 -872
- package/dist/codegen/index.d.ts +117 -51
- package/dist/codegen/index.js +1585 -93
- package/dist/codegen/index.js.map +1 -1
- package/dist/index.d.ts +100 -234
- package/dist/index.js +74 -638
- package/dist/index.js.map +1 -1
- package/dist/validation/index.d.ts +5 -0
- package/dist/validation/index.js.map +1 -1
- package/package.json +2 -2
- package/src/IcuTranslationProviderMulti.test.ts +18 -40
- package/src/IcuTranslationProviderMulti.ts +27 -37
- package/src/audit/audit-dictionaries.ts +1 -1
- package/src/audit/run-audit.test.ts +6 -7
- package/src/builder-load-registry.ts +26 -5
- package/src/builder-loaders.ts +8 -11
- package/src/builder-types.test.ts +11 -42
- package/src/builder.test.ts +82 -243
- package/src/builder.ts +8 -110
- package/src/codegen/codegen-config-schema.ts +82 -77
- package/src/codegen/config.test.ts +55 -143
- package/src/codegen/config.ts +10 -67
- package/src/codegen/dictionary-spec-contract.test.ts +28 -0
- package/src/codegen/dictionary-spec-contract.ts +138 -0
- package/src/codegen/emit/dictionary-schema-file.ts +7 -43
- package/src/codegen/emit/instance-file.test.ts +35 -52
- package/src/codegen/emit/instance-file.ts +170 -268
- package/src/codegen/emit/namespace-loaders-file.test.ts +72 -74
- package/src/codegen/emit/namespace-loaders-file.ts +147 -90
- package/src/codegen/emit/types-file.test.ts +20 -41
- package/src/codegen/emit/types-file.ts +48 -74
- package/src/codegen/generate-i18n-types.test.ts +163 -492
- package/src/codegen/generate-i18n-types.ts +7 -269
- package/src/codegen/paths.ts +0 -14
- package/src/codegen/project-locales-set-namespace.test.ts +44 -86
- package/src/codegen/read-dictionary.test.ts +27 -9
- package/src/codegen/read-dictionary.ts +18 -40
- package/src/codegen/regenerate-namespaces.ts +180 -0
- package/src/codegen/run-codegen.test.ts +230 -0
- package/src/codegen/run-codegen.ts +252 -0
- package/src/codegen/types.ts +0 -6
- package/src/codegen-config/build-config.ts +4 -23
- package/src/codegen-config/codegen-config.test.ts +10 -6
- package/src/codegen-config/index.ts +18 -3
- package/src/engine.ts +3 -27
- package/src/fetch-artifact.ts +16 -0
- package/src/i18n-handle.ts +202 -0
- package/src/index.ts +15 -34
- package/src/project-locales.ts +2 -2
- package/src/scope-multi.ts +1 -20
- package/src/scope-types.ts +14 -6
- package/src/scope.test.ts +3 -312
- package/src/serialized-state.test.ts +114 -0
- package/src/serialized-state.ts +28 -0
- package/src/setup/setup-i18n.test.ts +22 -33
- package/src/setup/setup-i18n.ts +22 -27
- package/src/types.ts +3 -3
- package/src/validation/index.ts +4 -0
- package/src/IcuTranslationProviderSingle.test.ts +0 -177
- package/src/IcuTranslationProviderSingle.ts +0 -114
- package/src/builder-multi.ts +0 -481
- package/src/codegen/emit/dictionary-file.test.ts +0 -215
- package/src/codegen/emit/dictionary-file.ts +0 -244
- package/src/deep-freeze.test.ts +0 -40
- package/src/deep-freeze.ts +0 -22
- package/src/patch-key.test.ts +0 -186
- package/src/patch-key.ts +0 -140
- package/src/scope-single.ts +0 -85
- package/src/single-builder.ts +0 -153
package/src/scope.test.ts
CHANGED
|
@@ -1,64 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
|
|
3
|
-
import {
|
|
4
|
-
import type { LocaleOfMulti, LocaleOfSingle } from "./types.js";
|
|
5
|
-
|
|
6
|
-
type SingleSchema = {
|
|
7
|
-
login_button: { en: string; it: string };
|
|
8
|
-
welcome: { en: string; it: string };
|
|
9
|
-
empty_label: { en: string };
|
|
10
|
-
broken: { en: string };
|
|
11
|
-
invoice_count: { en: string; it: string };
|
|
12
|
-
dashboard_status: { en: string; it: string };
|
|
13
|
-
inbox_owner: { en: string; it: string };
|
|
14
|
-
ranking_position: { en: string; it: string };
|
|
15
|
-
account_balance: { en: string; it: string };
|
|
16
|
-
appointment_summary: { en: string; it: string };
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
type SingleParams = {
|
|
20
|
-
login_button: never;
|
|
21
|
-
welcome: { name: string };
|
|
22
|
-
empty_label: never;
|
|
23
|
-
broken: { name: string };
|
|
24
|
-
invoice_count: { count: number };
|
|
25
|
-
dashboard_status: { msgCount: number; chatCount: number };
|
|
26
|
-
inbox_owner: { gender: string; name: string };
|
|
27
|
-
ranking_position: { position: number };
|
|
28
|
-
account_balance: { amount: number };
|
|
29
|
-
appointment_summary: { dueDate: Date | number; startTime: Date | number };
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const singleDictionary: SingleSchema = {
|
|
33
|
-
login_button: { en: "Login", it: "Accedi" },
|
|
34
|
-
welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
|
|
35
|
-
empty_label: { en: "" },
|
|
36
|
-
broken: { en: "Hi {name" },
|
|
37
|
-
invoice_count: {
|
|
38
|
-
en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
|
|
39
|
-
it: "Hai {count, plural, one {1 fattura} other {{count} fatture}}",
|
|
40
|
-
},
|
|
41
|
-
dashboard_status: {
|
|
42
|
-
en: "You have {msgCount, plural, one {1 message} other {{msgCount} messages}} in {chatCount, plural, one {one chat} other {{chatCount} chats}}",
|
|
43
|
-
it: "Hai {msgCount, plural, one {1 messaggio} other {{msgCount} messaggi}} in {chatCount, plural, one {una chat} other {{chatCount} chat}}",
|
|
44
|
-
},
|
|
45
|
-
inbox_owner: {
|
|
46
|
-
en: "{gender, select, female {{name} owns her inbox} male {{name} owns his inbox} other {{name} owns their inbox}}",
|
|
47
|
-
it: "{gender, select, other {{name} gestisce la propria casella}}",
|
|
48
|
-
},
|
|
49
|
-
ranking_position: {
|
|
50
|
-
en: "You finished {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}",
|
|
51
|
-
it: "Hai concluso al {position, selectordinal, one {#°} other {#°}} posto",
|
|
52
|
-
},
|
|
53
|
-
account_balance: {
|
|
54
|
-
en: "Balance: {amount, number, ::currency/EUR}",
|
|
55
|
-
it: "Saldo: {amount, number, ::currency/EUR}",
|
|
56
|
-
},
|
|
57
|
-
appointment_summary: {
|
|
58
|
-
en: "Due {dueDate, date, short} at {startTime, time, short}",
|
|
59
|
-
it: "Scade il {dueDate, date, short} alle {startTime, time, short}",
|
|
60
|
-
},
|
|
61
|
-
};
|
|
3
|
+
import type { LocaleOfMulti } from "./types.js";
|
|
62
4
|
|
|
63
5
|
type MultiSchema = {
|
|
64
6
|
default: {
|
|
@@ -126,169 +68,6 @@ const multiDictionary: MultiSchema = {
|
|
|
126
68
|
},
|
|
127
69
|
};
|
|
128
70
|
|
|
129
|
-
describe("I18nScope single", () => {
|
|
130
|
-
const engine = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary);
|
|
131
|
-
const view = engine.toScope();
|
|
132
|
-
|
|
133
|
-
it("translates with t(key, locale)", () => {
|
|
134
|
-
expect(view.t("login_button", "it")).toBe("Accedi");
|
|
135
|
-
expect(view.t("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("formats ICU plurals and nested plurals", () => {
|
|
139
|
-
expect(view.t("invoice_count", "en", { count: 1 })).toBe("You have 1 invoice");
|
|
140
|
-
expect(view.t("invoice_count", "en", { count: 5 })).toBe("You have 5 invoices");
|
|
141
|
-
expect(view.t("dashboard_status", "en", { msgCount: 3, chatCount: 2 })).toBe(
|
|
142
|
-
"You have 3 messages in 2 chats"
|
|
143
|
-
);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("binds locale via forLocale", () => {
|
|
147
|
-
const en = view.forLocale("en");
|
|
148
|
-
|
|
149
|
-
expect(en.locale).toBe("en");
|
|
150
|
-
expect(en.t("login_button")).toBe("Login");
|
|
151
|
-
expect(en.t("welcome", { name: "Ada" })).toBe("Welcome Ada!");
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("binds locale via toScope({ locale })", () => {
|
|
155
|
-
const it = engine.toScope({ locale: "it" });
|
|
156
|
-
|
|
157
|
-
expect(it.locale).toBe("it");
|
|
158
|
-
expect(it.t("login_button")).toBe("Accedi");
|
|
159
|
-
expect(it.t("welcome", { name: "Ada" })).toBe("Benvenuto Ada!");
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("supports destructuring t and set from locale-bound scopes", () => {
|
|
163
|
-
const { t, set } = engine.toScope().forLocale("en");
|
|
164
|
-
|
|
165
|
-
expect(t("login_button")).toBe("Login");
|
|
166
|
-
set("login_button", "Sign in");
|
|
167
|
-
expect(t("login_button")).toBe("Sign in");
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("throws when key or locale is missing", () => {
|
|
171
|
-
expect(() => view.t("login_button", "fr" as unknown as LocaleOfSingle<SingleSchema>)).toThrow(
|
|
172
|
-
'[i18n] Missing key or locale: "login_button" [fr] (fallback chain: fr)'
|
|
173
|
-
);
|
|
174
|
-
expect(() => view.t("missing_key" as "login_button", "en")).toThrow(
|
|
175
|
-
'[i18n] Missing key or locale: "missing_key" [en] (fallback chain: en)'
|
|
176
|
-
);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
describe("locale fallback", () => {
|
|
180
|
-
const fallbackMap = {
|
|
181
|
-
en: null,
|
|
182
|
-
"de-DE": "en",
|
|
183
|
-
"de-CH": "de-DE",
|
|
184
|
-
it: "en",
|
|
185
|
-
} as const;
|
|
186
|
-
|
|
187
|
-
const fallbackEngine = new IcuTranslationProviderSingle<
|
|
188
|
-
SingleSchema,
|
|
189
|
-
SingleParams,
|
|
190
|
-
keyof typeof fallbackMap | LocaleOfSingle<SingleSchema>,
|
|
191
|
-
typeof fallbackMap
|
|
192
|
-
>(singleDictionary, { localeFallback: fallbackMap });
|
|
193
|
-
const fallbackView = fallbackEngine.toScope();
|
|
194
|
-
|
|
195
|
-
it("resolves a locale through the fallback chain", () => {
|
|
196
|
-
expect(fallbackView.t("login_button", "de-CH")).toBe("Login");
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
describe("onMissing", () => {
|
|
201
|
-
it('returns the key itself with onMissing: "key"', () => {
|
|
202
|
-
const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary, {
|
|
203
|
-
onMissing: "key",
|
|
204
|
-
});
|
|
205
|
-
expect(local.toScope().t("missing_key" as "login_button", "en")).toBe("missing_key");
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("applies onMissing through forLocale wrappers", () => {
|
|
209
|
-
const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary, {
|
|
210
|
-
onMissing: "key",
|
|
211
|
-
});
|
|
212
|
-
expect(
|
|
213
|
-
local
|
|
214
|
-
.toScope()
|
|
215
|
-
.forLocale("en")
|
|
216
|
-
.t("missing_key" as "login_button")
|
|
217
|
-
).toBe("missing_key");
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
describe("set()", () => {
|
|
222
|
-
it("patches a preloaded key after load merge", () => {
|
|
223
|
-
const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>({
|
|
224
|
-
login_button: { en: "Login", it: "Accedi" },
|
|
225
|
-
welcome: { en: "Welcome {name}!", it: "benvenuto {name}..." },
|
|
226
|
-
empty_label: { en: "" },
|
|
227
|
-
broken: { en: "Hi {name" },
|
|
228
|
-
invoice_count: singleDictionary.invoice_count,
|
|
229
|
-
dashboard_status: singleDictionary.dashboard_status,
|
|
230
|
-
inbox_owner: singleDictionary.inbox_owner,
|
|
231
|
-
ranking_position: singleDictionary.ranking_position,
|
|
232
|
-
account_balance: singleDictionary.account_balance,
|
|
233
|
-
appointment_summary: singleDictionary.appointment_summary,
|
|
234
|
-
});
|
|
235
|
-
local.applyLoadMergeSingle({
|
|
236
|
-
welcome: { it: "Benvenuto {name}!" },
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
const enScope = local.toScope().forLocale("en");
|
|
240
|
-
enScope.set("login_button", "Sign in");
|
|
241
|
-
|
|
242
|
-
const itScope = local.toScope().forLocale("it");
|
|
243
|
-
|
|
244
|
-
expect(enScope.t("login_button")).toBe("Sign in");
|
|
245
|
-
expect(itScope.t("login_button")).toBe("Accedi");
|
|
246
|
-
|
|
247
|
-
expect(itScope.t("welcome", { name: "Mario" })).toBe("Benvenuto Mario!");
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it("throws when patching a key that was not preloaded", () => {
|
|
251
|
-
const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>({
|
|
252
|
-
// @ts-expect-error missing it
|
|
253
|
-
login_button: { en: "Login" },
|
|
254
|
-
welcome: { en: "Welcome {name}!", it: "" },
|
|
255
|
-
empty_label: { en: "" },
|
|
256
|
-
broken: { en: "Hi {name" },
|
|
257
|
-
invoice_count: singleDictionary.invoice_count,
|
|
258
|
-
dashboard_status: singleDictionary.dashboard_status,
|
|
259
|
-
inbox_owner: singleDictionary.inbox_owner,
|
|
260
|
-
ranking_position: singleDictionary.ranking_position,
|
|
261
|
-
account_balance: singleDictionary.account_balance,
|
|
262
|
-
appointment_summary: singleDictionary.appointment_summary,
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
expect(() => local.toScope().forLocale("it").set("login_button", "Accedi")).toThrow(
|
|
266
|
-
"[i18n] Key not preloaded: login_button (it)"
|
|
267
|
-
);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it("invalidates compiled cache after set()", () => {
|
|
271
|
-
const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary);
|
|
272
|
-
const en = local.toScope().forLocale("en");
|
|
273
|
-
|
|
274
|
-
expect(en.t("welcome", { name: "Ada" })).toBe("Welcome Ada!");
|
|
275
|
-
|
|
276
|
-
en.set("welcome", "Hi {name}!");
|
|
277
|
-
|
|
278
|
-
expect(en.t("welcome", { name: "Ada" })).toBe("Hi Ada!");
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it("throws on ICU args mismatch against other locales", () => {
|
|
282
|
-
const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary);
|
|
283
|
-
const en = local.toScope().forLocale("en");
|
|
284
|
-
|
|
285
|
-
expect(() => en.set("invoice_count", "{count, select, other {{count}}}")).toThrow(
|
|
286
|
-
"[i18n] ICU args mismatch on patch:"
|
|
287
|
-
);
|
|
288
|
-
});
|
|
289
|
-
});
|
|
290
|
-
});
|
|
291
|
-
|
|
292
71
|
describe("I18nScope multi", () => {
|
|
293
72
|
const engine = new IcuTranslationProviderMulti<MultiSchema, MultiParams>(multiDictionary);
|
|
294
73
|
|
|
@@ -329,20 +108,12 @@ describe("I18nScope multi", () => {
|
|
|
329
108
|
);
|
|
330
109
|
});
|
|
331
110
|
|
|
332
|
-
it("supports destructuring t
|
|
333
|
-
const { t
|
|
111
|
+
it("supports destructuring t from locale-bound scopes", () => {
|
|
112
|
+
const { t } = engine.toScope({ namespaces: ["billing"] }).forLocale("en");
|
|
334
113
|
|
|
335
114
|
expect(t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
|
|
336
115
|
"You have 1 invoice for Ada"
|
|
337
116
|
);
|
|
338
|
-
set(
|
|
339
|
-
"billing",
|
|
340
|
-
"invoice_summary",
|
|
341
|
-
"You have {count, plural, one {1 invoice only} other {{count} invoices only}} for {name}"
|
|
342
|
-
);
|
|
343
|
-
expect(t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
|
|
344
|
-
"You have 1 invoice only for Ada"
|
|
345
|
-
);
|
|
346
117
|
});
|
|
347
118
|
|
|
348
119
|
it("reflects partial applyLoadMergeNamespace via a fresh toScope", () => {
|
|
@@ -398,84 +169,4 @@ describe("I18nScope multi", () => {
|
|
|
398
169
|
expect(view.t("default", "login_button", "de-CH")).toBe("Login");
|
|
399
170
|
});
|
|
400
171
|
});
|
|
401
|
-
|
|
402
|
-
describe("set()", () => {
|
|
403
|
-
it("patches a preloaded key after load merge", () => {
|
|
404
|
-
const local = new IcuTranslationProviderMulti<MultiSchema, MultiParams>({
|
|
405
|
-
billing: {
|
|
406
|
-
invoice_summary: {
|
|
407
|
-
en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
|
|
408
|
-
},
|
|
409
|
-
},
|
|
410
|
-
});
|
|
411
|
-
local.applyLoadMergeNamespace("billing", {
|
|
412
|
-
invoice_summary: {
|
|
413
|
-
it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
|
|
414
|
-
},
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
const en = local.toScope({ namespaces: ["billing"] }).forLocale("en");
|
|
418
|
-
en.set(
|
|
419
|
-
"billing",
|
|
420
|
-
"invoice_summary",
|
|
421
|
-
"You have {count, plural, one {1 invoice only} other {{count} invoices only}} for {name}"
|
|
422
|
-
);
|
|
423
|
-
|
|
424
|
-
expect(en.t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
|
|
425
|
-
"You have 1 invoice only for Ada"
|
|
426
|
-
);
|
|
427
|
-
expect(
|
|
428
|
-
local
|
|
429
|
-
.toScope({ namespaces: ["billing"], locale: "it" })
|
|
430
|
-
.t("billing", "invoice_summary", { count: 2, name: "Bob" })
|
|
431
|
-
).toBe("Hai 2 fatture per Bob");
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
it("throws when patching a key that was not preloaded", () => {
|
|
435
|
-
const local = new IcuTranslationProviderMulti<MultiSchema, MultiParams>({
|
|
436
|
-
billing: {
|
|
437
|
-
invoice_summary: {
|
|
438
|
-
en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
|
|
439
|
-
},
|
|
440
|
-
},
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
expect(() =>
|
|
444
|
-
local
|
|
445
|
-
.toScope({ namespaces: ["billing"] })
|
|
446
|
-
.forLocale("it")
|
|
447
|
-
.set(
|
|
448
|
-
"billing",
|
|
449
|
-
"invoice_summary",
|
|
450
|
-
"Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}"
|
|
451
|
-
)
|
|
452
|
-
).toThrow("[i18n] Key not preloaded: billing.invoice_summary (it)");
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
it("invalidates compiled cache after set()", () => {
|
|
456
|
-
const local = new IcuTranslationProviderMulti<MultiSchema, MultiParams>(multiDictionary);
|
|
457
|
-
const en = local.toScope({ namespaces: ["billing"] }).forLocale("en");
|
|
458
|
-
|
|
459
|
-
expect(en.t("billing", "invoice_summary", { count: 2, name: "Bob" })).toBe(
|
|
460
|
-
"You have 2 invoices for Bob"
|
|
461
|
-
);
|
|
462
|
-
|
|
463
|
-
en.set(
|
|
464
|
-
"billing",
|
|
465
|
-
"invoice_summary",
|
|
466
|
-
"{count, plural, one {1 bill} other {{count} bills}} for {name}"
|
|
467
|
-
);
|
|
468
|
-
|
|
469
|
-
expect(en.t("billing", "invoice_summary", { count: 2, name: "Bob" })).toBe("2 bills for Bob");
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
it("throws on ICU args mismatch against other locales", () => {
|
|
473
|
-
const local = new IcuTranslationProviderMulti<MultiSchema, MultiParams>(multiDictionary);
|
|
474
|
-
const en = local.toScope({ namespaces: ["billing"] }).forLocale("en");
|
|
475
|
-
|
|
476
|
-
expect(() =>
|
|
477
|
-
en.set("billing", "invoice_summary", "{count, select, other {{count}}}")
|
|
478
|
-
).toThrow("[i18n] ICU args mismatch on patch:");
|
|
479
|
-
});
|
|
480
|
-
});
|
|
481
172
|
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createI18nHandle } from "./builder.js";
|
|
3
|
+
import { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
|
|
4
|
+
import { normalizeI18nCreateInput } from "./serialized-state.js";
|
|
5
|
+
|
|
6
|
+
type MultiSchema = {
|
|
7
|
+
default: { greeting: { en: string; it: string } };
|
|
8
|
+
billing: { invoice: { en: string; it: string } };
|
|
9
|
+
};
|
|
10
|
+
type MultiParams = {
|
|
11
|
+
default: { greeting: never };
|
|
12
|
+
billing: { invoice: never };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("serialize / createI18n({ state })", () => {
|
|
16
|
+
it("normalizeI18nCreateInput defaults missing state to empty dictionary and [] resources", () => {
|
|
17
|
+
expect(normalizeI18nCreateInput()).toEqual({
|
|
18
|
+
dictionary: {},
|
|
19
|
+
resources: [],
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("normalizeI18nCreateInput defaults missing resources to []", () => {
|
|
24
|
+
expect(normalizeI18nCreateInput({ dictionary: { greeting: { en: "Hi" } } })).toEqual({
|
|
25
|
+
dictionary: { greeting: { en: "Hi" } },
|
|
26
|
+
resources: [],
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("serialize after load → fresh handle skips loaders for marked resources", async () => {
|
|
31
|
+
const defaultLoader = vi.fn(async (locale: string) => ({
|
|
32
|
+
greeting: { [locale]: locale === "it" ? "Ciao" : "Hello" },
|
|
33
|
+
}));
|
|
34
|
+
const billingLoader = vi.fn(async (locale: string) => ({
|
|
35
|
+
invoice: { [locale]: locale === "it" ? "Fattura" : "Invoice" },
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const ssrEngine = new IcuTranslationProviderMulti<MultiSchema, MultiParams>({});
|
|
39
|
+
const ssr = createI18nHandle(ssrEngine, {
|
|
40
|
+
namespaceLoaders: {
|
|
41
|
+
default: defaultLoader,
|
|
42
|
+
billing: billingLoader,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await ssr.load({ namespaces: ["default", "billing"], locale: "it" });
|
|
47
|
+
expect(defaultLoader).toHaveBeenCalledTimes(1);
|
|
48
|
+
expect(billingLoader).toHaveBeenCalledTimes(1);
|
|
49
|
+
|
|
50
|
+
const serialized = ssr.serialize();
|
|
51
|
+
expect(serialized.resources).toEqual(
|
|
52
|
+
expect.arrayContaining([
|
|
53
|
+
["default", "it"],
|
|
54
|
+
["billing", "it"],
|
|
55
|
+
])
|
|
56
|
+
);
|
|
57
|
+
expect(serialized.dictionary.default?.greeting?.it).toBe("Ciao");
|
|
58
|
+
|
|
59
|
+
const csrEngine = new IcuTranslationProviderMulti<MultiSchema, MultiParams>(
|
|
60
|
+
serialized.dictionary
|
|
61
|
+
);
|
|
62
|
+
csrEngine.seedBuilderResources(serialized.resources);
|
|
63
|
+
const csr = createI18nHandle(csrEngine, {
|
|
64
|
+
namespaceLoaders: {
|
|
65
|
+
default: defaultLoader,
|
|
66
|
+
billing: billingLoader,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(csrEngine.hasBuilderResourceLoaded("default", "it")).toBe(true);
|
|
71
|
+
expect(csrEngine.hasBuilderResourceLoaded("billing", "it")).toBe(true);
|
|
72
|
+
|
|
73
|
+
await csr.load({ namespaces: ["default", "billing"], locale: "it" });
|
|
74
|
+
expect(defaultLoader).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(billingLoader).toHaveBeenCalledTimes(1);
|
|
76
|
+
|
|
77
|
+
const scope = csr.peek({ namespaces: ["default", "billing"], locale: "it" });
|
|
78
|
+
expect(scope).not.toBeNull();
|
|
79
|
+
expect(scope!.t("default", "greeting")).toBe("Ciao");
|
|
80
|
+
expect(scope!.t("billing", "invoice")).toBe("Fattura");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("peek returns scope after hydrate seed without invoking loaders", async () => {
|
|
84
|
+
const defaultLoader = vi.fn(async (locale: string) => ({
|
|
85
|
+
greeting: { [locale]: "Hello" },
|
|
86
|
+
}));
|
|
87
|
+
const billingLoader = vi.fn(async (locale: string) => ({
|
|
88
|
+
invoice: { [locale]: "Invoice" },
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
const ssrEngine = new IcuTranslationProviderMulti<MultiSchema, MultiParams>({});
|
|
92
|
+
const ssr = createI18nHandle(ssrEngine, {
|
|
93
|
+
namespaceLoaders: { default: defaultLoader, billing: billingLoader },
|
|
94
|
+
});
|
|
95
|
+
await ssr.load({ namespaces: ["default", "billing"], locale: "en" });
|
|
96
|
+
const serialized = ssr.serialize();
|
|
97
|
+
|
|
98
|
+
const csrEngine = new IcuTranslationProviderMulti<MultiSchema, MultiParams>(
|
|
99
|
+
serialized.dictionary
|
|
100
|
+
);
|
|
101
|
+
csrEngine.seedBuilderResources(serialized.resources);
|
|
102
|
+
const csr = createI18nHandle(csrEngine, {
|
|
103
|
+
namespaceLoaders: { default: defaultLoader, billing: billingLoader },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const sync = csr.peek({ namespaces: ["default", "billing"], locale: "en" });
|
|
107
|
+
expect(sync).not.toBeNull();
|
|
108
|
+
expect(sync!.t("default", "greeting")).toBe("Hello");
|
|
109
|
+
expect(defaultLoader).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(billingLoader).toHaveBeenCalledTimes(1);
|
|
111
|
+
|
|
112
|
+
expect(csr.peek({ namespaces: ["default"], locale: "it" })).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { BuilderResourceEntry } from "./builder-load-registry.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Portable i18n instance state after `serialize()`: dictionary strings + which
|
|
5
|
+
* builder resources are already loaded (namespace × partition).
|
|
6
|
+
*/
|
|
7
|
+
export interface I18nSerializedState<Dictionary = Record<string, unknown>> {
|
|
8
|
+
dictionary: Dictionary;
|
|
9
|
+
resources: readonly BuilderResourceEntry[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Input nested under generated `createI18n({ state? })`. Omitted `state` defaults to an empty
|
|
14
|
+
* dictionary (lazy cold start). `resources` may be omitted or `[]`.
|
|
15
|
+
*/
|
|
16
|
+
export type I18nCreateInput<Dictionary = Record<string, unknown>> = {
|
|
17
|
+
dictionary: Dictionary;
|
|
18
|
+
resources?: readonly BuilderResourceEntry[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function normalizeI18nCreateInput<Dictionary>(
|
|
22
|
+
value?: I18nCreateInput<Dictionary>
|
|
23
|
+
): I18nSerializedState<Dictionary> {
|
|
24
|
+
return {
|
|
25
|
+
dictionary: value?.dictionary ?? ({} as Dictionary),
|
|
26
|
+
resources: value?.resources ?? [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -30,51 +30,40 @@ describe("setup-i18n", () => {
|
|
|
30
30
|
}
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
it("
|
|
34
|
-
expect(parseSetupArgs(["single", ".", "--project", "MyApp"])).toEqual({
|
|
35
|
-
mode: "single",
|
|
36
|
-
targetDir: ".",
|
|
37
|
-
project: "MyApp",
|
|
38
|
-
force: false,
|
|
39
|
-
});
|
|
33
|
+
it("accepts multi alias or positional targetDir", () => {
|
|
40
34
|
expect(parseSetupArgs(["multi", "apps/myapp", "--project", "MyApp", "--force"])).toEqual({
|
|
41
|
-
mode: "multi",
|
|
42
35
|
targetDir: "apps/myapp",
|
|
43
36
|
project: "MyApp",
|
|
44
37
|
force: true,
|
|
45
38
|
});
|
|
39
|
+
expect(parseSetupArgs([".", "--project", "MyApp"])).toEqual({
|
|
40
|
+
targetDir: ".",
|
|
41
|
+
project: "MyApp",
|
|
42
|
+
force: false,
|
|
43
|
+
});
|
|
46
44
|
});
|
|
47
45
|
|
|
48
|
-
it("scaffolds
|
|
49
|
-
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-setup-"));
|
|
50
|
-
const result = runSetup({ mode: "single", targetDir: tempDir, project: "MyApp" });
|
|
51
|
-
|
|
52
|
-
const config = JSON.parse(readFileSync(join(tempDir, "i18n/i18n.codegen.json"), "utf8"));
|
|
53
|
-
expect(config.dictionary).toBe("translations/translations.json");
|
|
54
|
-
expect(config.paramsTypeName).toBe("MyAppParams");
|
|
55
|
-
expect(config.schemaTypeName).toBe("MyAppSchema");
|
|
56
|
-
expect(config.localeTypeName).toBe("MyAppLocale");
|
|
57
|
-
expect(existsSync(join(tempDir, "i18n/translations/translations.json"))).toBe(true);
|
|
58
|
-
expect(existsSync(join(tempDir, "i18n/index.ts"))).toBe(true);
|
|
59
|
-
expect(existsSync(join(tempDir, "src"))).toBe(false);
|
|
60
|
-
expect(result.created).toContain("i18n/i18n.codegen.json");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("scaffolds multi mode with default namespace only", () => {
|
|
46
|
+
it("scaffolds multi mode with projectName and codegenPath", () => {
|
|
64
47
|
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-setup-"));
|
|
65
|
-
runSetup({
|
|
48
|
+
const result = runSetup({ targetDir: tempDir, project: "MyApp" });
|
|
66
49
|
|
|
67
50
|
const config = JSON.parse(readFileSync(join(tempDir, "i18n/i18n.codegen.json"), "utf8"));
|
|
68
51
|
expect(config.namespaces).toEqual({
|
|
69
52
|
default: "translations/default.json",
|
|
70
53
|
});
|
|
54
|
+
expect(config.projectName).toBe("MyApp");
|
|
55
|
+
expect(config.codegenPath).toBe("generated");
|
|
56
|
+
expect(config).not.toHaveProperty("paramsTypeName");
|
|
71
57
|
expect(existsSync(join(tempDir, "i18n/translations/default.json"))).toBe(true);
|
|
58
|
+
expect(existsSync(join(tempDir, "i18n/index.ts"))).toBe(true);
|
|
59
|
+
expect(existsSync(join(tempDir, "src"))).toBe(false);
|
|
60
|
+
expect(result.created).toContain("i18n/i18n.codegen.json");
|
|
72
61
|
});
|
|
73
62
|
|
|
74
63
|
it("scaffolds under src/i18n when targetDir is src", () => {
|
|
75
64
|
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-setup-"));
|
|
76
65
|
const srcDir = join(tempDir, "src");
|
|
77
|
-
runSetup({
|
|
66
|
+
runSetup({ targetDir: srcDir, project: "MyApp" });
|
|
78
67
|
|
|
79
68
|
expect(existsSync(join(srcDir, "i18n/i18n.codegen.json"))).toBe(true);
|
|
80
69
|
expect(existsSync(join(tempDir, "i18n"))).toBe(false);
|
|
@@ -82,15 +71,15 @@ describe("setup-i18n", () => {
|
|
|
82
71
|
|
|
83
72
|
it("refuses to overwrite an existing config without --force", () => {
|
|
84
73
|
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-setup-"));
|
|
85
|
-
runSetup({
|
|
74
|
+
runSetup({ targetDir: tempDir, project: "MyApp" });
|
|
86
75
|
|
|
87
|
-
expect(() => runSetup({
|
|
88
|
-
"already exists"
|
|
89
|
-
);
|
|
76
|
+
expect(() => runSetup({ targetDir: tempDir, project: "MyApp" })).toThrow("already exists");
|
|
90
77
|
});
|
|
91
78
|
|
|
92
|
-
it("buildCodegenConfig
|
|
93
|
-
|
|
94
|
-
expect(
|
|
79
|
+
it("buildCodegenConfig defaults to split-by-locale", () => {
|
|
80
|
+
const config = buildCodegenConfig("MyApp");
|
|
81
|
+
expect(config.delivery).toBe("split-by-locale");
|
|
82
|
+
expect(config.codegenPath).toBe("generated");
|
|
83
|
+
expect(config.projectName).toBe("MyApp");
|
|
95
84
|
});
|
|
96
85
|
});
|
package/src/setup/setup-i18n.ts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { buildCodegenConfig
|
|
4
|
+
import { buildCodegenConfig } from "../codegen-config/build-config.js";
|
|
5
5
|
import { inferProjectName } from "../codegen-config/type-names.js";
|
|
6
6
|
import { writeCodegenConfig } from "../codegen-config/write-config.js";
|
|
7
7
|
import { DEFAULT_IMPORT_EXTENSION, importExtensionSuffix } from "../codegen/paths.js";
|
|
8
8
|
|
|
9
|
-
export type { SetupMode } from "../codegen-config/build-config.js";
|
|
10
9
|
export { buildCodegenConfig } from "../codegen-config/build-config.js";
|
|
11
10
|
|
|
12
11
|
export interface SetupOptions {
|
|
13
|
-
mode: SetupMode;
|
|
14
12
|
targetDir: string;
|
|
15
13
|
project?: string | undefined;
|
|
16
14
|
force?: boolean | undefined;
|
|
@@ -35,12 +33,11 @@ const DEFAULT_STARTER = {
|
|
|
35
33
|
const DEFAULT_IMPORT_SUFFIX = importExtensionSuffix(DEFAULT_IMPORT_EXTENSION);
|
|
36
34
|
|
|
37
35
|
const INDEX_TS =
|
|
38
|
-
`import { createI18n } from "./generated/instance.generated${DEFAULT_IMPORT_SUFFIX}";\n` +
|
|
39
|
-
`import { defaultDictionary } from "./generated/dictionary.generated${DEFAULT_IMPORT_SUFFIX}";\n\n` +
|
|
36
|
+
`import { createI18n } from "./generated/instance.generated${DEFAULT_IMPORT_SUFFIX}";\n\n` +
|
|
40
37
|
`export * from "./generated/instance.generated${DEFAULT_IMPORT_SUFFIX}";\n` +
|
|
41
|
-
`export * from "./generated/
|
|
42
|
-
`export * from "./generated/
|
|
43
|
-
`export const i18n = createI18n(
|
|
38
|
+
`export * from "./generated/i18n-types.generated${DEFAULT_IMPORT_SUFFIX}";\n` +
|
|
39
|
+
`export * from "./generated/namespace-loaders.generated${DEFAULT_IMPORT_SUFFIX}";\n\n` +
|
|
40
|
+
`export const i18n = createI18n();\n`;
|
|
44
41
|
|
|
45
42
|
function writeJson(filePath: string, value: unknown): void {
|
|
46
43
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -76,18 +73,12 @@ export function runSetup(options: SetupOptions): SetupResult {
|
|
|
76
73
|
|
|
77
74
|
const created: string[] = [];
|
|
78
75
|
|
|
79
|
-
writeCodegenConfig(configPath, buildCodegenConfig(
|
|
76
|
+
writeCodegenConfig(configPath, buildCodegenConfig(project));
|
|
80
77
|
created.push(relative(targetDir, configPath));
|
|
81
78
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
created.push(relative(targetDir, translationsPath));
|
|
86
|
-
} else {
|
|
87
|
-
const defaultPath = path.join(targetDir, I18N_ROOT, TRANSLATIONS_DIR, "default.json");
|
|
88
|
-
writeJson(defaultPath, DEFAULT_STARTER);
|
|
89
|
-
created.push(relative(targetDir, defaultPath));
|
|
90
|
-
}
|
|
79
|
+
const defaultPath = path.join(targetDir, I18N_ROOT, TRANSLATIONS_DIR, "default.json");
|
|
80
|
+
writeJson(defaultPath, DEFAULT_STARTER);
|
|
81
|
+
created.push(relative(targetDir, defaultPath));
|
|
91
82
|
|
|
92
83
|
const indexPath = path.join(targetDir, I18N_ROOT, "index.ts");
|
|
93
84
|
writeText(indexPath, INDEX_TS);
|
|
@@ -99,18 +90,22 @@ export function runSetup(options: SetupOptions): SetupResult {
|
|
|
99
90
|
}
|
|
100
91
|
|
|
101
92
|
export function parseSetupArgs(argv: string[]): SetupOptions {
|
|
102
|
-
const
|
|
103
|
-
if (mode !== "single" && mode !== "multi") {
|
|
104
|
-
throw new Error(
|
|
105
|
-
`[Setup Error] Usage: xndrjs-i18n-setup <single|multi> [targetDir] [--project MyApp] [--force]`
|
|
106
|
-
);
|
|
107
|
-
}
|
|
93
|
+
const first = argv[0];
|
|
108
94
|
|
|
109
95
|
let targetDir = ".";
|
|
110
96
|
let project: string | undefined;
|
|
111
97
|
let force = false;
|
|
98
|
+
let startIndex = 0;
|
|
99
|
+
|
|
100
|
+
if (first === "multi") {
|
|
101
|
+
startIndex = 1;
|
|
102
|
+
} else if (first !== undefined && !first.startsWith("-")) {
|
|
103
|
+
// positional targetDir without mode
|
|
104
|
+
} else if (first === undefined || first.startsWith("-")) {
|
|
105
|
+
// flags only / empty
|
|
106
|
+
}
|
|
112
107
|
|
|
113
|
-
for (let index =
|
|
108
|
+
for (let index = startIndex; index < argv.length; index++) {
|
|
114
109
|
const arg = argv[index]!;
|
|
115
110
|
if (arg === "--project") {
|
|
116
111
|
project = argv[++index];
|
|
@@ -129,7 +124,7 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
|
|
|
129
124
|
targetDir = arg;
|
|
130
125
|
}
|
|
131
126
|
|
|
132
|
-
return {
|
|
127
|
+
return { targetDir, project, force };
|
|
133
128
|
}
|
|
134
129
|
|
|
135
130
|
function main() {
|
|
@@ -137,7 +132,7 @@ function main() {
|
|
|
137
132
|
const options = parseSetupArgs(process.argv.slice(2));
|
|
138
133
|
const result = runSetup(options);
|
|
139
134
|
|
|
140
|
-
console.log(`✅ Setup
|
|
135
|
+
console.log(`✅ Setup i18n in ${result.targetDir}`);
|
|
141
136
|
console.log(
|
|
142
137
|
` Project types: ${result.project}Params, ${result.project}Schema, ${result.project}Locale`
|
|
143
138
|
);
|