fix(dom-css): compile standalone HTML exports
This commit is contained in:
parent
b22a751f27
commit
7ac3ee035d
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { writeFile } from 'node:fs/promises'
|
||||
import { basename, extname, resolve } from 'node:path'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, join, resolve } from 'node:path'
|
||||
|
||||
import { defineCommand } from 'citty'
|
||||
|
||||
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
|
||||
import type { RasterExportFormat } from '@open-pencil/core/io'
|
||||
import {
|
||||
exportHTMLBundle,
|
||||
sceneGraphToDesignDocument,
|
||||
serializeHTML,
|
||||
type SerializeHTMLOptions
|
||||
type ExportHTMLBundleOptions
|
||||
} from '@open-pencil/dom-css'
|
||||
|
||||
import { isAppMode, requireFile, rpc } from '#cli/app-client'
|
||||
|
|
@ -22,6 +22,8 @@ const ALL_FORMATS = new Set([...RASTER_FORMATS, 'SVG', 'PDF', 'JSX', 'FIG', 'HTM
|
|||
const JSX_STYLES = new Set(['openpencil', 'tailwind'])
|
||||
const HTML_STYLES = new Set(['inline', 'tailwind'])
|
||||
const HTML_MODES = new Set(['fragment', 'standalone'])
|
||||
const HTML_ASSETS = new Set(['inline', 'external'])
|
||||
const HTML_FONTS = new Set(['assets', 'none'])
|
||||
|
||||
interface ExportArgs {
|
||||
file?: string
|
||||
|
|
@ -34,6 +36,8 @@ interface ExportArgs {
|
|||
style: string
|
||||
html: string
|
||||
css: string
|
||||
assets: string
|
||||
fonts: string
|
||||
thumbnail?: boolean
|
||||
width: string
|
||||
height: string
|
||||
|
|
@ -105,6 +109,27 @@ function targetLabel(pageName?: string, nodeId?: string): string {
|
|||
|
||||
type FileExportTarget = { scope: 'node'; nodeId: string } | { scope: 'page'; pageId: string }
|
||||
|
||||
async function writeHTMLFiles(
|
||||
output: string,
|
||||
bundle: Awaited<ReturnType<typeof exportHTMLBundle>>
|
||||
) {
|
||||
const entrypoint = bundle.files.find((file) => file.path === bundle.entrypoint)
|
||||
if (!entrypoint) {
|
||||
printError(`HTML export did not include ${bundle.entrypoint}.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await writeAndLog(output, entrypoint.content)
|
||||
const outputDir = dirname(output)
|
||||
const assetFiles = bundle.files.filter((file) => file.path !== bundle.entrypoint)
|
||||
for (const file of assetFiles) {
|
||||
const assetPath = join(outputDir, file.path)
|
||||
await mkdir(dirname(assetPath), { recursive: true })
|
||||
await writeFile(assetPath, file.content)
|
||||
}
|
||||
if (assetFiles.length > 0) console.log(ok(`Assets: ${assetFiles.length} files`))
|
||||
}
|
||||
|
||||
async function exportHTMLFromFile(
|
||||
args: ExportArgs,
|
||||
graph: Awaited<ReturnType<typeof loadDocument>>,
|
||||
|
|
@ -114,12 +139,16 @@ async function exportHTMLFromFile(
|
|||
const document = sceneGraphToDesignDocument(graph, {
|
||||
rootId: target.scope === 'page' ? target.pageId : target.nodeId
|
||||
})
|
||||
const html = serializeHTML(document, {
|
||||
html: args.html as SerializeHTMLOptions['html'],
|
||||
style: args.css as SerializeHTMLOptions['style']
|
||||
})
|
||||
const output = resolve(args.output ?? exportFileName(defaultName, 'html'))
|
||||
await writeAndLog(output, html)
|
||||
const assetBasePath = `${basename(output, extname(output))}.assets`
|
||||
const bundle = await exportHTMLBundle(document, {
|
||||
html: args.html as ExportHTMLBundleOptions['html'],
|
||||
style: args.css as ExportHTMLBundleOptions['style'],
|
||||
assets: args.assets as ExportHTMLBundleOptions['assets'],
|
||||
fonts: args.fonts as ExportHTMLBundleOptions['fonts'],
|
||||
assetBasePath
|
||||
})
|
||||
await writeHTMLFiles(output, bundle)
|
||||
console.log(ok(`Target: ${targetLabel(args.page, args.node)}`))
|
||||
}
|
||||
|
||||
|
|
@ -241,6 +270,16 @@ export default defineCommand({
|
|||
description: 'HTML CSS output: inline or tailwind (default: inline)',
|
||||
default: 'inline'
|
||||
},
|
||||
assets: {
|
||||
type: 'string',
|
||||
description: 'HTML asset output: inline or external (default: inline)',
|
||||
default: 'inline'
|
||||
},
|
||||
fonts: {
|
||||
type: 'string',
|
||||
description: 'HTML font output: assets or none (default: none)',
|
||||
default: 'none'
|
||||
},
|
||||
thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' },
|
||||
width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
|
||||
height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' },
|
||||
|
|
@ -270,6 +309,16 @@ export default defineCommand({
|
|||
process.exit(1)
|
||||
}
|
||||
|
||||
if (format === 'HTML' && !HTML_ASSETS.has(args.assets)) {
|
||||
printError(`Invalid HTML asset output "${args.assets}". Use inline or external.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (format === 'HTML' && !HTML_FONTS.has(args.fonts)) {
|
||||
printError(`Invalid HTML font output "${args.fonts}". Use assets or none.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (isAppMode(args.file)) {
|
||||
await exportViaApp(format, args)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,29 @@ export const DEFAULT_WEB_FONT_PROVIDER_SETTINGS: Record<WebFontProviderId, boole
|
|||
|
||||
export type WebFontFetch = (url: string, init?: RequestInit) => Promise<Response>
|
||||
|
||||
export interface WebFontFaceRequest {
|
||||
family: string
|
||||
weight: number
|
||||
style?: 'normal' | 'italic'
|
||||
}
|
||||
|
||||
export interface WebFontFaceAsset {
|
||||
path: string
|
||||
content: Uint8Array
|
||||
}
|
||||
|
||||
export interface ExportWebFontFacesOptions {
|
||||
fonts: WebFontFaceRequest[]
|
||||
providers?: WebFontProviderId[]
|
||||
assetBasePath?: string
|
||||
fetcher?: WebFontFetch
|
||||
}
|
||||
|
||||
export interface ExportWebFontFacesResult {
|
||||
css: string
|
||||
assets: WebFontFaceAsset[]
|
||||
}
|
||||
|
||||
type WebFontProvider =
|
||||
| ReturnType<typeof providers.google>
|
||||
| ReturnType<typeof providers.fontsource>
|
||||
|
|
@ -54,6 +77,123 @@ function isRemoteFontSource(
|
|||
return 'url' in source
|
||||
}
|
||||
|
||||
function fontAssetExtension(source: RemoteFontSource): string {
|
||||
if (source.format === 'woff2') return 'woff2'
|
||||
if (source.format === 'woff') return 'woff'
|
||||
if (source.format === 'opentype' || source.format === 'otf') return 'otf'
|
||||
return 'ttf'
|
||||
}
|
||||
|
||||
function fontAssetFormat(source: RemoteFontSource): string {
|
||||
if (source.format === 'woff2') return 'woff2'
|
||||
if (source.format === 'woff') return 'woff'
|
||||
if (source.format === 'opentype' || source.format === 'otf') return 'opentype'
|
||||
return 'truetype'
|
||||
}
|
||||
|
||||
function slugFontFamily(family: string): string {
|
||||
let slug = ''
|
||||
let previousDash = false
|
||||
for (const char of family.toLowerCase()) {
|
||||
const code = char.charCodeAt(0)
|
||||
const isAlpha = code >= 97 && code <= 122
|
||||
const isDigit = code >= 48 && code <= 57
|
||||
if (isAlpha || isDigit) {
|
||||
slug += char
|
||||
previousDash = false
|
||||
continue
|
||||
}
|
||||
if (slug.length > 0 && !previousDash) {
|
||||
slug += '-'
|
||||
previousDash = true
|
||||
}
|
||||
}
|
||||
return slug.endsWith('-') ? slug.slice(0, -1) : slug
|
||||
}
|
||||
|
||||
async function resolveRemoteFontSource(
|
||||
family: string,
|
||||
request: WebFontFaceRequest,
|
||||
provider: WebFontProviderId
|
||||
): Promise<{ source: RemoteFontSource; face: ResolveFontResult['fonts'][number] } | undefined> {
|
||||
const unifont = await createProviderUnifont(provider)
|
||||
const options = {
|
||||
weights: [String(request.weight)],
|
||||
styles: [request.style ?? 'normal'],
|
||||
formats: ['woff2', 'woff', 'ttf'],
|
||||
subsets: ['latin']
|
||||
} satisfies WebFontResolveOptions
|
||||
const result = await unifont.resolveFont(family, options)
|
||||
for (const face of result.fonts.toSorted(
|
||||
(a, b) => (a.meta?.priority ?? 0) - (b.meta?.priority ?? 0)
|
||||
)) {
|
||||
const source = face.src.find(isRemoteFontSource)
|
||||
if (source) return { source, face }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function serializeFontWeight(weight: string | number | [number, number]): string {
|
||||
return Array.isArray(weight) ? weight.join(' ') : String(weight)
|
||||
}
|
||||
|
||||
function fontFaceCSS(
|
||||
family: string,
|
||||
request: WebFontFaceRequest,
|
||||
face: ResolveFontResult['fonts'][number],
|
||||
source: RemoteFontSource,
|
||||
path: string
|
||||
): string {
|
||||
const descriptors = [
|
||||
`font-family:${JSON.stringify(family)}`,
|
||||
`src:url("${path}") format("${fontAssetFormat(source)}")`,
|
||||
`font-weight:${serializeFontWeight(face.weight ?? request.weight)}`,
|
||||
`font-style:${face.style ?? request.style ?? 'normal'}`,
|
||||
`font-display:${face.display ?? 'swap'}`
|
||||
]
|
||||
if (face.stretch) descriptors.push(`font-stretch:${face.stretch}`)
|
||||
if (face.unicodeRange && face.unicodeRange.length > 0) {
|
||||
descriptors.push(`unicode-range:${face.unicodeRange.join(',')}`)
|
||||
}
|
||||
return `@font-face{${descriptors.join(';')}}`
|
||||
}
|
||||
|
||||
export async function exportWebFontFaces({
|
||||
fonts,
|
||||
providers = WEB_FONT_PROVIDER_IDS.slice(),
|
||||
assetBasePath = 'assets/fonts',
|
||||
fetcher = fetch
|
||||
}: ExportWebFontFacesOptions): Promise<ExportWebFontFacesResult> {
|
||||
const assets: WebFontFaceAsset[] = []
|
||||
const css: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const request of fonts) {
|
||||
const family = request.family
|
||||
const key = `${family}|${request.weight}|${request.style ?? 'normal'}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const resolved = await resolveRemoteFontSource(family, request, provider)
|
||||
if (!resolved) continue
|
||||
const response = await fetcher(resolved.source.url, resolved.face.meta?.init)
|
||||
if (!response.ok) continue
|
||||
const extension = fontAssetExtension(resolved.source)
|
||||
const path = `${assetBasePath}/${slugFontFamily(family)}-${request.weight}-${request.style ?? 'normal'}.${extension}`
|
||||
assets.push({ path, content: new Uint8Array(await response.arrayBuffer()) })
|
||||
css.push(fontFaceCSS(family, request, resolved.face, resolved.source, path))
|
||||
break
|
||||
} catch (error) {
|
||||
console.warn(`Failed to export ${family} from ${provider} fonts`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { css: css.join(''), assets }
|
||||
}
|
||||
|
||||
export class WebFontResolver {
|
||||
private enabled = new Set<WebFontProviderId>(
|
||||
WEB_FONT_PROVIDER_IDS.filter((provider) => DEFAULT_WEB_FONT_PROVIDER_SETTINGS[provider])
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
343
packages/dom-css/src/html-export.ts
Normal file
343
packages/dom-css/src/html-export.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
import { parseFragment, serialize, type DefaultTreeAdapterTypes } from 'parse5'
|
||||
|
||||
import {
|
||||
exportWebFontFaces,
|
||||
normalizeFontFamily,
|
||||
type WebFontFaceRequest
|
||||
} from '@open-pencil/core/text'
|
||||
|
||||
import { mergeClassNames, serializeHTML, splitWhitespace } from './serialize'
|
||||
import type { DesignDocument, DesignElement, DesignNode, DesignStyleDeclaration } from './types'
|
||||
|
||||
export interface ExportHTMLBundleOptions {
|
||||
html?: 'fragment' | 'standalone'
|
||||
style?: 'inline' | 'tailwind'
|
||||
assets?: 'inline' | 'external'
|
||||
fonts?: 'assets' | 'none'
|
||||
assetBasePath?: string
|
||||
}
|
||||
|
||||
export interface ExportHTMLFile {
|
||||
path: string
|
||||
content: string | Uint8Array
|
||||
}
|
||||
|
||||
export interface ExportHTMLBundle {
|
||||
entrypoint: string
|
||||
files: ExportHTMLFile[]
|
||||
}
|
||||
|
||||
interface StandaloneBounds {
|
||||
minX: number
|
||||
minY: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type ParseNode = DefaultTreeAdapterTypes.Node
|
||||
type ParseParent = DefaultTreeAdapterTypes.ParentNode
|
||||
type ParseElement = DefaultTreeAdapterTypes.Element
|
||||
|
||||
const RESET_CSS =
|
||||
'*,*::before,*::after{box-sizing:border-box}html,body{margin:0;padding:0}body{font-family:system-ui,sans-serif;background:#fff}'
|
||||
|
||||
function styleToCSS(style: DesignStyleDeclaration): string {
|
||||
return Object.entries(style)
|
||||
.filter(([, value]) => value !== '')
|
||||
.map(([property, value]) => `${property}: ${value}`)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
function cloneNode(node: DesignNode): DesignNode {
|
||||
if (node.type === 'text') return { ...node }
|
||||
return {
|
||||
...node,
|
||||
attrs: { ...node.attrs },
|
||||
inlineStyle: node.inlineStyle ? { ...node.inlineStyle } : undefined,
|
||||
children: node.children.map(cloneNode)
|
||||
}
|
||||
}
|
||||
|
||||
function standaloneStyleForNode(
|
||||
node: DesignElement,
|
||||
parent: DesignElement | undefined,
|
||||
origin: StandaloneBounds
|
||||
): DesignStyleDeclaration {
|
||||
const style = { ...node.inlineStyle }
|
||||
const source = node.sourceSceneNode
|
||||
if (!source) return style
|
||||
|
||||
style.position = 'absolute'
|
||||
style.left = `${source.x - (parent ? 0 : origin.minX)}px`
|
||||
style.top = `${source.y - (parent ? 0 : origin.minY)}px`
|
||||
return style
|
||||
}
|
||||
|
||||
function standaloneNode(
|
||||
node: DesignNode,
|
||||
origin: StandaloneBounds,
|
||||
parent?: DesignElement
|
||||
): DesignNode {
|
||||
if (node.type === 'text') return cloneNode(node)
|
||||
const standalone: DesignElement = {
|
||||
...node,
|
||||
attrs: { ...node.attrs },
|
||||
inlineStyle: standaloneStyleForNode(node, parent, origin),
|
||||
children: []
|
||||
}
|
||||
standalone.children = node.children.map((child) => standaloneNode(child, origin, node))
|
||||
return standalone
|
||||
}
|
||||
|
||||
function nodeBounds(node: DesignNode): StandaloneBounds | undefined {
|
||||
if (node.type === 'text' || !node.sourceSceneNode) return undefined
|
||||
return {
|
||||
minX: node.sourceSceneNode.x,
|
||||
minY: node.sourceSceneNode.y,
|
||||
width: node.sourceSceneNode.width,
|
||||
height: node.sourceSceneNode.height
|
||||
}
|
||||
}
|
||||
|
||||
function standaloneSize(document: DesignDocument): StandaloneBounds {
|
||||
const bounds = document.children
|
||||
.map(nodeBounds)
|
||||
.filter((value): value is NonNullable<typeof value> => value !== undefined)
|
||||
const minX = bounds.length > 0 ? Math.min(...bounds.map((bound) => bound.minX)) : 0
|
||||
const minY = bounds.length > 0 ? Math.min(...bounds.map((bound) => bound.minY)) : 0
|
||||
const maxX = bounds.length > 0 ? Math.max(...bounds.map((bound) => bound.minX + bound.width)) : 1
|
||||
const maxY = bounds.length > 0 ? Math.max(...bounds.map((bound) => bound.minY + bound.height)) : 1
|
||||
return { minX, minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) }
|
||||
}
|
||||
|
||||
function standaloneDocument(document: DesignDocument, size: StandaloneBounds): DesignDocument {
|
||||
return {
|
||||
...document,
|
||||
children: document.children.map((node) => standaloneNode(node, size))
|
||||
}
|
||||
}
|
||||
|
||||
function cssClassName(index: number): string {
|
||||
return `op-${index.toString(36)}`
|
||||
}
|
||||
|
||||
function extractInlineStyles(
|
||||
node: DesignNode,
|
||||
rules: string[],
|
||||
nextIndex: { value: number }
|
||||
): DesignNode {
|
||||
if (node.type === 'text') return node
|
||||
const children = node.children.map((child) => extractInlineStyles(child, rules, nextIndex))
|
||||
if (!node.inlineStyle || Object.keys(node.inlineStyle).length === 0) return { ...node, children }
|
||||
|
||||
const className = cssClassName(nextIndex.value)
|
||||
nextIndex.value += 1
|
||||
rules.push(`.${className}{${styleToCSS(node.inlineStyle)}}`)
|
||||
return {
|
||||
...node,
|
||||
attrs: { ...node.attrs, class: mergeClassNames(node.attrs.class, className) ?? className },
|
||||
inlineStyle: undefined,
|
||||
children
|
||||
}
|
||||
}
|
||||
|
||||
function isElement(node: ParseNode): node is ParseElement {
|
||||
return 'attrs' in node && 'tagName' in node
|
||||
}
|
||||
|
||||
function walkParseTree(node: ParseNode | ParseParent, visit: (node: ParseElement) => void): void {
|
||||
if (isElement(node)) visit(node)
|
||||
if ('childNodes' in node) {
|
||||
for (const child of node.childNodes) walkParseTree(child, visit)
|
||||
}
|
||||
}
|
||||
|
||||
function classNamesFromHTML(html: string): string[] {
|
||||
const fragment = parseFragment(html)
|
||||
const classes = new Set<string>()
|
||||
walkParseTree(fragment, (element) => {
|
||||
const classAttr = element.attrs.find((attr) => attr.name === 'class')
|
||||
if (!classAttr) return
|
||||
for (const className of splitWhitespace(classAttr.value)) classes.add(className)
|
||||
})
|
||||
return [...classes]
|
||||
}
|
||||
|
||||
async function compileTailwindClasses(classNames: string[]): Promise<string> {
|
||||
if (classNames.length === 0) return ''
|
||||
const [{ compile }, { readFile }] = await Promise.all([
|
||||
import('tailwindcss'),
|
||||
import('node:fs/promises')
|
||||
])
|
||||
const [themeCSS, utilitiesCSS] = await Promise.all([
|
||||
readFile(new URL(import.meta.resolve('tailwindcss/theme.css')), 'utf8'),
|
||||
readFile(new URL(import.meta.resolve('tailwindcss/utilities.css')), 'utf8')
|
||||
])
|
||||
const compiler = await compile(`${themeCSS}\n${utilitiesCSS}`)
|
||||
return compiler.build(classNames)
|
||||
}
|
||||
|
||||
function stripFontFamilyQuotes(value: string): string {
|
||||
if (value.length < 2) return value
|
||||
const first = value[0]
|
||||
const last = value[value.length - 1]
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) return value.slice(1, -1)
|
||||
return value
|
||||
}
|
||||
|
||||
function firstFontFamily(value: string): string {
|
||||
const commaIndex = value.indexOf(',')
|
||||
const raw = commaIndex !== -1 ? value.slice(0, commaIndex) : value
|
||||
return stripFontFamilyQuotes(raw.trim())
|
||||
}
|
||||
|
||||
function collectFontRequests(node: DesignNode, fonts: Map<string, WebFontFaceRequest>): void {
|
||||
if (node.type === 'text') return
|
||||
const source = node.sourceSceneNode
|
||||
if (source?.type === 'TEXT') {
|
||||
const family = normalizeFontFamily(firstFontFamily(source.fontFamily))
|
||||
const style = source.italic ? 'italic' : 'normal'
|
||||
const key = `${family}|${source.fontWeight}|${style}`
|
||||
fonts.set(key, { family, weight: source.fontWeight, style })
|
||||
}
|
||||
for (const child of node.children) collectFontRequests(child, fonts)
|
||||
}
|
||||
|
||||
async function fontFaceAssets(
|
||||
document: DesignDocument,
|
||||
options: Required<ExportHTMLBundleOptions>
|
||||
): Promise<{ css: string; files: ExportHTMLFile[] }> {
|
||||
if (options.fonts === 'none' || options.assets !== 'external') return { css: '', files: [] }
|
||||
const requests = new Map<string, WebFontFaceRequest>()
|
||||
for (const child of document.children) collectFontRequests(child, requests)
|
||||
const result = await exportWebFontFaces({
|
||||
fonts: [...requests.values()],
|
||||
assetBasePath: `${options.assetBasePath}/fonts`
|
||||
})
|
||||
return { css: result.css, files: result.assets }
|
||||
}
|
||||
|
||||
function dataImageParts(value: string): { mime: string; base64: string } | undefined {
|
||||
if (!value.startsWith('data:image/')) return undefined
|
||||
const marker = ';base64,'
|
||||
const markerIndex = value.indexOf(marker)
|
||||
if (markerIndex === -1) return undefined
|
||||
return {
|
||||
mime: value.slice('data:'.length, markerIndex),
|
||||
base64: value.slice(markerIndex + marker.length)
|
||||
}
|
||||
}
|
||||
|
||||
function bytesFromBase64(value: string): Uint8Array {
|
||||
return Uint8Array.from(atob(value), (char) => char.charCodeAt(0))
|
||||
}
|
||||
|
||||
function extensionForMime(mime: string): string {
|
||||
if (mime === 'image/jpeg') return 'jpg'
|
||||
if (mime === 'image/webp') return 'webp'
|
||||
if (mime === 'image/gif') return 'gif'
|
||||
if (mime === 'image/svg+xml') return 'svg'
|
||||
return 'png'
|
||||
}
|
||||
|
||||
function extractImageAssets(
|
||||
html: string,
|
||||
assetBasePath: string
|
||||
): { html: string; files: ExportHTMLFile[] } {
|
||||
const fragment = parseFragment(html)
|
||||
const files: ExportHTMLFile[] = []
|
||||
const sources = new Map<string, string>()
|
||||
walkParseTree(fragment, (element) => {
|
||||
const src = element.attrs.find((attr) => attr.name === 'src')
|
||||
if (!src) return
|
||||
const parts = dataImageParts(src.value)
|
||||
if (!parts) return
|
||||
const cachedPath = sources.get(src.value)
|
||||
if (cachedPath) {
|
||||
src.value = cachedPath
|
||||
return
|
||||
}
|
||||
const path = `${assetBasePath}/images/image-${sources.size + 1}.${extensionForMime(parts.mime)}`
|
||||
sources.set(src.value, path)
|
||||
files.push({ path, content: bytesFromBase64(parts.base64) })
|
||||
src.value = path
|
||||
})
|
||||
return { html: serialize(fragment), files }
|
||||
}
|
||||
|
||||
function stylesheetLink(path: string): string {
|
||||
return `<link rel="stylesheet" href="${path}">`
|
||||
}
|
||||
|
||||
function styleTag(css: string): string {
|
||||
return css ? `<style>${css}</style>` : ''
|
||||
}
|
||||
|
||||
async function exportStandaloneHTML(
|
||||
document: DesignDocument,
|
||||
options: Required<ExportHTMLBundleOptions>
|
||||
): Promise<ExportHTMLBundle> {
|
||||
const size = standaloneSize(document)
|
||||
const doc = standaloneDocument(document, size)
|
||||
const stageCSS = `.op-stage{position:relative;width:${size.width}px;height:${size.height}px;overflow:hidden;background:transparent}`
|
||||
let body: string
|
||||
let css = `${RESET_CSS}${stageCSS}`
|
||||
|
||||
if (options.style === 'tailwind') {
|
||||
body = serializeHTML(doc, { style: 'tailwind' })
|
||||
css += await compileTailwindClasses(classNamesFromHTML(body))
|
||||
} else {
|
||||
const rules: string[] = []
|
||||
const index = { value: 0 }
|
||||
const styledDocument: DesignDocument = {
|
||||
...doc,
|
||||
children: doc.children.map((node) => extractInlineStyles(node, rules, index))
|
||||
}
|
||||
body = serializeHTML(styledDocument)
|
||||
css += rules.join('')
|
||||
}
|
||||
|
||||
if (options.assets === 'external') {
|
||||
const [extracted, fonts] = await Promise.all([
|
||||
Promise.resolve(extractImageAssets(body, options.assetBasePath)),
|
||||
fontFaceAssets(document, options)
|
||||
])
|
||||
body = extracted.html
|
||||
const cssPath = `${options.assetBasePath}/openpencil.css`
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${stylesheetLink(cssPath)}</head><body><main data-open-pencil-html="standalone" class="op-stage">${body}</main></body></html>`
|
||||
return {
|
||||
entrypoint: 'index.html',
|
||||
files: [
|
||||
{ path: 'index.html', content: html },
|
||||
{ path: cssPath, content: `${fonts.css}${css}` },
|
||||
...fonts.files,
|
||||
...extracted.files
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${styleTag(css)}</head><body><main data-open-pencil-html="standalone" class="op-stage">${body}</main></body></html>`
|
||||
return { entrypoint: 'index.html', files: [{ path: 'index.html', content: html }] }
|
||||
}
|
||||
|
||||
export async function exportHTMLBundle(
|
||||
document: DesignDocument,
|
||||
options: ExportHTMLBundleOptions = {}
|
||||
): Promise<ExportHTMLBundle> {
|
||||
const resolvedOptions: Required<ExportHTMLBundleOptions> = {
|
||||
html: options.html ?? 'fragment',
|
||||
style: options.style ?? 'inline',
|
||||
assets: options.assets ?? 'inline',
|
||||
fonts: options.fonts ?? 'none',
|
||||
assetBasePath: options.assetBasePath ?? 'assets'
|
||||
}
|
||||
|
||||
if (resolvedOptions.html === 'standalone') return exportStandaloneHTML(document, resolvedOptions)
|
||||
|
||||
return {
|
||||
entrypoint: 'index.html',
|
||||
files: [
|
||||
{ path: 'index.html', content: serializeHTML(document, { style: resolvedOptions.style }) }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>): string | undefined {
|
||||
export function mergeClassNames(...values: Array<string | undefined>): string | undefined {
|
||||
const className = values
|
||||
.flatMap((value) => value?.split(/\s+/) ?? [])
|
||||
.flatMap((value) => (value ? splitWhitespace(value) : []))
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0)
|
||||
.join(' ')
|
||||
|
|
@ -94,77 +100,6 @@ function serializeElement(node: DesignElement, options: SerializeHTMLOptions): s
|
|||
return `<${tagName}${attrs}>${node.children.map((child) => serializeNode(child, options)).join('')}</${tagName}>`
|
||||
}
|
||||
|
||||
interface StandaloneBounds {
|
||||
minX: number
|
||||
minY: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function standaloneStyleForNode(
|
||||
node: DesignElement,
|
||||
parent: DesignElement | undefined,
|
||||
origin: StandaloneBounds
|
||||
): DesignStyleDeclaration {
|
||||
const style = { ...node.inlineStyle }
|
||||
const source = node.sourceSceneNode
|
||||
if (!source) return style
|
||||
|
||||
style.position = 'absolute'
|
||||
style.left = `${source.x - (parent ? 0 : origin.minX)}px`
|
||||
style.top = `${source.y - (parent ? 0 : origin.minY)}px`
|
||||
return style
|
||||
}
|
||||
|
||||
function standaloneNode(
|
||||
node: DesignNode,
|
||||
origin: StandaloneBounds,
|
||||
parent?: DesignElement
|
||||
): DesignNode {
|
||||
if (node.type === 'text') return node
|
||||
const standalone: DesignElement = {
|
||||
...node,
|
||||
inlineStyle: standaloneStyleForNode(node, parent, origin),
|
||||
children: []
|
||||
}
|
||||
standalone.children = node.children.map((child) => standaloneNode(child, origin, node))
|
||||
return standalone
|
||||
}
|
||||
|
||||
function nodeBounds(node: DesignNode): StandaloneBounds | undefined {
|
||||
if (node.type === 'text' || !node.sourceSceneNode) return undefined
|
||||
return {
|
||||
minX: node.sourceSceneNode.x,
|
||||
minY: node.sourceSceneNode.y,
|
||||
width: node.sourceSceneNode.width,
|
||||
height: node.sourceSceneNode.height
|
||||
}
|
||||
}
|
||||
|
||||
function standaloneSize(document: DesignDocument): StandaloneBounds {
|
||||
const bounds = document.children
|
||||
.map(nodeBounds)
|
||||
.filter((value): value is NonNullable<typeof value> => value !== undefined)
|
||||
const minX = bounds.length > 0 ? Math.min(...bounds.map((bound) => bound.minX)) : 0
|
||||
const minY = bounds.length > 0 ? Math.min(...bounds.map((bound) => bound.minY)) : 0
|
||||
const maxX = bounds.length > 0 ? Math.max(...bounds.map((bound) => bound.minX + bound.width)) : 1
|
||||
const maxY = bounds.length > 0 ? Math.max(...bounds.map((bound) => bound.minY + bound.height)) : 1
|
||||
return { minX, minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) }
|
||||
}
|
||||
|
||||
function serializeStandaloneHTML(document: DesignDocument, options: SerializeHTMLOptions): string {
|
||||
const size = standaloneSize(document)
|
||||
const body = document.children
|
||||
.map((node) => serializeNode(standaloneNode(node, size), options))
|
||||
.join('')
|
||||
const stageStyle = `position: relative; width: ${size.width}px; height: ${size.height}px; overflow: hidden; background: transparent`
|
||||
const reset =
|
||||
'*,*::before,*::after{box-sizing:border-box}html,body{margin:0;padding:0}body{font-family:system-ui,sans-serif;background:#fff}'
|
||||
const tailwindBrowser =
|
||||
options.style === 'tailwind' ? `<script src="${TAILWIND_BROWSER_CDN}"></script>` : ''
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${tailwindBrowser}<style>${reset}</style></head><body><main data-open-pencil-html="standalone" style="${stageStyle}">${body}</main></body></html>`
|
||||
}
|
||||
|
||||
export function serializeNode(node: DesignNode, options: SerializeHTMLOptions = {}): string {
|
||||
return node.type === 'text' ? serializeText(node) : serializeElement(node, options)
|
||||
}
|
||||
|
|
@ -173,6 +108,5 @@ export function serializeHTML(
|
|||
document: DesignDocument,
|
||||
options: SerializeHTMLOptions = {}
|
||||
): string {
|
||||
if (options.html === 'standalone') return serializeStandaloneHTML(document, options)
|
||||
return document.children.map((node) => serializeNode(node, options)).join('')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import { createCSSRuntime, createHeadlessCSSRuntime, serializeHTML } from '../src/index'
|
||||
import {
|
||||
createCSSRuntime,
|
||||
createHeadlessCSSRuntime,
|
||||
exportHTMLBundle,
|
||||
serializeHTML
|
||||
} from '../src/index'
|
||||
import { cardDocument, TEST_COLORS } from './helpers'
|
||||
|
||||
describe('@open-pencil/dom-css runtime', () => {
|
||||
|
|
@ -34,8 +39,9 @@ describe('@open-pencil/dom-css runtime', () => {
|
|||
expect(html).toBe('<section class="card flex p-4 gap-2 bg-white">OpenPencil</section>')
|
||||
})
|
||||
|
||||
it('serializes standalone HTML documents when requested', () => {
|
||||
const html = serializeHTML(cardDocument, { html: 'standalone' })
|
||||
it('exports standalone HTML documents when requested', async () => {
|
||||
const bundle = await exportHTMLBundle(cardDocument, { html: 'standalone' })
|
||||
const html = String(bundle.files[0]?.content)
|
||||
|
||||
expect(html).toContain('<!doctype html>')
|
||||
expect(html).toContain('data-open-pencil-html="standalone"')
|
||||
|
|
@ -43,12 +49,12 @@ describe('@open-pencil/dom-css runtime', () => {
|
|||
expect(html).not.toContain('@tailwindcss/browser@4')
|
||||
})
|
||||
|
||||
it('loads the Tailwind browser runtime for standalone Tailwind HTML', () => {
|
||||
const html = serializeHTML(cardDocument, { html: 'standalone', style: 'tailwind' })
|
||||
it('precompiles Tailwind CSS for standalone Tailwind HTML', async () => {
|
||||
const bundle = await exportHTMLBundle(cardDocument, { html: 'standalone', style: 'tailwind' })
|
||||
const html = String(bundle.files[0]?.content)
|
||||
|
||||
expect(html).toContain(
|
||||
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>'
|
||||
)
|
||||
expect(html).toContain('<style>')
|
||||
expect(html).not.toContain('@tailwindcss/browser@4')
|
||||
})
|
||||
|
||||
it('uses the headless runtime outside browser contexts', () => {
|
||||
|
|
|
|||
|
|
@ -104,12 +104,12 @@ test('export CLI can write standalone HTML', async () => {
|
|||
const html = await Bun.file(output).text()
|
||||
expect(html).toContain('<!doctype html>')
|
||||
expect(html).toContain('data-open-pencil-html="standalone"')
|
||||
expect(html).toContain('position: relative')
|
||||
expect(html).toContain('position:relative')
|
||||
expect(html).toContain('position: absolute')
|
||||
expect(html).not.toContain('@tailwindcss/browser@4')
|
||||
})
|
||||
|
||||
test('export CLI includes Tailwind browser runtime for standalone Tailwind HTML', async () => {
|
||||
test('export CLI precompiles Tailwind CSS for standalone Tailwind HTML', async () => {
|
||||
const { dir, figPath } = await createFigFixture()
|
||||
const output = join(dir, 'card-standalone-tailwind.html')
|
||||
|
||||
|
|
@ -131,8 +131,39 @@ test('export CLI includes Tailwind browser runtime for standalone Tailwind HTML'
|
|||
|
||||
const html = await Bun.file(output).text()
|
||||
expect(html).toContain('<!doctype html>')
|
||||
expect(html).toContain(
|
||||
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>'
|
||||
)
|
||||
expect(html).toContain('<style>')
|
||||
expect(html).toContain('class="')
|
||||
expect(html).toContain('.flex')
|
||||
expect(html).not.toContain('@tailwindcss/browser@4')
|
||||
})
|
||||
|
||||
test('export CLI can write external standalone HTML assets', async () => {
|
||||
const { dir, figPath } = await createFigFixture()
|
||||
const output = join(dir, 'card-external.html')
|
||||
|
||||
const { stderr, exitCode } = await runOpenPencilCLI([
|
||||
'export',
|
||||
figPath,
|
||||
'--format',
|
||||
'html',
|
||||
'--html',
|
||||
'standalone',
|
||||
'--css',
|
||||
'tailwind',
|
||||
'--assets',
|
||||
'external',
|
||||
'--output',
|
||||
output
|
||||
])
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(exitCode).toBe(0)
|
||||
|
||||
const html = await Bun.file(output).text()
|
||||
const cssPath = join(dir, 'card-external.assets', 'openpencil.css')
|
||||
const css = await Bun.file(cssPath).text()
|
||||
expect(html).toContain('<link rel="stylesheet" href="card-external.assets/openpencil.css">')
|
||||
expect(html).not.toContain('<style>')
|
||||
expect(css).toContain('.flex')
|
||||
expect(css).toContain('.op-stage')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import type { DesignElement } from '@open-pencil/dom-css'
|
||||
import type { DesignElement, DesignNode } from '@open-pencil/dom-css'
|
||||
import {
|
||||
compileTailwindCSS,
|
||||
createHeadlessCSSRuntime,
|
||||
|
|
@ -32,6 +32,16 @@ function expectFrame(node: SceneNode | undefined) {
|
|||
return node
|
||||
}
|
||||
|
||||
function findTextElement(nodes: DesignNode[]): DesignElement | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.type !== 'element') continue
|
||||
if (node.children.some((child) => child.type === 'text')) return node
|
||||
const child = findTextElement(node.children)
|
||||
if (child) return child
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function createStyleRoundTripGraph() {
|
||||
return designDocumentToSceneGraph({
|
||||
type: 'document',
|
||||
|
|
@ -238,6 +248,25 @@ describe('@open-pencil/dom-css conversion', () => {
|
|||
expect(html).toContain('box-shadow')
|
||||
})
|
||||
|
||||
it('exports multiline text with preserved whitespace', () => {
|
||||
const graph = designDocumentToSceneGraph({
|
||||
type: 'document',
|
||||
children: [
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'p',
|
||||
attrs: {},
|
||||
inlineStyle: { width: '160px' },
|
||||
children: [{ type: 'text', text: 'Open\nPencil' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
const document = sceneGraphToDesignDocument(graph)
|
||||
const text = findTextElement(document.children)
|
||||
|
||||
expect(text?.inlineStyle?.['white-space']).toBe('pre-wrap')
|
||||
})
|
||||
|
||||
it('maps CSS shape constraints, clipping, corners, and borders', () => {
|
||||
const graph = designDocumentToSceneGraph({
|
||||
type: 'document',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import { createCSSRuntime, createHeadlessCSSRuntime, serializeHTML } from '@open-pencil/dom-css'
|
||||
import {
|
||||
createCSSRuntime,
|
||||
createHeadlessCSSRuntime,
|
||||
exportHTMLBundle,
|
||||
serializeHTML
|
||||
} from '@open-pencil/dom-css'
|
||||
|
||||
import { DOM_CSS_COLORS, simpleCardDocument } from '#tests/helpers/dom-css'
|
||||
|
||||
|
|
@ -36,8 +41,9 @@ describe('@open-pencil/dom-css', () => {
|
|||
expect(html).toBe('<section class="card flex p-4 gap-2 bg-white">OpenPencil</section>')
|
||||
})
|
||||
|
||||
it('serializes standalone HTML documents when requested', () => {
|
||||
const html = serializeHTML(simpleCardDocument, { html: 'standalone' })
|
||||
it('exports standalone HTML documents when requested', async () => {
|
||||
const bundle = await exportHTMLBundle(simpleCardDocument, { html: 'standalone' })
|
||||
const html = String(bundle.files[0]?.content)
|
||||
|
||||
expect(html).toContain('<!doctype html>')
|
||||
expect(html).toContain('data-open-pencil-html="standalone"')
|
||||
|
|
@ -45,12 +51,15 @@ describe('@open-pencil/dom-css', () => {
|
|||
expect(html).not.toContain('@tailwindcss/browser@4')
|
||||
})
|
||||
|
||||
it('loads the Tailwind browser runtime for standalone Tailwind HTML', () => {
|
||||
const html = serializeHTML(simpleCardDocument, { html: 'standalone', style: 'tailwind' })
|
||||
it('precompiles Tailwind CSS for standalone Tailwind HTML', async () => {
|
||||
const bundle = await exportHTMLBundle(simpleCardDocument, {
|
||||
html: 'standalone',
|
||||
style: 'tailwind'
|
||||
})
|
||||
const html = String(bundle.files[0]?.content)
|
||||
|
||||
expect(html).toContain(
|
||||
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>'
|
||||
)
|
||||
expect(html).toContain('<style>')
|
||||
expect(html).not.toContain('@tailwindcss/browser@4')
|
||||
})
|
||||
|
||||
it('uses the headless runtime outside browser contexts', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue