@fluenti/solid 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +317 -0
  3. package/dist/compile-time-t.d.ts +3 -0
  4. package/dist/compile-time-t.d.ts.map +1 -0
  5. package/dist/components/DateTime.d.ts +16 -0
  6. package/dist/components/DateTime.d.ts.map +1 -0
  7. package/dist/components/NumberFormat.d.ts +16 -0
  8. package/dist/components/NumberFormat.d.ts.map +1 -0
  9. package/dist/context.d.ts +69 -0
  10. package/dist/context.d.ts.map +1 -0
  11. package/dist/hooks/__useI18n.d.ts +12 -0
  12. package/dist/hooks/__useI18n.d.ts.map +1 -0
  13. package/dist/index.cjs +2 -0
  14. package/dist/index.cjs.map +1 -0
  15. package/dist/index.d.ts +15 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +446 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/msg.d.ts +2 -0
  20. package/dist/msg.d.ts.map +1 -0
  21. package/dist/plural.d.ts +55 -0
  22. package/dist/plural.d.ts.map +1 -0
  23. package/dist/provider.d.ts +10 -0
  24. package/dist/provider.d.ts.map +1 -0
  25. package/dist/rich-dom.d.ts +17 -0
  26. package/dist/rich-dom.d.ts.map +1 -0
  27. package/dist/select.d.ts +49 -0
  28. package/dist/select.d.ts.map +1 -0
  29. package/dist/server.d.ts +77 -0
  30. package/dist/server.d.ts.map +1 -0
  31. package/dist/trans.d.ts +46 -0
  32. package/dist/trans.d.ts.map +1 -0
  33. package/dist/types.d.ts +45 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/dist/use-i18n.d.ts +12 -0
  36. package/dist/use-i18n.d.ts.map +1 -0
  37. package/package.json +75 -0
  38. package/src/compile-time-t.ts +9 -0
  39. package/src/components/DateTime.tsx +21 -0
  40. package/src/components/NumberFormat.tsx +21 -0
  41. package/src/context.ts +337 -0
  42. package/src/hooks/__useI18n.ts +15 -0
  43. package/src/index.ts +14 -0
  44. package/src/msg.ts +4 -0
  45. package/src/plural.tsx +136 -0
  46. package/src/provider.tsx +16 -0
  47. package/src/rich-dom.tsx +170 -0
  48. package/src/select.tsx +90 -0
  49. package/src/server.ts +153 -0
  50. package/src/trans.tsx +243 -0
  51. package/src/types.ts +55 -0
  52. package/src/use-i18n.ts +30 -0
  53. package/src/vite-runtime.d.ts +4 -0
