@pyreon/i18n 0.11.5 → 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
package/src/create-i18n.ts
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import { computed, signal } from
|
|
2
|
-
import { interpolate } from
|
|
3
|
-
import { resolvePluralCategory } from
|
|
4
|
-
import type { I18nInstance, I18nOptions, InterpolationValues, TranslationDictionary } from
|
|
1
|
+
import { computed, signal } from '@pyreon/reactivity'
|
|
2
|
+
import { interpolate } from './interpolation'
|
|
3
|
+
import { resolvePluralCategory } from './pluralization'
|
|
4
|
+
import type { I18nInstance, I18nOptions, InterpolationValues, TranslationDictionary } from './types'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Resolve a dot-separated key path in a nested dictionary.
|
|
8
8
|
* E.g. "user.greeting" → dictionary.user.greeting
|
|
9
9
|
*/
|
|
10
10
|
function resolveKey(dict: TranslationDictionary, keyPath: string): string | undefined {
|
|
11
|
-
const parts = keyPath.split(
|
|
11
|
+
const parts = keyPath.split('.')
|
|
12
12
|
let current: TranslationDictionary | string = dict
|
|
13
13
|
|
|
14
14
|
for (const part of parts) {
|
|
15
|
-
if (current == null || typeof current ===
|
|
15
|
+
if (current == null || typeof current === 'string') return undefined
|
|
16
16
|
current = current[part] as TranslationDictionary | string
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
return typeof current ===
|
|
19
|
+
return typeof current === 'string' ? current : undefined
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -31,13 +31,13 @@ function nestFlatKeys(messages: TranslationDictionary): TranslationDictionary {
|
|
|
31
31
|
|
|
32
32
|
for (const key of Object.keys(messages)) {
|
|
33
33
|
const value = messages[key]
|
|
34
|
-
if (key.includes(
|
|
34
|
+
if (key.includes('.') && typeof value === 'string') {
|
|
35
35
|
hasFlatKeys = true
|
|
36
|
-
const parts = key.split(
|
|
36
|
+
const parts = key.split('.')
|
|
37
37
|
let current: TranslationDictionary = result
|
|
38
38
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
39
39
|
const part = parts[i] as string
|
|
40
|
-
if (!(part in current) || typeof current[part] !==
|
|
40
|
+
if (!(part in current) || typeof current[part] !== 'object') {
|
|
41
41
|
current[part] = {}
|
|
42
42
|
}
|
|
43
43
|
current = current[part] as TranslationDictionary
|
|
@@ -56,13 +56,13 @@ function nestFlatKeys(messages: TranslationDictionary): TranslationDictionary {
|
|
|
56
56
|
*/
|
|
57
57
|
function deepMerge(target: TranslationDictionary, source: TranslationDictionary): void {
|
|
58
58
|
for (const key of Object.keys(source)) {
|
|
59
|
-
if (key ===
|
|
59
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue
|
|
60
60
|
const sourceVal = source[key]
|
|
61
61
|
const targetVal = target[key]
|
|
62
62
|
if (
|
|
63
|
-
typeof sourceVal ===
|
|
63
|
+
typeof sourceVal === 'object' &&
|
|
64
64
|
sourceVal !== null &&
|
|
65
|
-
typeof targetVal ===
|
|
65
|
+
typeof targetVal === 'object' &&
|
|
66
66
|
targetVal !== null
|
|
67
67
|
) {
|
|
68
68
|
deepMerge(targetVal as TranslationDictionary, sourceVal as TranslationDictionary)
|
|
@@ -103,7 +103,7 @@ function deepMerge(target: TranslationDictionary, source: TranslationDictionary)
|
|
|
103
103
|
* i18n.t('auth:errors.invalid') // looks up "errors.invalid" in "auth" namespace
|
|
104
104
|
*/
|
|
105
105
|
export function createI18n(options: I18nOptions): I18nInstance {
|
|
106
|
-
const { fallbackLocale, loader, defaultNamespace =
|
|
106
|
+
const { fallbackLocale, loader, defaultNamespace = 'common', pluralRules, onMissingKey } = options
|
|
107
107
|
|
|
108
108
|
// ── Reactive state ──────────────────────────────────────────────────
|
|
109
109
|
|
|
@@ -172,14 +172,14 @@ export function createI18n(options: I18nOptions): I18nInstance {
|
|
|
172
172
|
let namespace = defaultNamespace
|
|
173
173
|
let keyPath = key
|
|
174
174
|
|
|
175
|
-
const colonIndex = key.indexOf(
|
|
175
|
+
const colonIndex = key.indexOf(':')
|
|
176
176
|
if (colonIndex > 0) {
|
|
177
177
|
namespace = key.slice(0, colonIndex)
|
|
178
178
|
keyPath = key.slice(colonIndex + 1)
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
// Handle pluralization: if values contain `count`, try plural suffixes
|
|
182
|
-
if (values &&
|
|
182
|
+
if (values && 'count' in values) {
|
|
183
183
|
const count = Number(values.count)
|
|
184
184
|
const category = resolvePluralCategory(currentLocale, count, pluralRules)
|
|
185
185
|
|
|
@@ -257,7 +257,7 @@ export function createI18n(options: I18nOptions): I18nInstance {
|
|
|
257
257
|
|
|
258
258
|
let namespace = defaultNamespace
|
|
259
259
|
let keyPath = key
|
|
260
|
-
const colonIndex = key.indexOf(
|
|
260
|
+
const colonIndex = key.indexOf(':')
|
|
261
261
|
if (colonIndex > 0) {
|
|
262
262
|
namespace = key.slice(0, colonIndex)
|
|
263
263
|
keyPath = key.slice(colonIndex + 1)
|
package/src/devtools.ts
CHANGED
|
@@ -56,7 +56,7 @@ function safeRead(
|
|
|
56
56
|
): unknown {
|
|
57
57
|
try {
|
|
58
58
|
const val = obj[key]
|
|
59
|
-
return typeof val ===
|
|
59
|
+
return typeof val === 'function' ? (val as () => unknown)() : fallback
|
|
60
60
|
} catch {
|
|
61
61
|
return fallback
|
|
62
62
|
}
|
|
@@ -68,12 +68,12 @@ function safeRead(
|
|
|
68
68
|
export function getI18nSnapshot(name: string): Record<string, unknown> | undefined {
|
|
69
69
|
const instance = getI18nInstance(name) as Record<string, unknown> | undefined
|
|
70
70
|
if (!instance) return undefined
|
|
71
|
-
const ns = safeRead(instance,
|
|
71
|
+
const ns = safeRead(instance, 'loadedNamespaces', new Set())
|
|
72
72
|
return {
|
|
73
|
-
locale: safeRead(instance,
|
|
74
|
-
availableLocales: safeRead(instance,
|
|
73
|
+
locale: safeRead(instance, 'locale'),
|
|
74
|
+
availableLocales: safeRead(instance, 'availableLocales', []),
|
|
75
75
|
loadedNamespaces: ns instanceof Set ? [...ns] : [],
|
|
76
|
-
isLoading: safeRead(instance,
|
|
76
|
+
isLoading: safeRead(instance, 'isLoading', false),
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export type { I18nProviderProps } from
|
|
2
|
-
export { I18nContext, I18nProvider, useI18n } from
|
|
3
|
-
export { createI18n } from
|
|
4
|
-
export { interpolate } from
|
|
5
|
-
export { resolvePluralCategory } from
|
|
6
|
-
export type { TransProps } from
|
|
7
|
-
export { parseRichText, Trans } from
|
|
1
|
+
export type { I18nProviderProps } from './context'
|
|
2
|
+
export { I18nContext, I18nProvider, useI18n } from './context'
|
|
3
|
+
export { createI18n } from './create-i18n'
|
|
4
|
+
export { interpolate } from './interpolation'
|
|
5
|
+
export { resolvePluralCategory } from './pluralization'
|
|
6
|
+
export type { TransProps } from './trans'
|
|
7
|
+
export { parseRichText, Trans } from './trans'
|
|
8
8
|
export type {
|
|
9
9
|
I18nInstance,
|
|
10
10
|
I18nOptions,
|
|
@@ -13,4 +13,4 @@ export type {
|
|
|
13
13
|
PluralRules,
|
|
14
14
|
TranslationDictionary,
|
|
15
15
|
TranslationMessages,
|
|
16
|
-
} from
|
|
16
|
+
} from './types'
|
package/src/interpolation.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { InterpolationValues } from
|
|
1
|
+
import type { InterpolationValues } from './types'
|
|
2
2
|
|
|
3
3
|
const INTERPOLATION_RE = /\{\{(\s*\w+\s*)\}\}/g
|
|
4
4
|
|
|
@@ -8,14 +8,14 @@ const INTERPOLATION_RE = /\{\{(\s*\w+\s*)\}\}/g
|
|
|
8
8
|
* Unmatched placeholders are left as-is.
|
|
9
9
|
*/
|
|
10
10
|
export function interpolate(template: string, values?: InterpolationValues): string {
|
|
11
|
-
if (!values || !template.includes(
|
|
11
|
+
if (!values || !template.includes('{{')) return template
|
|
12
12
|
return template.replace(INTERPOLATION_RE, (_, key: string) => {
|
|
13
13
|
const trimmed = key.trim()
|
|
14
14
|
const value = values[trimmed]
|
|
15
15
|
if (value === undefined) return `{{${trimmed}}}`
|
|
16
16
|
// Safely coerce — guard against malicious toString/valueOf
|
|
17
17
|
try {
|
|
18
|
-
return typeof value ===
|
|
18
|
+
return typeof value === 'object' && value !== null ? JSON.stringify(value) : `${value}`
|
|
19
19
|
} catch {
|
|
20
20
|
return `{{${trimmed}}}`
|
|
21
21
|
}
|
package/src/pluralization.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PluralRules } from
|
|
1
|
+
import type { PluralRules } from './types'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Resolve the plural category for a given count and locale.
|
|
@@ -17,7 +17,7 @@ export function resolvePluralCategory(
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
// Use Intl.PluralRules if available
|
|
20
|
-
if (typeof Intl !==
|
|
20
|
+
if (typeof Intl !== 'undefined' && Intl.PluralRules) {
|
|
21
21
|
try {
|
|
22
22
|
const pr = new Intl.PluralRules(locale)
|
|
23
23
|
return pr.select(count)
|
|
@@ -27,5 +27,5 @@ export function resolvePluralCategory(
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
// Basic fallback
|
|
30
|
-
return count === 1 ?
|
|
30
|
+
return count === 1 ? 'one' : 'other'
|
|
31
31
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createI18n } from
|
|
1
|
+
import { createI18n } from '../create-i18n'
|
|
2
2
|
import {
|
|
3
3
|
_resetDevtools,
|
|
4
4
|
getActiveI18nInstances,
|
|
@@ -7,62 +7,62 @@ import {
|
|
|
7
7
|
onI18nChange,
|
|
8
8
|
registerI18n,
|
|
9
9
|
unregisterI18n,
|
|
10
|
-
} from
|
|
10
|
+
} from '../devtools'
|
|
11
11
|
|
|
12
12
|
afterEach(() => _resetDevtools())
|
|
13
13
|
|
|
14
|
-
describe(
|
|
15
|
-
test(
|
|
14
|
+
describe('i18n devtools', () => {
|
|
15
|
+
test('getActiveI18nInstances returns empty initially', () => {
|
|
16
16
|
expect(getActiveI18nInstances()).toEqual([])
|
|
17
17
|
})
|
|
18
18
|
|
|
19
|
-
test(
|
|
20
|
-
const i18n = createI18n({ locale:
|
|
21
|
-
registerI18n(
|
|
22
|
-
expect(getActiveI18nInstances()).toEqual([
|
|
19
|
+
test('registerI18n makes instance visible', () => {
|
|
20
|
+
const i18n = createI18n({ locale: 'en', messages: { en: { hi: 'Hello' } } })
|
|
21
|
+
registerI18n('app', i18n)
|
|
22
|
+
expect(getActiveI18nInstances()).toEqual(['app'])
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
test(
|
|
26
|
-
const i18n = createI18n({ locale:
|
|
27
|
-
registerI18n(
|
|
28
|
-
expect(getI18nInstance(
|
|
25
|
+
test('getI18nInstance returns the registered instance', () => {
|
|
26
|
+
const i18n = createI18n({ locale: 'en', messages: { en: { hi: 'Hello' } } })
|
|
27
|
+
registerI18n('app', i18n)
|
|
28
|
+
expect(getI18nInstance('app')).toBe(i18n)
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
test(
|
|
32
|
-
expect(getI18nInstance(
|
|
31
|
+
test('getI18nInstance returns undefined for unregistered name', () => {
|
|
32
|
+
expect(getI18nInstance('nope')).toBeUndefined()
|
|
33
33
|
})
|
|
34
34
|
|
|
35
|
-
test(
|
|
36
|
-
const i18n = createI18n({ locale:
|
|
37
|
-
registerI18n(
|
|
38
|
-
unregisterI18n(
|
|
35
|
+
test('unregisterI18n removes the instance', () => {
|
|
36
|
+
const i18n = createI18n({ locale: 'en' })
|
|
37
|
+
registerI18n('app', i18n)
|
|
38
|
+
unregisterI18n('app')
|
|
39
39
|
expect(getActiveI18nInstances()).toEqual([])
|
|
40
40
|
})
|
|
41
41
|
|
|
42
|
-
test(
|
|
42
|
+
test('getI18nSnapshot returns current state', () => {
|
|
43
43
|
const i18n = createI18n({
|
|
44
|
-
locale:
|
|
45
|
-
messages: { en: { hi:
|
|
44
|
+
locale: 'en',
|
|
45
|
+
messages: { en: { hi: 'Hello' }, de: { hi: 'Hallo' } },
|
|
46
46
|
})
|
|
47
|
-
registerI18n(
|
|
48
|
-
const snapshot = getI18nSnapshot(
|
|
47
|
+
registerI18n('app', i18n)
|
|
48
|
+
const snapshot = getI18nSnapshot('app')
|
|
49
49
|
expect(snapshot).toBeDefined()
|
|
50
|
-
expect(snapshot!.locale).toBe(
|
|
51
|
-
expect(snapshot!.availableLocales).toEqual(expect.arrayContaining([
|
|
50
|
+
expect(snapshot!.locale).toBe('en')
|
|
51
|
+
expect(snapshot!.availableLocales).toEqual(expect.arrayContaining(['en', 'de']))
|
|
52
52
|
expect(snapshot!.isLoading).toBe(false)
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
-
test(
|
|
55
|
+
test('getI18nSnapshot handles instance with non-function properties', () => {
|
|
56
56
|
// Register a plain object where properties are NOT functions
|
|
57
57
|
// This covers the false branches of typeof checks in getI18nSnapshot
|
|
58
58
|
const plainInstance = {
|
|
59
|
-
locale:
|
|
59
|
+
locale: 'not-a-function',
|
|
60
60
|
availableLocales: 42,
|
|
61
61
|
loadedNamespaces: null,
|
|
62
62
|
isLoading: undefined,
|
|
63
63
|
}
|
|
64
|
-
registerI18n(
|
|
65
|
-
const snapshot = getI18nSnapshot(
|
|
64
|
+
registerI18n('plain', plainInstance)
|
|
65
|
+
const snapshot = getI18nSnapshot('plain')
|
|
66
66
|
expect(snapshot).toBeDefined()
|
|
67
67
|
expect(snapshot!.locale).toBeUndefined()
|
|
68
68
|
expect(snapshot!.availableLocales).toEqual([])
|
|
@@ -70,56 +70,56 @@ describe("i18n devtools", () => {
|
|
|
70
70
|
expect(snapshot!.isLoading).toBe(false)
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
-
test(
|
|
74
|
-
const i18n = createI18n({ locale:
|
|
75
|
-
registerI18n(
|
|
76
|
-
i18n.locale.set(
|
|
77
|
-
const snapshot = getI18nSnapshot(
|
|
78
|
-
expect(snapshot!.locale).toBe(
|
|
73
|
+
test('getI18nSnapshot reflects locale change', () => {
|
|
74
|
+
const i18n = createI18n({ locale: 'en', messages: { en: {}, de: {} } })
|
|
75
|
+
registerI18n('app', i18n)
|
|
76
|
+
i18n.locale.set('de')
|
|
77
|
+
const snapshot = getI18nSnapshot('app')
|
|
78
|
+
expect(snapshot!.locale).toBe('de')
|
|
79
79
|
})
|
|
80
80
|
|
|
81
|
-
test(
|
|
82
|
-
expect(getI18nSnapshot(
|
|
81
|
+
test('getI18nSnapshot returns undefined for unregistered name', () => {
|
|
82
|
+
expect(getI18nSnapshot('nope')).toBeUndefined()
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
-
test(
|
|
85
|
+
test('onI18nChange fires on register', () => {
|
|
86
86
|
const calls: number[] = []
|
|
87
87
|
const unsub = onI18nChange(() => calls.push(1))
|
|
88
88
|
|
|
89
|
-
registerI18n(
|
|
89
|
+
registerI18n('app', createI18n({ locale: 'en' }))
|
|
90
90
|
expect(calls.length).toBe(1)
|
|
91
91
|
|
|
92
92
|
unsub()
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
-
test(
|
|
96
|
-
registerI18n(
|
|
95
|
+
test('onI18nChange fires on unregister', () => {
|
|
96
|
+
registerI18n('app', createI18n({ locale: 'en' }))
|
|
97
97
|
|
|
98
98
|
const calls: number[] = []
|
|
99
99
|
const unsub = onI18nChange(() => calls.push(1))
|
|
100
|
-
unregisterI18n(
|
|
100
|
+
unregisterI18n('app')
|
|
101
101
|
expect(calls.length).toBe(1)
|
|
102
102
|
|
|
103
103
|
unsub()
|
|
104
104
|
})
|
|
105
105
|
|
|
106
|
-
test(
|
|
106
|
+
test('onI18nChange unsubscribe stops notifications', () => {
|
|
107
107
|
const calls: number[] = []
|
|
108
108
|
const unsub = onI18nChange(() => calls.push(1))
|
|
109
109
|
unsub()
|
|
110
110
|
|
|
111
|
-
registerI18n(
|
|
111
|
+
registerI18n('app', createI18n({ locale: 'en' }))
|
|
112
112
|
expect(calls.length).toBe(0)
|
|
113
113
|
})
|
|
114
114
|
|
|
115
|
-
test(
|
|
116
|
-
registerI18n(
|
|
117
|
-
registerI18n(
|
|
118
|
-
expect(getActiveI18nInstances().sort()).toEqual([
|
|
115
|
+
test('multiple instances are tracked', () => {
|
|
116
|
+
registerI18n('app', createI18n({ locale: 'en' }))
|
|
117
|
+
registerI18n('admin', createI18n({ locale: 'en' }))
|
|
118
|
+
expect(getActiveI18nInstances().sort()).toEqual(['admin', 'app'])
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
-
test(
|
|
122
|
-
const instance = createI18n({ locale:
|
|
121
|
+
test('getI18nInstance cleans up and returns undefined when WeakRef is dead', () => {
|
|
122
|
+
const instance = createI18n({ locale: 'en' })
|
|
123
123
|
const originalWeakRef = globalThis.WeakRef
|
|
124
124
|
let mockDerefResult: object | undefined = instance
|
|
125
125
|
const MockWeakRef = class {
|
|
@@ -130,18 +130,18 @@ describe("i18n devtools", () => {
|
|
|
130
130
|
globalThis.WeakRef = MockWeakRef as any
|
|
131
131
|
|
|
132
132
|
_resetDevtools()
|
|
133
|
-
registerI18n(
|
|
134
|
-
expect(getI18nInstance(
|
|
133
|
+
registerI18n('mock-instance', instance)
|
|
134
|
+
expect(getI18nInstance('mock-instance')).toBe(instance)
|
|
135
135
|
|
|
136
136
|
// Simulate GC
|
|
137
137
|
mockDerefResult = undefined
|
|
138
|
-
expect(getI18nInstance(
|
|
138
|
+
expect(getI18nInstance('mock-instance')).toBeUndefined()
|
|
139
139
|
|
|
140
140
|
globalThis.WeakRef = originalWeakRef
|
|
141
141
|
})
|
|
142
142
|
|
|
143
|
-
test(
|
|
144
|
-
const instance = createI18n({ locale:
|
|
143
|
+
test('getActiveI18nInstances cleans up garbage-collected WeakRefs', () => {
|
|
144
|
+
const instance = createI18n({ locale: 'en' })
|
|
145
145
|
const originalWeakRef = globalThis.WeakRef
|
|
146
146
|
let mockDerefResult: object | undefined = instance
|
|
147
147
|
const MockWeakRef = class {
|
|
@@ -152,8 +152,8 @@ describe("i18n devtools", () => {
|
|
|
152
152
|
globalThis.WeakRef = MockWeakRef as any
|
|
153
153
|
|
|
154
154
|
_resetDevtools()
|
|
155
|
-
registerI18n(
|
|
156
|
-
expect(getActiveI18nInstances()).toEqual([
|
|
155
|
+
registerI18n('gc-instance', instance)
|
|
156
|
+
expect(getActiveI18nInstances()).toEqual(['gc-instance'])
|
|
157
157
|
|
|
158
158
|
// Simulate GC
|
|
159
159
|
mockDerefResult = undefined
|