@fluenti/solid 0.1.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 +21 -0
- package/README.md +317 -0
- package/dist/compile-time-t.d.ts +3 -0
- package/dist/compile-time-t.d.ts.map +1 -0
- package/dist/components/DateTime.d.ts +16 -0
- package/dist/components/DateTime.d.ts.map +1 -0
- package/dist/components/NumberFormat.d.ts +16 -0
- package/dist/components/NumberFormat.d.ts.map +1 -0
- package/dist/context.d.ts +69 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/hooks/__useI18n.d.ts +12 -0
- package/dist/hooks/__useI18n.d.ts.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +446 -0
- package/dist/index.js.map +1 -0
- package/dist/msg.d.ts +2 -0
- package/dist/msg.d.ts.map +1 -0
- package/dist/plural.d.ts +55 -0
- package/dist/plural.d.ts.map +1 -0
- package/dist/provider.d.ts +10 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/rich-dom.d.ts +17 -0
- package/dist/rich-dom.d.ts.map +1 -0
- package/dist/select.d.ts +49 -0
- package/dist/select.d.ts.map +1 -0
- package/dist/server.d.ts +77 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/trans.d.ts +46 -0
- package/dist/trans.d.ts.map +1 -0
- package/dist/types.d.ts +45 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/use-i18n.d.ts +12 -0
- package/dist/use-i18n.d.ts.map +1 -0
- package/package.json +75 -0
- package/src/compile-time-t.ts +9 -0
- package/src/components/DateTime.tsx +21 -0
- package/src/components/NumberFormat.tsx +21 -0
- package/src/context.ts +337 -0
- package/src/hooks/__useI18n.ts +15 -0
- package/src/index.ts +14 -0
- package/src/msg.ts +4 -0
- package/src/plural.tsx +136 -0
- package/src/provider.tsx +16 -0
- package/src/rich-dom.tsx +170 -0
- package/src/select.tsx +90 -0
- package/src/server.ts +153 -0
- package/src/trans.tsx +243 -0
- package/src/types.ts +55 -0
- package/src/use-i18n.ts +30 -0
- package/src/vite-runtime.d.ts +4 -0
package/src/context.ts
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { createSignal, createRoot, type Accessor } from 'solid-js'
|
|
2
|
+
import { formatDate, formatNumber, interpolate as coreInterpolate, buildICUMessage, resolveDescriptorId } from '@fluenti/core'
|
|
3
|
+
import type { FluentConfig, Locale, Messages, CompiledMessage, MessageDescriptor, DateFormatOptions, NumberFormatOptions } from '@fluenti/core'
|
|
4
|
+
|
|
5
|
+
/** Chunk loader for lazy locale loading */
|
|
6
|
+
export type ChunkLoader = (
|
|
7
|
+
locale: string,
|
|
8
|
+
) => Promise<Record<string, CompiledMessage> | { default: Record<string, CompiledMessage> }>
|
|
9
|
+
|
|
10
|
+
interface SplitRuntimeModule {
|
|
11
|
+
__switchLocale?: (locale: string) => Promise<void>
|
|
12
|
+
__preloadLocale?: (locale: string) => Promise<void>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SPLIT_RUNTIME_KEY = Symbol.for('fluenti.runtime.solid')
|
|
16
|
+
|
|
17
|
+
function getSplitRuntimeModule(): SplitRuntimeModule | null {
|
|
18
|
+
const runtime = (globalThis as Record<PropertyKey, unknown>)[SPLIT_RUNTIME_KEY]
|
|
19
|
+
return typeof runtime === 'object' && runtime !== null
|
|
20
|
+
? runtime as SplitRuntimeModule
|
|
21
|
+
: null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveChunkMessages(
|
|
25
|
+
loaded: Record<string, CompiledMessage> | { default: Record<string, CompiledMessage> },
|
|
26
|
+
): Record<string, CompiledMessage> {
|
|
27
|
+
return typeof loaded === 'object' && loaded !== null && 'default' in loaded
|
|
28
|
+
? (loaded as { default: Record<string, CompiledMessage> }).default
|
|
29
|
+
: loaded
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Extended config with lazy locale loading support */
|
|
33
|
+
export interface I18nConfig extends FluentConfig {
|
|
34
|
+
/** Async chunk loader for lazy locale loading */
|
|
35
|
+
chunkLoader?: ChunkLoader
|
|
36
|
+
/** Enable lazy locale loading through chunkLoader */
|
|
37
|
+
lazyLocaleLoading?: boolean
|
|
38
|
+
/** Locale-specific fallback chains */
|
|
39
|
+
fallbackChain?: Record<string, Locale[]>
|
|
40
|
+
/** Named date format styles */
|
|
41
|
+
dateFormats?: DateFormatOptions
|
|
42
|
+
/** Named number format styles */
|
|
43
|
+
numberFormats?: NumberFormatOptions
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Reactive i18n context holding locale signal and translation utilities */
|
|
47
|
+
export interface I18nContext {
|
|
48
|
+
/** Reactive accessor for the current locale */
|
|
49
|
+
locale(): Locale
|
|
50
|
+
/** Set the active locale (async when lazy locale loading is enabled) */
|
|
51
|
+
setLocale(locale: Locale): Promise<void>
|
|
52
|
+
/** Translate a message by id with optional interpolation values */
|
|
53
|
+
t(id: string | MessageDescriptor, values?: Record<string, unknown>): string
|
|
54
|
+
/** Tagged template form: t`Hello ${name}` */
|
|
55
|
+
t(strings: TemplateStringsArray, ...exprs: unknown[]): string
|
|
56
|
+
/** Merge additional messages into a locale catalog at runtime */
|
|
57
|
+
loadMessages(locale: Locale, messages: Messages): void
|
|
58
|
+
/** Return all locale codes that have loaded messages */
|
|
59
|
+
getLocales(): Locale[]
|
|
60
|
+
/** Format a date value for the current locale */
|
|
61
|
+
d(value: Date | number, style?: string): string
|
|
62
|
+
/** Format a number value for the current locale */
|
|
63
|
+
n(value: number, style?: string): string
|
|
64
|
+
/** Format an ICU message string directly (no catalog lookup) */
|
|
65
|
+
format(message: string, values?: Record<string, unknown>): string
|
|
66
|
+
/** Whether a locale chunk is currently being loaded */
|
|
67
|
+
isLoading: Accessor<boolean>
|
|
68
|
+
/** Set of locales whose messages have been loaded */
|
|
69
|
+
loadedLocales: Accessor<Set<string>>
|
|
70
|
+
/** Preload a locale in the background without switching to it */
|
|
71
|
+
preloadLocale(locale: string): void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a reactive i18n context backed by Solid signals.
|
|
76
|
+
*
|
|
77
|
+
* The returned `t()` reads the internal `locale()` signal, so any
|
|
78
|
+
* Solid computation that calls `t()` will re-run when the locale changes.
|
|
79
|
+
*/
|
|
80
|
+
export function createI18nContext(config: FluentConfig | I18nConfig): I18nContext {
|
|
81
|
+
const [locale, setLocaleSignal] = createSignal<Locale>(config.locale)
|
|
82
|
+
const [isLoading, setIsLoading] = createSignal(false)
|
|
83
|
+
const loadedLocalesSet = new Set<string>([config.locale])
|
|
84
|
+
const [loadedLocales, setLoadedLocales] = createSignal(new Set(loadedLocalesSet))
|
|
85
|
+
const messages: Record<string, Messages> = { ...config.messages }
|
|
86
|
+
const i18nConfig = config as I18nConfig
|
|
87
|
+
const lazyLocaleLoading = i18nConfig.lazyLocaleLoading
|
|
88
|
+
?? (config as I18nConfig & { splitting?: boolean }).splitting
|
|
89
|
+
?? false
|
|
90
|
+
|
|
91
|
+
function lookupCatalog(
|
|
92
|
+
id: string,
|
|
93
|
+
loc: Locale,
|
|
94
|
+
values?: Record<string, unknown>,
|
|
95
|
+
): string | undefined {
|
|
96
|
+
const catalog = messages[loc]
|
|
97
|
+
if (!catalog) {
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const msg = catalog[id]
|
|
102
|
+
if (msg === undefined) {
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeof msg === 'function') {
|
|
107
|
+
return msg(values)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof msg === 'string' && values) {
|
|
111
|
+
return coreInterpolate(msg, values, loc)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return String(msg)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function lookupWithFallbacks(
|
|
118
|
+
id: string,
|
|
119
|
+
loc: Locale,
|
|
120
|
+
values?: Record<string, unknown>,
|
|
121
|
+
): string | undefined {
|
|
122
|
+
const localesToTry: Locale[] = [loc]
|
|
123
|
+
const seen = new Set(localesToTry)
|
|
124
|
+
|
|
125
|
+
if (config.fallbackLocale && !seen.has(config.fallbackLocale)) {
|
|
126
|
+
localesToTry.push(config.fallbackLocale)
|
|
127
|
+
seen.add(config.fallbackLocale)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const chainLocales = i18nConfig.fallbackChain?.[loc] ?? i18nConfig.fallbackChain?.['*']
|
|
131
|
+
if (chainLocales) {
|
|
132
|
+
for (const chainLocale of chainLocales) {
|
|
133
|
+
if (!seen.has(chainLocale)) {
|
|
134
|
+
localesToTry.push(chainLocale)
|
|
135
|
+
seen.add(chainLocale)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const targetLocale of localesToTry) {
|
|
141
|
+
const result = lookupCatalog(id, targetLocale, values)
|
|
142
|
+
if (result !== undefined) {
|
|
143
|
+
return result
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveMissing(
|
|
151
|
+
id: string,
|
|
152
|
+
loc: Locale,
|
|
153
|
+
): string | undefined {
|
|
154
|
+
if (!config.missing) {
|
|
155
|
+
return undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const result = config.missing(loc, id)
|
|
159
|
+
if (result !== undefined) {
|
|
160
|
+
return result
|
|
161
|
+
}
|
|
162
|
+
return undefined
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function resolveMessage(
|
|
166
|
+
id: string,
|
|
167
|
+
loc: Locale,
|
|
168
|
+
values?: Record<string, unknown>,
|
|
169
|
+
): string {
|
|
170
|
+
const catalogResult = lookupWithFallbacks(id, loc, values)
|
|
171
|
+
if (catalogResult !== undefined) {
|
|
172
|
+
return catalogResult
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const missingResult = resolveMissing(id, loc)
|
|
176
|
+
if (missingResult !== undefined) {
|
|
177
|
+
return missingResult
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (id.includes('{')) {
|
|
181
|
+
return coreInterpolate(id, values, loc)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return id
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function t(strings: TemplateStringsArray, ...exprs: unknown[]): string
|
|
188
|
+
function t(id: string | MessageDescriptor, values?: Record<string, unknown>): string
|
|
189
|
+
function t(idOrStrings: string | MessageDescriptor | TemplateStringsArray, ...rest: unknown[]): string {
|
|
190
|
+
// Tagged template form: t`Hello ${name}`
|
|
191
|
+
if (Array.isArray(idOrStrings) && 'raw' in idOrStrings) {
|
|
192
|
+
const strings = idOrStrings as TemplateStringsArray
|
|
193
|
+
const icu = buildICUMessage(strings, rest)
|
|
194
|
+
const values = Object.fromEntries(rest.map((v, i) => [String(i), v]))
|
|
195
|
+
return t(icu, values)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const id = idOrStrings as string | MessageDescriptor
|
|
199
|
+
const values = rest[0] as Record<string, unknown> | undefined
|
|
200
|
+
const currentLocale = locale() // reactive dependency
|
|
201
|
+
if (typeof id === 'object' && id !== null) {
|
|
202
|
+
const messageId = resolveDescriptorId(id)
|
|
203
|
+
if (messageId) {
|
|
204
|
+
const catalogResult = lookupWithFallbacks(messageId, currentLocale, values)
|
|
205
|
+
if (catalogResult !== undefined) {
|
|
206
|
+
return catalogResult
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const missingResult = resolveMissing(messageId, currentLocale)
|
|
210
|
+
if (missingResult !== undefined) {
|
|
211
|
+
return missingResult
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (id.message !== undefined) {
|
|
216
|
+
return coreInterpolate(id.message, values, currentLocale)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return messageId ?? ''
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return resolveMessage(id, currentLocale, values)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const loadMessages = (loc: Locale, msgs: Messages): void => {
|
|
226
|
+
messages[loc] = { ...messages[loc], ...msgs }
|
|
227
|
+
loadedLocalesSet.add(loc)
|
|
228
|
+
setLoadedLocales(new Set(loadedLocalesSet))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const setLocale = async (newLocale: Locale): Promise<void> => {
|
|
232
|
+
if (!lazyLocaleLoading || !i18nConfig.chunkLoader) {
|
|
233
|
+
setLocaleSignal(newLocale)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const splitRuntime = getSplitRuntimeModule()
|
|
238
|
+
|
|
239
|
+
if (loadedLocalesSet.has(newLocale)) {
|
|
240
|
+
if (splitRuntime?.__switchLocale) {
|
|
241
|
+
await splitRuntime.__switchLocale(newLocale)
|
|
242
|
+
}
|
|
243
|
+
setLocaleSignal(newLocale)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
setIsLoading(true)
|
|
248
|
+
try {
|
|
249
|
+
const loaded = resolveChunkMessages(await i18nConfig.chunkLoader(newLocale))
|
|
250
|
+
messages[newLocale] = { ...messages[newLocale], ...loaded }
|
|
251
|
+
loadedLocalesSet.add(newLocale)
|
|
252
|
+
setLoadedLocales(new Set(loadedLocalesSet))
|
|
253
|
+
if (splitRuntime?.__switchLocale) {
|
|
254
|
+
await splitRuntime.__switchLocale(newLocale)
|
|
255
|
+
}
|
|
256
|
+
setLocaleSignal(newLocale)
|
|
257
|
+
} finally {
|
|
258
|
+
setIsLoading(false)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const preloadLocale = (loc: string): void => {
|
|
263
|
+
if (!lazyLocaleLoading || loadedLocalesSet.has(loc) || !i18nConfig.chunkLoader) return
|
|
264
|
+
const splitRuntime = getSplitRuntimeModule()
|
|
265
|
+
i18nConfig.chunkLoader(loc).then(async (loaded) => {
|
|
266
|
+
const resolved = resolveChunkMessages(loaded)
|
|
267
|
+
messages[loc] = { ...messages[loc], ...resolved }
|
|
268
|
+
loadedLocalesSet.add(loc)
|
|
269
|
+
setLoadedLocales(new Set(loadedLocalesSet))
|
|
270
|
+
if (splitRuntime?.__preloadLocale) {
|
|
271
|
+
await splitRuntime.__preloadLocale(loc)
|
|
272
|
+
}
|
|
273
|
+
}).catch(() => {
|
|
274
|
+
// Silent failure for preload
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const getLocales = (): Locale[] => Object.keys(messages)
|
|
279
|
+
|
|
280
|
+
const d = (value: Date | number, style?: string): string =>
|
|
281
|
+
formatDate(value, locale(), style, i18nConfig.dateFormats)
|
|
282
|
+
|
|
283
|
+
const n = (value: number, style?: string): string =>
|
|
284
|
+
formatNumber(value, locale(), style, i18nConfig.numberFormats)
|
|
285
|
+
|
|
286
|
+
const format = (message: string, values?: Record<string, unknown>): string => {
|
|
287
|
+
return coreInterpolate(message, values, locale())
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { locale, setLocale, t, loadMessages, getLocales, d, n, format, isLoading, loadedLocales, preloadLocale }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ─── Module-level singleton ─────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
let globalCtx: I18nContext | undefined
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Initialize the global i18n singleton.
|
|
299
|
+
*
|
|
300
|
+
* Call once at app startup (e.g. in your entry file) before any `useI18n()`.
|
|
301
|
+
* Signals are created inside a `createRoot` so they outlive any component scope.
|
|
302
|
+
*
|
|
303
|
+
* Returns the context for convenience, but `useI18n()` will also find it.
|
|
304
|
+
*/
|
|
305
|
+
export function createI18n(config: FluentConfig | I18nConfig): I18nContext {
|
|
306
|
+
const ctx = createRoot(() => createI18nContext(config))
|
|
307
|
+
|
|
308
|
+
// Only set global singleton in browser (client-side).
|
|
309
|
+
// In SSR, each request should use <I18nProvider> for per-request isolation.
|
|
310
|
+
if (typeof window !== 'undefined') {
|
|
311
|
+
globalCtx = ctx
|
|
312
|
+
} else {
|
|
313
|
+
console.warn(
|
|
314
|
+
'[fluenti] createI18n() detected SSR environment. ' +
|
|
315
|
+
'Use <I18nProvider> for per-request isolation in SSR.',
|
|
316
|
+
)
|
|
317
|
+
// Still set globalCtx as fallback, but document the risk
|
|
318
|
+
globalCtx = ctx
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return ctx
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** @internal — used by useI18n and I18nProvider */
|
|
325
|
+
export function getGlobalI18nContext(): I18nContext | undefined {
|
|
326
|
+
return globalCtx
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** @internal — used by I18nProvider to set context without createRoot wrapper */
|
|
330
|
+
export function setGlobalI18nContext(ctx: I18nContext): void {
|
|
331
|
+
globalCtx = ctx
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** @internal — reset the global singleton (for testing only) */
|
|
335
|
+
export function resetGlobalI18nContext(): void {
|
|
336
|
+
globalCtx = undefined
|
|
337
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useI18n } from '../use-i18n'
|
|
2
|
+
import type { I18nContext } from '../types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Internal hook used by the Vite plugin's compiled output.
|
|
6
|
+
* Returns the i18n context for direct t() calls.
|
|
7
|
+
*
|
|
8
|
+
* **Not part of the public API.** Users never write this — the Vite plugin
|
|
9
|
+
* generates imports of this hook automatically.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export function __useI18n(): I18nContext {
|
|
14
|
+
return useI18n()
|
|
15
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { createI18nContext, createI18n } from './context'
|
|
2
|
+
export type { I18nContext, I18nConfig } from './context'
|
|
3
|
+
export { I18nProvider, I18nCtx } from './provider'
|
|
4
|
+
export { useI18n } from './use-i18n'
|
|
5
|
+
export { t } from './compile-time-t'
|
|
6
|
+
export { Trans } from './trans'
|
|
7
|
+
export type { TransProps } from './trans'
|
|
8
|
+
export { Plural } from './plural'
|
|
9
|
+
export type { PluralProps } from './plural'
|
|
10
|
+
export { SelectComp as Select } from './select'
|
|
11
|
+
export type { SelectProps } from './select'
|
|
12
|
+
export { msg } from './msg'
|
|
13
|
+
export { DateTime } from './components/DateTime'
|
|
14
|
+
export { NumberFormat } from './components/NumberFormat'
|
package/src/msg.ts
ADDED
package/src/plural.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Dynamic } from 'solid-js/web'
|
|
2
|
+
import type { Component, JSX } from 'solid-js'
|
|
3
|
+
import { hashMessage } from '@fluenti/core'
|
|
4
|
+
import { useI18n } from './use-i18n'
|
|
5
|
+
import { reconstruct, serializeRichForms } from './rich-dom'
|
|
6
|
+
|
|
7
|
+
/** Plural category names in a stable order for ICU message building. */
|
|
8
|
+
const PLURAL_CATEGORIES = ['zero', 'one', 'two', 'few', 'many', 'other'] as const
|
|
9
|
+
|
|
10
|
+
type PluralCategory = (typeof PLURAL_CATEGORIES)[number]
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build an ICU plural message string from individual category props.
|
|
14
|
+
*
|
|
15
|
+
* Given `{ zero: "No items", one: "# item", other: "# items" }`,
|
|
16
|
+
* produces `"{count, plural, =0 {No items} one {# item} other {# items}}"`.
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
function buildICUPluralMessage(
|
|
21
|
+
forms: Partial<Record<PluralCategory, string>> & { other: string },
|
|
22
|
+
offset?: number,
|
|
23
|
+
): string {
|
|
24
|
+
const parts: string[] = []
|
|
25
|
+
for (const cat of PLURAL_CATEGORIES) {
|
|
26
|
+
const text = forms[cat]
|
|
27
|
+
if (text !== undefined) {
|
|
28
|
+
// Map the `zero` prop to ICU `=0` exact match. In ICU MessageFormat,
|
|
29
|
+
// `zero` is a CLDR plural category that only activates in languages
|
|
30
|
+
// with a grammatical zero form (e.g. Arabic). The `=0` exact match
|
|
31
|
+
// works universally for the common "show this when count is 0" intent.
|
|
32
|
+
const key = cat === 'zero' ? '=0' : cat
|
|
33
|
+
parts.push(`${key} {${text}}`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const offsetPrefix = offset ? `offset:${offset} ` : ''
|
|
37
|
+
return `{count, plural, ${offsetPrefix}${parts.join(' ')}}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Props for the `<Plural>` component */
|
|
41
|
+
export interface PluralProps {
|
|
42
|
+
/** The numeric value to pluralise */
|
|
43
|
+
value: number
|
|
44
|
+
/** Override the auto-generated synthetic ICU message id */
|
|
45
|
+
id?: string
|
|
46
|
+
/** Message context used for identity and translator disambiguation */
|
|
47
|
+
context?: string
|
|
48
|
+
/** Translator-facing note preserved in extraction catalogs */
|
|
49
|
+
comment?: string
|
|
50
|
+
/** Offset from value before selecting form */
|
|
51
|
+
offset?: number
|
|
52
|
+
/** Message for the "zero" plural category */
|
|
53
|
+
zero?: string | JSX.Element
|
|
54
|
+
/** Message for the "one" plural category */
|
|
55
|
+
one?: string | JSX.Element
|
|
56
|
+
/** Message for the "two" plural category */
|
|
57
|
+
two?: string | JSX.Element
|
|
58
|
+
/** Message for the "few" plural category */
|
|
59
|
+
few?: string | JSX.Element
|
|
60
|
+
/** Message for the "many" plural category */
|
|
61
|
+
many?: string | JSX.Element
|
|
62
|
+
/** Fallback message when no category-specific prop matches */
|
|
63
|
+
other: string | JSX.Element
|
|
64
|
+
/** Wrapper element tag name (default: `'span'`) */
|
|
65
|
+
tag?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `<Plural>` component — shorthand for ICU plural patterns.
|
|
70
|
+
*
|
|
71
|
+
* Plural form props (`zero`, `one`, `two`, `few`, `many`, `other`) are treated
|
|
72
|
+
* as source-language messages. The component builds an ICU plural message,
|
|
73
|
+
* looks it up via `t()` in the catalog, and interpolates the translated result.
|
|
74
|
+
*
|
|
75
|
+
* When no catalog translation exists, the component falls back to interpolating
|
|
76
|
+
* the source-language ICU message directly via core's `interpolate`.
|
|
77
|
+
*
|
|
78
|
+
* Rich text is supported via JSX element props:
|
|
79
|
+
* ```tsx
|
|
80
|
+
* <Plural
|
|
81
|
+
* value={count()}
|
|
82
|
+
* zero={<>No <strong>items</strong> left</>}
|
|
83
|
+
* one={<><em>1</em> item remaining</>}
|
|
84
|
+
* other={<><strong>{count()}</strong> items remaining</>}
|
|
85
|
+
* />
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* String props still work (backward compatible):
|
|
89
|
+
* ```tsx
|
|
90
|
+
* <Plural value={count()} zero="No items" one="# item" other="# items" />
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export const Plural: Component<PluralProps> = (props) => {
|
|
94
|
+
const { t } = useI18n()
|
|
95
|
+
|
|
96
|
+
/** Resolve a category prop value — handles string, accessor function, and JSX */
|
|
97
|
+
function resolveProp(val: string | JSX.Element | undefined): string | JSX.Element | undefined {
|
|
98
|
+
if (typeof val === 'function') return (val as () => string | JSX.Element)()
|
|
99
|
+
return val
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (() => {
|
|
103
|
+
// Resolve all category values (handles Solid accessors from createMemo)
|
|
104
|
+
const resolvedValues: Partial<Record<PluralCategory, string | JSX.Element>> = {}
|
|
105
|
+
for (const cat of PLURAL_CATEGORIES) {
|
|
106
|
+
const resolved = resolveProp(props[cat])
|
|
107
|
+
if (resolved !== undefined) {
|
|
108
|
+
resolvedValues[cat] = resolved
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const { messages, components } = serializeRichForms(PLURAL_CATEGORIES, resolvedValues)
|
|
112
|
+
const icuMessage = buildICUPluralMessage(
|
|
113
|
+
{
|
|
114
|
+
...(messages['zero'] !== undefined && { zero: messages['zero'] }),
|
|
115
|
+
...(messages['one'] !== undefined && { one: messages['one'] }),
|
|
116
|
+
...(messages['two'] !== undefined && { two: messages['two'] }),
|
|
117
|
+
...(messages['few'] !== undefined && { few: messages['few'] }),
|
|
118
|
+
...(messages['many'] !== undefined && { many: messages['many'] }),
|
|
119
|
+
other: messages['other'] ?? '',
|
|
120
|
+
},
|
|
121
|
+
props.offset,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const translated = t(
|
|
125
|
+
{
|
|
126
|
+
id: props.id ?? (props.context === undefined ? icuMessage : hashMessage(icuMessage, props.context)),
|
|
127
|
+
message: icuMessage,
|
|
128
|
+
...(props.context !== undefined ? { context: props.context } : {}),
|
|
129
|
+
...(props.comment !== undefined ? { comment: props.comment } : {}),
|
|
130
|
+
},
|
|
131
|
+
{ count: props.value },
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return (<Dynamic component={props.tag ?? 'span'}>{components.length > 0 ? reconstruct(translated, components) : translated}</Dynamic>) as JSX.Element
|
|
135
|
+
}) as unknown as JSX.Element
|
|
136
|
+
}
|
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createContext } from 'solid-js'
|
|
2
|
+
import type { ParentComponent } from 'solid-js'
|
|
3
|
+
import { createI18nContext } from './context'
|
|
4
|
+
import type { I18nConfig, I18nContext } from './context'
|
|
5
|
+
|
|
6
|
+
/** Solid context object for i18n — used internally by useI18n() */
|
|
7
|
+
export const I18nCtx = createContext<I18nContext>()
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Provide i18n context to the component tree.
|
|
11
|
+
*
|
|
12
|
+
*/
|
|
13
|
+
export const I18nProvider: ParentComponent<I18nConfig> = (props) => {
|
|
14
|
+
const ctx = createI18nContext(props)
|
|
15
|
+
return <I18nCtx.Provider value={ctx}>{props.children}</I18nCtx.Provider>
|
|
16
|
+
}
|
package/src/rich-dom.tsx
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { JSX } from 'solid-js'
|
|
2
|
+
|
|
3
|
+
function isNodeLike(value: unknown): value is Node {
|
|
4
|
+
return typeof Node !== 'undefined' && value instanceof Node
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function resolveValue(value: unknown): unknown {
|
|
8
|
+
if (typeof value === 'function' && !(value as { length?: number }).length) {
|
|
9
|
+
return (value as () => unknown)()
|
|
10
|
+
}
|
|
11
|
+
return value
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function offsetIndices(message: string, offset: number): string {
|
|
15
|
+
if (offset === 0) return message
|
|
16
|
+
return message
|
|
17
|
+
.replace(/<(\d+)(\/?>)/g, (_match, index: string, suffix: string) => `<${Number(index) + offset}${suffix}`)
|
|
18
|
+
.replace(/<\/(\d+)>/g, (_match, index: string) => `</${Number(index) + offset}>`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function extractMessage(value: unknown): {
|
|
22
|
+
message: string
|
|
23
|
+
components: Node[]
|
|
24
|
+
} {
|
|
25
|
+
const components: Node[] = []
|
|
26
|
+
let message = ''
|
|
27
|
+
|
|
28
|
+
function visit(node: unknown): void {
|
|
29
|
+
const resolved = resolveValue(node)
|
|
30
|
+
if (resolved === null || resolved === undefined || typeof resolved === 'boolean') return
|
|
31
|
+
if (Array.isArray(resolved)) {
|
|
32
|
+
for (const child of resolved) visit(child)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
if (typeof resolved === 'string' || typeof resolved === 'number') {
|
|
36
|
+
message += String(resolved)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (!isNodeLike(resolved)) return
|
|
40
|
+
if (resolved.nodeType === Node.TEXT_NODE) {
|
|
41
|
+
message += resolved.textContent ?? ''
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if (resolved.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
|
|
45
|
+
visit(Array.from(resolved.childNodes))
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const idx = components.length
|
|
50
|
+
const inner = extractMessage(Array.from(resolved.childNodes))
|
|
51
|
+
components.push((resolved as Element).cloneNode(false))
|
|
52
|
+
components.push(...inner.components)
|
|
53
|
+
message += `<${idx}>${offsetIndices(inner.message, idx + 1)}</${idx}>`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
visit(value)
|
|
57
|
+
return { message, components }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function appendChild(parent: Node, child: unknown): void {
|
|
61
|
+
const resolved = resolveValue(child)
|
|
62
|
+
if (resolved === null || resolved === undefined || typeof resolved === 'boolean') return
|
|
63
|
+
if (Array.isArray(resolved)) {
|
|
64
|
+
for (const entry of resolved) appendChild(parent, entry)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (typeof resolved === 'string' || typeof resolved === 'number') {
|
|
68
|
+
parent.appendChild(document.createTextNode(String(resolved)))
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (isNodeLike(resolved)) {
|
|
72
|
+
parent.appendChild(resolved)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function reconstruct(
|
|
77
|
+
translated: string,
|
|
78
|
+
components: Node[],
|
|
79
|
+
): JSX.Element {
|
|
80
|
+
const tagRe = /<(\d+)>([\s\S]*?)<\/\1>/g
|
|
81
|
+
const result: unknown[] = []
|
|
82
|
+
let lastIndex = 0
|
|
83
|
+
let match: RegExpExecArray | null
|
|
84
|
+
|
|
85
|
+
tagRe.lastIndex = 0
|
|
86
|
+
match = tagRe.exec(translated)
|
|
87
|
+
while (match !== null) {
|
|
88
|
+
if (match.index > lastIndex) {
|
|
89
|
+
result.push(translated.slice(lastIndex, match.index))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const idx = Number(match[1])
|
|
93
|
+
const template = components[idx]
|
|
94
|
+
const inner = reconstruct(match[2]!, components)
|
|
95
|
+
if (template) {
|
|
96
|
+
const clone = template.cloneNode(false)
|
|
97
|
+
appendChild(clone, inner)
|
|
98
|
+
result.push(clone)
|
|
99
|
+
} else {
|
|
100
|
+
result.push(match[2]!)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
lastIndex = tagRe.lastIndex
|
|
104
|
+
match = tagRe.exec(translated)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (lastIndex < translated.length) {
|
|
108
|
+
result.push(translated.slice(lastIndex))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (result.length <= 1 ? result[0] ?? '' : result) as JSX.Element
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function serializeRichForms<T extends string>(
|
|
115
|
+
keys: readonly T[],
|
|
116
|
+
forms: Partial<Record<T, unknown>> & Record<string, unknown>,
|
|
117
|
+
): {
|
|
118
|
+
messages: Record<string, string>
|
|
119
|
+
components: Node[]
|
|
120
|
+
} {
|
|
121
|
+
const messages: Record<string, string> = {}
|
|
122
|
+
const components: Node[] = []
|
|
123
|
+
|
|
124
|
+
for (const key of keys) {
|
|
125
|
+
const value = forms[key]
|
|
126
|
+
if (value === undefined) continue
|
|
127
|
+
const extracted = extractMessage(value)
|
|
128
|
+
messages[key] = offsetIndices(extracted.message, components.length)
|
|
129
|
+
components.push(...extracted.components)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const [key, value] of Object.entries(forms)) {
|
|
133
|
+
if (keys.includes(key as T) || value === undefined) continue
|
|
134
|
+
const extracted = extractMessage(value)
|
|
135
|
+
messages[key] = offsetIndices(extracted.message, components.length)
|
|
136
|
+
components.push(...extracted.components)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { messages, components }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function buildICUSelectMessage(forms: Record<string, string>): string {
|
|
143
|
+
return `{value, select, ${Object.entries(forms).map(([key, text]) => `${key} {${text}}`).join(' ')}}`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function normalizeSelectForms(forms: Record<string, string>): {
|
|
147
|
+
forms: Record<string, string>
|
|
148
|
+
valueMap: Record<string, string>
|
|
149
|
+
} {
|
|
150
|
+
const normalized: Record<string, string> = {}
|
|
151
|
+
const valueMap: Record<string, string> = {}
|
|
152
|
+
let index = 0
|
|
153
|
+
|
|
154
|
+
for (const [key, text] of Object.entries(forms)) {
|
|
155
|
+
if (key === 'other') {
|
|
156
|
+
normalized['other'] = text
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const safeKey = /^[A-Za-z0-9_]+$/.test(key) ? key : `case_${index++}`
|
|
161
|
+
normalized[safeKey] = text
|
|
162
|
+
valueMap[key] = safeKey
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (normalized['other'] === undefined) {
|
|
166
|
+
normalized['other'] = ''
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { forms: normalized, valueMap }
|
|
170
|
+
}
|