package/src/context.ts ADDED
@@ -0,0 +1,337 @@
1
+ import { createSignal, createRoot, type Accessor } from 'solid-js'
2
+ import { formatDate, formatNumber, interpolate as coreInterpolate, buildICUMessage, resolveDescriptorId } from '@fluenti/core'
3
+ import type { FluentConfig, Locale, Messages, CompiledMessage, MessageDescriptor, DateFormatOptions, NumberFormatOptions } from '@fluenti/core'
4
+
5
+ /** Chunk loader for lazy locale loading */
6
+ export type ChunkLoader = (
7
+ locale: string,
8
+ ) => Promise<Record<string, CompiledMessage> | { default: Record<string, CompiledMessage> }>
9
+
10
+ interface SplitRuntimeModule {
11
+ __switchLocale?: (locale: string) => Promise<void>
12
+ __preloadLocale?: (locale: string) => Promise<void>
13
+ }
14
+
15
+ const SPLIT_RUNTIME_KEY = Symbol.for('fluenti.runtime.solid')
16
+
17
+ function getSplitRuntimeModule(): SplitRuntimeModule | null {
18
+ const runtime = (globalThis as Record<PropertyKey, unknown>)[SPLIT_RUNTIME_KEY]
19
+ return typeof runtime === 'object' && runtime !== null
20
+ ? runtime as SplitRuntimeModule
21
+ : null
22
+ }
23
+
24
+ function resolveChunkMessages(
25
+ loaded: Record<string, CompiledMessage> | { default: Record<string, CompiledMessage> },
26
+ ): Record<string, CompiledMessage> {
27
+ return typeof loaded === 'object' && loaded !== null && 'default' in loaded
28
+ ? (loaded as { default: Record<string, CompiledMessage> }).default
29
+ : loaded
30
+ }
31
+
32
+ /** Extended config with lazy locale loading support */
33
+ export interface I18nConfig extends FluentConfig {
34
+ /** Async chunk loader for lazy locale loading */
35
+ chunkLoader?: ChunkLoader
36
+ /** Enable lazy locale loading through chunkLoader */
37
+ lazyLocaleLoading?: boolean
38
+ /** Locale-specific fallback chains */
39
+ fallbackChain?: Record<string, Locale[]>
40
+ /** Named date format styles */
41
+ dateFormats?: DateFormatOptions
42
+ /** Named number format styles */
43
+ numberFormats?: NumberFormatOptions
44
+ }
45
+
46
+ /** Reactive i18n context holding locale signal and translation utilities */
47
+ export interface I18nContext {
48
+ /** Reactive accessor for the current locale */
49
+ locale(): Locale
50
+ /** Set the active locale (async when lazy locale loading is enabled) */
51
+ setLocale(locale: Locale): Promise<void>
52
+ /** Translate a message by id with optional interpolation values */
53
+ t(id: string | MessageDescriptor, values?: Record<string, unknown>): string
54
+ /** Tagged template form: t`Hello ${name}` */
55
+ t(strings: TemplateStringsArray, ...exprs: unknown[]): string
56
+ /** Merge additional messages into a locale catalog at runtime */
57
+ loadMessages(locale: Locale, messages: Messages): void
58
+ /** Return all locale codes that have loaded messages */
59
+ getLocales(): Locale[]
60
+ /** Format a date value for the current locale */
61
+ d(value: Date | number, style?: string): string
62
+ /** Format a number value for the current locale */
63
+ n(value: number, style?: string): string
64
+ /** Format an ICU message string directly (no catalog lookup) */
65
+ format(message: string, values?: Record<string, unknown>): string
66
+ /** Whether a locale chunk is currently being loaded */
67
+ isLoading: Accessor<boolean>
68
+ /** Set of locales whose messages have been loaded */
69
+ loadedLocales: Accessor<Set<string>>
70
+ /** Preload a locale in the background without switching to it */
71
+ preloadLocale(locale: string): void
72
+ }
73
+
74
+ /**
75
+ * Create a reactive i18n context backed by Solid signals.
76
+ *
77
+ * The returned `t()` reads the internal `locale()` signal, so any
78
+ * Solid computation that calls `t()` will re-run when the locale changes.
79
+ */
80
+ export function createI18nContext(config: FluentConfig | I18nConfig): I18nContext {
81
+ const [locale, setLocaleSignal] = createSignal<Locale>(config.locale)
82
+ const [isLoading, setIsLoading] = createSignal(false)
83
+ const loadedLocalesSet = new Set<string>([config.locale])
84
+ const [loadedLocales, setLoadedLocales] = createSignal(new Set(loadedLocalesSet))
85
+ const messages: Record<string, Messages> = { ...config.messages }
86
+ const i18nConfig = config as I18nConfig
87
+ const lazyLocaleLoading = i18nConfig.lazyLocaleLoading
88
+ ?? (config as I18nConfig & { splitting?: boolean }).splitting
89
+ ?? false
90
+
91
+ function lookupCatalog(
92
+ id: string,
93
+ loc: Locale,
94
+ values?: Record<string, unknown>,
95
+ ): string | undefined {
96
+ const catalog = messages[loc]
97
+ if (!catalog) {
98
+ return undefined
99
+ }
100
+
101
+ const msg = catalog[id]
102
+ if (msg === undefined) {
103
+ return undefined
104
+ }
105
+
106
+ if (typeof msg === 'function') {
107
+ return msg(values)
108
+ }
109
+
110
+ if (typeof msg === 'string' && values) {
111
+ return coreInterpolate(msg, values, loc)
112
+ }
113
+
114
+ return String(msg)
115
+ }
116
+
117
+ function lookupWithFallbacks(
118
+ id: string,
119
+ loc: Locale,
120
+ values?: Record<string, unknown>,
121
+ ): string | undefined {
122
+ const localesToTry: Locale[] = [loc]
123
+ const seen = new Set(localesToTry)
124
+
125
+ if (config.fallbackLocale && !seen.has(config.fallbackLocale)) {
126
+ localesToTry.push(config.fallbackLocale)
127
+ seen.add(config.fallbackLocale)
128
+ }
129
+
130
+ const chainLocales = i18nConfig.fallbackChain?.[loc] ?? i18nConfig.fallbackChain?.['*']
131
+ if (chainLocales) {
132
+ for (const chainLocale of chainLocales) {
133
+ if (!seen.has(chainLocale)) {
134
+ localesToTry.push(chainLocale)
135
+ seen.add(chainLocale)
136
+ }
137
+ }
138
+ }
139
+
140
+ for (const targetLocale of localesToTry) {
141
+ const result = lookupCatalog(id, targetLocale, values)
142
+ if (result !== undefined) {
143
+ return result
144
+ }
145
+ }
146
+
147
+ return undefined
148
+ }
149
+
150
+ function resolveMissing(
151
+ id: string,
152
+ loc: Locale,
153
+ ): string | undefined {
154
+ if (!config.missing) {
155
+ return undefined
156
+ }
157
+
158
+ const result = config.missing(loc, id)
159
+ if (result !== undefined) {
160
+ return result
161
+ }
162
+ return undefined
163
+ }
164
+
165
+ function resolveMessage(
166
+ id: string,
167
+ loc: Locale,
168
+ values?: Record<string, unknown>,
169
+ ): string {
170
+ const catalogResult = lookupWithFallbacks(id, loc, values)
171
+ if (catalogResult !== undefined) {
172
+ return catalogResult
173
+ }
174
+
175
+ const missingResult = resolveMissing(id, loc)
176
+ if (missingResult !== undefined) {
177
+ return missingResult
178
+ }
179
+
180
+ if (id.includes('{')) {
181
+ return coreInterpolate(id, values, loc)
182
+ }
183
+
184
+ return id
185
+ }
186
+
187
+ function t(strings: TemplateStringsArray, ...exprs: unknown[]): string
188
+ function t(id: string | MessageDescriptor, values?: Record<string, unknown>): string
189
+ function t(idOrStrings: string | MessageDescriptor | TemplateStringsArray, ...rest: unknown[]): string {
190
+ // Tagged template form: t`Hello ${name}`
191
+ if (Array.isArray(idOrStrings) && 'raw' in idOrStrings) {
192
+ const strings = idOrStrings as TemplateStringsArray
193
+ const icu = buildICUMessage(strings, rest)
194
+ const values = Object.fromEntries(rest.map((v, i) => [String(i), v]))
195
+ return t(icu, values)
196
+ }
197
+
198
+ const id = idOrStrings as string | MessageDescriptor
199
+ const values = rest[0] as Record<string, unknown> | undefined
200
+ const currentLocale = locale() // reactive dependency
201
+ if (typeof id === 'object' && id !== null) {
202
+ const messageId = resolveDescriptorId(id)
203
+ if (messageId) {
204
+ const catalogResult = lookupWithFallbacks(messageId, currentLocale, values)
205
+ if (catalogResult !== undefined) {
206
+ return catalogResult
207
+ }
208
+
209
+ const missingResult = resolveMissing(messageId, currentLocale)
210
+ if (missingResult !== undefined) {
211
+ return missingResult
212
+ }
213
+ }
214
+
215
+ if (id.message !== undefined) {
216
+ return coreInterpolate(id.message, values, currentLocale)
217
+ }
218
+
219
+ return messageId ?? ''
220
+ }
221
+
222
+ return resolveMessage(id, currentLocale, values)
223
+ }
224
+
225
+ const loadMessages = (loc: Locale, msgs: Messages): void => {
226
+ messages[loc] = { ...messages[loc], ...msgs }
227
+ loadedLocalesSet.add(loc)
228
+ setLoadedLocales(new Set(loadedLocalesSet))
229
+ }
230
+
231
+ const setLocale = async (newLocale: Locale): Promise<void> => {
232
+ if (!lazyLocaleLoading || !i18nConfig.chunkLoader) {
233
+ setLocaleSignal(newLocale)
234
+ return
235
+ }
236
+
237
+ const splitRuntime = getSplitRuntimeModule()
238
+
239
+ if (loadedLocalesSet.has(newLocale)) {
240
+ if (splitRuntime?.__switchLocale) {
241
+ await splitRuntime.__switchLocale(newLocale)
242
+ }
243
+ setLocaleSignal(newLocale)
244
+ return
245
+ }
246
+
247
+ setIsLoading(true)
248
+ try {
249
+ const loaded = resolveChunkMessages(await i18nConfig.chunkLoader(newLocale))
250
+ messages[newLocale] = { ...messages[newLocale], ...loaded }
251
+ loadedLocalesSet.add(newLocale)
252
+ setLoadedLocales(new Set(loadedLocalesSet))
253
+ if (splitRuntime?.__switchLocale) {
254
+ await splitRuntime.__switchLocale(newLocale)
255
+ }
256
+ setLocaleSignal(newLocale)
257
+ } finally {
258
+ setIsLoading(false)
259
+ }
260
+ }
261
+
262
+ const preloadLocale = (loc: string): void => {
263
+ if (!lazyLocaleLoading || loadedLocalesSet.has(loc) || !i18nConfig.chunkLoader) return
264
+ const splitRuntime = getSplitRuntimeModule()
265
+ i18nConfig.chunkLoader(loc).then(async (loaded) => {
266
+ const resolved = resolveChunkMessages(loaded)
267
+ messages[loc] = { ...messages[loc], ...resolved }
268
+ loadedLocalesSet.add(loc)
269
+ setLoadedLocales(new Set(loadedLocalesSet))
270
+ if (splitRuntime?.__preloadLocale) {
271
+ await splitRuntime.__preloadLocale(loc)
272
+ }
273
+ }).catch(() => {
274
+ // Silent failure for preload
275
+ })
276
+ }
277
+
278
+ const getLocales = (): Locale[] => Object.keys(messages)
279
+
280
+ const d = (value: Date | number, style?: string): string =>
281
+ formatDate(value, locale(), style, i18nConfig.dateFormats)
282
+
283
+ const n = (value: number, style?: string): string =>
284
+ formatNumber(value, locale(), style, i18nConfig.numberFormats)
285
+
286
+ const format = (message: string, values?: Record<string, unknown>): string => {
287
+ return coreInterpolate(message, values, locale())
288
+ }
289
+
290
+ return { locale, setLocale, t, loadMessages, getLocales, d, n, format, isLoading, loadedLocales, preloadLocale }
291
+ }
292
+
293
+ // ─── Module-level singleton ─────────────────────────────────────────────────
294
+
295
+ let globalCtx: I18nContext | undefined
296
+
297
+ /**
298
+ * Initialize the global i18n singleton.
299
+ *
300
+ * Call once at app startup (e.g. in your entry file) before any `useI18n()`.
301
+ * Signals are created inside a `createRoot` so they outlive any component scope.
302
+ *
303
+ * Returns the context for convenience, but `useI18n()` will also find it.
304
+ */
305
+ export function createI18n(config: FluentConfig | I18nConfig): I18nContext {
306
+ const ctx = createRoot(() => createI18nContext(config))
307
+
308
+ // Only set global singleton in browser (client-side).
309
+ // In SSR, each request should use <I18nProvider> for per-request isolation.
310
+ if (typeof window !== 'undefined') {
311
+ globalCtx = ctx
312
+ } else {
313
+ console.warn(
314
+ '[fluenti] createI18n() detected SSR environment. ' +
315
+ 'Use <I18nProvider> for per-request isolation in SSR.',
316
+ )
317
+ // Still set globalCtx as fallback, but document the risk
318
+ globalCtx = ctx
319
+ }
320
+
321
+ return ctx
322
+ }
323
+
324
+ /** @internal — used by useI18n and I18nProvider */
325
+ export function getGlobalI18nContext(): I18nContext | undefined {
326
+ return globalCtx
327
+ }
328
+
329
+ /** @internal — used by I18nProvider to set context without createRoot wrapper */
330
+ export function setGlobalI18nContext(ctx: I18nContext): void {
331
+ globalCtx = ctx
332
+ }
333
+
334
+ /** @internal — reset the global singleton (for testing only) */
335
+ export function resetGlobalI18nContext(): void {
336
+ globalCtx = undefined
337
+ }
@@ -0,0 +1,15 @@
1
+ import { useI18n } from '../use-i18n'
2
+ import type { I18nContext } from '../types'
3
+
4
+ /**
5
+ * Internal hook used by the Vite plugin's compiled output.
6
+ * Returns the i18n context for direct t() calls.
7
+ *
8
+ * **Not part of the public API.** Users never write this — the Vite plugin
9
+ * generates imports of this hook automatically.
10
+ *
11
+ * @internal
12
+ */
13
+ export function __useI18n(): I18nContext {
14
+ return useI18n()
15
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export { createI18nContext, createI18n } from './context'
2
+ export type { I18nContext, I18nConfig } from './context'
3
+ export { I18nProvider, I18nCtx } from './provider'
4
+ export { useI18n } from './use-i18n'
5
+ export { t } from './compile-time-t'
6
+ export { Trans } from './trans'
7
+ export type { TransProps } from './trans'
8
+ export { Plural } from './plural'
9
+ export type { PluralProps } from './plural'
10
+ export { SelectComp as Select } from './select'
11
+ export type { SelectProps } from './select'
12
+ export { msg } from './msg'
13
+ export { DateTime } from './components/DateTime'
14
+ export { NumberFormat } from './components/NumberFormat'
package/src/msg.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Re-export msg from @fluenti/core
2
+ // msg`` is the only Fluenti API that requires an explicit import,
3
+ // because it's used outside the component tree where auto-injection can't work.
4
+ export { msg } from '@fluenti/core'
package/src/plural.tsx ADDED
@@ -0,0 +1,136 @@
1
+ import { Dynamic } from 'solid-js/web'
2
+ import type { Component, JSX } from 'solid-js'
3
+ import { hashMessage } from '@fluenti/core'
4
+ import { useI18n } from './use-i18n'
5
+ import { reconstruct, serializeRichForms } from './rich-dom'
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
+ /** Props for the `<Plural>` component */
41
+ export interface PluralProps {
42
+ /** The numeric value to pluralise */
43
+ value: number
44
+ /** Override the auto-generated synthetic ICU message id */
45
+ id?: string
46
+ /** Message context used for identity and translator disambiguation */
47
+ context?: string
48
+ /** Translator-facing note preserved in extraction catalogs */
49
+ comment?: string
50
+ /** Offset from value before selecting form */
51
+ offset?: number
52
+ /** Message for the "zero" plural category */
53
+ zero?: string | JSX.Element
54
+ /** Message for the "one" plural category */
55
+ one?: string | JSX.Element
56
+ /** Message for the "two" plural category */
57
+ two?: string | JSX.Element
58
+ /** Message for the "few" plural category */
59
+ few?: string | JSX.Element
60
+ /** Message for the "many" plural category */
61
+ many?: string | JSX.Element
62
+ /** Fallback message when no category-specific prop matches */
63
+ other: string | JSX.Element
64
+ /** Wrapper element tag name (default: `'span'`) */
65
+ tag?: string
66
+ }
67
+
68
+ /**
69
+ * `<Plural>` component — shorthand for ICU plural patterns.
70
+ *
71
+ * Plural form props (`zero`, `one`, `two`, `few`, `many`, `other`) are treated
72
+ * as source-language messages. The component builds an ICU plural message,
73
+ * looks it up via `t()` in the catalog, and interpolates the translated result.
74
+ *
75
+ * When no catalog translation exists, the component falls back to interpolating
76
+ * the source-language ICU message directly via core's `interpolate`.
77
+ *
78
+ * Rich text is supported via JSX element props:
79
+ * ```tsx
80
+ * <Plural
81
+ * value={count()}
82
+ * zero={<>No <strong>items</strong> left</>}
83
+ * one={<><em>1</em> item remaining</>}
84
+ * other={<><strong>{count()}</strong> items remaining</>}
85
+ * />
86
+ * ```
87
+ *
88
+ * String props still work (backward compatible):
89
+ * ```tsx
90
+ * <Plural value={count()} zero="No items" one="# item" other="# items" />
91
+ * ```
92
+ */
93
+ export const Plural: Component<PluralProps> = (props) => {
94
+ const { t } = useI18n()
95
+
96
+ /** Resolve a category prop value — handles string, accessor function, and JSX */
97
+ function resolveProp(val: string | JSX.Element | undefined): string | JSX.Element | undefined {
98
+ if (typeof val === 'function') return (val as () => string | JSX.Element)()
99
+ return val
100
+ }
101
+
102
+ return (() => {
103
+ // Resolve all category values (handles Solid accessors from createMemo)
104
+ const resolvedValues: Partial<Record<PluralCategory, string | JSX.Element>> = {}
105
+ for (const cat of PLURAL_CATEGORIES) {
106
+ const resolved = resolveProp(props[cat])
107
+ if (resolved !== undefined) {
108
+ resolvedValues[cat] = resolved
109
+ }
110
+ }
111
+ const { messages, components } = serializeRichForms(PLURAL_CATEGORIES, resolvedValues)
112
+ const icuMessage = buildICUPluralMessage(
113
+ {
114
+ ...(messages['zero'] !== undefined && { zero: messages['zero'] }),
115
+ ...(messages['one'] !== undefined && { one: messages['one'] }),
116
+ ...(messages['two'] !== undefined && { two: messages['two'] }),
117
+ ...(messages['few'] !== undefined && { few: messages['few'] }),
118
+ ...(messages['many'] !== undefined && { many: messages['many'] }),
119
+ other: messages['other'] ?? '',
120
+ },
121
+ props.offset,
122
+ )
123
+
124
+ const translated = t(
125
+ {
126
+ id: props.id ?? (props.context === undefined ? icuMessage : hashMessage(icuMessage, props.context)),
127
+ message: icuMessage,
128
+ ...(props.context !== undefined ? { context: props.context } : {}),
129
+ ...(props.comment !== undefined ? { comment: props.comment } : {}),
130
+ },
131
+ { count: props.value },
132
+ )
133
+
134
+ return (<Dynamic component={props.tag ?? 'span'}>{components.length > 0 ? reconstruct(translated, components) : translated}</Dynamic>) as JSX.Element
135
+ }) as unknown as JSX.Element
136
+ }
@@ -0,0 +1,16 @@
1
+ import { createContext } from 'solid-js'
2
+ import type { ParentComponent } from 'solid-js'
3
+ import { createI18nContext } from './context'
4
+ import type { I18nConfig, I18nContext } from './context'
5
+
6
+ /** Solid context object for i18n — used internally by useI18n() */
7
+ export const I18nCtx = createContext<I18nContext>()
8
+
9
+ /**
10
+ * Provide i18n context to the component tree.
11
+ *
12
+ */
13
+ export const I18nProvider: ParentComponent<I18nConfig> = (props) => {
14
+ const ctx = createI18nContext(props)
15
+ return <I18nCtx.Provider value={ctx}>{props.children}</I18nCtx.Provider>
16
+ }
@@ -0,0 +1,170 @@
1
+ import type { JSX } from 'solid-js'
2
+
3
+ function isNodeLike(value: unknown): value is Node {
4
+ return typeof Node !== 'undefined' && value instanceof Node
5
+ }
6
+
7
+ function resolveValue(value: unknown): unknown {
8
+ if (typeof value === 'function' && !(value as { length?: number }).length) {
9
+ return (value as () => unknown)()
10
+ }
11
+ return value
12
+ }
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
+ }
20
+
21
+ export function extractMessage(value: unknown): {
22
+ message: string
23
+ components: Node[]
24
+ } {
25
+ const components: Node[] = []
26
+ let message = ''
27
+
28
+ function visit(node: unknown): void {
29
+ const resolved = resolveValue(node)
30
+ if (resolved === null || resolved === undefined || typeof resolved === 'boolean') return
31
+ if (Array.isArray(resolved)) {
32
+ for (const child of resolved) visit(child)
33
+ return
34
+ }
35
+ if (typeof resolved === 'string' || typeof resolved === 'number') {
36
+ message += String(resolved)
37
+ return
38
+ }
39
+ if (!isNodeLike(resolved)) return
40
+ if (resolved.nodeType === Node.TEXT_NODE) {
41
+ message += resolved.textContent ?? ''
42
+ return
43
+ }
44
+ if (resolved.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
45
+ visit(Array.from(resolved.childNodes))
46
+ return
47
+ }
48
+
49
+ const idx = components.length
50
+ const inner = extractMessage(Array.from(resolved.childNodes))
51
+ components.push((resolved as Element).cloneNode(false))
52
+ components.push(...inner.components)
53
+ message += `<${idx}>${offsetIndices(inner.message, idx + 1)}</${idx}>`
54
+ }
55
+
56
+ visit(value)
57
+ return { message, components }
58
+ }
59
+
60
+ function appendChild(parent: Node, child: unknown): void {
61
+ const resolved = resolveValue(child)
62
+ if (resolved === null || resolved === undefined || typeof resolved === 'boolean') return
63
+ if (Array.isArray(resolved)) {
64
+ for (const entry of resolved) appendChild(parent, entry)
65
+ return
66
+ }
67
+ if (typeof resolved === 'string' || typeof resolved === 'number') {
68
+ parent.appendChild(document.createTextNode(String(resolved)))
69
+ return
70
+ }
71
+ if (isNodeLike(resolved)) {
72
+ parent.appendChild(resolved)
73
+ }
74
+ }
75
+
76
+ export function reconstruct(
77
+ translated: string,
78
+ components: Node[],
79
+ ): JSX.Element {
80
+ const tagRe = /<(\d+)>([\s\S]*?)<\/\1>/g
81
+ const result: unknown[] = []
82
+ let lastIndex = 0
83
+ let match: RegExpExecArray | null
84
+
85
+ tagRe.lastIndex = 0
86
+ match = tagRe.exec(translated)
87
+ while (match !== null) {
88
+ if (match.index > lastIndex) {
89
+ result.push(translated.slice(lastIndex, match.index))
90
+ }
91
+
92
+ const idx = Number(match[1])
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)
99
+ } else {
100
+ result.push(match[2]!)
101
+ }
102
+
103
+ lastIndex = tagRe.lastIndex
104
+ match = tagRe.exec(translated)
105
+ }
106
+
107
+ if (lastIndex < translated.length) {
108
+ result.push(translated.slice(lastIndex))
109
+ }
110
+
111
+ return (result.length <= 1 ? result[0] ?? '' : result) as JSX.Element
112
+ }
113
+
114
+ export function serializeRichForms<T extends string>(
115
+ keys: readonly T[],
116
+ forms: Partial<Record<T, unknown>> & Record<string, unknown>,
117
+ ): {
118
+ messages: Record<string, string>
119
+ components: Node[]
120
+ } {
121
+ const messages: Record<string, string> = {}
122
+ const components: Node[] = []
123
+
124
+ for (const key of keys) {
125
+ const value = forms[key]
126
+ if (value === undefined) continue
127
+ const extracted = extractMessage(value)
128
+ messages[key] = offsetIndices(extracted.message, components.length)
129
+ components.push(...extracted.components)
130
+ }
131
+
132
+ for (const [key, value] of Object.entries(forms)) {
133
+ if (keys.includes(key as T) || value === undefined) continue
134
+ const extracted = extractMessage(value)
135
+ messages[key] = offsetIndices(extracted.message, components.length)
136
+ components.push(...extracted.components)
137
+ }
138
+
139
+ return { messages, components }
140
+ }
141
+
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
+ }