feat(dom-css): parse HTML headlessly
This commit is contained in:
parent
141d88cf94
commit
47c1f9fb38
9
bun.lock
9
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -44,5 +44,8 @@
|
|||
"devDependencies": {
|
||||
"tsdown": "^0.21.7",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"parse5": "^8.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
packages/dom-css/src/style-attribute.ts
Normal file
17
packages/dom-css/src/style-attribute.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue