diff --git a/CHANGELOG.md b/CHANGELOG.md index 34aa236a3..0772048b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/bun.lock b/bun.lock index d38a6dfb9..b861f6eb7 100644 --- a/bun.lock +++ b/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", diff --git a/packages/dom-css/package.json b/packages/dom-css/package.json index a8cd14975..46cc37c5f 100644 --- a/packages/dom-css/package.json +++ b/packages/dom-css/package.json @@ -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" } } diff --git a/packages/dom-css/src/index.ts b/packages/dom-css/src/index.ts index 75186f2f1..f64fd5a9e 100644 --- a/packages/dom-css/src/index.ts +++ b/packages/dom-css/src/index.ts @@ -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 diff --git a/packages/dom-css/src/serialize.ts b/packages/dom-css/src/serialize.ts index ce41ccead..23348a0db 100644 --- a/packages/dom-css/src/serialize.ts +++ b/packages/dom-css/src/serialize.ts @@ -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 { + 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('')}` + return `<${tagName}${attrs}>${node.children.map((child) => serializeNode(child, options)).join('')}` } -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('') } diff --git a/packages/dom-css/tests/runtime.test.ts b/packages/dom-css/tests/runtime.test.ts index b52372715..c6c2cd53f 100644 --- a/packages/dom-css/tests/runtime.test.ts +++ b/packages/dom-css/tests/runtime.test.ts @@ -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('
OpenPencil
') + }) + it('uses the headless runtime outside browser contexts', () => { const runtime = createCSSRuntime() diff --git a/tests/engine/dom-css/runtime.test.ts b/tests/engine/dom-css/runtime.test.ts index a44546515..655853410 100644 --- a/tests/engine/dom-css/runtime.test.ts +++ b/tests/engine/dom-css/runtime.test.ts @@ -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('
OpenPencil
') + }) + it('uses the headless runtime outside browser contexts', () => { const runtime = createCSSRuntime()