Nychthemeron/packages/library/src/engine/index.ts
Matthew L McPeak eff75b615b Button (#3)
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>
2026-06-08 14:53:51 -04:00

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
}
}