Use direct conditional instead of optional chaining so Bun can statically analyze and remove the HMR block in production builds. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import type { JSX } from 'hono/jsx'
|
|
import { type TagDef, UnitlessProps, NonStyleKeys } from './types'
|
|
|
|
export const styles: Record<string, Record<string, string>> = {}
|
|
const themes: Record<string, Record<string, any>> = {}
|
|
|
|
export function clearStyles() {
|
|
for (const key in styles) delete styles[key]
|
|
for (const key in themes) delete themes[key]
|
|
}
|
|
|
|
// HMR support - clear styles when module is replaced
|
|
if (import.meta.hot) {
|
|
import.meta.hot.dispose(() => clearStyles())
|
|
}
|
|
|
|
export function createTheme<const T extends Record<string, string | number>>(name: string, values: T): T {
|
|
themes[name] = values as Record<string, string | number>
|
|
return values
|
|
}
|
|
|
|
// Generate CSS for all registered themes
|
|
export function themesToCSS(): string {
|
|
let out: string[] = []
|
|
|
|
for (const [name, vars] of Object.entries(themes)) {
|
|
out.push(`[data-theme="${name}"] {`)
|
|
for (const [key, value] of Object.entries(vars)) {
|
|
out.push(` --theme-${key}: ${value};`)
|
|
}
|
|
out.push(`}\n`)
|
|
}
|
|
|
|
return out.join('\n')
|
|
}
|
|
|
|
// Helper type to extract theme keys from multiple theme objects
|
|
type ThemeKeys<T> = T extends Record<string, any> ? keyof T : never
|
|
|
|
// Create a typed themeVar function from your themes
|
|
export function createThemedVar<T extends Record<string, any>>(_themes: T) {
|
|
return function themeVar<K extends ThemeKeys<T[keyof T]>>(name: K): string {
|
|
return `var(--theme-${name as string})`
|
|
}
|
|
}
|
|
|
|
type Theme = Record<string, string | number>
|
|
|
|
export function createThemes<T extends Record<string, Theme>>(themeDefs: T) {
|
|
for (const [name, values] of Object.entries(themeDefs))
|
|
createTheme(name, values)
|
|
|
|
return (name: keyof T[keyof T]) => `var(--theme-${name as string})`
|
|
}
|
|
|
|
export function extendThemes(overrides: Record<string, Theme>) {
|
|
for (const [name, values] of Object.entries(overrides))
|
|
themes[name] = { ...themes[name], ...values }
|
|
|
|
return (name: string) => `var(--theme-${name})`
|
|
}
|
|
|
|
// Generic themeVar (untyped fallback)
|
|
export function themeVar(name: string): string {
|
|
return `var(--theme-${name as string})`
|
|
}
|
|
|
|
// All CSS styles inside <style></style.
|
|
// Use w/ SSR: <Styles/>
|
|
export const Styles = () => <style dangerouslySetInnerHTML={{ __html: stylesToCSS() }} />
|
|
|
|
const isBrowser = typeof document !== 'undefined'
|
|
let styleElement: HTMLStyleElement | null = null
|
|
|
|
// automatically inject <style> tag into browser for SPA
|
|
function injectStylesInBrowser() {
|
|
if (!isBrowser) return
|
|
|
|
styleElement ??= document.getElementById('forge-styles') as HTMLStyleElement
|
|
|
|
if (!styleElement) {
|
|
styleElement = document.createElement('style')
|
|
styleElement.id = 'forge-styles'
|
|
document.head.appendChild(styleElement)
|
|
}
|
|
|
|
styleElement.textContent = stylesToCSS()
|
|
}
|
|
|
|
// turns style object into string CSS definition
|
|
export function stylesToCSS(): string {
|
|
let out: string[] = []
|
|
|
|
// Include theme CSS first
|
|
const themeCSS = themesToCSS()
|
|
if (themeCSS) {
|
|
out.push(themeCSS)
|
|
out.push('\n')
|
|
}
|
|
|
|
// Then component styles
|
|
for (const [selector, style] of Object.entries(styles)) {
|
|
if (Object.keys(style).length === 0) continue
|
|
|
|
out.push(`${expandSelector(selector)} { `)
|
|
for (const [name, value] of Object.entries(style).sort(([a], [b]) => a.localeCompare(b)))
|
|
out.push(` ${name}: ${value};`)
|
|
out.push(`}\n`)
|
|
}
|
|
|
|
return out.join('\n')
|
|
}
|
|
|
|
function expandSelector(selector: string): string {
|
|
return selector.startsWith('.') ? selector : `.${selector}`
|
|
}
|
|
|
|
// creates a CSS class name
|
|
function makeClassName(baseName: string, partName?: string, variantName?: string, variantKey?: string): string {
|
|
const cls = partName ? `${baseName}_${partName}` : baseName
|
|
|
|
if (variantName && variantKey) {
|
|
return cls + `.${variantName}-${variantKey}`
|
|
} else {
|
|
return cls
|
|
}
|
|
}
|
|
|
|
// 'fontSize' => 'font-size'
|
|
function camelToDash(name: string): string {
|
|
let out = ''
|
|
|
|
for (const letter of name.split(''))
|
|
out += letter.toUpperCase() === letter ? `-${letter.toLowerCase()}` : letter
|
|
|
|
return out
|
|
}
|
|
|
|
// turns a TagDef into a JSX style object
|
|
function makeStyle(def: TagDef) {
|
|
const style: Record<string, string> = {}
|
|
|
|
for (const [name, value] of Object.entries(def)) {
|
|
if (NonStyleKeys.has(name)) continue
|
|
style[camelToDash(name)] = `${typeof value === 'number' && !UnitlessProps.has(name) ? `${value}px` : value}`
|
|
}
|
|
|
|
return style
|
|
}
|
|
|
|
// turns a TagDef into a JSX component
|
|
function makeComponent(baseName: string, rootDef: TagDef, rootProps: Record<string, any>, partName?: string) {
|
|
const def = partName ? rootDef.parts?.[partName]! : rootDef
|
|
const base = def.base ?? 'div'
|
|
|
|
// Extract element name from base (e.g., 'input[type=radio]' -> 'input')
|
|
const tagName = base.split('[')[0]
|
|
const Tag = (tagName) as keyof JSX.IntrinsicElements
|
|
|
|
// Extract attributes from base (e.g., 'input[type=radio]' -> { type: 'radio' })
|
|
const baseAttrs: Record<string, string> = {}
|
|
const attrMatch = base.match(/\[([^\]]+)\]/)
|
|
if (attrMatch && attrMatch[1]) {
|
|
const attrStr = attrMatch[1]
|
|
const [attrName, attrValue] = attrStr.split('=')
|
|
if (attrName && attrValue) {
|
|
baseAttrs[attrName] = attrValue
|
|
}
|
|
}
|
|
|
|
return ({ children, ...props }: { children: any, [key: string]: any }) => {
|
|
const classNames = [makeClassName(baseName, partName)]
|
|
|
|
const allProps = { ...rootProps, ...props }
|
|
|
|
for (const [key, value] of Object.entries(allProps)) {
|
|
const variantConfig = rootDef.variants?.[key]
|
|
if (!variantConfig) continue
|
|
|
|
// Remove variant prop from being passed to HTML element
|
|
delete props[key]
|
|
|
|
const variantName = key
|
|
const variantKey = value
|
|
|
|
let variantDef: TagDef | undefined
|
|
// Distinguish boolean variants from keyed variants:
|
|
// - Boolean variants: component({ variant: true }) → variantConfig is a TagDef
|
|
// - Keyed variants: component({ variant: 'key' }) → variantConfig[key] is a TagDef
|
|
if (value === true) {
|
|
variantDef = variantConfig as TagDef
|
|
} else if (typeof value === 'string') {
|
|
variantDef = (variantConfig as Record<string, TagDef>)[value]
|
|
}
|
|
if (!variantDef) continue
|
|
|
|
classNames.push(variantKey === true ? variantName : `${variantName}-${variantKey}`)
|
|
}
|
|
|
|
return <Tag class={classNames.join(' ')} {...baseAttrs} {...props}>{children}</Tag>
|
|
}
|
|
}
|
|
|
|
// ensures 'hover' is ':hover'
|
|
function stateName(state: string): string {
|
|
return state.startsWith(':') ? state : `:${state}`
|
|
}
|
|
|
|
// Register base styles, selectors, and states for a class
|
|
function registerClassStyles(name: string, className: string, def: TagDef) {
|
|
styles[className] ??= makeStyle(def)
|
|
|
|
for (let [selector, selectorDef] of Object.entries(def.selectors ?? {})) {
|
|
selector = selector.replace(/@(\w+)/g, (_, partName) => `.${makeClassName(name, partName)}`)
|
|
selector = selector.replace('&', `.${className}`)
|
|
if (styles[selector]) throw `${selector} already defined!`
|
|
styles[selector] = makeStyle(selectorDef)
|
|
}
|
|
|
|
for (const [state, style] of Object.entries(def.states ?? {}))
|
|
styles[`${className}${stateName(state)}`] = makeStyle(style)
|
|
}
|
|
|
|
// adds CSS styles for tag definition
|
|
function registerStyles(name: string, def: TagDef) {
|
|
const rootClassName = makeClassName(name)
|
|
registerClassStyles(name, rootClassName, def)
|
|
|
|
for (const [partName, partDef] of Object.entries(def.parts ?? {})) {
|
|
const partClassName = makeClassName(name, partName)
|
|
registerClassStyles(name, partClassName, partDef)
|
|
}
|
|
|
|
for (const [variantName, variantConfig] of Object.entries(def.variants ?? {})) {
|
|
// Detect boolean vs keyed variants by checking if config has structural keys or looks like a TagDef
|
|
const isBooleanVariant = 'parts' in variantConfig || 'styles' in variantConfig || 'states' in variantConfig ||
|
|
// If first key is camelCase or contains CSS-like properties, treat as boolean variant
|
|
Object.keys(variantConfig).some(k => k !== k.toLowerCase() || typeof (variantConfig as any)[k] !== 'object')
|
|
|
|
if (isBooleanVariant) {
|
|
// Boolean variant - variantConfig is a TagDef
|
|
const variantDef = variantConfig as TagDef
|
|
const baseClassName = makeClassName(name)
|
|
const className = `${baseClassName}.${variantName}`
|
|
registerClassStyles(name, className, variantDef)
|
|
|
|
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
|
|
const basePartClassName = makeClassName(name, partName)
|
|
const partClassName = `${basePartClassName}.${variantName}`
|
|
registerClassStyles(name, partClassName, partDef)
|
|
}
|
|
} else {
|
|
// Keyed variant - iterate over the keys
|
|
for (const [variantKey, variantDef] of Object.entries(variantConfig as Record<string, TagDef>)) {
|
|
const className = makeClassName(name, undefined, variantName, variantKey)
|
|
registerClassStyles(name, className, variantDef)
|
|
|
|
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
|
|
const partClassName = makeClassName(name, partName, variantName, variantKey)
|
|
registerClassStyles(name, partClassName, partDef)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// In browser, inject styles into DOM immediately
|
|
injectStylesInBrowser()
|
|
}
|
|
|
|
// module-level scoping
|
|
export function createScope(scope: string) {
|
|
return {
|
|
define: (nameOrDef: string | TagDef, defIfNamed?: TagDef) => {
|
|
if (typeof nameOrDef === 'string')
|
|
return define(`${scope}${nameOrDef === 'Root' ? '' : nameOrDef}`, defIfNamed)
|
|
else
|
|
return define(`${scope}${anonName(nameOrDef)}`, nameOrDef as TagDef)
|
|
}
|
|
}
|
|
}
|
|
|
|
// the main event
|
|
export function define(nameOrDef: string | TagDef, defIfNamed?: TagDef) {
|
|
const def = defIfNamed ?? nameOrDef as TagDef
|
|
const name = defIfNamed ? (nameOrDef as string) : anonName(def)
|
|
|
|
if (styles[name]) throw `${name} is already defined! Must use unique names.`
|
|
registerStyles(name, def)
|
|
|
|
return (props: Record<string, any>) => {
|
|
const parts: Record<string, Function> = {}
|
|
|
|
for (const [part] of Object.entries(def.parts ?? {}))
|
|
parts[part] = makeComponent(name, def, props, part)
|
|
|
|
parts.Root = makeComponent(name, def, props)
|
|
return def.render?.({ props, parts }) ?? <parts.Root {...props}>{props.children}</parts.Root>
|
|
}
|
|
}
|
|
|
|
// automatic names
|
|
const anonComponents: Record<string, number> = {}
|
|
|
|
// div tag -> Div1
|
|
function anonName(def: TagDef): string {
|
|
const base = (def.base ?? 'div')
|
|
const count = (anonComponents[base] ??= 1)
|
|
anonComponents[base] += 1
|
|
return tagName(base) + (count === 1 ? '' : String(count))
|
|
}
|
|
|
|
// a => Anchor, nav => Nav
|
|
function tagName(base: string): string {
|
|
const capitalized = base.slice(0, 1).toUpperCase() + base.slice(1)
|
|
return capitalized === 'A' ? 'Anchor' : capitalized
|
|
}
|
|
|
|
// shortcut so you only have to import one thing, if you want
|
|
define.Styles = Styles |