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 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 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 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 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. - Add saved per-node export settings for repeat exports.

View file

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

View file

@ -73,6 +73,7 @@
"@acemir/cssom": "^0.9.31", "@acemir/cssom": "^0.9.31",
"parse5": "^8.0.1", "parse5": "^8.0.1",
"postcss-value-parser": "^4.2.0", "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 BrowserToSceneGraphOptions
} from './browser' } from './browser'
export type { CompileTailwindCSSOptions } from './tailwind' export type { CompileTailwindCSSOptions } from './tailwind'
export type { SerializeHTMLOptions } from './serialize'
export type { ToSceneGraphOptions } from './to-scene-graph' export type { ToSceneGraphOptions } from './to-scene-graph'
export type CSSComputeOptions = DesignTypes.CSSComputeOptions export type CSSComputeOptions = DesignTypes.CSSComputeOptions
export type CSSRuntime = DesignTypes.CSSRuntime export type CSSRuntime = DesignTypes.CSSRuntime

View file

@ -1,5 +1,11 @@
import { twirl } from 'twirlwind'
import type { DesignDocument, DesignElement, DesignNode, DesignText } from './types' import type { DesignDocument, DesignElement, DesignNode, DesignText } from './types'
export interface SerializeHTMLOptions {
style?: 'inline' | 'tailwind'
}
const VOID_ELEMENTS = new Set([ const VOID_ELEMENTS = new Set([
'area', 'area',
'base', 'base',
@ -37,27 +43,52 @@ function serializeStyle(node: DesignElement): string | undefined {
.join('; ') .join('; ')
} }
function serializeAttrs(node: DesignElement): string { function serializeTailwindClasses(node: DesignElement): string | undefined {
const style = serializeStyle(node) 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 !== '') .filter(([, value]) => value !== '')
.map(([name, value]) => `${name}="${escapeAttr(value)}"`) .map(([name, value]) => `${name}="${escapeAttr(value)}"`)
if (attrs.length === 0) return '' if (serialized.length === 0) return ''
return ` ${attrs.join(' ')}` return ` ${serialized.join(' ')}`
} }
function serializeElement(node: DesignElement): string { function serializeElement(node: DesignElement, options: SerializeHTMLOptions): string {
const tagName = node.tagName.toLowerCase() const tagName = node.tagName.toLowerCase()
const attrs = serializeAttrs(node) const attrs = serializeAttrs(node, options)
if (VOID_ELEMENTS.has(tagName)) return `<${tagName}${attrs}>` 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 { export function serializeNode(node: DesignNode, options: SerializeHTMLOptions = {}): string {
return node.type === 'text' ? serializeText(node) : serializeElement(node) return node.type === 'text' ? serializeText(node) : serializeElement(node, options)
} }
export function serializeHTML(document: DesignDocument): string { export function serializeHTML(
return document.children.map(serializeNode).join('') 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') 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', () => { it('uses the headless runtime outside browser contexts', () => {
const runtime = createCSSRuntime() 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', () => { it('uses the headless runtime outside browser contexts', () => {
const runtime = createCSSRuntime() const runtime = createCSSRuntime()