@fluenti/solid 0.2.1 → 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.
Files changed (53) hide show
  1. package/README.md +5 -5
  2. package/dist/context.d.ts +15 -24
  3. package/dist/context.d.ts.map +1 -1
  4. package/dist/hooks/__useI18n.d.ts +2 -2
  5. package/dist/hooks/__useI18n.d.ts.map +1 -1
  6. package/dist/index.cjs +1 -1
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.d.ts +6 -6
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +129 -167
  11. package/dist/index.js.map +1 -1
  12. package/dist/plural.d.ts +3 -3
  13. package/dist/plural.d.ts.map +1 -1
  14. package/dist/provider.d.ts +3 -3
  15. package/dist/provider.d.ts.map +1 -1
  16. package/dist/rich-dom.d.ts +0 -6
  17. package/dist/rich-dom.d.ts.map +1 -1
  18. package/dist/select.d.ts +3 -3
  19. package/dist/select.d.ts.map +1 -1
  20. package/dist/server.cjs +2 -0
  21. package/dist/server.cjs.map +1 -0
  22. package/dist/server.d.ts +33 -17
  23. package/dist/server.d.ts.map +1 -1
  24. package/dist/server.js +54 -0
  25. package/dist/server.js.map +1 -0
  26. package/dist/solid-runtime.d.ts.map +1 -1
  27. package/dist/trans.d.ts +3 -3
  28. package/dist/trans.d.ts.map +1 -1
  29. package/dist/types.d.ts +8 -8
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/use-i18n.d.ts +4 -8
  32. package/dist/use-i18n.d.ts.map +1 -1
  33. package/dist/vite-plugin.cjs +2 -113
  34. package/dist/vite-plugin.cjs.map +1 -1
  35. package/dist/vite-plugin.js +14 -123
  36. package/dist/vite-plugin.js.map +1 -1
  37. package/llms-full.txt +186 -0
  38. package/llms-migration.txt +176 -0
  39. package/llms.txt +64 -0
  40. package/package.json +17 -5
  41. package/src/context.ts +56 -77
  42. package/src/hooks/__useI18n.ts +2 -2
  43. package/src/index.ts +6 -6
  44. package/src/plural.tsx +9 -38
  45. package/src/provider.tsx +5 -5
  46. package/src/rich-dom.tsx +25 -47
  47. package/src/select.tsx +11 -8
  48. package/src/server.ts +94 -49
  49. package/src/solid-runtime.ts +15 -134
  50. package/src/trans.tsx +7 -4
  51. package/src/types.ts +9 -8
  52. package/src/use-i18n.ts +5 -16
  53. 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 PluralProps {
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 (default: `'span'`) */
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<PluralProps> = (props) => {
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
- return (<Dynamic component={props.tag ?? 'span'}>{components.length > 0 ? reconstruct(translated, components) : translated}</Dynamic>) as JSX.Element
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 { createI18nContext } from './context'
4
- import type { I18nConfig, I18nContext } from './context'
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<I18nContext>()
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<I18nConfig> = (props) => {
14
- const ctx = createI18nContext(props)
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
- 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
- }
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
- message += `<${idx}>${offsetIndices(inner.message, idx + 1)}</${idx}>`
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 tagRe = /<(\d+)>([\s\S]*?)<\/\1>/g
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
- tagRe.lastIndex = 0
86
- match = tagRe.exec(translated)
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
- const inner = reconstruct(match[2]!, components)
95
- if (template) {
96
- const clone = template.cloneNode(false)
97
- appendChild(clone, inner)
98
- result.push(clone)
94
+
95
+ if (isSelfClosing) {
96
+ if (template) {
97
+ result.push(template.cloneNode(false))
98
+ }
99
99
  } else {
100
- result.push(match[2]!)
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 = tagRe.lastIndex
104
- match = tagRe.exec(translated)
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 { buildICUSelectMessage, normalizeSelectForms, reconstruct, serializeRichForms } from './rich-dom'
5
+ import { reconstruct, serializeRichForms } from './rich-dom'
6
6
 
7
7
  /** Props for the `<Select>` component */
8
- export interface SelectProps {
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 (default: `span`) */
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<SelectProps> = (props) => {
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 (<Dynamic component={resolvedTag()}>{content()}</Dynamic>) as JSX.Element
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 { createFluent } from '@fluenti/core'
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { createFluentiCore } from '@fluenti/core'
2
3
  import type {
3
- FluentInstanceExtended,
4
- FluentConfigExtended,
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<FluentInstanceExtended & { locale: string }>
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
- * 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
- * **⚠️ 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
- let currentLocale: string | null = null
95
- let cachedInstance: (FluentInstanceExtended & { locale: string }) | null = null
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
- currentLocale = locale
100
- cachedInstance = null
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 getI18n(): Promise<FluentInstanceExtended & { locale: string }> {
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: FluentConfigExtended = {
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
- cachedInstance = createFluent(fluentConfig)
157
- return cachedInstance
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
  }
@@ -1,134 +1,15 @@
1
- import { resolve } from 'node:path'
2
- import type { RuntimeGenerator, RuntimeGeneratorOptions } from '@fluenti/vite-plugin'
3
-
4
- export const solidRuntimeGenerator: RuntimeGenerator = {
5
- generateRuntime(options: RuntimeGeneratorOptions): string {
6
- const { catalogDir, locales, sourceLocale, defaultBuildLocale } = options
7
- const defaultLocale = defaultBuildLocale || sourceLocale
8
- const absoluteCatalogDir = resolve(process.cwd(), catalogDir)
9
- const runtimeKey = 'fluenti.runtime.solid'
10
- const lazyLocales = locales.filter((locale) => locale !== defaultLocale)
11
-
12
- return `
13
- import { createSignal } from 'solid-js'
14
- import { createStore, reconcile } from 'solid-js/store'
15
- import __defaultMsgs from '${absoluteCatalogDir}/${defaultLocale}.js'
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 TransProps {
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 (default: `'span'`) used in children-only mode */
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<TransProps> = (props) => {
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
- return (<Dynamic component={props.tag ?? 'span'}>{result}</Dynamic>) as JSX.Element
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