forked from defunkt/forge
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8389a41c59 | |||
| a1578a770a | |||
| 30db3822d6 | |||
|
|
860ceba320 | ||
|
|
939c9c77d6 | ||
| 67180bb4f3 | |||
|
|
46652243c5 | ||
|
|
50a0e2642c | ||
| debfd73ab2 | |||
| 9b6e1e91ec | |||
| e772e0e711 | |||
| 50aa4c5d07 | |||
| 8484553029 |
4
bun.lock
4
bun.lock
|
|
@ -4,12 +4,10 @@
|
|||
"workspaces": {
|
||||
"": {
|
||||
"name": "forge",
|
||||
"dependencies": {
|
||||
"hono": "^4.11.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"hono": "^4.11.3",
|
||||
"prismjs": "^1.30.0",
|
||||
"snarkdown": "^2.0.0",
|
||||
},
|
||||
|
|
|
|||
1067
docs/GUIDE.md
Normal file
1067
docs/GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "forge",
|
||||
"version": "0.1.0",
|
||||
"name": "@because/forge",
|
||||
"version": "0.0.3",
|
||||
"type": "module",
|
||||
"main": "src/index.tsx",
|
||||
"module": "src/index.tsx",
|
||||
|
|
@ -11,6 +11,11 @@
|
|||
"types": "./src/index.tsx"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src/index.tsx",
|
||||
"src/types.ts",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "bun build:spa && bun run --hot server.tsx",
|
||||
"test": "bun test",
|
||||
|
|
@ -19,13 +24,11 @@
|
|||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"hono": "^4.11.3",
|
||||
"prismjs": "^1.30.0",
|
||||
"snarkdown": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.11.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { JSX } from 'hono/jsx'
|
||||
import { type TagDef, UnitlessProps, NonStyleKeys } from './types'
|
||||
import { type TagDef, type HTMLTag, UnitlessProps, NonStyleKeys } from './types'
|
||||
|
||||
export const styles: Record<string, Record<string, string>> = {}
|
||||
const themes: Record<string, Record<string, any>> = {}
|
||||
|
|
@ -10,7 +9,9 @@ export function clearStyles() {
|
|||
}
|
||||
|
||||
// HMR support - clear styles when module is replaced
|
||||
import.meta.hot?.dispose(() => clearStyles())
|
||||
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>
|
||||
|
|
@ -100,7 +101,7 @@ export function stylesToCSS(): string {
|
|||
for (const [selector, style] of Object.entries(styles)) {
|
||||
if (Object.keys(style).length === 0) continue
|
||||
|
||||
out.push(`${expandSelector(selector)} { `)
|
||||
out.push(`${selector} { `)
|
||||
for (const [name, value] of Object.entries(style).sort(([a], [b]) => a.localeCompare(b)))
|
||||
out.push(` ${name}: ${value};`)
|
||||
out.push(`}\n`)
|
||||
|
|
@ -109,10 +110,6 @@ export function stylesToCSS(): string {
|
|||
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
|
||||
|
|
@ -153,7 +150,7 @@ function makeComponent(baseName: string, rootDef: TagDef, rootProps: Record<stri
|
|||
|
||||
// Extract element name from base (e.g., 'input[type=radio]' -> 'input')
|
||||
const tagName = base.split('[')[0]
|
||||
const Tag = (tagName) as keyof JSX.IntrinsicElements
|
||||
const Tag = tagName as HTMLTag
|
||||
|
||||
// Extract attributes from base (e.g., 'input[type=radio]' -> { type: 'radio' })
|
||||
const baseAttrs: Record<string, string> = {}
|
||||
|
|
@ -195,7 +192,11 @@ function makeComponent(baseName: string, rootDef: TagDef, rootProps: Record<stri
|
|||
classNames.push(variantKey === true ? variantName : `${variantName}-${variantKey}`)
|
||||
}
|
||||
|
||||
return <Tag class={classNames.join(' ')} {...baseAttrs} {...props}>{children}</Tag>
|
||||
const finalProps = { class: classNames.join(' '), ...baseAttrs, ...props, children }
|
||||
|
||||
const content = (partName && def.render) ? def.render(finalProps) : children
|
||||
|
||||
return <Tag {...finalProps}>{content}</Tag>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -206,7 +207,7 @@ function stateName(state: string): string {
|
|||
|
||||
// Register base styles, selectors, and states for a class
|
||||
function registerClassStyles(name: string, className: string, def: TagDef) {
|
||||
styles[className] ??= makeStyle(def)
|
||||
styles[`.${className}`] ??= makeStyle(def)
|
||||
|
||||
for (let [selector, selectorDef] of Object.entries(def.selectors ?? {})) {
|
||||
selector = selector.replace(/@(\w+)/g, (_, partName) => `.${makeClassName(name, partName)}`)
|
||||
|
|
@ -216,7 +217,7 @@ function registerClassStyles(name: string, className: string, def: TagDef) {
|
|||
}
|
||||
|
||||
for (const [state, style] of Object.entries(def.states ?? {}))
|
||||
styles[`${className}${stateName(state)}`] = makeStyle(style)
|
||||
styles[`.${className}${stateName(state)}`] = makeStyle(style)
|
||||
}
|
||||
|
||||
// adds CSS styles for tag definition
|
||||
|
|
@ -282,17 +283,21 @@ 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.`
|
||||
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)
|
||||
|
||||
return (props: Record<string, any>) => {
|
||||
const parts: Record<string, Function> = {}
|
||||
for (const key in currentProps) delete currentProps[key]
|
||||
Object.assign(currentProps, props)
|
||||
const parts: Record<string, Function> = { Root }
|
||||
|
||||
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>
|
||||
return def.render?.({ props, parts }) ?? <Root {...props}>{props.children}</Root>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -314,4 +319,4 @@ function tagName(base: string): string {
|
|||
}
|
||||
|
||||
// shortcut so you only have to import one thing, if you want
|
||||
define.Styles = Styles
|
||||
define.Styles = Styles
|
||||
|
|
|
|||
|
|
@ -558,6 +558,82 @@ describe('custom render function', () => {
|
|||
expect(html).toContain('<footer')
|
||||
expect(html).toContain('Main Content')
|
||||
})
|
||||
|
||||
test('part can have its own render function', () => {
|
||||
const Component = define('PartRender', {
|
||||
parts: {
|
||||
Icon: {
|
||||
base: 'span',
|
||||
color: 'blue',
|
||||
render: (props) => <>★ {props.children}</>
|
||||
}
|
||||
},
|
||||
render: ({ props, parts }) => (
|
||||
<parts.Root>
|
||||
<parts.Icon>star</parts.Icon>
|
||||
</parts.Root>
|
||||
)
|
||||
})
|
||||
|
||||
const html = renderToString(Component({}))
|
||||
expect(html).toContain('★ star')
|
||||
expect(html).toContain('class="PartRender_Icon"')
|
||||
expect(html).toContain('<span') // base tag is preserved
|
||||
})
|
||||
|
||||
test('part render receives props including children', () => {
|
||||
let receivedProps: any
|
||||
|
||||
const Component = define('PartRenderProps', {
|
||||
parts: {
|
||||
Item: {
|
||||
base: 'li',
|
||||
render: (props) => {
|
||||
receivedProps = props
|
||||
return <>item: {props.children}</>
|
||||
}
|
||||
}
|
||||
},
|
||||
render: ({ props, parts }) => (
|
||||
<parts.Root>
|
||||
<parts.Item data-test="value">content</parts.Item>
|
||||
</parts.Root>
|
||||
)
|
||||
})
|
||||
|
||||
const html = renderToString(Component({}))
|
||||
expect(receivedProps.class).toBe('PartRenderProps_Item')
|
||||
expect(receivedProps['data-test']).toBe('value')
|
||||
expect(receivedProps.children).toBe('content')
|
||||
expect(html).toContain('<li') // base tag preserved
|
||||
expect(html).toContain('item: content')
|
||||
})
|
||||
|
||||
test('part render works with variants', () => {
|
||||
const Component = define('PartRenderVariant', {
|
||||
variants: {
|
||||
size: {
|
||||
small: { fontSize: 12 },
|
||||
large: { fontSize: 24 }
|
||||
}
|
||||
},
|
||||
parts: {
|
||||
Label: {
|
||||
fontWeight: 'bold',
|
||||
render: (props) => <>Label: {props.children}</>
|
||||
}
|
||||
},
|
||||
render: ({ props, parts }) => (
|
||||
<parts.Root>
|
||||
<parts.Label>text</parts.Label>
|
||||
</parts.Root>
|
||||
)
|
||||
})
|
||||
|
||||
const html = renderToString(Component({ size: 'large' }))
|
||||
expect(html).toContain('Label: text')
|
||||
expect(html).toContain('PartRenderVariant_Label')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles component', () => {
|
||||
|
|
|
|||
88
src/types.ts
88
src/types.ts
|
|
@ -31,7 +31,24 @@ export type TagDef = {
|
|||
rowGap?: number | string
|
||||
gap?: number | string
|
||||
|
||||
// multi-column layout
|
||||
columns?: string
|
||||
columnCount?: number | 'auto'
|
||||
columnWidth?: number | string
|
||||
columnRule?: string
|
||||
columnRuleColor?: string
|
||||
columnRuleStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden'
|
||||
columnRuleWidth?: number | string
|
||||
columnFill?: 'auto' | 'balance' | 'balance-all'
|
||||
columnSpan?: 'none' | 'all'
|
||||
|
||||
contain?: 'none' | 'strict' | 'content' | 'size' | 'layout' | 'style' | 'paint'
|
||||
contentVisibility?: 'visible' | 'hidden' | 'auto'
|
||||
|
||||
// container queries
|
||||
container?: string
|
||||
containerType?: 'normal' | 'size' | 'inline-size'
|
||||
containerName?: string
|
||||
|
||||
display?: 'block' | 'inline' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'inline-grid' | 'flow-root' | 'none' | 'contents' | 'table' | 'table-row' | 'table-cell'
|
||||
|
||||
|
|
@ -133,6 +150,14 @@ export type TagDef = {
|
|||
|
||||
// visual/theme-related
|
||||
animation?: string
|
||||
animationName?: string
|
||||
animationDuration?: string
|
||||
animationTimingFunction?: string
|
||||
animationDelay?: string
|
||||
animationIterationCount?: number | 'infinite'
|
||||
animationDirection?: 'normal' | 'reverse' | 'alternate' | 'alternate-reverse'
|
||||
animationFillMode?: 'none' | 'forwards' | 'backwards' | 'both'
|
||||
animationPlayState?: 'running' | 'paused'
|
||||
appearance?: 'none' | 'auto' | 'button' | 'textfield' | 'searchfield' | 'textarea' | 'checkbox' | 'radio'
|
||||
backdropFilter?: string
|
||||
|
||||
|
|
@ -170,6 +195,12 @@ export type TagDef = {
|
|||
borderTopStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden'
|
||||
borderTopWidth?: number | string
|
||||
borderWidth?: number | string
|
||||
borderImage?: string
|
||||
borderImageSource?: string
|
||||
borderImageSlice?: number | string
|
||||
borderImageWidth?: number | string
|
||||
borderImageOutset?: number | string
|
||||
borderImageRepeat?: 'stretch' | 'repeat' | 'round' | 'space' | string
|
||||
|
||||
// table-ish
|
||||
borderCollapse?: 'collapse' | 'separate'
|
||||
|
|
@ -182,10 +213,14 @@ export type TagDef = {
|
|||
clipPath?: string
|
||||
|
||||
color?: string
|
||||
colorScheme?: 'normal' | 'light' | 'dark' | 'light dark' | string
|
||||
content?: string
|
||||
counterReset?: string
|
||||
counterIncrement?: string
|
||||
cursor?: 'auto' | 'default' | 'none' | 'context-menu' | 'help' | 'pointer' | 'progress' | 'wait' | 'cell' | 'crosshair' | 'text' | 'vertical-text' | 'alias' | 'copy' | 'move' | 'no-drop' | 'not-allowed' | 'grab' | 'grabbing' | 'e-resize' | 'n-resize' | 'ne-resize' | 'nw-resize' | 's-resize' | 'se-resize' | 'sw-resize' | 'w-resize' | 'ew-resize' | 'ns-resize' | 'nesw-resize' | 'nwse-resize' | 'col-resize' | 'row-resize' | 'all-scroll' | 'zoom-in' | 'zoom-out'
|
||||
|
||||
filter?: string
|
||||
imageRendering?: 'auto' | 'crisp-edges' | 'pixelated'
|
||||
|
||||
font?: string
|
||||
fontFamily?: string
|
||||
|
|
@ -208,6 +243,7 @@ export type TagDef = {
|
|||
mixBlendMode?: 'normal' | 'multiply' | 'screen' | 'overlay' | 'darken' | 'lighten' | 'color-dodge' | 'color-burn' | 'hard-light' | 'soft-light' | 'difference' | 'exclusion' | 'hue' | 'saturation' | 'color' | 'luminosity'
|
||||
|
||||
objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'
|
||||
objectPosition?: string
|
||||
|
||||
opacity?: number
|
||||
|
||||
|
|
@ -247,15 +283,37 @@ export type TagDef = {
|
|||
wordBreak?: 'normal' | 'break-all' | 'keep-all' | 'break-word'
|
||||
wordSpacing?: number | string
|
||||
wordWrap?: 'normal' | 'break-word' | 'anywhere'
|
||||
textRendering?: 'auto' | 'optimizeSpeed' | 'optimizeLegibility' | 'geometricPrecision'
|
||||
textWrap?: 'wrap' | 'nowrap' | 'balance' | 'pretty' | 'stable'
|
||||
|
||||
// CSS shapes
|
||||
shapeOutside?: string
|
||||
shapeMargin?: number | string
|
||||
shapeImageThreshold?: number
|
||||
|
||||
// fragmentation / breaks
|
||||
breakBefore?: 'auto' | 'avoid' | 'always' | 'all' | 'avoid-page' | 'page' | 'left' | 'right' | 'recto' | 'verso' | 'avoid-column' | 'column' | 'avoid-region' | 'region'
|
||||
breakAfter?: 'auto' | 'avoid' | 'always' | 'all' | 'avoid-page' | 'page' | 'left' | 'right' | 'recto' | 'verso' | 'avoid-column' | 'column' | 'avoid-region' | 'region'
|
||||
breakInside?: 'auto' | 'avoid' | 'avoid-page' | 'avoid-column' | 'avoid-region'
|
||||
orphans?: number
|
||||
widows?: number
|
||||
|
||||
transform?: string
|
||||
transformOrigin?: string
|
||||
transformStyle?: 'flat' | 'preserve-3d'
|
||||
transformBox?: 'content-box' | 'border-box' | 'fill-box' | 'stroke-box' | 'view-box'
|
||||
rotate?: string
|
||||
scale?: string | number
|
||||
translate?: string
|
||||
perspective?: number | string
|
||||
perspectiveOrigin?: string
|
||||
backfaceVisibility?: 'visible' | 'hidden'
|
||||
|
||||
transition?: string
|
||||
transitionProperty?: string
|
||||
transitionDuration?: string
|
||||
transitionTimingFunction?: string
|
||||
transitionDelay?: string
|
||||
visibility?: 'visible' | 'hidden' | 'collapse'
|
||||
willChange?: 'auto' | 'scroll-position' | 'contents'
|
||||
|
||||
|
|
@ -300,6 +358,34 @@ export const UnitlessProps = new Set([
|
|||
'opacity',
|
||||
'order',
|
||||
'orphans',
|
||||
'scale',
|
||||
'shapeImageThreshold',
|
||||
'widows',
|
||||
'zIndex'
|
||||
])
|
||||
])
|
||||
|
||||
// All standard HTML element tag names
|
||||
export type HTMLTag =
|
||||
| 'a' | 'abbr' | 'address' | 'area' | 'article' | 'aside' | 'audio'
|
||||
| 'b' | 'base' | 'bdi' | 'bdo' | 'blockquote' | 'body' | 'br' | 'button'
|
||||
| 'canvas' | 'caption' | 'cite' | 'code' | 'col' | 'colgroup'
|
||||
| 'data' | 'datalist' | 'dd' | 'del' | 'details' | 'dfn' | 'dialog' | 'div' | 'dl' | 'dt'
|
||||
| 'em' | 'embed'
|
||||
| 'fieldset' | 'figcaption' | 'figure' | 'footer' | 'form'
|
||||
| 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'head' | 'header' | 'hgroup' | 'hr' | 'html'
|
||||
| 'i' | 'iframe' | 'img' | 'input' | 'ins'
|
||||
| 'kbd'
|
||||
| 'label' | 'legend' | 'li' | 'link'
|
||||
| 'main' | 'map' | 'mark' | 'menu' | 'meta' | 'meter'
|
||||
| 'nav' | 'noscript'
|
||||
| 'object' | 'ol' | 'optgroup' | 'option' | 'output'
|
||||
| 'p' | 'picture' | 'pre' | 'progress'
|
||||
| 'q'
|
||||
| 'rp' | 'rt' | 'ruby'
|
||||
| 's' | 'samp' | 'script' | 'search' | 'section' | 'select' | 'slot' | 'small' | 'source' | 'span' | 'strong' | 'style' | 'sub' | 'summary' | 'sup'
|
||||
| 'table' | 'tbody' | 'td' | 'template' | 'textarea' | 'tfoot' | 'th' | 'thead' | 'time' | 'title' | 'tr' | 'track'
|
||||
| 'u' | 'ul'
|
||||
| 'var' | 'video'
|
||||
| 'wbr'
|
||||
// SVG elements
|
||||
| 'svg' | 'path' | 'circle' | 'ellipse' | 'line' | 'polygon' | 'polyline' | 'rect' | 'g' | 'defs' | 'use' | 'text' | 'tspan' | 'image' | 'clipPath' | 'mask' | 'pattern' | 'linearGradient' | 'radialGradient' | 'stop' | 'filter' | 'feBlend' | 'feColorMatrix' | 'feGaussianBlur' | 'symbol'
|
||||
Loading…
Reference in New Issue
Block a user