forge/src/index.tsx

357 lines
12 KiB
TypeScript

import { type TagDef, type HTMLTag, UnitlessProps, NonStyleKeys } from './types'
type ForgeComponent<Parts = {}> =
((props: Record<string, any>) => any) &
{ [K in keyof Parts]: (props: Record<string, any>) => any }
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]
}
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(`${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')
}
// 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 HTMLTag
// 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}`)
}
const finalProps = { class: classNames.join(' '), ...baseAttrs, ...props, children }
if (finalProps.dangerouslySetInnerHTML) {
const { children: _, ...rest } = finalProps
return <Tag {...rest} />
}
const content = (partName && def.render) ? def.render(finalProps) : children
return <Tag {...finalProps}>{content}</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)
// Direct class on part (for render({ parts }) path where parts inherit variant props)
const partClassName = `${basePartClassName}.${variantName}`
registerClassStyles(name, partClassName, partDef)
// Descendant selector (for Component.Part path where root has variant class)
const descendantClassName = `${baseClassName}.${variantName} .${basePartClassName}`
registerClassStyles(name, descendantClassName, 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 basePartClassName = makeClassName(name, partName)
// Direct class on part (for render({ parts }) path)
const partClassName = makeClassName(name, partName, variantName, variantKey)
registerClassStyles(name, partClassName, partDef)
// Descendant selector (for Component.Part path)
const descendantClassName = `${className} .${basePartClassName}`
registerClassStyles(name, descendantClassName, 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<const T extends TagDef>(name: string, def: T): ForgeComponent<T['parts']>
export function define<const T extends TagDef>(def: T): ForgeComponent<T['parts']>
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)
const currentProps: Record<string, any> = {}
const Root = makeComponent(name, def, currentProps)
const parts: Record<string, Function> = { Root }
for (const [part] of Object.entries(def.parts ?? {}))
parts[part] = makeComponent(name, def, currentProps, part)
const component = (props: Record<string, any>) => {
for (const key in currentProps) delete currentProps[key]
Object.assign(currentProps, props)
return def.render?.({ props, parts }) ?? <Root {...props}>{props.children}</Root>
}
// Attach parts as static properties for Component.Part syntax
for (const [part] of Object.entries(def.parts ?? {}))
(component as any)[part] = makeComponent(name, def, {}, part)
return component
}
// 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
}
// global CSS for resets, element defaults, pseudo-elements
export function css(rules: Record<string, TagDef>) {
for (const [selector, def] of Object.entries(rules)) {
styles[selector] = makeStyle(def)
for (const [state, stateDef] of Object.entries(def.states ?? {}))
styles[`${selector}${stateName(state)}`] = makeStyle(stateDef)
}
injectStylesInBrowser()
}
// shortcut so you only have to import one thing, if you want
define.Styles = Styles
define.css = css