113 lines
3.5 KiB
TypeScript
113 lines
3.5 KiB
TypeScript
import { twirl } from 'twirlwind'
|
|
|
|
import type { DesignDocument, DesignElement, DesignNode, DesignText } from './types'
|
|
|
|
export interface SerializeHTMLOptions {
|
|
style?: 'inline' | 'tailwind'
|
|
}
|
|
|
|
const VOID_ELEMENTS = new Set([
|
|
'area',
|
|
'base',
|
|
'br',
|
|
'col',
|
|
'embed',
|
|
'hr',
|
|
'img',
|
|
'input',
|
|
'link',
|
|
'meta',
|
|
'param',
|
|
'source',
|
|
'track',
|
|
'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('>', '>')
|
|
}
|
|
|
|
function escapeAttr(value: string): string {
|
|
return escapeText(value).replaceAll('"', '"')
|
|
}
|
|
|
|
function serializeText(node: DesignText): string {
|
|
return escapeText(node.text)
|
|
}
|
|
|
|
function serializeStyle(node: DesignElement): string | undefined {
|
|
if (!node.inlineStyle || Object.keys(node.inlineStyle).length === 0) return undefined
|
|
return Object.entries(node.inlineStyle)
|
|
.filter(([, value]) => value !== '')
|
|
.map(([property, value]) => `${property}: ${value}`)
|
|
.join('; ')
|
|
}
|
|
|
|
function serializeTailwindClasses(node: DesignElement): string | undefined {
|
|
const style = serializeStyle(node)
|
|
if (!style) return undefined
|
|
const className = twirl(style)
|
|
return className.length > 0 ? className : undefined
|
|
}
|
|
|
|
export function mergeClassNames(...values: Array<string | undefined>): string | undefined {
|
|
const className = values
|
|
.flatMap((value) => (value ? splitWhitespace(value) : []))
|
|
.map((value) => value.trim())
|
|
.filter((value) => value.length > 0)
|
|
.join(' ')
|
|
return className.length > 0 ? className : undefined
|
|
}
|
|
|
|
function serializeAttrs(node: DesignElement, options: SerializeHTMLOptions): string {
|
|
const style = serializeStyle(node)
|
|
const tailwindClass = options.style === 'tailwind' ? serializeTailwindClasses(node) : undefined
|
|
const attrsWithoutStyle = { ...node.attrs }
|
|
delete attrsWithoutStyle.style
|
|
const sourceAttrs = options.style === 'tailwind' && tailwindClass ? attrsWithoutStyle : node.attrs
|
|
const attrs = {
|
|
...sourceAttrs,
|
|
...(tailwindClass ? { class: mergeClassNames(node.attrs.class, tailwindClass) } : {}),
|
|
...(style && options.style !== 'tailwind' ? { style } : {})
|
|
}
|
|
const serialized = Object.entries(attrs)
|
|
.filter(([, value]) => value !== '')
|
|
.map(([name, value]) => `${name}="${escapeAttr(value)}"`)
|
|
|
|
if (serialized.length === 0) return ''
|
|
return ` ${serialized.join(' ')}`
|
|
}
|
|
|
|
function serializeElement(node: DesignElement, options: SerializeHTMLOptions): string {
|
|
const tagName = node.tagName.toLowerCase()
|
|
const attrs = serializeAttrs(node, options)
|
|
if (VOID_ELEMENTS.has(tagName)) return `<${tagName}${attrs}>`
|
|
return `<${tagName}${attrs}>${node.children.map((child) => serializeNode(child, options)).join('')}</${tagName}>`
|
|
}
|
|
|
|
export function serializeNode(node: DesignNode, options: SerializeHTMLOptions = {}): string {
|
|
return node.type === 'text' ? serializeText(node) : serializeElement(node, options)
|
|
}
|
|
|
|
export function serializeHTML(
|
|
document: DesignDocument,
|
|
options: SerializeHTMLOptions = {}
|
|
): string {
|
|
return document.children.map((node) => serializeNode(node, options)).join('')
|
|
}
|