Created
January 18, 2026 23:23
-
-
Save allen-munsch/6c0a1930cce9111677769bb46dd01513 to your computer and use it in GitHub Desktop.
npm install tsx && tsx reifyReact.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * reifyToReact - Complete S-Expression to React Component Transpiler | |
| * | |
| * Converts a Matryoshka-style S-expression DSL into React/JSX code. | |
| * Supports nested components, attributes, styling, conditionals, loops, and events. | |
| */ | |
| // ============================================================================= | |
| // TYPES | |
| // ============================================================================= | |
| type SExpr = string | number | boolean | SExpr[]; | |
| interface ParsedComponent { | |
| tag: string; | |
| name?: string; | |
| attrs: Record<string, any>; | |
| children: SExpr[]; | |
| styles?: Record<string, any>; | |
| } | |
| interface ReifyOptions { | |
| styling: 'tailwind' | 'css-modules' | 'inline' | 'styled-components'; | |
| indent: number; | |
| typescript: boolean; | |
| componentPrefix?: string; | |
| } | |
| interface ReifyContext { | |
| imports: Set<string>; | |
| hooks: Set<string>; | |
| handlers: Map<string, string>; | |
| depth: number; | |
| options: ReifyOptions; | |
| } | |
| // ============================================================================= | |
| // TAG MAPPINGS | |
| // ============================================================================= | |
| const TAG_MAP: Record<string, { element: string; className?: string; isComponent?: boolean }> = { | |
| // Layout | |
| 'page': { element: 'div', className: 'page' }, | |
| 'header': { element: 'header' }, | |
| 'main': { element: 'main' }, | |
| 'section': { element: 'section' }, | |
| 'footer': { element: 'footer' }, | |
| 'nav': { element: 'nav' }, | |
| 'aside': { element: 'aside' }, | |
| // Content containers | |
| 'card': { element: 'div', className: 'card' }, | |
| 'modal': { element: 'dialog', className: 'modal' }, | |
| 'drawer': { element: 'div', className: 'drawer' }, | |
| 'tabs': { element: 'div', className: 'tabs', isComponent: true }, | |
| 'tab': { element: 'div', className: 'tab-panel' }, | |
| // Typography | |
| 'h1': { element: 'h1' }, | |
| 'h2': { element: 'h2' }, | |
| 'h3': { element: 'h3' }, | |
| 'h4': { element: 'h4' }, | |
| 'h5': { element: 'h5' }, | |
| 'h6': { element: 'h6' }, | |
| 'text': { element: 'p' }, | |
| 'span': { element: 'span' }, | |
| 'link': { element: 'a' }, | |
| // Interactive | |
| 'button': { element: 'button' }, | |
| 'icon': { element: 'span', className: 'icon' }, | |
| 'badge': { element: 'span', className: 'badge' }, | |
| // Media | |
| 'img': { element: 'img' }, | |
| 'gallery': { element: 'div', className: 'gallery' }, | |
| 'logo': { element: 'div', className: 'logo' }, | |
| // Form elements | |
| 'form': { element: 'form' }, | |
| 'input': { element: 'input' }, | |
| 'select': { element: 'select' }, | |
| 'number-input': { element: 'input', className: 'number-input' }, | |
| 'search-bar': { element: 'div', className: 'search-bar', isComponent: true }, | |
| 'quantity-selector': { element: 'div', className: 'quantity-selector', isComponent: true }, | |
| // Data display | |
| 'price': { element: 'span', className: 'price' }, | |
| 'rating': { element: 'div', className: 'rating', isComponent: true }, | |
| 'total': { element: 'div', className: 'total' }, | |
| 'breadcrumbs': { element: 'nav', className: 'breadcrumbs' }, | |
| 'crumb': { element: 'span', className: 'breadcrumb-item' }, | |
| 'divider': { element: 'hr', className: 'divider' }, | |
| // Custom components (will be PascalCase) | |
| 'cart-item': { element: 'CartItem', isComponent: true }, | |
| 'product-preview': { element: 'ProductPreview', isComponent: true }, | |
| 'close': { element: 'button', className: 'close-button' }, | |
| 'details': { element: 'div', className: 'details' }, | |
| }; | |
| // ============================================================================= | |
| // ATTRIBUTE MAPPINGS | |
| // ============================================================================= | |
| const ATTR_MAP: Record<string, string> = { | |
| // Events | |
| ':on-click': 'onClick', | |
| ':on-change': 'onChange', | |
| ':on-submit': 'onSubmit', | |
| ':on-focus': 'onFocus', | |
| ':on-blur': 'onBlur', | |
| ':on-hover': 'onMouseEnter', | |
| ':on-leave': 'onMouseLeave', | |
| ':on-input': 'onInput', | |
| ':on-keydown': 'onKeyDown', | |
| ':on-keyup': 'onKeyUp', | |
| // Common attributes | |
| ':src': 'src', | |
| ':alt': 'alt', | |
| ':href': 'href', | |
| ':to': 'href', | |
| ':placeholder': 'placeholder', | |
| ':value': 'value', | |
| ':default-value': 'defaultValue', | |
| ':disabled': 'disabled', | |
| ':required': 'required', | |
| ':min': 'min', | |
| ':max': 'max', | |
| ':type': 'type', | |
| ':name': 'name', | |
| ':id': 'id', | |
| ':for': 'htmlFor', | |
| ':class': 'className', | |
| ':style': 'style', | |
| ':aria-label': 'aria-label', | |
| ':role': 'role', | |
| ':tabindex': 'tabIndex', | |
| ':title': 'title', | |
| // Custom/data attributes | |
| ':variant': 'data-variant', | |
| ':size': 'data-size', | |
| ':count': 'data-count', | |
| ':current': 'aria-current', | |
| ':lightbox': 'data-lightbox', | |
| ':sticky': 'data-sticky', | |
| ':from': 'data-from', | |
| ':with': 'data-with', | |
| }; | |
| // ============================================================================= | |
| // STYLE MAPPINGS (to Tailwind) | |
| // ============================================================================= | |
| const STYLE_TO_TAILWIND: Record<string, (value: any) => string> = { | |
| // Layout | |
| 'layout': (v) => { | |
| if (Array.isArray(v) && v[0] === 'grid') return parseGridLayout(v); | |
| if (Array.isArray(v) && v[0] === 'flex') return parseFlexLayout(v); | |
| if (Array.isArray(v) && v[0] === 'stack') return `flex flex-col ${parseGap(v)}`; | |
| return ''; | |
| }, | |
| 'max-width': (v) => `max-w-[${v}]`, | |
| 'width': (v) => v === 'full' ? 'w-full' : `w-[${v}]`, | |
| 'height': (v) => v === 'full' ? 'h-full' : `h-[${v}]`, | |
| 'flex': (v) => `flex-${v}`, | |
| // Spacing | |
| 'margin': (v) => `m-${v}`, | |
| 'margin-x': (v) => v === 'auto' ? 'mx-auto' : `mx-${v}`, | |
| 'margin-y': (v) => `my-${v}`, | |
| 'margin-top': (v) => `mt-${v}`, | |
| 'margin-bottom': (v) => `mb-${v}`, | |
| 'margin-left': (v) => `ml-${v}`, | |
| 'margin-right': (v) => `mr-${v}`, | |
| 'padding': (v) => `p-${v}`, | |
| 'padding-x': (v) => `px-${v}`, | |
| 'padding-y': (v) => `py-${v}`, | |
| 'gap': (v) => `gap-${v}`, | |
| // Typography | |
| 'font-size': (v) => { | |
| const sizeMap: Record<string, string> = { | |
| 'xs': 'text-xs', 'sm': 'text-sm', 'base': 'text-base', | |
| 'lg': 'text-lg', 'xl': 'text-xl', '2xl': 'text-2xl', | |
| '3xl': 'text-3xl', '4xl': 'text-4xl', '5xl': 'text-5xl', | |
| }; | |
| return sizeMap[v] || `text-[${v}]`; | |
| }, | |
| 'font-weight': (v) => `font-${v}`, | |
| 'text-align': (v) => `text-${v}`, | |
| 'color': (v) => COLOR_MAP[v] || `text-${v}`, | |
| // Backgrounds & Borders | |
| 'bg': (v) => BG_COLOR_MAP[v] || `bg-${v}`, | |
| 'border': (v) => v === true ? 'border' : `border-${v}`, | |
| 'border-bottom': (v) => v === true ? 'border-b' : `border-b-${v}`, | |
| 'radius': (v) => { | |
| const radiusMap: Record<string, string> = { | |
| 'sm': 'rounded-sm', 'md': 'rounded-md', 'lg': 'rounded-lg', | |
| 'xl': 'rounded-xl', 'full': 'rounded-full', 'none': 'rounded-none', | |
| }; | |
| return radiusMap[v] || `rounded-[${v}]`; | |
| }, | |
| 'shadow': (v) => { | |
| const shadowMap: Record<string, string> = { | |
| 'sm': 'shadow-sm', 'md': 'shadow-md', 'lg': 'shadow-lg', | |
| 'xl': 'shadow-xl', 'none': 'shadow-none', | |
| }; | |
| return shadowMap[v] || 'shadow'; | |
| }, | |
| // Visual | |
| 'aspect-ratio': (v) => `aspect-[${v.replace(':', '/')}]`, | |
| 'opacity': (v) => `opacity-${v}`, | |
| 'overflow': (v) => `overflow-${v}`, | |
| // Position | |
| 'position': (v) => v, | |
| 'top': (v) => `top-${v}`, | |
| 'right': (v) => `right-${v}`, | |
| 'bottom': (v) => `bottom-${v}`, | |
| 'left': (v) => `left-${v}`, | |
| 'z-index': (v) => `z-${v}`, | |
| }; | |
| const COLOR_MAP: Record<string, string> = { | |
| 'primary': 'text-primary', | |
| 'secondary': 'text-secondary', | |
| 'error': 'text-red-500', | |
| 'success': 'text-green-500', | |
| 'warning': 'text-yellow-500', | |
| 'white': 'text-white', | |
| 'black': 'text-black', | |
| 'gray-500': 'text-gray-500', | |
| 'gray-600': 'text-gray-600', | |
| }; | |
| const BG_COLOR_MAP: Record<string, string> = { | |
| 'primary': 'bg-primary', | |
| 'secondary': 'bg-secondary', | |
| 'error': 'bg-red-500', | |
| 'success': 'bg-green-500', | |
| 'warning': 'bg-yellow-500', | |
| 'white': 'bg-white', | |
| 'black': 'bg-black', | |
| 'gray-50': 'bg-gray-50', | |
| 'gray-100': 'bg-gray-100', | |
| }; | |
| // ============================================================================= | |
| // PARSER | |
| // ============================================================================= | |
| function tokenize(input: string): string[] { | |
| const tokens: string[] = []; | |
| let current = 0; | |
| while (current < input.length) { | |
| const char = input[current]; | |
| // Skip whitespace | |
| if (/\s/.test(char)) { | |
| current++; | |
| continue; | |
| } | |
| // Parentheses | |
| if (char === '(' || char === ')') { | |
| tokens.push(char); | |
| current++; | |
| continue; | |
| } | |
| // String literals | |
| if (char === '"') { | |
| let value = ''; | |
| current++; // Skip opening quote | |
| while (current < input.length && input[current] !== '"') { | |
| if (input[current] === '\\' && current + 1 < input.length) { | |
| current++; | |
| value += input[current]; | |
| } else { | |
| value += input[current]; | |
| } | |
| current++; | |
| } | |
| current++; // Skip closing quote | |
| tokens.push(`"${value}"`); | |
| continue; | |
| } | |
| // Comments (skip lines starting with ;) | |
| if (char === ';') { | |
| while (current < input.length && input[current] !== '\n') { | |
| current++; | |
| } | |
| continue; | |
| } | |
| // Symbols, keywords, numbers | |
| let value = ''; | |
| while (current < input.length && !/[\s()]/.test(input[current])) { | |
| value += input[current]; | |
| current++; | |
| } | |
| if (value) { | |
| tokens.push(value); | |
| } | |
| } | |
| return tokens; | |
| } | |
| function parseTokens(tokens: string[]): SExpr[] { | |
| let position = 0; | |
| function parseExpr(): SExpr { | |
| const token = tokens[position]; | |
| if (token === '(') { | |
| position++; | |
| const list: SExpr[] = []; | |
| while (tokens[position] !== ')') { | |
| if (position >= tokens.length) { | |
| throw new Error('Unexpected end of input: missing closing parenthesis'); | |
| } | |
| list.push(parseExpr()); | |
| } | |
| position++; // Skip closing paren | |
| return list; | |
| } | |
| position++; | |
| // String literal | |
| if (token.startsWith('"') && token.endsWith('"')) { | |
| return token.slice(1, -1); | |
| } | |
| // Boolean | |
| if (token === 'true') return true; | |
| if (token === 'false') return false; | |
| // Number | |
| if (/^-?\d+(\.\d+)?$/.test(token)) { | |
| return parseFloat(token); | |
| } | |
| // Symbol/keyword | |
| return token; | |
| } | |
| const results: SExpr[] = []; | |
| while (position < tokens.length) { | |
| results.push(parseExpr()); | |
| } | |
| return results; | |
| } | |
| export function parseSExpr(input: string): SExpr[] { | |
| const tokens = tokenize(input); | |
| return parseTokens(tokens); | |
| } | |
| // ============================================================================= | |
| // COMPONENT PARSER | |
| // ============================================================================= | |
| function parseComponent(sexpr: SExpr[]): ParsedComponent { | |
| if (!Array.isArray(sexpr) || sexpr.length === 0) { | |
| throw new Error('Invalid component: expected non-empty array'); | |
| } | |
| const [tag, ...rest] = sexpr; | |
| if (typeof tag !== 'string') { | |
| throw new Error(`Invalid tag: expected string, got ${typeof tag}`); | |
| } | |
| const result: ParsedComponent = { | |
| tag, | |
| attrs: {}, | |
| children: [], | |
| }; | |
| let i = 0; | |
| // Check for optional name (identifier after tag) | |
| if (rest[i] && typeof rest[i] === 'string' && !String(rest[i]).startsWith(':')) { | |
| // Could be a name or text content | |
| const next = rest[i + 1]; | |
| if (next && (typeof next === 'string' && String(next).startsWith(':') || Array.isArray(next))) { | |
| result.name = String(rest[i]); | |
| i++; | |
| } | |
| } | |
| // Parse attributes and children | |
| while (i < rest.length) { | |
| const item = rest[i]; | |
| // Attribute (starts with :) | |
| if (typeof item === 'string' && item.startsWith(':')) { | |
| const attrName = item; | |
| i++; | |
| if (i < rest.length) { | |
| result.attrs[attrName] = rest[i]; | |
| i++; | |
| } | |
| } | |
| // Nested styles block | |
| else if (Array.isArray(item) && item[0] === 'styles') { | |
| result.styles = parseStyles(item.slice(1)); | |
| i++; | |
| } | |
| // Child component or text | |
| else { | |
| result.children.push(item); | |
| i++; | |
| } | |
| } | |
| return result; | |
| } | |
| function parseStyles(styleExprs: SExpr[]): Record<string, any> { | |
| const styles: Record<string, any> = {}; | |
| for (const expr of styleExprs) { | |
| if (Array.isArray(expr) && expr.length >= 2) { | |
| const [prop, ...values] = expr; | |
| styles[String(prop)] = values.length === 1 ? values[0] : values; | |
| } | |
| } | |
| return styles; | |
| } | |
| // ============================================================================= | |
| // LAYOUT HELPERS | |
| // ============================================================================= | |
| function parseGridLayout(grid: SExpr[]): string { | |
| const classes: string[] = ['grid']; | |
| for (let i = 1; i < grid.length; i++) { | |
| const item = grid[i]; | |
| if (item === ':cols' && i + 1 < grid.length) { | |
| const cols = grid[++i]; | |
| if (Array.isArray(cols)) { | |
| // Responsive: (desktop 2 mobile 1) | |
| for (let j = 0; j < cols.length; j += 2) { | |
| const breakpoint = cols[j]; | |
| const count = cols[j + 1]; | |
| if (breakpoint === 'desktop') { | |
| classes.push(`lg:grid-cols-${count}`); | |
| } else if (breakpoint === 'tablet') { | |
| classes.push(`md:grid-cols-${count}`); | |
| } else if (breakpoint === 'mobile') { | |
| classes.push(`grid-cols-${count}`); | |
| } | |
| } | |
| } else { | |
| classes.push(`grid-cols-${cols}`); | |
| } | |
| } else if (item === ':gap' && i + 1 < grid.length) { | |
| classes.push(`gap-${grid[++i]}`); | |
| } else if (item === ':rows' && i + 1 < grid.length) { | |
| classes.push(`grid-rows-${grid[++i]}`); | |
| } | |
| } | |
| return classes.join(' '); | |
| } | |
| function parseFlexLayout(flex: SExpr[]): string { | |
| const classes: string[] = ['flex']; | |
| for (let i = 1; i < flex.length; i++) { | |
| const item = flex[i]; | |
| if (item === ':gap' && i + 1 < flex.length) { | |
| classes.push(`gap-${flex[++i]}`); | |
| } else if (item === ':justify' && i + 1 < flex.length) { | |
| const justify = flex[++i]; | |
| const justifyMap: Record<string, string> = { | |
| 'start': 'justify-start', | |
| 'end': 'justify-end', | |
| 'center': 'justify-center', | |
| 'space-between': 'justify-between', | |
| 'space-around': 'justify-around', | |
| 'space-evenly': 'justify-evenly', | |
| }; | |
| classes.push(justifyMap[String(justify)] || `justify-${justify}`); | |
| } else if (item === ':align' && i + 1 < flex.length) { | |
| const align = flex[++i]; | |
| classes.push(`items-${align}`); | |
| } else if (item === ':direction' && i + 1 < flex.length) { | |
| const dir = flex[++i]; | |
| if (dir === 'column' || dir === 'col') classes.push('flex-col'); | |
| if (dir === 'row-reverse') classes.push('flex-row-reverse'); | |
| if (dir === 'column-reverse' || dir === 'col-reverse') classes.push('flex-col-reverse'); | |
| } else if (item === ':wrap' && i + 1 < flex.length) { | |
| classes.push('flex-wrap'); | |
| i++; | |
| } | |
| } | |
| return classes.join(' '); | |
| } | |
| function parseGap(layout: SExpr[]): string { | |
| for (let i = 1; i < layout.length; i++) { | |
| if (layout[i] === ':gap' && i + 1 < layout.length) { | |
| return `gap-${layout[i + 1]}`; | |
| } | |
| } | |
| return ''; | |
| } | |
| // ============================================================================= | |
| // REIFY ENGINE | |
| // ============================================================================= | |
| function createContext(options: Partial<ReifyOptions> = {}): ReifyContext { | |
| return { | |
| imports: new Set(), | |
| hooks: new Set(), | |
| handlers: new Map(), | |
| depth: 0, | |
| options: { | |
| styling: 'tailwind', | |
| indent: 2, | |
| typescript: false, | |
| ...options, | |
| }, | |
| }; | |
| } | |
| function indent(ctx: ReifyContext): string { | |
| return ' '.repeat(ctx.depth * ctx.options.indent); | |
| } | |
| function toPascalCase(str: string): string { | |
| return str | |
| .split(/[-_\s]+/) | |
| .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) | |
| .join(''); | |
| } | |
| function toCamelCase(str: string): string { | |
| const pascal = toPascalCase(str); | |
| return pascal.charAt(0).toLowerCase() + pascal.slice(1); | |
| } | |
| function resolveExpression(expr: SExpr, forAttribute: boolean = false): string { | |
| if (typeof expr === 'string') { | |
| // Check for template expressions like {product.name} | |
| if (expr.includes('{') && expr.includes('}')) { | |
| return '`' + expr.replace(/\{([^}]+)\}/g, '${$1}') + '`'; | |
| } | |
| // Check for property access like product.name | |
| if (expr.includes('.') && !expr.startsWith('"')) { | |
| return forAttribute ? `{${expr}}` : expr; | |
| } | |
| return `"${expr}"`; | |
| } | |
| if (typeof expr === 'number') { | |
| return forAttribute ? `{${expr}}` : String(expr); | |
| } | |
| if (typeof expr === 'boolean') { | |
| return forAttribute ? `{${expr}}` : String(expr); | |
| } | |
| if (Array.isArray(expr)) { | |
| return `{${JSON.stringify(expr)}}`; | |
| } | |
| return String(expr); | |
| } | |
| function buildClassName( | |
| component: ParsedComponent, | |
| ctx: ReifyContext | |
| ): string { | |
| const classes: string[] = []; | |
| // Base class from tag mapping | |
| const mapping = TAG_MAP[component.tag]; | |
| if (mapping?.className) { | |
| classes.push(mapping.className); | |
| } | |
| // Classes from :class attribute | |
| if (component.attrs[':class']) { | |
| classes.push(String(component.attrs[':class'])); | |
| } | |
| // Variant/size as data attributes for Tailwind variants | |
| if (component.attrs[':variant']) { | |
| const variant = component.attrs[':variant']; | |
| // Map common variants to Tailwind classes | |
| const variantClasses: Record<string, string> = { | |
| 'primary': 'bg-primary text-white hover:bg-primary/90', | |
| 'secondary': 'bg-secondary text-white hover:bg-secondary/90', | |
| 'outlined': 'border-2 border-current bg-transparent hover:bg-gray-100', | |
| 'ghost': 'bg-transparent hover:bg-gray-100', | |
| 'link': 'bg-transparent underline hover:no-underline', | |
| }; | |
| if (variantClasses[variant]) { | |
| classes.push(variantClasses[variant]); | |
| } | |
| } | |
| if (component.attrs[':size']) { | |
| const size = component.attrs[':size']; | |
| const sizeClasses: Record<string, string> = { | |
| 'sm': 'px-2 py-1 text-sm', | |
| 'md': 'px-4 py-2', | |
| 'lg': 'px-6 py-3 text-lg', | |
| 'xl': 'px-8 py-4 text-xl', | |
| }; | |
| if (sizeClasses[size]) { | |
| classes.push(sizeClasses[size]); | |
| } | |
| } | |
| // Styles block | |
| if (component.styles && ctx.options.styling === 'tailwind') { | |
| for (const [prop, value] of Object.entries(component.styles)) { | |
| const mapper = STYLE_TO_TAILWIND[prop]; | |
| if (mapper) { | |
| const tailwindClass = mapper(value); | |
| if (tailwindClass) { | |
| classes.push(tailwindClass); | |
| } | |
| } | |
| } | |
| } | |
| // Inline style attributes | |
| for (const [attr, value] of Object.entries(component.attrs)) { | |
| if (attr.startsWith(':') && !ATTR_MAP[attr]) { | |
| // Try to map unknown style attributes | |
| const styleProp = attr.slice(1); // Remove : | |
| const mapper = STYLE_TO_TAILWIND[styleProp]; | |
| if (mapper) { | |
| const tailwindClass = mapper(value); | |
| if (tailwindClass) { | |
| classes.push(tailwindClass); | |
| } | |
| } | |
| } | |
| } | |
| return classes.filter(Boolean).join(' '); | |
| } | |
| function buildAttributes( | |
| component: ParsedComponent, | |
| ctx: ReifyContext | |
| ): string { | |
| const attrs: string[] = []; | |
| const className = buildClassName(component, ctx); | |
| if (className) { | |
| attrs.push(`className="${className}"`); | |
| } | |
| // Map known attributes | |
| for (const [attr, value] of Object.entries(component.attrs)) { | |
| const reactAttr = ATTR_MAP[attr]; | |
| if (reactAttr) { | |
| // Event handlers | |
| if (reactAttr.startsWith('on')) { | |
| const handlerName = toCamelCase(`handle-${String(value)}`); | |
| ctx.handlers.set(handlerName, String(value)); | |
| attrs.push(`${reactAttr}={${handlerName}}`); | |
| } | |
| // Boolean attributes | |
| else if (typeof value === 'boolean') { | |
| if (value) { | |
| attrs.push(reactAttr); | |
| } | |
| } | |
| // Expression values (variable references) | |
| else if (typeof value === 'string' && value.includes('.')) { | |
| attrs.push(`${reactAttr}={${value}}`); | |
| } | |
| // String/number values | |
| else { | |
| attrs.push(`${reactAttr}=${resolveExpression(value, true)}`); | |
| } | |
| } | |
| // Data attributes and aria | |
| else if (attr.startsWith(':data-') || attr.startsWith(':aria-')) { | |
| const attrName = attr.slice(1); | |
| attrs.push(`${attrName}=${resolveExpression(value, true)}`); | |
| } | |
| } | |
| // Add name/id if present | |
| if (component.name && !component.attrs[':id']) { | |
| attrs.push(`id="${component.name}"`); | |
| } | |
| return attrs.join(' '); | |
| } | |
| function reifyComponent(sexpr: SExpr, ctx: ReifyContext): string { | |
| // Text node | |
| if (typeof sexpr === 'string') { | |
| // Template expression | |
| if (sexpr.includes('.')) { | |
| return `${indent(ctx)}{${sexpr}}`; | |
| } | |
| return `${indent(ctx)}${sexpr}`; | |
| } | |
| if (typeof sexpr === 'number' || typeof sexpr === 'boolean') { | |
| return `${indent(ctx)}{${sexpr}}`; | |
| } | |
| if (!Array.isArray(sexpr)) { | |
| return ''; | |
| } | |
| const component = parseComponent(sexpr); | |
| // Handle special constructs | |
| if (component.tag === 'if') { | |
| return reifyConditional(component, ctx); | |
| } | |
| if (component.tag === 'foreach') { | |
| return reifyLoop(component, ctx); | |
| } | |
| if (component.tag === 'styles') { | |
| return ''; // Styles are handled in buildClassName | |
| } | |
| // Get element/component name | |
| const mapping = TAG_MAP[component.tag] || { element: component.tag }; | |
| let elementName = mapping.element; | |
| // Convert to PascalCase if it's a custom component | |
| if (mapping.isComponent || component.tag.includes('-')) { | |
| elementName = toPascalCase(component.tag); | |
| ctx.imports.add(elementName); | |
| } | |
| const attributes = buildAttributes(component, ctx); | |
| const attrStr = attributes ? ` ${attributes}` : ''; | |
| // Self-closing elements | |
| const selfClosing = ['img', 'input', 'br', 'hr', 'meta', 'link'].includes(elementName); | |
| if (selfClosing || (component.children.length === 0 && !component.name)) { | |
| return `${indent(ctx)}<${elementName}${attrStr} />`; | |
| } | |
| // Build children | |
| ctx.depth++; | |
| const children: string[] = []; | |
| // Add text content from name if it's a text-displaying element | |
| const textElements = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'text', 'span', 'button', 'badge', 'price', 'crumb']; | |
| if (component.name && textElements.includes(component.tag)) { | |
| if (component.name.includes('.')) { | |
| children.push(`${indent(ctx)}{${component.name}}`); | |
| } else { | |
| children.push(`${indent(ctx)}${component.name}`); | |
| } | |
| } | |
| for (const child of component.children) { | |
| children.push(reifyComponent(child, ctx)); | |
| } | |
| ctx.depth--; | |
| if (children.length === 0) { | |
| return `${indent(ctx)}<${elementName}${attrStr} />`; | |
| } | |
| if (children.length === 1 && !children[0].includes('\n')) { | |
| const childContent = children[0].trim(); | |
| return `${indent(ctx)}<${elementName}${attrStr}>${childContent}</${elementName}>`; | |
| } | |
| return [ | |
| `${indent(ctx)}<${elementName}${attrStr}>`, | |
| ...children, | |
| `${indent(ctx)}</${elementName}>`, | |
| ].join('\n'); | |
| } | |
| function reifyConditional(component: ParsedComponent, ctx: ReifyContext): string { | |
| // (if condition (then-component) (else-component)?) | |
| const condition = component.name || component.children[0]; | |
| const thenBranch = component.children[component.name ? 0 : 1]; | |
| const elseBranch = component.children[component.name ? 1 : 2]; | |
| const conditionStr = resolveCondition(condition); | |
| ctx.depth++; | |
| const thenContent = reifyComponent(thenBranch, ctx); | |
| const elseContent = elseBranch ? reifyComponent(elseBranch, ctx) : null; | |
| ctx.depth--; | |
| if (elseContent) { | |
| return [ | |
| `${indent(ctx)}{${conditionStr} ? (`, | |
| thenContent, | |
| `${indent(ctx)}) : (`, | |
| elseContent, | |
| `${indent(ctx)})}`, | |
| ].join('\n'); | |
| } | |
| return [ | |
| `${indent(ctx)}{${conditionStr} && (`, | |
| thenContent, | |
| `${indent(ctx)})}`, | |
| ].join('\n'); | |
| } | |
| function resolveCondition(condition: SExpr): string { | |
| if (typeof condition === 'string') { | |
| return condition; | |
| } | |
| if (Array.isArray(condition)) { | |
| const [op, ...args] = condition; | |
| // Comparison operators | |
| if (op === '>' || op === '<' || op === '>=' || op === '<=' || op === '==' || op === '!=') { | |
| return `${resolveCondition(args[0])} ${op} ${resolveCondition(args[1])}`; | |
| } | |
| // Functions | |
| if (op === 'length') { | |
| return `${resolveCondition(args[0])}.length`; | |
| } | |
| if (op === 'not') { | |
| return `!${resolveCondition(args[0])}`; | |
| } | |
| if (op === 'and') { | |
| return args.map(resolveCondition).join(' && '); | |
| } | |
| if (op === 'or') { | |
| return args.map(resolveCondition).join(' || '); | |
| } | |
| } | |
| return String(condition); | |
| } | |
| function reifyLoop(component: ParsedComponent, ctx: ReifyContext): string { | |
| // (foreach item collection (component)) | |
| const itemName = component.name || String(component.children[0]); | |
| const collection = component.attrs[':in'] || component.children[component.name ? 0 : 1]; | |
| const template = component.children[component.name ? 1 : 2] || component.children[0]; | |
| ctx.depth++; | |
| const content = reifyComponent(template as SExpr, ctx); | |
| ctx.depth--; | |
| // Insert key={index} or key={item.id} into the first JSX element | |
| // Handle both self-closing and regular tags | |
| const contentWithKey = content | |
| .replace(/<(\w+)([^/>]*)\/>/, (match, tag, attrs) => { | |
| if (attrs.includes('key=')) return match; | |
| return `<${tag}${attrs} key={index} />`; | |
| }) | |
| .replace(/<(\w+)([^>]*)>/, (match, tag, attrs) => { | |
| if (attrs.includes('key=') || attrs.includes('key={')) return match; | |
| return `<${tag}${attrs} key={index}>`; | |
| }); | |
| return [ | |
| `${indent(ctx)}{${collection}.map((${itemName}, index) => (`, | |
| contentWithKey, | |
| `${indent(ctx)}))}`, | |
| ].join('\n'); | |
| } | |
| // ============================================================================= | |
| // MAIN EXPORT | |
| // ============================================================================= | |
| export interface ReifyResult { | |
| code: string; | |
| imports: string[]; | |
| hooks: string[]; | |
| handlers: string[]; | |
| componentName: string; | |
| } | |
| export function reifyToReact( | |
| input: string | SExpr | SExpr[], | |
| options: Partial<ReifyOptions> = {} | |
| ): ReifyResult { | |
| const ctx = createContext(options); | |
| // Parse if string | |
| let exprs: SExpr[]; | |
| if (typeof input === 'string') { | |
| exprs = parseSExpr(input); | |
| } else if (Array.isArray(input) && input.length > 0 && Array.isArray(input[0])) { | |
| exprs = input as SExpr[]; | |
| } else { | |
| exprs = [input as SExpr]; | |
| } | |
| // Find the main component (usually 'page') | |
| const mainExpr = exprs.find(e => | |
| Array.isArray(e) && (e[0] === 'page' || e[0] === 'component') | |
| ) || exprs[0]; | |
| // Extract component name | |
| let componentName = 'GeneratedComponent'; | |
| if (Array.isArray(mainExpr) && mainExpr.length > 1 && typeof mainExpr[1] === 'string' && !String(mainExpr[1]).startsWith(':')) { | |
| componentName = toPascalCase(String(mainExpr[1])); | |
| } | |
| // Reify all expressions | |
| const jsxParts: string[] = []; | |
| for (const expr of exprs) { | |
| // Skip styles declarations at root level | |
| if (Array.isArray(expr) && expr[0] === 'styles') continue; | |
| jsxParts.push(reifyComponent(expr, ctx)); | |
| } | |
| // Build handlers | |
| const handlers: string[] = []; | |
| for (const [name, action] of ctx.handlers) { | |
| handlers.push(`const ${name} = () => {\n // TODO: Implement ${action}\n console.log('${action}');\n };`); | |
| } | |
| // Build final component code | |
| const ts = ctx.options.typescript; | |
| const reactImport = ctx.hooks.size > 0 | |
| ? `import React, { ${[...ctx.hooks].join(', ')} } from 'react';` | |
| : `import React from 'react';`; | |
| const componentImports = [...ctx.imports] | |
| .map(name => `import { ${name} } from './${name}';`) | |
| .join('\n'); | |
| const code = `${reactImport} | |
| ${componentImports ? componentImports + '\n' : ''} | |
| ${ts ? `interface ${componentName}Props {\n // TODO: Define props\n}\n` : ''} | |
| export function ${componentName}(${ts ? `props: ${componentName}Props` : 'props'}) { | |
| ${handlers.join('\n\n ')} | |
| return ( | |
| ${jsxParts.map(part => ' ' + part.split('\n').join('\n ')).join('\n')} | |
| ); | |
| } | |
| export default ${componentName}; | |
| `; | |
| return { | |
| code, | |
| imports: [...ctx.imports], | |
| hooks: [...ctx.hooks], | |
| handlers: [...ctx.handlers.keys()], | |
| componentName, | |
| }; | |
| } | |
| // ============================================================================= | |
| // CACHING LAYER | |
| // ============================================================================= | |
| const fragmentCache = new Map<string, string>(); | |
| export function reifyWithCache( | |
| input: string | SExpr, | |
| options: Partial<ReifyOptions> = {} | |
| ): ReifyResult { | |
| const cacheKey = typeof input === 'string' ? input : JSON.stringify(input); | |
| const cached = fragmentCache.get(cacheKey); | |
| if (cached) { | |
| return JSON.parse(cached); | |
| } | |
| const result = reifyToReact(input, options); | |
| fragmentCache.set(cacheKey, JSON.stringify(result)); | |
| return result; | |
| } | |
| export function clearCache(): void { | |
| fragmentCache.clear(); | |
| } | |
| export function getCacheStats(): { size: number; keys: string[] } { | |
| return { | |
| size: fragmentCache.size, | |
| keys: [...fragmentCache.keys()].slice(0, 10), // First 10 keys | |
| }; | |
| } | |
| // ============================================================================= | |
| // CLI / DEMO | |
| // ============================================================================= | |
| // Example usage when run directly | |
| const exampleInput = ` | |
| (page product-detail | |
| (header | |
| :sticky true | |
| :bg white | |
| :shadow sm | |
| (logo "ShopFast" :to "/") | |
| (search-bar | |
| :placeholder "Search products...") | |
| (icon cart | |
| (badge :value cartCount) | |
| :to "/cart")) | |
| (main | |
| :max-width 1200px | |
| :margin-x auto | |
| :padding 2rem | |
| (breadcrumbs | |
| (crumb "Home" :to "/") | |
| (crumb "Products" :to "/products") | |
| (crumb product.category :to "/products/{category}") | |
| (crumb product.name :current true)) | |
| (section product | |
| :layout (grid :cols (desktop 2 mobile 1) :gap 3rem) | |
| (gallery | |
| :lightbox true | |
| (foreach image product.images | |
| (img :src image.url)) | |
| (styles | |
| (aspect-ratio 1:1) | |
| (radius lg))) | |
| (details | |
| (h1 product.name | |
| :font-size 2.5rem | |
| :font-weight bold) | |
| (rating | |
| :value product.rating | |
| (text "({product.reviews} reviews)")) | |
| (price product.price | |
| :font-size 2rem | |
| :color primary | |
| :font-weight bold) | |
| (if product.onSale | |
| (badge "Sale" | |
| :bg error | |
| :color white)) | |
| (form | |
| (select "Size" | |
| :options product.sizes | |
| :required true) | |
| (button "Add to Cart" | |
| :variant primary | |
| :size lg | |
| :width full | |
| :on-click addToCart)))))) | |
| `; | |
| // Demo function | |
| export function demo(): void { | |
| const result = reifyToReact(exampleInput); | |
| console.log(result.code); | |
| } |
Author
allen-munsch
commented
Jan 18, 2026
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment