@pyreon/i18n 0.10.0 → 0.11.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.
@@ -1,127 +1,120 @@
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'
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('interpolate', () => {
13
- it('replaces placeholders with values', () => {
14
- expect(interpolate('Hello {{name}}!', { name: 'Alice' })).toBe(
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('handles multiple placeholders', () => {
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('handles whitespace inside braces', () => {
26
- expect(interpolate('Hello {{ name }}!', { name: 'Alice' })).toBe(
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('leaves unmatched placeholders as-is', () => {
32
- expect(interpolate('Hello {{name}}!', {})).toBe('Hello {{name}}!')
25
+ it("leaves unmatched placeholders as-is", () => {
26
+ expect(interpolate("Hello {{name}}!", {})).toBe("Hello {{name}}!")
33
27
  })
34
28
 
35
- it('returns template unchanged when no values provided', () => {
36
- expect(interpolate('Hello {{name}}!')).toBe('Hello {{name}}!')
29
+ it("returns template unchanged when no values provided", () => {
30
+ expect(interpolate("Hello {{name}}!")).toBe("Hello {{name}}!")
37
31
  })
38
32
 
39
- it('returns template unchanged when no placeholders present', () => {
40
- expect(interpolate('Hello world!', { name: 'Alice' })).toBe('Hello world!')
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('handles number values', () => {
44
- expect(interpolate('Count: {{count}}', { count: 42 })).toBe('Count: 42')
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('resolvePluralCategory', () => {
44
+ describe("resolvePluralCategory", () => {
51
45
  it('returns "one" for count 1 in English', () => {
52
- expect(resolvePluralCategory('en', 1)).toBe('one')
46
+ expect(resolvePluralCategory("en", 1)).toBe("one")
53
47
  })
54
48
 
55
49
  it('returns "other" for count 0 in English', () => {
56
- expect(resolvePluralCategory('en', 0)).toBe('other')
50
+ expect(resolvePluralCategory("en", 0)).toBe("other")
57
51
  })
58
52
 
59
53
  it('returns "other" for count > 1 in English', () => {
60
- expect(resolvePluralCategory('en', 5)).toBe('other')
54
+ expect(resolvePluralCategory("en", 5)).toBe("other")
61
55
  })
62
56
 
63
- it('uses custom plural rules when provided', () => {
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('custom', 0, rules)).toBe('zero')
69
- expect(resolvePluralCategory('custom', 1, rules)).toBe('one')
70
- expect(resolvePluralCategory('custom', 5, rules)).toBe('other')
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('createI18n', () => {
69
+ describe("createI18n", () => {
77
70
  const en = {
78
- greeting: 'Hello {{name}}!',
79
- farewell: 'Goodbye',
71
+ greeting: "Hello {{name}}!",
72
+ farewell: "Goodbye",
80
73
  nested: {
81
74
  deep: {
82
- key: 'Deep value',
75
+ key: "Deep value",
83
76
  },
84
77
  },
85
78
  }
86
79
 
87
80
  const de = {
88
- greeting: 'Hallo {{name}}!',
89
- farewell: 'Auf Wiedersehen',
81
+ greeting: "Hallo {{name}}!",
82
+ farewell: "Auf Wiedersehen",
90
83
  nested: {
91
84
  deep: {
92
- key: 'Tiefer Wert',
85
+ key: "Tiefer Wert",
93
86
  },
94
87
  },
95
88
  }
96
89
 
97
- it('translates a simple key', () => {
98
- const i18n = createI18n({ locale: 'en', messages: { en } })
99
- expect(i18n.t('greeting', { name: 'Alice' })).toBe('Hello Alice!')
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('translates nested keys with dot notation', () => {
103
- const i18n = createI18n({ locale: 'en', messages: { en } })
104
- expect(i18n.t('nested.deep.key')).toBe('Deep value')
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('returns the key itself when missing', () => {
108
- const i18n = createI18n({ locale: 'en', messages: { en } })
109
- expect(i18n.t('nonexistent.key')).toBe('nonexistent.key')
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('falls back to fallbackLocale when key is missing', () => {
105
+ it("falls back to fallbackLocale when key is missing", () => {
113
106
  const i18n = createI18n({
114
- locale: 'de',
115
- fallbackLocale: 'en',
116
- messages: { en: { ...en, onlyEn: 'English only' }, de },
107
+ locale: "de",
108
+ fallbackLocale: "en",
109
+ messages: { en: { ...en, onlyEn: "English only" }, de },
117
110
  })
118
- expect(i18n.t('onlyEn')).toBe('English only')
111
+ expect(i18n.t("onlyEn")).toBe("English only")
119
112
  })
120
113
 
121
- it('calls onMissingKey when translation is not found', () => {
114
+ it("calls onMissingKey when translation is not found", () => {
122
115
  const missing: string[] = []
123
116
  const i18n = createI18n({
124
- locale: 'en',
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('unknown')).toBe('[MISSING: unknown]')
133
- expect(missing).toEqual(['en:unknown'])
125
+ expect(i18n.t("unknown")).toBe("[MISSING: unknown]")
126
+ expect(missing).toEqual(["en:unknown"])
134
127
  })
135
128
 
136
- it('falls back to key when onMissingKey returns void', () => {
129
+ it("falls back to key when onMissingKey returns void", () => {
137
130
  const i18n = createI18n({
138
- locale: 'en',
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('unknown')).toBe('unknown')
139
+ expect(i18n.t("unknown")).toBe("unknown")
147
140
  })
148
141
 
149
- it('uses fallback locale for plural forms', () => {
142
+ it("uses fallback locale for plural forms", () => {
150
143
  const i18n = createI18n({
151
- locale: 'de',
152
- fallbackLocale: 'en',
144
+ locale: "de",
145
+ fallbackLocale: "en",
153
146
  messages: {
154
147
  en: {
155
- items_one: '{{count}} item',
156
- items_other: '{{count}} items',
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('items', { count: 1 })).toBe('1 item')
164
- expect(i18n.t('items', { count: 5 })).toBe('5 items')
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('reactively updates when locale changes', () => {
160
+ it("reactively updates when locale changes", () => {
168
161
  const i18n = createI18n({
169
- locale: 'en',
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('farewell'))
168
+ results.push(i18n.t("farewell"))
176
169
  })
177
170
 
178
- expect(results).toEqual(['Goodbye'])
171
+ expect(results).toEqual(["Goodbye"])
179
172
 
180
- i18n.locale.set('de')
181
- expect(results).toEqual(['Goodbye', 'Auf Wiedersehen'])
173
+ i18n.locale.set("de")
174
+ expect(results).toEqual(["Goodbye", "Auf Wiedersehen"])
182
175
 
183
176
  cleanup.dispose()
184
177
  })
185
178
 
186
- it('reactively updates when messages are added', () => {
179
+ it("reactively updates when messages are added", () => {
187
180
  const i18n = createI18n({
188
- locale: 'en',
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('newKey'))
187
+ results.push(i18n.t("newKey"))
195
188
  })
196
189
 
197
- expect(results).toEqual(['newKey']) // Missing key returns key itself
190
+ expect(results).toEqual(["newKey"]) // Missing key returns key itself
198
191
 
199
- i18n.addMessages('en', { newKey: 'New value!' })
200
- expect(results).toEqual(['newKey', 'New value!'])
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('reports available locales', () => {
198
+ it("reports available locales", () => {
206
199
  const i18n = createI18n({
207
- locale: 'en',
200
+ locale: "en",
208
201
  messages: { en, de },
209
202
  })
210
- expect(i18n.availableLocales()).toEqual(['en', 'de'])
203
+ expect(i18n.availableLocales()).toEqual(["en", "de"])
211
204
  })
212
205
 
213
- it('exists() checks key presence', () => {
214
- const i18n = createI18n({ locale: 'en', messages: { en } })
215
- expect(i18n.exists('greeting')).toBe(true)
216
- expect(i18n.exists('nested.deep.key')).toBe(true)
217
- expect(i18n.exists('nonexistent')).toBe(false)
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('exists() checks namespaced keys', () => {
213
+ it("exists() checks namespaced keys", () => {
221
214
  const i18n = createI18n({
222
- locale: 'en',
215
+ locale: "en",
223
216
  messages: { en: {} },
224
217
  })
225
- i18n.addMessages('en', { title: 'Admin Panel' }, 'admin')
226
- expect(i18n.exists('admin:title')).toBe(true)
227
- expect(i18n.exists('admin:nonexistent')).toBe(false)
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('exists() checks fallback locale', () => {
223
+ it("exists() checks fallback locale", () => {
231
224
  const i18n = createI18n({
232
- locale: 'de',
233
- fallbackLocale: 'en',
234
- messages: { en: { ...en, onlyEn: 'yes' }, de },
225
+ locale: "de",
226
+ fallbackLocale: "en",
227
+ messages: { en: { ...en, onlyEn: "yes" }, de },
235
228
  })
236
- expect(i18n.exists('onlyEn')).toBe(true)
229
+ expect(i18n.exists("onlyEn")).toBe(true)
237
230
  })
238
231
  })
239
232
 
240
233
  // ─── Pluralization ───────────────────────────────────────────────────────────
241
234
 
242
- describe('createI18n pluralization', () => {
243
- it('selects the correct plural form', () => {
235
+ describe("createI18n pluralization", () => {
236
+ it("selects the correct plural form", () => {
244
237
  const i18n = createI18n({
245
- locale: 'en',
238
+ locale: "en",
246
239
  messages: {
247
240
  en: {
248
- items_one: '{{count}} item',
249
- items_other: '{{count}} items',
241
+ items_one: "{{count}} item",
242
+ items_other: "{{count}} items",
250
243
  },
251
244
  },
252
245
  })
253
246
 
254
- expect(i18n.t('items', { count: 1 })).toBe('1 item')
255
- expect(i18n.t('items', { count: 5 })).toBe('5 items')
256
- expect(i18n.t('items', { count: 0 })).toBe('0 items')
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('falls back to base key when plural form is missing', () => {
252
+ it("falls back to base key when plural form is missing", () => {
260
253
  const i18n = createI18n({
261
- locale: 'en',
254
+ locale: "en",
262
255
  messages: {
263
256
  en: {
264
- items: 'some items', // No _one or _other suffixes
257
+ items: "some items", // No _one or _other suffixes
265
258
  },
266
259
  },
267
260
  })
268
261
 
269
- expect(i18n.t('items', { count: 1 })).toBe('some items')
262
+ expect(i18n.t("items", { count: 1 })).toBe("some items")
270
263
  })
271
264
 
272
- it('falls back to basic rules when Intl.PluralRules fails', () => {
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('invalid-xxx-yyy', 1)
275
- const category5 = resolvePluralCategory('invalid-xxx-yyy', 5)
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('string')
278
- expect(typeof category5).toBe('string')
270
+ expect(typeof category1).toBe("string")
271
+ expect(typeof category5).toBe("string")
279
272
  })
280
273
 
281
- it('uses custom plural rules', () => {
274
+ it("uses custom plural rules", () => {
282
275
  const i18n = createI18n({
283
- locale: 'ar',
276
+ locale: "ar",
284
277
  pluralRules: {
285
278
  ar: (count: number) => {
286
- if (count === 0) return 'zero'
287
- if (count === 1) return 'one'
288
- if (count === 2) return 'two'
289
- if (count >= 3 && count <= 10) return 'few'
290
- return 'other'
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: '{{count}} عناصر',
299
- items_other: '{{count}} عنصراً',
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('items', { count: 0 })).toBe('لا عناصر')
305
- expect(i18n.t('items', { count: 1 })).toBe('عنصر واحد')
306
- expect(i18n.t('items', { count: 2 })).toBe('عنصران')
307
- expect(i18n.t('items', { count: 5 })).toBe('5 عناصر')
308
- expect(i18n.t('items', { count: 100 })).toBe('100 عنصراً')
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('createI18n namespaces', () => {
315
- it('supports namespace:key syntax', () => {
307
+ describe("createI18n namespaces", () => {
308
+ it("supports namespace:key syntax", () => {
316
309
  const i18n = createI18n({
317
- locale: 'en',
318
- messages: { en: { greeting: 'Hi' } },
310
+ locale: "en",
311
+ messages: { en: { greeting: "Hi" } },
319
312
  })
320
313
  // "greeting" is in the default "common" namespace
321
- expect(i18n.t('greeting')).toBe('Hi')
314
+ expect(i18n.t("greeting")).toBe("Hi")
322
315
  })
323
316
 
324
- it('loads namespaces asynchronously', async () => {
317
+ it("loads namespaces asynchronously", async () => {
325
318
  const loaderCalls: string[] = []
326
319
 
327
320
  const i18n = createI18n({
328
- locale: 'en',
321
+ locale: "en",
329
322
  loader: async (locale, namespace) => {
330
323
  loaderCalls.push(`${locale}:${namespace}`)
331
- if (namespace === 'auth') {
324
+ if (namespace === "auth") {
332
325
  return {
333
- login: 'Log in',
326
+ login: "Log in",
334
327
  errors: {
335
- invalid: 'Invalid credentials',
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('auth:login')).toBe('auth:login') // Not loaded yet
336
+ expect(i18n.t("auth:login")).toBe("auth:login") // Not loaded yet
344
337
 
345
- await i18n.loadNamespace('auth')
338
+ await i18n.loadNamespace("auth")
346
339
 
347
- expect(loaderCalls).toEqual(['en:auth'])
348
- expect(i18n.t('auth:login')).toBe('Log in')
349
- expect(i18n.t('auth:errors.invalid')).toBe('Invalid credentials')
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('tracks loading state', async () => {
345
+ it("tracks loading state", async () => {
353
346
  let resolveLoader: ((dict: TranslationDictionary) => void) | undefined
354
347
 
355
348
  const i18n = createI18n({
356
- locale: 'en',
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('test')
359
+ const loadPromise = i18n.loadNamespace("test")
367
360
  expect(i18n.isLoading()).toBe(true)
368
361
 
369
- resolveLoader!({ hello: 'world' })
362
+ resolveLoader!({ hello: "world" })
370
363
  await loadPromise
371
364
 
372
365
  expect(i18n.isLoading()).toBe(false)
373
366
  })
374
367
 
375
- it('does not re-fetch already loaded namespaces', async () => {
368
+ it("does not re-fetch already loaded namespaces", async () => {
376
369
  let callCount = 0
377
370
 
378
371
  const i18n = createI18n({
379
- locale: 'en',
372
+ locale: "en",
380
373
  loader: async () => {
381
374
  callCount++
382
- return { key: 'value' }
375
+ return { key: "value" }
383
376
  },
384
377
  })
385
378
 
386
- await i18n.loadNamespace('test')
387
- await i18n.loadNamespace('test')
379
+ await i18n.loadNamespace("test")
380
+ await i18n.loadNamespace("test")
388
381
 
389
382
  expect(callCount).toBe(1)
390
383
  })
391
384
 
392
- it('deduplicates concurrent loads for the same namespace', async () => {
385
+ it("deduplicates concurrent loads for the same namespace", async () => {
393
386
  let callCount = 0
394
387
 
395
388
  const i18n = createI18n({
396
- locale: 'en',
389
+ locale: "en",
397
390
  loader: async () => {
398
391
  callCount++
399
- return { key: 'value' }
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('test'), i18n.loadNamespace('test')])
397
+ await Promise.all([i18n.loadNamespace("test"), i18n.loadNamespace("test")])
405
398
 
406
399
  expect(callCount).toBe(1)
407
400
  })
408
401
 
409
- it('loadedNamespaces reflects current locale namespaces', async () => {
402
+ it("loadedNamespaces reflects current locale namespaces", async () => {
410
403
  const i18n = createI18n({
411
- locale: 'en',
412
- loader: async (_locale, ns) => ({ [`${ns}Key`]: 'value' }),
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('auth')
418
- expect(i18n.loadedNamespaces().has('auth')).toBe(true)
410
+ await i18n.loadNamespace("auth")
411
+ expect(i18n.loadedNamespaces().has("auth")).toBe(true)
419
412
 
420
- await i18n.loadNamespace('admin')
421
- expect(i18n.loadedNamespaces().has('auth')).toBe(true)
422
- expect(i18n.loadedNamespaces().has('admin')).toBe(true)
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('loadNamespace is a no-op without a loader', async () => {
418
+ it("loadNamespace is a no-op without a loader", async () => {
426
419
  const i18n = createI18n({
427
- locale: 'en',
428
- messages: { en: { key: 'value' } },
420
+ locale: "en",
421
+ messages: { en: { key: "value" } },
429
422
  })
430
423
 
431
- await i18n.loadNamespace('test') // Should not throw
424
+ await i18n.loadNamespace("test") // Should not throw
432
425
  expect(i18n.isLoading()).toBe(false)
433
426
  })
434
427
 
435
- it('handles loader returning undefined', async () => {
428
+ it("handles loader returning undefined", async () => {
436
429
  const i18n = createI18n({
437
- locale: 'en',
430
+ locale: "en",
438
431
  loader: async () => undefined,
439
432
  })
440
433
 
441
- await i18n.loadNamespace('missing')
442
- expect(i18n.loadedNamespaces().has('missing')).toBe(false)
434
+ await i18n.loadNamespace("missing")
435
+ expect(i18n.loadedNamespaces().has("missing")).toBe(false)
443
436
  })
444
437
 
445
- it('handles loader errors gracefully', async () => {
438
+ it("handles loader errors gracefully", async () => {
446
439
  const i18n = createI18n({
447
- locale: 'en',
440
+ locale: "en",
448
441
  loader: async () => {
449
- throw new Error('Network error')
442
+ throw new Error("Network error")
450
443
  },
451
444
  })
452
445
 
453
- await expect(i18n.loadNamespace('test')).rejects.toThrow('Network error')
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('addMessages does not corrupt store when source is mutated', () => {
458
- const i18n = createI18n({ locale: 'en', messages: { en: {} } })
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: 'Hello' }
461
- i18n.addMessages('en', source)
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 = 'MUTATED'
465
- expect(i18n.t('greeting')).toBe('Hello')
457
+ source.greeting = "MUTATED"
458
+ expect(i18n.t("greeting")).toBe("Hello")
466
459
  })
467
460
 
468
- it('loads namespace for a specific locale', async () => {
461
+ it("loads namespace for a specific locale", async () => {
469
462
  const loaderCalls: string[] = []
470
463
 
471
464
  const i18n = createI18n({
472
- locale: 'en',
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('common', 'de')
480
- expect(loaderCalls).toEqual(['de:common'])
472
+ await i18n.loadNamespace("common", "de")
473
+ expect(loaderCalls).toEqual(["de:common"])
481
474
  })
482
475
 
483
- it('addMessages merges into existing namespace', () => {
476
+ it("addMessages merges into existing namespace", () => {
484
477
  const i18n = createI18n({
485
- locale: 'en',
478
+ locale: "en",
486
479
  messages: {
487
- en: { existing: 'yes' },
480
+ en: { existing: "yes" },
488
481
  },
489
482
  })
490
483
 
491
- i18n.addMessages('en', { added: 'also yes' })
492
- expect(i18n.t('existing')).toBe('yes')
493
- expect(i18n.t('added')).toBe('also yes')
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('addMessages deep-merges nested dictionaries', () => {
489
+ it("addMessages deep-merges nested dictionaries", () => {
497
490
  const i18n = createI18n({
498
- locale: 'en',
491
+ locale: "en",
499
492
  messages: {
500
493
  en: {
501
494
  errors: {
502
- auth: 'Auth error',
495
+ auth: "Auth error",
503
496
  },
504
497
  },
505
498
  },
506
499
  })
507
500
 
508
- i18n.addMessages('en', {
501
+ i18n.addMessages("en", {
509
502
  errors: {
510
- network: 'Network error',
503
+ network: "Network error",
511
504
  },
512
505
  })
513
506
 
514
- expect(i18n.t('errors.auth')).toBe('Auth error')
515
- expect(i18n.t('errors.network')).toBe('Network error')
507
+ expect(i18n.t("errors.auth")).toBe("Auth error")
508
+ expect(i18n.t("errors.network")).toBe("Network error")
516
509
  })
517
510
 
518
- it('addMessages to a specific namespace', () => {
511
+ it("addMessages to a specific namespace", () => {
519
512
  const i18n = createI18n({
520
- locale: 'en',
513
+ locale: "en",
521
514
  messages: { en: {} },
522
515
  })
523
516
 
524
- i18n.addMessages('en', { title: 'Dashboard' }, 'admin')
525
- expect(i18n.t('admin:title')).toBe('Dashboard')
517
+ i18n.addMessages("en", { title: "Dashboard" }, "admin")
518
+ expect(i18n.t("admin:title")).toBe("Dashboard")
526
519
  })
527
520
 
528
- it('addMessages creates locale if not exists', () => {
529
- const i18n = createI18n({ locale: 'en', messages: { en: {} } })
521
+ it("addMessages creates locale if not exists", () => {
522
+ const i18n = createI18n({ locale: "en", messages: { en: {} } })
530
523
 
531
- i18n.addMessages('fr', { greeting: 'Bonjour' })
532
- i18n.locale.set('fr')
533
- expect(i18n.t('greeting')).toBe('Bonjour')
534
- expect(i18n.availableLocales()).toContain('fr')
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('createI18n locale switching', () => {
541
- it('reloads translations when switching locale with loader', async () => {
542
- const translations: Record<
543
- string,
544
- Record<string, TranslationDictionary>
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: 'en',
541
+ locale: "en",
552
542
  loader: async (locale, ns) => translations[locale]?.[ns],
553
543
  })
554
544
 
555
- await i18n.loadNamespace('common')
556
- expect(i18n.t('hello')).toBe('Hello')
545
+ await i18n.loadNamespace("common")
546
+ expect(i18n.t("hello")).toBe("Hello")
557
547
 
558
- i18n.locale.set('de')
559
- await i18n.loadNamespace('common')
560
- expect(i18n.t('hello')).toBe('Hallo')
548
+ i18n.locale.set("de")
549
+ await i18n.loadNamespace("common")
550
+ expect(i18n.t("hello")).toBe("Hallo")
561
551
  })
562
552
 
563
- it('handles mixed static + async messages', async () => {
553
+ it("handles mixed static + async messages", async () => {
564
554
  const i18n = createI18n({
565
- locale: 'en',
555
+ locale: "en",
566
556
  messages: {
567
- en: { staticKey: 'From static' },
557
+ en: { staticKey: "From static" },
568
558
  },
569
559
  loader: async (_locale, ns) => {
570
- if (ns === 'dynamic') return { dynamicKey: 'From loader' }
560
+ if (ns === "dynamic") return { dynamicKey: "From loader" }
571
561
  return undefined
572
562
  },
573
563
  })
574
564
 
575
- expect(i18n.t('staticKey')).toBe('From static')
576
- expect(i18n.t('dynamic:dynamicKey')).toBe('dynamic:dynamicKey')
565
+ expect(i18n.t("staticKey")).toBe("From static")
566
+ expect(i18n.t("dynamic:dynamicKey")).toBe("dynamic:dynamicKey")
577
567
 
578
- await i18n.loadNamespace('dynamic')
579
- expect(i18n.t('dynamic:dynamicKey')).toBe('From loader')
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('I18nProvider / useI18n', () => {
586
- it('provides i18n instance to child components via useI18n', () => {
575
+ describe("I18nProvider / useI18n", () => {
576
+ it("provides i18n instance to child components via useI18n", () => {
587
577
  const i18n = createI18n({
588
- locale: 'en',
589
- messages: { en: { hello: 'Hello World' } },
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('div')
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('hello')).toBe('Hello World')
597
+ expect(received!.t("hello")).toBe("Hello World")
608
598
  unmount()
609
599
  el.remove()
610
600
  })
611
601
 
612
- it('renders children passed as a function', () => {
602
+ it("renders children passed as a function", () => {
613
603
  const i18n = createI18n({
614
- locale: 'en',
615
- messages: { en: { key: 'Value' } },
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('div')
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('key')).toBe('Value')
618
+ expect(received!.t("key")).toBe("Value")
632
619
  unmount()
633
620
  el.remove()
634
621
  })
635
622
 
636
- it('useI18n throws when called outside I18nProvider', () => {
623
+ it("useI18n throws when called outside I18nProvider", () => {
637
624
  let error: Error | undefined
638
- const el = document.createElement('div')
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('renders direct VNode children (not a function)', () => {
644
+ it("renders direct VNode children (not a function)", () => {
660
645
  const i18n = createI18n({
661
- locale: 'en',
662
- messages: { en: { test: 'Test value' } },
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('div')
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('test')).toBe('Test value')
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('resolvePluralCategory fallback when Intl is unavailable', () => {
689
- it('falls back to basic one/other when Intl is undefined', () => {
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('en', 1)).toBe('one')
695
- expect(resolvePluralCategory('en', 0)).toBe('other')
696
- expect(resolvePluralCategory('en', 5)).toBe('other')
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('parseRichText', () => {
705
- it('returns a single-element array for plain text', () => {
706
- expect(parseRichText('Hello world')).toEqual(['Hello world'])
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('returns empty array for empty string', () => {
710
- expect(parseRichText('')).toEqual([])
694
+ it("returns empty array for empty string", () => {
695
+ expect(parseRichText("")).toEqual([])
711
696
  })
712
697
 
713
- it('parses a single tag', () => {
714
- expect(parseRichText('Hello <bold>world</bold>!')).toEqual([
715
- 'Hello ',
716
- { tag: 'bold', children: 'world' },
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('parses multiple tags', () => {
722
- expect(
723
- parseRichText('Read <terms>terms</terms> and <privacy>policy</privacy>'),
724
- ).toEqual([
725
- 'Read ',
726
- { tag: 'terms', children: 'terms' },
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('handles tags at the start and end', () => {
733
- expect(parseRichText('<a>start</a> middle <b>end</b>')).toEqual([
734
- { tag: 'a', children: 'start' },
735
- ' middle ',
736
- { tag: 'b', children: 'end' },
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('handles adjacent tags with no gap', () => {
741
- expect(parseRichText('<a>one</a><b>two</b>')).toEqual([
742
- { tag: 'a', children: 'one' },
743
- { tag: 'b', children: 'two' },
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('leaves unmatched tags as plain text', () => {
748
- expect(parseRichText('Hello <open>no close')).toEqual([
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('Trans', () => {
757
- it('returns plain translated text when no components provided', () => {
758
- const t = (key: string) => (key === 'hello' ? 'Hello World' : key)
759
- const result = Trans({ t, i18nKey: 'hello' })
760
- expect(result).toBe('Hello World')
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('returns plain translated text with values but no components', () => {
744
+ it("returns plain translated text with values but no components", () => {
764
745
  const i18n = createI18n({
765
- locale: 'en',
766
- messages: { en: { greeting: 'Hi {{name}}!' } },
746
+ locale: "en",
747
+ messages: { en: { greeting: "Hi {{name}}!" } },
767
748
  })
768
749
  const result = Trans({
769
750
  t: i18n.t,
770
- i18nKey: 'greeting',
771
- values: { name: 'Alice' },
751
+ i18nKey: "greeting",
752
+ values: { name: "Alice" },
772
753
  })
773
- expect(result).toBe('Hi Alice!')
754
+ expect(result).toBe("Hi Alice!")
774
755
  })
775
756
 
776
- it('returns plain string when components map is provided but text has no tags', () => {
777
- const t = () => 'No tags here'
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: 'plain',
781
- components: { bold: (ch: string) => ({ type: 'strong', children: ch }) },
761
+ i18nKey: "plain",
762
+ components: { bold: (ch: string) => ({ type: "strong", children: ch }) },
782
763
  })
783
- expect(result).toBe('No tags here')
764
+ expect(result).toBe("No tags here")
784
765
  })
785
766
 
786
- it('invokes component functions for matched tags', () => {
787
- const t = () => 'Click <link>here</link> please'
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: 'action',
771
+ i18nKey: "action",
791
772
  components: {
792
773
  link: (children: string) => ({
793
- type: 'a',
794
- props: { href: '/go' },
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('object')
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('Click ')
787
+ expect(vnode.children[0]).toBe("Click ")
807
788
  expect(vnode.children[1]).toEqual({
808
- type: 'a',
809
- props: { href: '/go' },
810
- children: 'here',
789
+ type: "a",
790
+ props: { href: "/go" },
791
+ children: "here",
811
792
  })
812
- expect(vnode.children[2]).toBe(' please')
793
+ expect(vnode.children[2]).toBe(" please")
813
794
  })
814
795
 
815
- it('renders unmatched tags as plain text children (no raw HTML)', () => {
816
- const t = () => 'Hello <unknown>world</unknown>'
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: 'test',
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('Hello ')
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('world')
808
+ expect(vnode.children[1]).toBe("world")
828
809
  })
829
810
 
830
- it('works with values and components together', () => {
811
+ it("works with values and components together", () => {
831
812
  const i18n = createI18n({
832
- locale: 'en',
813
+ locale: "en",
833
814
  messages: {
834
- en: { items: 'You have <bold>{{count}}</bold> 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: 'items',
821
+ i18nKey: "items",
841
822
  values: { count: 42 },
842
823
  components: {
843
- bold: (children: string) => ({ type: 'strong', children }),
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('You have ')
850
- expect(vnode.children[1]).toEqual({ type: 'strong', children: '42' })
851
- expect(vnode.children[2]).toBe(' items')
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
  })