@pyreon/i18n 0.11.2 → 0.11.4
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/i18n",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "Reactive internationalization for Pyreon with async namespace loading",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -51,13 +51,13 @@
|
|
|
51
51
|
"lint": "biome check ."
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"@pyreon/core": "^0.11.
|
|
55
|
-
"@pyreon/reactivity": "^0.11.
|
|
54
|
+
"@pyreon/core": "^0.11.4",
|
|
55
|
+
"@pyreon/reactivity": "^0.11.4"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@happy-dom/global-registrator": "^20.8.3",
|
|
59
|
-
"@pyreon/core": "^0.11.
|
|
60
|
-
"@pyreon/reactivity": "^0.11.
|
|
61
|
-
"@pyreon/runtime-dom": "^0.11.
|
|
59
|
+
"@pyreon/core": "^0.11.4",
|
|
60
|
+
"@pyreon/reactivity": "^0.11.4",
|
|
61
|
+
"@pyreon/runtime-dom": "^0.11.4"
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { effect } from "@pyreon/reactivity"
|
|
2
|
+
import { createI18n } from "../create-i18n"
|
|
3
|
+
import { parseRichText, Trans } from "../trans"
|
|
4
|
+
import type { TranslationDictionary } from "../types"
|
|
5
|
+
|
|
6
|
+
// ─── nestFlatKeys via addMessages ────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
describe("addMessages with nestFlatKeys", () => {
|
|
9
|
+
it("converts deeply nested flat keys", () => {
|
|
10
|
+
const i18n = createI18n({ locale: "en", messages: { en: {} } })
|
|
11
|
+
i18n.addMessages("en", { "a.b.c.d": "deep" })
|
|
12
|
+
expect(i18n.t("a.b.c.d")).toBe("deep")
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("multiple flat keys build shared parent objects", () => {
|
|
16
|
+
const i18n = createI18n({ locale: "en", messages: { en: {} } })
|
|
17
|
+
i18n.addMessages("en", {
|
|
18
|
+
"form.email.label": "Email",
|
|
19
|
+
"form.email.placeholder": "Enter email",
|
|
20
|
+
"form.password.label": "Password",
|
|
21
|
+
})
|
|
22
|
+
expect(i18n.t("form.email.label")).toBe("Email")
|
|
23
|
+
expect(i18n.t("form.email.placeholder")).toBe("Enter email")
|
|
24
|
+
expect(i18n.t("form.password.label")).toBe("Password")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("flat keys coexist with nested keys in same addMessages call", () => {
|
|
28
|
+
const i18n = createI18n({ locale: "en", messages: { en: {} } })
|
|
29
|
+
i18n.addMessages("en", {
|
|
30
|
+
"flat.key": "Flat Value",
|
|
31
|
+
nested: { key: "Nested Value" },
|
|
32
|
+
})
|
|
33
|
+
expect(i18n.t("flat.key")).toBe("Flat Value")
|
|
34
|
+
expect(i18n.t("nested.key")).toBe("Nested Value")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("flat key overwrites previous nested value at same path", () => {
|
|
38
|
+
const i18n = createI18n({ locale: "en", messages: { en: {} } })
|
|
39
|
+
i18n.addMessages("en", { section: { title: "Old" } })
|
|
40
|
+
i18n.addMessages("en", { "section.title": "New" })
|
|
41
|
+
expect(i18n.t("section.title")).toBe("New")
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("flat keys without dots are passed through unchanged", () => {
|
|
45
|
+
const i18n = createI18n({ locale: "en", messages: { en: {} } })
|
|
46
|
+
i18n.addMessages("en", { simple: "Just a string" })
|
|
47
|
+
expect(i18n.t("simple")).toBe("Just a string")
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("flat key with non-string value (nested object at dotted key) is treated as nested", () => {
|
|
51
|
+
const i18n = createI18n({ locale: "en", messages: { en: {} } })
|
|
52
|
+
// If a dotted key has an object value, nestFlatKeys treats it as a regular key
|
|
53
|
+
i18n.addMessages("en", { "a.b": { c: "Nested under flat" } } as any)
|
|
54
|
+
// Since "a.b" has a non-string value, it's kept as-is — lookupKey("a.b") won't resolve
|
|
55
|
+
// But "a" → "b" → { c: "Nested under flat" } won't be created either
|
|
56
|
+
// The object value at key "a.b" isn't a string, so nestFlatKeys passes it through
|
|
57
|
+
expect(i18n.t("a.b")).toBe("a.b") // Key not found as string
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("flat keys in namespaced addMessages", () => {
|
|
61
|
+
const i18n = createI18n({ locale: "en", messages: { en: {} } })
|
|
62
|
+
i18n.addMessages(
|
|
63
|
+
"en",
|
|
64
|
+
{ "errors.auth": "Auth failed", "errors.network": "Network error" },
|
|
65
|
+
"admin",
|
|
66
|
+
)
|
|
67
|
+
expect(i18n.t("admin:errors.auth")).toBe("Auth failed")
|
|
68
|
+
expect(i18n.t("admin:errors.network")).toBe("Network error")
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// ─── core subpath imports ────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
describe("i18n core subpath", () => {
|
|
75
|
+
it("exports createI18n and interpolate without @pyreon/core dependency", async () => {
|
|
76
|
+
const mod = await import("../core")
|
|
77
|
+
expect(mod.createI18n).toBeDefined()
|
|
78
|
+
expect(mod.interpolate).toBeDefined()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("exports resolvePluralCategory", async () => {
|
|
82
|
+
const mod = await import("../core")
|
|
83
|
+
expect(mod.resolvePluralCategory).toBeDefined()
|
|
84
|
+
expect(mod.resolvePluralCategory("en", 1)).toBe("one")
|
|
85
|
+
expect(mod.resolvePluralCategory("en", 5)).toBe("other")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("core createI18n is functional without DOM", async () => {
|
|
89
|
+
const { createI18n: coreCreateI18n } = await import("../core")
|
|
90
|
+
const i18n = coreCreateI18n({
|
|
91
|
+
locale: "en",
|
|
92
|
+
messages: { en: { greeting: "Hello {{name}}!" } },
|
|
93
|
+
})
|
|
94
|
+
expect(i18n.t("greeting", { name: "Server" })).toBe("Hello Server!")
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// ─── Pluralization with _zero suffix ─────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
describe("pluralization with _zero suffix", () => {
|
|
101
|
+
it("uses _zero form when count is 0 and rule returns 'zero'", () => {
|
|
102
|
+
const i18n = createI18n({
|
|
103
|
+
locale: "custom",
|
|
104
|
+
pluralRules: {
|
|
105
|
+
custom: (count: number) => (count === 0 ? "zero" : count === 1 ? "one" : "other"),
|
|
106
|
+
},
|
|
107
|
+
messages: {
|
|
108
|
+
custom: {
|
|
109
|
+
items_zero: "No items",
|
|
110
|
+
items_one: "{{count}} item",
|
|
111
|
+
items_other: "{{count}} items",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(i18n.t("items", { count: 0 })).toBe("No items")
|
|
117
|
+
expect(i18n.t("items", { count: 1 })).toBe("1 item")
|
|
118
|
+
expect(i18n.t("items", { count: 5 })).toBe("5 items")
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("falls back to _other when _zero is missing and count is 0 in English", () => {
|
|
122
|
+
const i18n = createI18n({
|
|
123
|
+
locale: "en",
|
|
124
|
+
messages: {
|
|
125
|
+
en: {
|
|
126
|
+
items_one: "{{count}} item",
|
|
127
|
+
items_other: "{{count}} items",
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// English Intl.PluralRules returns "other" for 0, so _other is used
|
|
133
|
+
expect(i18n.t("items", { count: 0 })).toBe("0 items")
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("pluralization with all three suffixes: _zero, _one, _other", () => {
|
|
137
|
+
const i18n = createI18n({
|
|
138
|
+
locale: "pl",
|
|
139
|
+
pluralRules: {
|
|
140
|
+
pl: (count: number) => {
|
|
141
|
+
if (count === 0) return "zero"
|
|
142
|
+
if (count === 1) return "one"
|
|
143
|
+
return "other"
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
messages: {
|
|
147
|
+
pl: {
|
|
148
|
+
messages_zero: "Brak wiadomości",
|
|
149
|
+
messages_one: "{{count}} wiadomość",
|
|
150
|
+
messages_other: "{{count}} wiadomości",
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
expect(i18n.t("messages", { count: 0 })).toBe("Brak wiadomości")
|
|
156
|
+
expect(i18n.t("messages", { count: 1 })).toBe("1 wiadomość")
|
|
157
|
+
expect(i18n.t("messages", { count: 7 })).toBe("7 wiadomości")
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it("count as string number still works for pluralization", () => {
|
|
161
|
+
const i18n = createI18n({
|
|
162
|
+
locale: "en",
|
|
163
|
+
messages: {
|
|
164
|
+
en: {
|
|
165
|
+
items_one: "{{count}} item",
|
|
166
|
+
items_other: "{{count}} items",
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// count is a string but Number("1") === 1
|
|
172
|
+
expect(i18n.t("items", { count: "1" })).toBe("1 item")
|
|
173
|
+
expect(i18n.t("items", { count: "5" })).toBe("5 items")
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// ─── Namespace lazy loading — additional scenarios ───────────────────────────
|
|
178
|
+
|
|
179
|
+
describe("namespace lazy loading", () => {
|
|
180
|
+
it("loads namespace for current locale by default", async () => {
|
|
181
|
+
const loaderCalls: string[] = []
|
|
182
|
+
|
|
183
|
+
const i18n = createI18n({
|
|
184
|
+
locale: "fr",
|
|
185
|
+
loader: async (locale, namespace) => {
|
|
186
|
+
loaderCalls.push(`${locale}:${namespace}`)
|
|
187
|
+
return { title: `${locale} ${namespace} title` }
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
await i18n.loadNamespace("dashboard")
|
|
192
|
+
expect(loaderCalls).toEqual(["fr:dashboard"])
|
|
193
|
+
expect(i18n.t("dashboard:title")).toBe("fr dashboard title")
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("loading namespace then switching locale does not lose previous locale data", async () => {
|
|
197
|
+
const translations: Record<string, Record<string, TranslationDictionary>> = {
|
|
198
|
+
en: { auth: { login: "Log in" } },
|
|
199
|
+
fr: { auth: { login: "Connexion" } },
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const i18n = createI18n({
|
|
203
|
+
locale: "en",
|
|
204
|
+
loader: async (locale, ns) => translations[locale]?.[ns],
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
await i18n.loadNamespace("auth")
|
|
208
|
+
expect(i18n.t("auth:login")).toBe("Log in")
|
|
209
|
+
|
|
210
|
+
i18n.locale.set("fr")
|
|
211
|
+
await i18n.loadNamespace("auth")
|
|
212
|
+
expect(i18n.t("auth:login")).toBe("Connexion")
|
|
213
|
+
|
|
214
|
+
// Switch back — en data should still be there
|
|
215
|
+
i18n.locale.set("en")
|
|
216
|
+
expect(i18n.t("auth:login")).toBe("Log in")
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it("loaded namespaces update reactively", async () => {
|
|
220
|
+
const i18n = createI18n({
|
|
221
|
+
locale: "en",
|
|
222
|
+
loader: async (_locale, ns) => ({ key: `${ns}-value` }),
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const snapshots: number[] = []
|
|
226
|
+
const cleanup = effect(() => {
|
|
227
|
+
snapshots.push(i18n.loadedNamespaces().size)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
expect(snapshots).toEqual([0])
|
|
231
|
+
|
|
232
|
+
await i18n.loadNamespace("auth")
|
|
233
|
+
expect(snapshots).toEqual([0, 1])
|
|
234
|
+
|
|
235
|
+
await i18n.loadNamespace("admin")
|
|
236
|
+
expect(snapshots).toEqual([0, 1, 2])
|
|
237
|
+
|
|
238
|
+
cleanup.dispose()
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
// ─── Locale switching — reactivity ───────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
describe("locale switching reactivity", () => {
|
|
245
|
+
it("locale.set triggers reactive t() updates across multiple calls", () => {
|
|
246
|
+
const i18n = createI18n({
|
|
247
|
+
locale: "en",
|
|
248
|
+
messages: {
|
|
249
|
+
en: { greeting: "Hello", farewell: "Goodbye" },
|
|
250
|
+
es: { greeting: "Hola", farewell: "Adiós" },
|
|
251
|
+
ja: { greeting: "こんにちは", farewell: "さようなら" },
|
|
252
|
+
},
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
const results: string[] = []
|
|
256
|
+
const cleanup = effect(() => {
|
|
257
|
+
results.push(i18n.t("greeting"))
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
expect(results).toEqual(["Hello"])
|
|
261
|
+
|
|
262
|
+
i18n.locale.set("es")
|
|
263
|
+
expect(results).toEqual(["Hello", "Hola"])
|
|
264
|
+
|
|
265
|
+
i18n.locale.set("ja")
|
|
266
|
+
expect(results).toEqual(["Hello", "Hola", "こんにちは"])
|
|
267
|
+
|
|
268
|
+
cleanup.dispose()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it("locale() returns current locale reactively", () => {
|
|
272
|
+
const i18n = createI18n({ locale: "en", messages: { en: {} } })
|
|
273
|
+
|
|
274
|
+
const locales: string[] = []
|
|
275
|
+
const cleanup = effect(() => {
|
|
276
|
+
locales.push(i18n.locale())
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
expect(locales).toEqual(["en"])
|
|
280
|
+
|
|
281
|
+
i18n.locale.set("fr")
|
|
282
|
+
expect(locales).toEqual(["en", "fr"])
|
|
283
|
+
|
|
284
|
+
i18n.locale.set("de")
|
|
285
|
+
expect(locales).toEqual(["en", "fr", "de"])
|
|
286
|
+
|
|
287
|
+
cleanup.dispose()
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it("switching to locale with missing key falls back to fallbackLocale", () => {
|
|
291
|
+
const i18n = createI18n({
|
|
292
|
+
locale: "en",
|
|
293
|
+
fallbackLocale: "en",
|
|
294
|
+
messages: {
|
|
295
|
+
en: { common: "English common", onlyEn: "Only in English" },
|
|
296
|
+
fr: { common: "French common" },
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
i18n.locale.set("fr")
|
|
301
|
+
expect(i18n.t("common")).toBe("French common")
|
|
302
|
+
expect(i18n.t("onlyEn")).toBe("Only in English") // falls back to en
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
// ─── parseRichText — additional edge cases ───────────────────────────────────
|
|
307
|
+
|
|
308
|
+
describe("parseRichText — additional", () => {
|
|
309
|
+
it("handles self-closing-like tags (treated as unmatched)", () => {
|
|
310
|
+
// parseRichText only handles <tag>content</tag> — <br/> etc. are left as-is
|
|
311
|
+
expect(parseRichText("Line1<br/>Line2")).toEqual(["Line1<br/>Line2"])
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it("nested tags match inner tag only (regex uses [^<]* for content)", () => {
|
|
315
|
+
// The regex [^<]* means content between tags cannot contain <, so the outer
|
|
316
|
+
// tag is not matched and only the inner <em> tag is parsed
|
|
317
|
+
const result = parseRichText("<bold>some <em>nested</em> text</bold>")
|
|
318
|
+
expect(result).toEqual(["<bold>some ", { tag: "em", children: "nested" }, " text</bold>"])
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it("tag names with only word characters are matched (\\w+)", () => {
|
|
322
|
+
// \\w matches [a-zA-Z0-9_], so underscores and digits work
|
|
323
|
+
const result = parseRichText("Click <cta_1>here</cta_1>!")
|
|
324
|
+
expect(result).toEqual(["Click ", { tag: "cta_1", children: "here" }, "!"])
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it("tag names with hyphens are not matched (left as plain text)", () => {
|
|
328
|
+
// Hyphens are not word characters, so <cta-1> is not a valid tag
|
|
329
|
+
const result = parseRichText("Click <cta-1>here</cta-1>!")
|
|
330
|
+
expect(result).toEqual(["Click <cta-1>here</cta-1>!"])
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// ─── Trans component — additional scenarios ──────────────────────────────────
|
|
335
|
+
|
|
336
|
+
describe("Trans — additional", () => {
|
|
337
|
+
it("handles translation with multiple component tags", () => {
|
|
338
|
+
const i18n = createI18n({
|
|
339
|
+
locale: "en",
|
|
340
|
+
messages: {
|
|
341
|
+
en: { tos: "Read <terms>terms</terms> and <privacy>privacy</privacy>" },
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
const result = Trans({
|
|
346
|
+
t: i18n.t,
|
|
347
|
+
i18nKey: "tos",
|
|
348
|
+
components: {
|
|
349
|
+
terms: (children: string) => ({ type: "a", props: { href: "/terms" }, children }),
|
|
350
|
+
privacy: (children: string) => ({ type: "a", props: { href: "/privacy" }, children }),
|
|
351
|
+
},
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
const vnode = result as any
|
|
355
|
+
expect(vnode.children.length).toBe(4) // "Read ", terms link, " and ", privacy link
|
|
356
|
+
expect(vnode.children[0]).toBe("Read ")
|
|
357
|
+
expect(vnode.children[1]).toEqual({ type: "a", props: { href: "/terms" }, children: "terms" })
|
|
358
|
+
expect(vnode.children[2]).toBe(" and ")
|
|
359
|
+
expect(vnode.children[3]).toEqual({
|
|
360
|
+
type: "a",
|
|
361
|
+
props: { href: "/privacy" },
|
|
362
|
+
children: "privacy",
|
|
363
|
+
})
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it("returns plain text when translation has no tags and no components", () => {
|
|
367
|
+
const t = (key: string) => (key === "plain" ? "Just plain text" : key)
|
|
368
|
+
const result = Trans({ t, i18nKey: "plain" })
|
|
369
|
+
expect(result).toBe("Just plain text")
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
// ─── Deep merge edge cases ───────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
describe("addMessages deep merge edge cases", () => {
|
|
376
|
+
it("prototype pollution keys are rejected", () => {
|
|
377
|
+
const i18n = createI18n({ locale: "en", messages: { en: { safe: "yes" } } })
|
|
378
|
+
// deepMerge skips __proto__, constructor, prototype
|
|
379
|
+
i18n.addMessages("en", { __proto__: { polluted: "yes" } } as any)
|
|
380
|
+
expect(i18n.t("safe")).toBe("yes")
|
|
381
|
+
// The polluted key should not be accessible
|
|
382
|
+
expect(({} as any).polluted).toBeUndefined()
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it("addMessages with undefined values are skipped in nestFlatKeys", () => {
|
|
386
|
+
const i18n = createI18n({ locale: "en", messages: { en: {} } })
|
|
387
|
+
i18n.addMessages("en", { key: undefined as any, valid: "yes" })
|
|
388
|
+
expect(i18n.t("valid")).toBe("yes")
|
|
389
|
+
})
|
|
390
|
+
})
|