feat(dom-css): serialize HTML styles as Tailwind
This commit is contained in:
parent
31e0634d3c
commit
7af2dba0ec
|
|
@ -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.
|
||||
|
|
|
|||
1
bun.lock
1
bun.lock
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue