openpencil/packages/dom-css/src/headless-css.ts
2026-06-30 10:47:25 +03:00

307 lines
9.5 KiB
TypeScript

import {
parse,
type CSSGroupingRuleLike,
type CSSStyleDeclarationLike,
type CSSStyleRuleLike
} from '@acemir/cssom'
import type { DesignDocument, DesignElement, DesignNode, DesignStyleDeclaration } from './types'
interface HeadlessCSSRule {
selector: string
specificity: number
order: number
style: DesignStyleDeclaration
}
interface ParsedHeadlessCSS {
rules: HeadlessCSSRule[]
customProperties: DesignStyleDeclaration
}
interface AncestorContext {
element: DesignElement
parent: AncestorContext | null
}
const INHERITED_PROPERTIES = new Set([
'color',
'font-family',
'font-size',
'font-weight',
'line-height'
])
function styleToRecord(
style: CSSStyleDeclarationLike,
customProperties: DesignStyleDeclaration = {}
): DesignStyleDeclaration {
const result: DesignStyleDeclaration = {}
for (const property of Array.from({ length: style.length }, (_, index) => style[index])) {
const value = resolveCSSValue(style.getPropertyValue(property), customProperties)
if (property && value) result[property] = value
}
return expandStyleShorthands(result)
}
function isStyleRule(rule: unknown): rule is CSSStyleRuleLike {
return (
typeof rule === 'object' &&
rule !== null &&
'selectorText' in rule &&
'style' in rule &&
typeof rule.selectorText === 'string'
)
}
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)
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())
.filter((selector) => selector.length > 0)
.map((selector) => ({
selector,
specificity: selectorSpecificity(selector),
order: order++,
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 {
const result = { ...style }
expandBoxShorthand(result, 'margin')
expandBoxShorthand(result, 'padding')
expandBorderShorthand(result)
expandBackgroundShorthand(result)
return result
}
function expandBoxShorthand(style: DesignStyleDeclaration, property: 'margin' | 'padding') {
const value = style[property]
if (!value) return
const parts = splitWhitespace(value)
const [top, right = top, bottom = top, left = right] = parts
if (!top || !right || !bottom || !left) return
style[`${property}-top`] ??= top
style[`${property}-right`] ??= right
style[`${property}-bottom`] ??= bottom
style[`${property}-left`] ??= left
}
function expandBorderShorthand(style: DesignStyleDeclaration) {
const value = style.border
if (!value) return
const parts = splitWhitespace(value)
const color = parts.find(isLikelyColor)
const width = parts.find((part) => /^\d/.test(part))
if (color) style['border-color'] ??= color
if (width) style['border-width'] ??= width
}
function expandBackgroundShorthand(style: DesignStyleDeclaration) {
const value = style.background
if (!value || style['background-color']) return
const color = splitWhitespace(value).find(isLikelyColor)
if (color) style['background-color'] = color
}
function splitWhitespace(value: string): string[] {
return value
.trim()
.split(/\s+/)
.filter((part) => part.length > 0)
}
function isLikelyColor(value: string): boolean {
return (
value.startsWith('#') ||
value.startsWith('rgb') ||
value.startsWith('hsl') ||
['black', 'white', 'transparent', 'red', 'green', 'blue'].includes(value.toLowerCase())
)
}
function classList(element: DesignElement): Set<string> {
const className = 'class' in element.attrs ? element.attrs.class : ''
return new Set(className.split(/\s+/).filter((name) => name.length > 0))
}
function elementId(element: DesignElement): string | undefined {
return 'id' in element.attrs ? element.attrs.id : undefined
}
function selectorSpecificity(selector: string): number {
const idCount = selector.match(/#[\w-]+/g)?.length ?? 0
const classCount = selector.match(/\.[\w-]+/g)?.length ?? 0
const tagCount = selector
.split(/[\s>]+/)
.filter((part) => part && !part.startsWith('.') && !part.startsWith('#')).length
return idCount * 100 + classCount * 10 + tagCount
}
function matchesSimpleSelector(element: DesignElement, selector: string): boolean {
if (selector.includes(':') || selector.includes('[')) return false
const idMatch = selector.match(/#([\w-]+)/)
if (idMatch?.[1] && elementId(element) !== idMatch[1]) return false
const classes = selector.match(/\.[\w-]+/g) ?? []
const elementClasses = classList(element)
if (!classes.every((name) => elementClasses.has(name.slice(1)))) return false
const tag = selector.replace(/#[\w-]+/g, '').replace(/\.[\w-]+/g, '')
return tag.length === 0 || element.tagName.toLowerCase() === tag.toLowerCase()
}
function matchesSelector(
element: DesignElement,
selector: string,
parent: AncestorContext | null
): boolean {
const childParts = selector.split('>').map((part) => part.trim())
if (childParts.length > 1) return matchesChildSelector(element, childParts, parent)
const descendantParts = selector.split(/\s+/).filter((part) => part.length > 0)
if (descendantParts.length > 1) return matchesDescendantSelector(element, descendantParts, parent)
return matchesSimpleSelector(element, selector)
}
function matchesSelectorParts(
element: DesignElement,
parts: string[],
parent: AncestorContext | null,
directParentOnly: boolean
): boolean {
const current = parts.at(-1)
if (!current || !matchesSimpleSelector(element, current)) return false
let ancestor = parent
for (const selector of parts.slice(0, -1).reverse()) {
if (!directParentOnly) {
while (ancestor && !matchesSimpleSelector(ancestor.element, selector)) {
ancestor = ancestor.parent
}
}
if (!ancestor || !matchesSimpleSelector(ancestor.element, selector)) return false
ancestor = ancestor.parent
}
return true
}
function matchesChildSelector(
element: DesignElement,
parts: string[],
parent: AncestorContext | null
): boolean {
return matchesSelectorParts(element, parts, parent, true)
}
function matchesDescendantSelector(
element: DesignElement,
parts: string[],
parent: AncestorContext | null
): boolean {
return matchesSelectorParts(element, parts, parent, false)
}
function applyComputedStyles(
node: DesignNode,
rules: HeadlessCSSRule[],
parent: AncestorContext | null,
inheritedStyle: DesignStyleDeclaration
): DesignNode {
if (node.type === 'text') return node
const computedStyle: DesignStyleDeclaration = pickInheritedStyle(inheritedStyle)
const matchingRules = rules
.filter((rule) => matchesSelector(node, rule.selector, parent))
.sort((left, right) => left.specificity - right.specificity || left.order - right.order)
for (const rule of matchingRules) Object.assign(computedStyle, rule.style)
Object.assign(computedStyle, expandStyleShorthands(node.inlineStyle ?? {}))
const context = { element: node, parent }
return {
...node,
computedStyle: Object.keys(computedStyle).length > 0 ? computedStyle : undefined,
children: node.children.map((child) =>
applyComputedStyles(child, rules, context, computedStyle)
)
}
}
function pickInheritedStyle(style: DesignStyleDeclaration): DesignStyleDeclaration {
const inherited: DesignStyleDeclaration = {}
for (const property of INHERITED_PROPERTIES) {
const value = style[property]
if (value) inherited[property] = value
}
return inherited
}
export function computeHeadlessStyles(document: DesignDocument, cssText = ''): DesignDocument {
const stylesheetText = [document.stylesheets?.map((sheet) => sheet.cssText).join('\n'), cssText]
.filter((text): text is string => !!text)
.join('\n')
const { rules } = parseRules(stylesheetText)
return {
...document,
children: document.children.map((child) => applyComputedStyles(child, rules, null, {}))
}
}