Reviewed-on: McPeakDev/nychthemeron#3 Co-authored-by: Matthew L McPeak <m.mcpeak98@icloud.com> Co-committed-by: Matthew L McPeak <m.mcpeak98@icloud.com>
192 lines
6.2 KiB
TypeScript
192 lines
6.2 KiB
TypeScript
import { h, type SetupContext } from 'vue'
|
|
|
|
export type LibComponent =
|
|
| { new (...args: unknown[]): unknown } // Constructor components
|
|
| { (...args: unknown[]): unknown } // Functional components
|
|
| Record<string, unknown> // Object components
|
|
|
|
export type Props<T> = T extends { new (...args: unknown[]): { $props: infer P } }
|
|
? P
|
|
: T extends { props: infer P }
|
|
? P
|
|
: string
|
|
|
|
export type Key<T> = T extends { new (...args: unknown[]): { $props: infer P } }
|
|
? keyof P
|
|
: T extends { props: infer P }
|
|
? keyof P
|
|
: string
|
|
|
|
// Try to extract unstyled type from component
|
|
// If extraction fails, accept any value via unknown
|
|
type ExtractUnstyled<T> = T extends { props: infer P }
|
|
? P extends { unstyled: infer U }
|
|
? U
|
|
: unknown
|
|
: T extends { new (...args: unknown[]): { $props: infer P } }
|
|
? P extends { unstyled: infer U }
|
|
? U
|
|
: unknown
|
|
: unknown
|
|
|
|
export type Themeable<
|
|
T extends LibComponent,
|
|
K extends Key<T> = Key<T>,
|
|
I extends Key<T> = Key<T>,
|
|
> = {
|
|
component: T
|
|
propMutator?: (props: Record<Key<T>, unknown>) => Record<Key<T>, unknown>
|
|
classes?: string[]
|
|
styles?: string[]
|
|
injectionKeys?: {
|
|
class?: K
|
|
style?: I
|
|
}
|
|
unstyled?: ExtractUnstyled<T>
|
|
slots?: Record<string, LibComponent>
|
|
}
|
|
|
|
export type ThemeLibrary<
|
|
T extends Record<string, Themeable<LibComponent>> = Record<string, Themeable<LibComponent>>,
|
|
> = {
|
|
components: T
|
|
}
|
|
|
|
export class ThemeEngine {
|
|
readonly lib: ThemeLibrary
|
|
|
|
constructor(library: ThemeLibrary) {
|
|
this.lib = library
|
|
}
|
|
|
|
getComponent<I extends LibComponent>(key: keyof ThemeLibrary['components']): I {
|
|
const { component, classes, styles, injectionKeys, propMutator, unstyled, slots } =
|
|
this.lib.components[key]
|
|
|
|
const componentObj = { ...(component as Record<string, unknown>) }
|
|
|
|
componentObj.props = {
|
|
...((componentObj.props as Record<string, unknown>) || {}),
|
|
injectedClasses: { default: classes },
|
|
injectedStyles: { default: styles },
|
|
stylePlug: { type: String },
|
|
unstyled: { default: unstyled },
|
|
}
|
|
|
|
const applyThemeProps = (props: Record<string, unknown>) => {
|
|
const mergedProps = { ...props }
|
|
|
|
const stylePlug =
|
|
typeof mergedProps['stylePlug'] === 'string' ? mergedProps['stylePlug'] : undefined
|
|
|
|
const defaultClassValues = Array.isArray(mergedProps['injectedClasses'])
|
|
? mergedProps['injectedClasses']
|
|
: []
|
|
|
|
const defaultStyleValues = Array.isArray(mergedProps['injectedStyles'])
|
|
? mergedProps['injectedStyles']
|
|
: []
|
|
|
|
const classKey = injectionKeys?.class ?? 'class'
|
|
const styleKey = injectionKeys?.style ?? 'style'
|
|
|
|
const classValue = mergedProps[classKey] ?? []
|
|
const styleValue =
|
|
typeof mergedProps[styleKey] === 'string'
|
|
? mergedProps[styleKey]
|
|
.split(';')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
: []
|
|
|
|
let classArray: string[] | undefined = undefined
|
|
|
|
if (typeof classValue === 'object') {
|
|
if (Array.isArray(classValue)) {
|
|
classArray = classValue
|
|
} else if (classKey !== 'pt') {
|
|
// Support Vue's object-form class syntax: { 'my-class': condition }
|
|
classArray = Object.entries(classValue as Record<string, boolean>)
|
|
.filter(([, active]) => Boolean(active))
|
|
.map(([name]) => name)
|
|
}
|
|
}
|
|
|
|
if (typeof classValue === 'string') {
|
|
classArray = classValue.split(' ')
|
|
}
|
|
|
|
if (!classArray) {
|
|
classArray = []
|
|
}
|
|
|
|
const finalClasses = stylePlug
|
|
? [...classArray, ...defaultClassValues]
|
|
.map((cls) => (cls.startsWith(`${stylePlug}-`) ? cls : `${stylePlug}-${cls}`))
|
|
.join(' ')
|
|
: [...classArray, ...defaultClassValues].join(' ')
|
|
const finalStyles = [...styleValue, ...defaultStyleValues].join('; ')
|
|
|
|
if (classKey === 'pt' || styleKey === 'pt') {
|
|
mergedProps['unstyled'] = true
|
|
const existingPt =
|
|
typeof mergedProps[classKey] === 'object'
|
|
? (mergedProps[classKey] as Record<string, unknown>)
|
|
: {}
|
|
const existingRoot = (existingPt.root as { class?: string; style?: string }) ?? {}
|
|
mergedProps[classKey] = {
|
|
...existingPt,
|
|
root: {
|
|
...existingRoot,
|
|
class: [existingRoot.class, finalClasses].filter(Boolean).join(' '),
|
|
style: [existingRoot.style, finalStyles].filter(Boolean).join('; '),
|
|
},
|
|
}
|
|
} else {
|
|
mergedProps[classKey] = finalClasses
|
|
}
|
|
|
|
if (styleKey !== 'pt') {
|
|
mergedProps[styleKey] = finalStyles
|
|
}
|
|
|
|
if (propMutator) {
|
|
// Supports both in-place mutation (returns same ref) and returning a new props object
|
|
const mutated = propMutator(mergedProps as Record<Key<typeof component>, unknown>)
|
|
if (mutated !== mergedProps) {
|
|
Object.assign(mergedProps, mutated)
|
|
}
|
|
}
|
|
|
|
return mergedProps
|
|
}
|
|
|
|
// Always wrap via a render function so the original component runs its own
|
|
// setup naturally through Vue's component system with the modified props.
|
|
// Slots are merged here so no extra wrapper component layer is needed.
|
|
componentObj.setup = (props: Record<string, unknown>, ctx: SetupContext) => {
|
|
return () => {
|
|
const themeSlots: Record<string, unknown> = {}
|
|
for (const [name, slotComponent] of Object.entries(slots || {})) {
|
|
themeSlots[name] = (scope?: Record<string, unknown>) => [h(slotComponent, scope || {})]
|
|
}
|
|
|
|
const originalSlots = ctx.slots || {}
|
|
const mergedSlots = { ...themeSlots, ...originalSlots }
|
|
|
|
// Merge attrs into props so the engine sees class/style from fallthrough attrs
|
|
// before merging theme values — prevents ctx.attrs from overwriting engine output.
|
|
// Running inside the render function also keeps mergedProps reactive to prop/attr changes.
|
|
const mergedProps = applyThemeProps({ ...props, ...ctx.attrs })
|
|
|
|
for (const key of ['injectedClasses', 'injectedStyles', 'stylePlug']) {
|
|
delete mergedProps[key as keyof typeof mergedProps]
|
|
}
|
|
|
|
return h(component as LibComponent, mergedProps, mergedSlots)
|
|
}
|
|
}
|
|
|
|
return componentObj as I
|
|
}
|
|
}
|