From 47c1f9fb3824636c3573da54e62d4e5e9bef609b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 2 Jun 2026 15:20:10 +0300 Subject: [PATCH] feat(dom-css): parse HTML headlessly --- bun.lock | 9 +++- packages/dom-css/README.md | 3 +- packages/dom-css/package.json | 3 ++ packages/dom-css/src/runtime/headless.ts | 64 ++++++++++++++++++++---- packages/dom-css/src/style-attribute.ts | 17 +++++++ tests/engine/dom-css/runtime.test.ts | 20 +++++++- 6 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 packages/dom-css/src/style-attribute.ts diff --git a/bun.lock b/bun.lock index 7696e092e..8644b1475 100644 --- a/bun.lock +++ b/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=="], diff --git a/packages/dom-css/README.md b/packages/dom-css/README.md index ef3d3988b..b9bf44ef2 100644 --- a/packages/dom-css/README.md +++ b/packages/dom-css/README.md @@ -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: diff --git a/packages/dom-css/package.json b/packages/dom-css/package.json index 8f2567b0d..4399a87dc 100644 --- a/packages/dom-css/package.json +++ b/packages/dom-css/package.json @@ -44,5 +44,8 @@ "devDependencies": { "tsdown": "^0.21.7", "typescript": "~5.8.3" + }, + "dependencies": { + "parse5": "^8.0.1" } } diff --git a/packages/dom-css/src/runtime/headless.ts b/packages/dom-css/src/runtime/headless.ts index 21126d890..90312a2c5 100644 --- a/packages/dom-css/src/runtime/headless.ts +++ b/packages/dom-css/src/runtime/headless.ts @@ -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 { + const result: Record = {} + 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')) } } } diff --git a/packages/dom-css/src/style-attribute.ts b/packages/dom-css/src/style-attribute.ts new file mode 100644 index 000000000..4ea37a83d --- /dev/null +++ b/packages/dom-css/src/style-attribute.ts @@ -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 +} diff --git a/tests/engine/dom-css/runtime.test.ts b/tests/engine/dom-css/runtime.test.ts index 311053cbe..70ffb816e 100644 --- a/tests/engine/dom-css/runtime.test.ts +++ b/tests/engine/dom-css/runtime.test.ts @@ -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( + '
OpenPencil
' + ) + 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('
')).toThrow('headless DOM/CSS adapter') + await expect(runtime.computeStyles(documentFixture)).rejects.toThrow('headless CSSOM adapter') }) })