feat(cli): add standalone HTML export mode
This commit is contained in:
parent
8b1c452cc3
commit
3df6cc9bcd
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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="')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue