feat(cli): add standalone HTML export mode

This commit is contained in:
Danila Poyarkov 2026-07-04 14:54:58 +03:00
parent 8b1c452cc3
commit 3df6cc9bcd
9 changed files with 213 additions and 17 deletions

View file

@ -7,6 +7,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 and CLI.
- Add standalone browser-openable HTML export with inline CSS or Tailwind browser runtime previews.
- 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

@ -79,7 +79,7 @@ openpencil export design.fig # PNG
openpencil export design.fig -f jpg -s 2 -q 90 # JPG at 2x, quality 90
openpencil export design.fig -f fig --page "Page 1" # Export a page as .fig
openpencil export design.fig -f jsx --style tailwind # Tailwind JSX
openpencil export design.fig -f html --style tailwind # Tailwind HTML
openpencil export design.fig -f html --css tailwind # Tailwind HTML fragment
openpencil convert design.pen output.fig # Convert between document formats
openpencil import page.html --css styles.css -o page.fig # HTML/CSS → editable .fig
```

View file

@ -21,6 +21,7 @@ const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP']
const ALL_FORMATS = new Set([...RASTER_FORMATS, 'SVG', 'PDF', 'JSX', 'FIG', 'HTML'])
const JSX_STYLES = new Set(['openpencil', 'tailwind'])
const HTML_STYLES = new Set(['inline', 'tailwind'])
const HTML_MODES = new Set(['fragment', 'standalone'])
interface ExportArgs {
file?: string
@ -31,6 +32,8 @@ interface ExportArgs {
page?: string
node?: string
style: string
html: string
css: string
thumbnail?: boolean
width: string
height: string
@ -111,7 +114,10 @@ async function exportHTMLFromFile(
const document = sceneGraphToDesignDocument(graph, {
rootId: target.scope === 'page' ? target.pageId : target.nodeId
})
const html = serializeHTML(document, { style: args.style as SerializeHTMLOptions['style'] })
const html = serializeHTML(document, {
html: args.html as SerializeHTMLOptions['html'],
style: args.css as SerializeHTMLOptions['style']
})
const output = resolve(args.output ?? exportFileName(defaultName, 'html'))
await writeAndLog(output, html)
console.log(ok(`Target: ${targetLabel(args.page, args.node)}`))
@ -222,10 +228,19 @@ export default defineCommand({
},
style: {
type: 'string',
description:
'Code style for JSX/HTML: openpencil or tailwind for JSX; inline or tailwind for HTML (default: openpencil)',
description: 'JSX style: openpencil or tailwind (default: openpencil)',
default: 'openpencil'
},
html: {
type: 'string',
description: 'HTML output mode: fragment or standalone (default: fragment)',
default: 'fragment'
},
css: {
type: 'string',
description: 'HTML CSS output: inline or tailwind (default: inline)',
default: 'inline'
},
thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' },
width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' },
@ -245,20 +260,20 @@ export default defineCommand({
process.exit(1)
}
const normalizedArgs = {
...args,
style: format === 'HTML' && args.style === 'openpencil' ? 'inline' : args.style
if (format === 'HTML' && !HTML_MODES.has(args.html)) {
printError(`Invalid HTML mode "${args.html}". Use fragment or standalone.`)
process.exit(1)
}
if (format === 'HTML' && !HTML_STYLES.has(normalizedArgs.style)) {
printError(`Invalid HTML style "${args.style}". Use inline or tailwind.`)
if (format === 'HTML' && !HTML_STYLES.has(args.css)) {
printError(`Invalid HTML CSS output "${args.css}". Use inline or tailwind.`)
process.exit(1)
}
if (isAppMode(args.file)) {
await exportViaApp(format, normalizedArgs)
await exportViaApp(format, args)
} else {
await exportFromFile(format, normalizedArgs)
await exportFromFile(format, args)
}
}
})

View file

@ -16,7 +16,7 @@ openpencil export design.fig -f webp -s 3 # WEBP at 3×
openpencil export design.fig -f svg # SVG vector
openpencil export design.fig -f fig --page "Page 1" # export one page as .fig
openpencil export design.fig -f fig --node 1:23 # export one node as .fig
openpencil export design.fig -f html --style tailwind # export HTML with Tailwind classes
openpencil export design.fig -f html --css tailwind # export an HTML fragment with Tailwind classes
```
Options:
@ -49,13 +49,22 @@ Also supports `--style openpencil` for the native JSX format (see [JSX Renderer]
## HTML Export
Export as HTML with inline styles by default, or Tailwind utility classes:
Export as an HTML fragment with inline styles by default, or Tailwind utility classes:
```sh
openpencil export design.fig -f html
openpencil export design.fig -f html --style tailwind
openpencil export design.fig -f html --css tailwind
```
Use `--html standalone` for a browser-openable HTML document with reset styles and a page wrapper. Standalone HTML is intended as a useful visual/code handoff, not a pixel-perfect renderer replacement:
```sh
openpencil export design.fig -f html --html standalone --css inline
openpencil export design.fig -f html --html standalone --css tailwind
```
Standalone Tailwind output includes Tailwind's v4 browser runtime from jsDelivr, so it needs network access when opened and is best for previews/prototypes. Use `--css inline` for a self-contained offline file.
HTML export is available in file mode.
## Thumbnails

View file

@ -104,7 +104,9 @@ openpencil export [file] [options]
| `--quality` | `-q` | Quality 0–100, JPG/WEBP only (default: 90) |
| `--page` | | Page name (default: first page) |
| `--node` | | Node ID to export (default: all top-level nodes) |
| `--style` | | JSX style: `openpencil` (default), `tailwind`; HTML style: `inline`, `tailwind` |
| `--style` | | JSX style: `openpencil` (default), `tailwind` |
| `--html` | | HTML mode: `fragment` (default), `standalone` |
| `--css` | | HTML CSS output: `inline` (default), `tailwind` |
| `--thumbnail` | | Export page thumbnail instead of full render |
| `--width` | | Thumbnail width (default: 1920) |
| `--height` | | Thumbnail height (default: 1080) |

View file

@ -1,11 +1,20 @@
import { twirl } from 'twirlwind'
import type { DesignDocument, DesignElement, DesignNode, DesignText } from './types'
import type {
DesignDocument,
DesignElement,
DesignNode,
DesignStyleDeclaration,
DesignText
} from './types'
export interface SerializeHTMLOptions {
style?: 'inline' | 'tailwind'
html?: 'fragment' | 'standalone'
}
const TAILWIND_BROWSER_CDN = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
const VOID_ELEMENTS = new Set([
'area',
'base',
@ -85,6 +94,77 @@ function serializeElement(node: DesignElement, options: SerializeHTMLOptions): s
return `<${tagName}${attrs}>${node.children.map((child) => serializeNode(child, options)).join('')}</${tagName}>`
}
interface StandaloneBounds {
minX: number
minY: number
width: number
height: number
}
function standaloneStyleForNode(
node: DesignElement,
parent: DesignElement | undefined,
origin: StandaloneBounds
): DesignStyleDeclaration {
const style = { ...node.inlineStyle }
const source = node.sourceSceneNode
if (!source) return style
style.position = 'absolute'
style.left = `${source.x - (parent ? 0 : origin.minX)}px`
style.top = `${source.y - (parent ? 0 : origin.minY)}px`
return style
}
function standaloneNode(
node: DesignNode,
origin: StandaloneBounds,
parent?: DesignElement
): DesignNode {
if (node.type === 'text') return node
const standalone: DesignElement = {
...node,
inlineStyle: standaloneStyleForNode(node, parent, origin),
children: []
}
standalone.children = node.children.map((child) => standaloneNode(child, origin, node))
return standalone
}
function nodeBounds(node: DesignNode): StandaloneBounds | undefined {
if (node.type === 'text' || !node.sourceSceneNode) return undefined
return {
minX: node.sourceSceneNode.x,
minY: node.sourceSceneNode.y,
width: node.sourceSceneNode.width,
height: node.sourceSceneNode.height
}
}
function standaloneSize(document: DesignDocument): StandaloneBounds {
const bounds = document.children
.map(nodeBounds)
.filter((value): value is NonNullable<typeof value> => value !== undefined)
const minX = bounds.length > 0 ? Math.min(...bounds.map((bound) => bound.minX)) : 0
const minY = bounds.length > 0 ? Math.min(...bounds.map((bound) => bound.minY)) : 0
const maxX = bounds.length > 0 ? Math.max(...bounds.map((bound) => bound.minX + bound.width)) : 1
const maxY = bounds.length > 0 ? Math.max(...bounds.map((bound) => bound.minY + bound.height)) : 1
return { minX, minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) }
}
function serializeStandaloneHTML(document: DesignDocument, options: SerializeHTMLOptions): string {
const size = standaloneSize(document)
const body = document.children
.map((node) => serializeNode(standaloneNode(node, size), options))
.join('')
const stageStyle = `position: relative; width: ${size.width}px; height: ${size.height}px; overflow: hidden; background: transparent`
const reset =
'*,*::before,*::after{box-sizing:border-box}html,body{margin:0;padding:0}body{font-family:system-ui,sans-serif;background:#fff}'
const tailwindBrowser =
options.style === 'tailwind' ? `<script src="${TAILWIND_BROWSER_CDN}"></script>` : ''
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${tailwindBrowser}<style>${reset}</style></head><body><main data-open-pencil-html="standalone" style="${stageStyle}">${body}</main></body></html>`
}
export function serializeNode(node: DesignNode, options: SerializeHTMLOptions = {}): string {
return node.type === 'text' ? serializeText(node) : serializeElement(node, options)
}
@ -93,5 +173,6 @@ export function serializeHTML(
document: DesignDocument,
options: SerializeHTMLOptions = {}
): string {
if (options.html === 'standalone') return serializeStandaloneHTML(document, options)
return document.children.map((node) => serializeNode(node, options)).join('')
}

View file

@ -34,6 +34,23 @@ describe('@open-pencil/dom-css runtime', () => {
expect(html).toBe('<section class="card flex p-4 gap-2 bg-white">OpenPencil</section>')
})
it('serializes standalone HTML documents when requested', () => {
const html = serializeHTML(cardDocument, { html: 'standalone' })
expect(html).toContain('<!doctype html>')
expect(html).toContain('data-open-pencil-html="standalone"')
expect(html).toContain('OpenPencil')
expect(html).not.toContain('@tailwindcss/browser@4')
})
it('loads the Tailwind browser runtime for standalone Tailwind HTML', () => {
const html = serializeHTML(cardDocument, { html: 'standalone', style: 'tailwind' })
expect(html).toContain(
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>'
)
})
it('uses the headless runtime outside browser contexts', () => {
const runtime = createCSSRuntime()

View file

@ -67,7 +67,7 @@ test('export CLI can write HTML styles as Tailwind classes', async () => {
figPath,
'--format',
'html',
'--style',
'--css',
'tailwind',
'--output',
output
@ -82,3 +82,57 @@ test('export CLI can write HTML styles as Tailwind classes', async () => {
expect(html).toContain('flex')
expect(html).not.toContain('style=')
})
test('export CLI can write standalone HTML', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'card-standalone.html')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'html',
'--html',
'standalone',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
const html = await Bun.file(output).text()
expect(html).toContain('<!doctype html>')
expect(html).toContain('data-open-pencil-html="standalone"')
expect(html).toContain('position: relative')
expect(html).toContain('position: absolute')
expect(html).not.toContain('@tailwindcss/browser@4')
})
test('export CLI includes Tailwind browser runtime for standalone Tailwind HTML', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'card-standalone-tailwind.html')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'html',
'--html',
'standalone',
'--css',
'tailwind',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
const html = await Bun.file(output).text()
expect(html).toContain('<!doctype html>')
expect(html).toContain(
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>'
)
expect(html).toContain('class="')
})

View file

@ -36,6 +36,23 @@ describe('@open-pencil/dom-css', () => {
expect(html).toBe('<section class="card flex p-4 gap-2 bg-white">OpenPencil</section>')
})
it('serializes standalone HTML documents when requested', () => {
const html = serializeHTML(simpleCardDocument, { html: 'standalone' })
expect(html).toContain('<!doctype html>')
expect(html).toContain('data-open-pencil-html="standalone"')
expect(html).toContain('OpenPencil')
expect(html).not.toContain('@tailwindcss/browser@4')
})
it('loads the Tailwind browser runtime for standalone Tailwind HTML', () => {
const html = serializeHTML(simpleCardDocument, { html: 'standalone', style: 'tailwind' })
expect(html).toContain(
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>'
)
})
it('uses the headless runtime outside browser contexts', () => {
const runtime = createCSSRuntime()