@fluenti/solid 0.3.3 → 0.4.0-rc.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 (43) hide show
  1. package/README.md +2 -2
  2. package/dist/components/DateTime.d.ts +3 -3
  3. package/dist/components/DateTime.d.ts.map +1 -1
  4. package/dist/components/NumberFormat.d.ts +3 -3
  5. package/dist/components/NumberFormat.d.ts.map +1 -1
  6. package/dist/components-entry.cjs +2 -0
  7. package/dist/components-entry.cjs.map +1 -0
  8. package/dist/components-entry.d.ts +12 -0
  9. package/dist/components-entry.d.ts.map +1 -0
  10. package/dist/components-entry.js +283 -0
  11. package/dist/components-entry.js.map +1 -0
  12. package/dist/context.d.ts +23 -1
  13. package/dist/context.d.ts.map +1 -1
  14. package/dist/index.cjs +1 -1
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.ts +1 -6
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -404
  19. package/dist/index.js.map +1 -1
  20. package/dist/plural.d.ts +9 -0
  21. package/dist/plural.d.ts.map +1 -1
  22. package/dist/provider.d.ts +19 -1
  23. package/dist/provider.d.ts.map +1 -1
  24. package/dist/rich-dom.d.ts.map +1 -1
  25. package/dist/select.d.ts +15 -0
  26. package/dist/select.d.ts.map +1 -1
  27. package/dist/trans.d.ts.map +1 -1
  28. package/dist/use-i18n-Bb-Ivibx.js +190 -0
  29. package/dist/use-i18n-Bb-Ivibx.js.map +1 -0
  30. package/dist/use-i18n-CooxLxjG.cjs +2 -0
  31. package/dist/use-i18n-CooxLxjG.cjs.map +1 -0
  32. package/llms.txt +44 -0
  33. package/package.json +18 -3
  34. package/src/components/DateTime.tsx +4 -4
  35. package/src/components/NumberFormat.tsx +4 -4
  36. package/src/components-entry.ts +13 -0
  37. package/src/context.ts +116 -142
  38. package/src/index.ts +5 -6
  39. package/src/plural.tsx +9 -0
  40. package/src/provider.tsx +19 -1
  41. package/src/rich-dom.tsx +10 -0
  42. package/src/select.tsx +15 -0
  43. package/src/trans.tsx +8 -2
@@ -3,8 +3,8 @@ import { useI18n } from '../use-i18n'
3
3
  export interface DateTimeProps {
4
4
  /** Date value to format */
5
5
  value: Date | number
6
- /** Named format style */
7
- style?: string
6
+ /** Named format key defined in dateFormats config */
7
+ format?: string
8
8
  }
9
9
 
10
10
  /** @alias DateTimeProps */
@@ -15,10 +15,10 @@ export type FluentiDateTimeProps = DateTimeProps
15
15
  *
16
16
  * @example
17
17
  * ```tsx
18
- * <DateTime value={new Date()} style="long" />
18
+ * <DateTime value={new Date()} format="long" />
19
19
  * ```
20
20
  */
21
21
  export function DateTime(props: DateTimeProps) {
22
22
  const { d } = useI18n()
23
- return <>{d(props.value, props.style)}</>
23
+ return <>{d(props.value, props.format)}</>
24
24
  }
@@ -3,8 +3,8 @@ import { useI18n } from '../use-i18n'
3
3
  export interface NumberProps {
4
4
  /** Number value to format */
5
5
  value: number
6
- /** Named format style */
7
- style?: string
6
+ /** Named format key defined in numberFormats config */
7
+ format?: string
8
8
  }
9
9
 
10
10
  /** @alias NumberProps */
@@ -15,10 +15,10 @@ export type FluentiNumberFormatProps = NumberProps
15
15
  *
16
16
  * @example
17
17
  * ```tsx
18
- * <NumberFormat value={1234.56} style="currency" />
18
+ * <NumberFormat value={1234.56} format="currency" />
19
19
  * ```
20
20
  */
