From 7ac3ee035d94debee5b558ab43641a707a543b40 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 4 Jul 2026 19:37:58 +0300 Subject: [PATCH] fix(dom-css): compile standalone HTML exports --- CHANGELOG.md | 2 +- README.md | 1 + packages/cli/src/commands/export.ts | 67 +++- packages/core/src/text/web-fonts.ts | 140 ++++++++ packages/docs/programmable/cli/exporting.md | 3 +- packages/docs/reference/cli.md | 2 + packages/dom-css/src/from-scene-graph.ts | 2 +- packages/dom-css/src/html-export.ts | 343 ++++++++++++++++++++ packages/dom-css/src/index.ts | 2 + packages/dom-css/src/serialize.ts | 102 +----- packages/dom-css/tests/conversion.test.ts | 31 +- packages/dom-css/tests/runtime.test.ts | 22 +- tests/engine/cli/export.test.ts | 41 ++- tests/engine/dom-css/conversion.test.ts | 31 +- tests/engine/dom-css/runtime.test.ts | 25 +- 15 files changed, 695 insertions(+), 119 deletions(-) create mode 100644 packages/dom-css/src/html-export.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a4e51ef10..f63e45d49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index d3a9f6f0f..27562dc8c 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 389d53a24..af595ece2 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -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> +) { + 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>, @@ -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 { diff --git a/packages/core/src/text/web-fonts.ts b/packages/core/src/text/web-fonts.ts index a61961613..9aec1d961 100644 --- a/packages/core/src/text/web-fonts.ts +++ b/packages/core/src/text/web-fonts.ts @@ -29,6 +29,29 @@ export const DEFAULT_WEB_FONT_PROVIDER_SETTINGS: Record Promise +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 | ReturnType @@ -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 { + const assets: WebFontFaceAsset[] = [] + const css: string[] = [] + const seen = new Set() + + 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( WEB_FONT_PROVIDER_IDS.filter((provider) => DEFAULT_WEB_FONT_PROVIDER_SETTINGS[provider]) diff --git a/packages/docs/programmable/cli/exporting.md b/packages/docs/programmable/cli/exporting.md index e0f7b3443..3f259f153 100644 --- a/packages/docs/programmable/cli/exporting.md +++ b/packages/docs/programmable/cli/exporting.md @@ -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. diff --git a/packages/docs/reference/cli.md b/packages/docs/reference/cli.md index ed48254e8..a9dde4318 100644 --- a/packages/docs/reference/cli.md +++ b/packages/docs/reference/cli.md @@ -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) | diff --git a/packages/dom-css/src/from-scene-graph.ts b/packages/dom-css/src/from-scene-graph.ts index 5a8eef4d4..b85ea2f6f 100644 --- a/packages/dom-css/src/from-scene-graph.ts +++ b/packages/dom-css/src/from-scene-graph.ts @@ -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 } diff --git a/packages/dom-css/src/html-export.ts b/packages/dom-css/src/html-export.ts new file mode 100644 index 000000000..7505e1da7 --- /dev/null +++ b/packages/dom-css/src/html-export.ts @@ -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 => 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() + 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 { + 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): 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 +): Promise<{ css: string; files: ExportHTMLFile[] }> { + if (options.fonts === 'none' || options.assets !== 'external') return { css: '', files: [] } + const requests = new Map() + 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() + 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 `` +} + +function styleTag(css: string): string { + return css ? `` : '' +} + +async function exportStandaloneHTML( + document: DesignDocument, + options: Required +): Promise { + 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 = `${stylesheetLink(cssPath)}
${body}
` + return { + entrypoint: 'index.html', + files: [ + { path: 'index.html', content: html }, + { path: cssPath, content: `${fonts.css}${css}` }, + ...fonts.files, + ...extracted.files + ] + } + } + + const html = `${styleTag(css)}
${body}
` + return { entrypoint: 'index.html', files: [{ path: 'index.html', content: html }] } +} + +export async function exportHTMLBundle( + document: DesignDocument, + options: ExportHTMLBundleOptions = {} +): Promise { + const resolvedOptions: Required = { + 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 }) } + ] + } +} diff --git a/packages/dom-css/src/index.ts b/packages/dom-css/src/index.ts index f64fd5a9e..296ee823d 100644 --- a/packages/dom-css/src/index.ts +++ b/packages/dom-css/src/index.ts @@ -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 diff --git a/packages/dom-css/src/serialize.ts b/packages/dom-css/src/serialize.ts index 5455003ef..daf0c1213 100644 --- a/packages/dom-css/src/serialize.ts +++ b/packages/dom-css/src/serialize.ts @@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>') } @@ -59,9 +65,9 @@ function serializeTailwindClasses(node: DesignElement): string | undefined { return className.length > 0 ? className : undefined } -function mergeClassNames(...values: Array): string | undefined { +export function mergeClassNames(...values: Array): 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('')}` } -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) } @@ -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('') } diff --git a/packages/dom-css/tests/conversion.test.ts b/packages/dom-css/tests/conversion.test.ts index d3d4cacf3..f4c3b3bb7 100644 --- a/packages/dom-css/tests/conversion.test.ts +++ b/packages/dom-css/tests/conversion.test.ts @@ -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) diff --git a/packages/dom-css/tests/runtime.test.ts b/packages/dom-css/tests/runtime.test.ts index 3e767dbe3..1d66e0f37 100644 --- a/packages/dom-css/tests/runtime.test.ts +++ b/packages/dom-css/tests/runtime.test.ts @@ -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('
OpenPencil
') }) - 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('') 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( - '' - ) + expect(html).toContain('