@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.
- package/README.md +16 -53
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +101 -169
- package/dist/index.d.ts +101 -169
- package/dist/index.mjs +309 -436
- package/dist/index.mjs.map +1 -1
- package/dist/router.cjs +2 -0
- package/dist/router.cjs.map +1 -0
- package/dist/router.d.cts +84 -0
- package/dist/router.d.ts +84 -0
- package/dist/router.mjs +26 -0
- package/dist/router.mjs.map +1 -0
- package/package.json +33 -15
- package/src/components/I18nGroup.tsx +27 -0
- package/src/components/I18nLink.tsx +71 -0
- package/src/components/I18nSwitcher.tsx +175 -0
- package/src/components/I18nT.tsx +126 -0
- package/src/components/merge-styles.ts +16 -0
- package/src/context.tsx +41 -0
- package/src/i18n.ts +68 -0
- package/src/index.ts +26 -0
- package/src/injection.ts +31 -0
- package/src/router/adapter.tsx +54 -0
- package/src/router/index.ts +3 -0
- package/src/router/types.ts +53 -0
- package/src/use-i18n.ts +72 -0
|
@@ -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
|
+
}
|
package/src/context.tsx
ADDED
|
@@ -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'
|
package/src/injection.ts
ADDED
|
@@ -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,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
|
+
}
|