21
21
  export function NumberFormat(props: NumberProps) {
22
22
  const { n } = useI18n()
23
- return <>{n(props.value, props.style)}</>
23
+ return <>{n(props.value, props.format)}</>
24
24
  }
@@ -0,0 +1,13 @@
1
+ export { Trans } from './trans'
2
+ export type { FluentiTransProps } from './trans'
3
+ export { Plural } from './plural'
4
+ export type { FluentiPluralProps } from './plural'
5
+ export { SelectComp as Select } from './select'
6
+ export type { FluentiSelectProps } from './select'
7
+ export { DateTime } from './components/DateTime'
8
+ export type { DateTimeProps, FluentiDateTimeProps } from './components/DateTime'
9
+ export { NumberFormat } from './components/NumberFormat'
10
+ export type { NumberProps, FluentiNumberFormatProps } from './components/NumberFormat'
11
+
12
+ // Re-export interpolate for apps that use <Plural>/<Select> at runtime
13
+ export { interpolate } from '@fluenti/core/internal'
package/src/context.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { createSignal, type Accessor } from 'solid-js'
2
- import { createDiagnostics, formatDate, formatNumber } from '@fluenti/core'
3
- import type { FluentiCoreConfig, Locale, LocalizedString, Messages, CompiledMessage, MessageDescriptor, DateFormatOptions, NumberFormatOptions, DiagnosticsConfig } from '@fluenti/core'
4
- import { interpolate as coreInterpolate, buildICUMessage, resolveDescriptorId } from '@fluenti/core/internal'
2
+ import { createFluentiCore } from '@fluenti/core'
3
+ import type { FluentiCoreConfig, FluentiCoreConfigFull, Locale, LocalizedString, Messages, CompiledMessage, MessageDescriptor, DateFormatOptions, NumberFormatOptions, DiagnosticsConfig, CustomFormatter } from '@fluenti/core'
5
4
 
6
5
  /** Chunk loader for lazy locale loading */
