feat(dom-css): parse HTML headlessly

This commit is contained in:
Danila Poyarkov 2026-06-02 15:20:10 +03:00
parent 141d88cf94
commit 47c1f9fb38
6 changed files with 102 additions and 14 deletions

View file

@ -159,6 +159,9 @@
"packages/dom-css": {
"name": "@open-pencil/dom-css",
"version": "0.13.2",
"dependencies": {
"parse5": "^8.0.1",
},
"devDependencies": {
"tsdown": "^0.21.7",
"typescript": "~5.8.3",
@ -1538,7 +1541,7 @@
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@ -2248,6 +2251,8 @@
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
@ -3038,6 +3043,8 @@
"local-pkg/quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
"markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"mdast-util-gfm-table/markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],

View file

@ -8,7 +8,8 @@ Current scope:
- DOM-shaped `DesignDocument` / `DesignElement` types
- Browser-backed runtime adapter for native HTML parsing, serialization, and computed-style extraction
- Headless runtime placeholder for future `parse5` / CSSOM-backed execution
- Headless runtime adapter with `parse5` HTML parsing and explicit CSSOM fallback errors
- Initial SceneGraph ⇄ DesignDOM conversion helpers for simple HTML/CSS-shaped layouts
Planned scope:

View file

@ -44,5 +44,8 @@
"devDependencies": {
"tsdown": "^0.21.7",
"typescript": "~5.8.3"
},
"dependencies": {
"parse5": "^8.0.1"
}
}

View file

@ -1,23 +1,67 @@
import { serializeHTML } from '../serialize'
import type { CssRuntime, DesignDocument } from '../types'
import { parseFragment, type DefaultTreeAdapterTypes } from 'parse5'
function unavailable(feature: string): never {
throw new Error(
`${feature} requires a headless DOM/CSS adapter. Planned dependencies: parse5 + CSSOM.`
)
import { serializeHTML } from '../serialize'
import { parseStyleAttribute } from '../style-attribute'
import type { CssRuntime, DesignDocument, DesignElement, DesignNode } from '../types'
function attrsToRecord(attrs: DefaultTreeAdapterTypes.Element['attrs']): Record<string, string> {
const result: Record<string, string> = {}
for (const attr of attrs) result[attr.name] = attr.value
return result
}
function isTextNode(
node: DefaultTreeAdapterTypes.ChildNode
): node is DefaultTreeAdapterTypes.TextNode {
return node.nodeName === '#text'
}
function childToDesignNode(node: DefaultTreeAdapterTypes.ChildNode): DesignNode | null {
if (isTextNode(node)) {
return node.value.length > 0 ? { type: 'text', text: node.value } : null
}
if (!('tagName' in node)) return null
const attrs = attrsToRecord(node.attrs)
const element: DesignElement = {
type: 'element',
tagName: node.tagName.toLowerCase(),
attrs,
children: node.childNodes
.map(childToDesignNode)
.filter((child): child is DesignNode => child !== null)
}
const inlineStyle = parseStyleAttribute(attrs.style)
if (inlineStyle) element.inlineStyle = inlineStyle
return element
}
function parseHTML(html: string): DesignDocument {
const fragment = parseFragment(html)
return {
type: 'document',
children: fragment.childNodes
.map(childToDesignNode)
.filter((node): node is DesignNode => node !== null)
}
}
function unavailableError(feature: string): Error {
return new Error(`${feature} requires a headless CSSOM adapter.`)
}
export function createHeadlessCssRuntime(): CssRuntime {
return {
kind: 'headless',
parseHTML() {
return unavailable('parseHTML')
},
parseHTML,
serializeHTML(document: DesignDocument) {
return serializeHTML(document)
},
computeStyles() {
return unavailable('computeStyles')
return Promise.reject(unavailableError('computeStyles'))
}
}
}

View file

@ -0,0 +1,17 @@
import type { DesignStyleDeclaration } from './types'
export function parseStyleAttribute(value: string | undefined): DesignStyleDeclaration | undefined {
if (!value) return undefined
const style: DesignStyleDeclaration = {}
for (const declaration of value.split(';')) {
const separatorIndex = declaration.indexOf(':')
if (separatorIndex <= 0) continue
const property = declaration.slice(0, separatorIndex).trim().toLowerCase()
const propertyValue = declaration.slice(separatorIndex + 1).trim()
if (property.length > 0 && propertyValue.length > 0) style[property] = propertyValue
}
return Object.keys(style).length > 0 ? style : undefined
}

View file

@ -35,9 +35,25 @@ describe('@open-pencil/dom-css', () => {
)
})
it('keeps headless HTML parsing explicit until the parser adapter is added', () => {
it('parses HTML with the headless runtime', () => {
const runtime = createHeadlessCssRuntime()
const document = runtime.parseHTML(
'<section class="card" style="width: 320px; color: rgb(17, 24, 39)">OpenPencil</section>'
)
const section = document.children[0]
expect(section?.type).toBe('element')
if (section?.type !== 'element') return
expect(section.tagName).toBe('section')
expect(section.attrs.class).toBe('card')
expect(section.inlineStyle?.width).toBe('320px')
expect(section.inlineStyle?.color).toBe('rgb(17, 24, 39)')
expect(section.children[0]).toEqual({ type: 'text', text: 'OpenPencil' })
})
it('keeps headless style computation explicit until the CSSOM adapter is added', async () => {
const runtime = createHeadlessCssRuntime()
expect(() => runtime.parseHTML('<div />')).toThrow('headless DOM/CSS adapter')
await expect(runtime.computeStyles(documentFixture)).rejects.toThrow('headless CSSOM adapter')
})
})