@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.
- package/LICENSE +21 -0
- package/README.md +227 -0
- package/lib/analysis/devtools.js.html +5406 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/devtools.js +81 -0
- package/lib/devtools.js.map +1 -0
- package/lib/index.js +330 -0
- package/lib/index.js.map +1 -0
- package/lib/types/devtools.d.ts +74 -0
- package/lib/types/devtools.d.ts.map +1 -0
- package/lib/types/devtools2.d.ts +30 -0
- package/lib/types/devtools2.d.ts.map +1 -0
- package/lib/types/index.d.ts +334 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +244 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +57 -0
- package/src/context.ts +57 -0
- package/src/create-i18n.ts +309 -0
- package/src/devtools.ts +94 -0
- package/src/index.ts +18 -0
- package/src/interpolation.ts +28 -0
- package/src/pluralization.ts +31 -0
- package/src/tests/devtools.test.ts +166 -0
- package/src/tests/i18n.test.ts +859 -0
- package/src/tests/setup.ts +1 -0
- package/src/trans.ts +111 -0
- package/src/types.ts +112 -0
package/src/context.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
pushContext,
|
|
4
|
+
popContext,
|
|
5
|
+
onUnmount,
|
|
6
|
+
useContext,
|
|
7
|
+
} from '@pyreon/core'
|
|
8
|
+
import type { VNodeChild, VNode, Props } from '@pyreon/core'
|
|
9
|
+
import type { I18nInstance } from './types'
|
|
10
|
+
|
|
11
|
+
export const I18nContext = createContext<I18nInstance | null>(null)
|
|
12
|
+
|
|
13
|
+
export interface I18nProviderProps extends Props {
|
|
14
|
+
instance: I18nInstance
|
|
15
|
+
children?: VNodeChild
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Provide an i18n instance to the component tree.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* const i18n = createI18n({ locale: 'en', messages: { en: { hello: 'Hello' } } })
|
|
23
|
+
*
|
|
24
|
+
* // In JSX:
|
|
25
|
+
* <I18nProvider instance={i18n}>
|
|
26
|
+
* <App />
|
|
27
|
+
* </I18nProvider>
|
|
28
|
+
*/
|
|
29
|
+
export function I18nProvider(props: I18nProviderProps): VNode {
|
|
30
|
+
const frame = new Map([[I18nContext.id, props.instance]])
|
|
31
|
+
pushContext(frame)
|
|
32
|
+
|
|
33
|
+
onUnmount(() => popContext())
|
|
34
|
+
|
|
35
|
+
const ch = props.children
|
|
36
|
+
return (typeof ch === 'function' ? (ch as () => VNodeChild)() : ch) as VNode
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Access the i18n instance from the nearest I18nProvider.
|
|
41
|
+
* Must be called within a component tree wrapped by I18nProvider.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* function Greeting() {
|
|
45
|
+
* const { t, locale } = useI18n()
|
|
46
|
+
* return <h1>{t('greeting', { name: 'World' })}</h1>
|
|
47
|
+
* }
|
|
48
|
+
*/
|
|
49
|
+
export function useI18n(): I18nInstance {
|
|
50
|
+
const instance = useContext(I18nContext)
|
|
51
|
+
if (!instance) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'[@pyreon/i18n] useI18n() must be used within an <I18nProvider>.',
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
return instance
|
|
57
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { signal, computed } from '@pyreon/reactivity'
|
|
2
|
+
import type {
|
|
3
|
+
I18nOptions,
|
|
4
|
+
I18nInstance,
|
|
5
|
+
TranslationDictionary,
|
|
6
|
+
InterpolationValues,
|
|
7
|
+
} from './types'
|
|
8
|
+
import { interpolate } from './interpolation'
|
|
9
|
+
import { resolvePluralCategory } from './pluralization'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve a dot-separated key path in a nested dictionary.
|
|
13
|
+
* E.g. "user.greeting" → dictionary.user.greeting
|
|
14
|
+
*/
|
|
15
|
+
function resolveKey(
|
|
16
|
+
dict: TranslationDictionary,
|
|
17
|
+
keyPath: string,
|
|
18
|
+
): string | undefined {
|
|
19
|
+
const parts = keyPath.split('.')
|
|
20
|
+
let current: TranslationDictionary | string = dict
|
|
21
|
+
|
|
22
|
+
for (const part of parts) {
|
|
23
|
+
if (current == null || typeof current === 'string') return undefined
|
|
24
|
+
current = current[part] as TranslationDictionary | string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return typeof current === 'string' ? current : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Deep-merge source into target (mutates target).
|
|
32
|
+
*/
|
|
33
|
+
function deepMerge(
|
|
34
|
+
target: TranslationDictionary,
|
|
35
|
+
source: TranslationDictionary,
|
|
36
|
+
): void {
|
|
37
|
+
for (const key of Object.keys(source)) {
|
|
38
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype')
|
|
39
|
+
continue
|
|
40
|
+
const sourceVal = source[key]
|
|
41
|
+
const targetVal = target[key]
|
|
42
|
+
if (
|
|
43
|
+
typeof sourceVal === 'object' &&
|
|
44
|
+
sourceVal !== null &&
|
|
45
|
+
typeof targetVal === 'object' &&
|
|
46
|
+
targetVal !== null
|
|
47
|
+
) {
|
|
48
|
+
deepMerge(
|
|
49
|
+
targetVal as TranslationDictionary,
|
|
50
|
+
sourceVal as TranslationDictionary,
|
|
51
|
+
)
|
|
52
|
+
} else {
|
|
53
|
+
target[key] = sourceVal!
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create a reactive i18n instance.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const i18n = createI18n({
|
|
63
|
+
* locale: 'en',
|
|
64
|
+
* fallbackLocale: 'en',
|
|
65
|
+
* messages: {
|
|
66
|
+
* en: { greeting: 'Hello {{name}}!' },
|
|
67
|
+
* de: { greeting: 'Hallo {{name}}!' },
|
|
68
|
+
* },
|
|
69
|
+
* })
|
|
70
|
+
*
|
|
71
|
+
* // Reactive translation — re-evaluates on locale change
|
|
72
|
+
* i18n.t('greeting', { name: 'Alice' }) // "Hello Alice!"
|
|
73
|
+
* i18n.locale.set('de')
|
|
74
|
+
* i18n.t('greeting', { name: 'Alice' }) // "Hallo Alice!"
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* // Async namespace loading
|
|
78
|
+
* const i18n = createI18n({
|
|
79
|
+
* locale: 'en',
|
|
80
|
+
* loader: async (locale, namespace) => {
|
|
81
|
+
* const mod = await import(`./locales/${locale}/${namespace}.json`)
|
|
82
|
+
* return mod.default
|
|
83
|
+
* },
|
|
84
|
+
* })
|
|
85
|
+
* await i18n.loadNamespace('auth')
|
|
86
|
+
* i18n.t('auth:errors.invalid') // looks up "errors.invalid" in "auth" namespace
|
|
87
|
+
*/
|
|
88
|
+
export function createI18n(options: I18nOptions): I18nInstance {
|
|
89
|
+
const {
|
|
90
|
+
fallbackLocale,
|
|
91
|
+
loader,
|
|
92
|
+
defaultNamespace = 'common',
|
|
93
|
+
pluralRules,
|
|
94
|
+
onMissingKey,
|
|
95
|
+
} = options
|
|
96
|
+
|
|
97
|
+
// ── Reactive state ──────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
const locale = signal(options.locale)
|
|
100
|
+
|
|
101
|
+
// Internal store: locale → namespace → dictionary
|
|
102
|
+
// We use a version counter to trigger reactive updates when messages change,
|
|
103
|
+
// since the store is mutated in place (Object.is would skip same-reference sets).
|
|
104
|
+
const store = new Map<string, Map<string, TranslationDictionary>>()
|
|
105
|
+
const storeVersion = signal(0)
|
|
106
|
+
|
|
107
|
+
// Loading state
|
|
108
|
+
const pendingLoads = signal(0)
|
|
109
|
+
const loadedNsVersion = signal(0)
|
|
110
|
+
|
|
111
|
+
// In-flight load promises — deduplicates concurrent loads for the same locale:namespace
|
|
112
|
+
const pendingPromises = new Map<string, Promise<void>>()
|
|
113
|
+
|
|
114
|
+
const isLoading = computed(() => pendingLoads() > 0)
|
|
115
|
+
const loadedNamespaces = computed(() => {
|
|
116
|
+
loadedNsVersion()
|
|
117
|
+
const currentLocale = locale()
|
|
118
|
+
const nsMap = store.get(currentLocale)
|
|
119
|
+
return new Set(nsMap ? nsMap.keys() : [])
|
|
120
|
+
})
|
|
121
|
+
const availableLocales = computed(() => {
|
|
122
|
+
storeVersion() // subscribe to store changes
|
|
123
|
+
return [...store.keys()]
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// ── Initialize static messages ──────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
if (options.messages) {
|
|
129
|
+
for (const [loc, dict] of Object.entries(options.messages)) {
|
|
130
|
+
const nsMap = new Map<string, TranslationDictionary>()
|
|
131
|
+
nsMap.set(defaultNamespace, dict)
|
|
132
|
+
store.set(loc, nsMap)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Internal helpers ────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
function getNamespaceMap(loc: string): Map<string, TranslationDictionary> {
|
|
139
|
+
let nsMap = store.get(loc)
|
|
140
|
+
if (!nsMap) {
|
|
141
|
+
nsMap = new Map()
|
|
142
|
+
store.set(loc, nsMap)
|
|
143
|
+
}
|
|
144
|
+
return nsMap
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function lookupKey(
|
|
148
|
+
loc: string,
|
|
149
|
+
namespace: string,
|
|
150
|
+
keyPath: string,
|
|
151
|
+
): string | undefined {
|
|
152
|
+
const nsMap = store.get(loc)
|
|
153
|
+
if (!nsMap) return undefined
|
|
154
|
+
const dict = nsMap.get(namespace)
|
|
155
|
+
if (!dict) return undefined
|
|
156
|
+
return resolveKey(dict, keyPath)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resolveTranslation(
|
|
160
|
+
key: string,
|
|
161
|
+
values?: InterpolationValues,
|
|
162
|
+
): string {
|
|
163
|
+
// Subscribe to reactive dependencies
|
|
164
|
+
const currentLocale = locale()
|
|
165
|
+
storeVersion()
|
|
166
|
+
|
|
167
|
+
// Parse key: "namespace:key.path" or just "key.path"
|
|
168
|
+
let namespace = defaultNamespace
|
|
169
|
+
let keyPath = key
|
|
170
|
+
|
|
171
|
+
const colonIndex = key.indexOf(':')
|
|
172
|
+
if (colonIndex > 0) {
|
|
173
|
+
namespace = key.slice(0, colonIndex)
|
|
174
|
+
keyPath = key.slice(colonIndex + 1)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Handle pluralization: if values contain `count`, try plural suffixes
|
|
178
|
+
if (values && 'count' in values) {
|
|
179
|
+
const count = Number(values.count)
|
|
180
|
+
const category = resolvePluralCategory(currentLocale, count, pluralRules)
|
|
181
|
+
|
|
182
|
+
// Try exact form first (e.g. "items_one"), then fall back to base key
|
|
183
|
+
const pluralKey = `${keyPath}_${category}`
|
|
184
|
+
const pluralResult =
|
|
185
|
+
lookupKey(currentLocale, namespace, pluralKey) ??
|
|
186
|
+
(fallbackLocale
|
|
187
|
+
? lookupKey(fallbackLocale, namespace, pluralKey)
|
|
188
|
+
: undefined)
|
|
189
|
+
|
|
190
|
+
if (pluralResult) {
|
|
191
|
+
return interpolate(pluralResult, values)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Standard lookup: current locale → fallback locale
|
|
196
|
+
const result =
|
|
197
|
+
lookupKey(currentLocale, namespace, keyPath) ??
|
|
198
|
+
(fallbackLocale
|
|
199
|
+
? lookupKey(fallbackLocale, namespace, keyPath)
|
|
200
|
+
: undefined)
|
|
201
|
+
|
|
202
|
+
if (result !== undefined) {
|
|
203
|
+
return interpolate(result, values)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Missing key handler
|
|
207
|
+
if (onMissingKey) {
|
|
208
|
+
const custom = onMissingKey(currentLocale, key, namespace)
|
|
209
|
+
if (custom !== undefined) return custom!
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Return the key itself as a visual fallback
|
|
213
|
+
return key
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
const t = (key: string, values?: InterpolationValues): string => {
|
|
219
|
+
return resolveTranslation(key, values)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const loadNamespace = async (
|
|
223
|
+
namespace: string,
|
|
224
|
+
loc?: string,
|
|
225
|
+
): Promise<void> => {
|
|
226
|
+
if (!loader) return
|
|
227
|
+
|
|
228
|
+
const targetLocale = loc ?? locale.peek()
|
|
229
|
+
const cacheKey = `${targetLocale}:${namespace}`
|
|
230
|
+
const nsMap = getNamespaceMap(targetLocale)
|
|
231
|
+
|
|
232
|
+
// Skip if already loaded
|
|
233
|
+
if (nsMap.has(namespace)) return
|
|
234
|
+
|
|
235
|
+
// Deduplicate concurrent loads for the same locale:namespace
|
|
236
|
+
const existing = pendingPromises.get(cacheKey)
|
|
237
|
+
if (existing) return existing
|
|
238
|
+
|
|
239
|
+
pendingLoads.update((n) => n + 1)
|
|
240
|
+
|
|
241
|
+
const promise = loader(targetLocale, namespace)
|
|
242
|
+
.then((dict) => {
|
|
243
|
+
if (dict) {
|
|
244
|
+
nsMap.set(namespace, dict)
|
|
245
|
+
storeVersion.update((n) => n + 1)
|
|
246
|
+
loadedNsVersion.update((n) => n + 1)
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
.finally(() => {
|
|
250
|
+
pendingPromises.delete(cacheKey)
|
|
251
|
+
pendingLoads.update((n) => n - 1)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
pendingPromises.set(cacheKey, promise)
|
|
255
|
+
return promise
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const exists = (key: string): boolean => {
|
|
259
|
+
const currentLocale = locale.peek()
|
|
260
|
+
|
|
261
|
+
let namespace = defaultNamespace
|
|
262
|
+
let keyPath = key
|
|
263
|
+
const colonIndex = key.indexOf(':')
|
|
264
|
+
if (colonIndex > 0) {
|
|
265
|
+
namespace = key.slice(0, colonIndex)
|
|
266
|
+
keyPath = key.slice(colonIndex + 1)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
lookupKey(currentLocale, namespace, keyPath) !== undefined ||
|
|
271
|
+
(fallbackLocale
|
|
272
|
+
? lookupKey(fallbackLocale, namespace, keyPath) !== undefined
|
|
273
|
+
: false)
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const addMessages = (
|
|
278
|
+
loc: string,
|
|
279
|
+
messages: TranslationDictionary,
|
|
280
|
+
namespace?: string,
|
|
281
|
+
): void => {
|
|
282
|
+
const ns = namespace ?? defaultNamespace
|
|
283
|
+
const nsMap = getNamespaceMap(loc)
|
|
284
|
+
const existing = nsMap.get(ns)
|
|
285
|
+
|
|
286
|
+
if (existing) {
|
|
287
|
+
deepMerge(existing, messages)
|
|
288
|
+
} else {
|
|
289
|
+
// Deep-clone to prevent external mutation from corrupting the store
|
|
290
|
+
const cloned: TranslationDictionary = {}
|
|
291
|
+
deepMerge(cloned, messages)
|
|
292
|
+
nsMap.set(ns, cloned)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
storeVersion.update((n) => n + 1)
|
|
296
|
+
loadedNsVersion.update((n) => n + 1)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
t,
|
|
301
|
+
locale,
|
|
302
|
+
loadNamespace,
|
|
303
|
+
isLoading,
|
|
304
|
+
loadedNamespaces,
|
|
305
|
+
exists,
|
|
306
|
+
addMessages,
|
|
307
|
+
availableLocales,
|
|
308
|
+
}
|
|
309
|
+
}
|
package/src/devtools.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pyreon/i18n devtools introspection API.
|
|
3
|
+
* Import: `import { ... } from "@pyreon/i18n/devtools"`
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const _activeInstances = new Map<string, WeakRef<object>>()
|
|
7
|
+
const _listeners = new Set<() => void>()
|
|
8
|
+
|
|
9
|
+
function _notify(): void {
|
|
10
|
+
for (const listener of _listeners) listener()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Register an i18n instance for devtools inspection.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const i18n = createI18n({ ... })
|
|
18
|
+
* registerI18n("app", i18n)
|
|
19
|
+
*/
|
|
20
|
+
export function registerI18n(name: string, instance: object): void {
|
|
21
|
+
_activeInstances.set(name, new WeakRef(instance))
|
|
22
|
+
_notify()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Unregister an i18n instance. */
|
|
26
|
+
export function unregisterI18n(name: string): void {
|
|
27
|
+
_activeInstances.delete(name)
|
|
28
|
+
_notify()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Get all registered i18n instance names. Cleans up garbage-collected instances. */
|
|
32
|
+
export function getActiveI18nInstances(): string[] {
|
|
33
|
+
for (const [name, ref] of _activeInstances) {
|
|
34
|
+
if (ref.deref() === undefined) _activeInstances.delete(name)
|
|
35
|
+
}
|
|
36
|
+
return [..._activeInstances.keys()]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Get an i18n instance by name (or undefined if GC'd or not registered). */
|
|
40
|
+
export function getI18nInstance(name: string): object | undefined {
|
|
41
|
+
const ref = _activeInstances.get(name)
|
|
42
|
+
if (!ref) return undefined
|
|
43
|
+
const instance = ref.deref()
|
|
44
|
+
if (!instance) {
|
|
45
|
+
_activeInstances.delete(name)
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
return instance
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Safely read a property that may be a signal (callable). */
|
|
52
|
+
function safeRead(
|
|
53
|
+
obj: Record<string, unknown>,
|
|
54
|
+
key: string,
|
|
55
|
+
fallback: unknown = undefined,
|
|
56
|
+
): unknown {
|
|
57
|
+
try {
|
|
58
|
+
const val = obj[key]
|
|
59
|
+
return typeof val === 'function' ? (val as () => unknown)() : fallback
|
|
60
|
+
} catch {
|
|
61
|
+
return fallback
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get a snapshot of an i18n instance's state.
|
|
67
|
+
*/
|
|
68
|
+
export function getI18nSnapshot(
|
|
69
|
+
name: string,
|
|
70
|
+
): Record<string, unknown> | undefined {
|
|
71
|
+
const instance = getI18nInstance(name) as Record<string, unknown> | undefined
|
|
72
|
+
if (!instance) return undefined
|
|
73
|
+
const ns = safeRead(instance, 'loadedNamespaces', new Set())
|
|
74
|
+
return {
|
|
75
|
+
locale: safeRead(instance, 'locale'),
|
|
76
|
+
availableLocales: safeRead(instance, 'availableLocales', []),
|
|
77
|
+
loadedNamespaces: ns instanceof Set ? [...ns] : [],
|
|
78
|
+
isLoading: safeRead(instance, 'isLoading', false),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Subscribe to i18n registry changes. Returns unsubscribe function. */
|
|
83
|
+
export function onI18nChange(listener: () => void): () => void {
|
|
84
|
+
_listeners.add(listener)
|
|
85
|
+
return () => {
|
|
86
|
+
_listeners.delete(listener)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @internal — reset devtools registry (for tests). */
|
|
91
|
+
export function _resetDevtools(): void {
|
|
92
|
+
_activeInstances.clear()
|
|
93
|
+
_listeners.clear()
|
|
94
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { createI18n } from './create-i18n'
|
|
2
|
+
export { interpolate } from './interpolation'
|
|
3
|
+
export { resolvePluralCategory } from './pluralization'
|
|
4
|
+
export { I18nProvider, useI18n, I18nContext } from './context'
|
|
5
|
+
export { Trans, parseRichText } from './trans'
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
I18nInstance,
|
|
9
|
+
I18nOptions,
|
|
10
|
+
TranslationDictionary,
|
|
11
|
+
TranslationMessages,
|
|
12
|
+
NamespaceLoader,
|
|
13
|
+
InterpolationValues,
|
|
14
|
+
PluralRules,
|
|
15
|
+
} from './types'
|
|
16
|
+
|
|
17
|
+
export type { I18nProviderProps } from './context'
|
|
18
|
+
export type { TransProps } from './trans'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { InterpolationValues } from './types'
|
|
2
|
+
|
|
3
|
+
const INTERPOLATION_RE = /\{\{(\s*\w+\s*)\}\}/g
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Replace `{{key}}` placeholders in a string with values from the given record.
|
|
7
|
+
* Supports optional whitespace inside braces: `{{ name }}` works too.
|
|
8
|
+
* Unmatched placeholders are left as-is.
|
|
9
|
+
*/
|
|
10
|
+
export function interpolate(
|
|
11
|
+
template: string,
|
|
12
|
+
values?: InterpolationValues,
|
|
13
|
+
): string {
|
|
14
|
+
if (!values || !template.includes('{{')) return template
|
|
15
|
+
return template.replace(INTERPOLATION_RE, (_, key: string) => {
|
|
16
|
+
const trimmed = key.trim()
|
|
17
|
+
const value = values[trimmed]
|
|
18
|
+
if (value === undefined) return `{{${trimmed}}}`
|
|
19
|
+
// Safely coerce — guard against malicious toString/valueOf
|
|
20
|
+
try {
|
|
21
|
+
return typeof value === 'object' && value !== null
|
|
22
|
+
? JSON.stringify(value)
|
|
23
|
+
: `${value}`
|
|
24
|
+
} catch {
|
|
25
|
+
return `{{${trimmed}}}`
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { PluralRules } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the plural category for a given count and locale.
|
|
5
|
+
*
|
|
6
|
+
* Uses custom rules if provided, otherwise falls back to `Intl.PluralRules`.
|
|
7
|
+
* Returns CLDR plural categories: "zero", "one", "two", "few", "many", "other".
|
|
8
|
+
*/
|
|
9
|
+
export function resolvePluralCategory(
|
|
10
|
+
locale: string,
|
|
11
|
+
count: number,
|
|
12
|
+
customRules?: PluralRules,
|
|
13
|
+
): string {
|
|
14
|
+
// Custom rules take priority
|
|
15
|
+
if (customRules?.[locale]) {
|
|
16
|
+
return customRules[locale](count)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Use Intl.PluralRules if available
|
|
20
|
+
if (typeof Intl !== 'undefined' && Intl.PluralRules) {
|
|
21
|
+
try {
|
|
22
|
+
const pr = new Intl.PluralRules(locale)
|
|
23
|
+
return pr.select(count)
|
|
24
|
+
} catch {
|
|
25
|
+
// Invalid locale — fall through
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Basic fallback
|
|
30
|
+
return count === 1 ? 'one' : 'other'
|
|
31
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createI18n } from '../create-i18n'
|
|
2
|
+
import {
|
|
3
|
+
registerI18n,
|
|
4
|
+
unregisterI18n,
|
|
5
|
+
getActiveI18nInstances,
|
|
6
|
+
getI18nInstance,
|
|
7
|
+
getI18nSnapshot,
|
|
8
|
+
onI18nChange,
|
|
9
|
+
_resetDevtools,
|
|
10
|
+
} from '../devtools'
|
|
11
|
+
|
|
12
|
+
afterEach(() => _resetDevtools())
|
|
13
|
+
|
|
14
|
+
describe('i18n devtools', () => {
|
|
15
|
+
test('getActiveI18nInstances returns empty initially', () => {
|
|
16
|
+
expect(getActiveI18nInstances()).toEqual([])
|
|
17
|
+
})
|
|
18
|
+
|
|
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
|
+
})
|
|
24
|
+
|
|
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
|
+
})
|
|
30
|
+
|
|
31
|
+
test('getI18nInstance returns undefined for unregistered name', () => {
|
|
32
|
+
expect(getI18nInstance('nope')).toBeUndefined()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('unregisterI18n removes the instance', () => {
|
|
36
|
+
const i18n = createI18n({ locale: 'en' })
|
|
37
|
+
registerI18n('app', i18n)
|
|
38
|
+
unregisterI18n('app')
|
|
39
|
+
expect(getActiveI18nInstances()).toEqual([])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('getI18nSnapshot returns current state', () => {
|
|
43
|
+
const i18n = createI18n({
|
|
44
|
+
locale: 'en',
|
|
45
|
+
messages: { en: { hi: 'Hello' }, de: { hi: 'Hallo' } },
|
|
46
|
+
})
|
|
47
|
+
registerI18n('app', i18n)
|
|
48
|
+
const snapshot = getI18nSnapshot('app')
|
|
49
|
+
expect(snapshot).toBeDefined()
|
|
50
|
+
expect(snapshot!.locale).toBe('en')
|
|
51
|
+
expect(snapshot!.availableLocales).toEqual(
|
|
52
|
+
expect.arrayContaining(['en', 'de']),
|
|
53
|
+
)
|
|
54
|
+
expect(snapshot!.isLoading).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('getI18nSnapshot handles instance with non-function properties', () => {
|
|
58
|
+
// Register a plain object where properties are NOT functions
|
|
59
|
+
// This covers the false branches of typeof checks in getI18nSnapshot
|
|
60
|
+
const plainInstance = {
|
|
61
|
+
locale: 'not-a-function',
|
|
62
|
+
availableLocales: 42,
|
|
63
|
+
loadedNamespaces: null,
|
|
64
|
+
isLoading: undefined,
|
|
65
|
+
}
|
|
66
|
+
registerI18n('plain', plainInstance)
|
|
67
|
+
const snapshot = getI18nSnapshot('plain')
|
|
68
|
+
expect(snapshot).toBeDefined()
|
|
69
|
+
expect(snapshot!.locale).toBeUndefined()
|
|
70
|
+
expect(snapshot!.availableLocales).toEqual([])
|
|
71
|
+
expect(snapshot!.loadedNamespaces).toEqual([])
|
|
72
|
+
expect(snapshot!.isLoading).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('getI18nSnapshot reflects locale change', () => {
|
|
76
|
+
const i18n = createI18n({ locale: 'en', messages: { en: {}, de: {} } })
|
|
77
|
+
registerI18n('app', i18n)
|
|
78
|
+
i18n.locale.set('de')
|
|
79
|
+
const snapshot = getI18nSnapshot('app')
|
|
80
|
+
expect(snapshot!.locale).toBe('de')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('getI18nSnapshot returns undefined for unregistered name', () => {
|
|
84
|
+
expect(getI18nSnapshot('nope')).toBeUndefined()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('onI18nChange fires on register', () => {
|
|
88
|
+
const calls: number[] = []
|
|
89
|
+
const unsub = onI18nChange(() => calls.push(1))
|
|
90
|
+
|
|
91
|
+
registerI18n('app', createI18n({ locale: 'en' }))
|
|
92
|
+
expect(calls.length).toBe(1)
|
|
93
|
+
|
|
94
|
+
unsub()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('onI18nChange fires on unregister', () => {
|
|
98
|
+
registerI18n('app', createI18n({ locale: 'en' }))
|
|
99
|
+
|
|
100
|
+
const calls: number[] = []
|
|
101
|
+
const unsub = onI18nChange(() => calls.push(1))
|
|
102
|
+
unregisterI18n('app')
|
|
103
|
+
expect(calls.length).toBe(1)
|
|
104
|
+
|
|
105
|
+
unsub()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('onI18nChange unsubscribe stops notifications', () => {
|
|
109
|
+
const calls: number[] = []
|
|
110
|
+
const unsub = onI18nChange(() => calls.push(1))
|
|
111
|
+
unsub()
|
|
112
|
+
|
|
113
|
+
registerI18n('app', createI18n({ locale: 'en' }))
|
|
114
|
+
expect(calls.length).toBe(0)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('multiple instances are tracked', () => {
|
|
118
|
+
registerI18n('app', createI18n({ locale: 'en' }))
|
|
119
|
+
registerI18n('admin', createI18n({ locale: 'en' }))
|
|
120
|
+
expect(getActiveI18nInstances().sort()).toEqual(['admin', 'app'])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('getI18nInstance cleans up and returns undefined when WeakRef is dead', () => {
|
|
124
|
+
const instance = createI18n({ locale: 'en' })
|
|
125
|
+
const originalWeakRef = globalThis.WeakRef
|
|
126
|
+
let mockDerefResult: object | undefined = instance
|
|
127
|
+
const MockWeakRef = class {
|
|
128
|
+
deref() {
|
|
129
|
+
return mockDerefResult
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
globalThis.WeakRef = MockWeakRef as any
|
|
133
|
+
|
|
134
|
+
_resetDevtools()
|
|
135
|
+
registerI18n('mock-instance', instance)
|
|
136
|
+
expect(getI18nInstance('mock-instance')).toBe(instance)
|
|
137
|
+
|
|
138
|
+
// Simulate GC
|
|
139
|
+
mockDerefResult = undefined
|
|
140
|
+
expect(getI18nInstance('mock-instance')).toBeUndefined()
|
|
141
|
+
|
|
142
|
+
globalThis.WeakRef = originalWeakRef
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('getActiveI18nInstances cleans up garbage-collected WeakRefs', () => {
|
|
146
|
+
const instance = createI18n({ locale: 'en' })
|
|
147
|
+
const originalWeakRef = globalThis.WeakRef
|
|
148
|
+
let mockDerefResult: object | undefined = instance
|
|
149
|
+
const MockWeakRef = class {
|
|
150
|
+
deref() {
|
|
151
|
+
return mockDerefResult
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
globalThis.WeakRef = MockWeakRef as any
|
|
155
|
+
|
|
156
|
+
_resetDevtools()
|
|
157
|
+
registerI18n('gc-instance', instance)
|
|
158
|
+
expect(getActiveI18nInstances()).toEqual(['gc-instance'])
|
|
159
|
+
|
|
160
|
+
// Simulate GC
|
|
161
|
+
mockDerefResult = undefined
|
|
162
|
+
expect(getActiveI18nInstances()).toEqual([])
|
|
163
|
+
|
|
164
|
+
globalThis.WeakRef = originalWeakRef
|
|
165
|
+
})
|
|
166
|
+
})
|