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 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 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 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 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 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.
|
- 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 fig --page "Page 1" # Export a page as .fig
|
||||||
openpencil export design.fig -f jsx --style tailwind # Tailwind JSX
|
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 --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 convert design.pen output.fig # Convert between document formats
|
||||||
openpencil import page.html --css styles.css -o page.fig # HTML/CSS → editable .fig
|
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 { mkdir, writeFile } from 'node:fs/promises'
|
||||||
import { basename, extname, resolve } from 'node:path'
|
import { basename, dirname, extname, join, resolve } from 'node:path'
|
||||||
|
|
||||||
import { defineCommand } from 'citty'
|
import { defineCommand } from 'citty'
|
||||||
|
|
||||||
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
|
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
|
||||||
import type { RasterExportFormat } from '@open-pencil/core/io'
|
import type { RasterExportFormat } from '@open-pencil/core/io'
|
||||||
import {
|
import {
|
||||||
|
exportHTMLBundle,
|
||||||
sceneGraphToDesignDocument,
|
sceneGraphToDesignDocument,
|
||||||
serializeHTML,
|
type ExportHTMLBundleOptions
|
||||||
type SerializeHTMLOptions
|
|
||||||
} from '@open-pencil/dom-css'
|
} from '@open-pencil/dom-css'
|
||||||
|
|
||||||
import { isAppMode, requireFile, rpc } from '#cli/app-client'
|
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 JSX_STYLES = new Set(['openpencil', 'tailwind'])
|
||||||
const HTML_STYLES = new Set(['inline', 'tailwind'])
|
const HTML_STYLES = new Set(['inline', 'tailwind'])
|
||||||
const HTML_MODES = new Set(['fragment', 'standalone'])
|
const HTML_MODES = new Set(['fragment', 'standalone'])
|
||||||
|
const HTML_ASSETS = new Set(['inline', 'external'])
|
||||||
|
const HTML_FONTS = new Set(['assets', 'none'])
|
||||||
|
|
||||||
interface ExportArgs {
|
interface ExportArgs {
|
||||||
file?: string
|
file?: string
|
||||||
|
|
@ -34,6 +36,8 @@ interface ExportArgs {
|
||||||
style: string
|
style: string
|
||||||
html: string
|
html: string
|
||||||
css: string
|
css: string
|
||||||
|
assets: string
|
||||||
|
fonts: string
|
||||||
thumbnail?: boolean
|
thumbnail?: boolean
|
||||||
width: string
|
width: string
|
||||||
height: string
|
height: string
|
||||||
|
|
@ -105,6 +109,27 @@ function targetLabel(pageName?: string, nodeId?: string): string {
|
||||||
|
|
||||||
type FileExportTarget = { scope: 'node'; nodeId: string } | { scope: 'page'; pageId: 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(
|
async function exportHTMLFromFile(
|
||||||
args: ExportArgs,
|
args: ExportArgs,
|
||||||
graph: Awaited<ReturnType<typeof loadDocument>>,
|
graph: Awaited<ReturnType<typeof loadDocument>>,
|
||||||
|
|
@ -114,12 +139,16 @@ async function exportHTMLFromFile(
|
||||||
const document = sceneGraphToDesignDocument(graph, {
|
const document = sceneGraphToDesignDocument(graph, {
|
||||||
rootId: target.scope === 'page' ? target.pageId : target.nodeId
|
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'))
|
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)}`))
|
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)',
|
description: 'HTML CSS output: inline or tailwind (default: inline)',
|
||||||
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' },
|
thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' },
|
||||||
width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
|
width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
|
||||||
height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' },
|
height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' },
|
||||||
|
|
@ -270,6 +309,16 @@ export default defineCommand({
|
||||||
process.exit(1)
|
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)) {
|
if (isAppMode(args.file)) {
|
||||||
await exportViaApp(format, args)
|
await exportViaApp(format, args)
|
||||||
} else {
|
} 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 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 =
|
type WebFontProvider =
|
||||||
| ReturnType<typeof providers.google>
|
| ReturnType<typeof providers.google>
|
||||||
| ReturnType<typeof providers.fontsource>
|
| ReturnType<typeof providers.fontsource>
|
||||||
|
|
@ -54,6 +77,123 @@ function isRemoteFontSource(
|
||||||
return 'url' in source
|
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 {
|
export class WebFontResolver {
|
||||||
private enabled = new Set<WebFontProviderId>(
|
private enabled = new Set<WebFontProviderId>(
|
||||||
WEB_FONT_PROVIDER_IDS.filter((provider) => DEFAULT_WEB_FONT_PROVIDER_SETTINGS[provider])
|
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
|
```sh
|
||||||
openpencil export design.fig -f html --html standalone --css inline
|
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
|
||||||
|
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.
|
HTML export is available in file mode.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,8 @@ openpencil export [file] [options]
|
||||||
| `--style` | | JSX style: `openpencil` (default), `tailwind` |
|
| `--style` | | JSX style: `openpencil` (default), `tailwind` |
|
||||||
| `--html` | | HTML mode: `fragment` (default), `standalone` |
|
| `--html` | | HTML mode: `fragment` (default), `standalone` |
|
||||||
| `--css` | | HTML CSS output: `inline` (default), `tailwind` |
|
| `--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 |
|
| `--thumbnail` | | Export page thumbnail instead of full render |
|
||||||
| `--width` | | Thumbnail width (default: 1920) |
|
| `--width` | | Thumbnail width (default: 1920) |
|
||||||
| `--height` | | Thumbnail height (default: 1080) |
|
| `--height` | | Thumbnail height (default: 1080) |
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ function styleFromTextNode(node: SceneNode): DesignStyleDeclaration {
|
||||||
}
|
}
|
||||||
const textTransform = textCaseToCSS(node.textCase)
|
const textTransform = textCaseToCSS(node.textCase)
|
||||||
if (textTransform) style['text-transform'] = textTransform
|
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
|
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'
|
import type * as DesignTypes from './types'
|
||||||
|
|
||||||
|
export { exportHTMLBundle } from './html-export'
|
||||||
export { serializeHTML, serializeNode } from './serialize'
|
export { serializeHTML, serializeNode } from './serialize'
|
||||||
export { createBrowserCSSRuntime, createCSSRuntime, createHeadlessCSSRuntime } from './runtime'
|
export { createBrowserCSSRuntime, createCSSRuntime, createHeadlessCSSRuntime } from './runtime'
|
||||||
export {
|
export {
|
||||||
|
|
@ -61,6 +62,7 @@ export type {
|
||||||
BrowserToSceneGraphOptions
|
BrowserToSceneGraphOptions
|
||||||
} from './browser'
|
} from './browser'
|
||||||
export type { CompileTailwindCSSOptions } from './tailwind'
|
export type { CompileTailwindCSSOptions } from './tailwind'
|
||||||
|
export type { ExportHTMLBundle, ExportHTMLBundleOptions, ExportHTMLFile } from './html-export'
|
||||||
export type { SerializeHTMLOptions } from './serialize'
|
export type { SerializeHTMLOptions } from './serialize'
|
||||||
export type { ToSceneGraphOptions } from './to-scene-graph'
|
export type { ToSceneGraphOptions } from './to-scene-graph'
|
||||||
export type CSSComputeOptions = DesignTypes.CSSComputeOptions
|
export type CSSComputeOptions = DesignTypes.CSSComputeOptions
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,11 @@
|
||||||
import { twirl } from 'twirlwind'
|
import { twirl } from 'twirlwind'
|
||||||
|
|
||||||
import type {
|
import type { DesignDocument, DesignElement, DesignNode, DesignText } from './types'
|
||||||
DesignDocument,
|
|
||||||
DesignElement,
|
|
||||||
DesignNode,
|
|
||||||
DesignStyleDeclaration,
|
|
||||||
DesignText
|
|
||||||
} from './types'
|
|
||||||
|
|
||||||
export interface SerializeHTMLOptions {
|
export interface SerializeHTMLOptions {
|
||||||
style?: 'inline' | 'tailwind'
|
style?: 'inline' | 'tailwind'
|
||||||
html?: 'fragment' | 'standalone'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAILWIND_BROWSER_CDN = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
|
|
||||||
|
|
||||||
const VOID_ELEMENTS = new Set([
|
const VOID_ELEMENTS = new Set([
|
||||||
'area',
|
'area',
|
||||||
'base',
|
'base',
|
||||||
|
|
@ -32,6 +23,21 @@ const VOID_ELEMENTS = new Set([
|
||||||
'wbr'
|
'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 {
|
function escapeText(value: string): string {
|
||||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||||
}
|
}
|
||||||
|
|
@ -59,9 +65,9 @@ function serializeTailwindClasses(node: DesignElement): string | undefined {
|
||||||
return className.length > 0 ? className : 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
|
const className = values
|
||||||
.flatMap((value) => value?.split(/\s+/) ?? [])
|
.flatMap((value) => (value ? splitWhitespace(value) : []))
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
.filter((value) => value.length > 0)
|
.filter((value) => value.length > 0)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
|
|
@ -94,77 +100,6 @@ function serializeElement(node: DesignElement, options: SerializeHTMLOptions): s
|
||||||
return `<${tagName}${attrs}>${node.children.map((child) => serializeNode(child, options)).join('')}</${tagName}>`
|
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 {
|
export function serializeNode(node: DesignNode, options: SerializeHTMLOptions = {}): string {
|
||||||
return node.type === 'text' ? serializeText(node) : serializeElement(node, options)
|
return node.type === 'text' ? serializeText(node) : serializeElement(node, options)
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +108,5 @@ export function serializeHTML(
|
||||||
document: DesignDocument,
|
document: DesignDocument,
|
||||||
options: SerializeHTMLOptions = {}
|
options: SerializeHTMLOptions = {}
|
||||||
): string {
|
): string {
|
||||||
if (options.html === 'standalone') return serializeStandaloneHTML(document, options)
|
|
||||||
return document.children.map((node) => serializeNode(node, options)).join('')
|
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 { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
||||||
|
|
||||||
import type { DesignElement } from '../src/index'
|
import type { DesignElement, DesignNode } from '../src/index'
|
||||||
import {
|
import {
|
||||||
createHeadlessCSSRuntime,
|
createHeadlessCSSRuntime,
|
||||||
designDocumentToSceneGraph,
|
designDocumentToSceneGraph,
|
||||||
|
|
@ -28,6 +28,16 @@ function expectText(node: SceneNode | undefined) {
|
||||||
return node
|
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() {
|
function createStyleRoundTripGraph() {
|
||||||
return designDocumentToSceneGraph({
|
return designDocumentToSceneGraph({
|
||||||
type: 'document',
|
type: 'document',
|
||||||
|
|
@ -244,6 +254,25 @@ describe('@open-pencil/dom-css conversion', () => {
|
||||||
expect(html).toContain('box-shadow')
|
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 () => {
|
it('projects a manually built DesignDOM document into a scene graph', async () => {
|
||||||
const runtime = createHeadlessCSSRuntime()
|
const runtime = createHeadlessCSSRuntime()
|
||||||
const document = await runtime.computeStyles(runtime.parseHTML(cardHTML), cardCSS)
|
const document = await runtime.computeStyles(runtime.parseHTML(cardHTML), cardCSS)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { describe, expect, it } from 'bun:test'
|
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'
|
import { cardDocument, TEST_COLORS } from './helpers'
|
||||||
|
|
||||||
describe('@open-pencil/dom-css runtime', () => {
|
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>')
|
expect(html).toBe('<section class="card flex p-4 gap-2 bg-white">OpenPencil</section>')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('serializes standalone HTML documents when requested', () => {
|
it('exports standalone HTML documents when requested', async () => {
|
||||||
const html = serializeHTML(cardDocument, { html: 'standalone' })
|
const bundle = await exportHTMLBundle(cardDocument, { html: 'standalone' })
|
||||||
|
const html = String(bundle.files[0]?.content)
|
||||||
|
|
||||||
expect(html).toContain('<!doctype html>')
|
expect(html).toContain('<!doctype html>')
|
||||||
expect(html).toContain('data-open-pencil-html="standalone"')
|
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')
|
expect(html).not.toContain('@tailwindcss/browser@4')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads the Tailwind browser runtime for standalone Tailwind HTML', () => {
|
it('precompiles Tailwind CSS for standalone Tailwind HTML', async () => {
|
||||||
const html = serializeHTML(cardDocument, { html: 'standalone', style: 'tailwind' })
|
const bundle = await exportHTMLBundle(cardDocument, { html: 'standalone', style: 'tailwind' })
|
||||||
|
const html = String(bundle.files[0]?.content)
|
||||||
|
|
||||||
expect(html).toContain(
|
expect(html).toContain('<style>')
|
||||||
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>'
|
expect(html).not.toContain('@tailwindcss/browser@4')
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the headless runtime outside browser contexts', () => {
|
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()
|
const html = await Bun.file(output).text()
|
||||||
expect(html).toContain('<!doctype html>')
|
expect(html).toContain('<!doctype html>')
|
||||||
expect(html).toContain('data-open-pencil-html="standalone"')
|
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).toContain('position: absolute')
|
||||||
expect(html).not.toContain('@tailwindcss/browser@4')
|
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 { dir, figPath } = await createFigFixture()
|
||||||
const output = join(dir, 'card-standalone-tailwind.html')
|
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()
|
const html = await Bun.file(output).text()
|
||||||
expect(html).toContain('<!doctype html>')
|
expect(html).toContain('<!doctype html>')
|
||||||
expect(html).toContain(
|
expect(html).toContain('<style>')
|
||||||
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>'
|
|
||||||
)
|
|
||||||
expect(html).toContain('class="')
|
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 { describe, expect, it } from 'bun:test'
|
||||||
|
|
||||||
import type { DesignElement } from '@open-pencil/dom-css'
|
import type { DesignElement, DesignNode } from '@open-pencil/dom-css'
|
||||||
import {
|
import {
|
||||||
compileTailwindCSS,
|
compileTailwindCSS,
|
||||||
createHeadlessCSSRuntime,
|
createHeadlessCSSRuntime,
|
||||||
|
|
@ -32,6 +32,16 @@ function expectFrame(node: SceneNode | undefined) {
|
||||||
return node
|
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() {
|
function createStyleRoundTripGraph() {
|
||||||
return designDocumentToSceneGraph({
|
return designDocumentToSceneGraph({
|
||||||
type: 'document',
|
type: 'document',
|
||||||
|
|
@ -238,6 +248,25 @@ describe('@open-pencil/dom-css conversion', () => {
|
||||||
expect(html).toContain('box-shadow')
|
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', () => {
|
it('maps CSS shape constraints, clipping, corners, and borders', () => {
|
||||||
const graph = designDocumentToSceneGraph({
|
const graph = designDocumentToSceneGraph({
|
||||||
type: 'document',
|
type: 'document',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { describe, expect, it } from 'bun:test'
|
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'
|
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>')
|
expect(html).toBe('<section class="card flex p-4 gap-2 bg-white">OpenPencil</section>')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('serializes standalone HTML documents when requested', () => {
|
it('exports standalone HTML documents when requested', async () => {
|
||||||
const html = serializeHTML(simpleCardDocument, { html: 'standalone' })
|
const bundle = await exportHTMLBundle(simpleCardDocument, { html: 'standalone' })
|
||||||
|
const html = String(bundle.files[0]?.content)
|
||||||
|
|
||||||
expect(html).toContain('<!doctype html>')
|
expect(html).toContain('<!doctype html>')
|
||||||
expect(html).toContain('data-open-pencil-html="standalone"')
|
expect(html).toContain('data-open-pencil-html="standalone"')
|
||||||
|
|
@ -45,12 +51,15 @@ describe('@open-pencil/dom-css', () => {
|
||||||
expect(html).not.toContain('@tailwindcss/browser@4')
|
expect(html).not.toContain('@tailwindcss/browser@4')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads the Tailwind browser runtime for standalone Tailwind HTML', () => {
|
it('precompiles Tailwind CSS for standalone Tailwind HTML', async () => {
|
||||||
const html = serializeHTML(simpleCardDocument, { html: 'standalone', style: 'tailwind' })
|
const bundle = await exportHTMLBundle(simpleCardDocument, {
|
||||||
|
html: 'standalone',
|
||||||
|
style: 'tailwind'
|
||||||
|
})
|
||||||
|
const html = String(bundle.files[0]?.content)
|
||||||
|
|
||||||
expect(html).toContain(
|
expect(html).toContain('<style>')
|
||||||
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>'
|
expect(html).not.toContain('@tailwindcss/browser@4')
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the headless runtime outside browser contexts', () => {
|
it('uses the headless runtime outside browser contexts', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue