diff --git a/packages/dom-css/README.md b/packages/dom-css/README.md index 7bc00b966..c1f274364 100644 --- a/packages/dom-css/README.md +++ b/packages/dom-css/README.md @@ -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 diff --git a/packages/dom-css/src/css-values.ts b/packages/dom-css/src/css-values.ts index 50d51a96a..fe52d1352 100644 --- a/packages/dom-css/src/css-values.ts +++ b/packages/dom-css/src/css-values.ts @@ -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 { diff --git a/packages/dom-css/src/cssom.d.ts b/packages/dom-css/src/cssom.d.ts index a2d311b6f..5c8ea80ae 100644 --- a/packages/dom-css/src/cssom.d.ts +++ b/packages/dom-css/src/cssom.d.ts @@ -10,6 +10,10 @@ declare module '@acemir/cssom' { style: CSSStyleDeclarationLike } + export interface CSSGroupingRuleLike { + cssRules: unknown[] + } + export interface CSSStyleSheetLike { cssRules: unknown[] } diff --git a/packages/dom-css/src/headless-css.ts b/packages/dom-css/src/headless-css.ts index 1bb37b302..c0910db6f 100644 --- a/packages/dom-css/src/headless-css.ts +++ b/packages/dom-css/src/headless-css.ts @@ -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, diff --git a/tests/engine/dom-css/conversion.test.ts b/tests/engine/dom-css/conversion.test.ts index 5a05ec6d9..3ab8b4fe4 100644 --- a/tests/engine/dom-css/conversion.test.ts +++ b/tests/engine/dom-css/conversion.test.ts @@ -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 = ` ` +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(`

OpenPencil

`), + 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)