* refactor!: generate Tailwind JSX through dom-css Core kept its own SceneGraph → Tailwind mapper next to the one dom-css uses for Tailwind HTML, and the two drifted: HTML export turned grid frames into flex columns and dropped rotation, inner shadows, blur, and flex grow, while Tailwind JSX had them. Tailwind JSX is now printed by dom-css from the same CSS projection as HTML export, with esrap building the JSX and string literals carrying text or attributes that JSX would otherwise reinterpret. The projection gains grid layout and placement, rotation, every shadow, layer and background blur, flex grow, right-to-left direction, and sections, and writes opaque colors as hex so Tailwind can match its palette. BREAKING CHANGE: `sceneNodeToJSX` and `selectionToJSX` in `@open-pencil/core` no longer accept a format, and `JSXFormat` and `JSXExportOptions` are removed. Use `sceneNodesToTailwindJSX` from `@open-pencil/dom-css` or `@open-pencil/dom-css/browser`. * fix(dom-css): keep backslashes and line breaks in Tailwind JSX attributes JSX attribute strings keep backslashes literally, but the printer escapes backslashes and line breaks in string literals, so a layer named `a\b` came back as `a\\b`. Such values are now written as expression containers, like values containing quotes or `&`.
111 lines
3.5 KiB
TypeScript
111 lines
3.5 KiB
TypeScript
import { twirl } from 'twirlwind'
|
|
|
|
import type { DesignDocument, DesignElement, DesignNode, DesignText } from './types'
|
|
|
|
export interface SerializeHTMLOptions {
|
|
style?: 'inline' | 'tailwind'
|
|
}
|
|
|
|
const VOID_ELEMENTS = new Set([
|
|
'area',
|
|
'base',
|
|
'br',
|
|
'col',
|
|
'embed',
|
|
'hr',
|
|
'img',
|
|
'input',
|
|
'link',
|
|
'meta',
|
|
'param',
|
|
'source',
|
|
'track',
|
|
'wbr'
|
|
])
|
|
|
|
export function splitWhitespace(value: string): string[] {
|
|
const parts: string[] = []
|
|
let current = ''
|
|
for (const char of value) {
|
|
if (char === ' ' || char === '\n' || char === '\t' || char === '\r' || char === '\f') {
|
|
if (current.length > 0) parts.push(current)
|
|
current = ''
|
|
} else {
|
|
current += char
|
|
}
|
|
}
|
|
if (current.length > 0) parts.push(current)
|
|
return parts
|
|
}
|
|
|
|
function escapeText(value: string): string {
|
|
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
}
|
|
|
|
function escapeAttr(value: string): string {
|
|
return escapeText(value).replaceAll('"', '"')
|
|
}
|
|
|
|
function serializeText(node: DesignText): string {
|
|
return escapeText(node.text)
|
|
}
|
|
|
|
function serializeStyle(node: DesignElement): string | undefined {
|
|
if (!node.inlineStyle || Object.keys(node.inlineStyle).length === 0) return undefined
|
|
return Object.entries(node.inlineStyle)
|
|
.filter(([, value]) => value !== '')
|
|
.map(([property, value]) => `${property}: ${value}`)
|
|
.join('; ')
|
|
}
|
|
|
|
export function serializeTailwindClasses(node: DesignElement): string | undefined {
|
|
const style = serializeStyle(node)
|
|
if (!style) return undefined
|
|
const className = twirl(style)
|
|
return className.length > 0 ? className : undefined
|
|
}
|
|
|
|
export function mergeClassNames(...values: Array<string | undefined>): string | undefined {
|
|
const className = values
|
|
.flatMap((value) => (value ? splitWhitespace(value) : []))
|
|
.map((value) => value.trim())
|
|
.filter((value) => value.length > 0)
|
|
.join(' ')
|
|
return className.length > 0 ? className : undefined
|
|
}
|
|
|
|
function serializeAttrs(node: DesignElement, options: SerializeHTMLOptions): string {
|
|
const style = serializeStyle(node)
|
|
const tailwindClass = options.style === 'tailwind' ? serializeTailwindClasses(node) : undefined
|
|
const attrsWithoutStyle = { ...node.attrs }
|
|
delete attrsWithoutStyle.style
|
|
const sourceAttrs = options.style === 'tailwind' && tailwindClass ? attrsWithoutStyle : node.attrs
|
|
const attrs: Record<string, string | undefined> = { ...sourceAttrs }
|
|
if (tailwindClass) attrs.class = mergeClassNames(node.attrs.class, tailwindClass)
|
|
if (style && options.style !== 'tailwind') attrs.style = style
|
|
const serialized = Object.entries(attrs)
|
|
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '')
|
|
.map(([name, value]) => `${name}="${escapeAttr(value)}"`)
|
|
|
|
if (serialized.length === 0) return ''
|
|
return ` ${serialized.join(' ')}`
|
|
}
|
|
|
|
function serializeElement(node: DesignElement, options: SerializeHTMLOptions): string {
|
|
const tagName = node.tagName.toLowerCase()
|
|
const attrs = serializeAttrs(node, options)
|
|
if (VOID_ELEMENTS.has(tagName)) return `<${tagName}${attrs}>`
|
|
return `<${tagName}${attrs}>${node.children.map((child) => serializeNode(child, options)).join('')}</${tagName}>`
|
|
}
|
|
|
|
export function serializeNode(node: DesignNode, options: SerializeHTMLOptions = {}): string {
|
|
return node.type === 'text' ? serializeText(node) : serializeElement(node, options)
|
|
}
|
|
|
|
export function serializeHTML(
|
|
document: DesignDocument,
|
|
options: SerializeHTMLOptions = {}
|
|
): string {
|
|
return document.children.map((node) => serializeNode(node, options)).join('')
|
|
}
|