Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/detector/engines/static-html/css-cascade.mjs
1import fs from 'node:fs';2import path from 'node:path';34import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';5import { parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';67// ---------------------------------------------------------------------------8// jsdom CSS-variable border override map9// ---------------------------------------------------------------------------10//11// jsdom's CSSOM silently drops any border shorthand that contains a var()12// reference — the computed style for the element then shows empty width,13// empty style, and a default black color. That's enough to hide the most14// common real-world side-tab pattern in AI-generated pages:15//16// :root { --brand: #87a8ff; }17// .card { border-left: 5px solid var(--brand); border-radius: 4px; }18//19// Real browsers (and therefore the browser detector path) resolve var()20// natively, so this only affects the Node jsdom path.21//22// This pre-pass walks the stylesheets, finds any rule whose per-side or23// all-sides border property contains var(), resolves the var() against24// :root-level custom properties (read from the documentElement's computed25// style, which jsdom DOES handle correctly), and attaches the resolved26// width+color to every element that matches the rule's selector. The27// Node-side `checkElementBorders` adapter consumes that map as a fallback28// whenever jsdom's computed style came back empty.29//30// Limitations (intentional, to keep the pass simple):31// * Only :root-level custom properties are resolved. Scoped overrides on32// descendants are not tracked — uncommon in practice and would require33// a per-element cascade walk.34// * @media / @supports wrapped rules are ignored (jsdom often mishandles35// these anyway).36// * The fallback only fills sides that jsdom left empty, so any rule37// whose border parses normally still wins via the computed style.3839const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i;4041// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb().42// CSS variables typically hold hex or named colors, so normalize those to43// rgb() before handing the value off to the shared check. Anything we don't44// recognise is passed through unchanged — isNeutralColor then treats it as45// non-neutral, which is the safer default (matches the oklch-era bugfix).46const NAMED_COLORS = {47white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128],48grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0],49green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0],50};5152function normalizeColorForCheck(value) {53if (!value) return value;54const v = value.trim();55const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);56if (hex6) {57const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];58return `rgb(${r}, ${g}, ${b})`;59}60const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);61if (hex3) {62const [r, g, b] = [63parseInt(hex3[1] + hex3[1], 16),64parseInt(hex3[2] + hex3[2], 16),65parseInt(hex3[3] + hex3[3], 16),66];67return `rgb(${r}, ${g}, ${b})`;68}69const named = NAMED_COLORS[v.toLowerCase()];70if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`;71return v;72}7374function buildBorderOverrideMap(document, window) {75const map = new Map();76const rootStyle = window.getComputedStyle(document.documentElement);7778function resolveVar(value, depth = 0) {79if (!value || depth > 10 || !value.includes('var(')) return value;80return value.replace(81/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g,82(_, name, fallback) => {83const v = rootStyle.getPropertyValue(name).trim();84if (v) return resolveVar(v, depth + 1);85if (fallback) return resolveVar(fallback.trim(), depth + 1);86return '';87}88);89}9091function parseShorthand(text) {92const m = text.trim().match(BORDER_SHORTHAND_RE);93if (!m) return null;94return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) };95}9697// Read from the per-property accessors on rule.style. jsdom preserves98// each border-* shorthand it parsed, even when the overall cssText has99// been truncated (e.g. a `border: 1px solid var(...)` followed by a100// `border-left: ...` loses the first declaration but keeps the second).101const SIDE_PROPS = [102['borderLeft', 'Left'],103['borderRight', 'Right'],104['borderTop', 'Top'],105['borderBottom', 'Bottom'],106['borderInlineStart', 'Left'],107['borderInlineEnd', 'Right'],108];109110for (const sheet of document.styleSheets) {111let rules;112try { rules = sheet.cssRules || []; } catch { continue; }113for (const rule of rules) {114// CSSStyleRule only; skip @media / @keyframes / @supports wrappers.115if (rule.type !== 1 || !rule.style || !rule.selectorText) continue;116117const perSide = {};118119for (const [prop, side] of SIDE_PROPS) {120const val = rule.style[prop];121if (!val || !val.includes('var(')) continue;122const parsed = parseShorthand(resolveVar(val));123if (parsed && parsed.color) perSide[side] = parsed;124}125126// Uniform `border: <w> <style> var(...)` applies to every side the127// per-side map didn't already claim.128const borderAll = rule.style.border;129if (borderAll && borderAll.includes('var(')) {130const parsed = parseShorthand(resolveVar(borderAll));131if (parsed && parsed.color) {132for (const s of ['Top', 'Right', 'Bottom', 'Left']) {133if (!perSide[s]) perSide[s] = parsed;134}135}136}137138// Longhand `border-*-color: var(...)` with width/style in separate139// declarations. Rare in AI-generated pages, but cheap to cover.140for (const [prop, side] of [141['borderLeftColor', 'Left'],142['borderRightColor', 'Right'],143['borderTopColor', 'Top'],144['borderBottomColor', 'Bottom'],145]) {146const val = rule.style[prop];147if (!val || !val.includes('var(')) continue;148const resolved = resolveVar(val).trim();149if (!resolved) continue;150// Width may or may not come from this rule — that's fine; the151// adapter only substitutes the color when jsdom left it as a152// literal var() string.153if (!perSide[side]) perSide[side] = { width: 0, color: normalizeColorForCheck(resolved) };154}155156if (Object.keys(perSide).length === 0) continue;157158let matched;159try { matched = document.querySelectorAll(rule.selectorText); }160catch { continue; }161162for (const el of matched) {163const existing = map.get(el);164if (existing) {165// Later rules overwrite earlier ones — approximates source-order166// cascade for equal-specificity rules and is good enough for the167// uncontested var()-dropped sides we're trying to recover.168Object.assign(existing, perSide);169} else {170map.set(el, { ...perSide });171}172}173}174}175176return map;177}178179// Strip `@layer NAME { … }` wrappers from a CSS / HTML source, leaving180// the inner rules as flat CSS. jsdom doesn't implement CSS @layer, so181// any rule inside a layer block becomes invisible to getComputedStyle.182// Tailwind v4 makes this ubiquitous: every utility class lives in183// `@layer utilities`, and Preflight lives in `@layer base`. Without184// unwrapping, every Tailwind-styled element returns empty computed185// styles. We walk the source character-by-character, balancing braces186// so we correctly handle nested style rules inside the layer block.187function unwrapCssAtLayer(source) {188if (!source || !source.includes('@layer')) return source;189// Find `@layer <name>? {` openers. The match starts at the @, and190// we then balance braces from the opening { onward.191const re = /@layer\b[^{;]*\{/g;192let out = '';193let lastIdx = 0;194let m;195while ((m = re.exec(source)) !== null) {196const openStart = m.index;197const openEnd = m.index + m[0].length; // position right after `{`198let depth = 1;199let i = openEnd;200while (i < source.length && depth > 0) {201const c = source.charCodeAt(i);202if (c === 0x7b /* { */) depth++;203else if (c === 0x7d /* } */) depth--;204i++;205}206if (depth !== 0) {207// Unbalanced — bail and return source unchanged.208return source;209}210// Emit everything before the @layer, then the inner contents211// (between the opening { and the matched closing }), then advance.212out += source.slice(lastIdx, openStart);213out += source.slice(openEnd, i - 1); // i-1 = position of the closing }214lastIdx = i;215re.lastIndex = i;216}217out += source.slice(lastIdx);218return out;219}220221// ---------------------------------------------------------------------------222// Static HTML/CSS detection (default for local HTML files)223// ---------------------------------------------------------------------------224225const STATIC_INHERITED_PROPS = new Set([226'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',227'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',228'webkitHyphens',229]);230231const STATIC_DEFAULT_STYLE = {232color: 'rgb(0, 0, 0)',233backgroundColor: 'rgba(0, 0, 0, 0)',234backgroundImage: 'none',235borderTopWidth: '0px',236borderRightWidth: '0px',237borderBottomWidth: '0px',238borderLeftWidth: '0px',239borderTopColor: 'rgb(0, 0, 0)',240borderRightColor: 'rgb(0, 0, 0)',241borderBottomColor: 'rgb(0, 0, 0)',242borderLeftColor: 'rgb(0, 0, 0)',243borderRadius: '0px',244outlineWidth: '0px',245outlineColor: 'rgb(0, 0, 0)',246outlineStyle: 'none',247boxShadow: 'none',248fontFamily: '',249fontSize: '16px',250fontStyle: 'normal',251fontWeight: '400',252lineHeight: 'normal',253letterSpacing: 'normal',254textTransform: 'none',255textAlign: 'start',256hyphens: 'manual',257webkitHyphens: 'manual',258transitionProperty: '',259transitionTimingFunction: '',260animationName: '',261animationTimingFunction: '',262webkitBackgroundClip: '',263backgroundClip: '',264width: '',265height: '',266paddingTop: '0px',267paddingRight: '0px',268paddingBottom: '0px',269paddingLeft: '0px',270marginTop: '0px',271marginRight: '0px',272marginBottom: '0px',273marginLeft: '0px',274position: 'static',275visibility: 'visible',276top: 'auto',277right: 'auto',278bottom: 'auto',279left: 'auto',280inset: '',281display: '',282overflow: 'visible',283overflowX: 'visible',284overflowY: 'visible',285};286287const STATIC_PROP_MAP = {288'background-color': 'backgroundColor',289'background-image': 'backgroundImage',290'background-clip': 'backgroundClip',291'-webkit-background-clip': 'webkitBackgroundClip',292'border-radius': 'borderRadius',293'border-top-width': 'borderTopWidth',294'border-right-width': 'borderRightWidth',295'border-bottom-width': 'borderBottomWidth',296'border-left-width': 'borderLeftWidth',297'border-top-color': 'borderTopColor',298'border-right-color': 'borderRightColor',299'border-bottom-color': 'borderBottomColor',300'border-left-color': 'borderLeftColor',301'outline-width': 'outlineWidth',302'outline-color': 'outlineColor',303'outline-style': 'outlineStyle',304'box-shadow': 'boxShadow',305'font-family': 'fontFamily',306'font-size': 'fontSize',307'font-style': 'fontStyle',308'font-weight': 'fontWeight',309'line-height': 'lineHeight',310'letter-spacing': 'letterSpacing',311'text-transform': 'textTransform',312'text-align': 'textAlign',313'hyphens': 'hyphens',314'-webkit-hyphens': 'webkitHyphens',315'transition-property': 'transitionProperty',316'transition-timing-function': 'transitionTimingFunction',317'animation-name': 'animationName',318'animation-timing-function': 'animationTimingFunction',319'width': 'width',320'height': 'height',321'padding-top': 'paddingTop',322'padding-right': 'paddingRight',323'padding-bottom': 'paddingBottom',324'padding-left': 'paddingLeft',325'margin-top': 'marginTop',326'margin-right': 'marginRight',327'margin-bottom': 'marginBottom',328'margin-left': 'marginLeft',329'position': 'position',330'visibility': 'visibility',331'top': 'top',332'right': 'right',333'bottom': 'bottom',334'left': 'left',335'inset': 'inset',336'display': 'display',337'overflow': 'overflow',338'overflow-x': 'overflowX',339'overflow-y': 'overflowY',340};341342const STATIC_NAMED_COLORS = {343black: { r: 0, g: 0, b: 0, a: 1 },344white: { r: 255, g: 255, b: 255, a: 1 },345transparent: { r: 0, g: 0, b: 0, a: 0 },346gray: { r: 128, g: 128, b: 128, a: 1 },347grey: { r: 128, g: 128, b: 128, a: 1 },348silver: { r: 192, g: 192, b: 192, a: 1 },349red: { r: 255, g: 0, b: 0, a: 1 },350green: { r: 0, g: 128, b: 0, a: 1 },351blue: { r: 0, g: 0, b: 255, a: 1 },352};353354function splitCssList(value) {355const parts = [];356let depth = 0, quote = '', start = 0;357for (let i = 0; i < value.length; i++) {358const ch = value[i];359if (quote) {360if (ch === quote && value[i - 1] !== '\\') quote = '';361continue;362}363if (ch === '"' || ch === "'") { quote = ch; continue; }364if (ch === '(' || ch === '[') depth++;365else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);366else if (ch === ',' && depth === 0) {367parts.push(value.slice(start, i).trim());368start = i + 1;369}370}371const tail = value.slice(start).trim();372if (tail) parts.push(tail);373return parts;374}375376function splitCssTokens(value) {377const tokens = [];378let depth = 0, quote = '', current = '';379for (let i = 0; i < value.length; i++) {380const ch = value[i];381if (quote) {382current += ch;383if (ch === quote && value[i - 1] !== '\\') quote = '';384continue;385}386if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }387if (ch === '(') { depth++; current += ch; continue; }388if (ch === ')') { depth = Math.max(0, depth - 1); current += ch; continue; }389if (/\s/.test(ch) && depth === 0) {390if (current) { tokens.push(current); current = ''; }391continue;392}393current += ch;394}395if (current) tokens.push(current);396return tokens;397}398399function cssPropToCamel(prop) {400if (!prop) return prop;401const mapped = STATIC_PROP_MAP[prop];402if (mapped) return mapped;403return prop.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());404}405406function staticColorToCss(c) {407if (!c) return '';408if (c.a != null && c.a < 1) return `rgba(${c.r}, ${c.g}, ${c.b}, ${Number(c.a.toFixed(3))})`;409return `rgb(${c.r}, ${c.g}, ${c.b})`;410}411412function parseStaticColor(value) {413const parsed = parseAnyColor(value);414if (parsed) return parsed;415const named = STATIC_NAMED_COLORS[String(value || '').trim().toLowerCase()];416return named ? { ...named } : null;417}418419function extractStaticColor(value) {420if (!value) return '';421const raw = String(value).trim();422if (/^var\(/i.test(raw)) return raw;423const colorLike = raw.match(/(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{3,8}\b|\b(?:black|white|gray|grey|silver|red|green|blue|transparent)\b)/i);424if (!colorLike) return '';425return colorLike[0];426}427428function normalizeStaticCssValue(prop, value, customProps, parentStyle, currentStyle = null) {429let resolved = resolveVarRefs(String(value || '').trim(), customProps);430if (resolved === 'inherit') return parentStyle?.[prop] || STATIC_DEFAULT_STYLE[prop] || '';431const isModernBorderColor = /^border[A-Z][a-z]+Color$/.test(prop) && /^(?:oklch|oklab|lch|lab|hsl|hwb)\(/i.test(resolved);432if (!isModernBorderColor && (/color$/i.test(prop) || prop === 'color' || prop === 'backgroundColor')) {433const parsed = parseStaticColor(resolved);434if (parsed) resolved = staticColorToCss(parsed);435}436if (prop === 'fontSize') {437const base = parseFloat(parentStyle?.fontSize) || 16;438const px = resolveLengthPx(resolved, base);439if (px != null) resolved = `${px}px`;440}441if (prop === 'letterSpacing') {442const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;443const px = resolveLengthPx(resolved, base);444if (px != null) resolved = `${px}px`;445}446if (prop === 'lineHeight' && resolved !== 'normal') {447const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;448const px = resolveLengthPx(resolved, base);449if (px != null) resolved = `${px}px`;450}451return resolved;452}453454function expandStaticBoxValues(tokens) {455if (tokens.length === 0) return ['0px', '0px', '0px', '0px'];456if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]];457if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]];458if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]];459return [tokens[0], tokens[1], tokens[2], tokens[3]];460}461462function parseStaticBorder(value) {463const tokens = splitCssTokens(value);464let width = '', color = '';465for (const token of tokens) {466if (!width && /^-?[\d.]+(?:px|rem|em|%)$/.test(token)) width = token;467if (!color) color = extractStaticColor(token);468}469return { width, color };470}471472function parseStaticFont(value) {473const out = [];474const slashParts = value.match(/(?:^|\s)([\d.]+(?:px|rem|em|%))(?:\/([^\s]+))?/);475if (/\bitalic\b/i.test(value)) out.push(['fontStyle', 'italic']);476const weight = value.match(/\b([1-9]00|bold|normal|lighter|bolder)\b/i);477if (weight) out.push(['fontWeight', weight[1]]);478if (slashParts) {479out.push(['fontSize', slashParts[1]]);480if (slashParts[2]) out.push(['lineHeight', slashParts[2]]);481const familyStart = value.indexOf(slashParts[0]) + slashParts[0].length;482const family = value.slice(familyStart).trim();483if (family) out.push(['fontFamily', family]);484}485return out;486}487488function parseStaticTransition(value) {489const props = [];490const timings = [];491for (const item of splitCssList(value)) {492const tokens = splitCssTokens(item);493const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));494if (timing) timings.push(timing);495const prop = tokens.find(token => /^[a-z-]+$/i.test(token) && !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none)$/.test(token) && !/s$/.test(token));496if (prop) props.push(prop);497}498return {499property: props.join(', '),500timing: timings.join(', '),501};502}503504function parseStaticAnimation(value) {505const names = [];506const timings = [];507for (const item of splitCssList(value)) {508const tokens = splitCssTokens(item);509const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));510if (timing) timings.push(timing);511const name = tokens.find(token =>512/^[a-z_-][\w-]*$/i.test(token) &&513!/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none|running|paused)$/.test(token)514);515if (name) names.push(name);516}517return {518name: names.join(', '),519timing: timings.join(', '),520};521}522523function expandStaticDeclaration(prop, value) {524const p = prop.toLowerCase();525const v = String(value || '').trim();526if (!v) return [];527if (p.startsWith('--')) return [[p, v]];528if (p === 'background') {529const out = [];530const hasImage = /gradient|url\(/i.test(v);531if (hasImage) out.push(['backgroundImage', v]);532const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;533const color = extractStaticColor(hasImage ? beforeImage : v);534if (color) out.push(['backgroundColor', color]);535return out;536}537if (p === 'border') {538const parsed = parseStaticBorder(v);539const out = [];540for (const side of ['Top', 'Right', 'Bottom', 'Left']) {541if (parsed.width) out.push([`border${side}Width`, parsed.width]);542if (parsed.color) out.push([`border${side}Color`, parsed.color]);543}544return out;545}546if (p === 'outline') {547// `outline` shorthand: width | style | color, in any order. Reuse the548// border parser for width + color, then sniff a style keyword from the549// tokens (solid|dashed|...). `outline: 0` (single-token zero) zeros550// the width and effectively hides the outline.551const tokens = splitCssTokens(v);552const parsed = parseStaticBorder(v);553const styleToken = tokens.find(t =>554/^(none|hidden|solid|dashed|dotted|double|groove|ridge|inset|outset)$/i.test(t)555);556const out = [];557if (parsed.width) out.push(['outlineWidth', parsed.width]);558if (parsed.color) out.push(['outlineColor', parsed.color]);559if (styleToken) out.push(['outlineStyle', styleToken.toLowerCase()]);560// `outline: 0` with no other tokens: explicit zero width.561if (!parsed.width && /^0(?:px|rem|em|%)?$/.test(v.trim())) {562out.push(['outlineWidth', '0px']);563}564return out;565}566const sideMatch = p.match(/^border-(top|right|bottom|left)$/);567if (sideMatch) {568const parsed = parseStaticBorder(v);569const side = sideMatch[1][0].toUpperCase() + sideMatch[1].slice(1);570return [571...(parsed.width ? [[`border${side}Width`, parsed.width]] : []),572...(parsed.color ? [[`border${side}Color`, parsed.color]] : []),573];574}575if (p === 'border-width') {576const vals = expandStaticBoxValues(splitCssTokens(v));577return [578['borderTopWidth', vals[0]],579['borderRightWidth', vals[1]],580['borderBottomWidth', vals[2]],581['borderLeftWidth', vals[3]],582];583}584if (p === 'border-color') {585const vals = expandStaticBoxValues(splitCssTokens(v));586return [587['borderTopColor', vals[0]],588['borderRightColor', vals[1]],589['borderBottomColor', vals[2]],590['borderLeftColor', vals[3]],591];592}593if (p === 'padding') {594const vals = expandStaticBoxValues(splitCssTokens(v));595return [596['paddingTop', vals[0]],597['paddingRight', vals[1]],598['paddingBottom', vals[2]],599['paddingLeft', vals[3]],600];601}602if (p === 'margin') {603const vals = expandStaticBoxValues(splitCssTokens(v));604return [605['marginTop', vals[0]],606['marginRight', vals[1]],607['marginBottom', vals[2]],608['marginLeft', vals[3]],609];610}611if (p === 'font') return parseStaticFont(v);612if (p === 'transition') {613const parsed = parseStaticTransition(v);614return [615...(parsed.property ? [['transitionProperty', parsed.property]] : []),616...(parsed.timing ? [['transitionTimingFunction', parsed.timing]] : []),617];618}619if (p === 'animation') {620const parsed = parseStaticAnimation(v);621return [622...(parsed.name ? [['animationName', parsed.name]] : []),623...(parsed.timing ? [['animationTimingFunction', parsed.timing]] : []),624];625}626const mapped = cssPropToCamel(p);627if (STATIC_DEFAULT_STYLE[mapped] != null || STATIC_INHERITED_PROPS.has(mapped)) {628return [[mapped, v]];629}630return [];631}632633function compareStaticPriority(a, b) {634if (!a) return true;635if (!!b.important !== !!a.important) return !!b.important;636if (!!b.inline !== !!a.inline) return !!b.inline;637for (let i = 0; i < 3; i++) {638if ((b.specificity[i] || 0) !== (a.specificity[i] || 0)) {639return (b.specificity[i] || 0) > (a.specificity[i] || 0);640}641}642return b.order >= a.order;643}644645function staticSpecificity(selector) {646const noWhere = selector.replace(/:where\([^)]*\)/g, '');647const ids = (noWhere.match(/#[\w-]+/g) || []).length;648const classes = (noWhere.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]*\))?/g) || []).length;649const stripped = noWhere650.replace(/#[\w-]+/g, ' ')651.replace(/\.[\w-]+|\[[^\]]+\]|:{1,2}[\w-]+(?:\([^)]*\))?/g, ' ')652.replace(/[*>+~(),]/g, ' ');653const types = (stripped.match(/\b[a-zA-Z][\w-]*\b/g) || []).length;654return [ids, classes, types];655}656657function applyStaticDeclaration(specified, node, prop, value, meta) {658let map = specified.get(node);659if (!map) { map = new Map(); specified.set(node, map); }660for (const [expandedProp, expandedValue] of expandStaticDeclaration(prop, value)) {661const existing = map.get(expandedProp);662const next = { ...meta, prop: expandedProp, value: expandedValue };663if (compareStaticPriority(existing, next)) map.set(expandedProp, next);664}665}666667function parseStaticStyleAttribute(styleText, orderBase = 0) {668const decls = [];669for (const part of String(styleText || '').split(';')) {670const idx = part.indexOf(':');671if (idx <= 0) continue;672const prop = part.slice(0, idx).trim();673let value = part.slice(idx + 1).trim();674const important = /!important\s*$/i.test(value);675value = value.replace(/\s*!important\s*$/i, '').trim();676decls.push({ prop, value, important, order: orderBase + decls.length });677}678return decls;679}680681function collectStaticCssRules(cssText, csstree) {682const rules = [];683let ast;684try {685ast = csstree.parse(cssText, { positions: false, parseValue: true, parseCustomProperty: false });686} catch {687return rules;688}689let order = 0;690const walkList = (list, atRuleStack = []) => {691list?.forEach?.(node => {692if (node.type === 'Rule' && node.block) {693if (atRuleStack.some(name => /keyframes$/i.test(name))) return;694const selectorText = csstree.generate(node.prelude).trim();695const declarations = [];696node.block.children?.forEach?.(child => {697if (child.type !== 'Declaration') return;698declarations.push({699prop: child.property,700value: csstree.generate(child.value).trim(),701important: !!child.important,702});703});704for (const selector of splitCssList(selectorText)) {705if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });706}707return;708}709if (node.type === 'Atrule' && node.block) {710const name = String(node.name || '').toLowerCase();711if (name === 'media' || name === 'supports' || name === 'layer') {712walkList(node.block.children, [...atRuleStack, name]);713}714}715});716};717walkList(ast.children);718return rules;719}720721class StaticElement {722constructor(node, doc) {723this.node = node;724this._doc = doc;725this.nodeType = 1;726this.tagName = String(node.name || '').toUpperCase();727this.nodeName = this.tagName;728}729get parentElement() {730let cur = this.node.parent;731while (cur && cur.type !== 'tag') cur = cur.parent;732return cur ? this._doc.wrap(cur) : null;733}734get previousElementSibling() {735let cur = this.node.prev;736while (cur && cur.type !== 'tag') cur = cur.prev;737return cur ? this._doc.wrap(cur) : null;738}739get children() {740return (this.node.children || []).filter(child => child.type === 'tag').map(child => this._doc.wrap(child));741}742get childNodes() {743return (this.node.children || []).map(child => {744if (child.type === 'text') return { nodeType: 3, textContent: child.data || '' };745if (child.type === 'tag') return this._doc.wrap(child);746return { nodeType: 8, textContent: child.data || '' };747});748}749get textContent() {750return this._doc.domutils.textContent(this.node);751}752get className() {753return this.getAttribute('class') || '';754}755get id() {756return this.getAttribute('id') || '';757}758getAttribute(name) {759return this.node.attribs?.[name] ?? null;760}761querySelector(selector) {762try {763const found = this._doc.selectOne(selector, this.node.children || []);764return found ? this._doc.wrap(found) : null;765} catch {766return null;767}768}769querySelectorAll(selector) {770try {771return this._doc.selectAll(selector, this.node.children || []).map(node => this._doc.wrap(node));772} catch {773return [];774}775}776closest(selector) {777let cur = this.node;778while (cur && cur.type === 'tag') {779try {780if (this._doc.is(cur, selector)) return this._doc.wrap(cur);781} catch {782return null;783}784cur = cur.parent;785while (cur && cur.type !== 'tag') cur = cur.parent;786}787return null;788}789contains(other) {790let cur = other?.node || null;791while (cur) {792if (cur === this.node) return true;793cur = cur.parent;794}795return false;796}797}798799class StaticDocument {800constructor(root, modules) {801this.root = root;802this.selectAll = modules.selectAll;803this.selectOne = modules.selectOne;804this.is = modules.is;805this.domutils = modules.domutils;806this._wrappers = new WeakMap();807this._styleMap = new WeakMap();808}809wrap(node) {810let wrapped = this._wrappers.get(node);811if (!wrapped) {812wrapped = new StaticElement(node, this);813this._wrappers.set(node, wrapped);814}815return wrapped;816}817querySelectorAll(selector) {818try {819return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));820} catch {821return [];822}823}824querySelector(selector) {825try {826const found = this.selectOne(selector, this.root.children || []);827return found ? this.wrap(found) : null;828} catch {829return null;830}831}832get documentElement() {833return this.querySelector('html');834}835get body() {836return this.querySelector('body');837}838setStyle(node, style) {839this._styleMap.set(node, style);840}841getStyle(el) {842return this._styleMap.get(el.node) || makeStaticStyle();843}844}845846function makeStaticStyle(values = {}) {847const style = { ...STATIC_DEFAULT_STYLE, ...values };848style.getPropertyValue = (prop) => {849const key = cssPropToCamel(prop);850return style[key] || style[prop] || '';851};852return style;853}854855function buildStaticWindow(staticDoc) {856return {857document: staticDoc,858getComputedStyle: (el) => staticDoc.getStyle(el),859};860}861862function collectStaticCssText(root, fileDir, profile, filePath, modules) {863const styleTexts = [];864for (const styleEl of modules.selectAll('style', root.children || [])) {865styleTexts.push(modules.domutils.textContent(styleEl));866}867const links = modules.selectAll('link', root.children || []);868for (const link of links) {869const rel = link.attribs?.rel || '';870const href = link.attribs?.href || '';871if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;872const cssPath = path.resolve(fileDir, href);873try {874const css = profileStep(profile, {875engine: 'static-html',876phase: 'preprocess',877ruleId: 'inline-linked-stylesheet',878target: filePath,879detail: href,880}, () => fs.readFileSync(cssPath, 'utf-8'));881styleTexts.push(css);882} catch { /* skip unreadable */ }883}884return styleTexts.join('\n');885}886887function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {888const specified = new Map();889const allNodes = modules.selectAll('*', root.children || []);890const rules = profileStep(profile, {891engine: 'static-html',892phase: 'parse-css',893ruleId: 'css-rules',894target: filePath,895}, () => collectStaticCssRules(cssText, modules.csstree));896897profileStep(profile, {898engine: 'static-html',899phase: 'selector-match',900ruleId: 'css-selectors',901target: filePath,902}, () => {903for (const rule of rules) {904let matched;905try {906matched = modules.selectAll(rule.selector, root.children || []);907} catch {908recordProfileEvent(profile, {909engine: 'static-html',910phase: 'selector-match',911ruleId: 'unsupported-selector',912target: filePath,913ms: 0,914findings: 0,915detail: rule.selector,916});917continue;918}919for (const node of matched) {920for (const decl of rule.declarations) {921applyStaticDeclaration(specified, node, decl.prop, decl.value, {922important: decl.important,923specificity: rule.specificity,924order: rule.order,925inline: false,926});927}928}929}930931let inlineOrder = rules.length + 1;932for (const node of allNodes) {933const styleText = node.attribs?.style;934if (!styleText) continue;935for (const decl of parseStaticStyleAttribute(styleText, inlineOrder)) {936applyStaticDeclaration(specified, node, decl.prop, decl.value, {937important: decl.important,938specificity: [1, 0, 0],939order: decl.order,940inline: true,941});942}943inlineOrder += 1000;944}945});946947const computeNode = (node, parentStyle = null, parentCustom = new Map()) => {948const specifiedMap = specified.get(node) || new Map();949const customProps = new Map(parentCustom);950for (const [prop, decl] of specifiedMap) {951if (prop.startsWith('--')) customProps.set(prop, resolveVarRefs(decl.value, customProps));952}953const values = {};954for (const prop of Object.keys(STATIC_DEFAULT_STYLE)) {955if (STATIC_INHERITED_PROPS.has(prop) && parentStyle?.[prop] != null) values[prop] = parentStyle[prop];956else values[prop] = STATIC_DEFAULT_STYLE[prop];957}958for (const [prop, decl] of specifiedMap) {959if (prop.startsWith('--')) continue;960values[prop] = normalizeStaticCssValue(prop, decl.value, customProps, parentStyle, values);961}962const style = makeStaticStyle(values);963staticDoc.setStyle(node, style);964for (const child of node.children || []) {965if (child.type === 'tag') computeNode(child, style, customProps);966}967};968969profileStep(profile, {970engine: 'static-html',971phase: 'cascade',972ruleId: 'compute-styles',973target: filePath,974}, () => {975for (const child of root.children || []) {976if (child.type === 'tag') computeNode(child);977}978});979}980981export {982BORDER_SHORTHAND_RE,983NAMED_COLORS,984normalizeColorForCheck,985buildBorderOverrideMap,986unwrapCssAtLayer,987STATIC_INHERITED_PROPS,988STATIC_DEFAULT_STYLE,989STATIC_PROP_MAP,990STATIC_NAMED_COLORS,991splitCssList,992splitCssTokens,993cssPropToCamel,994staticColorToCss,995parseStaticColor,996extractStaticColor,997normalizeStaticCssValue,998expandStaticBoxValues,999parseStaticBorder,1000parseStaticFont,1001parseStaticTransition,1002parseStaticAnimation,1003expandStaticDeclaration,1004compareStaticPriority,1005staticSpecificity,1006applyStaticDeclaration,1007parseStaticStyleAttribute,1008collectStaticCssRules,1009StaticElement,1010StaticDocument,1011makeStaticStyle,1012buildStaticWindow,1013collectStaticCssText,1014buildStaticStyleMap,1015};1016