feat(dom-css): ingest Tailwind CSS utilities

This commit is contained in:
Danila Poyarkov 2026-06-02 15:56:29 +03:00
parent 8fd4cbc105
commit 7db023b290
5 changed files with 125 additions and 12 deletions

View file

@ -8,11 +8,12 @@ Current scope:
- DOM-shaped `DesignDocument` / `DesignElement` types
- Browser-backed runtime adapter for native HTML parsing, serialization, and computed-style extraction
- Headless runtime adapter with `parse5` HTML parsing and CSSOM-backed style computation for basic selectors, cascade order, inheritance, and common shorthands
- Headless runtime adapter with `parse5` HTML parsing and CSSOM-backed style computation for basic selectors, nested CSSOM rules, cascade order, inheritance, common shorthands, and simple custom-property/calc values
- Initial SceneGraph ⇄ DesignDOM conversion helpers for simple HTML/CSS-shaped layouts
- Tailwind-generated CSS ingestion through CSSOM for utility-class card fixtures
Planned scope:
- SceneGraph ⇄ DesignDOM conversion
- CSSOM and cascade support in headless contexts
- Tailwind-generated CSS ingestion
- Broader SceneGraph ⇄ DesignDOM conversion
- Browser runtime parity fixtures
- First-class Tailwind compiler helpers

View file

