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 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.
|
||||||
|
|
|
||||||
1
bun.lock
1
bun.lock
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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('')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue