feat(dom-css): ingest Tailwind CSS utilities
This commit is contained in:
parent
8fd4cbc105
commit
7db023b290
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
4
packages/dom-css/src/cssom.d.ts
vendored
4
packages/dom-css/src/cssom.d.ts
vendored
|
|
@ -10,6 +10,10 @@ declare module '@acemir/cssom' {
|
|||
style: CSSStyleDeclarationLike
|
||||
}
|
||||
|
||||
export interface CSSGroupingRuleLike {
|
||||
cssRules: unknown[]
|
||||
}
|
||||
|
||||
export interface CSSStyleSheetLike {
|
||||
cssRules: unknown[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue