openpencil/packages/dom-css/src/serialize.ts
Danila Poyarkov 9bc353587f
refactor!: generate Tailwind JSX through dom-css (#763)
* 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 `&`.
2026-09-26 10:50:28 +04:00

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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}
function escapeAttr(value: string): string {
return escapeText(value).replaceAll('"', '&quot;')
}
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('')
}