diff --git a/CHANGELOG.md b/CHANGELOG.md index a4e51ef10..b46c86650 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 precompiled CSS, font links, and optional external asset bundles. - 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..ddb3be5ab 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 --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 ``` diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 389d53a24..63c0ff136 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 { + bundleHTML, sceneGraphToDesignDocument, - serializeHTML, - type SerializeHTMLOptions + type HTMLBundleOptions } 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_BUNDLES = new Set(['inline', 'external']) +const HTML_FONTS = new Set(['link', 'none']) interface ExportArgs { file?: string @@ -34,6 +36,8 @@ interface ExportArgs { style: string html: string css: string + bundle: string + fonts: string thumbnail?: boolean width: string height: string @@ -105,6 +109,24 @@ function targetLabel(pageName?: string, nodeId?: string): string { type FileExportTarget = { scope: 'node'; nodeId: string } | { scope: 'page'; pageId: string } +async function writeBundle(output: string, bundle: Awaited>) { + 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>, @@ -114,12 +136,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 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 + }) + await writeBundle(output, bundle) console.log(ok(`Target: ${targetLabel(args.page, args.node)}`)) } @@ -241,6 +267,16 @@ 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' }, @@ -270,6 +306,16 @@ 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 { diff --git a/packages/docs/programmable/cli/exporting.md b/packages/docs/programmable/cli/exporting.md index e0f7b3443..529fc5490 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 --bundle 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 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. HTML export is available in file mode. diff --git a/packages/docs/reference/cli.md b/packages/docs/reference/cli.md index ed48254e8..7d87c2d3d 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` | +| `--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) | diff --git a/packages/dom-css/src/bundle.ts b/packages/dom-css/src/bundle.ts new file mode 100644 index 000000000..91f7c6809 --- /dev/null +++ b/packages/dom-css/src/bundle.ts @@ -0,0 +1,321 @@ +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 => 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>): void { + if (node.type === 'text') return + const family = normalizeFontFamily(node.inlineStyle?.['font-family']) + if (family) { + const weights = fonts.get(family) ?? new Set() + 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 | 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>() + for (const child of document.children) collectFontFamilies(child, fontMap) + const href = googleFontsURL(fontMap) + if (!href) return '' + return `` +} + +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() + 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 { + 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() + 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 `` +} + +function styleTag(css: string): string { + return css ? `` : '' +} + +async function bundleStandaloneHTML( + document: DesignDocument, + options: Required +): Promise { + 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 = `${fontLinks(document, options.fonts)}${stylesheetLink(cssPath)}
${body}
` + return { entrypoint: 'index.html', files: [{ path: 'index.html', content: html }, ...files] } + } + + const html = `${fontLinks(document, options.fonts)}${styleTag(css)}
${body}
` + return { entrypoint: 'index.html', files: [{ path: 'index.html', content: html }] } +} + +export async function bundleHTML( + document: DesignDocument, + options: HTMLBundleOptions = {} +): Promise { + const resolvedOptions: Required = { + 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 }) } + ] + } +} diff --git a/packages/dom-css/src/index.ts b/packages/dom-css/src/index.ts index f64fd5a9e..4d289d5e9 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 { bundleHTML } from './bundle' 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 { HTMLBundle, HTMLBundleFile, HTMLBundleOptions } from './bundle' 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..f6f5adf03 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', @@ -59,7 +50,7 @@ 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+/) ?? []) .map((value) => value.trim()) @@ -94,77 +85,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 +93,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/runtime.test.ts b/packages/dom-css/tests/runtime.test.ts index 3e767dbe3..776cf63eb 100644 --- a/packages/dom-css/tests/runtime.test.ts +++ b/packages/dom-css/tests/runtime.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test' -import { createCSSRuntime, createHeadlessCSSRuntime, serializeHTML } from '../src/index' +import { bundleHTML, createCSSRuntime, createHeadlessCSSRuntime, serializeHTML } from '../src/index' import { cardDocument, TEST_COLORS } from './helpers' describe('@open-pencil/dom-css runtime', () => { @@ -34,8 +34,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('bundles standalone HTML documents when requested', async () => { + const bundle = await bundleHTML(cardDocument, { html: 'standalone' }) + const html = String(bundle.files[0]?.content) expect(html).toContain('') expect(html).toContain('data-open-pencil-html="standalone"') @@ -43,12 +44,34 @@ 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 bundleHTML(cardDocument, { html: 'standalone', style: 'tailwind' }) + const html = String(bundle.files[0]?.content) - expect(html).toContain( - '' + expect(html).toContain('