feat(dom-css): serialize HTML styles as Tailwind

This commit is contained in:
Danila Poyarkov 2026-07-04 13:01:59 +03:00
parent 31e0634d3c
commit 7af2dba0ec
7 changed files with 97 additions and 12 deletions

View file

@ -6,6 +6,7 @@
- Add Figma-style page management in the Pages panel, including rename/delete actions and drag-and-drop page reordering.
- Add DOM/CSS import and authoring support so HTML, CSS, Tailwind, and JSX can be converted into editable OpenPencil documents from the app, CLI, and SDK.
- Add Tailwind class serialization for DOM/CSS HTML export in the SDK.
- Add richer Design JSX authoring for components, variables, structured fills, gradients, shadows, and blur effects.
- Add overlap analysis for finding layout collisions and overflowing children from the CLI, AI tools, and MCP.
- Add saved per-node export settings for repeat exports.

View file

@ -179,6 +179,7 @@
"parse5": "^8.0.1",
"postcss-value-parser": "^4.2.0",
"tailwindcss": "^4.2.1",
"twirlwind": "^0.3.0",
},
"devDependencies": {
"@types/bun": "^1.3.14",

View file

@ -73,6 +73,7 @@
"@acemir/cssom": "^0.9.31",
"parse5": "^8.0.1",
"postcss-value-parser": "^4.2.0",
"tailwindcss": "^4.2.1"
"tailwindcss": "^4.2.1",
"twirlwind": "^0.3.0"
}
}

View file

@ -61,6 +61,7 @@ export type {
BrowserToSceneGraphOptions
} from './browser'
export type { CompileTailwindCSSOptions } from './tailwind'
export type { SerializeHTMLOptions } from './serialize'
export type { ToSceneGraphOptions } from './to-scene-graph'
export type CSSComputeOptions = DesignTypes.CSSComputeOptions
export type CSSRuntime = DesignTypes.CSSRuntime

View file

@ -1,5 +1,11 @@
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',
@ -37,27 +43,52 @@ function serializeStyle(node: DesignElement): string | undefined {
.join('; ')
}
function serializeAttrs(node: DesignElement): string {
function serializeTailwindClasses(node: DesignElement): string | undefined {
const style = serializeStyle(node)
const attrs = Object.entries({ ...node.attrs, ...(style ? { style } : {}) })
if (!style) return undefined
const className = twirl(style)
return className.length > 0 ? className : undefined
}
function mergeClassNames(...values: Array<string | undefined>): string | undefined {
const className = values
.flatMap((value) => value?.split(/\s+/) ?? [])
.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 attrs = {
...node.attrs,
...(tailwindClass ? { class: mergeClassNames(node.attrs.class, tailwindClass) } : {}),
...(style && options.style !== 'tailwind' ? { style } : {})
}
const serialized = Object.entries(attrs)
.filter(([, value]) => value !== '')
.map(([name, value]) => `${name}="${escapeAttr(value)}"`)
if (attrs.length === 0) return ''
return ` ${attrs.join(' ')}`
if (serialized.length === 0) return ''
return ` ${serialized.join(' ')}`
}
function serializeElement(node: DesignElement): string {
function serializeElement(node: DesignElement, options: SerializeHTMLOptions): string {
const tagName = node.tagName.toLowerCase()
const attrs = serializeAttrs(node)
const attrs = serializeAttrs(node, options)
if (VOID_ELEMENTS.has(tagName)) return `<${tagName}${attrs}>`
return `<${tagName}${attrs}>${node.children.map(serializeNode).join('')}</${tagName}>`
return `<${tagName}${attrs}>${node.children.map((child) => serializeNode(child, options)).join('')}</${tagName}>`
}
export function serializeNode(node: DesignNode): string {
return node.type === 'text' ? serializeText(node) : serializeElement(node)
export function serializeNode(node: DesignNode, options: SerializeHTMLOptions = {}): string {
return node.type === 'text' ? serializeText(node) : serializeElement(node, options)
}
export function serializeHTML(document: DesignDocument): string {
return document.children.map(serializeNode).join('')
export function serializeHTML(
document: DesignDocument,
options: SerializeHTMLOptions = {}
): string {
return document.children.map((node) => serializeNode(node, options)).join('')
}

View file

@ -9,6 +9,31 @@ describe('@open-pencil/dom-css runtime', () => {
expect(serializeHTML(cardDocument)).toContain('OpenPencil')
})
it('serializes inline styles as Tailwind classes when requested', () => {
const html = serializeHTML(
{
type: 'document',
children: [
{
type: 'element',
tagName: 'section',
attrs: { class: 'card' },
inlineStyle: {
display: 'flex',
padding: '16px',
gap: '8px',
'background-color': 'white'
},
children: [{ type: 'text', text: 'OpenPencil' }]
}
]
},
{ style: 'tailwind' }
)
expect(html).toBe('<section class="card flex p-4 gap-2 bg-white">OpenPencil</section>')
})
it('uses the headless runtime outside browser contexts', () => {
const runtime = createCSSRuntime()

View file

@ -11,6 +11,31 @@ describe('@open-pencil/dom-css', () => {
)
})
it('serializes inline styles as Tailwind classes when requested', () => {
const html = serializeHTML(
{
type: 'document',
children: [
{
type: 'element',
tagName: 'section',
attrs: { class: 'card' },
inlineStyle: {
display: 'flex',
padding: '16px',
gap: '8px',
'background-color': 'white'
},
children: [{ type: 'text', text: 'OpenPencil' }]
}
]
},
{ style: 'tailwind' }
)
expect(html).toBe('<section class="card flex p-4 gap-2 bg-white">OpenPencil</section>')
})
it('uses the headless runtime outside browser contexts', () => {
const runtime = createCSSRuntime()