@vobs/i18n 0.3.0 → 1.0.0
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 +1 -1
- package/README.md +58 -0
- package/package.json +12 -33
- package/src/index.test.ts +162 -0
- package/src/index.ts +460 -0
- package/dist/index.d.ts +0 -107
- package/dist/index.js +0 -337
package/LICENSE
CHANGED
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# @vobs/i18n
|
|
2
|
+
|
|
3
|
+
Reactive i18n context with nested message lookup, a locale fallback chain, and Intl-based formatting.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vobs/i18n
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createI18n } from '@vobs/i18n'
|
|
15
|
+
|
|
16
|
+
const i18n = createI18n({
|
|
17
|
+
defaultLocale: 'zh-CN',
|
|
18
|
+
fallbackLocale: 'en-US',
|
|
19
|
+
messages: {
|
|
20
|
+
'zh-CN': { common: { hello: '你好,{name}' } },
|
|
21
|
+
'en-US': { common: { cancel: 'Cancel' } }
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
i18n.t('common.hello', { name: 'Ada' }) // '你好,Ada'
|
|
26
|
+
i18n.t('common.cancel') // 'Cancel' — resolved through the fallback chain
|
|
27
|
+
i18n.setLocale('en-US')
|
|
28
|
+
i18n.formatDate(new Date(), 'short')
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## API
|
|
32
|
+
|
|
33
|
+
| Signature | Description |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `createI18n(options: I18nOptions): I18nContext` | Creates an i18n context; `defaultLocale` is required. |
|
|
36
|
+
| `i18n.locale: Signal<Locale>` | Reactive current locale. |
|
|
37
|
+
| `i18n.messages: Signal<LocaleMessages>` | Reactive message catalogs per locale. |
|
|
38
|
+
| `i18n.t(key: string, params?): string` | Translates a dotted key with `{param}` interpolation; returns the key itself when missing. |
|
|
39
|
+
| `i18n.setLocale(locale: Locale): void` | Switches the locale. |
|
|
40
|
+
| `i18n.setMessages(locale: Locale, messages: Messages): void` | Deep-merges messages into an existing catalog. |
|
|
41
|
+
| `i18n.loadLocale(locale: Locale, loader?): Promise<void>` | Loads a catalog lazily; concurrent calls for one locale share a single request. |
|
|
42
|
+
| `i18n.isLocaleLoaded(locale: Locale): boolean` | Whether the catalog is already loaded. |
|
|
43
|
+
| `i18n.formatDate(value, presetOrOptions?, options?): string` | Presets `short`, `medium`, `long`, `full`, `custom`; honors `options.timeZone`. |
|
|
44
|
+
| `i18n.formatNumber(value, presetOrOptions?): string` | Presets `decimal` and `percent`, or `Intl.NumberFormatOptions`. |
|
|
45
|
+
| `i18n.formatCurrency(value: number, currency: string, options?): string` | Currency formatting via `Intl`. |
|
|
46
|
+
| `i18n.formatRelativeTime(value, now?): string` | Relative time via `Intl.RelativeTimeFormat`. |
|
|
47
|
+
| `i18n.registerFormatter(name, formatter): () => void` | Registers a custom `{value, name, argument}` placeholder formatter. |
|
|
48
|
+
| `i18n.dehydrate()` / `i18n.hydrate(snapshot)` | Serializes and restores locale state for SSR. |
|
|
49
|
+
| `i18n.dispose(): void` | Disposes internal signals. |
|
|
50
|
+
| `i18nPlugin(options?): VobsPlugin` | Provides the context through `I18N_KEY`. |
|
|
51
|
+
| `useI18n(): I18nContext` | Injects the i18n context inside components. |
|
|
52
|
+
| `I18nBoundary(props?: I18nBoundaryProps): VobsNode` | Scopes children to a local locale while inheriting the parent catalogs. |
|
|
53
|
+
|
|
54
|
+
Key lookup walks a fallback chain: the current locale, its language-only part (`zh` from `zh-CN`), then `fallbackLocale` and its language part. Built-in placeholder formatters are `date`, `number`, `currency`, and `relativeTime`.
|
|
55
|
+
|
|
56
|
+
## Types
|
|
57
|
+
|
|
58
|
+
`Locale`, `Messages`, `MessageValue`, `LocaleMessages`, `I18nOptions`, `I18nContext`, `I18nPluginOptions`, `I18nBoundaryProps`, `I18nLocaleLoader`, `I18nFormatter`, `I18nDehydratedState`, `DatePreset`, `NumberPreset`
|
package/package.json
CHANGED
|
@@ -1,41 +1,20 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "@vobs/i18n",
|
|
3
|
-
"version": "0.3.0",
|
|
4
|
-
"description": "Instance-based translation and Intl formatting primitives for vobs.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"publishConfig": {
|
|
7
|
-
"access": "public"
|
|
8
|
-
},
|
|
9
2
|
"license": "MIT",
|
|
10
|
-
"author": "vobsjs",
|
|
11
|
-
"repository": {
|
|
12
|
-
"type": "git",
|
|
13
|
-
"url": "git+https://github.com/vobsjs/vobs.git",
|
|
14
|
-
"directory": "packages/features/i18n"
|
|
15
|
-
},
|
|
16
|
-
"bugs": {
|
|
17
|
-
"url": "https://github.com/vobsjs/vobs/issues"
|
|
18
|
-
},
|
|
19
|
-
"homepage": "https://github.com/vobsjs/vobs#readme",
|
|
20
|
-
"dependencies": {
|
|
21
|
-
"@vobs/reactivity": "0.3.0",
|
|
22
|
-
"@vobs/runtime-core": "0.3.0"
|
|
23
|
-
},
|
|
24
3
|
"files": [
|
|
25
|
-
"
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
26
7
|
],
|
|
8
|
+
"name": "@vobs/i18n",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "src/index.ts",
|
|
12
|
+
"types": "src/index.ts",
|
|
27
13
|
"exports": {
|
|
28
|
-
".":
|
|
29
|
-
"types": "./dist/index.d.ts",
|
|
30
|
-
"import": "./dist/index.js"
|
|
31
|
-
},
|
|
32
|
-
"./package.json": "./package.json"
|
|
14
|
+
".": "./src/index.ts"
|
|
33
15
|
},
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"sideEffects": false,
|
|
38
|
-
"engines": {
|
|
39
|
-
"node": ">=22.12.0"
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@vobs/reactivity": "1.0.0",
|
|
18
|
+
"@vobs/vobs": "1.0.0"
|
|
40
19
|
}
|
|
41
20
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createComponent,
|
|
4
|
+
createDOMRenderer,
|
|
5
|
+
createElement,
|
|
6
|
+
createText,
|
|
7
|
+
createVobs,
|
|
8
|
+
bindText,
|
|
9
|
+
insertBefore,
|
|
10
|
+
setRenderer
|
|
11
|
+
} from '@vobs/vobs'
|
|
12
|
+
import {
|
|
13
|
+
createI18n,
|
|
14
|
+
I18nBoundary,
|
|
15
|
+
I18N_KEY,
|
|
16
|
+
i18nPlugin,
|
|
17
|
+
useI18n
|
|
18
|
+
} from './index'
|
|
19
|
+
|
|
20
|
+
describe('@vobs/i18n', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
setRenderer(createDOMRenderer())
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('按嵌套 key 查找翻译,支持 fallback、缺失 key 和参数插值', () => {
|
|
26
|
+
const i18n = createI18n({
|
|
27
|
+
defaultLocale: 'zh-CN',
|
|
28
|
+
fallbackLocale: 'en-US',
|
|
29
|
+
messages: {
|
|
30
|
+
'zh-CN': { common: { hello: '你好,{name}' } },
|
|
31
|
+
'en-US': { common: { cancel: 'Cancel' } }
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
expect(i18n.t('common.hello', { name: 'Ada' })).toBe('你好,Ada')
|
|
36
|
+
expect(i18n.t('common.cancel')).toBe('Cancel')
|
|
37
|
+
expect(i18n.t('common.missing')).toBe('common.missing')
|
|
38
|
+
i18n.dispose()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('locale 变化会驱动使用 t 的节点更新', () => {
|
|
42
|
+
const i18n = createI18n({
|
|
43
|
+
defaultLocale: 'zh-CN',
|
|
44
|
+
messages: {
|
|
45
|
+
'zh-CN': { title: '中文' },
|
|
46
|
+
'en-US': { title: 'English' }
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
const container = document.createElement('div')
|
|
50
|
+
const app = createVobs({
|
|
51
|
+
render: () => {
|
|
52
|
+
const text = createText('')
|
|
53
|
+
bindText(text, () => i18n.t('title'))
|
|
54
|
+
return text
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
app.mount(container)
|
|
59
|
+
expect(container.textContent).toBe('中文')
|
|
60
|
+
i18n.setLocale('en-US')
|
|
61
|
+
app.update()
|
|
62
|
+
expect(container.textContent).toBe('English')
|
|
63
|
+
app.destroy()
|
|
64
|
+
i18n.dispose()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('提供日期、数字、货币和相对时间格式化', () => {
|
|
68
|
+
const i18n = createI18n({ defaultLocale: 'en-US', timeZone: 'UTC' })
|
|
69
|
+
|
|
70
|
+
expect(i18n.formatDate(new Date('2024-01-15T00:00:00Z'), 'short')).toContain('2024')
|
|
71
|
+
expect(i18n.formatNumber(0.85, 'percent')).toContain('85')
|
|
72
|
+
expect(i18n.formatCurrency(100, 'USD')).toContain('$')
|
|
73
|
+
expect(i18n.formatRelativeTime(Date.now() - 60 * 60 * 1000)).toContain('hour')
|
|
74
|
+
i18n.dispose()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('支持自定义 formatter 和运行时追加翻译', () => {
|
|
78
|
+
const i18n = createI18n({
|
|
79
|
+
defaultLocale: 'en-US',
|
|
80
|
+
messages: { 'en-US': { greeting: 'Hello {name, uppercase}' } }
|
|
81
|
+
})
|
|
82
|
+
const unregister = i18n.registerFormatter('uppercase', value => String(value).toUpperCase())
|
|
83
|
+
|
|
84
|
+
expect(i18n.t('greeting', { name: 'Ada' })).toBe('Hello ADA')
|
|
85
|
+
i18n.setMessages('en-US', { added: 'Added' })
|
|
86
|
+
expect(i18n.t('added')).toBe('Added')
|
|
87
|
+
unregister()
|
|
88
|
+
expect(i18n.t('greeting', { name: 'Ada' })).toBe('Hello Ada')
|
|
89
|
+
i18n.dispose()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('按需加载语言资源时去重请求并深度合并 namespace', async () => {
|
|
93
|
+
let calls = 0
|
|
94
|
+
const i18n = createI18n({
|
|
95
|
+
defaultLocale: 'en-US',
|
|
96
|
+
messages: { 'en-US': { common: { ok: 'OK' } } },
|
|
97
|
+
localeLoaders: {
|
|
98
|
+
'zh-CN': async () => {
|
|
99
|
+
calls++
|
|
100
|
+
return { common: { cancel: '取消' }, page: { title: '首页' } }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
await Promise.all([i18n.loadLocale('zh-CN'), i18n.loadLocale('zh-CN')])
|
|
105
|
+
expect(calls).toBe(1)
|
|
106
|
+
expect(i18n.isLocaleLoaded('zh-CN')).toBe(true)
|
|
107
|
+
i18n.setLocale('zh-CN')
|
|
108
|
+
expect(i18n.t('common.cancel')).toBe('取消')
|
|
109
|
+
expect(i18n.t('page.title')).toBe('首页')
|
|
110
|
+
i18n.dispose()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('拒绝没有 loader 或格式错误的语言资源', async () => {
|
|
114
|
+
const i18n = createI18n({ defaultLocale: 'en-US' })
|
|
115
|
+
await expect(i18n.loadLocale('zh-CN')).rejects.toThrow('没有可用的 loader')
|
|
116
|
+
await expect(i18n.loadLocale('zh-CN', async () => ({ broken: [] as unknown as string }))).rejects.toThrow('值无效')
|
|
117
|
+
i18n.dispose()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('i18nPlugin 注入共享上下文,I18nBoundary 的惰性 children 使用局部语言', () => {
|
|
121
|
+
const i18n = createI18n({
|
|
122
|
+
defaultLocale: 'zh-CN',
|
|
123
|
+
messages: {
|
|
124
|
+
'zh-CN': { title: '中文' },
|
|
125
|
+
'en-US': { title: 'English' }
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
function Child() {
|
|
129
|
+
return createText(useI18n().t('title'))
|
|
130
|
+
}
|
|
131
|
+
const container = document.createElement('div')
|
|
132
|
+
const app = createVobs({
|
|
133
|
+
render: () => {
|
|
134
|
+
const root = createElement('div')
|
|
135
|
+
const global = createText(useI18n().t('title'))
|
|
136
|
+
root.append(global)
|
|
137
|
+
const local = createComponent(I18nBoundary, {
|
|
138
|
+
locale: 'en-US',
|
|
139
|
+
children: () => createComponent(Child, {})
|
|
140
|
+
})
|
|
141
|
+
insertBefore(root, local, null)
|
|
142
|
+
return root
|
|
143
|
+
},
|
|
144
|
+
plugins: [i18nPlugin({ i18n })]
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
app.mount(container)
|
|
148
|
+
expect(container.textContent).toBe('中文English')
|
|
149
|
+
expect(container.querySelector('div')).toBeTruthy()
|
|
150
|
+
app.destroy()
|
|
151
|
+
i18n.dispose()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('没有安装插件时,useI18n 给出明确错误', () => {
|
|
155
|
+
const app = createVobs({ render: () => {
|
|
156
|
+
void useI18n()
|
|
157
|
+
return createText('')
|
|
158
|
+
}})
|
|
159
|
+
expect(() => app.mount(document.createElement('div'))).toThrow('i18nPlugin')
|
|
160
|
+
expect(I18N_KEY).toBeDefined()
|
|
161
|
+
})
|
|
162
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { getCurrentOwner, onDispose, state, type Signal } from '@vobs/reactivity'
|
|
2
|
+
import {
|
|
3
|
+
createFragment,
|
|
4
|
+
createInjectionKey,
|
|
5
|
+
inject,
|
|
6
|
+
insertBefore,
|
|
7
|
+
provide,
|
|
8
|
+
type InjectionKey,
|
|
9
|
+
type VobsNode,
|
|
10
|
+
type VobsPlugin
|
|
11
|
+
} from '@vobs/vobs'
|
|
12
|
+
|
|
13
|
+
export type Locale = string
|
|
14
|
+
export type MessageValue = string | Messages
|
|
15
|
+
|
|
16
|
+
export interface Messages {
|
|
17
|
+
readonly [key: string]: MessageValue
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type LocaleMessages = Readonly<Record<Locale, Messages>>
|
|
21
|
+
export type DatePreset = 'short' | 'medium' | 'long' | 'full' | 'custom'
|
|
22
|
+
export type NumberPreset = 'decimal' | 'percent'
|
|
23
|
+
export type I18nLocaleLoader = () => Promise<Messages>
|
|
24
|
+
|
|
25
|
+
export interface I18nDehydratedState {
|
|
26
|
+
readonly version: 1
|
|
27
|
+
readonly locale: Locale
|
|
28
|
+
readonly messages: LocaleMessages
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type I18nFormatter = (
|
|
32
|
+
value: unknown,
|
|
33
|
+
locale: Locale,
|
|
34
|
+
argument?: string
|
|
35
|
+
) => string
|
|
36
|
+
|
|
37
|
+
export interface I18nOptions {
|
|
38
|
+
readonly defaultLocale: Locale
|
|
39
|
+
readonly messages?: LocaleMessages
|
|
40
|
+
readonly localeLoaders?: Readonly<Record<Locale, I18nLocaleLoader>>
|
|
41
|
+
readonly fallbackLocale?: Locale
|
|
42
|
+
readonly timeZone?: string
|
|
43
|
+
readonly formatters?: Record<string, I18nFormatter>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface I18nContext {
|
|
47
|
+
readonly locale: Signal<Locale>
|
|
48
|
+
readonly messages: Signal<LocaleMessages>
|
|
49
|
+
readonly fallbackLocale: Locale | undefined
|
|
50
|
+
readonly timeZone: string | undefined
|
|
51
|
+
dehydrate(): I18nDehydratedState
|
|
52
|
+
hydrate(snapshot: unknown): void
|
|
53
|
+
setLocale(locale: Locale): void
|
|
54
|
+
setMessages(locale: Locale, messages: Messages): void
|
|
55
|
+
loadLocale(locale: Locale, loader?: I18nLocaleLoader): Promise<void>
|
|
56
|
+
isLocaleLoaded(locale: Locale): boolean
|
|
57
|
+
t(key: string, params?: Record<string, unknown>): string
|
|
58
|
+
formatDate(
|
|
59
|
+
value: Date | number,
|
|
60
|
+
presetOrOptions?: DatePreset | Intl.DateTimeFormatOptions,
|
|
61
|
+
options?: Intl.DateTimeFormatOptions
|
|
62
|
+
): string
|
|
63
|
+
formatNumber(value: number, presetOrOptions?: NumberPreset | Intl.NumberFormatOptions): string
|
|
64
|
+
formatCurrency(
|
|
65
|
+
value: number,
|
|
66
|
+
currency: string,
|
|
67
|
+
options?: Omit<Intl.NumberFormatOptions, 'currency' | 'style'>
|
|
68
|
+
): string
|
|
69
|
+
formatRelativeTime(value: Date | number, now?: Date | number): string
|
|
70
|
+
registerFormatter(name: string, formatter: I18nFormatter): () => void
|
|
71
|
+
dispose(): void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const I18N_KEY: InjectionKey<I18nContext> = createInjectionKey<I18nContext>('vobs.i18n')
|
|
75
|
+
|
|
76
|
+
export interface I18nPluginOptions extends Omit<I18nOptions, 'defaultLocale'> {
|
|
77
|
+
readonly defaultLocale?: Locale
|
|
78
|
+
readonly i18n?: I18nContext
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface I18nBoundaryProps {
|
|
82
|
+
readonly locale?: Locale
|
|
83
|
+
readonly children?: VobsNode | (() => VobsNode | null | undefined)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const DATE_PRESETS: Record<Exclude<DatePreset, 'custom'>, Intl.DateTimeFormatOptions> = {
|
|
87
|
+
short: {
|
|
88
|
+
year: 'numeric',
|
|
89
|
+
month: '2-digit',
|
|
90
|
+
day: '2-digit'
|
|
91
|
+
},
|
|
92
|
+
medium: {
|
|
93
|
+
year: 'numeric',
|
|
94
|
+
month: 'short',
|
|
95
|
+
day: 'numeric'
|
|
96
|
+
},
|
|
97
|
+
long: {
|
|
98
|
+
year: 'numeric',
|
|
99
|
+
month: 'long',
|
|
100
|
+
day: 'numeric',
|
|
101
|
+
weekday: 'long'
|
|
102
|
+
},
|
|
103
|
+
full: {
|
|
104
|
+
year: 'numeric',
|
|
105
|
+
month: 'long',
|
|
106
|
+
day: 'numeric',
|
|
107
|
+
weekday: 'long',
|
|
108
|
+
hour: '2-digit',
|
|
109
|
+
minute: '2-digit'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createI18n(options: I18nOptions): I18nContext {
|
|
114
|
+
const defaultLocale = validateLocale(options.defaultLocale)
|
|
115
|
+
const fallbackLocale = options.fallbackLocale === undefined
|
|
116
|
+
? defaultLocale
|
|
117
|
+
: validateLocale(options.fallbackLocale)
|
|
118
|
+
const initialMessages = validateLocaleMessages(options.messages ?? {})
|
|
119
|
+
const locale = state(defaultLocale)
|
|
120
|
+
const messages = state<LocaleMessages>({ ...initialMessages })
|
|
121
|
+
const formatters = new Map<string, I18nFormatter>(Object.entries(options.formatters ?? {}))
|
|
122
|
+
const loadedLocales = new Set(Object.keys(initialMessages))
|
|
123
|
+
const pendingLoads = new Map<string, Promise<void>>()
|
|
124
|
+
let disposed = false
|
|
125
|
+
|
|
126
|
+
const context: I18nContext = {
|
|
127
|
+
locale,
|
|
128
|
+
messages,
|
|
129
|
+
fallbackLocale,
|
|
130
|
+
timeZone: options.timeZone,
|
|
131
|
+
|
|
132
|
+
dehydrate(): I18nDehydratedState {
|
|
133
|
+
ensureActive()
|
|
134
|
+
return { version: 1, locale: locale.value, messages: messages.value }
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
hydrate(snapshot: unknown): void {
|
|
138
|
+
ensureActive()
|
|
139
|
+
const restored = parseDehydratedState(snapshot)
|
|
140
|
+
locale.value = restored.locale
|
|
141
|
+
messages.value = restored.messages
|
|
142
|
+
loadedLocales.clear()
|
|
143
|
+
for (const loadedLocale of Object.keys(restored.messages)) loadedLocales.add(loadedLocale)
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
setLocale(nextLocale: Locale): void {
|
|
147
|
+
ensureActive()
|
|
148
|
+
locale.value = validateLocale(nextLocale)
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
setMessages(nextLocale: Locale, nextMessages: Messages): void {
|
|
152
|
+
ensureActive()
|
|
153
|
+
const normalizedLocale = validateLocale(nextLocale)
|
|
154
|
+
const normalizedMessages = validateMessages(nextMessages)
|
|
155
|
+
messages.value = {
|
|
156
|
+
...messages.value,
|
|
157
|
+
[normalizedLocale]: mergeMessages(messages.value[normalizedLocale], normalizedMessages)
|
|
158
|
+
}
|
|
159
|
+
loadedLocales.add(normalizedLocale)
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
loadLocale(nextLocale: Locale, loader?: I18nLocaleLoader): Promise<void> {
|
|
163
|
+
ensureActive()
|
|
164
|
+
const normalizedLocale = validateLocale(nextLocale)
|
|
165
|
+
if (loadedLocales.has(normalizedLocale)) return Promise.resolve()
|
|
166
|
+
const pending = pendingLoads.get(normalizedLocale)
|
|
167
|
+
if (pending) return pending
|
|
168
|
+
const source = loader ?? options.localeLoaders?.[normalizedLocale]
|
|
169
|
+
if (!source) return Promise.reject(new Error(`Vobs I18n: locale ${normalizedLocale} 没有可用的 loader`))
|
|
170
|
+
const task = Promise.resolve()
|
|
171
|
+
.then(() => source())
|
|
172
|
+
.then(nextMessages => {
|
|
173
|
+
context.setMessages(normalizedLocale, nextMessages)
|
|
174
|
+
})
|
|
175
|
+
.finally(() => {
|
|
176
|
+
pendingLoads.delete(normalizedLocale)
|
|
177
|
+
})
|
|
178
|
+
pendingLoads.set(normalizedLocale, task)
|
|
179
|
+
return task
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
isLocaleLoaded(nextLocale: Locale): boolean {
|
|
183
|
+
ensureActive()
|
|
184
|
+
return loadedLocales.has(validateLocale(nextLocale))
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
t(key: string, params?: Record<string, unknown>): string {
|
|
188
|
+
ensureActive()
|
|
189
|
+
if (!key) return ''
|
|
190
|
+
const allMessages = messages.value
|
|
191
|
+
const template = findMessage(allMessages, locale.value, fallbackLocale, key)
|
|
192
|
+
if (template === undefined) return key
|
|
193
|
+
return interpolate(template, params, context, formatters)
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
formatDate(value, presetOrOptions, dateOptions): string {
|
|
197
|
+
ensureActive()
|
|
198
|
+
const date = toDate(value)
|
|
199
|
+
if (!date) return ''
|
|
200
|
+
const resolvedOptions = resolveDateOptions(presetOrOptions, dateOptions)
|
|
201
|
+
return new Intl.DateTimeFormat(locale.value, withTimeZone(resolvedOptions, options.timeZone)).format(date)
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
formatNumber(value, presetOrOptions): string {
|
|
205
|
+
ensureActive()
|
|
206
|
+
if (!Number.isFinite(value)) return ''
|
|
207
|
+
const numberOptions = typeof presetOrOptions === 'string'
|
|
208
|
+
? presetOrOptions === 'percent' ? { style: 'percent' as const } : {}
|
|
209
|
+
: presetOrOptions
|
|
210
|
+
return new Intl.NumberFormat(locale.value, numberOptions).format(value)
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
formatCurrency(value, currency, currencyOptions): string {
|
|
214
|
+
ensureActive()
|
|
215
|
+
if (!Number.isFinite(value)) return ''
|
|
216
|
+
if (!currency) throw new Error('Vobs I18n: currency 不能为空')
|
|
217
|
+
return new Intl.NumberFormat(locale.value, {
|
|
218
|
+
...currencyOptions,
|
|
219
|
+
style: 'currency',
|
|
220
|
+
currency
|
|
221
|
+
}).format(value)
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
formatRelativeTime(value, now = Date.now()): string {
|
|
225
|
+
ensureActive()
|
|
226
|
+
const target = toDate(value)?.getTime()
|
|
227
|
+
const current = toDate(now)?.getTime()
|
|
228
|
+
if (target === undefined || current === undefined) return ''
|
|
229
|
+
const difference = current - target
|
|
230
|
+
const units = [
|
|
231
|
+
['year', 365 * 24 * 60 * 60 * 1000],
|
|
232
|
+
['month', 30 * 24 * 60 * 60 * 1000],
|
|
233
|
+
['day', 24 * 60 * 60 * 1000],
|
|
234
|
+
['hour', 60 * 60 * 1000],
|
|
235
|
+
['minute', 60 * 1000],
|
|
236
|
+
['second', 1000]
|
|
237
|
+
] as const
|
|
238
|
+
for (const [unit, milliseconds] of units) {
|
|
239
|
+
const amount = Math.round(difference / milliseconds)
|
|
240
|
+
if (Math.abs(amount) >= 1) {
|
|
241
|
+
return new Intl.RelativeTimeFormat(locale.value, { numeric: 'auto' })
|
|
242
|
+
.format(-amount, unit)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return new Intl.RelativeTimeFormat(locale.value, { numeric: 'auto' }).format(0, 'second')
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
registerFormatter(name: string, formatter: I18nFormatter): () => void {
|
|
249
|
+
ensureActive()
|
|
250
|
+
if (!name) throw new Error('Vobs I18n: formatter 名称不能为空')
|
|
251
|
+
const previous = formatters.get(name)
|
|
252
|
+
formatters.set(name, formatter)
|
|
253
|
+
return () => {
|
|
254
|
+
if (previous) formatters.set(name, previous)
|
|
255
|
+
else formatters.delete(name)
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
dispose(): void {
|
|
260
|
+
if (disposed) return
|
|
261
|
+
disposed = true
|
|
262
|
+
locale.dispose()
|
|
263
|
+
messages.dispose()
|
|
264
|
+
formatters.clear()
|
|
265
|
+
pendingLoads.clear()
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (getCurrentOwner()) onDispose(context.dispose)
|
|
270
|
+
return context
|
|
271
|
+
|
|
272
|
+
function ensureActive(): void {
|
|
273
|
+
if (disposed) throw new Error('Vobs I18n: 已销毁的上下文不能继续使用')
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parseDehydratedState(snapshot: unknown): I18nDehydratedState {
|
|
278
|
+
let value: unknown = snapshot
|
|
279
|
+
if (typeof snapshot === 'string') {
|
|
280
|
+
try {
|
|
281
|
+
value = JSON.parse(snapshot)
|
|
282
|
+
} catch {
|
|
283
|
+
throw new Error('Vobs I18n: 初始状态不是有效 JSON')
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!value || typeof value !== 'object') throw new Error('Vobs I18n: 初始状态格式无效')
|
|
287
|
+
const candidate = value as Partial<I18nDehydratedState>
|
|
288
|
+
if (candidate.version !== 1 || typeof candidate.locale !== 'string'
|
|
289
|
+
|| !candidate.messages || typeof candidate.messages !== 'object') {
|
|
290
|
+
throw new Error('Vobs I18n: 初始状态版本或字段无效')
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
version: 1,
|
|
294
|
+
locale: validateLocale(candidate.locale),
|
|
295
|
+
messages: validateLocaleMessages(candidate.messages)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function i18nPlugin(options: I18nPluginOptions = {}): VobsPlugin {
|
|
300
|
+
return {
|
|
301
|
+
name: '@vobs/i18n',
|
|
302
|
+
version: '0.1.0',
|
|
303
|
+
install(context) {
|
|
304
|
+
const ownedI18n = options.i18n ? undefined : createI18n({
|
|
305
|
+
defaultLocale: options.defaultLocale ?? 'en-US',
|
|
306
|
+
messages: options.messages,
|
|
307
|
+
localeLoaders: options.localeLoaders,
|
|
308
|
+
fallbackLocale: options.fallbackLocale,
|
|
309
|
+
timeZone: options.timeZone,
|
|
310
|
+
formatters: options.formatters
|
|
311
|
+
})
|
|
312
|
+
const i18n = options.i18n ?? ownedI18n!
|
|
313
|
+
context.provide(I18N_KEY, i18n)
|
|
314
|
+
return () => ownedI18n?.dispose()
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function useI18n(): I18nContext {
|
|
320
|
+
const i18n = inject(I18N_KEY)
|
|
321
|
+
if (!i18n) throw new Error('Vobs I18n: 找不到上下文,请安装 i18nPlugin')
|
|
322
|
+
return i18n
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function I18nBoundary(props: I18nBoundaryProps = {}): VobsNode {
|
|
326
|
+
const parent = useI18n()
|
|
327
|
+
const local = createI18n({
|
|
328
|
+
defaultLocale: props.locale ?? parent.locale.value,
|
|
329
|
+
messages: parent.messages.value,
|
|
330
|
+
fallbackLocale: parent.fallbackLocale,
|
|
331
|
+
timeZone: parent.timeZone
|
|
332
|
+
})
|
|
333
|
+
provide(I18N_KEY, local)
|
|
334
|
+
|
|
335
|
+
return createFragment((parentNode, anchor) => {
|
|
336
|
+
const child = typeof props.children === 'function' ? props.children() : props.children
|
|
337
|
+
if (child) insertBefore(parentNode, child, anchor)
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function findMessage(
|
|
342
|
+
allMessages: LocaleMessages,
|
|
343
|
+
locale: Locale,
|
|
344
|
+
fallbackLocale: Locale | undefined,
|
|
345
|
+
key: string
|
|
346
|
+
): string | undefined {
|
|
347
|
+
const locales = unique([
|
|
348
|
+
locale,
|
|
349
|
+
locale.split('-')[0],
|
|
350
|
+
fallbackLocale,
|
|
351
|
+
fallbackLocale?.split('-')[0]
|
|
352
|
+
])
|
|
353
|
+
for (const candidate of locales) {
|
|
354
|
+
const message = getMessage(allMessages[candidate], key)
|
|
355
|
+
if (message !== undefined) return message
|
|
356
|
+
}
|
|
357
|
+
return undefined
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function getMessage(messages: Messages | undefined, key: string): string | undefined {
|
|
361
|
+
if (!messages) return undefined
|
|
362
|
+
const direct = messages[key]
|
|
363
|
+
if (typeof direct === 'string') return direct
|
|
364
|
+
|
|
365
|
+
let current: MessageValue | undefined = messages
|
|
366
|
+
for (const segment of key.split('.')) {
|
|
367
|
+
if (!isMessages(current)) return undefined
|
|
368
|
+
current = current[segment]
|
|
369
|
+
}
|
|
370
|
+
return typeof current === 'string' ? current : undefined
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function interpolate(
|
|
374
|
+
template: string,
|
|
375
|
+
params: Record<string, unknown> | undefined,
|
|
376
|
+
context: I18nContext,
|
|
377
|
+
formatters: Map<string, I18nFormatter>
|
|
378
|
+
): string {
|
|
379
|
+
if (!params) return template
|
|
380
|
+
return template.replace(/\{([\w.-]+)(?:,\s*([\w-]+)(?:,\s*([^}]+))?)?\}/g,
|
|
381
|
+
(match, key: string, formatter?: string, argument?: string) => {
|
|
382
|
+
if (!(key in params)) return match
|
|
383
|
+
const value = params[key]
|
|
384
|
+
if (!formatter) return String(value)
|
|
385
|
+
if (formatter === 'date') return context.formatDate(toDate(value) ?? Number.NaN, argument as DatePreset | undefined)
|
|
386
|
+
if (formatter === 'number') return context.formatNumber(Number(value))
|
|
387
|
+
if (formatter === 'currency') return context.formatCurrency(Number(value), argument ?? 'USD')
|
|
388
|
+
if (formatter === 'relativeTime') return context.formatRelativeTime(toDate(value) ?? Number.NaN)
|
|
389
|
+
const custom = formatters.get(formatter)
|
|
390
|
+
return custom ? custom(value, context.locale.value, argument) : String(value)
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function toDate(value: Date | number | unknown): Date | undefined {
|
|
395
|
+
const date = value instanceof Date ? new Date(value.getTime()) : typeof value === 'number' ? new Date(value) : undefined
|
|
396
|
+
return date && Number.isFinite(date.getTime()) ? date : undefined
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function resolveDateOptions(
|
|
400
|
+
presetOrOptions: DatePreset | Intl.DateTimeFormatOptions | undefined,
|
|
401
|
+
options: Intl.DateTimeFormatOptions | undefined
|
|
402
|
+
): Intl.DateTimeFormatOptions {
|
|
403
|
+
if (presetOrOptions === undefined) return DATE_PRESETS.medium
|
|
404
|
+
if (typeof presetOrOptions === 'object') return presetOrOptions
|
|
405
|
+
if (presetOrOptions === 'custom') return options ?? {}
|
|
406
|
+
return { ...DATE_PRESETS[presetOrOptions], ...options }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function withTimeZone(options: Intl.DateTimeFormatOptions, timeZone: string | undefined): Intl.DateTimeFormatOptions {
|
|
410
|
+
return timeZone && options.timeZone === undefined ? { ...options, timeZone } : options
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function validateLocale(locale: Locale): Locale {
|
|
414
|
+
if (typeof locale !== 'string' || !locale.trim()) throw new Error('Vobs I18n: locale 不能为空')
|
|
415
|
+
return locale
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function validateMessages(value: Messages): Messages {
|
|
419
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
420
|
+
throw new Error('Vobs I18n: messages 必须是对象')
|
|
421
|
+
}
|
|
422
|
+
for (const [key, message] of Object.entries(value)) {
|
|
423
|
+
if (typeof message === 'string') continue
|
|
424
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
425
|
+
throw new Error(`Vobs I18n: 翻译字段 ${key} 的值无效`)
|
|
426
|
+
}
|
|
427
|
+
validateMessages(message)
|
|
428
|
+
}
|
|
429
|
+
return value
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function validateLocaleMessages(value: LocaleMessages): LocaleMessages {
|
|
433
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
434
|
+
throw new Error('Vobs I18n: locale messages 必须是对象')
|
|
435
|
+
}
|
|
436
|
+
for (const [locale, messages] of Object.entries(value)) {
|
|
437
|
+
validateLocale(locale)
|
|
438
|
+
validateMessages(messages)
|
|
439
|
+
}
|
|
440
|
+
return value
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function mergeMessages(parent: Messages | undefined, next: Messages): Messages {
|
|
444
|
+
const merged: Record<string, MessageValue> = { ...(parent ?? {}) }
|
|
445
|
+
for (const [key, value] of Object.entries(next)) {
|
|
446
|
+
const previous = merged[key]
|
|
447
|
+
merged[key] = typeof value === 'object' && typeof previous === 'object'
|
|
448
|
+
? mergeMessages(previous, value)
|
|
449
|
+
: value
|
|
450
|
+
}
|
|
451
|
+
return merged
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function isMessages(value: MessageValue | undefined): value is Messages {
|
|
455
|
+
return Boolean(value) && typeof value === 'object'
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function unique(values: Array<string | undefined>): string[] {
|
|
459
|
+
return [...new Set(values.filter((value): value is string => value !== undefined))]
|
|
460
|
+
}
|
package/dist/index.d.ts
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
/** @license MIT
|
|
2
|
-
* Copyright (c) 2026 vobsjs
|
|
3
|
-
* @vobs/i18n
|
|
4
|
-
*/
|
|
5
|
-
import { type WritableSignal } from '@vobs/reactivity';
|
|
6
|
-
import { type InjectionKey, type Owner } from '@vobs/runtime-core';
|
|
7
|
-
export type LocaleCode = string;
|
|
8
|
-
export type MessageValue = string;
|
|
9
|
-
export type MessageParams = Readonly<Record<string, string | number | boolean | null | undefined>>;
|
|
10
|
-
export type LocaleMessages = Readonly<Record<string, MessageValue>>;
|
|
11
|
-
export type LocaleMessageCatalog = Readonly<Record<LocaleCode, LocaleMessages | undefined>>;
|
|
12
|
-
/** DI injection key: provide(I18nKey, i18n) at app assembly; components/pages consume via useI18n (resolved along the InjectionScope parent chain). */
|
|
13
|
-
export declare const I18nKey: InjectionKey<I18nInstance>;
|
|
14
|
-
/** The injection capability subset useI18n needs (structurally compatible with ComponentSetupContext/PageSetupContext provide/maybeInject). */
|
|
15
|
-
export interface I18nInjectionHost {
|
|
16
|
-
provide<T>(key: InjectionKey<T>, value: T): () => void;
|
|
17
|
-
maybeInject<T>(key: InjectionKey<T>): T | undefined;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* App assembly: provides the I18n instance into the injection chain in root component/page setup;
|
|
21
|
-
* child components get it via useI18n along the InjectionScope parent chain (multiple instances can coexist, isolated by assembly level).
|
|
22
|
-
*/
|
|
23
|
-
export declare function provideI18n(context: I18nInjectionHost, instance: I18nInstance): () => void;
|
|
24
|
-
/**
|
|
25
|
-
* Gets the I18n instance in a component/page (composition API):
|
|
26
|
-
* - component setup: `const i18n = useI18n(context)`, context is ComponentSetupContext
|
|
27
|
-
* - page setup: `const i18n = useI18n(context)`, context is PageSetupContext
|
|
28
|
-
* - the app must provideI18n(context, i18n) at the root; returns undefined when not injected, the caller decides the fallback (e.g. show the raw key).
|
|
29
|
-
*/
|
|
30
|
-
export declare function useI18n<Schema extends LocaleCatalogSchema = LocaleCatalogSchema>(context: I18nInjectionHost): I18nInstance<Schema> | undefined;
|
|
31
|
-
/** Nested message schema: values are either strings (leaves) or nested objects (`menu.file.save` dotted paths). */
|
|
32
|
-
export interface LocaleMessageSchema {
|
|
33
|
-
readonly [key: string]: MessageValue | LocaleMessageSchema;
|
|
34
|
-
}
|
|
35
|
-
/** Nested schema organized by locale (for defineMessages/createI18n generic inference). */
|
|
36
|
-
export type LocaleCatalogSchema = Readonly<Record<LocaleCode, LocaleMessageSchema | undefined>>;
|
|
37
|
-
/** Decrements recursion depth (P0-C: prevents deep schemas from triggering TS combinatorial explosion/depth limits). Any N returns a decreased value; recursion stops at zero. */
|
|
38
|
-
type DecrementDepth<N extends number> = N extends 0 | 1 ? 0 : N extends 2 ? 1 : N extends 3 ? 2 : N extends 4 ? 3 : N extends 5 ? 4 : N extends 6 ? 5 : N extends 7 ? 6 : 7;
|
|
39
|
-
/**
|
|
40
|
-
* Derives all leaf keys of a nested schema (dotted-path union): `{ menu: { file: { save } }, hello }` → `'menu.file.save' | 'hello'`.
|
|
41
|
-
* `Depth` (default 5) caps recursion; subtrees degrade to `string` at zero — deep schemas do not hit TS depth limits/union
|
|
42
|
-
* explosion, and top-level type safety is unaffected (P0-C).
|
|
43
|
-
*/
|
|
44
|
-
export type MessageKey<Schema extends LocaleMessageSchema, Depth extends number = 5> = {
|
|
45
|
-
readonly [Key in keyof Schema & string]: Schema[Key] extends MessageValue ? Key : Schema[Key] extends LocaleMessageSchema ? Depth extends 0 ? string : `${Key}.${MessageKey<Schema[Key], DecrementDepth<Depth>>}` : never;
|
|
46
|
-
}[keyof Schema & string];
|
|
47
|
-
/** Derives the message key union from a catalog schema (takes the first locale's schema). Depth is passed through to MessageKey (P0-C). */
|
|
48
|
-
export type CatalogMessageKey<Schema extends LocaleCatalogSchema, Depth extends number = 5> = Schema[keyof Schema] extends LocaleMessageSchema ? MessageKey<Schema[keyof Schema], Depth> : string;
|
|
49
|
-
/** Declares a message schema type-safely: keeps the nested structure for createI18n's key union inference. */
|
|
50
|
-
export declare function defineMessages<const Schema extends LocaleMessageSchema>(messages: Schema): Schema;
|
|
51
|
-
/** Missing level: 'fallback' = missing in the active locale but the fallback hit (translation fell back); 'all' = missing from every catalog. */
|
|
52
|
-
export type MissingMessageLevel = 'fallback' | 'all';
|
|
53
|
-
/** Diagnostic data for the missing handler: distinguishes missing levels to locate catalog configuration issues. */
|
|
54
|
-
export interface MissingMessageInfo {
|
|
55
|
-
/** Missing level (see MissingMessageLevel). */
|
|
56
|
-
readonly level: MissingMessageLevel;
|
|
57
|
-
/** Configured fallback locale (undefined when not configured). */
|
|
58
|
-
readonly fallbackLocale?: LocaleCode;
|
|
59
|
-
}
|
|
60
|
-
export interface I18nOptions {
|
|
61
|
-
readonly locale: LocaleCode;
|
|
62
|
-
readonly fallbackLocale?: LocaleCode;
|
|
63
|
-
/**
|
|
64
|
-
* Message catalog: flat or nested (dotted paths); nested catalogs are flattened at creation.
|
|
65
|
-
* Broadly typed (LocaleCatalogSchema values may nest) — type safety comes from explicit `createI18n<typeof messages>`.
|
|
66
|
-
*/
|
|
67
|
-
readonly messages?: LocaleCatalogSchema;
|
|
68
|
-
/**
|
|
69
|
-
* Missing-key handler (default console.warn + return the key).
|
|
70
|
-
* The third arg info.level distinguishes: 'fallback' (missing in the active locale, translation fell back, return value ignored),
|
|
71
|
-
* 'all' (missing from every catalog, return value is used as display text).
|
|
72
|
-
*/
|
|
73
|
-
readonly missing?: (key: string, locale: LocaleCode, info?: MissingMessageInfo) => string;
|
|
74
|
-
/** Owner the I18n instance belongs to (default currentOwner); the instance is released on Owner disposal. */
|
|
75
|
-
readonly owner?: Owner;
|
|
76
|
-
}
|
|
77
|
-
export interface I18nInstance<Schema extends LocaleCatalogSchema = LocaleCatalogSchema> {
|
|
78
|
-
readonly locale: WritableSignal<LocaleCode>;
|
|
79
|
-
readonly fallbackLocale: LocaleCode | undefined;
|
|
80
|
-
/** Flattened catalog (dotted-path keys). */
|
|
81
|
-
readonly messages: LocaleMessageCatalog;
|
|
82
|
-
/** Translate: key is constrained by the schema type (nested keys use dotted paths). */
|
|
83
|
-
t<Key extends CatalogMessageKey<Schema>>(key: Key, params?: MessageParams): string;
|
|
84
|
-
setLocale(locale: LocaleCode): void;
|
|
85
|
-
addMessages(locale: LocaleCode, messages: LocaleMessageSchema): void;
|
|
86
|
-
hasMessage(key: string, locale?: LocaleCode): boolean;
|
|
87
|
-
formatDate(value: Date | number | string, options?: Intl.DateTimeFormatOptions): string;
|
|
88
|
-
formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
|
|
89
|
-
formatCurrency(value: number, currency: string, options?: Intl.NumberFormatOptions): string;
|
|
90
|
-
formatRelativeTime(value: number, unit: Intl.RelativeTimeFormatUnit): string;
|
|
91
|
-
/** Releases the instance (idempotent). Called automatically on Owner disposal. */
|
|
92
|
-
dispose(): void;
|
|
93
|
-
}
|
|
94
|
-
export interface SerializedI18nState {
|
|
95
|
-
readonly locale: LocaleCode;
|
|
96
|
-
readonly fallbackLocale?: LocaleCode;
|
|
97
|
-
readonly messages: LocaleMessageCatalog;
|
|
98
|
-
}
|
|
99
|
-
export declare function createI18n<const Schema extends LocaleCatalogSchema = LocaleCatalogSchema>(options: I18nOptions): I18nInstance<Schema>;
|
|
100
|
-
export declare function serializeI18nState(instance: I18nInstance): SerializedI18nState;
|
|
101
|
-
/**
|
|
102
|
-
* Symmetrically rebuilds an I18n instance from serialized state (contract §4):
|
|
103
|
-
* serialize and hydrate are a pair — SSR serializes, and before client hydration this rebuilds,
|
|
104
|
-
* keeping first-screen translations identical to the SSR output (see the Security contract's SSR state boundary).
|
|
105
|
-
*/
|
|
106
|
-
export declare function createI18nFromState(state: SerializedI18nState, options?: Pick<I18nOptions, 'missing' | 'owner'>): I18nInstance;
|
|
107
|
-
export {};
|
package/dist/index.js
DELETED
|
@@ -1,337 +0,0 @@
|
|
|
1
|
-
/** @license MIT
|
|
2
|
-
* Copyright (c) 2026 vobsjs
|
|
3
|
-
* @vobs/i18n
|
|
4
|
-
*/
|
|
5
|
-
import { signal } from '@vobs/reactivity';
|
|
6
|
-
import { createInjectionKey, currentOwner, TranslationAdapterKey, } from '@vobs/runtime-core';
|
|
7
|
-
/** DI injection key: provide(I18nKey, i18n) at app assembly; components/pages consume via useI18n (resolved along the InjectionScope parent chain). */
|
|
8
|
-
export const I18nKey = createInjectionKey('vobs.i18n');
|
|
9
|
-
/**
|
|
10
|
-
* App assembly: provides the I18n instance into the injection chain in root component/page setup;
|
|
11
|
-
* child components get it via useI18n along the InjectionScope parent chain (multiple instances can coexist, isolated by assembly level).
|
|
12
|
-
*/
|
|
13
|
-
export function provideI18n(context, instance) {
|
|
14
|
-
const undoI18n = context.provide(I18nKey, instance);
|
|
15
|
-
// Register the TranslationAdapter port: template {{@key}} / :attr="@key" is translated through it (overridable by a user-defined adapter)
|
|
16
|
-
const undoAdapter = context.provide(TranslationAdapterKey, {
|
|
17
|
-
t: (key, params) => instance.t(key, params),
|
|
18
|
-
locale: instance.locale,
|
|
19
|
-
});
|
|
20
|
-
return () => {
|
|
21
|
-
undoI18n();
|
|
22
|
-
undoAdapter();
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Gets the I18n instance in a component/page (composition API):
|
|
27
|
-
* - component setup: `const i18n = useI18n(context)`, context is ComponentSetupContext
|
|
28
|
-
* - page setup: `const i18n = useI18n(context)`, context is PageSetupContext
|
|
29
|
-
* - the app must provideI18n(context, i18n) at the root; returns undefined when not injected, the caller decides the fallback (e.g. show the raw key).
|
|
30
|
-
*/
|
|
31
|
-
export function useI18n(context) {
|
|
32
|
-
return context.maybeInject(I18nKey);
|
|
33
|
-
}
|
|
34
|
-
/** Declares a message schema type-safely: keeps the nested structure for createI18n's key union inference. */
|
|
35
|
-
export function defineMessages(messages) {
|
|
36
|
-
return messages;
|
|
37
|
-
}
|
|
38
|
-
/** Flattens a nested schema into a dotted-path flat map (internal storage/lookup shape). */
|
|
39
|
-
function flattenMessages(schema, prefix = '') {
|
|
40
|
-
const flattened = {};
|
|
41
|
-
for (const [key, value] of Object.entries(schema)) {
|
|
42
|
-
const path = prefix === '' ? key : `${prefix}.${key}`;
|
|
43
|
-
if (typeof value === 'string') {
|
|
44
|
-
flattened[path] = value;
|
|
45
|
-
}
|
|
46
|
-
else {
|
|
47
|
-
Object.assign(flattened, flattenMessages(value, path));
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return flattened;
|
|
51
|
-
}
|
|
52
|
-
export function createI18n(options) {
|
|
53
|
-
const locale = signal(options.locale);
|
|
54
|
-
const catalogVersion = signal(0);
|
|
55
|
-
const catalogs = {};
|
|
56
|
-
for (const [messageLocale, catalog] of Object.entries(options.messages ?? {})) {
|
|
57
|
-
if (catalog !== undefined && catalog !== null && typeof catalog === 'object') {
|
|
58
|
-
catalogs[messageLocale] = flattenMessages(catalog);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
const missing = options.missing ??
|
|
62
|
-
((key, locale, info) => {
|
|
63
|
-
if (typeof console !== 'undefined') {
|
|
64
|
-
if (info?.level === 'fallback') {
|
|
65
|
-
console.warn(`[vobs i18n] Missing message: "${key}" in locale "${locale}" (fallback "${info.fallbackLocale ?? ''}")`);
|
|
66
|
-
}
|
|
67
|
-
else {
|
|
68
|
-
console.warn(`[vobs i18n] Missing message: "${key}"`);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return key;
|
|
72
|
-
});
|
|
73
|
-
const resolveMessage = (key, activeLocale, params, depth) => {
|
|
74
|
-
if (depth > 8)
|
|
75
|
-
return key;
|
|
76
|
-
const activeMessage = catalogs[activeLocale]?.[key];
|
|
77
|
-
const fallbackMessage = options.fallbackLocale === undefined ? undefined : catalogs[options.fallbackLocale]?.[key];
|
|
78
|
-
if (activeMessage === undefined && fallbackMessage === undefined) {
|
|
79
|
-
// missing from every catalog: the missing return value is used as display text.
|
|
80
|
-
return missing(key, activeLocale, {
|
|
81
|
-
level: 'all',
|
|
82
|
-
...(options.fallbackLocale === undefined ? {} : { fallbackLocale: options.fallbackLocale }),
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
const message = activeMessage ?? fallbackMessage;
|
|
86
|
-
if (message === undefined)
|
|
87
|
-
return key;
|
|
88
|
-
if (activeMessage === undefined) {
|
|
89
|
-
// missing in the active locale but the fallback hit: diagnostics distinguish the level; translation still uses the fallback text (the missing return value is ignored).
|
|
90
|
-
void missing(key, activeLocale, {
|
|
91
|
-
level: 'fallback',
|
|
92
|
-
...(options.fallbackLocale === undefined ? {} : { fallbackLocale: options.fallbackLocale }),
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
return formatMessage(message, activeLocale, params, (nestedKey) => resolveMessage(nestedKey, activeLocale, params, depth + 1));
|
|
96
|
-
};
|
|
97
|
-
let disposed = false;
|
|
98
|
-
const dispose = () => {
|
|
99
|
-
if (disposed)
|
|
100
|
-
return;
|
|
101
|
-
disposed = true;
|
|
102
|
-
};
|
|
103
|
-
const owner = options.owner ?? currentOwner();
|
|
104
|
-
if (owner !== undefined) {
|
|
105
|
-
owner.own(dispose);
|
|
106
|
-
}
|
|
107
|
-
const instance = {
|
|
108
|
-
locale,
|
|
109
|
-
fallbackLocale: options.fallbackLocale,
|
|
110
|
-
get messages() {
|
|
111
|
-
return catalogs;
|
|
112
|
-
},
|
|
113
|
-
t(key, params) {
|
|
114
|
-
void catalogVersion.value;
|
|
115
|
-
return resolveMessage(key, locale.value, params, 0);
|
|
116
|
-
},
|
|
117
|
-
setLocale(nextLocale) {
|
|
118
|
-
locale.value = nextLocale;
|
|
119
|
-
},
|
|
120
|
-
addMessages(messageLocale, nextMessages) {
|
|
121
|
-
catalogs[messageLocale] = {
|
|
122
|
-
...(catalogs[messageLocale] ?? {}),
|
|
123
|
-
...flattenMessages(nextMessages),
|
|
124
|
-
};
|
|
125
|
-
catalogVersion.value += 1;
|
|
126
|
-
},
|
|
127
|
-
hasMessage(key, messageLocale = locale.value) {
|
|
128
|
-
return catalogs[messageLocale]?.[key] !== undefined;
|
|
129
|
-
},
|
|
130
|
-
formatDate(value, formatOptions) {
|
|
131
|
-
return cachedDateTimeFormat(locale.value, formatOptions).format(new Date(value));
|
|
132
|
-
},
|
|
133
|
-
formatNumber(value, formatOptions) {
|
|
134
|
-
return cachedNumberFormat(locale.value, formatOptions).format(value);
|
|
135
|
-
},
|
|
136
|
-
formatCurrency(value, currency, formatOptions) {
|
|
137
|
-
return cachedNumberFormat(locale.value, {
|
|
138
|
-
style: 'currency',
|
|
139
|
-
currency,
|
|
140
|
-
...formatOptions,
|
|
141
|
-
}).format(value);
|
|
142
|
-
},
|
|
143
|
-
formatRelativeTime(value, unit) {
|
|
144
|
-
return cachedRelativeTimeFormat(locale.value).format(value, unit);
|
|
145
|
-
},
|
|
146
|
-
dispose,
|
|
147
|
-
};
|
|
148
|
-
return instance;
|
|
149
|
-
}
|
|
150
|
-
export function serializeI18nState(instance) {
|
|
151
|
-
return {
|
|
152
|
-
locale: instance.locale.value,
|
|
153
|
-
...(instance.fallbackLocale === undefined ? {} : { fallbackLocale: instance.fallbackLocale }),
|
|
154
|
-
messages: instance.messages,
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
/**
|
|
158
|
-
* Symmetrically rebuilds an I18n instance from serialized state (contract §4):
|
|
159
|
-
* serialize and hydrate are a pair — SSR serializes, and before client hydration this rebuilds,
|
|
160
|
-
* keeping first-screen translations identical to the SSR output (see the Security contract's SSR state boundary).
|
|
161
|
-
*/
|
|
162
|
-
export function createI18nFromState(state, options = {}) {
|
|
163
|
-
return createI18n({
|
|
164
|
-
locale: state.locale,
|
|
165
|
-
...(state.fallbackLocale === undefined ? {} : { fallbackLocale: state.fallbackLocale }),
|
|
166
|
-
messages: state.messages,
|
|
167
|
-
...(options.missing === undefined ? {} : { missing: options.missing }),
|
|
168
|
-
...(options.owner === undefined ? {} : { owner: options.owner }),
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
function formatMessage(message, locale, params, resolveKey) {
|
|
172
|
-
if (!message.includes('{'))
|
|
173
|
-
return message;
|
|
174
|
-
let result = '';
|
|
175
|
-
let cursor = 0;
|
|
176
|
-
while (cursor < message.length) {
|
|
177
|
-
const open = message.indexOf('{', cursor);
|
|
178
|
-
if (open < 0) {
|
|
179
|
-
result += message.slice(cursor);
|
|
180
|
-
break;
|
|
181
|
-
}
|
|
182
|
-
result += message.slice(cursor, open);
|
|
183
|
-
const close = findClosingBrace(message, open);
|
|
184
|
-
if (close < 0) {
|
|
185
|
-
result += message.slice(open);
|
|
186
|
-
break;
|
|
187
|
-
}
|
|
188
|
-
result += formatPlaceholder(message.slice(open + 1, close), locale, params, resolveKey);
|
|
189
|
-
cursor = close + 1;
|
|
190
|
-
}
|
|
191
|
-
return result;
|
|
192
|
-
}
|
|
193
|
-
function formatPlaceholder(source, locale, params, resolveKey) {
|
|
194
|
-
const content = source.trim();
|
|
195
|
-
if (content === '')
|
|
196
|
-
return '{}';
|
|
197
|
-
if (content.startsWith('@')) {
|
|
198
|
-
return resolveKey(content.slice(1).trim());
|
|
199
|
-
}
|
|
200
|
-
const parts = splitTopLevel(content, ',');
|
|
201
|
-
const name = parts[0]?.trim() ?? '';
|
|
202
|
-
const type = parts[1]?.trim();
|
|
203
|
-
if (type === 'plural') {
|
|
204
|
-
return formatPlural(name, parts.slice(2).join(','), locale, params, resolveKey);
|
|
205
|
-
}
|
|
206
|
-
if (type === 'select') {
|
|
207
|
-
return formatSelect(name, parts.slice(2).join(','), locale, params, resolveKey);
|
|
208
|
-
}
|
|
209
|
-
if (name === '')
|
|
210
|
-
return `{${content}}`;
|
|
211
|
-
const value = params?.[name];
|
|
212
|
-
return value === null || value === undefined ? `{${name}}` : String(value);
|
|
213
|
-
}
|
|
214
|
-
function findClosingBrace(message, open) {
|
|
215
|
-
let depth = 0;
|
|
216
|
-
for (let index = open; index < message.length; index += 1) {
|
|
217
|
-
const char = message[index];
|
|
218
|
-
if (char === '{')
|
|
219
|
-
depth += 1;
|
|
220
|
-
else if (char === '}') {
|
|
221
|
-
depth -= 1;
|
|
222
|
-
if (depth === 0)
|
|
223
|
-
return index;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
return -1;
|
|
227
|
-
}
|
|
228
|
-
function splitTopLevel(source, separator) {
|
|
229
|
-
const parts = [];
|
|
230
|
-
let depth = 0;
|
|
231
|
-
let current = '';
|
|
232
|
-
for (const char of source) {
|
|
233
|
-
if (char === '{') {
|
|
234
|
-
depth += 1;
|
|
235
|
-
current += char;
|
|
236
|
-
}
|
|
237
|
-
else if (char === '}') {
|
|
238
|
-
depth -= 1;
|
|
239
|
-
current += char;
|
|
240
|
-
}
|
|
241
|
-
else if (char === separator && depth === 0) {
|
|
242
|
-
parts.push(current);
|
|
243
|
-
current = '';
|
|
244
|
-
}
|
|
245
|
-
else {
|
|
246
|
-
current += char;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
parts.push(current);
|
|
250
|
-
return parts;
|
|
251
|
-
}
|
|
252
|
-
function parseCases(body) {
|
|
253
|
-
const cases = [];
|
|
254
|
-
let index = 0;
|
|
255
|
-
while (index < body.length) {
|
|
256
|
-
while (index < body.length && /\s/u.test(body[index] ?? ''))
|
|
257
|
-
index += 1;
|
|
258
|
-
if (index >= body.length)
|
|
259
|
-
break;
|
|
260
|
-
const open = body.indexOf('{', index);
|
|
261
|
-
if (open < 0)
|
|
262
|
-
break;
|
|
263
|
-
const match = body.slice(index, open).trim();
|
|
264
|
-
const close = findClosingBrace(body, open);
|
|
265
|
-
if (close < 0)
|
|
266
|
-
break;
|
|
267
|
-
cases.push({ match, content: body.slice(open + 1, close) });
|
|
268
|
-
index = close + 1;
|
|
269
|
-
}
|
|
270
|
-
return cases;
|
|
271
|
-
}
|
|
272
|
-
function formatPlural(name, body, locale, params, resolveKey) {
|
|
273
|
-
const cases = parseCases(body);
|
|
274
|
-
const value = params?.[name];
|
|
275
|
-
if (typeof value === 'number') {
|
|
276
|
-
const category = cachedPluralRules(locale).select(value);
|
|
277
|
-
const exact = cases.find((entry) => entry.match === `=${value}`);
|
|
278
|
-
if (exact !== undefined)
|
|
279
|
-
return formatMessage(exact.content, locale, params, resolveKey);
|
|
280
|
-
const matched = cases.find((entry) => entry.match === category);
|
|
281
|
-
if (matched !== undefined)
|
|
282
|
-
return formatMessage(matched.content, locale, params, resolveKey);
|
|
283
|
-
}
|
|
284
|
-
const other = cases.find((entry) => entry.match === 'other');
|
|
285
|
-
return other === undefined ? '' : formatMessage(other.content, locale, params, resolveKey);
|
|
286
|
-
}
|
|
287
|
-
function formatSelect(name, body, locale, params, resolveKey) {
|
|
288
|
-
const cases = parseCases(body);
|
|
289
|
-
const value = params?.[name];
|
|
290
|
-
const stringValue = value === null || value === undefined ? undefined : String(value);
|
|
291
|
-
if (stringValue !== undefined) {
|
|
292
|
-
const matched = cases.find((entry) => entry.match === stringValue);
|
|
293
|
-
if (matched !== undefined)
|
|
294
|
-
return formatMessage(matched.content, locale, params, resolveKey);
|
|
295
|
-
}
|
|
296
|
-
const other = cases.find((entry) => entry.match === 'other');
|
|
297
|
-
return other === undefined ? '' : formatMessage(other.content, locale, params, resolveKey);
|
|
298
|
-
}
|
|
299
|
-
// Intl constructor cache: PluralRules/NumberFormat/DateTimeFormat/RelativeTimeFormat are expensive to construct;
|
|
300
|
-
// instances for the same locale are reusable (allowed by the Intl spec, results unchanged). Module-level caches are
|
|
301
|
-
// shared across instances and use an LRU limit so request-specific options cannot grow the process indefinitely.
|
|
302
|
-
const FORMATTER_CACHE_LIMIT = 100;
|
|
303
|
-
const pluralRulesCache = new Map();
|
|
304
|
-
const numberFormatCache = new Map();
|
|
305
|
-
const dateTimeFormatCache = new Map();
|
|
306
|
-
const relativeTimeFormatCache = new Map();
|
|
307
|
-
function readCachedFormatter(cache, key, create) {
|
|
308
|
-
const existing = cache.get(key);
|
|
309
|
-
if (existing !== undefined) {
|
|
310
|
-
cache.delete(key);
|
|
311
|
-
cache.set(key, existing);
|
|
312
|
-
return existing;
|
|
313
|
-
}
|
|
314
|
-
const formatter = create();
|
|
315
|
-
cache.set(key, formatter);
|
|
316
|
-
while (cache.size > FORMATTER_CACHE_LIMIT) {
|
|
317
|
-
const oldest = cache.keys().next().value;
|
|
318
|
-
if (oldest === undefined)
|
|
319
|
-
break;
|
|
320
|
-
cache.delete(oldest);
|
|
321
|
-
}
|
|
322
|
-
return formatter;
|
|
323
|
-
}
|
|
324
|
-
function cachedPluralRules(locale) {
|
|
325
|
-
return readCachedFormatter(pluralRulesCache, locale, () => new Intl.PluralRules(locale));
|
|
326
|
-
}
|
|
327
|
-
function cachedNumberFormat(locale, options) {
|
|
328
|
-
const key = options === undefined ? locale : `${locale}\u0000${JSON.stringify(options)}`;
|
|
329
|
-
return readCachedFormatter(numberFormatCache, key, () => new Intl.NumberFormat(locale, options));
|
|
330
|
-
}
|
|
331
|
-
function cachedDateTimeFormat(locale, options) {
|
|
332
|
-
const key = options === undefined ? locale : `${locale}\u0000${JSON.stringify(options)}`;
|
|
333
|
-
return readCachedFormatter(dateTimeFormatCache, key, () => new Intl.DateTimeFormat(locale, options));
|
|
334
|
-
}
|
|
335
|
-
function cachedRelativeTimeFormat(locale) {
|
|
336
|
-
return readCachedFormatter(relativeTimeFormatCache, locale, () => new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }));
|
|
337
|
-
}
|