fix(dom-css): compile standalone HTML exports

This commit is contained in:
Danila Poyarkov 2026-07-04 19:37:58 +03:00
parent b22a751f27
commit 7ac3ee035d
15 changed files with 695 additions and 119 deletions

View file

@ -7,7 +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 standalone browser-openable HTML export with compiled CSS and optional external image/font assets.
- 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

@ -80,6 +80,7 @@ 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 --css tailwind # Tailwind HTML fragment
openpencil export design.fig -f html --html standalone --assets external # HTML + assets
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

@ -1,14 +1,14 @@
import { writeFile } from 'node:fs/promises'
import { basename, extname, resolve } from 'node:path'
import { mkdir, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, join, resolve } from 'node:path'
import { defineCommand } from 'citty'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
import type { RasterExportFormat } from '@open-pencil/core/io'
import {
exportHTMLBundle,
sceneGraphToDesignDocument,
serializeHTML,
type SerializeHTMLOptions
type ExportHTMLBundleOptions
} from '@open-pencil/dom-css'
import { isAppMode, requireFile, rpc } from '#cli/app-client'
@ -22,6 +22,8 @@ const ALL_FORMATS = new Set([...RASTER_FORMATS, 'SVG', 'PDF', 'JSX', 'FIG', 'HTM
const JSX_STYLES = new Set(['openpencil', 'tailwind'])
const HTML_STYLES = new Set(['inline', 'tailwind'])
const HTML_MODES = new Set(['fragment', 'standalone'])
const HTML_ASSETS = new Set(['inline', 'external'])
const HTML_FONTS = new Set(['assets', 'none'])
interface ExportArgs {
file?: string
@ -34,6 +36,8 @@ interface ExportArgs {
style: string
html: string
css: string
assets: string
fonts: string
thumbnail?: boolean
width: string
height: string
@ -105,6 +109,27 @@ function targetLabel(pageName?: string, nodeId?: string): string {
type FileExportTarget = { scope: 'node'; nodeId: string } | { scope: 'page'; pageId: string }
async function writeHTMLFiles(
output: string,
bundle: Awaited<ReturnType<typeof exportHTMLBundle>>
) {
const entrypoint = bundle.files.find((file) => file.path === bundle.entrypoint)
if (!entrypoint) {
printError(`HTML export did not include ${bundle.entrypoint}.`)
process.exit(1)
}
await writeAndLog(output, entrypoint.content)
const outputDir = dirname(output)
const assetFiles = bundle.files.filter((file) => file.path !== bundle.entrypoint)
for (const file of assetFiles) {
const assetPath = join(outputDir, file.path)
await mkdir(dirname(assetPath), { recursive: true })
await writeFile(assetPath, file.content)
}
if (assetFiles.length > 0) console.log(ok(`Assets: ${assetFiles.length} files`))
}
async function exportHTMLFromFile(
args: ExportArgs,
graph: Awaited<ReturnType<typeof loadDocument>>,
@ -114,12 +139,16 @@ async function exportHTMLFromFile(
const document = sceneGraphToDesignDocument(graph, {
rootId: target.scope === 'page' ? target.pageId : target.nodeId
})
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)
const assetBasePath = `${basename(output, extname(output))}.assets`
const bundle = await exportHTMLBundle(document, {
html: args.html as ExportHTMLBundleOptions['html'],
style: args.css as ExportHTMLBundleOptions['style'],
assets: args.assets as ExportHTMLBundleOptions['assets'],
fonts: args.fonts as ExportHTMLBundleOptions['fonts'],
assetBasePath
})
await writeHTMLFiles(output, bundle)
console.log(ok(`Target: ${targetLabel(args.page, args.node)}`))
}
@ -241,6 +270,16 @@ export default defineCommand({
description: 'HTML CSS output: inline or tailwind (default: inline)',
default: 'inline'
},
assets: {
type: 'string',
description: 'HTML asset output: inline or external (default: inline)',
default: 'inline'
},
fonts: {
type: 'string',
description: 'HTML font output: assets or none (default: none)',
default: 'none'
},
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' },
@ -270,6 +309,16 @@ export default defineCommand({
process.exit(1)
}
if (format === 'HTML' && !HTML_ASSETS.has(args.assets)) {
printError(`Invalid HTML asset output "${args.assets}". Use inline or external.`)
process.exit(1)
}
if (format === 'HTML' && !HTML_FONTS.has(args.fonts)) {
printError(`Invalid HTML font output "${args.fonts}". Use assets or none.`)
process.exit(1)
}
if (isAppMode(args.file)) {
await exportViaApp(format, args)
} else {

View file

@ -29,6 +29,29 @@ export const DEFAULT_WEB_FONT_PROVIDER_SETTINGS: Record<WebFontProviderId, boole
export type WebFontFetch = (url: string, init?: RequestInit) => Promise<Response>
export interface WebFontFaceRequest {
family: string
weight: number
style?: 'normal' | 'italic'
}
export interface WebFontFaceAsset {
path: string
content: Uint8Array
}
export interface ExportWebFontFacesOptions {
fonts: WebFontFaceRequest[]
providers?: WebFontProviderId[]
assetBasePath?: string
fetcher?: WebFontFetch
}
export interface ExportWebFontFacesResult {
css: string
assets: WebFontFaceAsset[]
}
type WebFontProvider =
| ReturnType<typeof providers.google>
| ReturnType<typeof providers.fontsource>
@ -54,6 +77,123 @@ function isRemoteFontSource(
return 'url' in source
}
function fontAssetExtension(source: RemoteFontSource): string {
if (source.format === 'woff2') return 'woff2'
if (source.format === 'woff') return 'woff'
if (source.format === 'opentype' || source.format === 'otf') return 'otf'
return 'ttf'
}
function fontAssetFormat(source: RemoteFontSource): string {
if (source.format === 'woff2') return 'woff2'
if (source.format === 'woff') return 'woff'
if (source.format === 'opentype' || source.format === 'otf') return 'opentype'
return 'truetype'
}
function slugFontFamily(family: string): string {
let slug = ''
let previousDash = false
for (const char of family.toLowerCase()) {
const code = char.charCodeAt(0)
const isAlpha = code >= 97 && code <= 122
const isDigit = code >= 48 && code <= 57
if (isAlpha || isDigit) {
slug += char
previousDash = false
continue
}
if (slug.length > 0 && !previousDash) {
slug += '-'
previousDash = true
}
}
return slug.endsWith('-') ? slug.slice(0, -1) : slug
}
async function resolveRemoteFontSource(
family: string,
request: WebFontFaceRequest,
provider: WebFontProviderId
): Promise<{ source: RemoteFontSource; face: ResolveFontResult['fonts'][number] } | undefined> {
const unifont = await createProviderUnifont(provider)
const options = {
weights: [String(request.weight)],
styles: [request.style ?? 'normal'],
formats: ['woff2', 'woff', 'ttf'],
subsets: ['latin']
} satisfies WebFontResolveOptions
const result = await unifont.resolveFont(family, options)
for (const face of result.fonts.toSorted(
(a, b) => (a.meta?.priority ?? 0) - (b.meta?.priority ?? 0)
)) {
const source = face.src.find(isRemoteFontSource)
if (source) return { source, face }
}
return undefined
}
function serializeFontWeight(weight: string | number | [number, number]): string {
return Array.isArray(weight) ? weight.join(' ') : String(weight)
}
function fontFaceCSS(
family: string,
request: WebFontFaceRequest,
face: ResolveFontResult['fonts'][number],
source: RemoteFontSource,
path: string
): string {
const descriptors = [
`font-family:${JSON.stringify(family)}`,
`src:url("${path}") format("${fontAssetFormat(source)}")`,
`font-weight:${serializeFontWeight(face.weight ?? request.weight)}`,
`font-style:${face.style ?? request.style ?? 'normal'}`,
`font-display:${face.display ?? 'swap'}`
]
if (face.stretch) descriptors.push(`font-stretch:${face.stretch}`)
if (face.unicodeRange && face.unicodeRange.length > 0) {
descriptors.push(`unicode-range:${face.unicodeRange.join(',')}`)
}
return `@font-face{${descriptors.join(';')}}`
}
export async function exportWebFontFaces({
fonts,
providers = WEB_FONT_PROVIDER_IDS.slice(),
assetBasePath = 'assets/fonts',
fetcher = fetch
}: ExportWebFontFacesOptions): Promise<ExportWebFontFacesResult> {
const assets: WebFontFaceAsset[] = []
const css: string[] = []
const seen = new Set<string>()
for (const request of fonts) {
const family = request.family
const key = `${family}|${request.weight}|${request.style ?? 'normal'}`
if (seen.has(key)) continue
seen.add(key)
for (const provider of providers) {
try {
const resolved = await resolveRemoteFontSource(family, request, provider)
if (!resolved) continue
const response = await fetcher(resolved.source.url, resolved.face.meta?.init)
if (!response.ok) continue
const extension = fontAssetExtension(resolved.source)
const path = `${assetBasePath}/${slugFontFamily(family)}-${request.weight}-${request.style ?? 'normal'}.${extension}`
assets.push({ path, content: new Uint8Array(await response.arrayBuffer()) })
css.push(fontFaceCSS(family, request, resolved.face, resolved.source, path))
break
} catch (error) {
console.warn(`Failed to export ${family} from ${provider} fonts`, error)
}
}
}
return { css: css.join(''), assets }
}
export class WebFontResolver {
private enabled = new Set<WebFontProviderId>(
WEB_FONT_PROVIDER_IDS.filter((provider) => DEFAULT_WEB_FONT_PROVIDER_SETTINGS[provider])

View file

@ -61,9 +61,10 @@ Use `--html standalone` for a browser-openable HTML document with reset styles a
```sh
openpencil export design.fig -f html --html standalone --css inline
openpencil export design.fig -f html --html standalone --css tailwind
openpencil export design.fig -f html --html standalone --css tailwind --assets external
```
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.
Standalone Tailwind output is compiled during export; it does not depend on the Tailwind browser runtime. Use `--assets external` to write CSS and extracted image assets next to the HTML file. Use `--fonts assets` with external assets to resolve detected SceneGraph text fonts through OpenPencil's configured web-font providers and emit local `@font-face` files.
HTML export is available in file mode.

View file

@ -107,6 +107,8 @@ openpencil export [file] [options]
| `--style` | | JSX style: `openpencil` (default), `tailwind` |
| `--html` | | HTML mode: `fragment` (default), `standalone` |
| `--css` | | HTML CSS output: `inline` (default), `tailwind` |
| `--assets` | | Standalone HTML assets: `inline` (default), `external` |
| `--fonts` | | Standalone HTML font output: `assets`, `none` (default) |
| `--thumbnail` | | Export page thumbnail instead of full render |
| `--width` | | Thumbnail width (default: 1920) |
| `--height` | | Thumbnail height (default: 1080) |

View file

@ -205,7 +205,7 @@ function styleFromTextNode(node: SceneNode): DesignStyleDeclaration {
}
const textTransform = textCaseToCSS(node.textCase)
if (textTransform) style['text-transform'] = textTransform
if (node.maxLines === 1) style['white-space'] = 'nowrap'
style['white-space'] = node.maxLines === 1 ? 'nowrap' : 'pre-wrap'
return style
}

View file

@ -0,0 +1,343 @@
import { parseFragment, serialize, type DefaultTreeAdapterTypes } from 'parse5'
import {
exportWebFontFaces,
normalizeFontFamily,
type WebFontFaceRequest
} from '@open-pencil/core/text'
import { mergeClassNames, serializeHTML, splitWhitespace } from './serialize'
import type { DesignDocument, DesignElement, DesignNode, DesignStyleDeclaration } from './types'
export interface ExportHTMLBundleOptions {
html?: 'fragment' | 'standalone'
style?: 'inline' | 'tailwind'
assets?: 'inline' | 'external'
fonts?: 'assets' | 'none'
assetBasePath?: string
}
export interface ExportHTMLFile {
path: string
content: string | Uint8Array
}
export interface ExportHTMLBundle {
entrypoint: string
files: ExportHTMLFile[]
}
interface StandaloneBounds {
minX: number
minY: number
width: number
height: number
}
type ParseNode = DefaultTreeAdapterTypes.Node
type ParseParent = DefaultTreeAdapterTypes.ParentNode
type ParseElement = DefaultTreeAdapterTypes.Element
const RESET_CSS =
'*,*::before,*::after{box-sizing:border-box}html,body{margin:0;padding:0}body{font-family:system-ui,sans-serif;background:#fff}'
function styleToCSS(style: DesignStyleDeclaration): string {
return Object.entries(style)
.filter(([, value]) => value !== '')
.map(([property, value]) => `${property}: ${value}`)
.join('; ')
}
function cloneNode(node: DesignNode): DesignNode {
if (node.type === 'text') return { ...node }
return {
...node,
attrs: { ...node.attrs },
inlineStyle: node.inlineStyle ? { ...node.inlineStyle } : undefined,
children: node.children.map(cloneNode)
}
}
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 cloneNode(node)
const standalone: DesignElement = {
...node,
attrs: { ...node.attrs },
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 standaloneDocument(document: DesignDocument, size: StandaloneBounds): DesignDocument {
return {
...document,
children: document.children.map((node) => standaloneNode(node, size))
}
}
function cssClassName(index: number): string {
return `op-${index.toString(36)}`
}
function extractInlineStyles(
node: DesignNode,
rules: string[],
nextIndex: { value: number }
): DesignNode {
if (node.type === 'text') return node
const children = node.children.map((child) => extractInlineStyles(child, rules, nextIndex))
if (!node.inlineStyle || Object.keys(node.inlineStyle).length === 0) return { ...node, children }
const className = cssClassName(nextIndex.value)
nextIndex.value += 1
rules.push(`.${className}{${styleToCSS(node.inlineStyle)}}`)
return {
...node,
attrs: { ...node.attrs, class: mergeClassNames(node.attrs.class, className) ?? className },
inlineStyle: undefined,
children
}
}
function isElement(node: ParseNode): node is ParseElement {
return 'attrs' in node && 'tagName' in node
}
function walkParseTree(node: ParseNode | ParseParent, visit: (node: ParseElement) => void): void {
if (isElement(node)) visit(node)
if ('childNodes' in node) {
for (const child of node.childNodes) walkParseTree(child, visit)
}
}
function classNamesFromHTML(html: string): string[] {
const fragment = parseFragment(html)
const classes = new Set<string>()
walkParseTree(fragment, (element) => {
const classAttr = element.attrs.find((attr) => attr.name === 'class')
if (!classAttr) return
for (const className of splitWhitespace(classAttr.value)) classes.add(className)
})
return [...classes]
}
async function compileTailwindClasses(classNames: string[]): Promise<string> {
if (classNames.length === 0) return ''
const [{ compile }, { readFile }] = await Promise.all([
import('tailwindcss'),
import('node:fs/promises')
])
const [themeCSS, utilitiesCSS] = await Promise.all([
readFile(new URL(import.meta.resolve('tailwindcss/theme.css')), 'utf8'),
readFile(new URL(import.meta.resolve('tailwindcss/utilities.css')), 'utf8')
])
const compiler = await compile(`${themeCSS}\n${utilitiesCSS}`)
return compiler.build(classNames)
}
function stripFontFamilyQuotes(value: string): string {
if (value.length < 2) return value
const first = value[0]
const last = value[value.length - 1]
if ((first === '"' && last === '"') || (first === "'" && last === "'")) return value.slice(1, -1)
return value
}
function firstFontFamily(value: string): string {
const commaIndex = value.indexOf(',')
const raw = commaIndex !== -1 ? value.slice(0, commaIndex) : value
return stripFontFamilyQuotes(raw.trim())
}
function collectFontRequests(node: DesignNode, fonts: Map<string, WebFontFaceRequest>): void {
if (node.type === 'text') return
const source = node.sourceSceneNode
if (source?.type === 'TEXT') {
const family = normalizeFontFamily(firstFontFamily(source.fontFamily))
const style = source.italic ? 'italic' : 'normal'
const key = `${family}|${source.fontWeight}|${style}`
fonts.set(key, { family, weight: source.fontWeight, style })
}
for (const child of node.children) collectFontRequests(child, fonts)
}
async function fontFaceAssets(
document: DesignDocument,
options: Required<ExportHTMLBundleOptions>
): Promise<{ css: string; files: ExportHTMLFile[] }> {
if (options.fonts === 'none' || options.assets !== 'external') return { css: '', files: [] }
const requests = new Map<string, WebFontFaceRequest>()
for (const child of document.children) collectFontRequests(child, requests)
const result = await exportWebFontFaces({
fonts: [...requests.values()],
assetBasePath: `${options.assetBasePath}/fonts`
})
return { css: result.css, files: result.assets }
}
function dataImageParts(value: string): { mime: string; base64: string } | undefined {
if (!value.startsWith('data:image/')) return undefined
const marker = ';base64,'
const markerIndex = value.indexOf(marker)
if (markerIndex === -1) return undefined
return {
mime: value.slice('data:'.length, markerIndex),
base64: value.slice(markerIndex + marker.length)
}
}
function bytesFromBase64(value: string): Uint8Array {
return Uint8Array.from(atob(value), (char) => char.charCodeAt(0))
}
function extensionForMime(mime: string): string {
if (mime === 'image/jpeg') return 'jpg'
if (mime === 'image/webp') return 'webp'
if (mime === 'image/gif') return 'gif'
if (mime === 'image/svg+xml') return 'svg'
return 'png'
}
function extractImageAssets(
html: string,
assetBasePath: string
): { html: string; files: ExportHTMLFile[] } {
const fragment = parseFragment(html)
const files: ExportHTMLFile[] = []
const sources = new Map<string, string>()
walkParseTree(fragment, (element) => {
const src = element.attrs.find((attr) => attr.name === 'src')
if (!src) return
const parts = dataImageParts(src.value)
if (!parts) return
const cachedPath = sources.get(src.value)
if (cachedPath) {
src.value = cachedPath
return
}
const path = `${assetBasePath}/images/image-${sources.size + 1}.${extensionForMime(parts.mime)}`
sources.set(src.value, path)
files.push({ path, content: bytesFromBase64(parts.base64) })
src.value = path
})
return { html: serialize(fragment), files }
}
function stylesheetLink(path: string): string {
return `<link rel="stylesheet" href="${path}">`
}
function styleTag(css: string): string {
return css ? `<style>${css}</style>` : ''
}
async function exportStandaloneHTML(
document: DesignDocument,
options: Required<ExportHTMLBundleOptions>
): Promise<ExportHTMLBundle> {
const size = standaloneSize(document)
const doc = standaloneDocument(document, size)
const stageCSS = `.op-stage{position:relative;width:${size.width}px;height:${size.height}px;overflow:hidden;background:transparent}`
let body: string
let css = `${RESET_CSS}${stageCSS}`
if (options.style === 'tailwind') {
body = serializeHTML(doc, { style: 'tailwind' })
css += await compileTailwindClasses(classNamesFromHTML(body))
} else {
const rules: string[] = []
const index = { value: 0 }
const styledDocument: DesignDocument = {
...doc,
children: doc.children.map((node) => extractInlineStyles(node, rules, index))
}
body = serializeHTML(styledDocument)
css += rules.join('')
}
if (options.assets === 'external') {
const [extracted, fonts] = await Promise.all([
Promise.resolve(extractImageAssets(body, options.assetBasePath)),
fontFaceAssets(document, options)
])
body = extracted.html
const cssPath = `${options.assetBasePath}/openpencil.css`
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${stylesheetLink(cssPath)}</head><body><main data-open-pencil-html="standalone" class="op-stage">${body}</main></body></html>`
return {
entrypoint: 'index.html',
files: [
{ path: 'index.html', content: html },
{ path: cssPath, content: `${fonts.css}${css}` },
...fonts.files,
...extracted.files
]
}
}
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${styleTag(css)}</head><body><main data-open-pencil-html="standalone" class="op-stage">${body}</main></body></html>`
return { entrypoint: 'index.html', files: [{ path: 'index.html', content: html }] }
}
export async function exportHTMLBundle(
document: DesignDocument,
options: ExportHTMLBundleOptions = {}
): Promise<ExportHTMLBundle> {
const resolvedOptions: Required<ExportHTMLBundleOptions> = {
html: options.html ?? 'fragment',
style: options.style ?? 'inline',
assets: options.assets ?? 'inline',
fonts: options.fonts ?? 'none',
assetBasePath: options.assetBasePath ?? 'assets'
}
if (resolvedOptions.html === 'standalone') return exportStandaloneHTML(document, resolvedOptions)
return {
entrypoint: 'index.html',
files: [
{ path: 'index.html', content: serializeHTML(document, { style: resolvedOptions.style }) }
]
}
}

View file

@ -1,5 +1,6 @@
import type * as DesignTypes from './types'
export { exportHTMLBundle } from './html-export'
export { serializeHTML, serializeNode } from './serialize'
export { createBrowserCSSRuntime, createCSSRuntime, createHeadlessCSSRuntime } from './runtime'
export {
@ -61,6 +62,7 @@ export type {
BrowserToSceneGraphOptions
} from './browser'
export type { CompileTailwindCSSOptions } from './tailwind'
export type { ExportHTMLBundle, ExportHTMLBundleOptions, ExportHTMLFile } from './html-export'
export type { SerializeHTMLOptions } from './serialize'
export type { ToSceneGraphOptions } from './to-scene-graph'
export type CSSComputeOptions = DesignTypes.CSSComputeOptions

View file

@ -1,20 +1,11 @@
import { twirl } from 'twirlwind'
import type {
DesignDocument,
DesignElement,
DesignNode,
DesignStyleDeclaration,
DesignText
} from './types'
import type { DesignDocument, DesignElement, DesignNode, 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',
@ -32,6 +23,21 @@ const VOID_ELEMENTS = new Set([
'wbr'
])
export function splitWhitespace(value: string): string[] {
const parts: string[] = []
let current = ''
for (const char of value) {
if (char === ' ' || char === '\n' || char === '\t' || char === '\r' || char === '\f') {
if (current.length > 0) parts.push(current)
current = ''
} else {
current += char
}
}
if (current.length > 0) parts.push(current)
return parts
}
function escapeText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}
@ -59,9 +65,9 @@ function serializeTailwindClasses(node: DesignElement): string | undefined {
return className.length > 0 ? className : undefined
}
function mergeClassNames(...values: Array<string | undefined>): string | undefined {
export function mergeClassNames(...values: Array<string | undefined>): string | undefined {
const className = values
.flatMap((value) => value?.split(/\s+/) ?? [])
.flatMap((value) => (value ? splitWhitespace(value) : []))
.map((value) => value.trim())
.filter((value) => value.length > 0)
.join(' ')
@ -94,77 +100,6 @@ 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)
}
@ -173,6 +108,5 @@ 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

@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { DesignElement } from '../src/index'
import type { DesignElement, DesignNode } from '../src/index'
import {
createHeadlessCSSRuntime,
designDocumentToSceneGraph,
@ -28,6 +28,16 @@ function expectText(node: SceneNode | undefined) {
return node
}
function findTextElement(nodes: DesignNode[]): DesignElement | undefined {
for (const node of nodes) {
if (node.type !== 'element') continue
if (node.children.some((child) => child.type === 'text')) return node
const child = findTextElement(node.children)
if (child) return child
}
return undefined
}
function createStyleRoundTripGraph() {
return designDocumentToSceneGraph({
type: 'document',
@ -244,6 +254,25 @@ describe('@open-pencil/dom-css conversion', () => {
expect(html).toContain('box-shadow')
})
it('exports multiline text with preserved whitespace', () => {
const graph = designDocumentToSceneGraph({
type: 'document',
children: [
{
type: 'element',
tagName: 'p',
attrs: {},
inlineStyle: { width: '160px' },
children: [{ type: 'text', text: 'Open\nPencil' }]
}
]
})
const document = sceneGraphToDesignDocument(graph)
const text = findTextElement(document.children)
expect(text?.inlineStyle?.['white-space']).toBe('pre-wrap')
})
it('projects a manually built DesignDOM document into a scene graph', async () => {
const runtime = createHeadlessCSSRuntime()
const document = await runtime.computeStyles(runtime.parseHTML(cardHTML), cardCSS)

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from 'bun:test'
import { createCSSRuntime, createHeadlessCSSRuntime, serializeHTML } from '../src/index'
import {
createCSSRuntime,
createHeadlessCSSRuntime,
exportHTMLBundle,
serializeHTML
} from '../src/index'
import { cardDocument, TEST_COLORS } from './helpers'
describe('@open-pencil/dom-css runtime', () => {
@ -34,8 +39,9 @@ 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' })
it('exports standalone HTML documents when requested', async () => {
const bundle = await exportHTMLBundle(cardDocument, { html: 'standalone' })
const html = String(bundle.files[0]?.content)
expect(html).toContain('<!doctype html>')
expect(html).toContain('data-open-pencil-html="standalone"')
@ -43,12 +49,12 @@ describe('@open-pencil/dom-css runtime', () => {
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' })
it('precompiles Tailwind CSS for standalone Tailwind HTML', async () => {
const bundle = await exportHTMLBundle(cardDocument, { html: 'standalone', style: 'tailwind' })
const html = String(bundle.files[0]?.content)
expect(html).toContain(
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>'
)
expect(html).toContain('<style>')
expect(html).not.toContain('@tailwindcss/browser@4')
})
it('uses the headless runtime outside browser contexts', () => {

View file

@ -104,12 +104,12 @@ test('export CLI can write standalone HTML', async () => {
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: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 () => {
test('export CLI precompiles Tailwind CSS for standalone Tailwind HTML', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'card-standalone-tailwind.html')
@ -131,8 +131,39 @@ test('export CLI includes Tailwind browser runtime for standalone Tailwind HTML'
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('<style>')
expect(html).toContain('class="')
expect(html).toContain('.flex')
expect(html).not.toContain('@tailwindcss/browser@4')
})
test('export CLI can write external standalone HTML assets', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'card-external.html')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'html',
'--html',
'standalone',
'--css',
'tailwind',
'--assets',
'external',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
const html = await Bun.file(output).text()
const cssPath = join(dir, 'card-external.assets', 'openpencil.css')
const css = await Bun.file(cssPath).text()
expect(html).toContain('<link rel="stylesheet" href="card-external.assets/openpencil.css">')
expect(html).not.toContain('<style>')
expect(css).toContain('.flex')
expect(css).toContain('.op-stage')
})

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test'
import type { DesignElement } from '@open-pencil/dom-css'
import type { DesignElement, DesignNode } from '@open-pencil/dom-css'
import {
compileTailwindCSS,
createHeadlessCSSRuntime,
@ -32,6 +32,16 @@ function expectFrame(node: SceneNode | undefined) {
return node
}
function findTextElement(nodes: DesignNode[]): DesignElement | undefined {
for (const node of nodes) {
if (node.type !== 'element') continue
if (node.children.some((child) => child.type === 'text')) return node
const child = findTextElement(node.children)
if (child) return child
}
return undefined
}
function createStyleRoundTripGraph() {
return designDocumentToSceneGraph({
type: 'document',
@ -238,6 +248,25 @@ describe('@open-pencil/dom-css conversion', () => {
expect(html).toContain('box-shadow')
})
it('exports multiline text with preserved whitespace', () => {
const graph = designDocumentToSceneGraph({
type: 'document',
children: [
{
type: 'element',
tagName: 'p',
attrs: {},
inlineStyle: { width: '160px' },
children: [{ type: 'text', text: 'Open\nPencil' }]
}
]
})
const document = sceneGraphToDesignDocument(graph)
const text = findTextElement(document.children)
expect(text?.inlineStyle?.['white-space']).toBe('pre-wrap')
})
it('maps CSS shape constraints, clipping, corners, and borders', () => {
const graph = designDocumentToSceneGraph({
type: 'document',

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from 'bun:test'
import { createCSSRuntime, createHeadlessCSSRuntime, serializeHTML } from '@open-pencil/dom-css'
import {
createCSSRuntime,
createHeadlessCSSRuntime,
exportHTMLBundle,
serializeHTML
} from '@open-pencil/dom-css'
import { DOM_CSS_COLORS, simpleCardDocument } from '#tests/helpers/dom-css'
@ -36,8 +41,9 @@ 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' })
it('exports standalone HTML documents when requested', async () => {
const bundle = await exportHTMLBundle(simpleCardDocument, { html: 'standalone' })
const html = String(bundle.files[0]?.content)
expect(html).toContain('<!doctype html>')
expect(html).toContain('data-open-pencil-html="standalone"')
@ -45,12 +51,15 @@ describe('@open-pencil/dom-css', () => {
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' })
it('precompiles Tailwind CSS for standalone Tailwind HTML', async () => {
const bundle = await exportHTMLBundle(simpleCardDocument, {
html: 'standalone',
style: 'tailwind'
})
const html = String(bundle.files[0]?.content)
expect(html).toContain(
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>'
)
expect(html).toContain('<style>')
expect(html).not.toContain('@tailwindcss/browser@4')
})
it('uses the headless runtime outside browser contexts', () => {