@ -11,7 +11,8 @@ export function parseCSSNumber(value: string | undefined): number | null {
const trimmed = value.trim()
if (trimmed.length === 0 || trimmed === 'auto') return null
const parsed = Number.parseFloat(trimmed)
return Number.isFinite(parsed) ? parsed : null
if (!Number.isFinite(parsed)) return null
return trimmed.endsWith('rem') ? parsed * 16 : parsed
}
export function parseCSSColor(value: string | undefined): Color | null {

View file

@ -10,6 +10,10 @@ declare module '@acemir/cssom' {
style: CSSStyleDeclarationLike
}
export interface CSSGroupingRuleLike {
cssRules: unknown[]
}
export interface CSSStyleSheetLike {
cssRules: unknown[]
}

View file

@ -1,4 +1,9 @@
import { parse, type CSSStyleDeclarationLike, type CSSStyleRuleLike } from '@acemir/cssom'
import {
parse,
type CSSGroupingRuleLike,
type CSSStyleDeclarationLike,
type CSSStyleRuleLike
} from '@acemir/cssom'
import type { DesignDocument, DesignElement, DesignNode, DesignStyleDeclaration } from './types'
@ -9,6 +14,11 @@ interface HeadlessCSSRule {
style: DesignStyleDeclaration
}
interface ParsedHeadlessCSS {
rules: HeadlessCSSRule[]
customProperties: DesignStyleDeclaration
}
interface AncestorContext {
element: DesignElement
parent: AncestorContext | null
@ -22,10 +32,13 @@ const INHERITED_PROPERTIES = new Set([
'line-height'
])
function styleToRecord(style: CSSStyleDeclarationLike): DesignStyleDeclaration {
function styleToRecord(
style: CSSStyleDeclarationLike,
customProperties: DesignStyleDeclaration = {}
): DesignStyleDeclaration {
const result: DesignStyleDeclaration = {}
for (const property of Array.from({ length: style.length }, (_, index) => style[index])) {
const value = style.getPropertyValue(property)
const value = resolveCSSValue(style.getPropertyValue(property), customProperties)
if (property && value) result[property] = value
}
return expandStyleShorthands(result)
@ -41,11 +54,27 @@ function isStyleRule(rule: unknown): rule is CSSStyleRuleLike {
)
}
function parseRules(cssText: string): HeadlessCSSRule[] {
function isGroupingRule(rule: unknown): rule is CSSGroupingRuleLike {
return (
typeof rule === 'object' && rule !== null && 'cssRules' in rule && Array.isArray(rule.cssRules)
)
}
function collectStyleRules(rules: unknown[]): CSSStyleRuleLike[] {
return rules.flatMap((rule) => {
if (isStyleRule(rule)) return [rule]
if (isGroupingRule(rule)) return collectStyleRules(rule.cssRules)
return []
})
}
function parseRules(cssText: string): ParsedHeadlessCSS {
let order = 0
const sheet = parse(cssText)
return sheet.cssRules.filter(isStyleRule).flatMap((rule) => {
const style = styleToRecord(rule.style)
const styleRules = collectStyleRules(sheet.cssRules)
const customProperties = collectCustomProperties(styleRules)
const rules = styleRules.flatMap((rule) => {
const style = styleToRecord(rule.style, customProperties)
return rule.selectorText
.split(',')
.map((selector) => selector.trim())
@ -57,6 +86,34 @@ function parseRules(cssText: string): HeadlessCSSRule[] {
style
}))
})
return { rules, customProperties }
}
function collectCustomProperties(rules: CSSStyleRuleLike[]): DesignStyleDeclaration {
const customProperties: DesignStyleDeclaration = {}
for (const rule of rules) {
if (!rule.selectorText.split(',').some((selector) => selector.trim() === ':root')) continue
Object.assign(customProperties, styleToRecord(rule.style))
}
return customProperties
}
function resolveCSSValue(value: string, customProperties: DesignStyleDeclaration): string {
const withVariables = value.replaceAll(/var\((--[\w-]+)(?:,[^)]+)?\)/g, (_, name: string) => {
return customProperties[name] ?? ''
})
return resolveSimpleCalc(withVariables)
}
function resolveSimpleCalc(value: string): string {
const calc = value.match(/^calc\(([-\d.]+)(rem|px)?\s*\*\s*([-\d.]+)\)$/)
if (!calc?.[1] || !calc[3]) return value
const base = Number.parseFloat(calc[1])
const multiplier = Number.parseFloat(calc[3])
const unit = calc[2] ?? 'px'
if (!Number.isFinite(base) || !Number.isFinite(multiplier)) return value
return `${unit === 'rem' ? base * multiplier * 16 : base * multiplier}px`
}
function expandStyleShorthands(style: DesignStyleDeclaration): DesignStyleDeclaration {
@ -240,7 +297,7 @@ export function computeHeadlessStyles(document: DesignDocument, cssText = ''): D
const stylesheetText = [document.stylesheets?.map((sheet) => sheet.cssText).join('\n'), cssText]
.filter((text): text is string => !!text)
.join('\n')
const rules = parseRules(stylesheetText)
const { rules } = parseRules(stylesheetText)
return {
...document,

View file

@ -1,4 +1,7 @@
import { describe, expect, it } from 'bun:test'
import { readFile } from 'node:fs/promises'
import { compile } from 'tailwindcss'
import { colorToCSS } from '@open-pencil/core/color'
import {
@ -20,6 +23,8 @@ const cardHTML = `
</article>
`
const tailwindRoot = new URL('../../../node_modules/tailwindcss/', import.meta.url)
const cardCSS = `
.card {
display: flex;
@ -45,6 +50,19 @@ const cardCSS = `
}
`
async function compileTailwindClasses(classes: string[]) {
const compiler = await compile('@import "tailwindcss";', {
async loadStylesheet(id, base) {
const file = id === 'tailwindcss' ? 'index.css' : id.replace('tailwindcss/', '')
return {
base: base || tailwindRoot.pathname,
content: await readFile(new URL(file, tailwindRoot), 'utf8')
}
}
})
return compiler.build(classes)
}
const cardDocument: DesignDocument = {
type: 'document',
children: [
@ -136,6 +154,38 @@ describe('@open-pencil/dom-css conversion', () => {
expect(html).toContain('box-shadow')
})
it('projects a Tailwind card through generated CSS into a scene graph', async () => {
const runtime = createHeadlessCSSRuntime()
const classes = [
'flex',
'flex-col',
'gap-3',
'w-80',
'h-44',
'p-6',
'rounded-xl',
'bg-white',
'text-slate-900'
]
const document = await runtime.computeStyles(
runtime.parseHTML(`<article class="${classes.join(' ')}"><h1>OpenPencil</h1></article>`),
await compileTailwindClasses(classes)
)
const graph = designDocumentToSceneGraph(document)
const page = graph.getPages()[0]
const card = page ? graph.getChildren(page.id)[0] : undefined
expect(card?.type).toBe('FRAME')
if (card?.type !== 'FRAME') return
expect(card.width).toBe(320)
expect(card.height).toBe(176)
expect(card.layoutMode).toBe('VERTICAL')
expect(card.itemSpacing).toBe(12)
expect(card.paddingTop).toBe(24)
expect(card.cornerRadius).toBe(12)
expect(card.fills[0]?.type).toBe('SOLID')
})
it('projects a scene graph back into DesignDOM', () => {
const graph = designDocumentToSceneGraph(cardDocument)
const document = sceneGraphToDesignDocument(graph)