Revert "feat(cli): bundle standalone HTML exports"
This reverts commit 8330dda410.
This commit is contained in:
parent
8330dda410
commit
b22a751f27
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 }) }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue