@pyreon/i18n 0.9.0 → 0.11.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/lib/analysis/core.js.html +5406 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/core.js +249 -0
- package/lib/core.js.map +1 -0
- package/lib/devtools.js.map +1 -1
- package/lib/index.js +29 -53
- package/lib/index.js.map +1 -1
- package/lib/types/core.d.ts +142 -0
- package/lib/types/core.d.ts.map +1 -0
- package/lib/types/devtools.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +19 -7
- package/src/context.ts +5 -7
- package/src/core.ts +22 -0
- package/src/create-i18n.ts +59 -67
- package/src/devtools.ts +6 -8
- package/src/index.ts +8 -8
- package/src/interpolation.ts +4 -9
- package/src/pluralization.ts +3 -3
- package/src/tests/devtools.test.ts +57 -59
- package/src/tests/i18n.test.tsx +356 -342
- package/src/tests/setup.ts +1 -1
- package/src/trans.tsx +4 -4
- package/src/types.ts +3 -11
package/src/tests/i18n.test.tsx
CHANGED
|
@@ -1,127 +1,120 @@
|
|
|
1
|
-
import { effect } from
|
|
2
|
-
import { mount } from
|
|
3
|
-
import { I18nProvider, useI18n } from
|
|
4
|
-
import { createI18n } from
|
|
5
|
-
import { interpolate } from
|
|
6
|
-
import { resolvePluralCategory } from
|
|
7
|
-
import { parseRichText, Trans } from
|
|
8
|
-
import type { TranslationDictionary } from
|
|
1
|
+
import { effect } from "@pyreon/reactivity"
|
|
2
|
+
import { mount } from "@pyreon/runtime-dom"
|
|
3
|
+
import { I18nProvider, useI18n } from "../context"
|
|
4
|
+
import { createI18n } from "../create-i18n"
|
|
5
|
+
import { interpolate } from "../interpolation"
|
|
6
|
+
import { resolvePluralCategory } from "../pluralization"
|
|
7
|
+
import { parseRichText, Trans } from "../trans"
|
|
8
|
+
import type { TranslationDictionary } from "../types"
|
|
9
9
|
|
|
10
10
|
// ─── interpolate ─────────────────────────────────────────────────────────────
|
|
11
11
|
|
|
12
|
-
describe(
|
|
13
|
-
it(
|
|
14
|
-
expect(interpolate(
|
|
15
|
-
'Hello Alice!',
|
|
16
|
-
)
|
|
12
|
+
describe("interpolate", () => {
|
|
13
|
+
it("replaces placeholders with values", () => {
|
|
14
|
+
expect(interpolate("Hello {{name}}!", { name: "Alice" })).toBe("Hello Alice!")
|
|
17
15
|
})
|
|
18
16
|
|
|
19
|
-
it(
|
|
20
|
-
expect(
|
|
21
|
-
interpolate('{{greeting}}, {{name}}!', { greeting: 'Hi', name: 'Bob' }),
|
|
22
|
-
).toBe('Hi, Bob!')
|
|
17
|
+
it("handles multiple placeholders", () => {
|
|
18
|
+
expect(interpolate("{{greeting}}, {{name}}!", { greeting: "Hi", name: "Bob" })).toBe("Hi, Bob!")
|
|
23
19
|
})
|
|
24
20
|
|
|
25
|
-
it(
|
|
26
|
-
expect(interpolate(
|
|
27
|
-
'Hello Alice!',
|
|
28
|
-
)
|
|
21
|
+
it("handles whitespace inside braces", () => {
|
|
22
|
+
expect(interpolate("Hello {{ name }}!", { name: "Alice" })).toBe("Hello Alice!")
|
|
29
23
|
})
|
|
30
24
|
|
|
31
|
-
it(
|
|
32
|
-
expect(interpolate(
|
|
25
|
+
it("leaves unmatched placeholders as-is", () => {
|
|
26
|
+
expect(interpolate("Hello {{name}}!", {})).toBe("Hello {{name}}!")
|
|
33
27
|
})
|
|
34
28
|
|
|
35
|
-
it(
|
|
36
|
-
expect(interpolate(
|
|
29
|
+
it("returns template unchanged when no values provided", () => {
|
|
30
|
+
expect(interpolate("Hello {{name}}!")).toBe("Hello {{name}}!")
|
|
37
31
|
})
|
|
38
32
|
|
|
39
|
-
it(
|
|
40
|
-
expect(interpolate(
|
|
33
|
+
it("returns template unchanged when no placeholders present", () => {
|
|
34
|
+
expect(interpolate("Hello world!", { name: "Alice" })).toBe("Hello world!")
|
|
41
35
|
})
|
|
42
36
|
|
|
43
|
-
it(
|
|
44
|
-
expect(interpolate(
|
|
37
|
+
it("handles number values", () => {
|
|
38
|
+
expect(interpolate("Count: {{count}}", { count: 42 })).toBe("Count: 42")
|
|
45
39
|
})
|
|
46
40
|
})
|
|
47
41
|
|
|
48
42
|
// ─── resolvePluralCategory ───────────────────────────────────────────────────
|
|
49
43
|
|
|
50
|
-
describe(
|
|
44
|
+
describe("resolvePluralCategory", () => {
|
|
51
45
|
it('returns "one" for count 1 in English', () => {
|
|
52
|
-
expect(resolvePluralCategory(
|
|
46
|
+
expect(resolvePluralCategory("en", 1)).toBe("one")
|
|
53
47
|
})
|
|
54
48
|
|
|
55
49
|
it('returns "other" for count 0 in English', () => {
|
|
56
|
-
expect(resolvePluralCategory(
|
|
50
|
+
expect(resolvePluralCategory("en", 0)).toBe("other")
|
|
57
51
|
})
|
|
58
52
|
|
|
59
53
|
it('returns "other" for count > 1 in English', () => {
|
|
60
|
-
expect(resolvePluralCategory(
|
|
54
|
+
expect(resolvePluralCategory("en", 5)).toBe("other")
|
|
61
55
|
})
|
|
62
56
|
|
|
63
|
-
it(
|
|
57
|
+
it("uses custom plural rules when provided", () => {
|
|
64
58
|
const rules = {
|
|
65
|
-
custom: (count: number) =>
|
|
66
|
-
count === 0 ? 'zero' : count === 1 ? 'one' : 'other',
|
|
59
|
+
custom: (count: number) => (count === 0 ? "zero" : count === 1 ? "one" : "other"),
|
|
67
60
|
}
|
|
68
|
-
expect(resolvePluralCategory(
|
|
69
|
-
expect(resolvePluralCategory(
|
|
70
|
-
expect(resolvePluralCategory(
|
|
61
|
+
expect(resolvePluralCategory("custom", 0, rules)).toBe("zero")
|
|
62
|
+
expect(resolvePluralCategory("custom", 1, rules)).toBe("one")
|
|
63
|
+
expect(resolvePluralCategory("custom", 5, rules)).toBe("other")
|
|
71
64
|
})
|
|
72
65
|
})
|
|
73
66
|
|
|
74
67
|
// ─── createI18n ──────────────────────────────────────────────────────────────
|
|
75
68
|
|
|
76
|
-
describe(
|
|
69
|
+
describe("createI18n", () => {
|
|
77
70
|
const en = {
|
|
78
|
-
greeting:
|
|
79
|
-
farewell:
|
|
71
|
+
greeting: "Hello {{name}}!",
|
|
72
|
+
farewell: "Goodbye",
|
|
80
73
|
nested: {
|
|
81
74
|
deep: {
|
|
82
|
-
key:
|
|
75
|
+
key: "Deep value",
|
|
83
76
|
},
|
|
84
77
|
},
|
|
85
78
|
}
|
|
86
79
|
|
|
87
80
|
const de = {
|
|
88
|
-
greeting:
|
|
89
|
-
farewell:
|
|
81
|
+
greeting: "Hallo {{name}}!",
|
|
82
|
+
farewell: "Auf Wiedersehen",
|
|
90
83
|
nested: {
|
|
91
84
|
deep: {
|
|
92
|
-
key:
|
|
85
|
+
key: "Tiefer Wert",
|
|
93
86
|
},
|
|
94
87
|
},
|
|
95
88
|
}
|
|
96
89
|
|
|
97
|
-
it(
|
|
98
|
-
const i18n = createI18n({ locale:
|
|
99
|
-
expect(i18n.t(
|
|
90
|
+
it("translates a simple key", () => {
|
|
91
|
+
const i18n = createI18n({ locale: "en", messages: { en } })
|
|
92
|
+
expect(i18n.t("greeting", { name: "Alice" })).toBe("Hello Alice!")
|
|
100
93
|
})
|
|
101
94
|
|
|
102
|
-
it(
|
|
103
|
-
const i18n = createI18n({ locale:
|
|
104
|
-
expect(i18n.t(
|
|
95
|
+
it("translates nested keys with dot notation", () => {
|
|
96
|
+
const i18n = createI18n({ locale: "en", messages: { en } })
|
|
97
|
+
expect(i18n.t("nested.deep.key")).toBe("Deep value")
|
|
105
98
|
})
|
|
106
99
|
|
|
107
|
-
it(
|
|
108
|
-
const i18n = createI18n({ locale:
|
|
109
|
-
expect(i18n.t(
|
|
100
|
+
it("returns the key itself when missing", () => {
|
|
101
|
+
const i18n = createI18n({ locale: "en", messages: { en } })
|
|
102
|
+
expect(i18n.t("nonexistent.key")).toBe("nonexistent.key")
|
|
110
103
|
})
|
|
111
104
|
|
|
112
|
-
it(
|
|
105
|
+
it("falls back to fallbackLocale when key is missing", () => {
|
|
113
106
|
const i18n = createI18n({
|
|
114
|
-
locale:
|
|
115
|
-
fallbackLocale:
|
|
116
|
-
messages: { en: { ...en, onlyEn:
|
|
107
|
+
locale: "de",
|
|
108
|
+
fallbackLocale: "en",
|
|
109
|
+
messages: { en: { ...en, onlyEn: "English only" }, de },
|
|
117
110
|
})
|
|
118
|
-
expect(i18n.t(
|
|
111
|
+
expect(i18n.t("onlyEn")).toBe("English only")
|
|
119
112
|
})
|
|
120
113
|
|
|
121
|
-
it(
|
|
114
|
+
it("calls onMissingKey when translation is not found", () => {
|
|
122
115
|
const missing: string[] = []
|
|
123
116
|
const i18n = createI18n({
|
|
124
|
-
locale:
|
|
117
|
+
locale: "en",
|
|
125
118
|
messages: { en },
|
|
126
119
|
onMissingKey: (locale, key) => {
|
|
127
120
|
missing.push(`${locale}:${key}`)
|
|
@@ -129,13 +122,13 @@ describe('createI18n', () => {
|
|
|
129
122
|
},
|
|
130
123
|
})
|
|
131
124
|
|
|
132
|
-
expect(i18n.t(
|
|
133
|
-
expect(missing).toEqual([
|
|
125
|
+
expect(i18n.t("unknown")).toBe("[MISSING: unknown]")
|
|
126
|
+
expect(missing).toEqual(["en:unknown"])
|
|
134
127
|
})
|
|
135
128
|
|
|
136
|
-
it(
|
|
129
|
+
it("falls back to key when onMissingKey returns void", () => {
|
|
137
130
|
const i18n = createI18n({
|
|
138
|
-
locale:
|
|
131
|
+
locale: "en",
|
|
139
132
|
messages: { en },
|
|
140
133
|
onMissingKey: () => {
|
|
141
134
|
// intentionally returns undefined
|
|
@@ -143,196 +136,196 @@ describe('createI18n', () => {
|
|
|
143
136
|
},
|
|
144
137
|
})
|
|
145
138
|
|
|
146
|
-
expect(i18n.t(
|
|
139
|
+
expect(i18n.t("unknown")).toBe("unknown")
|
|
147
140
|
})
|
|
148
141
|
|
|
149
|
-
it(
|
|
142
|
+
it("uses fallback locale for plural forms", () => {
|
|
150
143
|
const i18n = createI18n({
|
|
151
|
-
locale:
|
|
152
|
-
fallbackLocale:
|
|
144
|
+
locale: "de",
|
|
145
|
+
fallbackLocale: "en",
|
|
153
146
|
messages: {
|
|
154
147
|
en: {
|
|
155
|
-
items_one:
|
|
156
|
-
items_other:
|
|
148
|
+
items_one: "{{count}} item",
|
|
149
|
+
items_other: "{{count}} items",
|
|
157
150
|
},
|
|
158
151
|
de: {},
|
|
159
152
|
},
|
|
160
153
|
})
|
|
161
154
|
|
|
162
155
|
// German has no items translation, falls back to English plural forms
|
|
163
|
-
expect(i18n.t(
|
|
164
|
-
expect(i18n.t(
|
|
156
|
+
expect(i18n.t("items", { count: 1 })).toBe("1 item")
|
|
157
|
+
expect(i18n.t("items", { count: 5 })).toBe("5 items")
|
|
165
158
|
})
|
|
166
159
|
|
|
167
|
-
it(
|
|
160
|
+
it("reactively updates when locale changes", () => {
|
|
168
161
|
const i18n = createI18n({
|
|
169
|
-
locale:
|
|
162
|
+
locale: "en",
|
|
170
163
|
messages: { en, de },
|
|
171
164
|
})
|
|
172
165
|
|
|
173
166
|
const results: string[] = []
|
|
174
167
|
const cleanup = effect(() => {
|
|
175
|
-
results.push(i18n.t(
|
|
168
|
+
results.push(i18n.t("farewell"))
|
|
176
169
|
})
|
|
177
170
|
|
|
178
|
-
expect(results).toEqual([
|
|
171
|
+
expect(results).toEqual(["Goodbye"])
|
|
179
172
|
|
|
180
|
-
i18n.locale.set(
|
|
181
|
-
expect(results).toEqual([
|
|
173
|
+
i18n.locale.set("de")
|
|
174
|
+
expect(results).toEqual(["Goodbye", "Auf Wiedersehen"])
|
|
182
175
|
|
|
183
176
|
cleanup.dispose()
|
|
184
177
|
})
|
|
185
178
|
|
|
186
|
-
it(
|
|
179
|
+
it("reactively updates when messages are added", () => {
|
|
187
180
|
const i18n = createI18n({
|
|
188
|
-
locale:
|
|
181
|
+
locale: "en",
|
|
189
182
|
messages: { en },
|
|
190
183
|
})
|
|
191
184
|
|
|
192
185
|
const results: string[] = []
|
|
193
186
|
const cleanup = effect(() => {
|
|
194
|
-
results.push(i18n.t(
|
|
187
|
+
results.push(i18n.t("newKey"))
|
|
195
188
|
})
|
|
196
189
|
|
|
197
|
-
expect(results).toEqual([
|
|
190
|
+
expect(results).toEqual(["newKey"]) // Missing key returns key itself
|
|
198
191
|
|
|
199
|
-
i18n.addMessages(
|
|
200
|
-
expect(results).toEqual([
|
|
192
|
+
i18n.addMessages("en", { newKey: "New value!" })
|
|
193
|
+
expect(results).toEqual(["newKey", "New value!"])
|
|
201
194
|
|
|
202
195
|
cleanup.dispose()
|
|
203
196
|
})
|
|
204
197
|
|
|
205
|
-
it(
|
|
198
|
+
it("reports available locales", () => {
|
|
206
199
|
const i18n = createI18n({
|
|
207
|
-
locale:
|
|
200
|
+
locale: "en",
|
|
208
201
|
messages: { en, de },
|
|
209
202
|
})
|
|
210
|
-
expect(i18n.availableLocales()).toEqual([
|
|
203
|
+
expect(i18n.availableLocales()).toEqual(["en", "de"])
|
|
211
204
|
})
|
|
212
205
|
|
|
213
|
-
it(
|
|
214
|
-
const i18n = createI18n({ locale:
|
|
215
|
-
expect(i18n.exists(
|
|
216
|
-
expect(i18n.exists(
|
|
217
|
-
expect(i18n.exists(
|
|
206
|
+
it("exists() checks key presence", () => {
|
|
207
|
+
const i18n = createI18n({ locale: "en", messages: { en } })
|
|
208
|
+
expect(i18n.exists("greeting")).toBe(true)
|
|
209
|
+
expect(i18n.exists("nested.deep.key")).toBe(true)
|
|
210
|
+
expect(i18n.exists("nonexistent")).toBe(false)
|
|
218
211
|
})
|
|
219
212
|
|
|
220
|
-
it(
|
|
213
|
+
it("exists() checks namespaced keys", () => {
|
|
221
214
|
const i18n = createI18n({
|
|
222
|
-
locale:
|
|
215
|
+
locale: "en",
|
|
223
216
|
messages: { en: {} },
|
|
224
217
|
})
|
|
225
|
-
i18n.addMessages(
|
|
226
|
-
expect(i18n.exists(
|
|
227
|
-
expect(i18n.exists(
|
|
218
|
+
i18n.addMessages("en", { title: "Admin Panel" }, "admin")
|
|
219
|
+
expect(i18n.exists("admin:title")).toBe(true)
|
|
220
|
+
expect(i18n.exists("admin:nonexistent")).toBe(false)
|
|
228
221
|
})
|
|
229
222
|
|
|
230
|
-
it(
|
|
223
|
+
it("exists() checks fallback locale", () => {
|
|
231
224
|
const i18n = createI18n({
|
|
232
|
-
locale:
|
|
233
|
-
fallbackLocale:
|
|
234
|
-
messages: { en: { ...en, onlyEn:
|
|
225
|
+
locale: "de",
|
|
226
|
+
fallbackLocale: "en",
|
|
227
|
+
messages: { en: { ...en, onlyEn: "yes" }, de },
|
|
235
228
|
})
|
|
236
|
-
expect(i18n.exists(
|
|
229
|
+
expect(i18n.exists("onlyEn")).toBe(true)
|
|
237
230
|
})
|
|
238
231
|
})
|
|
239
232
|
|
|
240
233
|
// ─── Pluralization ───────────────────────────────────────────────────────────
|
|
241
234
|
|
|
242
|
-
describe(
|
|
243
|
-
it(
|
|
235
|
+
describe("createI18n pluralization", () => {
|
|
236
|
+
it("selects the correct plural form", () => {
|
|
244
237
|
const i18n = createI18n({
|
|
245
|
-
locale:
|
|
238
|
+
locale: "en",
|
|
246
239
|
messages: {
|
|
247
240
|
en: {
|
|
248
|
-
items_one:
|
|
249
|
-
items_other:
|
|
241
|
+
items_one: "{{count}} item",
|
|
242
|
+
items_other: "{{count}} items",
|
|
250
243
|
},
|
|
251
244
|
},
|
|
252
245
|
})
|
|
253
246
|
|
|
254
|
-
expect(i18n.t(
|
|
255
|
-
expect(i18n.t(
|
|
256
|
-
expect(i18n.t(
|
|
247
|
+
expect(i18n.t("items", { count: 1 })).toBe("1 item")
|
|
248
|
+
expect(i18n.t("items", { count: 5 })).toBe("5 items")
|
|
249
|
+
expect(i18n.t("items", { count: 0 })).toBe("0 items")
|
|
257
250
|
})
|
|
258
251
|
|
|
259
|
-
it(
|
|
252
|
+
it("falls back to base key when plural form is missing", () => {
|
|
260
253
|
const i18n = createI18n({
|
|
261
|
-
locale:
|
|
254
|
+
locale: "en",
|
|
262
255
|
messages: {
|
|
263
256
|
en: {
|
|
264
|
-
items:
|
|
257
|
+
items: "some items", // No _one or _other suffixes
|
|
265
258
|
},
|
|
266
259
|
},
|
|
267
260
|
})
|
|
268
261
|
|
|
269
|
-
expect(i18n.t(
|
|
262
|
+
expect(i18n.t("items", { count: 1 })).toBe("some items")
|
|
270
263
|
})
|
|
271
264
|
|
|
272
|
-
it(
|
|
265
|
+
it("falls back to basic rules when Intl.PluralRules fails", () => {
|
|
273
266
|
// Use an invalid locale to trigger the catch branch
|
|
274
|
-
const category1 = resolvePluralCategory(
|
|
275
|
-
const category5 = resolvePluralCategory(
|
|
267
|
+
const category1 = resolvePluralCategory("invalid-xxx-yyy", 1)
|
|
268
|
+
const category5 = resolvePluralCategory("invalid-xxx-yyy", 5)
|
|
276
269
|
// Intl.PluralRules may either handle it or throw — either way we get a result
|
|
277
|
-
expect(typeof category1).toBe(
|
|
278
|
-
expect(typeof category5).toBe(
|
|
270
|
+
expect(typeof category1).toBe("string")
|
|
271
|
+
expect(typeof category5).toBe("string")
|
|
279
272
|
})
|
|
280
273
|
|
|
281
|
-
it(
|
|
274
|
+
it("uses custom plural rules", () => {
|
|
282
275
|
const i18n = createI18n({
|
|
283
|
-
locale:
|
|
276
|
+
locale: "ar",
|
|
284
277
|
pluralRules: {
|
|
285
278
|
ar: (count: number) => {
|
|
286
|
-
if (count === 0) return
|
|
287
|
-
if (count === 1) return
|
|
288
|
-
if (count === 2) return
|
|
289
|
-
if (count >= 3 && count <= 10) return
|
|
290
|
-
return
|
|
279
|
+
if (count === 0) return "zero"
|
|
280
|
+
if (count === 1) return "one"
|
|
281
|
+
if (count === 2) return "two"
|
|
282
|
+
if (count >= 3 && count <= 10) return "few"
|
|
283
|
+
return "other"
|
|
291
284
|
},
|
|
292
285
|
},
|
|
293
286
|
messages: {
|
|
294
287
|
ar: {
|
|
295
|
-
items_zero:
|
|
296
|
-
items_one:
|
|
297
|
-
items_two:
|
|
298
|
-
items_few:
|
|
299
|
-
items_other:
|
|
288
|
+
items_zero: "لا عناصر",
|
|
289
|
+
items_one: "عنصر واحد",
|
|
290
|
+
items_two: "عنصران",
|
|
291
|
+
items_few: "{{count}} عناصر",
|
|
292
|
+
items_other: "{{count}} عنصراً",
|
|
300
293
|
},
|
|
301
294
|
},
|
|
302
295
|
})
|
|
303
296
|
|
|
304
|
-
expect(i18n.t(
|
|
305
|
-
expect(i18n.t(
|
|
306
|
-
expect(i18n.t(
|
|
307
|
-
expect(i18n.t(
|
|
308
|
-
expect(i18n.t(
|
|
297
|
+
expect(i18n.t("items", { count: 0 })).toBe("لا عناصر")
|
|
298
|
+
expect(i18n.t("items", { count: 1 })).toBe("عنصر واحد")
|
|
299
|
+
expect(i18n.t("items", { count: 2 })).toBe("عنصران")
|
|
300
|
+
expect(i18n.t("items", { count: 5 })).toBe("5 عناصر")
|
|
301
|
+
expect(i18n.t("items", { count: 100 })).toBe("100 عنصراً")
|
|
309
302
|
})
|
|
310
303
|
})
|
|
311
304
|
|
|
312
305
|
// ─── Namespaces ──────────────────────────────────────────────────────────────
|
|
313
306
|
|
|
314
|
-
describe(
|
|
315
|
-
it(
|
|
307
|
+
describe("createI18n namespaces", () => {
|
|
308
|
+
it("supports namespace:key syntax", () => {
|
|
316
309
|
const i18n = createI18n({
|
|
317
|
-
locale:
|
|
318
|
-
messages: { en: { greeting:
|
|
310
|
+
locale: "en",
|
|
311
|
+
messages: { en: { greeting: "Hi" } },
|
|
319
312
|
})
|
|
320
313
|
// "greeting" is in the default "common" namespace
|
|
321
|
-
expect(i18n.t(
|
|
314
|
+
expect(i18n.t("greeting")).toBe("Hi")
|
|
322
315
|
})
|
|
323
316
|
|
|
324
|
-
it(
|
|
317
|
+
it("loads namespaces asynchronously", async () => {
|
|
325
318
|
const loaderCalls: string[] = []
|
|
326
319
|
|
|
327
320
|
const i18n = createI18n({
|
|
328
|
-
locale:
|
|
321
|
+
locale: "en",
|
|
329
322
|
loader: async (locale, namespace) => {
|
|
330
323
|
loaderCalls.push(`${locale}:${namespace}`)
|
|
331
|
-
if (namespace ===
|
|
324
|
+
if (namespace === "auth") {
|
|
332
325
|
return {
|
|
333
|
-
login:
|
|
326
|
+
login: "Log in",
|
|
334
327
|
errors: {
|
|
335
|
-
invalid:
|
|
328
|
+
invalid: "Invalid credentials",
|
|
336
329
|
},
|
|
337
330
|
}
|
|
338
331
|
}
|
|
@@ -340,20 +333,20 @@ describe('createI18n namespaces', () => {
|
|
|
340
333
|
},
|
|
341
334
|
})
|
|
342
335
|
|
|
343
|
-
expect(i18n.t(
|
|
336
|
+
expect(i18n.t("auth:login")).toBe("auth:login") // Not loaded yet
|
|
344
337
|
|
|
345
|
-
await i18n.loadNamespace(
|
|
338
|
+
await i18n.loadNamespace("auth")
|
|
346
339
|
|
|
347
|
-
expect(loaderCalls).toEqual([
|
|
348
|
-
expect(i18n.t(
|
|
349
|
-
expect(i18n.t(
|
|
340
|
+
expect(loaderCalls).toEqual(["en:auth"])
|
|
341
|
+
expect(i18n.t("auth:login")).toBe("Log in")
|
|
342
|
+
expect(i18n.t("auth:errors.invalid")).toBe("Invalid credentials")
|
|
350
343
|
})
|
|
351
344
|
|
|
352
|
-
it(
|
|
345
|
+
it("tracks loading state", async () => {
|
|
353
346
|
let resolveLoader: ((dict: TranslationDictionary) => void) | undefined
|
|
354
347
|
|
|
355
348
|
const i18n = createI18n({
|
|
356
|
-
locale:
|
|
349
|
+
locale: "en",
|
|
357
350
|
loader: async () => {
|
|
358
351
|
return new Promise<TranslationDictionary>((resolve) => {
|
|
359
352
|
resolveLoader = resolve
|
|
@@ -363,234 +356,231 @@ describe('createI18n namespaces', () => {
|
|
|
363
356
|
|
|
364
357
|
expect(i18n.isLoading()).toBe(false)
|
|
365
358
|
|
|
366
|
-
const loadPromise = i18n.loadNamespace(
|
|
359
|
+
const loadPromise = i18n.loadNamespace("test")
|
|
367
360
|
expect(i18n.isLoading()).toBe(true)
|
|
368
361
|
|
|
369
|
-
resolveLoader!({ hello:
|
|
362
|
+
resolveLoader!({ hello: "world" })
|
|
370
363
|
await loadPromise
|
|
371
364
|
|
|
372
365
|
expect(i18n.isLoading()).toBe(false)
|
|
373
366
|
})
|
|
374
367
|
|
|
375
|
-
it(
|
|
368
|
+
it("does not re-fetch already loaded namespaces", async () => {
|
|
376
369
|
let callCount = 0
|
|
377
370
|
|
|
378
371
|
const i18n = createI18n({
|
|
379
|
-
locale:
|
|
372
|
+
locale: "en",
|
|
380
373
|
loader: async () => {
|
|
381
374
|
callCount++
|
|
382
|
-
return { key:
|
|
375
|
+
return { key: "value" }
|
|
383
376
|
},
|
|
384
377
|
})
|
|
385
378
|
|
|
386
|
-
await i18n.loadNamespace(
|
|
387
|
-
await i18n.loadNamespace(
|
|
379
|
+
await i18n.loadNamespace("test")
|
|
380
|
+
await i18n.loadNamespace("test")
|
|
388
381
|
|
|
389
382
|
expect(callCount).toBe(1)
|
|
390
383
|
})
|
|
391
384
|
|
|
392
|
-
it(
|
|
385
|
+
it("deduplicates concurrent loads for the same namespace", async () => {
|
|
393
386
|
let callCount = 0
|
|
394
387
|
|
|
395
388
|
const i18n = createI18n({
|
|
396
|
-
locale:
|
|
389
|
+
locale: "en",
|
|
397
390
|
loader: async () => {
|
|
398
391
|
callCount++
|
|
399
|
-
return { key:
|
|
392
|
+
return { key: "value" }
|
|
400
393
|
},
|
|
401
394
|
})
|
|
402
395
|
|
|
403
396
|
// Fire two loads concurrently — should only call loader once
|
|
404
|
-
await Promise.all([i18n.loadNamespace(
|
|
397
|
+
await Promise.all([i18n.loadNamespace("test"), i18n.loadNamespace("test")])
|
|
405
398
|
|
|
406
399
|
expect(callCount).toBe(1)
|
|
407
400
|
})
|
|
408
401
|
|
|
409
|
-
it(
|
|
402
|
+
it("loadedNamespaces reflects current locale namespaces", async () => {
|
|
410
403
|
const i18n = createI18n({
|
|
411
|
-
locale:
|
|
412
|
-
loader: async (_locale, ns) => ({ [`${ns}Key`]:
|
|
404
|
+
locale: "en",
|
|
405
|
+
loader: async (_locale, ns) => ({ [`${ns}Key`]: "value" }),
|
|
413
406
|
})
|
|
414
407
|
|
|
415
408
|
expect(i18n.loadedNamespaces().size).toBe(0)
|
|
416
409
|
|
|
417
|
-
await i18n.loadNamespace(
|
|
418
|
-
expect(i18n.loadedNamespaces().has(
|
|
410
|
+
await i18n.loadNamespace("auth")
|
|
411
|
+
expect(i18n.loadedNamespaces().has("auth")).toBe(true)
|
|
419
412
|
|
|
420
|
-
await i18n.loadNamespace(
|
|
421
|
-
expect(i18n.loadedNamespaces().has(
|
|
422
|
-
expect(i18n.loadedNamespaces().has(
|
|
413
|
+
await i18n.loadNamespace("admin")
|
|
414
|
+
expect(i18n.loadedNamespaces().has("auth")).toBe(true)
|
|
415
|
+
expect(i18n.loadedNamespaces().has("admin")).toBe(true)
|
|
423
416
|
})
|
|
424
417
|
|
|
425
|
-
it(
|
|
418
|
+
it("loadNamespace is a no-op without a loader", async () => {
|
|
426
419
|
const i18n = createI18n({
|
|
427
|
-
locale:
|
|
428
|
-
messages: { en: { key:
|
|
420
|
+
locale: "en",
|
|
421
|
+
messages: { en: { key: "value" } },
|
|
429
422
|
})
|
|
430
423
|
|
|
431
|
-
await i18n.loadNamespace(
|
|
424
|
+
await i18n.loadNamespace("test") // Should not throw
|
|
432
425
|
expect(i18n.isLoading()).toBe(false)
|
|
433
426
|
})
|
|
434
427
|
|
|
435
|
-
it(
|
|
428
|
+
it("handles loader returning undefined", async () => {
|
|
436
429
|
const i18n = createI18n({
|
|
437
|
-
locale:
|
|
430
|
+
locale: "en",
|
|
438
431
|
loader: async () => undefined,
|
|
439
432
|
})
|
|
440
433
|
|
|
441
|
-
await i18n.loadNamespace(
|
|
442
|
-
expect(i18n.loadedNamespaces().has(
|
|
434
|
+
await i18n.loadNamespace("missing")
|
|
435
|
+
expect(i18n.loadedNamespaces().has("missing")).toBe(false)
|
|
443
436
|
})
|
|
444
437
|
|
|
445
|
-
it(
|
|
438
|
+
it("handles loader errors gracefully", async () => {
|
|
446
439
|
const i18n = createI18n({
|
|
447
|
-
locale:
|
|
440
|
+
locale: "en",
|
|
448
441
|
loader: async () => {
|
|
449
|
-
throw new Error(
|
|
442
|
+
throw new Error("Network error")
|
|
450
443
|
},
|
|
451
444
|
})
|
|
452
445
|
|
|
453
|
-
await expect(i18n.loadNamespace(
|
|
446
|
+
await expect(i18n.loadNamespace("test")).rejects.toThrow("Network error")
|
|
454
447
|
expect(i18n.isLoading()).toBe(false) // Loading state cleaned up
|
|
455
448
|
})
|
|
456
449
|
|
|
457
|
-
it(
|
|
458
|
-
const i18n = createI18n({ locale:
|
|
450
|
+
it("addMessages does not corrupt store when source is mutated", () => {
|
|
451
|
+
const i18n = createI18n({ locale: "en", messages: { en: {} } })
|
|
459
452
|
|
|
460
|
-
const source = { greeting:
|
|
461
|
-
i18n.addMessages(
|
|
453
|
+
const source = { greeting: "Hello" }
|
|
454
|
+
i18n.addMessages("en", source)
|
|
462
455
|
|
|
463
456
|
// Mutating the source should not affect the store
|
|
464
|
-
source.greeting =
|
|
465
|
-
expect(i18n.t(
|
|
457
|
+
source.greeting = "MUTATED"
|
|
458
|
+
expect(i18n.t("greeting")).toBe("Hello")
|
|
466
459
|
})
|
|
467
460
|
|
|
468
|
-
it(
|
|
461
|
+
it("loads namespace for a specific locale", async () => {
|
|
469
462
|
const loaderCalls: string[] = []
|
|
470
463
|
|
|
471
464
|
const i18n = createI18n({
|
|
472
|
-
locale:
|
|
465
|
+
locale: "en",
|
|
473
466
|
loader: async (locale, namespace) => {
|
|
474
467
|
loaderCalls.push(`${locale}:${namespace}`)
|
|
475
468
|
return { key: `${locale} value` }
|
|
476
469
|
},
|
|
477
470
|
})
|
|
478
471
|
|
|
479
|
-
await i18n.loadNamespace(
|
|
480
|
-
expect(loaderCalls).toEqual([
|
|
472
|
+
await i18n.loadNamespace("common", "de")
|
|
473
|
+
expect(loaderCalls).toEqual(["de:common"])
|
|
481
474
|
})
|
|
482
475
|
|
|
483
|
-
it(
|
|
476
|
+
it("addMessages merges into existing namespace", () => {
|
|
484
477
|
const i18n = createI18n({
|
|
485
|
-
locale:
|
|
478
|
+
locale: "en",
|
|
486
479
|
messages: {
|
|
487
|
-
en: { existing:
|
|
480
|
+
en: { existing: "yes" },
|
|
488
481
|
},
|
|
489
482
|
})
|
|
490
483
|
|
|
491
|
-
i18n.addMessages(
|
|
492
|
-
expect(i18n.t(
|
|
493
|
-
expect(i18n.t(
|
|
484
|
+
i18n.addMessages("en", { added: "also yes" })
|
|
485
|
+
expect(i18n.t("existing")).toBe("yes")
|
|
486
|
+
expect(i18n.t("added")).toBe("also yes")
|
|
494
487
|
})
|
|
495
488
|
|
|
496
|
-
it(
|
|
489
|
+
it("addMessages deep-merges nested dictionaries", () => {
|
|
497
490
|
const i18n = createI18n({
|
|
498
|
-
locale:
|
|
491
|
+
locale: "en",
|
|
499
492
|
messages: {
|
|
500
493
|
en: {
|
|
501
494
|
errors: {
|
|
502
|
-
auth:
|
|
495
|
+
auth: "Auth error",
|
|
503
496
|
},
|
|
504
497
|
},
|
|
505
498
|
},
|
|
506
499
|
})
|
|
507
500
|
|
|
508
|
-
i18n.addMessages(
|
|
501
|
+
i18n.addMessages("en", {
|
|
509
502
|
errors: {
|
|
510
|
-
network:
|
|
503
|
+
network: "Network error",
|
|
511
504
|
},
|
|
512
505
|
})
|
|
513
506
|
|
|
514
|
-
expect(i18n.t(
|
|
515
|
-
expect(i18n.t(
|
|
507
|
+
expect(i18n.t("errors.auth")).toBe("Auth error")
|
|
508
|
+
expect(i18n.t("errors.network")).toBe("Network error")
|
|
516
509
|
})
|
|
517
510
|
|
|
518
|
-
it(
|
|
511
|
+
it("addMessages to a specific namespace", () => {
|
|
519
512
|
const i18n = createI18n({
|
|
520
|
-
locale:
|
|
513
|
+
locale: "en",
|
|
521
514
|
messages: { en: {} },
|
|
522
515
|
})
|
|
523
516
|
|
|
524
|
-
i18n.addMessages(
|
|
525
|
-
expect(i18n.t(
|
|
517
|
+
i18n.addMessages("en", { title: "Dashboard" }, "admin")
|
|
518
|
+
expect(i18n.t("admin:title")).toBe("Dashboard")
|
|
526
519
|
})
|
|
527
520
|
|
|
528
|
-
it(
|
|
529
|
-
const i18n = createI18n({ locale:
|
|
521
|
+
it("addMessages creates locale if not exists", () => {
|
|
522
|
+
const i18n = createI18n({ locale: "en", messages: { en: {} } })
|
|
530
523
|
|
|
531
|
-
i18n.addMessages(
|
|
532
|
-
i18n.locale.set(
|
|
533
|
-
expect(i18n.t(
|
|
534
|
-
expect(i18n.availableLocales()).toContain(
|
|
524
|
+
i18n.addMessages("fr", { greeting: "Bonjour" })
|
|
525
|
+
i18n.locale.set("fr")
|
|
526
|
+
expect(i18n.t("greeting")).toBe("Bonjour")
|
|
527
|
+
expect(i18n.availableLocales()).toContain("fr")
|
|
535
528
|
})
|
|
536
529
|
})
|
|
537
530
|
|
|
538
531
|
// ─── Locale switching with namespaces ────────────────────────────────────────
|
|
539
532
|
|
|
540
|
-
describe(
|
|
541
|
-
it(
|
|
542
|
-
const translations: Record<
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
> = {
|
|
546
|
-
en: { common: { hello: 'Hello' } },
|
|
547
|
-
de: { common: { hello: 'Hallo' } },
|
|
533
|
+
describe("createI18n locale switching", () => {
|
|
534
|
+
it("reloads translations when switching locale with loader", async () => {
|
|
535
|
+
const translations: Record<string, Record<string, TranslationDictionary>> = {
|
|
536
|
+
en: { common: { hello: "Hello" } },
|
|
537
|
+
de: { common: { hello: "Hallo" } },
|
|
548
538
|
}
|
|
549
539
|
|
|
550
540
|
const i18n = createI18n({
|
|
551
|
-
locale:
|
|
541
|
+
locale: "en",
|
|
552
542
|
loader: async (locale, ns) => translations[locale]?.[ns],
|
|
553
543
|
})
|
|
554
544
|
|
|
555
|
-
await i18n.loadNamespace(
|
|
556
|
-
expect(i18n.t(
|
|
545
|
+
await i18n.loadNamespace("common")
|
|
546
|
+
expect(i18n.t("hello")).toBe("Hello")
|
|
557
547
|
|
|
558
|
-
i18n.locale.set(
|
|
559
|
-
await i18n.loadNamespace(
|
|
560
|
-
expect(i18n.t(
|
|
548
|
+
i18n.locale.set("de")
|
|
549
|
+
await i18n.loadNamespace("common")
|
|
550
|
+
expect(i18n.t("hello")).toBe("Hallo")
|
|
561
551
|
})
|
|
562
552
|
|
|
563
|
-
it(
|
|
553
|
+
it("handles mixed static + async messages", async () => {
|
|
564
554
|
const i18n = createI18n({
|
|
565
|
-
locale:
|
|
555
|
+
locale: "en",
|
|
566
556
|
messages: {
|
|
567
|
-
en: { staticKey:
|
|
557
|
+
en: { staticKey: "From static" },
|
|
568
558
|
},
|
|
569
559
|
loader: async (_locale, ns) => {
|
|
570
|
-
if (ns ===
|
|
560
|
+
if (ns === "dynamic") return { dynamicKey: "From loader" }
|
|
571
561
|
return undefined
|
|
572
562
|
},
|
|
573
563
|
})
|
|
574
564
|
|
|
575
|
-
expect(i18n.t(
|
|
576
|
-
expect(i18n.t(
|
|
565
|
+
expect(i18n.t("staticKey")).toBe("From static")
|
|
566
|
+
expect(i18n.t("dynamic:dynamicKey")).toBe("dynamic:dynamicKey")
|
|
577
567
|
|
|
578
|
-
await i18n.loadNamespace(
|
|
579
|
-
expect(i18n.t(
|
|
568
|
+
await i18n.loadNamespace("dynamic")
|
|
569
|
+
expect(i18n.t("dynamic:dynamicKey")).toBe("From loader")
|
|
580
570
|
})
|
|
581
571
|
})
|
|
582
572
|
|
|
583
573
|
// ─── I18nProvider / useI18n context ──────────────────────────────────────────
|
|
584
574
|
|
|
585
|
-
describe(
|
|
586
|
-
it(
|
|
575
|
+
describe("I18nProvider / useI18n", () => {
|
|
576
|
+
it("provides i18n instance to child components via useI18n", () => {
|
|
587
577
|
const i18n = createI18n({
|
|
588
|
-
locale:
|
|
589
|
-
messages: { en: { hello:
|
|
578
|
+
locale: "en",
|
|
579
|
+
messages: { en: { hello: "Hello World" } },
|
|
590
580
|
})
|
|
591
581
|
|
|
592
582
|
let received: ReturnType<typeof useI18n> | undefined
|
|
593
|
-
const el = document.createElement(
|
|
583
|
+
const el = document.createElement("div")
|
|
594
584
|
document.body.appendChild(el)
|
|
595
585
|
const Child = () => {
|
|
596
586
|
received = useI18n()
|
|
@@ -604,38 +594,35 @@ describe('I18nProvider / useI18n', () => {
|
|
|
604
594
|
)
|
|
605
595
|
|
|
606
596
|
expect(received).toBe(i18n)
|
|
607
|
-
expect(received!.t(
|
|
597
|
+
expect(received!.t("hello")).toBe("Hello World")
|
|
608
598
|
unmount()
|
|
609
599
|
el.remove()
|
|
610
600
|
})
|
|
611
601
|
|
|
612
|
-
it(
|
|
602
|
+
it("renders children passed as a function", () => {
|
|
613
603
|
const i18n = createI18n({
|
|
614
|
-
locale:
|
|
615
|
-
messages: { en: { key:
|
|
604
|
+
locale: "en",
|
|
605
|
+
messages: { en: { key: "Value" } },
|
|
616
606
|
})
|
|
617
607
|
|
|
618
608
|
let received: ReturnType<typeof useI18n> | undefined
|
|
619
|
-
const el = document.createElement(
|
|
609
|
+
const el = document.createElement("div")
|
|
620
610
|
document.body.appendChild(el)
|
|
621
611
|
const Child = () => {
|
|
622
612
|
received = useI18n()
|
|
623
613
|
return null
|
|
624
614
|
}
|
|
625
|
-
const unmount = mount(
|
|
626
|
-
<I18nProvider instance={i18n}>{() => <Child />}</I18nProvider>,
|
|
627
|
-
el,
|
|
628
|
-
)
|
|
615
|
+
const unmount = mount(<I18nProvider instance={i18n}>{() => <Child />}</I18nProvider>, el)
|
|
629
616
|
|
|
630
617
|
expect(received).toBeDefined()
|
|
631
|
-
expect(received!.t(
|
|
618
|
+
expect(received!.t("key")).toBe("Value")
|
|
632
619
|
unmount()
|
|
633
620
|
el.remove()
|
|
634
621
|
})
|
|
635
622
|
|
|
636
|
-
it(
|
|
623
|
+
it("useI18n throws when called outside I18nProvider", () => {
|
|
637
624
|
let error: Error | undefined
|
|
638
|
-
const el = document.createElement(
|
|
625
|
+
const el = document.createElement("div")
|
|
639
626
|
document.body.appendChild(el)
|
|
640
627
|
|
|
641
628
|
const Child = () => {
|
|
@@ -649,21 +636,19 @@ describe('I18nProvider / useI18n', () => {
|
|
|
649
636
|
const unmount = mount(<Child />, el)
|
|
650
637
|
|
|
651
638
|
expect(error).toBeDefined()
|
|
652
|
-
expect(error!.message).toContain(
|
|
653
|
-
'useI18n() must be used within an <I18nProvider>',
|
|
654
|
-
)
|
|
639
|
+
expect(error!.message).toContain("useI18n() must be used within an <I18nProvider>")
|
|
655
640
|
unmount()
|
|
656
641
|
el.remove()
|
|
657
642
|
})
|
|
658
643
|
|
|
659
|
-
it(
|
|
644
|
+
it("renders direct VNode children (not a function)", () => {
|
|
660
645
|
const i18n = createI18n({
|
|
661
|
-
locale:
|
|
662
|
-
messages: { en: { test:
|
|
646
|
+
locale: "en",
|
|
647
|
+
messages: { en: { test: "Test value" } },
|
|
663
648
|
})
|
|
664
649
|
|
|
665
650
|
let received: ReturnType<typeof useI18n> | undefined
|
|
666
|
-
const el = document.createElement(
|
|
651
|
+
const el = document.createElement("div")
|
|
667
652
|
document.body.appendChild(el)
|
|
668
653
|
const Child = () => {
|
|
669
654
|
received = useI18n()
|
|
@@ -677,7 +662,7 @@ describe('I18nProvider / useI18n', () => {
|
|
|
677
662
|
)
|
|
678
663
|
|
|
679
664
|
expect(received).toBeDefined()
|
|
680
|
-
expect(received!.t(
|
|
665
|
+
expect(received!.t("test")).toBe("Test value")
|
|
681
666
|
unmount()
|
|
682
667
|
el.remove()
|
|
683
668
|
})
|
|
@@ -685,15 +670,15 @@ describe('I18nProvider / useI18n', () => {
|
|
|
685
670
|
|
|
686
671
|
// ─── Pluralization — Intl.PluralRules unavailable fallback ──────────────────
|
|
687
672
|
|
|
688
|
-
describe(
|
|
689
|
-
it(
|
|
673
|
+
describe("resolvePluralCategory fallback when Intl is unavailable", () => {
|
|
674
|
+
it("falls back to basic one/other when Intl is undefined", () => {
|
|
690
675
|
const originalIntl = globalThis.Intl
|
|
691
676
|
// @ts-expect-error — temporarily remove Intl
|
|
692
677
|
globalThis.Intl = undefined
|
|
693
678
|
|
|
694
|
-
expect(resolvePluralCategory(
|
|
695
|
-
expect(resolvePluralCategory(
|
|
696
|
-
expect(resolvePluralCategory(
|
|
679
|
+
expect(resolvePluralCategory("en", 1)).toBe("one")
|
|
680
|
+
expect(resolvePluralCategory("en", 0)).toBe("other")
|
|
681
|
+
expect(resolvePluralCategory("en", 5)).toBe("other")
|
|
697
682
|
|
|
698
683
|
globalThis.Intl = originalIntl
|
|
699
684
|
})
|
|
@@ -701,97 +686,93 @@ describe('resolvePluralCategory fallback when Intl is unavailable', () => {
|
|
|
701
686
|
|
|
702
687
|
// ─── parseRichText ──────────────────────────────────────────────────────────
|
|
703
688
|
|
|
704
|
-
describe(
|
|
705
|
-
it(
|
|
706
|
-
expect(parseRichText(
|
|
689
|
+
describe("parseRichText", () => {
|
|
690
|
+
it("returns a single-element array for plain text", () => {
|
|
691
|
+
expect(parseRichText("Hello world")).toEqual(["Hello world"])
|
|
707
692
|
})
|
|
708
693
|
|
|
709
|
-
it(
|
|
710
|
-
expect(parseRichText(
|
|
694
|
+
it("returns empty array for empty string", () => {
|
|
695
|
+
expect(parseRichText("")).toEqual([])
|
|
711
696
|
})
|
|
712
697
|
|
|
713
|
-
it(
|
|
714
|
-
expect(parseRichText(
|
|
715
|
-
|
|
716
|
-
{ tag:
|
|
717
|
-
|
|
698
|
+
it("parses a single tag", () => {
|
|
699
|
+
expect(parseRichText("Hello <bold>world</bold>!")).toEqual([
|
|
700
|
+
"Hello ",
|
|
701
|
+
{ tag: "bold", children: "world" },
|
|
702
|
+
"!",
|
|
718
703
|
])
|
|
719
704
|
})
|
|
720
705
|
|
|
721
|
-
it(
|
|
722
|
-
expect(
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
{ tag:
|
|
727
|
-
' and ',
|
|
728
|
-
{ tag: 'privacy', children: 'policy' },
|
|
706
|
+
it("parses multiple tags", () => {
|
|
707
|
+
expect(parseRichText("Read <terms>terms</terms> and <privacy>policy</privacy>")).toEqual([
|
|
708
|
+
"Read ",
|
|
709
|
+
{ tag: "terms", children: "terms" },
|
|
710
|
+
" and ",
|
|
711
|
+
{ tag: "privacy", children: "policy" },
|
|
729
712
|
])
|
|
730
713
|
})
|
|
731
714
|
|
|
732
|
-
it(
|
|
733
|
-
expect(parseRichText(
|
|
734
|
-
{ tag:
|
|
735
|
-
|
|
736
|
-
{ tag:
|
|
715
|
+
it("handles tags at the start and end", () => {
|
|
716
|
+
expect(parseRichText("<a>start</a> middle <b>end</b>")).toEqual([
|
|
717
|
+
{ tag: "a", children: "start" },
|
|
718
|
+
" middle ",
|
|
719
|
+
{ tag: "b", children: "end" },
|
|
737
720
|
])
|
|
738
721
|
})
|
|
739
722
|
|
|
740
|
-
it(
|
|
741
|
-
expect(parseRichText(
|
|
742
|
-
{ tag:
|
|
743
|
-
{ tag:
|
|
723
|
+
it("handles adjacent tags with no gap", () => {
|
|
724
|
+
expect(parseRichText("<a>one</a><b>two</b>")).toEqual([
|
|
725
|
+
{ tag: "a", children: "one" },
|
|
726
|
+
{ tag: "b", children: "two" },
|
|
744
727
|
])
|
|
745
728
|
})
|
|
746
729
|
|
|
747
|
-
it(
|
|
748
|
-
expect(parseRichText(
|
|
749
|
-
'Hello <open>no close',
|
|
750
|
-
])
|
|
730
|
+
it("leaves unmatched tags as plain text", () => {
|
|
731
|
+
expect(parseRichText("Hello <open>no close")).toEqual(["Hello <open>no close"])
|
|
751
732
|
})
|
|
752
733
|
})
|
|
753
734
|
|
|
754
735
|
// ─── Trans ──────────────────────────────────────────────────────────────────
|
|
755
736
|
|
|
756
|
-
describe(
|
|
757
|
-
it(
|
|
758
|
-
const t = (key: string) => (key ===
|
|
759
|
-
const result = Trans({ t, i18nKey:
|
|
760
|
-
expect(result).toBe(
|
|
737
|
+
describe("Trans", () => {
|
|
738
|
+
it("returns plain translated text when no components provided", () => {
|
|
739
|
+
const t = (key: string) => (key === "hello" ? "Hello World" : key)
|
|
740
|
+
const result = Trans({ t, i18nKey: "hello" })
|
|
741
|
+
expect(result).toBe("Hello World")
|
|
761
742
|
})
|
|
762
743
|
|
|
763
|
-
it(
|
|
744
|
+
it("returns plain translated text with values but no components", () => {
|
|
764
745
|
const i18n = createI18n({
|
|
765
|
-
locale:
|
|
766
|
-
messages: { en: { greeting:
|
|
746
|
+
locale: "en",
|
|
747
|
+
messages: { en: { greeting: "Hi {{name}}!" } },
|
|
767
748
|
})
|
|
768
749
|
const result = Trans({
|
|
769
750
|
t: i18n.t,
|
|
770
|
-
i18nKey:
|
|
771
|
-
values: { name:
|
|
751
|
+
i18nKey: "greeting",
|
|
752
|
+
values: { name: "Alice" },
|
|
772
753
|
})
|
|
773
|
-
expect(result).toBe(
|
|
754
|
+
expect(result).toBe("Hi Alice!")
|
|
774
755
|
})
|
|
775
756
|
|
|
776
|
-
it(
|
|
777
|
-
const t = () =>
|
|
757
|
+
it("returns plain string when components map is provided but text has no tags", () => {
|
|
758
|
+
const t = () => "No tags here"
|
|
778
759
|
const result = Trans({
|
|
779
760
|
t,
|
|
780
|
-
i18nKey:
|
|
781
|
-
components: { bold: (ch: string) => ({ type:
|
|
761
|
+
i18nKey: "plain",
|
|
762
|
+
components: { bold: (ch: string) => ({ type: "strong", children: ch }) },
|
|
782
763
|
})
|
|
783
|
-
expect(result).toBe(
|
|
764
|
+
expect(result).toBe("No tags here")
|
|
784
765
|
})
|
|
785
766
|
|
|
786
|
-
it(
|
|
787
|
-
const t = () =>
|
|
767
|
+
it("invokes component functions for matched tags", () => {
|
|
768
|
+
const t = () => "Click <link>here</link> please"
|
|
788
769
|
const result = Trans({
|
|
789
770
|
t,
|
|
790
|
-
i18nKey:
|
|
771
|
+
i18nKey: "action",
|
|
791
772
|
components: {
|
|
792
773
|
link: (children: string) => ({
|
|
793
|
-
type:
|
|
794
|
-
props: { href:
|
|
774
|
+
type: "a",
|
|
775
|
+
props: { href: "/go" },
|
|
795
776
|
children,
|
|
796
777
|
}),
|
|
797
778
|
},
|
|
@@ -799,55 +780,88 @@ describe('Trans', () => {
|
|
|
799
780
|
|
|
800
781
|
// Result is a Fragment VNode; check its children
|
|
801
782
|
expect(result).toBeTruthy()
|
|
802
|
-
expect(typeof result).toBe(
|
|
783
|
+
expect(typeof result).toBe("object")
|
|
803
784
|
// The Fragment wraps: ["Click ", { type: 'a', ... }, " please"]
|
|
804
785
|
const vnode = result as any
|
|
805
786
|
expect(vnode.children.length).toBe(3)
|
|
806
|
-
expect(vnode.children[0]).toBe(
|
|
787
|
+
expect(vnode.children[0]).toBe("Click ")
|
|
807
788
|
expect(vnode.children[1]).toEqual({
|
|
808
|
-
type:
|
|
809
|
-
props: { href:
|
|
810
|
-
children:
|
|
789
|
+
type: "a",
|
|
790
|
+
props: { href: "/go" },
|
|
791
|
+
children: "here",
|
|
811
792
|
})
|
|
812
|
-
expect(vnode.children[2]).toBe(
|
|
793
|
+
expect(vnode.children[2]).toBe(" please")
|
|
813
794
|
})
|
|
814
795
|
|
|
815
|
-
it(
|
|
816
|
-
const t = () =>
|
|
796
|
+
it("renders unmatched tags as plain text children (no raw HTML)", () => {
|
|
797
|
+
const t = () => "Hello <unknown>world</unknown>"
|
|
817
798
|
const result = Trans({
|
|
818
799
|
t,
|
|
819
|
-
i18nKey:
|
|
800
|
+
i18nKey: "test",
|
|
820
801
|
components: {}, // No matching component
|
|
821
802
|
})
|
|
822
803
|
|
|
823
804
|
const vnode = result as any
|
|
824
805
|
expect(vnode.children.length).toBe(2)
|
|
825
|
-
expect(vnode.children[0]).toBe(
|
|
806
|
+
expect(vnode.children[0]).toBe("Hello ")
|
|
826
807
|
// Unmatched tags render children as plain text, stripping markup for safety
|
|
827
|
-
expect(vnode.children[1]).toBe(
|
|
808
|
+
expect(vnode.children[1]).toBe("world")
|
|
828
809
|
})
|
|
829
810
|
|
|
830
|
-
it(
|
|
811
|
+
it("works with values and components together", () => {
|
|
831
812
|
const i18n = createI18n({
|
|
832
|
-
locale:
|
|
813
|
+
locale: "en",
|
|
833
814
|
messages: {
|
|
834
|
-
en: { items:
|
|
815
|
+
en: { items: "You have <bold>{{count}}</bold> items" },
|
|
835
816
|
},
|
|
836
817
|
})
|
|
837
818
|
|
|
838
819
|
const result = Trans({
|
|
839
820
|
t: i18n.t,
|
|
840
|
-
i18nKey:
|
|
821
|
+
i18nKey: "items",
|
|
841
822
|
values: { count: 42 },
|
|
842
823
|
components: {
|
|
843
|
-
bold: (children: string) => ({ type:
|
|
824
|
+
bold: (children: string) => ({ type: "strong", children }),
|
|
844
825
|
},
|
|
845
826
|
})
|
|
846
827
|
|
|
847
828
|
const vnode = result as any
|
|
848
829
|
expect(vnode.children.length).toBe(3)
|
|
849
|
-
expect(vnode.children[0]).toBe(
|
|
850
|
-
expect(vnode.children[1]).toEqual({ type:
|
|
851
|
-
expect(vnode.children[2]).toBe(
|
|
830
|
+
expect(vnode.children[0]).toBe("You have ")
|
|
831
|
+
expect(vnode.children[1]).toEqual({ type: "strong", children: "42" })
|
|
832
|
+
expect(vnode.children[2]).toBe(" items")
|
|
833
|
+
})
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
// ─── addMessages flat keys ──────────────────────────────────────────────────
|
|
837
|
+
|
|
838
|
+
describe("addMessages flat dot-notation keys", () => {
|
|
839
|
+
it("flat key makes t() resolve via dot notation", () => {
|
|
840
|
+
const i18n = createI18n({ locale: "en", messages: { en: {} } })
|
|
841
|
+
i18n.addMessages("en", { "section.title": "Report" })
|
|
842
|
+
expect(i18n.t("section.title")).toBe("Report")
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
it("nested keys still work", () => {
|
|
846
|
+
const i18n = createI18n({ locale: "en", messages: { en: {} } })
|
|
847
|
+
i18n.addMessages("en", { section: { title: "Report" } })
|
|
848
|
+
expect(i18n.t("section.title")).toBe("Report")
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
it("mixed flat and nested keys", () => {
|
|
852
|
+
const i18n = createI18n({ locale: "en", messages: { en: {} } })
|
|
853
|
+
i18n.addMessages("en", { "a.b": "flat", c: { d: "nested" } })
|
|
854
|
+
expect(i18n.t("a.b")).toBe("flat")
|
|
855
|
+
expect(i18n.t("c.d")).toBe("nested")
|
|
856
|
+
})
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
// ─── core subpath ───────────────────────────────────────────────────────────
|
|
860
|
+
|
|
861
|
+
describe("i18n core subpath", () => {
|
|
862
|
+
it("exports createI18n and interpolate without @pyreon/core dependency", async () => {
|
|
863
|
+
const mod = await import("../core")
|
|
864
|
+
expect(mod.createI18n).toBeDefined()
|
|
865
|
+
expect(mod.interpolate).toBeDefined()
|
|
852
866
|
})
|
|
853
867
|
})
|