Skip to content

Instantly share code, notes, and snippets.

@allen-munsch
Created January 18, 2026 23:23
Show Gist options
  • Select an option

  • Save allen-munsch/6c0a1930cce9111677769bb46dd01513 to your computer and use it in GitHub Desktop.

Select an option

Save allen-munsch/6c0a1930cce9111677769bb46dd01513 to your computer and use it in GitHub Desktop.
npm install tsx && tsx reifyReact.ts
/**
* 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);
}
@allen-munsch
Copy link
Author

import React from 'react';
import { SearchBar } from './SearchBar';
import { Rating } from './Rating';


export function ProductDetail(props) {
  const handleAddtocart = () => {
    // TODO: Implement addToCart
    console.log('addToCart');
  };
  
  return (
    <div className="page" id="product-detail">
      <header className="bg-white shadow-sm" data-sticky>
        <div className="logo" href="/" id="ShopFast" />
        <SearchBar className="search-bar" placeholder={Search products...} />
        <span className="icon" href="/cart" id="cart"><span className="badge" value="cartCount" /></span>
      </header>
      <main className="max-w-[1200px] mx-auto p-2rem">
        <nav className="breadcrumbs">
          <span className="breadcrumb-item" href="/" id="Home">Home</span>
          <span className="breadcrumb-item" href="/products" id="Products">Products</span>
          <span className="breadcrumb-item" href=`/products/${category}` id="product.category">{product.category}</span>
          <span className="breadcrumb-item" aria-current id="product.name">{product.name}</span>
        </nav>
        <section className="grid lg:grid-cols-2 grid-cols-1 gap-3rem" id="product">
          <div className="gallery aspect-[1/1] rounded-lg" data-lightbox>
            {product.images.map((image, index) => (
              <img src={image.url}  key={index} />
            ))}
          </div>
          <div className="details">
            <h1 className="text-[2.5rem] font-bold" id="product.name">{product.name}</h1>
            <Rating className="rating" value={product.rating}><p>{({product.reviews} reviews)}</p></Rating>
            <span className="price text-[2rem] text-primary font-bold" id="product.price">{product.price}</span>
            {product.onSale && (
              <span className="badge bg-red-500 text-white" id="Sale">Sale</span>
            )}
            <form>
              <select required id="Size" />
              <button className="bg-primary text-white hover:bg-primary/90 px-6 py-3 text-lg w-full" data-variant="primary" data-size="lg" onClick={handleAddtocart} id="Add to Cart">Add to Cart</button>
            </form>
          </div>
        </section>
      </main>
    </div>
  );
}

export default ProductDetail;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment