feat(codegen): add React and HTML code generation

Implement code generators that convert PenNode trees to React+Tailwind
and HTML+CSS output. Add code panel with tab switching, syntax
highlighting, and copy-to-clipboard. Supports generating code for
selected elements or the entire document.
This commit is contained in:
Kayshen-X 2026-02-18 21:49:49 +08:00
parent 6a7a0d0019
commit 23aa080979
4 changed files with 920 additions and 0 deletions

View file

@ -0,0 +1,131 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import { Copy, Check, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { generateReactCode } from '@/services/codegen/react-generator'
import { generateHTMLCode } from '@/services/codegen/html-generator'
import { highlightCode } from '@/utils/syntax-highlight'
import type { PenNode } from '@/types/pen'
type CodeTab = 'react' | 'html'
export default function CodePanel({ onClose }: { onClose: () => void }) {
const [activeTab, setActiveTab] = useState<CodeTab>('react')
const [copied, setCopied] = useState(false)
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
const selectedIds = useCanvasStore((s) => s.selection.selectedIds)
const children = useDocumentStore((s) => s.document.children)
const getNodeById = useDocumentStore((s) => s.getNodeById)
// Force re-render when document changes
void children
const targetNodes: PenNode[] = useMemo(() => {
if (selectedIds.length > 0) {
return selectedIds
.map((id) => getNodeById(id))
.filter((n): n is PenNode => n !== undefined)
}
return children
}, [selectedIds, children, getNodeById])
const generatedCode = useMemo(() => {
if (activeTab === 'react') {
return generateReactCode(targetNodes)
}
const { html, css } = generateHTMLCode(targetNodes)
return `<!-- HTML -->\n${html}\n\n/* CSS */\n${css}`
}, [activeTab, targetNodes])
const highlightedHTML = useMemo(() => {
if (activeTab === 'react') {
return highlightCode(generatedCode, 'jsx')
}
// Split HTML and CSS sections for mixed highlighting
const htmlEnd = generatedCode.indexOf('\n\n/* CSS */')
if (htmlEnd === -1) return highlightCode(generatedCode, 'html')
const htmlPart = generatedCode.slice(0, htmlEnd)
const cssPart = generatedCode.slice(htmlEnd + 2)
return highlightCode(htmlPart, 'html') + '\n\n' + highlightCode(cssPart, 'css')
}, [activeTab, generatedCode])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(generatedCode).then(() => {
setCopied(true)
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
})
}, [generatedCode])
useEffect(() => {
return () => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
}
}, [])
const tabs: { key: CodeTab; label: string }[] = [
{ key: 'react', label: 'React + Tailwind' },
{ key: 'html', label: 'HTML + CSS' },
]
return (
<div className="bg-card border-t border-border flex flex-col" style={{ height: 280 }}>
{/* Header */}
<div className="h-8 flex items-center px-3 border-b border-border shrink-0">
<div className="flex items-center gap-2 flex-1">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={cn(
'text-xs px-2 py-1 rounded transition-colors',
activeTab === tab.key
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{tab.label}
</button>
))}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={handleCopy}
title="Copy to clipboard"
>
{copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
title="Close code panel"
>
<X size={14} />
</Button>
</div>
</div>
{/* Code content */}
<div className="flex-1 overflow-auto p-3">
<pre className="text-xs leading-relaxed font-mono text-foreground/80 whitespace-pre">
<code dangerouslySetInnerHTML={{ __html: highlightedHTML }} />
</pre>
</div>
{/* Footer info */}
<div className="h-6 flex items-center px-3 border-t border-border shrink-0">
<span className="text-[10px] text-muted-foreground">
{selectedIds.length > 0
? `Generating code for ${selectedIds.length} selected element${selectedIds.length > 1 ? 's' : ''}`
: 'Generating code for entire document'}
</span>
</div>
</div>
)
}

View file

@ -0,0 +1,326 @@
import type { PenDocument, PenNode, ContainerProps, TextNode } from '@/types/pen'
import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles'
/**
* Converts PenDocument nodes to HTML + CSS.
*/
let classCounter = 0
function resetClassCounter() {
classCounter = 0
}
function nextClassName(prefix: string): string {
classCounter++
return `${prefix}-${classCounter}`
}
function indent(depth: number): string {
return ' '.repeat(depth)
}
function fillToCSS(fills: PenFill[] | undefined): Record<string, string> {
if (!fills || fills.length === 0) return {}
const fill = fills[0]
if (fill.type === 'solid') {
return { background: fill.color }
}
if (fill.type === 'linear_gradient') {
const angle = fill.angle ?? 180
const stops = fill.stops.map((s) => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ')
return { background: `linear-gradient(${angle}deg, ${stops})` }
}
if (fill.type === 'radial_gradient') {
const stops = fill.stops.map((s) => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ')
return { background: `radial-gradient(circle, ${stops})` }
}
return {}
}
function strokeToCSS(stroke: PenStroke | undefined): Record<string, string> {
if (!stroke) return {}
const css: Record<string, string> = {}
const thickness = typeof stroke.thickness === 'number'
? stroke.thickness
: stroke.thickness[0]
css['border-width'] = `${thickness}px`
css['border-style'] = 'solid'
if (stroke.fill && stroke.fill.length > 0) {
const sf = stroke.fill[0]
if (sf.type === 'solid') {
css['border-color'] = sf.color
}
}
return css
}
function effectsToCSS(effects: PenEffect[] | undefined): Record<string, string> {
if (!effects || effects.length === 0) return {}
const shadows: string[] = []
for (const effect of effects) {
if (effect.type === 'shadow') {
const s = effect as ShadowEffect
const inset = s.inner ? 'inset ' : ''
shadows.push(`${inset}${s.offsetX}px ${s.offsetY}px ${s.blur}px ${s.spread}px ${s.color}`)
}
}
if (shadows.length > 0) {
return { 'box-shadow': shadows.join(', ') }
}
return {}
}
function cornerRadiusToCSS(
cr: number | [number, number, number, number] | undefined,
): Record<string, string> {
if (cr === undefined) return {}
if (typeof cr === 'number') {
return cr === 0 ? {} : { 'border-radius': `${cr}px` }
}
return { 'border-radius': `${cr[0]}px ${cr[1]}px ${cr[2]}px ${cr[3]}px` }
}
function layoutToCSS(node: ContainerProps): Record<string, string> {
const css: Record<string, string> = {}
if (node.layout === 'vertical') {
css.display = 'flex'
css['flex-direction'] = 'column'
} else if (node.layout === 'horizontal') {
css.display = 'flex'
css['flex-direction'] = 'row'
}
if (node.gap !== undefined && typeof node.gap === 'number') {
css.gap = `${node.gap}px`
}
if (node.padding !== undefined) {
if (typeof node.padding === 'number') {
css.padding = `${node.padding}px`
} else if (Array.isArray(node.padding)) {
css.padding = node.padding.map((p) => `${p}px`).join(' ')
}
}
if (node.justifyContent) {
const map: Record<string, string> = {
start: 'flex-start',
center: 'center',
end: 'flex-end',
space_between: 'space-between',
space_around: 'space-around',
}
css['justify-content'] = map[node.justifyContent] ?? node.justifyContent
}
if (node.alignItems) {
const map: Record<string, string> = {
start: 'flex-start',
center: 'center',
end: 'flex-end',
}
css['align-items'] = map[node.alignItems] ?? node.alignItems
}
return css
}
interface CSSRule {
className: string
properties: Record<string, string>
}
function getTextContent(node: TextNode): string {
if (typeof node.content === 'string') return node.content
return node.content.map((s) => s.text).join('')
}
function escapeHTML(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function generateNodeHTML(
node: PenNode,
depth: number,
rules: CSSRule[],
): string {
const pad = indent(depth)
const css: Record<string, string> = {}
// Position
if (node.x !== undefined || node.y !== undefined) {
css.position = 'absolute'
if (node.x !== undefined) css.left = `${node.x}px`
if (node.y !== undefined) css.top = `${node.y}px`
}
// Opacity
if (node.opacity !== undefined && node.opacity !== 1 && typeof node.opacity === 'number') {
css.opacity = String(node.opacity)
}
// Rotation
if (node.rotation) {
css.transform = `rotate(${node.rotation}deg)`
}
switch (node.type) {
case 'frame':
case 'rectangle':
case 'group': {
if (typeof node.width === 'number') css.width = `${node.width}px`
if (typeof node.height === 'number') css.height = `${node.height}px`
Object.assign(css, fillToCSS(node.fill))
Object.assign(css, strokeToCSS(node.stroke))
Object.assign(css, cornerRadiusToCSS(node.cornerRadius))
Object.assign(css, effectsToCSS(node.effects))
Object.assign(css, layoutToCSS(node))
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? node.type)
rules.push({ className, properties: css })
const children = node.children ?? []
if (children.length === 0) {
return `${pad}<div class="${className}"></div>`
}
const childrenHTML = children
.map((c) => generateNodeHTML(c, depth + 1, rules))
.join('\n')
return `${pad}<div class="${className}">\n${childrenHTML}\n${pad}</div>`
}
case 'ellipse': {
if (typeof node.width === 'number') css.width = `${node.width}px`
if (typeof node.height === 'number') css.height = `${node.height}px`
css['border-radius'] = '50%'
Object.assign(css, fillToCSS(node.fill))
Object.assign(css, strokeToCSS(node.stroke))
Object.assign(css, effectsToCSS(node.effects))
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'ellipse')
rules.push({ className, properties: css })
return `${pad}<div class="${className}"></div>`
}
case 'text': {
if (typeof node.width === 'number') css.width = `${node.width}px`
if (typeof node.height === 'number') css.height = `${node.height}px`
if (node.fill) {
const fill = node.fill[0]
if (fill?.type === 'solid') css.color = fill.color
}
if (node.fontSize) css['font-size'] = `${node.fontSize}px`
if (node.fontWeight) css['font-weight'] = String(node.fontWeight)
if (node.fontStyle === 'italic') css['font-style'] = 'italic'
if (node.textAlign) css['text-align'] = node.textAlign
if (node.fontFamily) css['font-family'] = `'${node.fontFamily}', sans-serif`
if (node.lineHeight) css['line-height'] = String(node.lineHeight)
if (node.letterSpacing) css['letter-spacing'] = `${node.letterSpacing}px`
if (node.underline) css['text-decoration'] = 'underline'
if (node.strikethrough) css['text-decoration'] = 'line-through'
Object.assign(css, effectsToCSS(node.effects))
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'text')
rules.push({ className, properties: css })
const size = node.fontSize ?? 16
const tag = size >= 32 ? 'h1' : size >= 24 ? 'h2' : size >= 20 ? 'h3' : 'p'
const text = escapeHTML(getTextContent(node))
return `${pad}<${tag} class="${className}">${text}</${tag}>`
}
case 'line': {
const w = node.x2 !== undefined ? Math.abs(node.x2 - (node.x ?? 0)) : 0
css.width = `${w}px`
if (node.stroke) {
const thickness = typeof node.stroke.thickness === 'number'
? node.stroke.thickness
: node.stroke.thickness[0]
css['border-top-width'] = `${thickness}px`
css['border-top-style'] = 'solid'
if (node.stroke.fill && node.stroke.fill.length > 0) {
const sf = node.stroke.fill[0]
if (sf.type === 'solid') css['border-top-color'] = sf.color
}
}
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'line')
rules.push({ className, properties: css })
return `${pad}<hr class="${className}" />`
}
case 'polygon':
case 'path': {
if (typeof node.width === 'number') css.width = `${node.width}px`
if (typeof node.height === 'number') css.height = `${node.height}px`
Object.assign(css, fillToCSS(node.fill))
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? node.type)
rules.push({ className, properties: css })
if (node.type === 'path') {
const w = typeof node.width === 'number' ? node.width : 100
const h = typeof node.height === 'number' ? node.height : 100
const fillColor = node.fill?.[0]?.type === 'solid' ? node.fill[0].color : 'currentColor'
return `${pad}<svg class="${className}" viewBox="0 0 ${w} ${h}">\n${pad} <path d="${node.d}" fill="${fillColor}" />\n${pad}</svg>`
}
return `${pad}<div class="${className}"></div>`
}
case 'ref':
return `${pad}<!-- Ref: ${node.ref} -->`
default:
return `${pad}<!-- Unknown node -->`
}
}
function cssRulesToString(rules: CSSRule[]): string {
return rules
.map((r) => {
const props = Object.entries(r.properties)
.map(([k, v]) => ` ${k}: ${v};`)
.join('\n')
return `.${r.className} {\n${props}\n}`
})
.join('\n\n')
}
export function generateHTMLCode(nodes: PenNode[]): { html: string; css: string } {
resetClassCounter()
const rules: CSSRule[] = []
if (nodes.length === 0) {
return {
html: '<div class="container"></div>',
css: '.container {\n position: relative;\n}',
}
}
// Compute wrapper size
let maxW = 0
let maxH = 0
for (const node of nodes) {
const x = node.x ?? 0
const y = node.y ?? 0
const w = 'width' in node && typeof node.width === 'number' ? node.width : 0
const h = 'height' in node && typeof node.height === 'number' ? node.height : 0
maxW = Math.max(maxW, x + w)
maxH = Math.max(maxH, y + h)
}
const containerCSS: Record<string, string> = { position: 'relative' }
if (maxW > 0) containerCSS.width = `${maxW}px`
if (maxH > 0) containerCSS.height = `${maxH}px`
rules.push({ className: 'container', properties: containerCSS })
const childrenHTML = nodes
.map((n) => generateNodeHTML(n, 1, rules))
.join('\n')
const html = `<div class="container">\n${childrenHTML}\n</div>`
const css = cssRulesToString(rules)
return { html, css }
}
export function generateHTMLFromDocument(doc: PenDocument): { html: string; css: string } {
return generateHTMLCode(doc.children)
}

View file

@ -0,0 +1,331 @@
import type { PenDocument, PenNode, ContainerProps, TextNode } from '@/types/pen'
import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles'
/**
* Converts PenDocument nodes to React + Tailwind code.
*/
function indent(depth: number): string {
return ' '.repeat(depth)
}
function fillToTailwind(fills: PenFill[] | undefined): string[] {
if (!fills || fills.length === 0) return []
const fill = fills[0]
if (fill.type === 'solid') {
return [`bg-[${fill.color}]`]
}
return []
}
function fillToTextColor(fills: PenFill[] | undefined): string[] {
if (!fills || fills.length === 0) return []
const fill = fills[0]
if (fill.type === 'solid') {
return [`text-[${fill.color}]`]
}
return []
}
function strokeToTailwind(stroke: PenStroke | undefined): string[] {
if (!stroke) return []
const classes: string[] = []
const thickness = typeof stroke.thickness === 'number'
? stroke.thickness
: stroke.thickness[0]
classes.push('border', `border-[${thickness}px]`)
if (stroke.fill && stroke.fill.length > 0) {
const sf = stroke.fill[0]
if (sf.type === 'solid') {
classes.push(`border-[${sf.color}]`)
}
}
return classes
}
function effectsToTailwind(effects: PenEffect[] | undefined): string[] {
if (!effects || effects.length === 0) return []
const classes: string[] = []
for (const effect of effects) {
if (effect.type === 'shadow') {
const s = effect as ShadowEffect
classes.push(`shadow-[${s.offsetX}px_${s.offsetY}px_${s.blur}px_${s.spread}px_${s.color}]`)
}
}
return classes
}
function cornerRadiusToTailwind(
cr: number | [number, number, number, number] | undefined,
): string[] {
if (cr === undefined) return []
if (typeof cr === 'number') {
if (cr === 0) return []
return [`rounded-[${cr}px]`]
}
const [tl, tr, br, bl] = cr
if (tl === tr && tr === br && br === bl) {
return tl === 0 ? [] : [`rounded-[${tl}px]`]
}
return [`rounded-[${tl}px_${tr}px_${br}px_${bl}px]`]
}
function layoutToTailwind(node: ContainerProps): string[] {
const classes: string[] = []
if (node.layout === 'vertical') {
classes.push('flex', 'flex-col')
} else if (node.layout === 'horizontal') {
classes.push('flex', 'flex-row')
}
if (node.gap !== undefined && typeof node.gap === 'number' && node.gap > 0) {
classes.push(`gap-[${node.gap}px]`)
}
if (node.padding !== undefined) {
if (typeof node.padding === 'number') {
classes.push(`p-[${node.padding}px]`)
} else if (Array.isArray(node.padding)) {
if (node.padding.length === 2) {
classes.push(`py-[${node.padding[0]}px]`, `px-[${node.padding[1]}px]`)
} else if (node.padding.length === 4) {
classes.push(
`pt-[${node.padding[0]}px]`,
`pr-[${node.padding[1]}px]`,
`pb-[${node.padding[2]}px]`,
`pl-[${node.padding[3]}px]`,
)
}
}
}
if (node.justifyContent) {
const jcMap: Record<string, string> = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
space_between: 'justify-between',
space_around: 'justify-around',
}
if (jcMap[node.justifyContent]) classes.push(jcMap[node.justifyContent])
}
if (node.alignItems) {
const aiMap: Record<string, string> = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
}
if (aiMap[node.alignItems]) classes.push(aiMap[node.alignItems])
}
return classes
}
function sizeToTailwind(
width: number | string | undefined,
height: number | string | undefined,
): string[] {
const classes: string[] = []
if (typeof width === 'number') classes.push(`w-[${width}px]`)
if (typeof height === 'number') classes.push(`h-[${height}px]`)
return classes
}
function opacityToTailwind(opacity: number | string | undefined): string[] {
if (opacity === undefined || opacity === 1) return []
if (typeof opacity === 'number') {
const pct = Math.round(opacity * 100)
return [`opacity-[${pct}%]`]
}
return []
}
function textTag(node: TextNode): string {
const size = node.fontSize ?? 16
if (size >= 32) return 'h1'
if (size >= 24) return 'h2'
if (size >= 20) return 'h3'
return 'p'
}
function getTextContent(node: TextNode): string {
if (typeof node.content === 'string') return node.content
return node.content.map((s) => s.text).join('')
}
function textToTailwind(node: TextNode): string[] {
const classes: string[] = []
if (node.fontSize) classes.push(`text-[${node.fontSize}px]`)
if (node.fontWeight) {
const w = typeof node.fontWeight === 'number' ? node.fontWeight : parseInt(node.fontWeight, 10)
if (!isNaN(w)) classes.push(`font-[${w}]`)
}
if (node.fontStyle === 'italic') classes.push('italic')
if (node.textAlign) {
const taMap: Record<string, string> = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
}
if (taMap[node.textAlign]) classes.push(taMap[node.textAlign])
}
if (node.fontFamily) classes.push(`font-['${node.fontFamily.replace(/\s/g, '_')}']`)
if (node.lineHeight) classes.push(`leading-[${node.lineHeight}]`)
if (node.letterSpacing) classes.push(`tracking-[${node.letterSpacing}px]`)
if (node.underline) classes.push('underline')
if (node.strikethrough) classes.push('line-through')
return classes
}
function generateNodeJSX(node: PenNode, depth: number): string {
const pad = indent(depth)
const classes: string[] = []
// Position
if (node.x !== undefined || node.y !== undefined) {
classes.push('absolute')
if (node.x !== undefined) classes.push(`left-[${node.x}px]`)
if (node.y !== undefined) classes.push(`top-[${node.y}px]`)
}
// Opacity
classes.push(...opacityToTailwind(node.opacity))
// Rotation
if (node.rotation) {
classes.push(`rotate-[${node.rotation}deg]`)
}
switch (node.type) {
case 'frame':
case 'rectangle':
case 'group': {
classes.push(
...sizeToTailwind(node.width, node.height),
...fillToTailwind(node.fill),
...strokeToTailwind(node.stroke),
...cornerRadiusToTailwind(node.cornerRadius),
...effectsToTailwind(node.effects),
...layoutToTailwind(node),
)
const childNodes = node.children ?? []
if (childNodes.length === 0) {
return `${pad}<div className="${classes.join(' ')}" />`
}
const childrenJSX = childNodes
.map((c) => generateNodeJSX(c, depth + 1))
.join('\n')
const comment = node.name ? `${pad}{/* ${node.name} */}\n` : ''
return `${comment}${pad}<div className="${classes.join(' ')}">\n${childrenJSX}\n${pad}</div>`
}
case 'ellipse': {
classes.push(
'rounded-full',
...sizeToTailwind(node.width, node.height),
...fillToTailwind(node.fill),
...strokeToTailwind(node.stroke),
...effectsToTailwind(node.effects),
)
return `${pad}<div className="${classes.join(' ')}" />`
}
case 'text': {
const tag = textTag(node)
classes.push(
...sizeToTailwind(node.width, node.height),
...fillToTextColor(node.fill),
...textToTailwind(node),
...effectsToTailwind(node.effects),
)
const text = escapeJSX(getTextContent(node))
return `${pad}<${tag} className="${classes.join(' ')}">${text}</${tag}>`
}
case 'line': {
const w = node.x2 !== undefined ? Math.abs(node.x2 - (node.x ?? 0)) : 0
classes.push(`w-[${w}px]`)
if (node.stroke) {
const thickness = typeof node.stroke.thickness === 'number'
? node.stroke.thickness
: node.stroke.thickness[0]
classes.push(`border-t-[${thickness}px]`)
if (node.stroke.fill && node.stroke.fill.length > 0) {
const sf = node.stroke.fill[0]
if (sf.type === 'solid') {
classes.push(`border-[${sf.color}]`)
}
}
}
return `${pad}<hr className="${classes.join(' ')}" />`
}
case 'polygon':
case 'path': {
// For complex shapes, output an SVG inline
classes.push(...sizeToTailwind(node.width, node.height))
if (node.type === 'path') {
const w = typeof node.width === 'number' ? node.width : 100
const h = typeof node.height === 'number' ? node.height : 100
const fillColor = node.fill?.[0]?.type === 'solid' ? node.fill[0].color : 'currentColor'
return `${pad}<svg className="${classes.join(' ')}" viewBox="0 0 ${w} ${h}">\n${pad} <path d="${node.d}" fill="${fillColor}" />\n${pad}</svg>`
}
classes.push(...fillToTailwind(node.fill))
return `${pad}<div className="${classes.join(' ')}" />`
}
case 'ref':
return `${pad}{/* Ref: ${node.ref} */}`
default:
return `${pad}{/* Unknown node */}`
}
}
function escapeJSX(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/{/g, '&#123;')
.replace(/}/g, '&#125;')
}
export function generateReactCode(
nodes: PenNode[],
componentName = 'GeneratedDesign',
): string {
if (nodes.length === 0) {
return `export function ${componentName}() {\n return <div className="relative" />\n}\n`
}
// Find bounding box for the root wrapper
let maxW = 0
let maxH = 0
for (const node of nodes) {
const x = node.x ?? 0
const y = node.y ?? 0
const w = 'width' in node && typeof node.width === 'number' ? node.width : 0
const h = 'height' in node && typeof node.height === 'number' ? node.height : 0
maxW = Math.max(maxW, x + w)
maxH = Math.max(maxH, y + h)
}
const wrapperClasses = ['relative']
if (maxW > 0) wrapperClasses.push(`w-[${maxW}px]`)
if (maxH > 0) wrapperClasses.push(`h-[${maxH}px]`)
const childrenJSX = nodes
.map((n) => generateNodeJSX(n, 2))
.join('\n')
return `export function ${componentName}() {
return (
<div className="${wrapperClasses.join(' ')}">
${childrenJSX}
</div>
)
}
`
}
export function generateReactFromDocument(doc: PenDocument): string {
return generateReactCode(doc.children)
}

