@i18n-micro/solid 1.2.15 → 2.0.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,71 @@
1
+ import { externalLinkHref, isActiveLink, isPlainClick } from '@i18n-micro/core'
2
+ import { type Component, createMemo, type JSX, mergeProps, splitProps } from 'solid-js'
3
+ import { Dynamic } from 'solid-js/web'
4
+ import { useI18nContextValue } from '../injection'
5
+ import type { RouteTarget } from '../router/types'
6
+ import { routePath, useI18n } from '../use-i18n'
7
+ import { mergeStyles } from './merge-styles'
8
+
9
+ export interface I18nLinkProps extends JSX.AnchorHTMLAttributes<HTMLAnchorElement> {
10
+ to: RouteTarget
11
+ localeRoute?: (to: RouteTarget, locale?: string) => RouteTarget
12
+ activeStyle?: JSX.CSSProperties
13
+ }
14
+
15
+ /** Solid's two handler shapes: a function, or `[function, data]`. */
16
+ function callHandler(handler: I18nLinkProps['onClick'], event: Parameters<ClickHandler>[0]): void {
17
+ if (typeof handler === 'function') handler(event)
18
+ else if (handler) handler[0](handler[1], event)
19
+ }
20
+
21
+ type ClickHandler = JSX.EventHandler<HTMLAnchorElement, MouseEvent>
22
+
23
+ export const I18nLink: Component<I18nLinkProps> = (props): JSX.Element => {
24
+ // the provider's props are reactive: `context.router` is read where it is used
25
+ const context = useI18nContextValue()
26
+ const i18n = context.i18n
27
+ const api = useI18n()
28
+ const [local, others] = splitProps(props, ['to', 'localeRoute', 'activeStyle', 'style', 'onClick'])
29
+
30
+ const externalHref = createMemo(() => externalLinkHref(local.to))
31
+ const target = createMemo(() => routePath((local.localeRoute ?? api.localeRoute)(local.to, i18n.locale)))
32
+
33
+ const isActive = createMemo(() => {
34
+ const router = context.router
35
+ if (!router || !local.activeStyle || externalHref() !== undefined) return false
36
+ // The current, default and fallback locale count too, so a provider without `locales` still knows its own root.
37
+ const codes = [i18n.locale, api.defaultLocale(), i18n.fallbackLocale, ...api.getLocales().map((l) => l.code)]
38
+ return isActiveLink(router.getCurrentPath(), target(), codes)
39
+ })
40
+
41
+ const handleClick: ClickHandler = (event) => {
42
+ callHandler(local.onClick, event)
43
+ const router = context.router
44
+ if (!router || externalHref() !== undefined || !isPlainClick(event)) return
45
+ event.preventDefault()
46
+ router.push({ path: target() })
47
+ }
48
+
49
+ const usesRouterLink = () => externalHref() === undefined && context.router?.linkComponent !== undefined
50
+
51
+ const merged = mergeProps(others, {
52
+ get href() {
53
+ return externalHref() ?? target()
54
+ },
55
+ get style() {
56
+ return isActive() ? mergeStyles(local.style, local.activeStyle) : local.style
57
+ },
58
+ // a router link handles its own clicks
59
+ get onClick() {
60
+ return usesRouterLink() ? local.onClick : handleClick
61
+ },
62
+ get target() {
63
+ return externalHref() === undefined ? undefined : (others.target ?? '_blank')
64
+ },
65
+ get rel() {
66
+ return externalHref() === undefined ? undefined : (others.rel ?? 'noopener noreferrer')
67
+ },
68
+ })
69
+
70
+ return <Dynamic component={usesRouterLink() ? context.router!.linkComponent! : 'a'} {...merged} />
71
+ }
@@ -0,0 +1,175 @@
1
+ import { isPlainClick, ownValue } from '@i18n-micro/core'
2
+ import { SWITCHER_STYLES } from '@i18n-micro/core/render'
3
+ import type { Locale } from '@i18n-micro/types'
4
+ import { type Component, createMemo, createSignal, For, type JSX, onCleanup, onMount, Show, splitProps } from 'solid-js'
5
+ import { useI18nContextValue } from '../injection'
6
+ import type { RouteTarget } from '../router/types'
7
+ import { routePath, useI18n } from '../use-i18n'
8
+ import { mergeStyles } from './merge-styles'
9
+
10
+ export interface I18nSwitcherProps extends JSX.HTMLAttributes<HTMLDivElement> {
11
+ locales?: Locale[]
12
+ currentLocale?: string | (() => string)
13
+ getLocaleName?: () => string | null
14
+ switchLocale?: (locale: string) => void
15
+ localeRoute?: (to: RouteTarget, locale?: string) => RouteTarget
16
+ customLabels?: Record<string, string>
17
+ customWrapperStyle?: JSX.CSSProperties
18
+ customButtonStyle?: JSX.CSSProperties
19
+ customDropdownStyle?: JSX.CSSProperties
20
+ customItemStyle?: JSX.CSSProperties
21
+ customLinkStyle?: JSX.CSSProperties
22
+ customActiveLinkStyle?: JSX.CSSProperties
23
+ /**
24
+ * Inline style applied to the main switcher button when the current locale is
25
+ * marked as `disabled: true` in locale config.
26
+ *
27
+ * Disabled locales are omitted from dropdown items.
28
+ */
29
+ customDisabledLinkStyle?: JSX.CSSProperties
30
+ customIconStyle?: JSX.CSSProperties
31
+ }
32
+
33
+ /** Core's look with Solid's property names: `style` here takes CSS names. */
34
+ const css = (style: object): JSX.CSSProperties =>
35
+ Object.fromEntries(
36
+ Object.entries(style).map(([name, value]) => [name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`), value]),
37
+ ) as JSX.CSSProperties
38
+
39
+ const WRAPPER_STYLE = /*#__PURE__*/ css(SWITCHER_STYLES.wrapper)
40
+ const BUTTON_STYLE = /*#__PURE__*/ css(SWITCHER_STYLES.button)
41
+ const DROPDOWN_STYLE = /*#__PURE__*/ css(SWITCHER_STYLES.dropdown)
42
+ const ITEM_STYLE = /*#__PURE__*/ css(SWITCHER_STYLES.item)
43
+ const LINK_STYLE = /*#__PURE__*/ css(SWITCHER_STYLES.link)
44
+ const ACTIVE_LINK_STYLE = /*#__PURE__*/ css(SWITCHER_STYLES.activeLink)
45
+ const ICON_STYLE = /*#__PURE__*/ css(SWITCHER_STYLES.icon)
46
+ const OPEN_ICON_STYLE = /*#__PURE__*/ css(SWITCHER_STYLES.openIcon)
47
+
48
+ export const I18nSwitcher: Component<I18nSwitcherProps> = (props): JSX.Element => {
49
+ const i18n = useI18n()
50
+ // the provider's props are reactive: its values are read where they are used
51
+ const context = useI18nContextValue()
52
+
53
+ const [local, others] = splitProps(props, [
54
+ 'locales',
55
+ 'currentLocale',
56
+ 'getLocaleName',
57
+ 'switchLocale',
58
+ 'localeRoute',
59
+ 'customLabels',
60
+ 'customWrapperStyle',
61
+ 'customButtonStyle',
62
+ 'customDropdownStyle',
63
+ 'customItemStyle',
64
+ 'customLinkStyle',
65
+ 'customActiveLinkStyle',
66
+ 'customDisabledLinkStyle',
67
+ 'customIconStyle',
68
+ 'class',
69
+ 'style',
70
+ ])
71
+
72
+ const [open, setOpen] = createSignal(false)
73
+ let wrapper: HTMLDivElement | undefined
74
+
75
+ const allLocales = createMemo(() => local.locales ?? context.locales ?? [])
76
+ const current = createMemo(() => {
77
+ const prop = local.currentLocale
78
+ return typeof prop === 'function' ? prop() : (prop ?? i18n.locale())
79
+ })
80
+ const currentItem = createMemo(() => allLocales().find((l) => l.code === current()))
81
+
82
+ const label = (locale: Locale): string =>
83
+ (local.customLabels && (ownValue(local.customLabels, locale.code) as string)) || locale.displayName || locale.code
84
+
85
+ const href = (code: string): string => {
86
+ const router = context.router
87
+ const path =
88
+ router?.getCurrentPath() ?? (typeof window === 'undefined' ? '/' : window.location.pathname + window.location.search + window.location.hash)
89
+ if (local.localeRoute) return routePath(local.localeRoute(path, code))
90
+ if (!router) return '#'
91
+ return routePath(router.resolvePath ? router.resolvePath(path, code) : path)
92
+ }
93
+
94
+ const select = (event: MouseEvent, code: string): void => {
95
+ if (!isPlainClick(event)) return
96
+ // a custom `localeRoute` with nothing to navigate: `href` is a real page, the browser's to follow
97
+ if (local.switchLocale || context.router || !local.localeRoute) event.preventDefault()
98
+ setOpen(false)
99
+ if (local.switchLocale) return local.switchLocale(code)
100
+ if (!local.localeRoute) return i18n.switchLocale(code)
101
+ i18n.instance.locale = code
102
+ context.router?.push({ path: href(code) })
103
+ }
104
+
105
+ const onDocumentClick = (event: MouseEvent) => {
106
+ if (open() && !wrapper?.contains(event.target as Node)) setOpen(false)
107
+ }
108
+ const onKeydown = (event: KeyboardEvent) => {
109
+ if (event.key === 'Escape') setOpen(false)
110
+ }
111
+
112
+ // inside onMount: a cleanup registered at setup also runs on the server, where document is missing
113
+ onMount(() => {
114
+ document.addEventListener('click', onDocumentClick)
115
+ document.addEventListener('keydown', onKeydown)
116
+ onCleanup(() => {
117
+ document.removeEventListener('click', onDocumentClick)
118
+ document.removeEventListener('keydown', onKeydown)
119
+ })
120
+ })
121
+
122
+ return (
123
+ <div
124
+ {...others}
125
+ ref={(element) => {
126
+ wrapper = element
127
+ }}
128
+ class={['i18n-switcher-wrapper', local.class].filter(Boolean).join(' ')}
129
+ style={mergeStyles(WRAPPER_STYLE, local.customWrapperStyle, local.style)}
130
+ >
131
+ <button
132
+ type="button"
133
+ class="i18n-switcher-button language-switcher"
134
+ style={{ ...BUTTON_STYLE, ...local.customButtonStyle, ...(currentItem()?.disabled ? local.customDisabledLinkStyle : undefined) }}
135
+ aria-expanded={open()}
136
+ onClick={() => setOpen(!open())}
137
+ >
138
+ <span>{local.getLocaleName?.() || label(currentItem() ?? { code: current() })}</span>{' '}
139
+ <span style={{ ...ICON_STYLE, ...(open() ? OPEN_ICON_STYLE : undefined), ...local.customIconStyle }}>▾</span>
140
+ </button>
141
+
142
+ <Show when={open()}>
143
+ <ul class="i18n-switcher-dropdown" style={{ ...DROPDOWN_STYLE, ...local.customDropdownStyle }}>
144
+ <For each={allLocales().filter((locale) => !locale.disabled)}>
145
+ {(locale) => {
146
+ const active = () => locale.code === current()
147
+ const language = locale.iso || locale.code
148
+ return (
149
+ <li style={{ ...ITEM_STYLE, ...local.customItemStyle }}>
150
+ <a
151
+ class={`switcher-locale-${locale.code}`}
152
+ href={href(locale.code)}
153
+ hreflang={language}
154
+ lang={language}
155
+ dir={locale.dir}
156
+ aria-current={active() ? 'page' : undefined}
157
+ style={{
158
+ ...LINK_STYLE,
159
+ ...(active() ? ACTIVE_LINK_STYLE : undefined),
160
+ ...local.customLinkStyle,
161
+ ...(active() ? local.customActiveLinkStyle : undefined),
162
+ }}
163
+ onClick={(event) => select(event, locale.code)}
164
+ >
165
+ {label(locale)}
166
+ </a>
167
+ </li>
168
+ )
169
+ }}
170
+ </For>
171
+ </ul>
172
+ </Show>
173
+ </div>
174
+ )
175
+ }
@@ -0,0 +1,126 @@
1
+ import { foldTemplate, type RenderSlot, type RenderSlots } from '@i18n-micro/core/render'
2
+ import { interpolate, resolveText, type ResolveTextOptions } from '@i18n-micro/core'
3
+ import { type Component, createMemo, createRoot, getOwner, type JSX, mergeProps, onCleanup, splitProps } from 'solid-js'
4
+ import { Dynamic } from 'solid-js/web'
5
+ import { useI18nContext } from '../injection'
6
+
7
+ /** What fills a `{name}` placeholder. A function may run per occurrence; one node can only stand in one place. */
8
+ export type I18nTComponent = JSX.Element | (() => JSX.Element)
9
+
10
+ export interface I18nTProps extends Omit<JSX.HTMLAttributes<HTMLElement>, 'children'>, ResolveTextOptions {
11
+ tag?: string
12
+ /** Elements for `{name}` placeholders. Beats a param of the same name. */
13
+ components?: Record<string, I18nTComponent>
14
+ /** Replaces the text. As a function it gets the translation, like Vue's default slot. */
15
+ children?: JSX.Element | ((props: { translation: string }) => JSX.Element)
16
+ }
17
+
18
+ const OWN_PROPS = [
19
+ 'keypath',
20
+ 'params',
21
+ 'tag',
22
+ 'plural',
23
+ 'defaultValue',
24
+ 'html',
25
+ 'hideIfEmpty',
26
+ 'customPluralRule',
27
+ 'number',
28
+ 'date',
29
+ 'relativeDate',
30
+ 'components',
31
+ 'children',
32
+ ] as const
33
+
34
+ /** Attributes that would set the content, and with it get past the `html` prop. */
35
+ const CONTENT_ATTRS = ['innerHTML', 'textContent', 'dangerouslySetInnerHTML', 'prop:innerHTML', 'prop:textContent'] as const
36
+
37
+ /** What JSX itself draws as nothing: no children, not empty ones. */
38
+ const isUnset = (children: unknown): boolean => children === undefined || children === null || typeof children === 'boolean'
39
+
40
+ function toSlots(components: I18nTProps['components']): RenderSlots {
41
+ const slots: Record<string, RenderSlot> = Object.create(null)
42
+ for (const name of Object.keys(components ?? {})) {
43
+ const value = components![name]
44
+ if (value !== undefined) slots[name] = typeof value === 'function' ? (value as RenderSlot) : () => value
45
+ }
46
+ return slots
47
+ }
48
+
49
+ export const I18nT: Component<I18nTProps> = (props): JSX.Element => {
50
+ const i18n = useI18nContext()
51
+ const [local, , attrs] = splitProps(props, OWN_PROPS, CONTENT_ATTRS as never)
52
+
53
+ const resolved = createMemo(() => {
54
+ // a custom rule may read only its `locale` argument, which core takes untracked
55
+ i18n.localeAccessor()
56
+ return resolveText(i18n, local)
57
+ })
58
+ const text = (): string => {
59
+ const { template, params } = resolved()!
60
+ return interpolate(template, params)
61
+ }
62
+
63
+ // A locale that reorders placeholders moves these nodes: each is built once, in a root of its own,
64
+ // and disposed when the template drops it or new `components` replace the slots.
65
+ const slotScope = createMemo(() => {
66
+ const nodes = new Map<string, { node: unknown; dispose: () => void }>()
67
+ onCleanup(() => {
68
+ for (const built of nodes.values()) built.dispose()
69
+ })
70
+ return { slots: toSlots(local.components), nodes, owner: getOwner() }
71
+ })
72
+ const renderSlot = (slot: RenderSlot, key: string): unknown => {
73
+ const { nodes, owner } = slotScope()
74
+ if (!nodes.has(key))
75
+ nodes.set(
76
+ key,
77
+ createRoot((dispose) => ({ node: slot({}), dispose }), owner),
78
+ )
79
+ return nodes.get(key)!.node
80
+ }
81
+ const foldSlots = (): unknown => {
82
+ const { template, params } = resolved()!
83
+ const { slots, nodes } = slotScope()
84
+ const used = new Set<string>()
85
+ const folded = foldTemplate(
86
+ (slot, key) => {
87
+ used.add(key)
88
+ return renderSlot(slot, key)
89
+ },
90
+ template,
91
+ params,
92
+ slots,
93
+ )
94
+ for (const [key, built] of nodes) {
95
+ if (used.has(key)) continue
96
+ built.dispose()
97
+ nodes.delete(key)
98
+ }
99
+ return folded
100
+ }
101
+
102
+ const content = (): JSX.Element => {
103
+ const children = local.children
104
+ if (typeof children === 'function') return children({ translation: text() })
105
+ if (!isUnset(children)) return children
106
+ return foldSlots() as JSX.Element
107
+ }
108
+
109
+ // a static tag skips `Dynamic`: one memo per element less
110
+ const element = (body: object): JSX.Element => {
111
+ const merged = mergeProps(attrs, body)
112
+ return local.tag ? <Dynamic component={local.tag} {...merged} /> : <span {...(merged as JSX.HTMLAttributes<HTMLSpanElement>)} />
113
+ }
114
+ const markup = {
115
+ get innerHTML() {
116
+ return text()
117
+ },
118
+ }
119
+ const body = {
120
+ get children() {
121
+ return content()
122
+ },
123
+ }
124
+
125
+ return <>{resolved() === null ? local.defaultValue : local.html ? element(markup) : element(body)}</>
126
+ }
@@ -0,0 +1,16 @@
1
+ import type { JSX } from 'solid-js'
2
+
3
+ type Style = JSX.CSSProperties | string | undefined
4
+
5
+ const cssText = (style: Style): string =>
6
+ typeof style === 'string'
7
+ ? style
8
+ : Object.entries(style ?? {})
9
+ .map(([name, value]) => `${name}:${value}`)
10
+ .join(';')
11
+
12
+ /** Later wins. Solid's `style` takes an object or a CSS string; a string among them makes the result one too. */
13
+ export function mergeStyles(...styles: Style[]): JSX.CSSProperties | string {
14
+ if (styles.some((style) => typeof style === 'string')) return styles.map(cssText).filter(Boolean).join(';')
15
+ return Object.assign({}, ...styles)
16
+ }
@@ -0,0 +1,41 @@
1
+ import { localeIsoMap } from '@i18n-micro/core'
2
+ import type { Locale } from '@i18n-micro/types'
3
+ import { createComputed, type JSX, type ParentComponent, useContext } from 'solid-js'
4
+ import type { SolidI18n } from './i18n'
5
+ import { I18nContext } from './injection'
6
+ import type { I18nRoutingStrategy } from './router/types'
7
+
8
+ export interface I18nProviderProps {
9
+ /** Without it, the outer provider's instance. */
10
+ i18n?: SolidI18n
11
+ locales?: Locale[]
12
+ defaultLocale?: string
13
+ routingStrategy?: I18nRoutingStrategy
14
+ }
15
+
16
+ // An omitted prop keeps the value of an outer provider.
17
+ export const I18nProvider: ParentComponent<I18nProviderProps> = (props): JSX.Element => {
18
+ const outer = useContext(I18nContext)
19
+ const value = {
20
+ get i18n() {
21
+ const i18n = props.i18n ?? outer?.i18n
22
+ if (!i18n) throw new Error('[i18n-micro] I18nProvider needs an `i18n` instance or an outer provider.')
23
+ return i18n
24
+ },
25
+ get locales() {
26
+ return props.locales ?? outer?.locales
27
+ },
28
+ get defaultLocale() {
29
+ return props.defaultLocale ?? outer?.defaultLocale
30
+ },
31
+ get router() {
32
+ return props.routingStrategy ?? outer?.router
33
+ },
34
+ }
35
+ createComputed(() => {
36
+ if (props.locales) value.i18n.addLocaleIso(localeIsoMap(props.locales))
37
+ })
38
+ return <I18nContext.Provider value={value}>{props.children}</I18nContext.Provider>
39
+ }
40
+
41
+ export { useI18nContext } from './injection'
package/src/i18n.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { I18n, type I18nOptions } from '@i18n-micro/runtime'
2
+ import { type Accessor, batch, createSignal } from 'solid-js'
3
+
4
+ export type SolidI18nOptions = I18nOptions
5
+
6
+ /**
7
+ * The runtime instance with signals over its store. A translation read tracks the revision, which
8
+ * every change bumps. Reading the locale tracks the locale alone, so `createEffect(() => load(locale()))`
9
+ * does not wake itself. `getLocale()` and `getRoute()` stay plain, as a write reads them too: track
10
+ * with `locale` and `currentRoute`.
11
+ */
12
+ export class SolidI18n extends I18n {
13
+ private readonly revision: Accessor<number>
14
+ private readonly localeSignal: Accessor<string>
15
+ private readonly fallbackSignal: Accessor<string>
16
+ private readonly routeSignal: Accessor<string>
17
+
18
+ public readonly localeAccessor: Accessor<string> = () => this.locale
19
+ public readonly routeAccessor: Accessor<string> = () => this.currentRoute
20
+
21
+ constructor(options: SolidI18nOptions) {
22
+ super(options)
23
+ const [revision, setRevision] = createSignal(0)
24
+ const [locale, setLocale] = createSignal(this.getLocale())
25
+ const [fallback, setFallback] = createSignal(this.getFallbackLocale())
26
+ const [route, setRoute] = createSignal(this.getRoute())
27
+ this.revision = revision
28
+ this.localeSignal = locale
29
+ this.fallbackSignal = fallback
30
+ this.routeSignal = route
31
+ this.subscribe(() => {
32
+ batch(() => {
33
+ setLocale(this.getLocale())
34
+ setFallback(this.getFallbackLocale())
35
+ setRoute(this.getRoute())
36
+ setRevision((n) => n + 1)
37
+ })
38
+ })
39
+ }
40
+
41
+ protected override touch(): void {
42
+ this.revision()
43
+ }
44
+
45
+ override get locale(): string {
46
+ return this.localeSignal()
47
+ }
48
+
49
+ override set locale(value: string) {
50
+ super.locale = value
51
+ }
52
+
53
+ override get fallbackLocale(): string {
54
+ return this.fallbackSignal()
55
+ }
56
+
57
+ override set fallbackLocale(value: string) {
58
+ super.fallbackLocale = value
59
+ }
60
+
61
+ override get currentRoute(): string {
62
+ return this.routeSignal()
63
+ }
64
+ }
65
+
66
+ export function createI18n(options: SolidI18nOptions): SolidI18n {
67
+ return new SolidI18n(options)
68
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ // Main exports
2
+
3
+ // Re-export utilities from core
4
+ export { defaultPlural, FormatService, interpolate } from '@i18n-micro/core'
5
+ // Re-export types from @i18n-micro/types
6
+ export type { CleanTranslation, Getter, Locale, LocaleCode, ModuleOptions, Params, PluralFunc, TranslationKey, Translations } from '@i18n-micro/types'
7
+ export type { I18nGroupProps } from './components/I18nGroup'
8
+ export { I18nGroup } from './components/I18nGroup'
9
+ export type { I18nLinkProps } from './components/I18nLink'
10
+ export { I18nLink } from './components/I18nLink'
11
+ export type { I18nSwitcherProps } from './components/I18nSwitcher'
12
+ export { I18nSwitcher } from './components/I18nSwitcher'
13
+ export type { I18nTComponent, I18nTProps } from './components/I18nT'
14
+ export { I18nT } from './components/I18nT'
15
+ export type { I18nProviderProps } from './context'
16
+ export { I18nProvider } from './context'
17
+ export type { SolidI18nOptions } from './i18n'
18
+ export { createI18n, SolidI18n } from './i18n'
19
+ // Injection helpers
20
+ export type { I18nContextValue } from './injection'
21
+ export { I18nContext, useI18nContext, useI18nDefaultLocale, useI18nLocales, useI18nRouter } from './injection'
22
+ // Router adapter: `@i18n-micro/solid/router` (keeps the optional router peer out of this entry)
23
+ // Router abstraction
24
+ export type { I18nRoutingStrategy, RouteTarget } from './router/types'
25
+ export type { UseI18nOptions, UseI18nReturn } from './use-i18n'
26
+ export { useI18n } from './use-i18n'
@@ -0,0 +1,31 @@
1
+ import type { Locale } from '@i18n-micro/types'
2
+ import { createContext, useContext } from 'solid-js'
3
+ import type { SolidI18n } from './i18n'
4
+ import type { I18nRoutingStrategy } from './router/types'
5
+ import type { UseI18nReturn } from './use-i18n'
6
+
7
+ /** Everything a provider hands down: getters over its props, so they stay reactive. */
8
+ export interface I18nContextValue {
9
+ readonly i18n: SolidI18n
10
+ readonly locales: Locale[] | undefined
11
+ readonly defaultLocale: string | undefined
12
+ readonly router: I18nRoutingStrategy | undefined
13
+ /** The `useI18n()` object: one per provider, signals do the rest. */
14
+ api?: UseI18nReturn
15
+ }
16
+
17
+ export const I18nContext = createContext<I18nContextValue>()
18
+
19
+ export const useI18nContextValue = (): I18nContextValue => {
20
+ const context = useContext(I18nContext)
21
+ if (!context) {
22
+ throw new Error('[i18n-micro] I18nContext not found. Make sure I18nProvider is used.')
23
+ }
24
+ return context
25
+ }
26
+
27
+ export const useI18nContext = (): SolidI18n => useI18nContextValue().i18n
28
+ // One read at setup. A component that follows a provider's changes reads `useI18n().getLocales()`, or the context, inside a memo.
29
+ export const useI18nLocales = (): Locale[] | undefined => useContext(I18nContext)?.locales
30
+ export const useI18nDefaultLocale = (): string | undefined => useContext(I18nContext)?.defaultLocale
31
+ export const useI18nRouter = (): I18nRoutingStrategy | undefined => useContext(I18nContext)?.router
@@ -0,0 +1,54 @@
1
+ import { localizedPath, routeQuery } from '@i18n-micro/core'
2
+ import type { Locale } from '@i18n-micro/types'
3
+ import { A, type Location, type useNavigate } from '@solidjs/router'
4
+ import type { ComponentProps, JSX } from 'solid-js'
5
+ import type { I18nRoutingStrategy } from './types'
6
+
7
+ type NavigateFunction = ReturnType<typeof useNavigate>
8
+
9
+ const RouterLink = (props: { href: string; [key: string]: unknown }): JSX.Element => <A {...(props as ComponentProps<typeof A>)} />
10
+
11
+ /**
12
+ * Factory for Solid Router adapter
13
+ *
14
+ * `location` is the router's reactive object, so reading the path inside a memo or JSX follows
15
+ * navigation without a signal of its own.
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * const RouterRoot: Component = () => {
20
+ * const navigate = useNavigate()
21
+ * const location = useLocation()
22
+ * const routingStrategy = createSolidRouterAdapter(locales, defaultLocale, navigate, location)
23
+ * // ...
24
+ * }
25
+ * ```
26
+ *
27
+ * @param locales - Array of locale configurations
28
+ * @param defaultLocale - Default locale code
29
+ * @param navigate - Navigation function from @solidjs/router's useNavigate hook
30
+ * @param location - Location object from @solidjs/router's useLocation hook
31
+ * @returns I18nRoutingStrategy
32
+ */
33
+ export function createSolidRouterAdapter(
34
+ locales: Locale[],
35
+ defaultLocale: string,
36
+ navigate: NavigateFunction,
37
+ location: Location,
38
+ ): I18nRoutingStrategy {
39
+ const codes = locales.map((locale) => locale.code)
40
+ const fullPath = () => `${location.pathname}${location.search}${location.hash}`
41
+
42
+ return {
43
+ linkComponent: RouterLink,
44
+ getCurrentPath: fullPath,
45
+ push: (target) => {
46
+ navigate(target.path)
47
+ },
48
+ replace: (target) => {
49
+ navigate(target.path, { replace: true })
50
+ },
51
+ resolvePath: (to, locale) => localizedPath(to, locale, codes, defaultLocale),
52
+ getRoute: () => ({ fullPath: fullPath(), query: routeQuery(location.search) }),
53
+ }
54
+ }
@@ -0,0 +1,3 @@
1
+ // `@solidjs/router` is an optional peer: only this entry imports it, never the main one.
2
+ export { createSolidRouterAdapter } from './adapter'
3
+ export type { I18nRoutingStrategy } from './types'
@@ -0,0 +1,53 @@
1
+ import type { Component, JSX } from 'solid-js'
2
+
3
+ /** Where a link or a switch goes. */
4
+ export type RouteTarget = string | { path?: string }
5
+
6
+ /**
7
+ * Routing strategy interface for i18n
8
+ * Implement this interface to integrate with any router library
9
+ * Matches Vue package interface for consistency
10
+ */
11
+ export interface I18nRoutingStrategy {
12
+ /**
13
+ * Returns the current path with its query and hash, so a locale switch keeps them. Links compare
14
+ * it by page. Read a signal or a reactive location here and links and the switcher follow it.
15
+ */
16
+ getCurrentPath: () => string
17
+
18
+ /**
19
+ * Component to use for rendering links (e.g., RouterLink)
20
+ */
21
+ linkComponent?:
22
+ | string
23
+ | Component<{
24
+ href: string
25
+ children?: JSX.Element
26
+ style?: JSX.CSSProperties | string
27
+ class?: string
28
+ [key: string]: unknown
29
+ }>
30
+
31
+ /**
32
+ * Function to navigate to another route/locale
33
+ */
34
+ push: (target: { path: string }) => void
35
+
36
+ /**
37
+ * Function to replace current route
38
+ */
39
+ replace: (target: { path: string }) => void
40
+
41
+ /**
42
+ * Generate path for specific locale
43
+ */
44
+ resolvePath?: (to: RouteTarget, locale: string) => RouteTarget
45
+
46
+ /**
47
+ * (Optional) Get current route object for SEO/Meta tags
48
+ */
49
+ getRoute?: () => {
50
+ fullPath: string
51
+ query: Record<string, unknown>
52
+ }
53
+ }