@i18n-micro/core 1.0.27

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,216 @@
1
+ import type { Translations } from '@i18n-micro/types'
2
+
3
+ // Duck-typing для Ref, чтобы не тащить Vue зависимость
4
+ export interface RefLike<T> {
5
+ value: T
6
+ }
7
+
8
+ export interface TranslationCache {
9
+ generalLocaleCache: RefLike<Record<string, Translations>> | Record<string, Translations>
10
+ routeLocaleCache: RefLike<Record<string, Translations>> | Record<string, Translations>
11
+ dynamicTranslationsCaches: RefLike<Record<string, Translations>[]> | Record<string, Translations>[]
12
+ serverTranslationCache: RefLike<Record<string, Map<string, Translations | unknown>>> | Record<string, Map<string, Translations | unknown>>
13
+ }
14
+
15
+ // Глобальные кэши для fallback (только для unit-тестов и обратной совместимости)
16
+ // НЕ используются в SSR продакшене
17
+ const globalGeneralLocaleCache: Record<string, Translations> = {}
18
+ const globalRouteLocaleCache: Record<string, Translations> = {}
19
+ const globalDynamicTranslationsCaches: Record<string, Translations>[] = []
20
+ const globalServerTranslationCache: Record<string, Map<string, Translations | unknown>> = {}
21
+
22
+ function deepClone<T>(value: T): T {
23
+ if (Array.isArray(value)) {
24
+ return JSON.parse(JSON.stringify(value)) as T
25
+ }
26
+ else if (typeof value === 'object' && value !== null) {
27
+ return JSON.parse(JSON.stringify(value)) as T
28
+ }
29
+ return value
30
+ }
31
+
32
+ function findTranslation<T = unknown>(translations: Translations | null, key: string): T | null {
33
+ let value: string | number | boolean | Translations | unknown | null = translations
34
+
35
+ if (translations === null || typeof key !== 'string') {
36
+ return null
37
+ }
38
+
39
+ if (translations[key]) {
40
+ value = translations[key]
41
+ }
42
+ else {
43
+ const parts = key.toString().split('.')
44
+ for (const part of parts) {
45
+ if (value && typeof value === 'object' && part in value) {
46
+ value = (value as Translations)[part]
47
+ }
48
+ else {
49
+ return null
50
+ }
51
+ }
52
+ }
53
+
54
+ if (typeof value === 'object' && value !== null) {
55
+ return deepClone(value) as T
56
+ }
57
+
58
+ return (value as T) ?? null
59
+ }
60
+
61
+ // Вспомогательная функция для получения значения из Ref или обычного объекта
62
+ function getValue<T>(refOrValue: RefLike<T> | T): T {
63
+ return typeof refOrValue === 'object' && refOrValue !== null && 'value' in refOrValue
64
+ ? (refOrValue as RefLike<T>).value
65
+ : refOrValue as T
66
+ }
67
+
68
+ // Вспомогательная функция для установки значения в Ref или обычный объект
69
+ function setValue<T extends Record<string, unknown>>(
70
+ refOrValue: RefLike<T> | T,
71
+ key: string,
72
+ value: T[keyof T],
73
+ ): void {
74
+ const target = getValue(refOrValue)
75
+ ;(target as Record<string, unknown>)[key] = value
76
+ }
77
+
78
+ // Вспомогательная функция для получения значения из Ref или обычного объекта по ключу
79
+ function getValueByKey<T extends Record<string, unknown>>(refOrValue: RefLike<T> | T, key: string): T[keyof T] | undefined {
80
+ const target = getValue(refOrValue)
81
+ return (target as Record<string, unknown>)[key] as T[keyof T] | undefined
82
+ }
83
+
84
+ export function useTranslationHelper(caches?: TranslationCache) {
85
+ // Используем переданные кэши или глобальные (fallback для тестов)
86
+ const generalLocaleCache = caches?.generalLocaleCache ?? globalGeneralLocaleCache
87
+ const routeLocaleCache = caches?.routeLocaleCache ?? globalRouteLocaleCache
88
+ const dynamicTranslationsCaches = caches?.dynamicTranslationsCaches ?? globalDynamicTranslationsCaches
89
+ const serverTranslationCache = caches?.serverTranslationCache ?? globalServerTranslationCache
90
+
91
+ return {
92
+ hasCache(locale: string, page: string) {
93
+ const cacheKey = `${locale}:${page}`
94
+ const cache = getValueByKey(serverTranslationCache, cacheKey)
95
+ return (cache ?? new Map<string, Translations | unknown>()).size > 0
96
+ },
97
+ getCache(locale: string, routeName: string) {
98
+ const cacheKey = `${locale}:${routeName}`
99
+ return getValueByKey(serverTranslationCache, cacheKey)
100
+ },
101
+ setCache(locale: string, routeName: string, cache: Map<string, Translations | unknown>) {
102
+ const cacheKey = `${locale}:${routeName}`
103
+ setValue(serverTranslationCache, cacheKey, cache)
104
+ },
105
+ mergeTranslation(locale: string, routeName: string, newTranslations: Translations, force = false) {
106
+ const cacheKey = `${locale}:${routeName}`
107
+ const currentCache = getValueByKey(routeLocaleCache, cacheKey)
108
+
109
+ if (currentCache || force) {
110
+ const existing = currentCache ?? {}
111
+ setValue(routeLocaleCache, cacheKey, {
112
+ ...existing,
113
+ ...newTranslations,
114
+ })
115
+ }
116
+
117
+ const isDev = process.env.NODE_ENV !== 'production'
118
+ if (!currentCache && isDev) {
119
+ // Если кэша нет, выводим предупреждение в dev-режиме и ничего не делаем.
120
+ console.warn(`[i18n] mergeTranslation called for '${cacheKey}' which was not pre-loaded. Skipping merge. Use force: true if this is intentional.`)
121
+ }
122
+ },
123
+ mergeGlobalTranslation(locale: string, newTranslations: Translations, force = false) {
124
+ const currentCache = getValueByKey(generalLocaleCache, locale)
125
+ if (!force && !currentCache) {
126
+ console.error(`marge: route ${locale} not loaded`)
127
+ }
128
+ const existing = currentCache ?? {}
129
+ setValue(generalLocaleCache, locale, {
130
+ ...existing,
131
+ ...newTranslations,
132
+ })
133
+ },
134
+ hasGeneralTranslation(locale: string) {
135
+ return !!getValueByKey(generalLocaleCache, locale)
136
+ },
137
+ hasPageTranslation(locale: string, routeName: string) {
138
+ const cacheKey = `${locale}:${routeName}`
139
+
140
+ return !!getValueByKey(routeLocaleCache, cacheKey)
141
+ },
142
+ hasTranslation: (locale: string, key: import('@i18n-micro/types').TranslationKey): boolean => {
143
+ const dynamicCaches = getValue(dynamicTranslationsCaches)
144
+ for (const dynamicCache of dynamicCaches) {
145
+ if (findTranslation(dynamicCache[locale] || null, key) !== null) {
146
+ return true
147
+ }
148
+ }
149
+
150
+ const generalCache = getValueByKey(generalLocaleCache, locale)
151
+ return findTranslation(generalCache || null, key) !== null
152
+ },
153
+ getTranslation: <T = unknown>(locale: string, routeName: string, key: import('@i18n-micro/types').TranslationKey): T | null => {
154
+ const cacheKey = `${locale}:${routeName}`
155
+ const serverCache = getValueByKey(serverTranslationCache, cacheKey)
156
+ const cached = serverCache?.get(key)
157
+ if (cached) {
158
+ return cached as T
159
+ }
160
+
161
+ let result: T | null = null
162
+
163
+ const dynamicCaches = getValue(dynamicTranslationsCaches)
164
+ for (const dynamicCache of dynamicCaches) {
165
+ result = findTranslation<T>(dynamicCache[locale] || null, key)
166
+ if (result !== null) break
167
+ }
168
+
169
+ if (!result) {
170
+ const routeCache = getValueByKey(routeLocaleCache, cacheKey)
171
+ const generalCache = getValueByKey(generalLocaleCache, locale)
172
+ result = findTranslation<T>(routeCache || null, key)
173
+ ?? findTranslation<T>(generalCache || null, key)
174
+ }
175
+
176
+ if (result) {
177
+ const currentServerCache = serverCache ?? new Map<string, Translations | unknown>()
178
+ currentServerCache.set(key, result)
179
+ setValue(serverTranslationCache, cacheKey, currentServerCache)
180
+ }
181
+
182
+ return result
183
+ },
184
+ async loadPageTranslations(locale: string, routeName: string, translations: Translations): Promise<void> {
185
+ const cacheKey = `${locale}:${routeName}`
186
+ setValue(routeLocaleCache, cacheKey, { ...translations })
187
+ },
188
+ async loadTranslations(locale: string, translations: Translations): Promise<void> {
189
+ setValue(generalLocaleCache, locale, { ...translations })
190
+ },
191
+ clearCache() {
192
+ // Clear general cache
193
+ const generalCache = getValue(generalLocaleCache)
194
+ Object.keys(generalCache).forEach((key) => {
195
+ setValue(generalLocaleCache, key, {})
196
+ })
197
+
198
+ // Clear route-specific cache
199
+ const routeCache = getValue(routeLocaleCache)
200
+ Object.keys(routeCache).forEach((key) => {
201
+ setValue(routeLocaleCache, key, {})
202
+ })
203
+
204
+ // Clear dynamic caches
205
+ const dynamicCaches = getValue(dynamicTranslationsCaches)
206
+ dynamicCaches.length = 0
207
+
208
+ // Clear server translation cache
209
+ const serverCache = getValue(serverTranslationCache)
210
+ Object.keys(serverCache).forEach((key) => {
211
+ const cacheMap = getValueByKey(serverTranslationCache, key)
212
+ cacheMap?.clear()
213
+ })
214
+ },
215
+ }
216
+ }
@@ -0,0 +1,81 @@
1
+ import { useTranslationHelper, interpolate } from '../src'
2
+
3
+ describe('Translation Helper', () => {
4
+ const translations = {
5
+ en: {
6
+ greeting: 'Hello, {name}!',
7
+ nested: {
8
+ message: 'This is a nested message.',
9
+ },
10
+ },
11
+ fr: {
12
+ greeting: 'Bonjour, {name}!',
13
+ },
14
+ }
15
+
16
+ test('interpolate function replaces placeholders correctly', () => {
17
+ const result = interpolate('Hello, {name}!', { name: 'John' })
18
+ expect(result).toBe('Hello, John!')
19
+ })
20
+
21
+ test('interpolate function handles missing placeholders gracefully', () => {
22
+ const result = interpolate('Hello, {name}!', { age: 30 })
23
+ expect(result).toBe('Hello, {name}!')
24
+ })
25
+
26
+ test('getTranslation fetches correct translation', () => {
27
+ const helper = useTranslationHelper()
28
+ helper.loadTranslations('en', translations.en)
29
+
30
+ const translation = helper.getTranslation('en', 'index', 'greeting')
31
+ expect(translation).toBe('Hello, {name}!')
32
+ })
33
+
34
+ test('getTranslation supports nested keys', () => {
35
+ const helper = useTranslationHelper()
36
+ helper.loadTranslations('en', translations.en)
37
+
38
+ const translation = helper.getTranslation('en', 'index', 'nested.message')
39
+ expect(translation).toBe('This is a nested message.')
40
+ })
41
+
42
+ test('getTranslation falls back when translation is missing', () => {
43
+ const helper = useTranslationHelper()
44
+ helper.loadTranslations('en', translations.en)
45
+
46
+ const translation = helper.getTranslation('en', 'index', 'nonexistent.key')
47
+ expect(translation).toBeNull()
48
+ })
49
+
50
+ test('loadPageTranslations correctly caches translations', async () => {
51
+ const helper = useTranslationHelper()
52
+ await helper.loadPageTranslations('fr', 'home', translations.fr)
53
+
54
+ expect(helper.hasPageTranslation('fr', 'home')).toBe(true)
55
+ expect(helper.getTranslation('fr', 'home', 'greeting')).toBe('Bonjour, {name}!')
56
+ })
57
+
58
+ test('mergeTranslation updates route translations', () => {
59
+ const helper = useTranslationHelper()
60
+ helper.loadPageTranslations('en', 'home', translations.en)
61
+
62
+ helper.mergeTranslation('en', 'home', { newKey: 'New value' })
63
+ expect(helper.getTranslation('en', 'home', 'newKey')).toBe('New value')
64
+ })
65
+
66
+ test('mergeGlobalTranslation updates general translations', () => {
67
+ const helper = useTranslationHelper()
68
+ helper.loadTranslations('en', translations.en)
69
+
70
+ helper.mergeGlobalTranslation('en', { newGlobalKey: 'Global value' })
71
+ expect(helper.getTranslation('en', 'index', 'newGlobalKey')).toBe('Global value')
72
+ })
73
+
74
+ test('deepClone creates a deep copy of objects', () => {
75
+ const original = { nested: { key: 'value' } }
76
+ const cloned = JSON.parse(JSON.stringify(original))
77
+
78
+ expect(cloned).toEqual(original)
79
+ expect(cloned).not.toBe(original)
80
+ })
81
+ })
@@ -0,0 +1,101 @@
1
+ import { FormatService } from '../src'
2
+
3
+ describe('FormatService', () => {
4
+ let formatService: FormatService
5
+
6
+ beforeEach(() => {
7
+ formatService = new FormatService()
8
+ })
9
+
10
+ describe('formatNumber', () => {
11
+ test('should format number with default options', () => {
12
+ const result = formatService.formatNumber(123456.789, 'en-US')
13
+ expect(result).toBe('123,456.789')
14
+ })
15
+
16
+ test('should format number with custom options', () => {
17
+ const result = formatService.formatNumber(123456.789, 'de-DE', {
18
+ style: 'currency',
19
+ currency: 'EUR',
20
+ })
21
+ expect(result).toBe('123.456,79 €')
22
+ })
23
+
24
+ test('should handle invalid locale by falling back to default formatting', () => {
25
+ const result = formatService.formatNumber(123456.789, 'invalid-locale')
26
+ expect(result).toBe('123,456.789') // Fallback to default formatting
27
+ })
28
+ })
29
+
30
+ describe('formatDate', () => {
31
+ test('should format date with default options', () => {
32
+ const date = new Date('2023-10-05T12:34:56Z')
33
+ const result = formatService.formatDate(date, 'en-US')
34
+ expect(result).toBe('10/5/2023')
35
+ })
36
+
37
+ test('should format date with custom options', () => {
38
+ const date = new Date('2023-10-05T12:34:56Z')
39
+ const result = formatService.formatDate(date, 'de-DE', {
40
+ year: 'numeric',
41
+ month: 'long',
42
+ day: 'numeric',
43
+ })
44
+ expect(result).toBe('5. Oktober 2023')
45
+ })
46
+
47
+ test('should handle invalid date by returning "Invalid Date"', () => {
48
+ const result = formatService.formatDate('invalid-date', 'en-US')
49
+ expect(result).toBe('Invalid Date')
50
+ })
51
+ })
52
+
53
+ describe('formatRelativeTime', () => {
54
+ test('should format relative time for seconds', () => {
55
+ const now = new Date()
56
+ const past = new Date(now.getTime() - 30 * 1000) // 30 seconds ago
57
+ const result = formatService.formatRelativeTime(past, 'en-US')
58
+ expect(result).toBe('30 seconds ago')
59
+ })
60
+
61
+ test('should format relative time for minutes', () => {
62
+ const now = new Date()
63
+ const past = new Date(now.getTime() - 5 * 60 * 1000) // 5 minutes ago
64
+ const result = formatService.formatRelativeTime(past, 'en-US')
65
+ expect(result).toBe('5 minutes ago')
66
+ })
67
+
68
+ test('should format relative time for hours', () => {
69
+ const now = new Date()
70
+ const past = new Date(now.getTime() - 2 * 60 * 60 * 1000) // 2 hours ago
71
+ const result = formatService.formatRelativeTime(past, 'en-US')
72
+ expect(result).toBe('2 hours ago')
73
+ })
74
+
75
+ test('should format relative time for days', () => {
76
+ const now = new Date()
77
+ const past = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000) // 3 days ago
78
+ const result = formatService.formatRelativeTime(past, 'en-US')
79
+ expect(result).toBe('3 days ago')
80
+ })
81
+
82
+ test('should format relative time for months', () => {
83
+ const now = new Date()
84
+ const past = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000) // ~2 months ago
85
+ const result = formatService.formatRelativeTime(past, 'en-US')
86
+ expect(result).toBe('2 months ago')
87
+ })
88
+
89
+ test('should format relative time for years', () => {
90
+ const now = new Date()
91
+ const past = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000) // 1 year ago
92
+ const result = formatService.formatRelativeTime(past, 'en-US')
93
+ expect(result).toBe('1 year ago')
94
+ })
95
+
96
+ test('should handle invalid date by returning "0 seconds ago"', () => {
97
+ const result = formatService.formatRelativeTime('invalid-date', 'en-US')
98
+ expect(result).toBe('in 0 seconds')
99
+ })
100
+ })
101
+ })
@@ -0,0 +1,103 @@
1
+ import {
2
+ interpolate,
3
+ withPrefixStrategy,
4
+ isNoPrefixStrategy,
5
+ isPrefixStrategy,
6
+ isPrefixExceptDefaultStrategy,
7
+ isPrefixAndDefaultStrategy,
8
+ } from '../src/helpers'
9
+
10
+ describe('Helpers', () => {
11
+ describe('interpolate', () => {
12
+ test('should replace placeholders with params', () => {
13
+ const template = 'Hello, {name}! Your age is {age}.'
14
+ const params = { name: 'John', age: 30 }
15
+ const result = interpolate(template, params)
16
+ expect(result).toBe('Hello, John! Your age is 30.')
17
+ })
18
+
19
+ test('should handle missing params by leaving placeholders', () => {
20
+ const template = 'Hello, {name}! Your age is {age}.'
21
+ const params = { name: 'John' } // age is missing
22
+ const result = interpolate(template, params)
23
+ expect(result).toBe('Hello, John! Your age is {age}.')
24
+ })
25
+
26
+ test('should handle empty params', () => {
27
+ const template = 'Hello, {name}!'
28
+ const params = {}
29
+ const result = interpolate(template, params)
30
+ expect(result).toBe('Hello, {name}!')
31
+ })
32
+
33
+ test('should handle empty template', () => {
34
+ const template = ''
35
+ const params = { name: 'John' }
36
+ const result = interpolate(template, params)
37
+ expect(result).toBe('')
38
+ })
39
+ })
40
+
41
+ describe('withPrefixStrategy', () => {
42
+ test('should return true for "prefix" strategy', () => {
43
+ expect(withPrefixStrategy('prefix')).toBe(true)
44
+ })
45
+
46
+ test('should return true for "prefix_and_default" strategy', () => {
47
+ expect(withPrefixStrategy('prefix_and_default')).toBe(true)
48
+ })
49
+
50
+ test('should return false for other strategies', () => {
51
+ expect(withPrefixStrategy('no_prefix')).toBe(false)
52
+ expect(withPrefixStrategy('prefix_except_default')).toBe(false)
53
+ })
54
+ })
55
+
56
+ describe('isNoPrefixStrategy', () => {
57
+ test('should return true for "no_prefix" strategy', () => {
58
+ expect(isNoPrefixStrategy('no_prefix')).toBe(true)
59
+ })
60
+
61
+ test('should return false for other strategies', () => {
62
+ expect(isNoPrefixStrategy('prefix')).toBe(false)
63
+ expect(isNoPrefixStrategy('prefix_and_default')).toBe(false)
64
+ expect(isNoPrefixStrategy('prefix_except_default')).toBe(false)
65
+ })
66
+ })
67
+
68
+ describe('isPrefixStrategy', () => {
69
+ test('should return true for "prefix" strategy', () => {
70
+ expect(isPrefixStrategy('prefix')).toBe(true)
71
+ })
72
+
73
+ test('should return false for other strategies', () => {
74
+ expect(isPrefixStrategy('no_prefix')).toBe(false)
75
+ expect(isPrefixStrategy('prefix_and_default')).toBe(false)
76
+ expect(isPrefixStrategy('prefix_except_default')).toBe(false)
77
+ })
78
+ })
79
+
80
+ describe('isPrefixExceptDefaultStrategy', () => {
81
+ test('should return true for "prefix_except_default" strategy', () => {
82
+ expect(isPrefixExceptDefaultStrategy('prefix_except_default')).toBe(true)
83
+ })
84
+
85
+ test('should return false for other strategies', () => {
86
+ expect(isPrefixExceptDefaultStrategy('no_prefix')).toBe(false)
87
+ expect(isPrefixExceptDefaultStrategy('prefix')).toBe(false)
88
+ expect(isPrefixExceptDefaultStrategy('prefix_and_default')).toBe(false)
89
+ })
90
+ })
91
+
92
+ describe('isPrefixAndDefaultStrategy', () => {
93
+ test('should return true for "prefix_and_default" strategy', () => {
94
+ expect(isPrefixAndDefaultStrategy('prefix_and_default')).toBe(true)
95
+ })
96
+
97
+ test('should return false for other strategies', () => {
98
+ expect(isPrefixAndDefaultStrategy('no_prefix')).toBe(false)
99
+ expect(isPrefixAndDefaultStrategy('prefix')).toBe(false)
100
+ expect(isPrefixAndDefaultStrategy('prefix_except_default')).toBe(false)
101
+ })
102
+ })
103
+ })