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