@fluenti/solid 0.3.4 → 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.
@@ -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,155 +148,42 @@ 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
- try {
170
- const result = config.missing(loc, id)
171
- if (result !== undefined) {
172
- return result as LocalizedString
173
- }
174
- } catch {
175
- // Missing handler threw — fall through to next resolution path
176
- }
177
- return undefined
178
- }
179
-
180
- function resolveMessage(
181
- id: string,
182
- loc: Locale,
183
- values?: Record<string, unknown>,
184
- ): LocalizedString {
185
- const catalogResult = lookupWithFallbacks(id, loc, values)
186
- if (catalogResult !== undefined) {
187
- return catalogResult
188
- }
189
-
190
- diagnostics?.missingKey(loc, id)
191
-
192
- const missingResult = resolveMissing(id, loc)
193
- if (missingResult !== undefined) {
194
- return missingResult
195
- }
196
-
197
- if (id.includes('{')) {
198
- return coreInterpolate(id, values, loc) as LocalizedString
199
- }
200
-
201
- 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 },
202
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)
203
171
 
204
172
  function t(strings: TemplateStringsArray, ...exprs: unknown[]): LocalizedString
205
173
  function t(id: string | MessageDescriptor, values?: Record<string, unknown>): LocalizedString
206
174
  function t(idOrStrings: string | MessageDescriptor | TemplateStringsArray, ...rest: unknown[]): LocalizedString {
207
- // 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
208
178
  if (Array.isArray(idOrStrings) && 'raw' in idOrStrings) {
209
- const strings = idOrStrings as TemplateStringsArray
210
- const icu = buildICUMessage(strings, rest)
211
- const values = Object.fromEntries(rest.map((v, i) => [`arg${i}`, v]))
212
- return resolveMessage(icu, locale(), values)
179
+ return i18n.t(idOrStrings as TemplateStringsArray, ...rest) as LocalizedString
213
180
  }
214
-
215
- const id = idOrStrings as string | MessageDescriptor
216
- const values = rest[0] as Record<string, unknown> | undefined
217
- const currentLocale = locale() // reactive dependency
218
- if (typeof id === 'object' && id !== null) {
219
- const messageId = resolveDescriptorId(id)
220
- if (messageId) {
221
- const catalogResult = lookupWithFallbacks(messageId, currentLocale, values)
222
- if (catalogResult !== undefined) {
223
- return catalogResult
224
- }
225
-
226
- const missingResult = resolveMissing(messageId, currentLocale)
227
- if (missingResult !== undefined) {
228
- return missingResult
229
- }
230
- }
231
-
232
- if (id.message !== undefined) {
233
- return coreInterpolate(id.message, values, currentLocale) as LocalizedString
234
- }
235
-
236
- return (messageId ?? '') as LocalizedString
237
- }
238
-
239
- return resolveMessage(id, currentLocale, values)
181
+ return i18n.t(idOrStrings as string | MessageDescriptor, rest[0] as Record<string, unknown> | undefined) as LocalizedString
240
182
  }
241
183
 
242
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
243
187
  // Intentional mutation: messages record is locally scoped to this context closure
244
188
  messages[loc] = { ...messages[loc], ...msgs }
245
189
  loadedLocalesSet.add(loc)
@@ -270,6 +214,7 @@ export function createFluentiContext(config: FluentiCoreConfig | FluentiConfig):
270
214
  try {
271
215
  const loaded = resolveChunkMessages(await i18nConfig.chunkLoader(newLocale))
272
216
  // Always store loaded messages — they may be needed if locale is switched back
217
+ i18n.loadMessages(newLocale, loaded)
273
218
  // Intentional mutation: messages record is locally scoped to this context closure
274
219
  messages[newLocale] = { ...messages[newLocale], ...loaded }
275
220
  loadedLocalesSet.add(newLocale)
@@ -297,6 +242,7 @@ export function createFluentiContext(config: FluentiCoreConfig | FluentiConfig):
297
242
  const splitRuntime = getSplitRuntimeModule()
298
243
  i18nConfig.chunkLoader(loc).then(async (loaded) => {
299
244
  const resolved = resolveChunkMessages(loaded)
245
+ i18n.loadMessages(loc, resolved)
300
246
  // Intentional mutation: messages record is locally scoped to this context closure
301
247
  messages[loc] = { ...messages[loc], ...resolved }
302
248
  loadedLocalesSet.add(loc)
@@ -311,16 +257,24 @@ export function createFluentiContext(config: FluentiCoreConfig | FluentiConfig):
311
257
  })
312
258
  }
313
259
 
314
- const getLocales = (): Locale[] => Object.keys(messages)
260
+ const getLocales = (): Locale[] => i18n.getLocales()
315
261
 
316
- const d = (value: Date | number, style?: string): LocalizedString =>
317
- 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
+ }
318
267
 
319
- const n = (value: number, style?: string): LocalizedString =>
320
- 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
+ }
321
273
 
322
274
  const format = (message: string, values?: Record<string, unknown>): LocalizedString => {
323
- 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
324
278
  }
325
279
 
326
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
  }