7
6
  export type ChunkLoader = (
@@ -30,6 +29,37 @@ function resolveChunkMessages(
30
29
  : loaded
31
30
  }
32
31
 
32
+ /** @internal Map locale → default currency code */
33
+ const LOCALE_CURRENCY_MAP: Record<string, string> = {
34
+ 'en': 'USD', 'en-US': 'USD', 'en-GB': 'GBP', 'en-AU': 'AUD', 'en-CA': 'CAD',
35
+ 'zh-CN': 'CNY', 'zh-TW': 'TWD', 'zh-HK': 'HKD',
36
+ 'ja': 'JPY', 'ja-JP': 'JPY',
37
+ 'ko': 'KRW', 'ko-KR': 'KRW',
38
+ 'de': 'EUR', 'de-DE': 'EUR', 'de-AT': 'EUR',
39
+ 'fr': 'EUR', 'fr-FR': 'EUR', 'fr-CA': 'CAD',
40
+ 'es': 'EUR', 'es-ES': 'EUR', 'es-MX': 'MXN',
41
+ 'pt': 'EUR', 'pt-BR': 'BRL', 'pt-PT': 'EUR',
42
+ 'it': 'EUR', 'ru': 'RUB', 'ar': 'SAR', 'hi': 'INR',
43
+ }
44
+
45
+ /** @internal Built-in date format styles (merged under user-provided dateFormats) */
46
+ const DEFAULT_DATE_FORMATS: Record<string, Intl.DateTimeFormatOptions> = {
47
+ short: { year: 'numeric', month: 'numeric', day: 'numeric' },
48
+ long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' },
49
+ time: { hour: 'numeric', minute: 'numeric' },
50
+ datetime: { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' },
51
+ }
52
+
53
+ /** @internal Built-in number format styles (merged under user-provided numberFormats) */
54
+ const DEFAULT_NUMBER_FORMATS: Record<string, Intl.NumberFormatOptions | ((locale: Locale) => Intl.NumberFormatOptions)> = {
55
+ currency: (locale: string) => ({
56
+ style: 'currency',
57
+ currency: LOCALE_CURRENCY_MAP[locale] ?? LOCALE_CURRENCY_MAP[locale.split('-')[0]!] ?? 'USD',
58
+ }),
59
+ percent: { style: 'percent' },
60
+ decimal: { minimumFractionDigits: 2, maximumFractionDigits: 2 },
61
+ }
62
+
33
63
  /** Extended config with lazy locale loading support */
34
64
  export interface FluentiConfig extends FluentiCoreConfig {
35
65
  /** Async chunk loader for lazy locale loading */
@@ -44,6 +74,19 @@ export interface FluentiConfig extends FluentiCoreConfig {
44
74
  numberFormats?: NumberFormatOptions
45
75
  /** Runtime diagnostics configuration */
46
76
  diagnostics?: DiagnosticsConfig
77
+ /**
78
+ * Custom message interpolation function.
79
+ *
80
+ * By default, the runtime uses a lightweight `{key}` replacer.
81
+ * Pass the full `interpolate` from `@fluenti/core/internal` for
82
+ * runtime ICU MessageFormat support (plurals, selects, nested arguments).
83
+ */
84
+ interpolate?: (
85
+ message: string,
86
+ values: Record<string, unknown> | undefined,
87
+ locale: string,
88
+ formatters?: Record<string, CustomFormatter>,
89
+ ) => string
47
90
  }
48
91
 
49
92
  /** Reactive i18n context holding locale signal and translation utilities */
@@ -83,6 +126,20 @@ export interface FluentiContext {
83
126
  *
84
127
  * The returned `t()` reads the internal `locale()` signal, so any
85
128
  * Solid computation that calls `t()` will re-run when the locale changes.
129
+ *
130
+ * @example
131
+ * ```tsx
132
+ * import { createFluentiContext } from '@fluenti/solid'
133
+ * import messages from './locales/compiled/en.js'
134
+ *
135
+ * const ctx = createFluentiContext({
136
+ * locale: 'en',
137
+ * messages: { en: messages },
138
+ * })
139
+ *
140
+ * // Use t`` tagged template (preferred)
141
+ * const greeting = ctx.t`Hello, {name}!`
142
+ * ```
86
143
  */
87
144
  export function createFluentiContext(config: FluentiCoreConfig | FluentiConfig): FluentiContext {
88
145
  const [locale, setLocaleSignal] = createSignal<Locale>(config.locale)
@@ -91,157 +148,50 @@ export function createFluentiContext(config: FluentiCoreConfig | FluentiConfig):
91
148
  const [loadedLocales, setLoadedLocales] = createSignal(new Set(loadedLocalesSet))
92
149
  const messages: Record<string, Messages> = { ...config.messages }
93
150
  const i18nConfig = config as FluentiConfig
94
- const diagnostics = i18nConfig.diagnostics ? createDiagnostics(i18nConfig.diagnostics) : undefined
95
151
  const lazyLocaleLoading = i18nConfig.lazyLocaleLoading
96
152
  ?? (config as FluentiConfig & { splitting?: boolean }).splitting
97
153
  ?? false
98
154
 
99
- function lookupCatalog(
100
- id: string,
101
- loc: Locale,
102
- values?: Record<string, unknown>,
103
- ): LocalizedString | undefined {
104
- const catalog = messages[loc]
105
- if (!catalog) {
106
- return undefined
107
- }
108
-
109
- const msg = catalog[id]
110
- if (msg === undefined) {
111
- return undefined
112
- }
113
-
114
- if (typeof msg === 'function') {
115
- return msg(values) as LocalizedString
116
- }
117
-
118
- if (typeof msg === 'string' && values) {
119
- return coreInterpolate(msg, values, loc) as LocalizedString
120
- }
121
-
122
- return String(msg) as LocalizedString
123
- }
124
-
125
- function lookupWithFallbacks(
126
- id: string,
127
- loc: Locale,
128
- values?: Record<string, unknown>,
129
- ): LocalizedString | undefined {
130
- const localesToTry: Locale[] = [loc]
131
- const seen = new Set(localesToTry)
132
-
133
- if (config.fallbackLocale && !seen.has(config.fallbackLocale)) {
134
- localesToTry.push(config.fallbackLocale)
135
- seen.add(config.fallbackLocale)
136
- }
137
-
138
- const chainLocales = i18nConfig.fallbackChain?.[loc] ?? i18nConfig.fallbackChain?.['*']
139
- if (chainLocales) {
140
- for (const chainLocale of chainLocales) {
141
- if (!seen.has(chainLocale)) {
142
- localesToTry.push(chainLocale)
143
- seen.add(chainLocale)
144
- }
145
- }
146
- }
147
-
148
- for (const targetLocale of localesToTry) {
149
- const result = lookupCatalog(id, targetLocale, values)
150
- if (result !== undefined) {
151
- if (targetLocale !== loc) {
152
- diagnostics?.fallbackUsed(loc, targetLocale, id)
153
- }
154
- return result
155
- }
156
- }
157
-
158
- return undefined
159
- }
160
-
161
- function resolveMissing(
162
- id: string,
163
- loc: Locale,
164
- ): LocalizedString | undefined {
165
- if (!config.missing) {
166
- return undefined
167
- }
168
-
169
- const result = config.missing(loc, id)
170
- if (result !== undefined) {
171
- return result as LocalizedString
172
- }
173
- return undefined
174
- }
175
-
176
- function resolveMessage(
177
- id: string,
178
- loc: Locale,
179
- values?: Record<string, unknown>,
180
- ): LocalizedString {
181
- const catalogResult = lookupWithFallbacks(id, loc, values)
182
- if (catalogResult !== undefined) {
183
- return catalogResult
184
- }
185
-
186
- diagnostics?.missingKey(loc, id)
187
-
188
- const missingResult = resolveMissing(id, loc)
189
- if (missingResult !== undefined) {
190
- return missingResult
191
- }
192
-
193
- if (id.includes('{')) {
194
- return coreInterpolate(id, values, loc) as LocalizedString
195
- }
196
-
197
- return id as LocalizedString
155
+ // Create a core instance that handles all translation, lookup, fallback, and formatting logic.
156
+ // Merge built-in date/number format styles under user-provided overrides.
157
+ // Build config incrementally to satisfy exactOptionalPropertyTypes —
158
+ // optional properties must not receive `undefined` as a value.
159
+ const coreConfig: FluentiCoreConfigFull = {
160
+ locale: config.locale,
161
+ messages: config.messages ?? {},
162
+ dateFormats: { ...DEFAULT_DATE_FORMATS, ...i18nConfig.dateFormats },
163
+ numberFormats: { ...DEFAULT_NUMBER_FORMATS, ...i18nConfig.numberFormats },
198
164
  }
165
+ if (config.fallbackLocale !== undefined) coreConfig.fallbackLocale = config.fallbackLocale
166
+ if (i18nConfig.fallbackChain !== undefined) coreConfig.fallbackChain = i18nConfig.fallbackChain
167
+ if (config.missing !== undefined) coreConfig.missing = config.missing
168
+ if (i18nConfig.diagnostics !== undefined) coreConfig.diagnostics = i18nConfig.diagnostics as FluentiCoreConfigFull['diagnostics']
169
+ if (i18nConfig.interpolate !== undefined) coreConfig.interpolate = i18nConfig.interpolate
170
+ const i18n = createFluentiCore(coreConfig)
199
171
 
200
172
  function t(strings: TemplateStringsArray, ...exprs: unknown[]): LocalizedString
201
173
  function t(id: string | MessageDescriptor, values?: Record<string, unknown>): LocalizedString
202
174
  function t(idOrStrings: string | MessageDescriptor | TemplateStringsArray, ...rest: unknown[]): LocalizedString {
203
- // Tagged template form: t`Hello ${name}`
175
+ const current = locale() // READ SIGNAL reactive dependency for Solid re-renders
176
+ if (i18n.locale !== current) i18n.locale = current
177
+ // Dispatch to the correct overload based on input type
204
178
  if (Array.isArray(idOrStrings) && 'raw' in idOrStrings) {
205
- const strings = idOrStrings as TemplateStringsArray
206
- const icu = buildICUMessage(strings, rest)
207
- const values = Object.fromEntries(rest.map((v, i) => [String(i), v]))
208
- return t(icu, values)
179
+ return i18n.t(idOrStrings as TemplateStringsArray, ...rest) as LocalizedString
209
180
  }
210
-
211
- const id = idOrStrings as string | MessageDescriptor
212
- const values = rest[0] as Record<string, unknown> | undefined
213
- const currentLocale = locale() // reactive dependency
214
- if (typeof id === 'object' && id !== null) {
215
- const messageId = resolveDescriptorId(id)
216
- if (messageId) {
217
- const catalogResult = lookupWithFallbacks(messageId, currentLocale, values)
218
- if (catalogResult !== undefined) {
219
- return catalogResult
220
- }
221
-
222
- const missingResult = resolveMissing(messageId, currentLocale)
223
- if (missingResult !== undefined) {
224
- return missingResult
225
- }
226
- }
227
-
228
- if (id.message !== undefined) {
229
- return coreInterpolate(id.message, values, currentLocale) as LocalizedString
230
- }
231
-
232
- return (messageId ?? '') as LocalizedString
233
- }
234
-
235
- return resolveMessage(id, currentLocale, values)
181
+ return i18n.t(idOrStrings as string | MessageDescriptor, rest[0] as Record<string, unknown> | undefined) as LocalizedString
236
182
  }
237
183
 
238
184
  const loadMessages = (loc: Locale, msgs: Messages): void => {
185
+ i18n.loadMessages(loc, msgs)
186
+ // Keep local messages in sync for te/tm which check the local object
239
187
  // Intentional mutation: messages record is locally scoped to this context closure
240
188
  messages[loc] = { ...messages[loc], ...msgs }
241
189
  loadedLocalesSet.add(loc)
242
190
  setLoadedLocales(new Set(loadedLocalesSet))
243
191
  }
244
192
 
193
+ let _localeRequestId = 0
194
+
245
195
  const setLocale = async (newLocale: Locale): Promise<void> => {
246
196
  if (!lazyLocaleLoading || !i18nConfig.chunkLoader) {
247
197
  setLocaleSignal(newLocale)
@@ -258,27 +208,41 @@ export function createFluentiContext(config: FluentiCoreConfig | FluentiConfig):
258
208
  return
259
209
  }
260
210
 
211
+ // Race-condition protection: track request ID
212
+ const thisRequest = ++_localeRequestId
261
213
  setIsLoading(true)
262
214
  try {
263
215
  const loaded = resolveChunkMessages(await i18nConfig.chunkLoader(newLocale))
216
+ // Always store loaded messages — they may be needed if locale is switched back
217
+ i18n.loadMessages(newLocale, loaded)
264
218
  // Intentional mutation: messages record is locally scoped to this context closure
265
219
  messages[newLocale] = { ...messages[newLocale], ...loaded }
266
220
  loadedLocalesSet.add(newLocale)
267
221
  setLoadedLocales(new Set(loadedLocalesSet))
222
+ // Stale request — a newer setLocale call superseded this one; don't switch locale
223
+ if (thisRequest !== _localeRequestId) return
268
224
  if (splitRuntime?.__switchLocale) {
269
225
  await splitRuntime.__switchLocale(newLocale)
270
226
  }
227
+ // Re-check after async __switchLocale — a newer setLocale() may have superseded this one
228
+ if (thisRequest !== _localeRequestId) return
271
229
  setLocaleSignal(newLocale)
272
230
  } finally {
273
- setIsLoading(false)
231
+ if (thisRequest === _localeRequestId) {
232
+ setIsLoading(false)
233
+ }
274
234
  }
275
235
  }
276
236
 
237
+ const _preloadInFlight = new Set<string>()
238
+
277
239
  const preloadLocale = (loc: string): void => {
278
- if (!lazyLocaleLoading || loadedLocalesSet.has(loc) || !i18nConfig.chunkLoader) return
240
+ if (!lazyLocaleLoading || loadedLocalesSet.has(loc) || !i18nConfig.chunkLoader || _preloadInFlight.has(loc)) return
241
+ _preloadInFlight.add(loc)
279
242
  const splitRuntime = getSplitRuntimeModule()
280
243
  i18nConfig.chunkLoader(loc).then(async (loaded) => {
281
244
  const resolved = resolveChunkMessages(loaded)
245
+ i18n.loadMessages(loc, resolved)
282
246
  // Intentional mutation: messages record is locally scoped to this context closure
283
247
  messages[loc] = { ...messages[loc], ...resolved }
284
248
  loadedLocalesSet.add(loc)
@@ -288,19 +252,29 @@ export function createFluentiContext(config: FluentiCoreConfig | FluentiConfig):
288
252
  }
289
253
  }).catch((e: unknown) => {
290
254
  console.warn('[fluenti] preload failed:', loc, e)
255
+ }).finally(() => {
256
+ _preloadInFlight.delete(loc)
291
257
  })
292
258
  }
293
259
 
294
- const getLocales = (): Locale[] => Object.keys(messages)
260
+ const getLocales = (): Locale[] => i18n.getLocales()
295
261
 
296
- const d = (value: Date | number, style?: string): LocalizedString =>
297
- formatDate(value, locale(), style, i18nConfig.dateFormats) as LocalizedString
262
+ const d = (value: Date | number, style?: string): LocalizedString => {
263
+ const current = locale() // READ SIGNAL → reactive dependency
264
+ if (i18n.locale !== current) i18n.locale = current
265
+ return i18n.d(value, style) as LocalizedString
266
+ }
298
267
 
299
- const n = (value: number, style?: string): LocalizedString =>
300
- formatNumber(value, locale(), style, i18nConfig.numberFormats) as LocalizedString
268
+ const n = (value: number, style?: string): LocalizedString => {
269
+ const current = locale() // READ SIGNAL → reactive dependency
270
+ if (i18n.locale !== current) i18n.locale = current
271
+ return i18n.n(value, style) as LocalizedString
272
+ }
301
273
 
302
274
  const format = (message: string, values?: Record<string, unknown>): LocalizedString => {
303
- return coreInterpolate(message, values, locale()) as LocalizedString
275
+ const current = locale() // READ SIGNAL → reactive dependency
276
+ if (i18n.locale !== current) i18n.locale = current
277
+ return i18n.format(message, values) as LocalizedString
304
278
  }
305
279
 
306
280
  const te = (key: string, loc?: string): boolean => {
package/src/index.ts CHANGED
@@ -3,14 +3,13 @@ export type { FluentiContext, FluentiConfig } from './context'
3
3
  export { I18nProvider } from './provider'
4
4
  export { useI18n } from './use-i18n'
5
5
  export { t } from './compile-time-t'
6
- export { Trans } from './trans'
6
+ export { msg } from './msg'
7
+
8
+ // Components moved to @fluenti/solid/components:
9
+ // import { Trans, Plural, Select, DateTime, NumberFormat } from '@fluenti/solid/components'
7
10
  export type { FluentiTransProps } from './trans'
8
- export { Plural } from './plural'
9
11
  export type { FluentiPluralProps } from './plural'
10
- export { SelectComp as Select } from './select'
11
12
  export type { FluentiSelectProps } from './select'
12
- export { msg } from './msg'
13
- export { DateTime } from './components/DateTime'
14
13
  export type { DateTimeProps, FluentiDateTimeProps } from './components/DateTime'
15
- export { NumberFormat } from './components/NumberFormat'
16
14
  export type { NumberProps, FluentiNumberFormatProps } from './components/NumberFormat'
15
+
package/src/plural.tsx CHANGED
@@ -56,6 +56,15 @@ export interface FluentiPluralProps {
56
56
  * ```tsx
57
57
  * <Plural value={count()} zero="No items" one="# item" other="# items" />
58
58
  * ```
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * import { Plural } from '@fluenti/solid'
63
+ *
64
+ * function ItemCount(props: { count: number }) {
65
+ * return <Plural value={props.count} one="# item" other="# items" />
66
+ * }
67
+ * ```
59
68
  */
60
69
  export const Plural: Component<FluentiPluralProps> = (props) => {
61
70
  const { t } = useI18n()
package/src/provider.tsx CHANGED
@@ -7,8 +7,26 @@ import type { FluentiConfig, FluentiContext } from './context'
7
7
  export const I18nCtx = createContext<FluentiContext>()
8
8
 
9
9
  /**
10
- * Provide i18n context to the component tree.
10
+ * Provides the Fluenti i18n context to the Solid component tree.
11
11
  *
12
+ * @example
13
+ * ```tsx
14
+ * import { I18nProvider, useI18n } from '@fluenti/solid'
15
+ * import messages from './locales/compiled/en.js'
16
+ *
17
+ * function App() {
18
+ * return (
19
+ * <I18nProvider locale="en" messages={{ en: messages }}>
20
+ * <Content />
21
+ * </I18nProvider>
22
+ * )
23
+ * }
24
+ *
25
+ * function Content() {
26
+ * const { t } = useI18n()
27
+ * return <h1>{t`Welcome to our app`}</h1>
28
+ * }
29
+ * ```
12
30
  */
13
31
  export const I18nProvider: ParentComponent<FluentiConfig> = (props) => {
14
32
  const ctx = createFluentiContext(props)
package/src/rich-dom.tsx CHANGED
@@ -90,6 +90,16 @@ export function reconstruct(
90
90
 
91
91
  const idx = Number(match[1])
92
92
  const isSelfClosing = match[2] === undefined
93
+
94
+ if (!Number.isInteger(idx) || idx < 0 || idx >= components.length) {
95
+ if (!isSelfClosing) {
96
+ result.push(match[3] ?? '')
97
+ }
98
+ lastIndex = combinedRe.lastIndex
99
+ match = combinedRe.exec(translated)
100
+ continue
101
+ }
102
+
93
103
  const template = components[idx]
94
104
 
95
105
  if (isSelfClosing) {
package/src/select.tsx CHANGED
@@ -50,6 +50,21 @@ export interface FluentiSelectProps {
50
50
  * ```
51
51
  *
52
52
  * Falls back to the `other` prop when no key matches.
53
+ *
54
+ * @example
55
+ * ```tsx
56
+ * import { Select } from '@fluenti/solid'
57
+ *
58
+ * function Greeting(props: { gender: string }) {
59
+ * return (
60
+ * <Select value={props.gender}
61
+ * male="He liked your post"
62
+ * female="She liked your post"
63
+ * other="They liked your post"
64
+ * />
65
+ * )
66
+ * }
67
+ * ```
53
68
  */
54
69
  export const SelectComp: Component<FluentiSelectProps> = (props) => {
55
70
  const { t } = useI18n()
package/src/trans.tsx CHANGED
@@ -46,6 +46,8 @@ interface TagToken {
46
46
 
47
47
  type Token = TextToken | TagToken
48
48
 
49
+ const MAX_TOKEN_DEPTH = 100
50
+
49
51
  /**
50
52
  * Parse a message string containing XML-like tags into a token tree.
51
53
  *
@@ -54,7 +56,11 @@ type Token = TextToken | TagToken
54
56
  * - Self-closing tags: `<br/>`
55
57
  * - Nested tags: `<bold>hello <italic>world</italic></bold>`
56
58
  */
57
- function parseTokens(input: string): readonly Token[] {
59
+ function parseTokens(input: string, depth: number = 0): readonly Token[] {
60
+ if (depth > MAX_TOKEN_DEPTH) {
61
+ // Bail out as plain text to prevent stack overflow
62
+ return [{ type: 'text', value: input }]
63
+ }
58
64
  const tokens: Token[] = []
59
65
  let pos = 0
60
66
 
@@ -106,7 +112,7 @@ function parseTokens(input: string): readonly Token[] {
106
112
  tokens.push({
107
113
  type: 'tag',
108
114
  name: tagName,
109
- children: parseTokens(innerContent),
115
+ children: parseTokens(innerContent, depth + 1),
110
116
  })
111
117
  pos = innerEnd + closingTag.length
112
118
  }