@pyreon/i18n 0.0.1

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