diff --git a/CHANGELOG.md b/CHANGELOG.md index 40259e255..a4e51ef10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 74ae59272..d3a9f6f0f 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index fea92bbfd..389d53a24 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -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) } } }) diff --git a/packages/docs/programmable/cli/exporting.md b/packages/docs/programmable/cli/exporting.md index b089f05a9..e0f7b3443 100644 --- a/packages/docs/programmable/cli/exporting.md +++ b/packages/docs/programmable/cli/exporting.md @@ -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 diff --git a/packages/docs/reference/cli.md b/packages/docs/reference/cli.md index dca3812c6..ed48254e8 100644 --- a/packages/docs/reference/cli.md +++ b/packages/docs/reference/cli.md @@ -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) | diff --git a/packages/dom-css/src/serialize.ts b/packages/dom-css/src/serialize.ts index b06ce7b8f..5455003ef 100644 --- a/packages/dom-css/src/serialize.ts +++ b/packages/dom-css/src/serialize.ts @@ -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('')}` } +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 => 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' ? `` : '' + return `${tailwindBrowser}
${body}
` +} + 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('') } diff --git a/packages/dom-css/tests/runtime.test.ts b/packages/dom-css/tests/runtime.test.ts index c6c2cd53f..3e767dbe3 100644 --- a/packages/dom-css/tests/runtime.test.ts +++ b/packages/dom-css/tests/runtime.test.ts @@ -34,6 +34,23 @@ describe('@open-pencil/dom-css runtime', () => { expect(html).toBe('
OpenPencil
') }) + it('serializes standalone HTML documents when requested', () => { + const html = serializeHTML(cardDocument, { html: 'standalone' }) + + expect(html).toContain('') + 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( + '' + ) + }) + it('uses the headless runtime outside browser contexts', () => { const runtime = createCSSRuntime() diff --git a/tests/engine/cli/export.test.ts b/tests/engine/cli/export.test.ts index 7c1be285e..bb5194356 100644 --- a/tests/engine/cli/export.test.ts +++ b/tests/engine/cli/export.test.ts @@ -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('') + 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('') + expect(html).toContain( + '' + ) + expect(html).toContain('class="') +}) diff --git a/tests/engine/dom-css/runtime.test.ts b/tests/engine/dom-css/runtime.test.ts index 655853410..916d2c305 100644 --- a/tests/engine/dom-css/runtime.test.ts +++ b/tests/engine/dom-css/runtime.test.ts @@ -36,6 +36,23 @@ describe('@open-pencil/dom-css', () => { expect(html).toBe('
OpenPencil
') }) + it('serializes standalone HTML documents when requested', () => { + const html = serializeHTML(simpleCardDocument, { html: 'standalone' }) + + expect(html).toContain('') + 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( + '' + ) + }) + it('uses the headless runtime outside browser contexts', () => { const runtime = createCSSRuntime()