@fluenti/solid 0.2.0 → 0.3.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/README.md +5 -5
- package/dist/context.d.ts +15 -24
- package/dist/context.d.ts.map +1 -1
- package/dist/hooks/__useI18n.d.ts +2 -2
- package/dist/hooks/__useI18n.d.ts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +129 -167
- package/dist/index.js.map +1 -1
- package/dist/plural.d.ts +3 -3
- package/dist/plural.d.ts.map +1 -1
- package/dist/provider.d.ts +3 -3
- package/dist/provider.d.ts.map +1 -1
- package/dist/rich-dom.d.ts +0 -6
- package/dist/rich-dom.d.ts.map +1 -1
- package/dist/select.d.ts +3 -3
- package/dist/select.d.ts.map +1 -1
- package/dist/server.cjs +2 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.ts +33 -17
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +54 -0
- package/dist/server.js.map +1 -0
- package/dist/solid-runtime.d.ts.map +1 -1
- package/dist/trans.d.ts +3 -3
- package/dist/trans.d.ts.map +1 -1
- package/dist/types.d.ts +8 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/use-i18n.d.ts +4 -8
- package/dist/use-i18n.d.ts.map +1 -1
- package/dist/vite-plugin.cjs +2 -113
- package/dist/vite-plugin.cjs.map +1 -1
- package/dist/vite-plugin.js +14 -123
- package/dist/vite-plugin.js.map +1 -1
- package/llms-full.txt +186 -0
- package/llms-migration.txt +176 -0
- package/llms.txt +64 -0
- package/package.json +17 -5
- package/src/context.ts +56 -77
- package/src/hooks/__useI18n.ts +2 -2
- package/src/index.ts +6 -6
- package/src/plural.tsx +9 -38
- package/src/provider.tsx +5 -5
- package/src/rich-dom.tsx +25 -47
- package/src/select.tsx +11 -8
- package/src/server.ts +94 -49
- package/src/solid-runtime.ts +15 -134
- package/src/trans.tsx +7 -4
- package/src/types.ts +9 -8
- package/src/use-i18n.ts +5 -16
- package/src/vite-plugin.ts +1 -1
package/src/plural.tsx
CHANGED
|
@@ -1,44 +1,11 @@
|
|
|
1
1
|
import { Dynamic } from 'solid-js/web'
|
|
2
2
|
import type { Component, JSX } from 'solid-js'
|
|
3
|
-
import { hashMessage } from '@fluenti/core'
|
|
3
|
+
import { hashMessage, buildICUPluralMessage, PLURAL_CATEGORIES, type PluralCategory } from '@fluenti/core/internal'
|
|
4
4
|
import { useI18n } from './use-i18n'
|
|
5
5
|
import { reconstruct, serializeRichForms } from './rich-dom'
|
|
6
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
7
|
/** Props for the `<Plural>` component */
|
|
41
|
-
export interface
|
|
8
|
+
export interface FluentiPluralProps {
|
|
42
9
|
/** The numeric value to pluralise */
|
|
43
10
|
value: number
|
|
44
11
|
/** Override the auto-generated synthetic ICU message id */
|
|
@@ -61,7 +28,7 @@ export interface PluralProps {
|
|
|
61
28
|
many?: string | JSX.Element
|
|
62
29
|
/** Fallback message when no category-specific prop matches */
|
|
63
30
|
other: string | JSX.Element
|
|
64
|
-
/** Wrapper element tag name (
|
|
31
|
+
/** Wrapper element tag name. Defaults to no wrapper (Fragment). */
|
|
65
32
|
tag?: string
|
|
66
33
|
}
|
|
67
34
|
|
|
@@ -90,7 +57,7 @@ export interface PluralProps {
|
|
|
90
57
|
* <Plural value={count()} zero="No items" one="# item" other="# items" />
|
|
91
58
|
* ```
|
|
92
59
|
*/
|
|
93
|
-
export const Plural: Component<
|
|
60
|
+
export const Plural: Component<FluentiPluralProps> = (props) => {
|
|
94
61
|
const { t } = useI18n()
|
|
95
62
|
|
|
96
63
|
/** Resolve a category prop value — handles string, accessor function, and JSX */
|
|
@@ -131,6 +98,10 @@ export const Plural: Component<PluralProps> = (props) => {
|
|
|
131
98
|
{ count: props.value },
|
|
132
99
|
)
|
|
133
100
|
|
|
134
|
-
|
|
101
|
+
const result = components.length > 0 ? reconstruct(translated, components) : translated
|
|
102
|
+
if (props.tag) {
|
|
103
|
+
return (<Dynamic component={props.tag}>{result}</Dynamic>) as JSX.Element
|
|
104
|
+
}
|
|
105
|
+
return (<>{result}</>) as JSX.Element
|
|
135
106
|
}) as unknown as JSX.Element
|
|
136
107
|
}
|
package/src/provider.tsx
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { createContext } from 'solid-js'
|
|
2
2
|
import type { ParentComponent } from 'solid-js'
|
|
3
|
-
import {
|
|
4
|
-
import type {
|
|
3
|
+
import { createFluentiContext } from './context'
|
|
4
|
+
import type { FluentiConfig, FluentiContext } from './context'
|
|
5
5
|
|
|
6
6
|
/** Solid context object for i18n — used internally by useI18n() */
|
|
7
|
-
export const I18nCtx = createContext<
|
|
7
|
+
export const I18nCtx = createContext<FluentiContext>()
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Provide i18n context to the component tree.
|
|
11
11
|
*
|
|
12
12
|
*/
|
|
13
|
-
export const I18nProvider: ParentComponent<
|
|
14
|
-
const ctx =
|
|
13
|
+
export const I18nProvider: ParentComponent<FluentiConfig> = (props) => {
|
|
14
|
+
const ctx = createFluentiContext(props)
|
|
15
15
|
return <I18nCtx.Provider value={ctx}>{props.children}</I18nCtx.Provider>
|
|
16
16
|
}
|
package/src/rich-dom.tsx
CHANGED
|
@@ -11,12 +11,7 @@ function resolveValue(value: unknown): unknown {
|
|
|
11
11
|
return value
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
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
|
-
}
|
|
14
|
+
import { offsetIndices } from '@fluenti/core/internal'
|
|
20
15
|
|
|
21
16
|
export function extractMessage(value: unknown): {
|
|
22
17
|
message: string
|
|
@@ -50,7 +45,11 @@ export function extractMessage(value: unknown): {
|
|
|
50
45
|
const inner = extractMessage(Array.from(resolved.childNodes))
|
|
51
46
|
components.push((resolved as Element).cloneNode(false))
|
|
52
47
|
components.push(...inner.components)
|
|
53
|
-
|
|
48
|
+
if (inner.message === '' && inner.components.length === 0) {
|
|
49
|
+
message += `<${idx}/>`
|
|
50
|
+
} else {
|
|
51
|
+
message += `<${idx}>${offsetIndices(inner.message, idx + 1)}</${idx}>`
|
|
52
|
+
}
|
|
54
53
|
}
|
|
55
54
|
|
|
56
55
|
visit(value)
|
|
@@ -77,31 +76,39 @@ export function reconstruct(
|
|
|
77
76
|
translated: string,
|
|
78
77
|
components: Node[],
|
|
79
78
|
): JSX.Element {
|
|
80
|
-
const
|
|
79
|
+
const combinedRe = /<(\d+)(?:\/>|(>)([\s\S]*?)<\/\1>)/g
|
|
81
80
|
const result: unknown[] = []
|
|
82
81
|
let lastIndex = 0
|
|
83
82
|
let match: RegExpExecArray | null
|
|
84
83
|
|
|
85
|
-
|
|
86
|
-
match =
|
|
84
|
+
combinedRe.lastIndex = 0
|
|
85
|
+
match = combinedRe.exec(translated)
|
|
87
86
|
while (match !== null) {
|
|
88
87
|
if (match.index > lastIndex) {
|
|
89
88
|
result.push(translated.slice(lastIndex, match.index))
|
|
90
89
|
}
|
|
91
90
|
|
|
92
91
|
const idx = Number(match[1])
|
|
92
|
+
const isSelfClosing = match[2] === undefined
|
|
93
93
|
const template = components[idx]
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
|
|
95
|
+
if (isSelfClosing) {
|
|
96
|
+
if (template) {
|
|
97
|
+
result.push(template.cloneNode(false))
|
|
98
|
+
}
|
|
99
99
|
} else {
|
|
100
|
-
|
|
100
|
+
const inner = reconstruct(match[2] !== undefined ? match[3]! : '', components)
|
|
101
|
+
if (template) {
|
|
102
|
+
const clone = template.cloneNode(false)
|
|
103
|
+
appendChild(clone, inner)
|
|
104
|
+
result.push(clone)
|
|
105
|
+
} else {
|
|
106
|
+
result.push(match[3] ?? '')
|
|
107
|
+
}
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
lastIndex =
|
|
104
|
-
match =
|
|
110
|
+
lastIndex = combinedRe.lastIndex
|
|
111
|
+
match = combinedRe.exec(translated)
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
if (lastIndex < translated.length) {
|
|
@@ -139,32 +146,3 @@ export function serializeRichForms<T extends string>(
|
|
|
139
146
|
return { messages, components }
|
|
140
147
|
}
|
|
141
148
|
|
|
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
|
-
}
|
package/src/select.tsx
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { Component, JSX } from 'solid-js'
|
|
2
2
|
import { Dynamic } from 'solid-js/web'
|
|
3
|
-
import { hashMessage } from '@fluenti/core'
|
|
3
|
+
import { hashMessage, buildICUSelectMessage, normalizeSelectForms } from '@fluenti/core/internal'
|
|
4
4
|
import { useI18n } from './use-i18n'
|
|
5
|
-
import {
|
|
5
|
+
import { reconstruct, serializeRichForms } from './rich-dom'
|
|
6
6
|
|
|
7
7
|
/** Props for the `<Select>` component */
|
|
8
|
-
export interface
|
|
8
|
+
export interface FluentiSelectProps {
|
|
9
9
|
/** The value to match against prop keys */
|
|
10
10
|
value: string
|
|
11
11
|
/** Override the auto-generated synthetic ICU message id */
|
|
@@ -23,7 +23,7 @@ export interface SelectProps {
|
|
|
23
23
|
* @example `{ male: 'He', female: 'She' }`
|
|
24
24
|
*/
|
|
25
25
|
options?: Record<string, string | JSX.Element>
|
|
26
|
-
/** Wrapper element tag name (
|
|
26
|
+
/** Wrapper element tag name. Defaults to no wrapper (Fragment). */
|
|
27
27
|
tag?: string
|
|
28
28
|
/** Additional key/message pairs for matching (attrs fallback) */
|
|
29
29
|
[key: string]: unknown
|
|
@@ -51,11 +51,9 @@ export interface SelectProps {
|
|
|
51
51
|
*
|
|
52
52
|
* Falls back to the `other` prop when no key matches.
|
|
53
53
|
*/
|
|
54
|
-
export const SelectComp: Component<
|
|
54
|
+
export const SelectComp: Component<FluentiSelectProps> = (props) => {
|
|
55
55
|
const { t } = useI18n()
|
|
56
56
|
|
|
57
|
-
const resolvedTag = () => props.tag ?? 'span'
|
|
58
|
-
|
|
59
57
|
const content = () => {
|
|
60
58
|
const forms: Record<string, unknown> = props.options !== undefined
|
|
61
59
|
? { ...props.options, other: props.other }
|
|
@@ -86,5 +84,10 @@ export const SelectComp: Component<SelectProps> = (props) => {
|
|
|
86
84
|
return components.length > 0 ? reconstruct(translated, components) : translated
|
|
87
85
|
}
|
|
88
86
|
|
|
89
|
-
return (
|
|
87
|
+
return (() => {
|
|
88
|
+
if (props.tag) {
|
|
89
|
+
return (<Dynamic component={props.tag}>{content()}</Dynamic>) as JSX.Element
|
|
90
|
+
}
|
|
91
|
+
return (<>{content()}</>) as JSX.Element
|
|
92
|
+
}) as unknown as JSX.Element
|
|
90
93
|
}
|
package/src/server.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { createFluentiCore } from '@fluenti/core'
|
|
2
3
|
import type {
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
FluentiCoreInstanceFull,
|
|
5
|
+
FluentiCoreConfigFull,
|
|
5
6
|
Locale,
|
|
6
7
|
Messages,
|
|
7
8
|
DateFormatOptions,
|
|
@@ -58,46 +59,78 @@ export interface ServerI18n {
|
|
|
58
59
|
* Get a fully configured i18n instance for the current request.
|
|
59
60
|
* Messages are loaded lazily and cached.
|
|
60
61
|
*/
|
|
61
|
-
getI18n: () => Promise<
|
|
62
|
+
getI18n: () => Promise<FluentiCoreInstanceFull & { locale: string }>
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run a callback with per-request locale isolation.
|
|
66
|
+
*
|
|
67
|
+
* Uses `AsyncLocalStorage` to scope locale and instance to the callback,
|
|
68
|
+
* preventing cross-request locale leakage in concurrent SSR environments.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```ts
|
|
72
|
+
* // In your SolidStart middleware
|
|
73
|
+
* export function middleware(event) {
|
|
74
|
+
* const locale = detectLocaleFromEvent(event)
|
|
75
|
+
* return withLocale(locale, () => handleRequest(event))
|
|
76
|
+
* }
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
withLocale: <T>(locale: string, fn: () => T | Promise<T>) => Promise<T>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Per-request store shape */
|
|
83
|
+
interface RequestStore {
|
|
84
|
+
locale: string | null
|
|
85
|
+
instance: (FluentiCoreInstanceFull & { locale: string }) | null
|
|
62
86
|
}
|
|
63
87
|
|
|
64
88
|
/**
|
|
65
89
|
* Create server-side i18n utilities for SolidStart.
|
|
66
90
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
-
* **⚠️ SSR Concurrency Warning**: This function uses module-level state for locale
|
|
76
|
-
* and cached instance. In concurrent SSR environments (e.g. multiple simultaneous
|
|
77
|
-
* requests), this can cause cross-request locale leakage. For per-request isolation:
|
|
78
|
-
* - Use `getRequestEvent()` in SolidStart to scope locale per request
|
|
79
|
-
* - Or create a separate `createServerI18n()` per request context
|
|
80
|
-
* - Consider using AsyncLocalStorage for true per-request isolation (future)
|
|
91
|
+
* Uses `AsyncLocalStorage` for per-request isolation of locale state.
|
|
92
|
+
* Wrap each request in `withLocale(locale, fn)` for safe concurrent SSR,
|
|
93
|
+
* or use `setLocale()` / `getI18n()` directly if concurrency is not a concern.
|
|
81
94
|
*
|
|
82
95
|
* @example
|
|
83
96
|
* ```ts
|
|
84
97
|
* // lib/i18n.server.ts
|
|
85
98
|
* import { createServerI18n } from '@fluenti/solid/server'
|
|
86
99
|
*
|
|
87
|
-
* export const { setLocale, getI18n } = createServerI18n({
|
|
100
|
+
* export const { setLocale, getI18n, withLocale } = createServerI18n({
|
|
88
101
|
* loadMessages: (locale) => import(`../locales/compiled/${locale}.ts`),
|
|
89
102
|
* fallbackLocale: 'en',
|
|
90
103
|
* })
|
|
91
104
|
* ```
|
|
105
|
+
*
|
|
106
|
+
* @example Per-request isolation (recommended for concurrent SSR):
|
|
107
|
+
* ```ts
|
|
108
|
+
* // src/middleware.ts
|
|
109
|
+
* import { withLocale } from './lib/i18n.server'
|
|
110
|
+
*
|
|
111
|
+
* export function middleware(event) {
|
|
112
|
+
* const locale = detectLocaleFromEvent(event)
|
|
113
|
+
* return withLocale(locale, () => handleRequest(event))
|
|
114
|
+
* }
|
|
115
|
+
* ```
|
|
92
116
|
*/
|
|
93
117
|
export function createServerI18n(config: ServerI18nConfig): ServerI18n {
|
|
94
|
-
|
|
95
|
-
|
|
118
|
+
const als = new AsyncLocalStorage<RequestStore>()
|
|
119
|
+
|
|
120
|
+
// Module-level message cache — safe to share across requests (keyed by locale)
|
|
96
121
|
const messageCache = new Map<string, Messages>()
|
|
97
122
|
|
|
123
|
+
// Module-level fallback store for when ALS context is not active
|
|
124
|
+
let fallbackStore: RequestStore = { locale: null, instance: null }
|
|
125
|
+
|
|
126
|
+
function getStore(): RequestStore {
|
|
127
|
+
return als.getStore() ?? fallbackStore
|
|
128
|
+
}
|
|
129
|
+
|
|
98
130
|
function setLocale(locale: string): void {
|
|
99
|
-
|
|
100
|
-
|
|
131
|
+
const store = getStore()
|
|
132
|
+
store.locale = locale
|
|
133
|
+
store.instance = null
|
|
101
134
|
}
|
|
102
135
|
|
|
103
136
|
async function loadLocaleMessages(locale: string): Promise<Messages> {
|
|
@@ -114,28 +147,7 @@ export function createServerI18n(config: ServerI18nConfig): ServerI18n {
|
|
|
114
147
|
return messages
|
|
115
148
|
}
|
|
116
149
|
|
|
117
|
-
async function
|
|
118
|
-
// If setLocale() was never called, try the resolveLocale fallback.
|
|
119
|
-
if (!currentLocale && config.resolveLocale) {
|
|
120
|
-
currentLocale = await config.resolveLocale()
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const locale = currentLocale
|
|
124
|
-
|
|
125
|
-
if (!locale) {
|
|
126
|
-
throw new Error(
|
|
127
|
-
'[fluenti] No locale set. Call setLocale(locale) in your entry-server or layout before using getI18n(), ' +
|
|
128
|
-
'or provide a resolveLocale function in createServerI18n config to auto-detect locale ' +
|
|
129
|
-
'in server functions and other contexts where the layout does not run.',
|
|
130
|
-
)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Return cached instance if locale hasn't changed
|
|
134
|
-
if (cachedInstance && cachedInstance.locale === locale) {
|
|
135
|
-
return cachedInstance
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Load messages for current locale (and fallback if configured)
|
|
150
|
+
async function buildInstance(locale: string): Promise<FluentiCoreInstanceFull & { locale: string }> {
|
|
139
151
|
const allMessages: Record<string, Messages> = {}
|
|
140
152
|
allMessages[locale] = await loadLocaleMessages(locale)
|
|
141
153
|
|
|
@@ -143,7 +155,7 @@ export function createServerI18n(config: ServerI18nConfig): ServerI18n {
|
|
|
143
155
|
allMessages[config.fallbackLocale] = await loadLocaleMessages(config.fallbackLocale)
|
|
144
156
|
}
|
|
145
157
|
|
|
146
|
-
const fluentConfig:
|
|
158
|
+
const fluentConfig: FluentiCoreConfigFull = {
|
|
147
159
|
locale,
|
|
148
160
|
messages: allMessages,
|
|
149
161
|
}
|
|
@@ -153,9 +165,42 @@ export function createServerI18n(config: ServerI18nConfig): ServerI18n {
|
|
|
153
165
|
if (config.numberFormats !== undefined) fluentConfig.numberFormats = config.numberFormats
|
|
154
166
|
if (config.missing !== undefined) fluentConfig.missing = config.missing
|
|
155
167
|
|
|
156
|
-
|
|
157
|
-
|
|
168
|
+
return createFluentiCore(fluentConfig)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function getI18n(): Promise<FluentiCoreInstanceFull & { locale: string }> {
|
|
172
|
+
const store = getStore()
|
|
173
|
+
|
|
174
|
+
// If setLocale() was never called, try the resolveLocale fallback.
|
|
175
|
+
if (!store.locale && config.resolveLocale) {
|
|
176
|
+
store.locale = await config.resolveLocale()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const locale = store.locale
|
|
180
|
+
|
|
181
|
+
if (!locale) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
'[fluenti] No locale set. Call setLocale(locale) in your entry-server or layout before using getI18n(), ' +
|
|
184
|
+
'or provide a resolveLocale function in createServerI18n config to auto-detect locale ' +
|
|
185
|
+
'in server functions and other contexts where the layout does not run.',
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Return cached instance if locale hasn't changed
|
|
190
|
+
if (store.instance && store.instance.locale === locale) {
|
|
191
|
+
return store.instance
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
store.instance = await buildInstance(locale)
|
|
195
|
+
return store.instance
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function withLocale<T>(locale: string, fn: () => T | Promise<T>): Promise<T> {
|
|
199
|
+
const store: RequestStore = { locale, instance: null }
|
|
200
|
+
return als.run(store, async () => {
|
|
201
|
+
return fn()
|
|
202
|
+
})
|
|
158
203
|
}
|
|
159
204
|
|
|
160
|
-
return { setLocale, getI18n }
|
|
205
|
+
return { setLocale, getI18n, withLocale }
|
|
161
206
|
}
|
package/src/solid-runtime.ts
CHANGED
|
@@ -1,134 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { RuntimeGenerator
|
|
3
|
-
|
|
4
|
-
export const solidRuntimeGenerator: RuntimeGenerator = {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const [__catalog, __setCatalog] = createStore({ ...__defaultMsgs })
|
|
18
|
-
const [__currentLocale, __setCurrentLocale] = createSignal('${defaultLocale}')
|
|
19
|
-
const __loadedLocales = new Set(['${defaultLocale}'])
|
|
20
|
-
const [__loading, __setLoading] = createSignal(false)
|
|
21
|
-
const __cache = new Map()
|
|
22
|
-
const __normalizeMessages = (mod) => mod.default ?? mod
|
|
23
|
-
|
|
24
|
-
const __loaders = {
|
|
25
|
-
${lazyLocales.map((l) => ` '${l}': () => import('${absoluteCatalogDir}/${l}.js'),`).join('\n')}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function __switchLocale(locale) {
|
|
29
|
-
if (__loadedLocales.has(locale)) {
|
|
30
|
-
__setCatalog(reconcile(__cache.get(locale) || __defaultMsgs))
|
|
31
|
-
__setCurrentLocale(locale)
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
|
-
__setLoading(true)
|
|
35
|
-
try {
|
|
36
|
-
const mod = __normalizeMessages(await __loaders[locale]())
|
|
37
|
-
__cache.set(locale, mod)
|
|
38
|
-
__loadedLocales.add(locale)
|
|
39
|
-
__setCatalog(reconcile(mod))
|
|
40
|
-
__setCurrentLocale(locale)
|
|
41
|
-
} finally {
|
|
42
|
-
__setLoading(false)
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async function __preloadLocale(locale) {
|
|
47
|
-
if (__loadedLocales.has(locale) || !__loaders[locale]) return
|
|
48
|
-
try {
|
|
49
|
-
const mod = __normalizeMessages(await __loaders[locale]())
|
|
50
|
-
__cache.set(locale, mod)
|
|
51
|
-
__loadedLocales.add(locale)
|
|
52
|
-
} catch (e) { console.warn('[fluenti] preload failed:', locale, e) }
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
globalThis[Symbol.for('${runtimeKey}')] = { __switchLocale, __preloadLocale }
|
|
56
|
-
|
|
57
|
-
export { __catalog, __switchLocale, __preloadLocale, __currentLocale, __loading, __loadedLocales }
|
|
58
|
-
`
|
|
59
|
-
},
|
|
60
|
-
|
|
61
|
-
generateRouteRuntime(options: RuntimeGeneratorOptions): string {
|
|
62
|
-
const { catalogDir, locales, sourceLocale, defaultBuildLocale } = options
|
|
63
|
-
const defaultLocale = defaultBuildLocale || sourceLocale
|
|
64
|
-
const absoluteCatalogDir = resolve(process.cwd(), catalogDir)
|
|
65
|
-
const runtimeKey = 'fluenti.runtime.solid'
|
|
66
|
-
const lazyLocales = locales.filter((locale) => locale !== defaultLocale)
|
|
67
|
-
|
|
68
|
-
return `
|
|
69
|
-
import { createSignal } from 'solid-js'
|
|
70
|
-
import { createStore, reconcile } from 'solid-js/store'
|
|
71
|
-
import __defaultMsgs from '${absoluteCatalogDir}/${defaultLocale}.js'
|
|
72
|
-
|
|
73
|
-
const [__catalog, __setCatalog] = createStore({ ...__defaultMsgs })
|
|
74
|
-
const [__currentLocale, __setCurrentLocale] = createSignal('${defaultLocale}')
|
|
75
|
-
const __loadedLocales = new Set(['${defaultLocale}'])
|
|
76
|
-
const [__loading, __setLoading] = createSignal(false)
|
|
77
|
-
const __cache = new Map()
|
|
78
|
-
const __loadedRoutes = new Set()
|
|
79
|
-
const __normalizeMessages = (mod) => mod.default ?? mod
|
|
80
|
-
|
|
81
|
-
const __loaders = {
|
|
82
|
-
${lazyLocales.map((l) => ` '${l}': () => import('${absoluteCatalogDir}/${l}.js'),`).join('\n')}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const __routeLoaders = {}
|
|
86
|
-
|
|
87
|
-
function __registerRouteLoader(routeId, locale, loader) {
|
|
88
|
-
const key = routeId + ':' + locale
|
|
89
|
-
__routeLoaders[key] = loader
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
async function __loadRoute(routeId, locale) {
|
|
93
|
-
const key = routeId + ':' + (locale || __currentLocale())
|
|
94
|
-
if (__loadedRoutes.has(key)) return
|
|
95
|
-
const loader = __routeLoaders[key]
|
|
96
|
-
if (!loader) return
|
|
97
|
-
const mod = __normalizeMessages(await loader())
|
|
98
|
-
__setCatalog(reconcile({ ...__catalog, ...mod }))
|
|
99
|
-
__loadedRoutes.add(key)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async function __switchLocale(locale) {
|
|
103
|
-
if (locale === __currentLocale()) return
|
|
104
|
-
__setLoading(true)
|
|
105
|
-
try {
|
|
106
|
-
if (__cache.has(locale)) {
|
|
107
|
-
__setCatalog(reconcile(__cache.get(locale)))
|
|
108
|
-
} else {
|
|
109
|
-
const mod = __normalizeMessages(await __loaders[locale]())
|
|
110
|
-
__cache.set(locale, mod)
|
|
111
|
-
__setCatalog(reconcile(mod))
|
|
112
|
-
}
|
|
113
|
-
__loadedLocales.add(locale)
|
|
114
|
-
__setCurrentLocale(locale)
|
|
115
|
-
} finally {
|
|
116
|
-
__setLoading(false)
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function __preloadLocale(locale) {
|
|
121
|
-
if (__cache.has(locale) || !__loaders[locale]) return
|
|
122
|
-
try {
|
|
123
|
-
const mod = __normalizeMessages(await __loaders[locale]())
|
|
124
|
-
__cache.set(locale, mod)
|
|
125
|
-
__loadedLocales.add(locale)
|
|
126
|
-
} catch (e) { console.warn('[fluenti] preload failed:', locale, e) }
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
globalThis[Symbol.for('${runtimeKey}')] = { __switchLocale, __preloadLocale }
|
|
130
|
-
|
|
131
|
-
export { __catalog, __switchLocale, __preloadLocale, __loadRoute, __registerRouteLoader, __currentLocale, __loading, __loadedLocales }
|
|
132
|
-
`
|
|
133
|
-
},
|
|
134
|
-
}
|
|
1
|
+
import { createRuntimeGenerator } from '@fluenti/vite-plugin'
|
|
2
|
+
import type { RuntimeGenerator } from '@fluenti/vite-plugin'
|
|
3
|
+
|
|
4
|
+
export const solidRuntimeGenerator: RuntimeGenerator = createRuntimeGenerator({
|
|
5
|
+
imports: `import { createSignal } from 'solid-js'\nimport { createStore, reconcile } from 'solid-js/store'`,
|
|
6
|
+
catalogInit: 'const [__catalog, __setCatalog] = createStore({ ...__defaultMsgs })',
|
|
7
|
+
localeInit: (defaultLocale) => `const [__currentLocale, __setCurrentLocale] = createSignal('${defaultLocale}')`,
|
|
8
|
+
loadingInit: 'const [__loading, __setLoading] = createSignal(false)',
|
|
9
|
+
catalogUpdate: (msgs) => `__setCatalog(reconcile(${msgs}))`,
|
|
10
|
+
catalogMerge: (msgs) => `__setCatalog(reconcile({ ...__catalog, ...${msgs} }))`,
|
|
11
|
+
localeUpdate: (locale) => `__setCurrentLocale(${locale})`,
|
|
12
|
+
loadingUpdate: (value) => `__setLoading(${value})`,
|
|
13
|
+
localeRead: '__currentLocale()',
|
|
14
|
+
runtimeKey: 'fluenti.runtime.solid.v1',
|
|
15
|
+
})
|
package/src/trans.tsx
CHANGED
|
@@ -8,14 +8,14 @@ import { extractMessage as extractDomMessage, reconstruct as reconstructDomMessa
|
|
|
8
8
|
export type RichComponent = Component<{ children?: JSX.Element }>
|
|
9
9
|
|
|
10
10
|
/** Props for the `<Trans>` component */
|
|
11
|
-
export interface
|
|
11
|
+
export interface FluentiTransProps {
|
|
12
12
|
/** Override auto-generated hash ID */
|
|
13
13
|
id?: string
|
|
14
14
|
/** Message context used for identity and translator disambiguation */
|
|
15
15
|
context?: string
|
|
16
16
|
/** Translator-facing note preserved in extraction catalogs */
|
|
17
17
|
comment?: string
|
|
18
|
-
/** Wrapper element tag name
|
|
18
|
+
/** Wrapper element tag name. Defaults to no wrapper (Fragment). */
|
|
19
19
|
tag?: string
|
|
20
20
|
/** Children — the content to translate (legacy API) */
|
|
21
21
|
children?: JSX.Element
|
|
@@ -192,7 +192,7 @@ function renderTokens(
|
|
|
192
192
|
* <Trans>Click <a href="/next">here</a> to continue</Trans>
|
|
193
193
|
* ```
|
|
194
194
|
*/
|
|
195
|
-
export const Trans: Component<
|
|
195
|
+
export const Trans: Component<FluentiTransProps> = (props) => {
|
|
196
196
|
const { t } = useI18n()
|
|
197
197
|
const resolvedChildren = resolveChildren(() => props.children)
|
|
198
198
|
// message + components API (including build-time __message/__components)
|
|
@@ -235,7 +235,10 @@ export const Trans: Component<TransProps> = (props) => {
|
|
|
235
235
|
: translated
|
|
236
236
|
|
|
237
237
|
if (Array.isArray(result) && result.length > 1) {
|
|
238
|
-
|
|
238
|
+
if (props.tag) {
|
|
239
|
+
return (<Dynamic component={props.tag}>{result}</Dynamic>) as JSX.Element
|
|
240
|
+
}
|
|
241
|
+
return (<>{result}</>) as JSX.Element
|
|
239
242
|
}
|
|
240
243
|
|
|
241
244
|
return result as JSX.Element
|