import { h, type SetupContext } from 'vue' export type LibComponent = | { new (...args: unknown[]): unknown } // Constructor components | { (...args: unknown[]): unknown } // Functional components | Record // Object components export type Props = T extends { new (...args: unknown[]): { $props: infer P } } ? P : T extends { props: infer P } ? P : string export type Key = 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 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 = Key, I extends Key = Key, > = { component: T propMutator?: (props: Record, unknown>) => Record, unknown> classes?: string[] styles?: string[] injectionKeys?: { class?: K style?: I } unstyled?: ExtractUnstyled slots?: Record } export type ThemeLibrary< T extends Record> = Record>, > = { components: T } export class ThemeEngine { readonly lib: ThemeLibrary constructor(library: ThemeLibrary) { this.lib = library } getComponent(key: keyof ThemeLibrary['components']): I { const { component, classes, styles, injectionKeys, propMutator, unstyled, slots } = this.lib.components[key] const componentObj = { ...(component as Record) } componentObj.props = { ...((componentObj.props as Record) || {}), injectedClasses: { default: classes }, injectedStyles: { default: styles }, stylePlug: { type: String }, unstyled: { default: unstyled }, } const applyThemeProps = (props: Record) => { 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) .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) : {} 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, 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, ctx: SetupContext) => { return () => { const themeSlots: Record = {} for (const [name, slotComponent] of Object.entries(slots || {})) { themeSlots[name] = (scope?: Record) => [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 } }