Revert "feat(cli): bundle standalone HTML exports"

This reverts commit 8330dda410.
This commit is contained in:
Danila Poyarkov 2026-07-04 18:18:32 +03:00
parent 8330dda410
commit b22a751f27
11 changed files with 113 additions and 509 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 precompiled CSS, font links, and optional external asset bundles.
- Add standalone browser-openable HTML export with inline CSS or Tailwind browser runtime previews.
- Add richer Design JSX authoring for components, variables, structured fills, gradients, shadows, and blur effects.
- Add overlap analysis for finding layout collisions and overflowing children from the CLI, AI tools, and MCP.
- Add saved per-node export settings for repeat exports.

View file

@ -80,7 +80,6 @@ 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 --bundle 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 { mkdir, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, join, resolve } from 'node:path'
import { writeFile } from 'node:fs/promises'
import { basename, extname, 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 {
bundleHTML,
sceneGraphToDesignDocument,
type HTMLBundleOptions
serializeHTML,
type SerializeHTMLOptions
} from '@open-pencil/dom-css'
import { isAppMode, requireFile, rpc } from '#cli/app-client'
@ -22,8 +22,6 @@ 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_BUNDLES = new Set(['inline', 'external'])
const HTML_FONTS = new Set(['link', 'none'])
interface ExportArgs {
file?: string
@ -36,8 +34,6 @@ interface ExportArgs {
style: string
html: string
css: string
bundle: string
fonts: string
thumbnail?: boolean
width: string
height: string
@ -109,24 +105,6 @@ function targetLabel(pageName?: string, nodeId?: string): string {
type FileExportTarget = { scope: 'node'; nodeId: string } | { scope: 'page'; pageId: string }
async function writeBundle(output: string, bundle: Awaited<ReturnType<typeof bundleHTML>>) {
const entrypoint = bundle.files.find((file) => file.path === bundle.entrypoint)
if (!entrypoint) {
printError(`HTML bundle 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>>,
@ -136,16 +114,12 @@ async function exportHTMLFromFile(
const document = sceneGraphToDesignDocument(graph, {
rootId: target.scope === 'page' ? target.pageId : target.nodeId
})
const output = resolve(args.output ?? exportFileName(defaultName, 'html'))
const assetBasePath = `${basename(output, extname(output))}.assets`
const bundle = await bundleHTML(document, {
html: args.html as HTMLBundleOptions['html'],
style: args.css as HTMLBundleOptions['style'],
bundle: args.bundle as HTMLBundleOptions['bundle'],
fonts: args.fonts as HTMLBundleOptions['fonts'],
assetBasePath
const html = serializeHTML(document, {
html: args.html as SerializeHTMLOptions['html'],
style: args.css as SerializeHTMLOptions['style']
})
await writeBundle(output, bundle)
const output = resolve(args.output ?? exportFileName(defaultName, 'html'))
await writeAndLog(output, html)
console.log(ok(`Target: ${targetLabel(args.page, args.node)}`))
}
@ -267,16 +241,6 @@ export default defineCommand({
description: 'HTML CSS output: inline or tailwind (default: inline)',
default: 'inline'
},
bundle: {
type: 'string',
description: 'HTML bundle strategy: inline or external (default: inline)',
default: 'inline'
},
fonts: {
type: 'string',
description: 'HTML font loading: link or none (default: link)',
default: 'link'
},
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' },
@ -306,16 +270,6 @@ export default defineCommand({
process.exit(1)
}
if (format === 'HTML' && !HTML_BUNDLES.has(args.bundle)) {
printError(`Invalid HTML bundle strategy "${args.bundle}". Use inline or external.`)
process.exit(1)
}
if (format === 'HTML' && !HTML_FONTS.has(args.fonts)) {
printError(`Invalid HTML font loading "${args.fonts}". Use link or none.`)
process.exit(1)
}
if (isAppMode(args.file)) {
await exportViaApp(format, args)
} else {

View file

@ -61,10 +61,9 @@ 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 --bundle external
```
Standalone Tailwind output is precompiled during export; it does not depend on the Tailwind browser runtime. Use `--bundle external` to write CSS and extracted image assets next to the HTML file. Font loading defaults to Google Fonts links for detected web fonts; use `--fonts none` to disable that.
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.

View file

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

View file

@ -1,321 +0,0 @@
import { mergeClassNames, serializeHTML } from './serialize'
import type { DesignDocument, DesignElement, DesignNode, DesignStyleDeclaration } from './types'
export interface HTMLBundleOptions {
html?: 'fragment' | 'standalone'
style?: 'inline' | 'tailwind'
bundle?: 'inline' | 'external'
fonts?: 'link' | 'none'
assetBasePath?: string
}
export interface HTMLBundleFile {
path: string
content: string | Uint8Array
}
export interface HTMLBundle {
entrypoint: string
files: HTMLBundleFile[]
}
interface StandaloneBounds {
minX: number
minY: number
width: number
height: number
}
const GENERIC_FONT_FAMILIES = new Set([
'serif',
'sans-serif',
'monospace',
'cursive',
'fantasy',
'system-ui',
'ui-serif',
'ui-sans-serif',
'ui-monospace',
'ui-rounded',
'emoji',
'math',
'fangsong'
])
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 normalizeFontFamily(value: string | undefined): string | undefined {
const family = value
?.split(',')[0]
?.trim()
.replace(/^['"]|['"]$/g, '')
if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return undefined
return family
}
function normalizeFontWeight(value: string | undefined): string {
if (!value || value === 'normal') return '400'
if (value === 'bold') return '700'
return /^\d+$/.test(value) ? value : '400'
}
function collectFontFamilies(node: DesignNode, fonts: Map<string, Set<string>>): void {
if (node.type === 'text') return
const family = normalizeFontFamily(node.inlineStyle?.['font-family'])
if (family) {
const weights = fonts.get(family) ?? new Set<string>()
weights.add(normalizeFontWeight(node.inlineStyle?.['font-weight']))
fonts.set(family, weights)
}
for (const child of node.children) collectFontFamilies(child, fonts)
}
function googleFontsURL(fonts: Map<string, Set<string>>): string | undefined {
if (fonts.size === 0) return undefined
const families = [...fonts]
.sort(([left], [right]) => left.localeCompare(right))
.map(([family, weights]) => {
const encodedFamily = encodeURIComponent(family).replaceAll('%20', '+')
const encodedWeights = [...weights]
.sort((left, right) => Number(left) - Number(right))
.join(';')
return `family=${encodedFamily}:wght@${encodedWeights}`
})
return `https://fonts.googleapis.com/css2?${families.join('&')}&display=swap`
}
function fontLinks(document: DesignDocument, fonts: HTMLBundleOptions['fonts']): string {
if (fonts === 'none') return ''
const fontMap = new Map<string, Set<string>>()
for (const child of document.children) collectFontFamilies(child, fontMap)
const href = googleFontsURL(fontMap)
if (!href) return ''
return `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="${href}">`
}
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 classNamesFromHTML(html: string): string[] {
const classes = new Set<string>()
for (const match of html.matchAll(/\sclass="([^"]*)"/g)) {
for (const className of match[1]?.split(/\s+/) ?? []) {
if (className) 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 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 extractImages(
html: string,
assetBasePath: string
): { html: string; files: HTMLBundleFile[] } {
const files: HTMLBundleFile[] = []
const sources = new Map<string, string>()
const nextHTML = html.replaceAll(
/src="data:(image\/[a-zA-Z0-9.+-]+);base64,([^"]+)"/g,
(source, mime: string, base64: string) => {
const existing = sources.get(source)
if (existing) return `src="${existing}"`
const path = `${assetBasePath}/images/image-${sources.size + 1}.${extensionForMime(mime)}`
sources.set(source, path)
files.push({ path, content: bytesFromBase64(base64) })
return `src="${path}"`
}
)
return { html: nextHTML, files }
}
function stylesheetLink(path: string): string {
return `<link rel="stylesheet" href="${path}">`
}
function styleTag(css: string): string {
return css ? `<style>${css}</style>` : ''
}
async function bundleStandaloneHTML(
document: DesignDocument,
options: Required<HTMLBundleOptions>
): Promise<HTMLBundle> {
const size = standaloneSize(document)
const stageCSS = `.op-stage{position:relative;width:${size.width}px;height:${size.height}px;overflow:hidden;background:transparent}`
const doc = standaloneDocument(document, size)
const files: HTMLBundleFile[] = []
let css = `${RESET_CSS}${stageCSS}`
let body = ''
if (options.style === 'tailwind') {
body = serializeHTML(doc, { style: 'tailwind' })
css += await compileTailwindClasses(classNamesFromHTML(body))
} else {
const rules: string[] = []
const styledDoc: DesignDocument = {
...doc,
children: doc.children.map((node) =>
extractInlineStyles(node, rules, { value: rules.length })
)
}
body = serializeHTML(styledDoc)
css += rules.join('')
}
if (options.bundle === 'external') {
const extracted = extractImages(body, options.assetBasePath)
body = extracted.html
files.push(...extracted.files)
const cssPath = `${options.assetBasePath}/openpencil.css`
files.push({ path: cssPath, content: css })
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${fontLinks(document, options.fonts)}${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 }, ...files] }
}
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${fontLinks(document, options.fonts)}${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 bundleHTML(
document: DesignDocument,
options: HTMLBundleOptions = {}
): Promise<HTMLBundle> {
const resolvedOptions: Required<HTMLBundleOptions> = {
html: options.html ?? 'fragment',
style: options.style ?? 'inline',
bundle: options.bundle ?? 'inline',
fonts: options.fonts ?? 'link',
assetBasePath: options.assetBasePath ?? 'assets'
}
if (resolvedOptions.html === 'standalone') return bundleStandaloneHTML(document, resolvedOptions)
return {
entrypoint: 'index.html',
files: [
{ path: 'index.html', content: serializeHTML(document, { style: resolvedOptions.style }) }
]
}
}

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test'
import { bundleHTML, createCSSRuntime, createHeadlessCSSRuntime, serializeHTML } from '../src/index'
import { createCSSRuntime, createHeadlessCSSRuntime, serializeHTML } from '../src/index'
import { cardDocument, TEST_COLORS } from './helpers'
describe('@open-pencil/dom-css runtime', () => {
@ -34,9 +34,8 @@ describe('@open-pencil/dom-css runtime', () => {
expect(html).toBe('<section class="card flex p-4 gap-2 bg-white">OpenPencil</section>')
})
it('bundles standalone HTML documents when requested', async () => {
const bundle = await bundleHTML(cardDocument, { html: 'standalone' })
const html = String(bundle.files[0]?.content)
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"')
@ -44,34 +43,12 @@ describe('@open-pencil/dom-css runtime', () => {
expect(html).not.toContain('@tailwindcss/browser@4')
})
it('precompiles Tailwind CSS for standalone Tailwind HTML', async () => {
const bundle = await bundleHTML(cardDocument, { html: 'standalone', style: 'tailwind' })
const html = String(bundle.files[0]?.content)
it('loads the Tailwind browser runtime for standalone Tailwind HTML', () => {
const html = serializeHTML(cardDocument, { html: 'standalone', style: 'tailwind' })
expect(html).toContain('<style>')
expect(html).not.toContain('@tailwindcss/browser@4')
})
it('loads detected web fonts for standalone HTML bundles', async () => {
const bundle = await bundleHTML(
{
type: 'document',
children: [
{
type: 'element',
tagName: 'span',
attrs: {},
inlineStyle: { 'font-family': 'Roboto, sans-serif', 'font-weight': '500' },
children: [{ type: 'text', text: 'OpenPencil' }]
}
]
},
{ html: 'standalone', style: 'tailwind' }
expect(html).toContain(
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>'
)
const html = String(bundle.files[0]?.content)
expect(html).toContain('https://fonts.googleapis.com/css2?family=Roboto:wght@500&display=swap')
expect(html).toContain('font-family: Roboto, sans-serif')
})
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 precompiles Tailwind CSS for standalone Tailwind HTML', async () => {
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')
@ -131,39 +131,8 @@ test('export CLI precompiles Tailwind CSS for standalone Tailwind HTML', async (
const html = await Bun.file(output).text()
expect(html).toContain('<!doctype html>')
expect(html).toContain('<style>')
expect(html).toContain(
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>'
)
expect(html).toContain('class="')
expect(html).toContain('.flex')
expect(html).not.toContain('@tailwindcss/browser@4')
})
test('export CLI can write external standalone HTML bundles', 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',
'--bundle',
'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,11 +1,6 @@
import { describe, expect, it } from 'bun:test'
import {
bundleHTML,
createCSSRuntime,
createHeadlessCSSRuntime,
serializeHTML
} from '@open-pencil/dom-css'
import { createCSSRuntime, createHeadlessCSSRuntime, serializeHTML } from '@open-pencil/dom-css'
import { DOM_CSS_COLORS, simpleCardDocument } from '#tests/helpers/dom-css'
@ -41,9 +36,8 @@ describe('@open-pencil/dom-css', () => {
expect(html).toBe('<section class="card flex p-4 gap-2 bg-white">OpenPencil</section>')
})
it('bundles standalone HTML documents when requested', async () => {
const bundle = await bundleHTML(simpleCardDocument, { html: 'standalone' })
const html = String(bundle.files[0]?.content)
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"')
@ -51,56 +45,12 @@ describe('@open-pencil/dom-css', () => {
expect(html).not.toContain('@tailwindcss/browser@4')
})
it('precompiles Tailwind CSS for standalone Tailwind HTML', async () => {
const bundle = await bundleHTML(simpleCardDocument, { html: 'standalone', style: 'tailwind' })
const html = String(bundle.files[0]?.content)
it('loads the Tailwind browser runtime for standalone Tailwind HTML', () => {
const html = serializeHTML(simpleCardDocument, { html: 'standalone', style: 'tailwind' })
expect(html).toContain('<style>')
expect(html).not.toContain('@tailwindcss/browser@4')
})
it('loads detected web fonts for standalone HTML bundles', async () => {
const bundle = await bundleHTML(
{
type: 'document',
children: [
{
type: 'element',
tagName: 'span',
attrs: {},
inlineStyle: { 'font-family': 'Roboto, sans-serif', 'font-weight': '500' },
children: [{ type: 'text', text: 'OpenPencil' }]
}
]
},
{ html: 'standalone', style: 'tailwind' }
expect(html).toContain(
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>'
)
const html = String(bundle.files[0]?.content)
expect(html).toContain('https://fonts.googleapis.com/css2?family=Roboto:wght@500&display=swap')
expect(html).toContain('font-family: Roboto, sans-serif')
})
it('extracts data URI images for external standalone HTML bundles', async () => {
const bundle = await bundleHTML(
{
type: 'document',
children: [
{
type: 'element',
tagName: 'img',
attrs: { src: 'data:image/png;base64,AQID' },
children: []
}
]
},
{ html: 'standalone', bundle: 'external', assetBasePath: 'card.assets' }
)
const html = String(bundle.files.find((file) => file.path === 'index.html')?.content)
const image = bundle.files.find((file) => file.path === 'card.assets/images/image-1.png')
expect(html).toContain('src="card.assets/images/image-1.png"')
expect(image?.content).toEqual(new Uint8Array([1, 2, 3]))
})
it('uses the headless runtime outside browser contexts', () => {