Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

7 changed files with 26 additions and 1262 deletions

1
.npmrc
View File

@ -1 +0,0 @@
registry=https://npm.nose.space

View File

@ -4,10 +4,12 @@
"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",
},

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "@because/forge",
"version": "0.0.3",
"name": "forge",
"version": "0.1.0",
"type": "module",
"main": "src/index.tsx",
"module": "src/index.tsx",
@ -11,11 +11,6 @@
"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",
@ -24,11 +19,13 @@
"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"
}
}

View File

@ -1,4 +1,5 @@
import { type TagDef, type HTMLTag, UnitlessProps, NonStyleKeys } from './types'
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>> = {}
@ -9,9 +10,7 @@ export function clearStyles() {
}
// HMR support - clear styles when module is replaced
if (import.meta.hot) {
import.meta.hot.dispose(() => clearStyles())
}
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>
@ -101,7 +100,7 @@ export function stylesToCSS(): string {
for (const [selector, style] of Object.entries(styles)) {
if (Object.keys(style).length === 0) continue
out.push(`${selector} { `)
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`)
@ -110,6 +109,10 @@ 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
@ -150,7 +153,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 HTMLTag
const Tag = (tagName) as keyof JSX.IntrinsicElements
// Extract attributes from base (e.g., 'input[type=radio]' -> { type: 'radio' })
const baseAttrs: Record<string, string> = {}
@ -192,11 +195,7 @@ function makeComponent(baseName: string, rootDef: TagDef, rootProps: Record<stri
classNames.push(variantKey === true ? variantName : `${variantName}-${variantKey}`)
}
const finalProps = { class: classNames.join(' '), ...baseAttrs, ...props, children }
const content = (partName && def.render) ? def.render(finalProps) : children
return <Tag {...finalProps}>{content}</Tag>
return <Tag class={classNames.join(' ')} {...baseAttrs} {...props}>{children}</Tag>
}
}
@ -207,7 +206,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)}`)
@ -217,7 +216,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
@ -283,21 +282,17 @@ 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>) => {
for (const key in currentProps) delete currentProps[key]
Object.assign(currentProps, props)
const parts: Record<string, Function> = { Root }
const parts: Record<string, Function> = {}
for (const [part] of Object.entries(def.parts ?? {}))
parts[part] = makeComponent(name, def, props, part)
return def.render?.({ props, parts }) ?? <Root {...props}>{props.children}</Root>
parts.Root = makeComponent(name, def, props)
return def.render?.({ props, parts }) ?? <parts.Root {...props}>{props.children}</parts.Root>
}
}
@ -319,4 +314,4 @@ function tagName(base: string): string {
}
// shortcut so you only have to import one thing, if you want
define.Styles = Styles
define.Styles = Styles

View File

@ -558,82 +558,6 @@ 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', () => {

View File

@ -31,24 +31,7 @@ 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'
@ -150,14 +133,6 @@ 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
@ -195,12 +170,6 @@ 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'
@ -213,14 +182,10 @@ 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
@ -243,7 +208,6 @@ 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
@ -283,37 +247,15 @@ 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'
@ -358,34 +300,6 @@ 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'
])