@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/select.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Component, JSX } from 'solid-js'
|
|
2
|
+
import { Dynamic } from 'solid-js/web'
|
|
3
|
+
import { hashMessage } from '@fluenti/core'
|
|
4
|
+
import { useI18n } from './use-i18n'
|
|
5
|
+
import { buildICUSelectMessage, normalizeSelectForms, reconstruct, serializeRichForms } from './rich-dom'
|
|
6
|
+
|
|
7
|
+
/** Props for the `<Select>` component */
|
|
8
|
+
export interface SelectProps {
|
|
9
|
+
/** The value to match against prop keys */
|
|
10
|
+
value: string
|
|
11
|
+
/** Override the auto-generated synthetic ICU message id */
|
|
12
|
+
id?: string
|
|
13
|
+
/** Message context used for identity and translator disambiguation */
|
|
14
|
+
context?: string
|
|
15
|
+
/** Translator-facing note preserved in extraction catalogs */
|
|
16
|
+
comment?: string
|
|
17
|
+
/** Fallback message when no key matches */
|
|
18
|
+
other: string | JSX.Element
|
|
19
|
+
/**
|
|
20
|
+
* Named options map. Keys are match values, values are display strings or JSX.
|
|
21
|
+
* Takes precedence over dynamic attrs when both are provided.
|
|
22
|
+
*
|
|
23
|
+
* @example `{ male: 'He', female: 'She' }`
|
|
24
|
+
*/
|
|
25
|
+
options?: Record<string, string | JSX.Element>
|
|
26
|
+
/** Wrapper element tag name (default: `span`) */
|
|
27
|
+
tag?: string
|
|
28
|
+
/** Additional key/message pairs for matching (attrs fallback) */
|
|
29
|
+
[key: string]: unknown
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Render a message selected by matching `value` against prop keys.
|
|
34
|
+
*
|
|
35
|
+
* Options can be provided via the type-safe `options` prop (recommended)
|
|
36
|
+
* or as direct attrs (convenience). When both are present, `options` takes
|
|
37
|
+
* precedence.
|
|
38
|
+
*
|
|
39
|
+
* Rich text is supported via JSX element values in the `options` prop or
|
|
40
|
+
* as direct JSX element props:
|
|
41
|
+
* ```tsx
|
|
42
|
+
* <Select
|
|
43
|
+
* value={gender()}
|
|
44
|
+
* options={{
|
|
45
|
+
* male: <><strong>He</strong> liked this</>,
|
|
46
|
+
* female: <><strong>She</strong> liked this</>,
|
|
47
|
+
* }}
|
|
48
|
+
* other={<><em>They</em> liked this</>}
|
|
49
|
+
* />
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* Falls back to the `other` prop when no key matches.
|
|
53
|
+
*/
|
|
54
|
+
export const SelectComp: Component<SelectProps> = (props) => {
|
|
55
|
+
const { t } = useI18n()
|
|
56
|
+
|
|
57
|
+
const resolvedTag = () => props.tag ?? 'span'
|
|
58
|
+
|
|
59
|
+
const content = () => {
|
|
60
|
+
const forms: Record<string, unknown> = props.options !== undefined
|
|
61
|
+
? { ...props.options, other: props.other }
|
|
62
|
+
: {
|
|
63
|
+
...Object.fromEntries(
|
|
64
|
+
Object.entries(props).filter(([key]) => !['value', 'id', 'context', 'comment', 'options', 'other', 'tag'].includes(key)),
|
|
65
|
+
),
|
|
66
|
+
other: props.other,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const orderedKeys = [...Object.keys(forms).filter(key => key !== 'other'), 'other'] as const
|
|
70
|
+
const { messages, components } = serializeRichForms(orderedKeys, forms)
|
|
71
|
+
const normalized = normalizeSelectForms(
|
|
72
|
+
Object.fromEntries([...orderedKeys].map((key) => [key, messages[key] ?? ''])),
|
|
73
|
+
)
|
|
74
|
+
const translated = t(
|
|
75
|
+
{
|
|
76
|
+
id: props.id ?? (props.context === undefined
|
|
77
|
+
? buildICUSelectMessage(normalized.forms)
|
|
78
|
+
: hashMessage(buildICUSelectMessage(normalized.forms), props.context)),
|
|
79
|
+
message: buildICUSelectMessage(normalized.forms),
|
|
80
|
+
...(props.context !== undefined ? { context: props.context } : {}),
|
|
81
|
+
...(props.comment !== undefined ? { comment: props.comment } : {}),
|
|
82
|
+
},
|
|
83
|
+
{ value: normalized.valueMap[props.value] ?? 'other' },
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return components.length > 0 ? reconstruct(translated, components) : translated
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (<Dynamic component={resolvedTag()}>{content()}</Dynamic>) as JSX.Element
|
|
90
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createFluent } from '@fluenti/core'
|
|
2
|
+
import type {
|
|
3
|
+
FluentInstanceExtended,
|
|
4
|
+
FluentConfigExtended,
|
|
5
|
+
Locale,
|
|
6
|
+
Messages,
|
|
7
|
+
DateFormatOptions,
|
|
8
|
+
NumberFormatOptions,
|
|
9
|
+
} from '@fluenti/core'
|
|
10
|
+
|
|
11
|
+
// Re-export SSR utilities from core for convenience
|
|
12
|
+
export { detectLocale, getSSRLocaleScript, getHydratedLocale, isRTL, getDirection } from '@fluenti/core'
|
|
13
|
+
export type { DetectLocaleOptions } from '@fluenti/core'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Configuration for `createServerI18n`.
|
|
17
|
+
*/
|
|
18
|
+
export interface ServerI18nConfig {
|
|
19
|
+
/** Load messages for a given locale. Called once per locale per request. */
|
|
20
|
+
loadMessages: (locale: string) => Promise<Messages | { default: Messages }>
|
|
21
|
+
/** Fallback locale when a translation is missing */
|
|
22
|
+
fallbackLocale?: string
|
|
23
|
+
/**
|
|
24
|
+
* Auto-resolve locale when `setLocale()` was not called.
|
|
25
|
+
*
|
|
26
|
+
* This is the fallback for contexts where the layout doesn't run — most
|
|
27
|
+
* notably `"use server"` functions, which are independent requests
|
|
28
|
+
* that skip the layout tree entirely.
|
|
29
|
+
*
|
|
30
|
+
* Common patterns:
|
|
31
|
+
* - Read from a cookie via `getRequestEvent()`
|
|
32
|
+
* - Read from a request header set by middleware
|
|
33
|
+
*
|
|
34
|
+
* If omitted and `setLocale()` was not called, `getI18n()` will throw.
|
|
35
|
+
*/
|
|
36
|
+
resolveLocale?: () => string | Promise<string>
|
|
37
|
+
/** Custom fallback chains per locale */
|
|
38
|
+
fallbackChain?: Record<string, Locale[]>
|
|
39
|
+
/** Custom date format styles */
|
|
40
|
+
dateFormats?: DateFormatOptions
|
|
41
|
+
/** Custom number format styles */
|
|
42
|
+
numberFormats?: NumberFormatOptions
|
|
43
|
+
/** Handler for missing translation keys */
|
|
44
|
+
missing?: (locale: Locale, id: string) => string | undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The object returned by `createServerI18n`.
|
|
49
|
+
*/
|
|
50
|
+
export interface ServerI18n {
|
|
51
|
+
/**
|
|
52
|
+
* Set the locale for the current server request.
|
|
53
|
+
* Call this once in your entry-server or root layout before any `getI18n()` calls.
|
|
54
|
+
*/
|
|
55
|
+
setLocale: (locale: string) => void
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get a fully configured i18n instance for the current request.
|
|
59
|
+
* Messages are loaded lazily and cached.
|
|
60
|
+
*/
|
|
61
|
+
getI18n: () => Promise<FluentInstanceExtended & { locale: string }>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create server-side i18n utilities for SolidStart.
|
|
66
|
+
*
|
|
67
|
+
* Unlike React's `createServerI18n` which uses `React.cache()` for RSC
|
|
68
|
+
* per-request isolation, this version uses a simple module-level store
|
|
69
|
+
* matching SolidStart's synchronous rendering model.
|
|
70
|
+
*
|
|
71
|
+
* For per-request isolation in SolidStart, use `getRequestEvent()` in
|
|
72
|
+
* your `resolveLocale` callback, or call `setLocale()` in your
|
|
73
|
+
* entry-server middleware.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* // lib/i18n.server.ts
|
|
78
|
+
* import { createServerI18n } from '@fluenti/solid/server'
|
|
79
|
+
*
|
|
80
|
+
* export const { setLocale, getI18n } = createServerI18n({
|
|
81
|
+
* loadMessages: (locale) => import(`../locales/compiled/${locale}.ts`),
|
|
82
|
+
* fallbackLocale: 'en',
|
|
83
|
+
* })
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export function createServerI18n(config: ServerI18nConfig): ServerI18n {
|
|
87
|
+
let currentLocale: string | null = null
|
|
88
|
+
let cachedInstance: (FluentInstanceExtended & { locale: string }) | null = null
|
|
89
|
+
const messageCache = new Map<string, Messages>()
|
|
90
|
+
|
|
91
|
+
function setLocale(locale: string): void {
|
|
92
|
+
currentLocale = locale
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function loadLocaleMessages(locale: string): Promise<Messages> {
|
|
96
|
+
const cached = messageCache.get(locale)
|
|
97
|
+
if (cached) return cached
|
|
98
|
+
|
|
99
|
+
const raw = await config.loadMessages(locale)
|
|
100
|
+
const messages: Messages =
|
|
101
|
+
typeof raw === 'object' && raw !== null && 'default' in raw
|
|
102
|
+
? (raw as { default: Messages }).default
|
|
103
|
+
: (raw as Messages)
|
|
104
|
+
|
|
105
|
+
messageCache.set(locale, messages)
|
|
106
|
+
return messages
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function getI18n(): Promise<FluentInstanceExtended & { locale: string }> {
|
|
110
|
+
// If setLocale() was never called, try the resolveLocale fallback.
|
|
111
|
+
if (!currentLocale && config.resolveLocale) {
|
|
112
|
+
currentLocale = await config.resolveLocale()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const locale = currentLocale
|
|
116
|
+
|
|
117
|
+
if (!locale) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
'[fluenti] No locale set. Call setLocale(locale) in your entry-server or layout before using getI18n(), ' +
|
|
120
|
+
'or provide a resolveLocale function in createServerI18n config to auto-detect locale ' +
|
|
121
|
+
'in server functions and other contexts where the layout does not run.',
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Return cached instance if locale hasn't changed
|
|
126
|
+
if (cachedInstance && cachedInstance.locale === locale) {
|
|
127
|
+
return cachedInstance
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Load messages for current locale (and fallback if configured)
|
|
131
|
+
const allMessages: Record<string, Messages> = {}
|
|
132
|
+
allMessages[locale] = await loadLocaleMessages(locale)
|
|
133
|
+
|
|
134
|
+
if (config.fallbackLocale && config.fallbackLocale !== locale) {
|
|
135
|
+
allMessages[config.fallbackLocale] = await loadLocaleMessages(config.fallbackLocale)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const fluentConfig: FluentConfigExtended = {
|
|
139
|
+
locale,
|
|
140
|
+
messages: allMessages,
|
|
141
|
+
}
|
|
142
|
+
if (config.fallbackLocale !== undefined) fluentConfig.fallbackLocale = config.fallbackLocale
|
|
143
|
+
if (config.fallbackChain !== undefined) fluentConfig.fallbackChain = config.fallbackChain
|
|
144
|
+
if (config.dateFormats !== undefined) fluentConfig.dateFormats = config.dateFormats
|
|
145
|
+
if (config.numberFormats !== undefined) fluentConfig.numberFormats = config.numberFormats
|
|
146
|
+
if (config.missing !== undefined) fluentConfig.missing = config.missing
|
|
147
|
+
|
|
148
|
+
cachedInstance = createFluent(fluentConfig)
|
|
149
|
+
return cachedInstance
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { setLocale, getI18n }
|
|
153
|
+
}
|
package/src/trans.tsx
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { Dynamic } from 'solid-js/web'
|
|
2
|
+
import { children as resolveChildren, createMemo } from 'solid-js'
|
|
3
|
+
import type { Component, JSX } from 'solid-js'
|
|
4
|
+
import { useI18n } from './use-i18n'
|
|
5
|
+
import { extractMessage as extractDomMessage, reconstruct as reconstructDomMessage } from './rich-dom'
|
|
6
|
+
|
|
7
|
+
/** A Solid component that accepts children */
|
|
8
|
+
export type RichComponent = Component<{ children?: JSX.Element }>
|
|
9
|
+
|
|
10
|
+
/** Props for the `<Trans>` component */
|
|
11
|
+
export interface TransProps {
|
|
12
|
+
/** Override auto-generated hash ID */
|
|
13
|
+
id?: string
|
|
14
|
+
/** Message context used for identity and translator disambiguation */
|
|
15
|
+
context?: string
|
|
16
|
+
/** Translator-facing note preserved in extraction catalogs */
|
|
17
|
+
comment?: string
|
|
18
|
+
/** Wrapper element tag name (default: `'span'`) — used in children-only mode */
|
|
19
|
+
tag?: string
|
|
20
|
+
/** Children — the content to translate (legacy API) */
|
|
21
|
+
children?: JSX.Element
|
|
22
|
+
/** Translated message string with XML-like tags (e.g. `<bold>text</bold>`) */
|
|
23
|
+
message?: string
|
|
24
|
+
/** Map of tag names to Solid components */
|
|
25
|
+
components?: Record<string, RichComponent>
|
|
26
|
+
/** @internal Pre-computed message from build plugin */
|
|
27
|
+
__message?: string
|
|
28
|
+
/** @internal Pre-computed component map from build plugin */
|
|
29
|
+
__components?: Record<string, RichComponent>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A token from parsing the message string.
|
|
34
|
+
* Either a plain text segment or a tag with inner content.
|
|
35
|
+
*/
|
|
36
|
+
interface TextToken {
|
|
37
|
+
readonly type: 'text'
|
|
38
|
+
readonly value: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface TagToken {
|
|
42
|
+
readonly type: 'tag'
|
|
43
|
+
readonly name: string
|
|
44
|
+
readonly children: readonly Token[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Token = TextToken | TagToken
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse a message string containing XML-like tags into a token tree.
|
|
51
|
+
*
|
|
52
|
+
* Supports:
|
|
53
|
+
* - Named tags: `<bold>content</bold>`
|
|
54
|
+
* - Self-closing tags: `<br/>`
|
|
55
|
+
* - Nested tags: `<bold>hello <italic>world</italic></bold>`
|
|
56
|
+
*/
|
|
57
|
+
function parseTokens(input: string): readonly Token[] {
|
|
58
|
+
const tokens: Token[] = []
|
|
59
|
+
let pos = 0
|
|
60
|
+
|
|
61
|
+
while (pos < input.length) {
|
|
62
|
+
const openIdx = input.indexOf('<', pos)
|
|
63
|
+
|
|
64
|
+
if (openIdx === -1) {
|
|
65
|
+
// No more tags — rest is plain text
|
|
66
|
+
tokens.push({ type: 'text', value: input.slice(pos) })
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Push any text before this tag
|
|
71
|
+
if (openIdx > pos) {
|
|
72
|
+
tokens.push({ type: 'text', value: input.slice(pos, openIdx) })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check for self-closing tag: <tagName/>
|
|
76
|
+
const selfCloseMatch = input.slice(openIdx).match(/^<(\w+)\s*\/>/)
|
|
77
|
+
if (selfCloseMatch) {
|
|
78
|
+
tokens.push({ type: 'tag', name: selfCloseMatch[1]!, children: [] })
|
|
79
|
+
pos = openIdx + selfCloseMatch[0].length
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check for opening tag: <tagName>
|
|
84
|
+
const openMatch = input.slice(openIdx).match(/^<(\w+)>/)
|
|
85
|
+
if (!openMatch) {
|
|
86
|
+
// Not a valid tag — treat '<' as text
|
|
87
|
+
tokens.push({ type: 'text', value: '<' })
|
|
88
|
+
pos = openIdx + 1
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const tagName = openMatch[1]!
|
|
93
|
+
const contentStart = openIdx + openMatch[0].length
|
|
94
|
+
|
|
95
|
+
// Find the matching closing tag, respecting nesting
|
|
96
|
+
const innerEnd = findClosingTag(input, tagName, contentStart)
|
|
97
|
+
if (innerEnd === -1) {
|
|
98
|
+
// No closing tag found — treat as plain text
|
|
99
|
+
tokens.push({ type: 'text', value: input.slice(openIdx, contentStart) })
|
|
100
|
+
pos = contentStart
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const innerContent = input.slice(contentStart, innerEnd)
|
|
105
|
+
const closingTag = `</${tagName}>`
|
|
106
|
+
tokens.push({
|
|
107
|
+
type: 'tag',
|
|
108
|
+
name: tagName,
|
|
109
|
+
children: parseTokens(innerContent),
|
|
110
|
+
})
|
|
111
|
+
pos = innerEnd + closingTag.length
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return tokens
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Find the position of the matching closing tag, accounting for nesting
|
|
119
|
+
* of the same tag name.
|
|
120
|
+
*
|
|
121
|
+
* Returns the index of the start of the closing tag, or -1 if not found.
|
|
122
|
+
*/
|
|
123
|
+
function findClosingTag(input: string, tagName: string, startPos: number): number {
|
|
124
|
+
const openTag = `<${tagName}>`
|
|
125
|
+
const closeTag = `</${tagName}>`
|
|
126
|
+
let depth = 1
|
|
127
|
+
let pos = startPos
|
|
128
|
+
|
|
129
|
+
while (pos < input.length && depth > 0) {
|
|
130
|
+
const nextOpen = input.indexOf(openTag, pos)
|
|
131
|
+
const nextClose = input.indexOf(closeTag, pos)
|
|
132
|
+
|
|
133
|
+
if (nextClose === -1) return -1
|
|
134
|
+
|
|
135
|
+
if (nextOpen !== -1 && nextOpen < nextClose) {
|
|
136
|
+
depth++
|
|
137
|
+
pos = nextOpen + openTag.length
|
|
138
|
+
} else {
|
|
139
|
+
depth--
|
|
140
|
+
if (depth === 0) return nextClose
|
|
141
|
+
pos = nextClose + closeTag.length
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return -1
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Render a token tree into Solid JSX elements using the components map.
|
|
150
|
+
*/
|
|
151
|
+
function renderTokens(
|
|
152
|
+
tokens: readonly Token[],
|
|
153
|
+
components: Record<string, RichComponent>,
|
|
154
|
+
): JSX.Element {
|
|
155
|
+
const elements = tokens.map((token): JSX.Element => {
|
|
156
|
+
if (token.type === 'text') {
|
|
157
|
+
return token.value as unknown as JSX.Element
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const Comp = components[token.name]
|
|
161
|
+
if (!Comp) {
|
|
162
|
+
// Unknown component — render inner content as plain text
|
|
163
|
+
return renderTokens(token.children, components)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const innerContent = token.children.length > 0
|
|
167
|
+
? renderTokens(token.children, components)
|
|
168
|
+
: undefined
|
|
169
|
+
|
|
170
|
+
return (<Dynamic component={Comp}>{innerContent}</Dynamic>) as JSX.Element
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
if (elements.length === 1) return elements[0]!
|
|
174
|
+
return (<>{elements}</>) as JSX.Element
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Render translated content with inline components.
|
|
179
|
+
*
|
|
180
|
+
* Supports two APIs:
|
|
181
|
+
*
|
|
182
|
+
* 1. **message + components** (recommended for rich text):
|
|
183
|
+
* ```tsx
|
|
184
|
+
* <Trans
|
|
185
|
+
* message={t`Welcome to <bold>Fluenti</bold>!`}
|
|
186
|
+
* components={{ bold: (props) => <strong>{props.children}</strong> }}
|
|
187
|
+
* />
|
|
188
|
+
* ```
|
|
189
|
+
*
|
|
190
|
+
* 2. **children** (legacy / simple passthrough):
|
|
191
|
+
* ```tsx
|
|
192
|
+
* <Trans>Click <a href="/next">here</a> to continue</Trans>
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
export const Trans: Component<TransProps> = (props) => {
|
|
196
|
+
const { t } = useI18n()
|
|
197
|
+
const resolvedChildren = resolveChildren(() => props.children)
|
|
198
|
+
// message + components API (including build-time __message/__components)
|
|
199
|
+
// Note: the vite-plugin tagged-template transform wraps Solid expressions in
|
|
200
|
+
// createMemo(), so props.message may be a memo accessor (function) instead of
|
|
201
|
+
// a string. We unwrap it here to handle both cases.
|
|
202
|
+
const message = createMemo(() => {
|
|
203
|
+
const raw = props.__message ?? props.message
|
|
204
|
+
return typeof raw === 'function' ? (raw as () => string)() : raw
|
|
205
|
+
})
|
|
206
|
+
const components = createMemo(() => props.__components ?? props.components)
|
|
207
|
+
|
|
208
|
+
return (() => {
|
|
209
|
+
const msg = message()
|
|
210
|
+
const comps = components()
|
|
211
|
+
|
|
212
|
+
if (msg !== undefined && comps) {
|
|
213
|
+
const translated = t({
|
|
214
|
+
...(props.id !== undefined ? { id: props.id } : {}),
|
|
215
|
+
message: msg,
|
|
216
|
+
...(props.context !== undefined ? { context: props.context } : {}),
|
|
217
|
+
...(props.comment !== undefined ? { comment: props.comment } : {}),
|
|
218
|
+
})
|
|
219
|
+
const tokens = parseTokens(translated)
|
|
220
|
+
return renderTokens(tokens, comps)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Fallback: children-only API with runtime extraction/reconstruction
|
|
224
|
+
const children = resolvedChildren.toArray()
|
|
225
|
+
if (children.length === 0) return null
|
|
226
|
+
const extracted = extractDomMessage(children)
|
|
227
|
+
const translated = t({
|
|
228
|
+
...(props.id !== undefined ? { id: props.id } : {}),
|
|
229
|
+
message: extracted.message,
|
|
230
|
+
...(props.context !== undefined ? { context: props.context } : {}),
|
|
231
|
+
...(props.comment !== undefined ? { comment: props.comment } : {}),
|
|
232
|
+
})
|
|
233
|
+
const result = extracted.components.length > 0
|
|
234
|
+
? reconstructDomMessage(translated, extracted.components)
|
|
235
|
+
: translated
|
|
236
|
+
|
|
237
|
+
if (Array.isArray(result) && result.length > 1) {
|
|
238
|
+
return (<Dynamic component={props.tag ?? 'span'}>{result}</Dynamic>) as JSX.Element
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return result as JSX.Element
|
|
242
|
+
}) as unknown as JSX.Element
|
|
243
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Accessor } from 'solid-js'
|
|
2
|
+
import type {
|
|
3
|
+
FluentConfig,
|
|
4
|
+
Locale,
|
|
5
|
+
Messages,
|
|
6
|
+
CompiledMessage,
|
|
7
|
+
MessageDescriptor,
|
|
8
|
+
DateFormatOptions,
|
|
9
|
+
NumberFormatOptions,
|
|
10
|
+
} from '@fluenti/core'
|
|
11
|
+
|
|
12
|
+
/** Chunk loader for lazy locale loading */
|
|
13
|
+
export type ChunkLoader = (
|
|
14
|
+
locale: string,
|
|
15
|
+
) => Promise<Record<string, CompiledMessage> | { default: Record<string, CompiledMessage> }>
|
|
16
|
+
|
|
17
|
+
/** Extended config with lazy locale loading support */
|
|
18
|
+
export interface I18nConfig extends FluentConfig {
|
|
19
|
+
/** Async chunk loader for lazy locale loading */
|
|
20
|
+
chunkLoader?: ChunkLoader
|
|
21
|
+
/** Enable lazy locale loading through chunkLoader */
|
|
22
|
+
lazyLocaleLoading?: boolean
|
|
23
|
+
/** Named date format styles */
|
|
24
|
+
dateFormats?: DateFormatOptions
|
|
25
|
+
/** Named number format styles */
|
|
26
|
+
numberFormats?: NumberFormatOptions
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Reactive i18n context holding locale signal and translation utilities */
|
|
30
|
+
export interface I18nContext {
|
|
31
|
+
/** Reactive accessor for the current locale */
|
|
32
|
+
locale(): Locale
|
|
33
|
+
/** Set the active locale (async when lazy locale loading is enabled) */
|
|
34
|
+
setLocale(locale: Locale): Promise<void>
|
|
35
|
+
/** Translate a message by id with optional interpolation values */
|
|
36
|
+
t(id: string | MessageDescriptor, values?: Record<string, unknown>): string
|
|
37
|
+
/** Tagged template form: t`Hello ${name}` */
|
|
38
|
+
t(strings: TemplateStringsArray, ...exprs: unknown[]): string
|
|
39
|
+
/** Merge additional messages into a locale catalog at runtime */
|
|
40
|
+
loadMessages(locale: Locale, messages: Messages): void
|
|
41
|
+
/** Return all locale codes that have loaded messages */
|
|
42
|
+
getLocales(): Locale[]
|
|
43
|
+
/** Format a date value for the current locale */
|
|
44
|
+
d(value: Date | number, style?: string): string
|
|
45
|
+
/** Format a number value for the current locale */
|
|
46
|
+
n(value: number, style?: string): string
|
|
47
|
+
/** Format an ICU message string directly (no catalog lookup) */
|
|
48
|
+
format(message: string, values?: Record<string, unknown>): string
|
|
49
|
+
/** Whether a locale chunk is currently being loaded */
|
|
50
|
+
isLoading: Accessor<boolean>
|
|
51
|
+
/** Set of locales whose messages have been loaded */
|
|
52
|
+
loadedLocales: Accessor<Set<string>>
|
|
53
|
+
/** Preload a locale in the background without switching to it */
|
|
54
|
+
preloadLocale(locale: string): void
|
|
55
|
+
}
|
package/src/use-i18n.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useContext } from 'solid-js'
|
|
2
|
+
import { I18nCtx } from './provider'
|
|
3
|
+
import { getGlobalI18nContext } from './context'
|
|
4
|
+
import type { I18nContext } from './context'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Access the i18n context.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order:
|
|
10
|
+
* 1. Nearest `<I18nProvider>` in the component tree
|
|
11
|
+
* 2. Module-level singleton created by `createI18n()`
|
|
12
|
+
*
|
|
13
|
+
* Throws if neither is available.
|
|
14
|
+
*/
|
|
15
|
+
export function useI18n(): I18nContext {
|
|
16
|
+
const ctx = useContext(I18nCtx)
|
|
17
|
+
if (ctx) {
|
|
18
|
+
return ctx
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const global = getGlobalI18nContext()
|
|
22
|
+
if (global) {
|
|
23
|
+
return global
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
throw new Error(
|
|
27
|
+
'useI18n requires either createI18n() to be called at startup, ' +
|
|
28
|
+
'or the component to be inside an <I18nProvider>.',
|
|
29
|
+
)
|
|
30
|
+
}
|