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