@i18n-micro/core 1.3.0 → 1.3.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/dist/index.d.cts +5 -0
- package/package.json +45 -9
- package/fix.ts +0 -1
- package/jest.config.cjs +0 -7
- package/src/base.ts +0 -194
- package/src/format-service.ts +0 -41
- package/src/helpers.ts +0 -54
- package/src/index.ts +0 -27
- package/src/translation.ts +0 -95
- package/tests/base.test.ts +0 -464
- package/tests/core.test.ts +0 -81
- package/tests/format-service.test.ts +0 -101
- package/tests/helpers.test.ts +0 -103
- package/tsconfig.json +0 -24
- package/vite.config.mts +0 -22
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { BaseI18n, BaseI18nOptions } from './base';
|
|
2
|
+
import { FormatService } from './format-service';
|
|
3
|
+
import { defaultPlural, interpolate, isNoPrefixStrategy, isPrefixAndDefaultStrategy, isPrefixExceptDefaultStrategy, isPrefixStrategy, withPrefixStrategy } from './helpers';
|
|
4
|
+
import { TranslationStorage, useTranslationHelper } from './translation';
|
|
5
|
+
export { useTranslationHelper, interpolate, withPrefixStrategy, isNoPrefixStrategy, isPrefixStrategy, isPrefixExceptDefaultStrategy, isPrefixAndDefaultStrategy, defaultPlural, FormatService, BaseI18n, type TranslationStorage, type BaseI18nOptions, };
|
package/package.json
CHANGED
|
@@ -1,35 +1,71 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i18n-micro/core",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "",
|
|
5
|
-
"repository":
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"description": "Core utilities for translations, formatting, and locale routing in Nuxt I18n Micro.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/s00d/nuxt-i18n-micro.git",
|
|
8
|
+
"directory": "packages/core"
|
|
9
|
+
},
|
|
6
10
|
"license": "MIT",
|
|
7
11
|
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
8
13
|
"author": {
|
|
9
14
|
"name": "s00d",
|
|
10
15
|
"email": "Virus191288@gmail.com",
|
|
11
16
|
"url": "https://s00d.github.io/"
|
|
12
17
|
},
|
|
13
|
-
"homepage": "https://github.com/s00d/nuxt-i18n-micro",
|
|
18
|
+
"homepage": "https://github.com/s00d/nuxt-i18n-micro/tree/main/packages/core#readme",
|
|
14
19
|
"bugs": {
|
|
15
20
|
"url": "https://github.com/s00d/nuxt-i18n-micro/issues"
|
|
16
21
|
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
17
25
|
"main": "dist/index.cjs",
|
|
18
|
-
"module": "dist/index.mjs",
|
|
19
26
|
"types": "dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"import": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.mjs"
|
|
32
|
+
},
|
|
33
|
+
"require": {
|
|
34
|
+
"types": "./dist/index.d.cts",
|
|
35
|
+
"default": "./dist/index.cjs"
|
|
36
|
+
},
|
|
37
|
+
"default": "./dist/index.mjs"
|
|
38
|
+
},
|
|
39
|
+
"./package.json": "./package.json"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
20
46
|
"publishConfig": {
|
|
21
47
|
"access": "public"
|
|
22
48
|
},
|
|
23
|
-
"keywords": [
|
|
49
|
+
"keywords": [
|
|
50
|
+
"nuxt",
|
|
51
|
+
"i18n",
|
|
52
|
+
"translations",
|
|
53
|
+
"formatting",
|
|
54
|
+
"locale"
|
|
55
|
+
],
|
|
24
56
|
"dependencies": {
|
|
25
|
-
"@i18n-micro/types": "1.2.
|
|
57
|
+
"@i18n-micro/types": "1.2.2"
|
|
26
58
|
},
|
|
27
59
|
"devDependencies": {
|
|
60
|
+
"publint": "^0.3.17",
|
|
28
61
|
"vite": "^7.3.1",
|
|
29
|
-
"vite-plugin-dts": "^4.5.4"
|
|
62
|
+
"vite-plugin-dts": "^4.5.4",
|
|
63
|
+
"vitest": "^3.2.4"
|
|
30
64
|
},
|
|
31
65
|
"scripts": {
|
|
32
66
|
"build": "vite build",
|
|
33
|
-
"
|
|
67
|
+
"check:package": "publint",
|
|
68
|
+
"test": "jest",
|
|
69
|
+
"test:dist": "vitest run --config vitest.dist.config.ts"
|
|
34
70
|
}
|
|
35
71
|
}
|
package/fix.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import '@types/jest'
|
package/jest.config.cjs
DELETED
package/src/base.ts
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
import type { CleanTranslation, Getter, MissingHandler, Params, PluralFunc, TranslationKey, Translations } from '@i18n-micro/types'
|
|
2
|
-
import { FormatService } from './format-service'
|
|
3
|
-
import { defaultPlural, interpolate } from './helpers'
|
|
4
|
-
import { type TranslationStorage, useTranslationHelper } from './translation'
|
|
5
|
-
|
|
6
|
-
export interface BaseI18nOptions {
|
|
7
|
-
storage?: TranslationStorage
|
|
8
|
-
plural?: PluralFunc
|
|
9
|
-
missingWarn?: boolean
|
|
10
|
-
missingHandler?: (locale: string, key: string, routeName: string) => void
|
|
11
|
-
getCustomMissingHandler?: () => MissingHandler | null
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Abstract base class for i18n adapters
|
|
16
|
-
*
|
|
17
|
-
* Contains all common translation logic (t, ts, tc, tn, td, tdr, has).
|
|
18
|
-
* Adapters must implement abstract methods to provide current state (locale, fallbackLocale, route).
|
|
19
|
-
*/
|
|
20
|
-
export abstract class BaseI18n {
|
|
21
|
-
// Public fields (made public to allow type export in Nuxt plugins)
|
|
22
|
-
public helper: ReturnType<typeof useTranslationHelper>
|
|
23
|
-
public formatter = new FormatService()
|
|
24
|
-
public pluralFunc: PluralFunc
|
|
25
|
-
public missingWarn: boolean
|
|
26
|
-
public missingHandler?: (locale: string, key: string, routeName: string) => void
|
|
27
|
-
public getCustomMissingHandler?: () => MissingHandler | null
|
|
28
|
-
|
|
29
|
-
constructor(options: BaseI18nOptions = {}) {
|
|
30
|
-
this.helper = useTranslationHelper(options.storage)
|
|
31
|
-
this.formatter = new FormatService()
|
|
32
|
-
this.pluralFunc = options.plural || defaultPlural
|
|
33
|
-
this.missingWarn = options.missingWarn ?? true
|
|
34
|
-
this.missingHandler = options.missingHandler
|
|
35
|
-
this.getCustomMissingHandler = options.getCustomMissingHandler
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// --- Abstract methods (must be implemented by subclasses) ---
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Get current locale
|
|
42
|
-
*/
|
|
43
|
-
public abstract getLocale(): string
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Get fallback locale
|
|
47
|
-
*/
|
|
48
|
-
public abstract getFallbackLocale(): string
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Get current route name
|
|
52
|
-
*/
|
|
53
|
-
public abstract getRoute(): string
|
|
54
|
-
|
|
55
|
-
// --- Public methods (implemented in base class) ---
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Get translation for a key
|
|
59
|
-
* Based on logic from src/runtime/plugins/01.plugin.ts
|
|
60
|
-
*/
|
|
61
|
-
public t(key: TranslationKey, params?: Params, defaultValue?: string | null, routeName?: string): CleanTranslation {
|
|
62
|
-
if (!key) return ''
|
|
63
|
-
|
|
64
|
-
// Use abstract getters to get current state
|
|
65
|
-
const locale = this.getLocale()
|
|
66
|
-
const route = routeName || this.getRoute()
|
|
67
|
-
|
|
68
|
-
// 1. Try to find translation in current locale
|
|
69
|
-
let value = this.helper.getTranslation<string>(locale, route, key)
|
|
70
|
-
|
|
71
|
-
// 2. Fallback to fallbackLocale if not found and different
|
|
72
|
-
if (!value) {
|
|
73
|
-
const fallbackLocale = this.getFallbackLocale()
|
|
74
|
-
if (locale !== fallbackLocale) {
|
|
75
|
-
value = this.helper.getTranslation<string>(fallbackLocale, route, key)
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// 3. Handle missing
|
|
80
|
-
if (!value) {
|
|
81
|
-
// Call custom handler if set (Nuxt runtime), otherwise use instance handler
|
|
82
|
-
const customHandler = this.getCustomMissingHandler?.()
|
|
83
|
-
if (customHandler) {
|
|
84
|
-
customHandler(locale, key, route)
|
|
85
|
-
} else if (this.missingHandler) {
|
|
86
|
-
this.missingHandler(locale, key as string, route)
|
|
87
|
-
} else if (this.missingWarn) {
|
|
88
|
-
const isDev = process.env.NODE_ENV !== 'production'
|
|
89
|
-
const isClient = typeof window !== 'undefined'
|
|
90
|
-
if (isDev && isClient) {
|
|
91
|
-
console.warn(`Not found '${key}' key in '${locale}' locale messages for route '${route}'.`)
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
value = defaultValue === undefined ? key : defaultValue || key
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// 4. Interpolate
|
|
98
|
-
return typeof value === 'string' && params ? interpolate(value, params) : (value as CleanTranslation)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Get translation as string
|
|
103
|
-
*/
|
|
104
|
-
public ts(key: TranslationKey, params?: Params, defaultValue?: string, routeName?: string): string {
|
|
105
|
-
const value = this.t(key, params, defaultValue, routeName)
|
|
106
|
-
return value?.toString() ?? defaultValue ?? key
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Plural translation
|
|
111
|
-
*/
|
|
112
|
-
public tc(key: TranslationKey, count: number | Params, defaultValue?: string): string {
|
|
113
|
-
const { count: countValue, ...params } = typeof count === 'number' ? { count } : count
|
|
114
|
-
|
|
115
|
-
if (countValue === undefined) {
|
|
116
|
-
return defaultValue ?? key
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Getter passed to plural function
|
|
120
|
-
const getter: Getter = (k: TranslationKey, p?: Params, dv?: string) => {
|
|
121
|
-
return this.t(k, p, dv)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const result = this.pluralFunc(key, Number.parseInt(countValue.toString(), 10), params, this.getLocale(), getter)
|
|
125
|
-
|
|
126
|
-
return result ?? defaultValue ?? key
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Format number
|
|
131
|
-
*/
|
|
132
|
-
public tn(value: number, options?: Intl.NumberFormatOptions): string {
|
|
133
|
-
return this.formatter.formatNumber(value, this.getLocale(), options)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Format date
|
|
138
|
-
*/
|
|
139
|
-
public td(value: Date | number | string, options?: Intl.DateTimeFormatOptions): string {
|
|
140
|
-
return this.formatter.formatDate(value, this.getLocale(), options)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Format relative time
|
|
145
|
-
*/
|
|
146
|
-
public tdr(value: Date | number | string, options?: Intl.RelativeTimeFormatOptions): string {
|
|
147
|
-
return this.formatter.formatRelativeTime(value, this.getLocale(), options)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Check if translation exists
|
|
152
|
-
* Based on logic from src/runtime/plugins/01.plugin.ts
|
|
153
|
-
*/
|
|
154
|
-
public has(key: TranslationKey, routeName?: string): boolean {
|
|
155
|
-
const route = routeName || this.getRoute()
|
|
156
|
-
const locale = this.getLocale()
|
|
157
|
-
|
|
158
|
-
// Check only through getTranslation (as in plugin)
|
|
159
|
-
return !!this.helper.getTranslation(locale, route, key)
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Clear cache
|
|
164
|
-
*/
|
|
165
|
-
public clearCache(): void {
|
|
166
|
-
this.helper.clearCache()
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// --- Public methods (for subclasses to use) ---
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Core translation loading logic (without reactivity)
|
|
173
|
-
* Subclasses can override addTranslations/addRouteTranslations to add reactivity
|
|
174
|
-
*/
|
|
175
|
-
public loadTranslationsCore(locale: string, translations: Translations, merge: boolean, routeName = 'index'): void {
|
|
176
|
-
if (merge) {
|
|
177
|
-
this.helper.mergeTranslation(locale, routeName, translations, true)
|
|
178
|
-
} else {
|
|
179
|
-
this.helper.setTranslations(locale, translations, routeName)
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Core route translation loading logic (without reactivity)
|
|
185
|
-
* Subclasses can override addRouteTranslations to add reactivity
|
|
186
|
-
*/
|
|
187
|
-
public loadRouteTranslationsCore(locale: string, routeName: string, translations: Translations, merge: boolean): void {
|
|
188
|
-
if (merge) {
|
|
189
|
-
this.helper.mergeTranslation(locale, routeName, translations, true)
|
|
190
|
-
} else {
|
|
191
|
-
this.helper.loadPageTranslations(locale, routeName, translations)
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
package/src/format-service.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
export class FormatService {
|
|
2
|
-
formatNumber(value: number, locale: string, options?: Intl.NumberFormatOptions): string {
|
|
3
|
-
return new Intl.NumberFormat(locale, options).format(value)
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
formatDate(value: Date | number | string, locale: string, options?: Intl.DateTimeFormatOptions): string {
|
|
7
|
-
const date = new Date(value)
|
|
8
|
-
if (Number.isNaN(date.getTime())) {
|
|
9
|
-
return 'Invalid Date'
|
|
10
|
-
}
|
|
11
|
-
return new Intl.DateTimeFormat(locale, options).format(date)
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
formatRelativeTime(value: Date | number | string, locale: string, options?: Intl.RelativeTimeFormatOptions): string {
|
|
15
|
-
const date = new Date(value)
|
|
16
|
-
if (Number.isNaN(date.getTime())) {
|
|
17
|
-
// Return "0 seconds ago" for invalid dates
|
|
18
|
-
return new Intl.RelativeTimeFormat(locale, options).format(0, 'second')
|
|
19
|
-
}
|
|
20
|
-
const now = new Date()
|
|
21
|
-
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
|
22
|
-
|
|
23
|
-
const units: { unit: Intl.RelativeTimeFormatUnit; seconds: number }[] = [
|
|
24
|
-
{ unit: 'year', seconds: 31536000 },
|
|
25
|
-
{ unit: 'month', seconds: 2592000 },
|
|
26
|
-
{ unit: 'day', seconds: 86400 },
|
|
27
|
-
{ unit: 'hour', seconds: 3600 },
|
|
28
|
-
{ unit: 'minute', seconds: 60 },
|
|
29
|
-
{ unit: 'second', seconds: 1 },
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
for (const { unit, seconds } of units) {
|
|
33
|
-
const diff = Math.floor(diffInSeconds / seconds)
|
|
34
|
-
if (diff >= 1) {
|
|
35
|
-
return new Intl.RelativeTimeFormat(locale, options).format(-diff, unit)
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return new Intl.RelativeTimeFormat(locale, options).format(0, 'second')
|
|
40
|
-
}
|
|
41
|
-
}
|
package/src/helpers.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import type { Getter, Params, PluralFunc, Strategies, TranslationKey } from '@i18n-micro/types'
|
|
2
|
-
|
|
3
|
-
const RE_TOKEN = /\{(\w+)\}/g
|
|
4
|
-
|
|
5
|
-
export function interpolate(template: string, params: Params): string {
|
|
6
|
-
if (!params) return template
|
|
7
|
-
|
|
8
|
-
return template.replace(RE_TOKEN, (_, key) => {
|
|
9
|
-
const value = params[key]
|
|
10
|
-
return value !== undefined ? String(value) : `{${key}}`
|
|
11
|
-
})
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function withPrefixStrategy(strategy: Strategies) {
|
|
15
|
-
return strategy === 'prefix' || strategy === 'prefix_and_default'
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function isNoPrefixStrategy(strategy: Strategies) {
|
|
19
|
-
return strategy === 'no_prefix'
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function isPrefixStrategy(strategy: Strategies) {
|
|
23
|
-
return strategy === 'prefix'
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function isPrefixExceptDefaultStrategy(strategy: Strategies) {
|
|
27
|
-
return strategy === 'prefix_except_default'
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function isPrefixAndDefaultStrategy(strategy: Strategies) {
|
|
31
|
-
return strategy === 'prefix_and_default'
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Default pluralization function
|
|
36
|
-
* Splits translation by '|' and selects form based on count
|
|
37
|
-
* @param key - Translation key
|
|
38
|
-
* @param count - Count for pluralization
|
|
39
|
-
* @param params - Parameters for translation
|
|
40
|
-
* @param _locale - Current locale (unused in default implementation)
|
|
41
|
-
* @param getTranslation - Function to get translation value
|
|
42
|
-
* @returns Selected plural form or null if not found
|
|
43
|
-
*/
|
|
44
|
-
export const defaultPlural: PluralFunc = (key: TranslationKey, count: number, params: Params, _locale: string, getTranslation: Getter) => {
|
|
45
|
-
const translation = getTranslation(key, params)
|
|
46
|
-
if (!translation) {
|
|
47
|
-
return null
|
|
48
|
-
}
|
|
49
|
-
const forms = translation.toString().split('|')
|
|
50
|
-
if (forms.length === 0) return null
|
|
51
|
-
const selectedForm = count < forms.length ? forms[count] : forms[forms.length - 1]
|
|
52
|
-
if (!selectedForm) return null
|
|
53
|
-
return selectedForm.trim().replace('{count}', count.toString())
|
|
54
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { BaseI18n, type BaseI18nOptions } from './base'
|
|
2
|
-
import { FormatService } from './format-service'
|
|
3
|
-
import {
|
|
4
|
-
defaultPlural,
|
|
5
|
-
interpolate,
|
|
6
|
-
isNoPrefixStrategy,
|
|
7
|
-
isPrefixAndDefaultStrategy,
|
|
8
|
-
isPrefixExceptDefaultStrategy,
|
|
9
|
-
isPrefixStrategy,
|
|
10
|
-
withPrefixStrategy,
|
|
11
|
-
} from './helpers'
|
|
12
|
-
import { type TranslationStorage, useTranslationHelper } from './translation'
|
|
13
|
-
|
|
14
|
-
export {
|
|
15
|
-
useTranslationHelper,
|
|
16
|
-
interpolate,
|
|
17
|
-
withPrefixStrategy,
|
|
18
|
-
isNoPrefixStrategy,
|
|
19
|
-
isPrefixStrategy,
|
|
20
|
-
isPrefixExceptDefaultStrategy,
|
|
21
|
-
isPrefixAndDefaultStrategy,
|
|
22
|
-
defaultPlural,
|
|
23
|
-
FormatService,
|
|
24
|
-
BaseI18n,
|
|
25
|
-
type TranslationStorage,
|
|
26
|
-
type BaseI18nOptions,
|
|
27
|
-
}
|
package/src/translation.ts
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import type { Translations } from '@i18n-micro/types'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Bare Metal: Simple translation storage without Ref, useState, devalue.
|
|
5
|
-
* Map key: `${locale}:${routeName}` (page-specific).
|
|
6
|
-
*/
|
|
7
|
-
export interface TranslationStorage {
|
|
8
|
-
translations: Map<string, Translations>
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function findValue<T = unknown>(data: Translations | null | undefined, key: string): T | null {
|
|
12
|
-
if (!data || typeof key !== 'string') return null
|
|
13
|
-
|
|
14
|
-
if (key in data) {
|
|
15
|
-
const value = data[key]
|
|
16
|
-
if (typeof value === 'object' && value !== null) {
|
|
17
|
-
return value as T
|
|
18
|
-
}
|
|
19
|
-
return value as T
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const parts = key.split('.')
|
|
23
|
-
let value: unknown = data
|
|
24
|
-
for (const part of parts) {
|
|
25
|
-
if (value && typeof value === 'object' && part in (value as object)) {
|
|
26
|
-
value = (value as Translations)[part]
|
|
27
|
-
} else {
|
|
28
|
-
return null
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return (value as T) ?? null
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function useTranslationHelper(storage?: TranslationStorage) {
|
|
35
|
-
const translations = storage?.translations ?? new Map<string, Translations>()
|
|
36
|
-
|
|
37
|
-
return {
|
|
38
|
-
hasCache(locale: string, page: string) {
|
|
39
|
-
const p = page || 'index'
|
|
40
|
-
return translations.has(`${locale}:${p}`)
|
|
41
|
-
},
|
|
42
|
-
getCache(locale: string, routeName: string) {
|
|
43
|
-
const rn = routeName || 'index'
|
|
44
|
-
return translations.get(`${locale}:${rn}`)
|
|
45
|
-
},
|
|
46
|
-
setCache(_locale: string, _routeName: string, _cache: Map<string, unknown>) {
|
|
47
|
-
// No-op for bare metal
|
|
48
|
-
},
|
|
49
|
-
hasTranslation(locale: string, key: string): boolean {
|
|
50
|
-
for (const [k, v] of translations) {
|
|
51
|
-
if (k.startsWith(`${locale}:`) && findValue(v, key) !== null) {
|
|
52
|
-
return true
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return false
|
|
56
|
-
},
|
|
57
|
-
hasPageTranslation(locale: string, routeName: string): boolean {
|
|
58
|
-
const rn = routeName || 'index'
|
|
59
|
-
return translations.has(`${locale}:${rn}`)
|
|
60
|
-
},
|
|
61
|
-
getTranslation<T = unknown>(locale: string, routeName: string, key: string): T | null {
|
|
62
|
-
const rn = routeName || 'index'
|
|
63
|
-
return findValue<T>(translations.get(`${locale}:${rn}`), key)
|
|
64
|
-
},
|
|
65
|
-
loadTranslations(locale: string, data: Translations, routeName = 'index'): void {
|
|
66
|
-
const rn = routeName || 'index'
|
|
67
|
-
const key = `${locale}:${rn}`
|
|
68
|
-
const existing = translations.get(key) ?? {}
|
|
69
|
-
translations.set(key, { ...existing, ...data })
|
|
70
|
-
},
|
|
71
|
-
setTranslations(locale: string, data: Translations, routeName = 'index'): void {
|
|
72
|
-
const rn = routeName || 'index'
|
|
73
|
-
translations.set(`${locale}:${rn}`, data)
|
|
74
|
-
},
|
|
75
|
-
loadPageTranslations(locale: string, routeName: string, data: Translations): void {
|
|
76
|
-
const rn = routeName || 'index'
|
|
77
|
-
const key = `${locale}:${rn}`
|
|
78
|
-
const existing = translations.get(key)
|
|
79
|
-
if (!existing || Object.keys(existing).length === 0) {
|
|
80
|
-
translations.set(key, data)
|
|
81
|
-
} else {
|
|
82
|
-
translations.set(key, { ...existing, ...data })
|
|
83
|
-
}
|
|
84
|
-
},
|
|
85
|
-
mergeTranslation(locale: string, routeName: string, newTranslations: Translations, _force = false): void {
|
|
86
|
-
const rn = routeName || 'index'
|
|
87
|
-
const key = `${locale}:${rn}`
|
|
88
|
-
const existing = translations.get(key) ?? {}
|
|
89
|
-
translations.set(key, { ...existing, ...newTranslations })
|
|
90
|
-
},
|
|
91
|
-
clearCache(): void {
|
|
92
|
-
translations.clear()
|
|
93
|
-
},
|
|
94
|
-
}
|
|
95
|
-
}
|
package/tests/base.test.ts
DELETED
|
@@ -1,464 +0,0 @@
|
|
|
1
|
-
import type { PluralFunc, Translations } from '@i18n-micro/types'
|
|
2
|
-
import { BaseI18n, type BaseI18nOptions } from '../src/base'
|
|
3
|
-
|
|
4
|
-
// Test implementation of BaseI18n
|
|
5
|
-
class TestI18n extends BaseI18n {
|
|
6
|
-
private _locale: string
|
|
7
|
-
private _fallbackLocale: string
|
|
8
|
-
private _route: string
|
|
9
|
-
|
|
10
|
-
constructor(locale: string, fallbackLocale: string, route: string, options?: BaseI18nOptions) {
|
|
11
|
-
super(options)
|
|
12
|
-
this._locale = locale
|
|
13
|
-
this._fallbackLocale = fallbackLocale
|
|
14
|
-
this._route = route
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
public getLocale(): string {
|
|
18
|
-
return this._locale
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
public getFallbackLocale(): string {
|
|
22
|
-
return this._fallbackLocale
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
public getRoute(): string {
|
|
26
|
-
return this._route
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
public setLocale(locale: string): void {
|
|
30
|
-
this._locale = locale
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
public setRoute(route: string): void {
|
|
34
|
-
this._route = route
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
describe('BaseI18n', () => {
|
|
39
|
-
describe('Constructor', () => {
|
|
40
|
-
test('should initialize with default options', () => {
|
|
41
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
42
|
-
expect(i18n.getLocale()).toBe('en')
|
|
43
|
-
expect(i18n.getFallbackLocale()).toBe('en')
|
|
44
|
-
expect(i18n.getRoute()).toBe('index')
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
test('should initialize with custom storage', () => {
|
|
48
|
-
const storage = { translations: new Map<string, Translations>() }
|
|
49
|
-
const i18n = new TestI18n('en', 'en', 'index', { storage })
|
|
50
|
-
expect(i18n).toBeDefined()
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
test('should initialize with custom plural function', () => {
|
|
54
|
-
const customPlural: PluralFunc = () => 'custom'
|
|
55
|
-
const i18n = new TestI18n('en', 'en', 'index', { plural: customPlural })
|
|
56
|
-
expect(i18n).toBeDefined()
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
test('should initialize with missingWarn option', () => {
|
|
60
|
-
const i18n = new TestI18n('en', 'en', 'index', { missingWarn: false })
|
|
61
|
-
expect(i18n).toBeDefined()
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
test('should initialize with missingHandler', () => {
|
|
65
|
-
const handler = jest.fn()
|
|
66
|
-
const i18n = new TestI18n('en', 'en', 'index', { missingHandler: handler })
|
|
67
|
-
expect(i18n).toBeDefined()
|
|
68
|
-
})
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
describe('t() method', () => {
|
|
72
|
-
test('should return empty string for empty key', () => {
|
|
73
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
74
|
-
expect(i18n.t('')).toBe('')
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
test('should return translation for existing key', async () => {
|
|
78
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
79
|
-
const translations: Translations = { greeting: 'Hello' }
|
|
80
|
-
i18n['helper'].loadTranslations('en', translations)
|
|
81
|
-
|
|
82
|
-
expect(i18n.t('greeting')).toBe('Hello')
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
test('should interpolate params in translation', async () => {
|
|
86
|
-
const storage = { translations: new Map<string, Translations>() }
|
|
87
|
-
const i18n = new TestI18n('en', 'en', 'index', { storage })
|
|
88
|
-
const translations: Translations = { greeting: 'Hello, {name}!' }
|
|
89
|
-
await i18n['helper'].loadTranslations('en', translations)
|
|
90
|
-
|
|
91
|
-
expect(i18n.t('greeting', { name: 'John' })).toBe('Hello, John!')
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
test('should use defaultValue when translation is missing', () => {
|
|
95
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
96
|
-
expect(i18n.t('missing.key', undefined, 'Default value')).toBe('Default value')
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
test('should return key when translation is missing and no defaultValue', () => {
|
|
100
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
101
|
-
expect(i18n.t('missing.key')).toBe('missing.key')
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
test('should fallback to fallbackLocale when translation is missing', async () => {
|
|
105
|
-
const storage = { translations: new Map<string, Translations>() }
|
|
106
|
-
const i18n = new TestI18n('en', 'fr', 'index', { storage })
|
|
107
|
-
const translations: Translations = { greeting: 'Bonjour' }
|
|
108
|
-
await i18n['helper'].loadTranslations('fr', translations)
|
|
109
|
-
|
|
110
|
-
expect(i18n.t('greeting')).toBe('Bonjour')
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
test('should use route-specific translation when routeName is provided', async () => {
|
|
114
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
115
|
-
const routeTranslations: Translations = { title: 'Route Title' }
|
|
116
|
-
await i18n['helper'].loadPageTranslations('en', 'about', routeTranslations)
|
|
117
|
-
|
|
118
|
-
expect(i18n.t('title', undefined, undefined, 'about')).toBe('Route Title')
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
test('should call missingHandler when translation is missing', () => {
|
|
122
|
-
const handler = jest.fn()
|
|
123
|
-
const i18n = new TestI18n('en', 'en', 'index', { missingHandler: handler })
|
|
124
|
-
|
|
125
|
-
i18n.t('missing.key')
|
|
126
|
-
|
|
127
|
-
expect(handler).toHaveBeenCalledWith('en', 'missing.key', 'index')
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
test('should call customMissingHandler when set (Nuxt runtime)', () => {
|
|
131
|
-
const customHandler = jest.fn()
|
|
132
|
-
const i18n = new TestI18n('en', 'en', 'index', {
|
|
133
|
-
getCustomMissingHandler: () => customHandler,
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
i18n.t('missing.key')
|
|
137
|
-
|
|
138
|
-
expect(customHandler).toHaveBeenCalledWith('en', 'missing.key', 'index')
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
test('should not warn when missingWarn is false', () => {
|
|
142
|
-
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
|
|
143
|
-
const i18n = new TestI18n('en', 'en', 'index', { missingWarn: false })
|
|
144
|
-
|
|
145
|
-
i18n.t('missing.key')
|
|
146
|
-
|
|
147
|
-
expect(consoleSpy).not.toHaveBeenCalled()
|
|
148
|
-
consoleSpy.mockRestore()
|
|
149
|
-
})
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
describe('ts() method', () => {
|
|
153
|
-
test('should return translation as string', async () => {
|
|
154
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
155
|
-
const translations: Translations = { greeting: 'Hello' }
|
|
156
|
-
i18n['helper'].loadTranslations('en', translations)
|
|
157
|
-
|
|
158
|
-
expect(i18n.ts('greeting')).toBe('Hello')
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
test('should return defaultValue when translation is missing', () => {
|
|
162
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
163
|
-
expect(i18n.ts('missing.key', undefined, 'Default')).toBe('Default')
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
test('should return key when translation is missing and no defaultValue', () => {
|
|
167
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
168
|
-
expect(i18n.ts('missing.key')).toBe('missing.key')
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
test('should convert non-string values to string', async () => {
|
|
172
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
173
|
-
const translations: Translations = { count: 42 }
|
|
174
|
-
i18n['helper'].loadTranslations('en', translations)
|
|
175
|
-
|
|
176
|
-
expect(i18n.ts('count')).toBe('42')
|
|
177
|
-
})
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
describe('tc() method', () => {
|
|
181
|
-
test('should return defaultValue when count is undefined', () => {
|
|
182
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
183
|
-
expect(i18n.tc('apples', { other: 'params' }, 'No count')).toBe('No count')
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
test('should use plural function with count', async () => {
|
|
187
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
188
|
-
const translations: Translations = { apples: 'apple|apples' }
|
|
189
|
-
await i18n['helper'].loadTranslations('en', translations)
|
|
190
|
-
|
|
191
|
-
// defaultPlural selects form by index: forms[count] or last form if count >= forms.length
|
|
192
|
-
// For 'apple|apples': forms[0]='apple', forms[1]='apples'
|
|
193
|
-
expect(i18n.tc('apples', 0)).toBe('apple')
|
|
194
|
-
expect(i18n.tc('apples', 1)).toBe('apples') // forms[1]
|
|
195
|
-
expect(i18n.tc('apples', 5)).toBe('apples') // last form
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
test('should handle count as number', async () => {
|
|
199
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
200
|
-
const translations: Translations = { apples: 'apple|apples' }
|
|
201
|
-
i18n['helper'].loadTranslations('en', translations)
|
|
202
|
-
|
|
203
|
-
expect(i18n.tc('apples', 2)).toBe('apples')
|
|
204
|
-
})
|
|
205
|
-
|
|
206
|
-
test('should handle count as Params object', async () => {
|
|
207
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
208
|
-
const translations: Translations = { apples: 'apple|apples' }
|
|
209
|
-
i18n['helper'].loadTranslations('en', translations)
|
|
210
|
-
|
|
211
|
-
expect(i18n.tc('apples', { count: 2, name: 'John' })).toBe('apples')
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
test('should return defaultValue when plural function returns null', () => {
|
|
215
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
216
|
-
// When translation is missing, t() returns key, which is passed to pluralFunc
|
|
217
|
-
// pluralFunc tries to process 'missing.key' as translation, but since it doesn't contain '|',
|
|
218
|
-
// it returns the key itself. So tc returns the key, not defaultValue.
|
|
219
|
-
// To test defaultValue, we need a case where pluralFunc actually returns null.
|
|
220
|
-
// This happens when translation exists but is empty or invalid.
|
|
221
|
-
expect(i18n.tc('missing.key', 1, 'Default')).toBe('missing.key')
|
|
222
|
-
})
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
describe('tn() method', () => {
|
|
226
|
-
test('should format number with default locale', () => {
|
|
227
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
228
|
-
const result = i18n.tn(1234.56)
|
|
229
|
-
expect(result).toMatch(/1[,.]234[.,]56/)
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
test('should format number with custom options', () => {
|
|
233
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
234
|
-
const result = i18n.tn(1234.56, { style: 'currency', currency: 'USD' })
|
|
235
|
-
expect(result).toContain('1,234.56')
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
test('should use current locale for formatting', () => {
|
|
239
|
-
const i18n = new TestI18n('ru', 'en', 'index')
|
|
240
|
-
const result = i18n.tn(1234.56)
|
|
241
|
-
// Russian locale uses different number formatting
|
|
242
|
-
expect(result).toBeDefined()
|
|
243
|
-
})
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
describe('td() method', () => {
|
|
247
|
-
test('should format date with default locale', () => {
|
|
248
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
249
|
-
const date = new Date('2024-01-15')
|
|
250
|
-
const result = i18n.td(date)
|
|
251
|
-
expect(result).toBeDefined()
|
|
252
|
-
expect(result).not.toBe('Invalid Date')
|
|
253
|
-
})
|
|
254
|
-
|
|
255
|
-
test('should format date with custom options', () => {
|
|
256
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
257
|
-
const date = new Date('2024-01-15')
|
|
258
|
-
const result = i18n.td(date, { year: 'numeric', month: 'long', day: 'numeric' })
|
|
259
|
-
expect(result).toContain('2024')
|
|
260
|
-
expect(result).toContain('January')
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
test('should handle date as number (timestamp)', () => {
|
|
264
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
265
|
-
const timestamp = new Date('2024-01-15').getTime()
|
|
266
|
-
const result = i18n.td(timestamp)
|
|
267
|
-
expect(result).toBeDefined()
|
|
268
|
-
expect(result).not.toBe('Invalid Date')
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
test('should handle date as string', () => {
|
|
272
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
273
|
-
const result = i18n.td('2024-01-15')
|
|
274
|
-
expect(result).toBeDefined()
|
|
275
|
-
expect(result).not.toBe('Invalid Date')
|
|
276
|
-
})
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
describe('tdr() method', () => {
|
|
280
|
-
test('should format relative time', () => {
|
|
281
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
282
|
-
const yesterday = new Date(Date.now() - 86400000)
|
|
283
|
-
const result = i18n.tdr(yesterday)
|
|
284
|
-
expect(result).toBeDefined()
|
|
285
|
-
expect(result).toMatch(/day|ago/i)
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
test('should format relative time with custom options', () => {
|
|
289
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
290
|
-
const yesterday = new Date(Date.now() - 86400000)
|
|
291
|
-
const result = i18n.tdr(yesterday, { numeric: 'always' })
|
|
292
|
-
expect(result).toBeDefined()
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
test('should handle invalid date gracefully', () => {
|
|
296
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
297
|
-
const invalidDate = new Date('invalid')
|
|
298
|
-
const result = i18n.tdr(invalidDate)
|
|
299
|
-
expect(result).toBeDefined()
|
|
300
|
-
})
|
|
301
|
-
})
|
|
302
|
-
|
|
303
|
-
describe('has() method', () => {
|
|
304
|
-
test('should return true when translation exists', async () => {
|
|
305
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
306
|
-
const translations: Translations = { greeting: 'Hello' }
|
|
307
|
-
i18n['helper'].loadTranslations('en', translations)
|
|
308
|
-
|
|
309
|
-
expect(i18n.has('greeting')).toBe(true)
|
|
310
|
-
})
|
|
311
|
-
|
|
312
|
-
test('should return false when translation does not exist', () => {
|
|
313
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
314
|
-
expect(i18n.has('missing.key')).toBe(false)
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
test('should check route-specific translation when routeName is provided', async () => {
|
|
318
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
319
|
-
const routeTranslations: Translations = { title: 'Route Title' }
|
|
320
|
-
await i18n['helper'].loadPageTranslations('en', 'about', routeTranslations)
|
|
321
|
-
|
|
322
|
-
expect(i18n.has('title', 'about')).toBe(true)
|
|
323
|
-
expect(i18n.has('title', 'index')).toBe(false)
|
|
324
|
-
})
|
|
325
|
-
|
|
326
|
-
test('should use current route when routeName is not provided', async () => {
|
|
327
|
-
const i18n = new TestI18n('en', 'en', 'about')
|
|
328
|
-
const routeTranslations: Translations = { title: 'Route Title' }
|
|
329
|
-
await i18n['helper'].loadPageTranslations('en', 'about', routeTranslations)
|
|
330
|
-
|
|
331
|
-
expect(i18n.has('title')).toBe(true)
|
|
332
|
-
})
|
|
333
|
-
})
|
|
334
|
-
|
|
335
|
-
describe('clearCache() method', () => {
|
|
336
|
-
test('should clear all translations from cache', async () => {
|
|
337
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
338
|
-
const translations: Translations = { greeting: 'Hello' }
|
|
339
|
-
i18n['helper'].loadTranslations('en', translations)
|
|
340
|
-
|
|
341
|
-
expect(i18n.has('greeting')).toBe(true)
|
|
342
|
-
|
|
343
|
-
i18n.clearCache()
|
|
344
|
-
|
|
345
|
-
expect(i18n.has('greeting')).toBe(false)
|
|
346
|
-
})
|
|
347
|
-
})
|
|
348
|
-
|
|
349
|
-
describe('loadTranslationsCore() method', () => {
|
|
350
|
-
test('should load translations when merge is false', async () => {
|
|
351
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
352
|
-
const translations: Translations = { greeting: 'Hello' }
|
|
353
|
-
|
|
354
|
-
i18n['loadTranslationsCore']('en', translations, false)
|
|
355
|
-
// Wait for async operation to complete
|
|
356
|
-
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
357
|
-
|
|
358
|
-
expect(i18n.has('greeting')).toBe(true)
|
|
359
|
-
})
|
|
360
|
-
|
|
361
|
-
test('should merge translations when merge is true', async () => {
|
|
362
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
363
|
-
const initial: Translations = { greeting: 'Hello' }
|
|
364
|
-
const additional: Translations = { farewell: 'Goodbye' }
|
|
365
|
-
|
|
366
|
-
await i18n['helper'].loadTranslations('en', initial)
|
|
367
|
-
i18n['loadTranslationsCore']('en', additional, true)
|
|
368
|
-
// Wait for async operation to complete
|
|
369
|
-
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
370
|
-
|
|
371
|
-
expect(i18n.has('greeting')).toBe(true)
|
|
372
|
-
expect(i18n.has('farewell')).toBe(true)
|
|
373
|
-
})
|
|
374
|
-
})
|
|
375
|
-
|
|
376
|
-
describe('loadRouteTranslationsCore() method', () => {
|
|
377
|
-
test('should load route translations when merge is false', async () => {
|
|
378
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
379
|
-
const translations: Translations = { title: 'Page Title' }
|
|
380
|
-
|
|
381
|
-
i18n['loadRouteTranslationsCore']('en', 'about', translations, false)
|
|
382
|
-
// Wait for async operation to complete
|
|
383
|
-
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
384
|
-
|
|
385
|
-
expect(i18n.has('title', 'about')).toBe(true)
|
|
386
|
-
})
|
|
387
|
-
|
|
388
|
-
test('should merge route translations when merge is true', async () => {
|
|
389
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
390
|
-
const initial: Translations = { title: 'Page Title' }
|
|
391
|
-
const additional: Translations = { description: 'Page Description' }
|
|
392
|
-
|
|
393
|
-
await i18n['helper'].loadPageTranslations('en', 'about', initial)
|
|
394
|
-
i18n['loadRouteTranslationsCore']('en', 'about', additional, true)
|
|
395
|
-
// Wait for async operation to complete
|
|
396
|
-
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
397
|
-
|
|
398
|
-
expect(i18n.has('title', 'about')).toBe(true)
|
|
399
|
-
expect(i18n.has('description', 'about')).toBe(true)
|
|
400
|
-
})
|
|
401
|
-
})
|
|
402
|
-
|
|
403
|
-
describe('Edge cases', () => {
|
|
404
|
-
test('should handle nested translation keys', async () => {
|
|
405
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
406
|
-
const translations: Translations = {
|
|
407
|
-
nested: {
|
|
408
|
-
deep: {
|
|
409
|
-
key: 'Nested value',
|
|
410
|
-
},
|
|
411
|
-
},
|
|
412
|
-
}
|
|
413
|
-
i18n['helper'].loadTranslations('en', translations)
|
|
414
|
-
|
|
415
|
-
expect(i18n.t('nested.deep.key')).toBe('Nested value')
|
|
416
|
-
})
|
|
417
|
-
|
|
418
|
-
test('should handle multiple params in interpolation', async () => {
|
|
419
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
420
|
-
const translations: Translations = {
|
|
421
|
-
message: 'Hello, {name}! You are {age} years old.',
|
|
422
|
-
}
|
|
423
|
-
i18n['helper'].loadTranslations('en', translations)
|
|
424
|
-
|
|
425
|
-
expect(i18n.t('message', { name: 'John', age: 30 })).toBe('Hello, John! You are 30 years old.')
|
|
426
|
-
})
|
|
427
|
-
|
|
428
|
-
test('should handle null defaultValue', () => {
|
|
429
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
430
|
-
expect(i18n.t('missing.key', undefined, null)).toBe('missing.key')
|
|
431
|
-
})
|
|
432
|
-
|
|
433
|
-
test('should handle empty string defaultValue', () => {
|
|
434
|
-
const i18n = new TestI18n('en', 'en', 'index')
|
|
435
|
-
// Empty string is falsy, so it will fall back to key (as per defaultValue || key logic)
|
|
436
|
-
expect(i18n.t('missing.key', undefined, '')).toBe('missing.key')
|
|
437
|
-
})
|
|
438
|
-
|
|
439
|
-
test('should handle route change', async () => {
|
|
440
|
-
const storage = { translations: new Map<string, Translations>() }
|
|
441
|
-
const i18n = new TestI18n('en', 'en', 'index', { storage })
|
|
442
|
-
const rootTranslations: Translations = { greeting: 'Hello' }
|
|
443
|
-
// Packages (vue, node, etc.) merge root into pages automatically.
|
|
444
|
-
// Core does not — so we simulate pre-merged data here.
|
|
445
|
-
const routeTranslations: Translations = { greeting: 'Hello', title: 'About Page' }
|
|
446
|
-
|
|
447
|
-
await i18n['helper'].loadTranslations('en', rootTranslations)
|
|
448
|
-
await i18n['helper'].loadPageTranslations('en', 'about', routeTranslations)
|
|
449
|
-
|
|
450
|
-
// Verify translations are loaded
|
|
451
|
-
expect(i18n.has('greeting')).toBe(true)
|
|
452
|
-
expect(i18n.has('title', 'about')).toBe(true)
|
|
453
|
-
|
|
454
|
-
// Check route-specific translation with explicit routeName
|
|
455
|
-
const titleValue = i18n.t('title', undefined, undefined, 'about')
|
|
456
|
-
expect(titleValue).toBe('About Page')
|
|
457
|
-
|
|
458
|
-
// Change route and check
|
|
459
|
-
i18n.setRoute('about')
|
|
460
|
-
expect(i18n.t('title')).toBe('About Page')
|
|
461
|
-
expect(i18n.t('greeting')).toBe('Hello')
|
|
462
|
-
})
|
|
463
|
-
})
|
|
464
|
-
})
|
package/tests/core.test.ts
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { interpolate, useTranslationHelper } 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('mergeTranslation with index routeName updates index translations', () => {
|
|
67
|
-
const helper = useTranslationHelper()
|
|
68
|
-
helper.loadTranslations('en', translations.en)
|
|
69
|
-
|
|
70
|
-
helper.mergeTranslation('en', 'index', { newKey: 'New value' })
|
|
71
|
-
expect(helper.getTranslation('en', 'index', 'newKey')).toBe('New 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
|
-
})
|
|
@@ -1,101 +0,0 @@
|
|
|
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
|
-
})
|
package/tests/helpers.test.ts
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
interpolate,
|
|
3
|
-
isNoPrefixStrategy,
|
|
4
|
-
isPrefixAndDefaultStrategy,
|
|
5
|
-
isPrefixExceptDefaultStrategy,
|
|
6
|
-
isPrefixStrategy,
|
|
7
|
-
withPrefixStrategy,
|
|
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
|
-
})
|
package/tsconfig.json
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2018",
|
|
4
|
-
"module": "esnext",
|
|
5
|
-
"moduleResolution": "node",
|
|
6
|
-
|
|
7
|
-
"strict": true,
|
|
8
|
-
"esModuleInterop": true,
|
|
9
|
-
"skipLibCheck": true,
|
|
10
|
-
"forceConsistentCasingInFileNames": true,
|
|
11
|
-
"outDir": "./dist",
|
|
12
|
-
"declaration": true,
|
|
13
|
-
"declarationDir": "./dist",
|
|
14
|
-
"sourceMap": true,
|
|
15
|
-
"rootDir": "./src",
|
|
16
|
-
"baseUrl": "./",
|
|
17
|
-
"paths": {
|
|
18
|
-
"*": ["node_modules/*", "src/types/*"]
|
|
19
|
-
},
|
|
20
|
-
"types": ["jest", "node"]
|
|
21
|
-
},
|
|
22
|
-
"include": ["src/**/*"],
|
|
23
|
-
"exclude": ["node_modules", "dist", "tests"]
|
|
24
|
-
}
|
package/vite.config.mts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
|
-
import { resolve } from 'node:path'
|
|
3
|
-
import { defineConfig } from 'vite'
|
|
4
|
-
import dts from 'vite-plugin-dts'
|
|
5
|
-
|
|
6
|
-
export default defineConfig({
|
|
7
|
-
build: {
|
|
8
|
-
lib: {
|
|
9
|
-
entry: resolve(__dirname, 'src/index.ts'),
|
|
10
|
-
name: '@i18n-micro/core',
|
|
11
|
-
formats: ['cjs', 'es'],
|
|
12
|
-
fileName: (format) => `index.${format === 'cjs' ? 'cjs' : 'mjs'}`,
|
|
13
|
-
},
|
|
14
|
-
rollupOptions: {
|
|
15
|
-
external: [],
|
|
16
|
-
output: {
|
|
17
|
-
exports: 'named',
|
|
18
|
-
},
|
|
19
|
-
},
|
|
20
|
-
},
|
|
21
|
-
plugins: [dts()],
|
|
22
|
-
})
|