View file

@ -0,0 +1,132 @@
/**
* Lightweight syntax highlighter for JSX, HTML, and CSS.
* Produces HTML strings with color spans.
*/
interface TokenRule {
pattern: RegExp
className: string
}
const JSX_RULES: TokenRule[] = [
// Multi-line comments
{ pattern: /\/\*[\s\S]*?\*\//g, className: 'syn-comment' },
// Single-line comments
{ pattern: /\/\/.*/g, className: 'syn-comment' },
// JSX self-closing tags: <Tag ... />
{ pattern: /<\/?[A-Za-z][A-Za-z0-9.]*/g, className: 'syn-tag' },
// Closing bracket
{ pattern: /\/?>/g, className: 'syn-tag' },
// Strings (double and single quoted)
{ pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' },
{ pattern: /'(?:[^'\\]|\\.)*'/g, className: 'syn-string' },
// Template literals
{ pattern: /`(?:[^`\\]|\\.)*`/g, className: 'syn-string' },
// Keywords
{ pattern: /\b(export|function|return|const|let|var|import|from|default|if|else|for|while|switch|case|break|continue|new|this|class|extends|typeof|instanceof|void|null|undefined|true|false)\b/g, className: 'syn-keyword' },
// Attribute names (word followed by =)
{ pattern: /\b[a-zA-Z-]+(?==)/g, className: 'syn-attr' },
// Numbers
{ pattern: /\b\d+\.?\d*\b/g, className: 'syn-number' },
// Curly braces in JSX
{ pattern: /[{}]/g, className: 'syn-bracket' },
]
const HTML_RULES: TokenRule[] = [
// Comments
{ pattern: /<!--[\s\S]*?-->/g, className: 'syn-comment' },
// Tags
{ pattern: /<\/?[a-zA-Z][a-zA-Z0-9-]*/g, className: 'syn-tag' },
{ pattern: /\/?>/g, className: 'syn-tag' },
// Attribute values
{ pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' },
{ pattern: /'(?:[^'\\]|\\.)*'/g, className: 'syn-string' },
// Attribute names
{ pattern: /\b[a-zA-Z-]+(?==)/g, className: 'syn-attr' },
]
const CSS_RULES: TokenRule[] = [
// Comments
{ pattern: /\/\*[\s\S]*?\*\//g, className: 'syn-comment' },
// Selectors (class/id/element)
{ pattern: /[.#]?[a-zA-Z_-][a-zA-Z0-9_-]*(?=\s*\{)/g, className: 'syn-tag' },
// Property names
{ pattern: /[a-zA-Z-]+(?=\s*:)/g, className: 'syn-attr' },
// Strings
{ pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' },
{ pattern: /'(?:[^'\\]|\\.)*'/g, className: 'syn-string' },
// Numbers with units
{ pattern: /\b\d+\.?\d*(px|em|rem|%|deg|vh|vw|s|ms)?\b/g, className: 'syn-number' },
// Colors
{ pattern: /#[0-9a-fA-F]{3,8}\b/g, className: 'syn-string' },
// Braces
{ pattern: /[{}]/g, className: 'syn-bracket' },
// Keywords
{ pattern: /\b(important|inherit|initial|unset|none|auto|solid|dashed|flex|grid|block|inline|absolute|relative|fixed|sticky)\b/g, className: 'syn-keyword' },
]
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
interface Token {
start: number
end: number
className: string
}
function tokenize(code: string, rules: TokenRule[]): Token[] {
const tokens: Token[] = []
for (const rule of rules) {
const regex = new RegExp(rule.pattern.source, rule.pattern.flags)
let match: RegExpExecArray | null
while ((match = regex.exec(code)) !== null) {
tokens.push({
start: match.index,
end: match.index + match[0].length,
className: rule.className,
})
}
}
// Sort by start position; earlier rules win ties (priority order)
tokens.sort((a, b) => a.start - b.start)
// Remove overlapping tokens (first match wins)
const filtered: Token[] = []
let lastEnd = 0
for (const token of tokens) {
if (token.start >= lastEnd) {
filtered.push(token)
lastEnd = token.end
}
}
return filtered
}
function renderTokens(code: string, tokens: Token[]): string {
let result = ''
let pos = 0
for (const token of tokens) {
if (token.start > pos) {
result += escapeHtml(code.slice(pos, token.start))
}
result += `<span class="${token.className}">${escapeHtml(code.slice(token.start, token.end))}</span>`
pos = token.end
}
if (pos < code.length) {
result += escapeHtml(code.slice(pos))
}
return result
}
export type SyntaxLanguage = 'jsx' | 'html' | 'css'
export function highlightCode(code: string, language: SyntaxLanguage): string {
const rules = language === 'jsx' ? JSX_RULES : language === 'html' ? HTML_RULES : CSS_RULES
const tokens = tokenize(code, rules)
return renderTokens(code, tokens)
}