css-cascade.mjs

  1import fs from 'node:fs';
  2import path from 'node:path';
  3
  4import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
  5import { parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
  6
  7// ---------------------------------------------------------------------------
  8// jsdom CSS-variable border override map
  9// ---------------------------------------------------------------------------
 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 most
 14// 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 or
 23// all-sides border property contains var(), resolves the var() against
 24// :root-level custom properties (read from the documentElement's computed
 25// style, which jsdom DOES handle correctly), and attaches the resolved
 26// width+color to every element that matches the rule's selector. The
 27// Node-side `checkElementBorders` adapter consumes that map as a fallback
 28// 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 on
 32//     descendants are not tracked — uncommon in practice and would require
 33//     a per-element cascade walk.
 34//   * @media / @supports wrapped rules are ignored (jsdom often mishandles
 35//     these anyway).
 36//   * The fallback only fills sides that jsdom left empty, so any rule
 37//     whose border parses normally still wins via the computed style.
 38
 39const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i;
 40
 41// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb().
 42// CSS variables typically hold hex or named colors, so normalize those to
 43// rgb() before handing the value off to the shared check. Anything we don't
 44// recognise is passed through unchanged — isNeutralColor then treats it as
 45// non-neutral, which is the safer default (matches the oklch-era bugfix).
 46const NAMED_COLORS = {
 47  white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128],
 48  grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0],
 49  green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0],
 50};
 51
 52function normalizeColorForCheck(value) {
 53  if (!value) return value;
 54  const v = value.trim();
 55  const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
 56  if (hex6) {
 57    const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
 58    return `rgb(${r}, ${g}, ${b})`;
 59  }
 60  const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
 61  if (hex3) {
 62    const [r, g, b] = [
 63      parseInt(hex3[1] + hex3[1], 16),
 64      parseInt(hex3[2] + hex3[2], 16),
 65      parseInt(hex3[3] + hex3[3], 16),
 66    ];
 67    return `rgb(${r}, ${g}, ${b})`;
 68  }
 69  const named = NAMED_COLORS[v.toLowerCase()];
 70  if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`;
 71  return v;
 72}
 73
 74function buildBorderOverrideMap(document, window) {
 75  const map = new Map();
 76  const rootStyle = window.getComputedStyle(document.documentElement);
 77
 78  function resolveVar(value, depth = 0) {
 79    if (!value || depth > 10 || !value.includes('var(')) return value;
 80    return value.replace(
 81      /var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g,
 82      (_, name, fallback) => {
 83        const v = rootStyle.getPropertyValue(name).trim();
 84        if (v) return resolveVar(v, depth + 1);
 85        if (fallback) return resolveVar(fallback.trim(), depth + 1);
 86        return '';
 87      }
 88    );
 89  }
 90
 91  function parseShorthand(text) {
 92    const m = text.trim().match(BORDER_SHORTHAND_RE);
 93    if (!m) return null;
 94    return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) };
 95  }
 96
 97  // Read from the per-property accessors on rule.style. jsdom preserves
 98  // each border-* shorthand it parsed, even when the overall cssText has
 99  // been truncated (e.g. a `border: 1px solid var(...)` followed by a
100  // `border-left: ...` loses the first declaration but keeps the second).
101  const SIDE_PROPS = [
102    ['borderLeft', 'Left'],
103    ['borderRight', 'Right'],
104    ['borderTop', 'Top'],
105    ['borderBottom', 'Bottom'],
106    ['borderInlineStart', 'Left'],
107    ['borderInlineEnd', 'Right'],
108  ];
109
110  for (const sheet of document.styleSheets) {
111    let rules;
112    try { rules = sheet.cssRules || []; } catch { continue; }
113    for (const rule of rules) {
114      // CSSStyleRule only; skip @media / @keyframes / @supports wrappers.
115      if (rule.type !== 1 || !rule.style || !rule.selectorText) continue;
116
117      const perSide = {};
118
119      for (const [prop, side] of SIDE_PROPS) {
120        const val = rule.style[prop];
121        if (!val || !val.includes('var(')) continue;
122        const parsed = parseShorthand(resolveVar(val));
123        if (parsed && parsed.color) perSide[side] = parsed;
124      }
125
126      // Uniform `border: <w> <style> var(...)` applies to every side the
127      // per-side map didn't already claim.
128      const borderAll = rule.style.border;
129      if (borderAll && borderAll.includes('var(')) {
130        const parsed = parseShorthand(resolveVar(borderAll));
131        if (parsed && parsed.color) {
132          for (const s of ['Top', 'Right', 'Bottom', 'Left']) {
133            if (!perSide[s]) perSide[s] = parsed;
134          }
135        }
136      }
137
138      // Longhand `border-*-color: var(...)` with width/style in separate
139      // declarations. Rare in AI-generated pages, but cheap to cover.
140      for (const [prop, side] of [
141        ['borderLeftColor', 'Left'],
142        ['borderRightColor', 'Right'],
143        ['borderTopColor', 'Top'],
144        ['borderBottomColor', 'Bottom'],
145      ]) {
146        const val = rule.style[prop];
147        if (!val || !val.includes('var(')) continue;
148        const resolved = resolveVar(val).trim();
149        if (!resolved) continue;
150        // Width may or may not come from this rule — that's fine; the
151        // adapter only substitutes the color when jsdom left it as a
152        // literal var() string.
153        if (!perSide[side]) perSide[side] = { width: 0, color: normalizeColorForCheck(resolved) };
154      }
155
156      if (Object.keys(perSide).length === 0) continue;
157
158      let matched;
159      try { matched = document.querySelectorAll(rule.selectorText); }
160      catch { continue; }
161
162      for (const el of matched) {
163        const existing = map.get(el);
164        if (existing) {
165          // Later rules overwrite earlier ones — approximates source-order
166          // cascade for equal-specificity rules and is good enough for the
167          // uncontested var()-dropped sides we're trying to recover.
168          Object.assign(existing, perSide);
169        } else {
170          map.set(el, { ...perSide });
171        }
172      }
173    }
174  }
175
176  return map;
177}
178
179// Strip `@layer NAME { … }` wrappers from a CSS / HTML source, leaving
180// the inner rules as flat CSS. jsdom doesn't implement CSS @layer, so
181// any rule inside a layer block becomes invisible to getComputedStyle.
182// Tailwind v4 makes this ubiquitous: every utility class lives in
183// `@layer utilities`, and Preflight lives in `@layer base`. Without
184// unwrapping, every Tailwind-styled element returns empty computed
185// styles. We walk the source character-by-character, balancing braces
186// so we correctly handle nested style rules inside the layer block.
187function unwrapCssAtLayer(source) {
188  if (!source || !source.includes('@layer')) return source;
189  // Find `@layer <name>? {` openers. The match starts at the @, and
190  // we then balance braces from the opening { onward.
191  const re = /@layer\b[^{;]*\{/g;
192  let out = '';
193  let lastIdx = 0;
194  let m;
195  while ((m = re.exec(source)) !== null) {
196    const openStart = m.index;
197    const openEnd = m.index + m[0].length; // position right after `{`
198    let depth = 1;
199    let i = openEnd;
200    while (i < source.length && depth > 0) {
201      const c = source.charCodeAt(i);
202      if (c === 0x7b /* { */) depth++;
203      else if (c === 0x7d /* } */) depth--;
204      i++;
205    }
206    if (depth !== 0) {
207      // Unbalanced — bail and return source unchanged.
208      return source;
209    }
210    // Emit everything before the @layer, then the inner contents
211    // (between the opening { and the matched closing }), then advance.
212    out += source.slice(lastIdx, openStart);
213    out += source.slice(openEnd, i - 1); // i-1 = position of the closing }
214    lastIdx = i;
215    re.lastIndex = i;
216  }
217  out += source.slice(lastIdx);
218  return out;
219}
220
221// ---------------------------------------------------------------------------
222// Static HTML/CSS detection (default for local HTML files)
223// ---------------------------------------------------------------------------
224
225const STATIC_INHERITED_PROPS = new Set([
226  'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
227  'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
228  'webkitHyphens',
229]);
230
231const STATIC_DEFAULT_STYLE = {
232  color: 'rgb(0, 0, 0)',
233  backgroundColor: 'rgba(0, 0, 0, 0)',
234  backgroundImage: 'none',
235  borderTopWidth: '0px',
236  borderRightWidth: '0px',
237  borderBottomWidth: '0px',
238  borderLeftWidth: '0px',
239  borderTopColor: 'rgb(0, 0, 0)',
240  borderRightColor: 'rgb(0, 0, 0)',
241  borderBottomColor: 'rgb(0, 0, 0)',
242  borderLeftColor: 'rgb(0, 0, 0)',
243  borderRadius: '0px',
244  boxShadow: 'none',
245  fontFamily: '',
246  fontSize: '16px',
247  fontStyle: 'normal',
248  fontWeight: '400',
249  lineHeight: 'normal',
250  letterSpacing: 'normal',
251  textTransform: 'none',
252  textAlign: 'start',
253  hyphens: 'manual',
254  webkitHyphens: 'manual',
255  transitionProperty: '',
256  transitionTimingFunction: '',
257  animationName: '',
258  animationTimingFunction: '',
259  webkitBackgroundClip: '',
260  backgroundClip: '',
261  width: '',
262  height: '',
263  paddingTop: '0px',
264  paddingRight: '0px',
265  paddingBottom: '0px',
266  paddingLeft: '0px',
267  position: 'static',
268  display: '',
269};
270
271const STATIC_PROP_MAP = {
272  'background-color': 'backgroundColor',
273  'background-image': 'backgroundImage',
274  'background-clip': 'backgroundClip',
275  '-webkit-background-clip': 'webkitBackgroundClip',
276  'border-radius': 'borderRadius',
277  'border-top-width': 'borderTopWidth',
278  'border-right-width': 'borderRightWidth',
279  'border-bottom-width': 'borderBottomWidth',
280  'border-left-width': 'borderLeftWidth',
281  'border-top-color': 'borderTopColor',
282  'border-right-color': 'borderRightColor',
283  'border-bottom-color': 'borderBottomColor',
284  'border-left-color': 'borderLeftColor',
285  'box-shadow': 'boxShadow',
286  'font-family': 'fontFamily',
287  'font-size': 'fontSize',
288  'font-style': 'fontStyle',
289  'font-weight': 'fontWeight',
290  'line-height': 'lineHeight',
291  'letter-spacing': 'letterSpacing',
292  'text-transform': 'textTransform',
293  'text-align': 'textAlign',
294  'hyphens': 'hyphens',
295  '-webkit-hyphens': 'webkitHyphens',
296  'transition-property': 'transitionProperty',
297  'transition-timing-function': 'transitionTimingFunction',
298  'animation-name': 'animationName',
299  'animation-timing-function': 'animationTimingFunction',
300  'width': 'width',
301  'height': 'height',
302  'padding-top': 'paddingTop',
303  'padding-right': 'paddingRight',
304  'padding-bottom': 'paddingBottom',
305  'padding-left': 'paddingLeft',
306  'position': 'position',
307  'display': 'display',
308};
309
310const STATIC_NAMED_COLORS = {
311  black: { r: 0, g: 0, b: 0, a: 1 },
312  white: { r: 255, g: 255, b: 255, a: 1 },
313  transparent: { r: 0, g: 0, b: 0, a: 0 },
314  gray: { r: 128, g: 128, b: 128, a: 1 },
315  grey: { r: 128, g: 128, b: 128, a: 1 },
316  silver: { r: 192, g: 192, b: 192, a: 1 },
317  red: { r: 255, g: 0, b: 0, a: 1 },
318  green: { r: 0, g: 128, b: 0, a: 1 },
319  blue: { r: 0, g: 0, b: 255, a: 1 },
320};
321
322function splitCssList(value) {
323  const parts = [];
324  let depth = 0, quote = '', start = 0;
325  for (let i = 0; i < value.length; i++) {
326    const ch = value[i];
327    if (quote) {
328      if (ch === quote && value[i - 1] !== '\\') quote = '';
329      continue;
330    }
331    if (ch === '"' || ch === "'") { quote = ch; continue; }
332    if (ch === '(' || ch === '[') depth++;
333    else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
334    else if (ch === ',' && depth === 0) {
335      parts.push(value.slice(start, i).trim());
336      start = i + 1;
337    }
338  }
339  const tail = value.slice(start).trim();
340  if (tail) parts.push(tail);
341  return parts;
342}
343
344function splitCssTokens(value) {
345  const tokens = [];
346  let depth = 0, quote = '', current = '';
347  for (let i = 0; i < value.length; i++) {
348    const ch = value[i];
349    if (quote) {
350      current += ch;
351      if (ch === quote && value[i - 1] !== '\\') quote = '';
352      continue;
353    }
354    if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
355    if (ch === '(') { depth++; current += ch; continue; }
356    if (ch === ')') { depth = Math.max(0, depth - 1); current += ch; continue; }
357    if (/\s/.test(ch) && depth === 0) {
358      if (current) { tokens.push(current); current = ''; }
359      continue;
360    }
361    current += ch;
362  }
363  if (current) tokens.push(current);
364  return tokens;
365}
366
367function cssPropToCamel(prop) {
368  if (!prop) return prop;
369  const mapped = STATIC_PROP_MAP[prop];
370  if (mapped) return mapped;
371  return prop.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
372}
373
374function staticColorToCss(c) {
375  if (!c) return '';
376  if (c.a != null && c.a < 1) return `rgba(${c.r}, ${c.g}, ${c.b}, ${Number(c.a.toFixed(3))})`;
377  return `rgb(${c.r}, ${c.g}, ${c.b})`;
378}
379
380function parseStaticColor(value) {
381  const parsed = parseAnyColor(value);
382  if (parsed) return parsed;
383  const named = STATIC_NAMED_COLORS[String(value || '').trim().toLowerCase()];
384  return named ? { ...named } : null;
385}
386
387function extractStaticColor(value) {
388  if (!value) return '';
389  const raw = String(value).trim();
390  if (/^var\(/i.test(raw)) return raw;
391  const 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);
392  if (!colorLike) return '';
393  return colorLike[0];
394}
395
396function normalizeStaticCssValue(prop, value, customProps, parentStyle, currentStyle = null) {
397  let resolved = resolveVarRefs(String(value || '').trim(), customProps);
398  if (resolved === 'inherit') return parentStyle?.[prop] || STATIC_DEFAULT_STYLE[prop] || '';
399  const isModernBorderColor = /^border[A-Z][a-z]+Color$/.test(prop) && /^(?:oklch|oklab|lch|lab|hsl|hwb)\(/i.test(resolved);
400  if (!isModernBorderColor && (/color$/i.test(prop) || prop === 'color' || prop === 'backgroundColor')) {
401    const parsed = parseStaticColor(resolved);
402    if (parsed) resolved = staticColorToCss(parsed);
403  }
404  if (prop === 'fontSize') {
405    const base = parseFloat(parentStyle?.fontSize) || 16;
406    const px = resolveLengthPx(resolved, base);
407    if (px != null) resolved = `${px}px`;
408  }
409  if (prop === 'letterSpacing') {
410    const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
411    const px = resolveLengthPx(resolved, base);
412    if (px != null) resolved = `${px}px`;
413  }
414  if (prop === 'lineHeight' && resolved !== 'normal') {
415    const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
416    const px = resolveLengthPx(resolved, base);
417    if (px != null) resolved = `${px}px`;
418  }
419  return resolved;
420}
421
422function expandStaticBoxValues(tokens) {
423  if (tokens.length === 0) return ['0px', '0px', '0px', '0px'];
424  if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]];
425  if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]];
426  if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]];
427  return [tokens[0], tokens[1], tokens[2], tokens[3]];
428}
429
430function parseStaticBorder(value) {
431  const tokens = splitCssTokens(value);
432  let width = '', color = '';
433  for (const token of tokens) {
434    if (!width && /^-?[\d.]+(?:px|rem|em|%)$/.test(token)) width = token;
435    if (!color) color = extractStaticColor(token);
436  }
437  return { width, color };
438}
439
440function parseStaticFont(value) {
441  const out = [];
442  const slashParts = value.match(/(?:^|\s)([\d.]+(?:px|rem|em|%))(?:\/([^\s]+))?/);
443  if (/\bitalic\b/i.test(value)) out.push(['fontStyle', 'italic']);
444  const weight = value.match(/\b([1-9]00|bold|normal|lighter|bolder)\b/i);
445  if (weight) out.push(['fontWeight', weight[1]]);
446  if (slashParts) {
447    out.push(['fontSize', slashParts[1]]);
448    if (slashParts[2]) out.push(['lineHeight', slashParts[2]]);
449    const familyStart = value.indexOf(slashParts[0]) + slashParts[0].length;
450    const family = value.slice(familyStart).trim();
451    if (family) out.push(['fontFamily', family]);
452  }
453  return out;
454}
455
456function parseStaticTransition(value) {
457  const props = [];
458  const timings = [];
459  for (const item of splitCssList(value)) {
460    const tokens = splitCssTokens(item);
461    const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
462    if (timing) timings.push(timing);
463    const prop = tokens.find(token => /^[a-z-]+$/i.test(token) && !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none)$/.test(token) && !/s$/.test(token));
464    if (prop) props.push(prop);
465  }
466  return {
467    property: props.join(', '),
468    timing: timings.join(', '),
469  };
470}
471
472function parseStaticAnimation(value) {
473  const names = [];
474  const timings = [];
475  for (const item of splitCssList(value)) {
476    const tokens = splitCssTokens(item);
477    const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
478    if (timing) timings.push(timing);
479    const name = tokens.find(token =>
480      /^[a-z_-][\w-]*$/i.test(token) &&
481      !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none|running|paused)$/.test(token)
482    );
483    if (name) names.push(name);
484  }
485  return {
486    name: names.join(', '),
487    timing: timings.join(', '),
488  };
489}
490
491function expandStaticDeclaration(prop, value) {
492  const p = prop.toLowerCase();
493  const v = String(value || '').trim();
494  if (!v) return [];
495  if (p.startsWith('--')) return [[p, v]];
496  if (p === 'background') {
497    const out = [];
498    const hasImage = /gradient|url\(/i.test(v);
499    if (hasImage) out.push(['backgroundImage', v]);
500    const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;
501    const color = extractStaticColor(hasImage ? beforeImage : v);
502    if (color) out.push(['backgroundColor', color]);
503    return out;
504  }
505  if (p === 'border') {
506    const parsed = parseStaticBorder(v);
507    const out = [];
508    for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
509      if (parsed.width) out.push([`border${side}Width`, parsed.width]);
510      if (parsed.color) out.push([`border${side}Color`, parsed.color]);
511    }
512    return out;
513  }
514  const sideMatch = p.match(/^border-(top|right|bottom|left)$/);
515  if (sideMatch) {
516    const parsed = parseStaticBorder(v);
517    const side = sideMatch[1][0].toUpperCase() + sideMatch[1].slice(1);
518    return [
519      ...(parsed.width ? [[`border${side}Width`, parsed.width]] : []),
520      ...(parsed.color ? [[`border${side}Color`, parsed.color]] : []),
521    ];
522  }
523  if (p === 'border-width') {
524    const vals = expandStaticBoxValues(splitCssTokens(v));
525    return [
526      ['borderTopWidth', vals[0]],
527      ['borderRightWidth', vals[1]],
528      ['borderBottomWidth', vals[2]],
529      ['borderLeftWidth', vals[3]],
530    ];
531  }
532  if (p === 'border-color') {
533    const vals = expandStaticBoxValues(splitCssTokens(v));
534    return [
535      ['borderTopColor', vals[0]],
536      ['borderRightColor', vals[1]],
537      ['borderBottomColor', vals[2]],
538      ['borderLeftColor', vals[3]],
539    ];
540  }
541  if (p === 'padding') {
542    const vals = expandStaticBoxValues(splitCssTokens(v));
543    return [
544      ['paddingTop', vals[0]],
545      ['paddingRight', vals[1]],
546      ['paddingBottom', vals[2]],
547      ['paddingLeft', vals[3]],
548    ];
549  }
550  if (p === 'font') return parseStaticFont(v);
551  if (p === 'transition') {
552    const parsed = parseStaticTransition(v);
553    return [
554      ...(parsed.property ? [['transitionProperty', parsed.property]] : []),
555      ...(parsed.timing ? [['transitionTimingFunction', parsed.timing]] : []),
556    ];
557  }
558  if (p === 'animation') {
559    const parsed = parseStaticAnimation(v);
560    return [
561      ...(parsed.name ? [['animationName', parsed.name]] : []),
562      ...(parsed.timing ? [['animationTimingFunction', parsed.timing]] : []),
563    ];
564  }
565  const mapped = cssPropToCamel(p);
566  if (STATIC_DEFAULT_STYLE[mapped] != null || STATIC_INHERITED_PROPS.has(mapped)) {
567    return [[mapped, v]];
568  }
569  return [];
570}
571
572function compareStaticPriority(a, b) {
573  if (!a) return true;
574  if (!!b.important !== !!a.important) return !!b.important;
575  if (!!b.inline !== !!a.inline) return !!b.inline;
576  for (let i = 0; i < 3; i++) {
577    if ((b.specificity[i] || 0) !== (a.specificity[i] || 0)) {
578      return (b.specificity[i] || 0) > (a.specificity[i] || 0);
579    }
580  }
581  return b.order >= a.order;
582}
583
584function staticSpecificity(selector) {
585  const noWhere = selector.replace(/:where\([^)]*\)/g, '');
586  const ids = (noWhere.match(/#[\w-]+/g) || []).length;
587  const classes = (noWhere.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]*\))?/g) || []).length;
588  const stripped = noWhere
589    .replace(/#[\w-]+/g, ' ')
590    .replace(/\.[\w-]+|\[[^\]]+\]|:{1,2}[\w-]+(?:\([^)]*\))?/g, ' ')
591    .replace(/[*>+~(),]/g, ' ');
592  const types = (stripped.match(/\b[a-zA-Z][\w-]*\b/g) || []).length;
593  return [ids, classes, types];
594}
595
596function applyStaticDeclaration(specified, node, prop, value, meta) {
597  let map = specified.get(node);
598  if (!map) { map = new Map(); specified.set(node, map); }
599  for (const [expandedProp, expandedValue] of expandStaticDeclaration(prop, value)) {
600    const existing = map.get(expandedProp);
601    const next = { ...meta, prop: expandedProp, value: expandedValue };
602    if (compareStaticPriority(existing, next)) map.set(expandedProp, next);
603  }
604}
605
606function parseStaticStyleAttribute(styleText, orderBase = 0) {
607  const decls = [];
608  for (const part of String(styleText || '').split(';')) {
609    const idx = part.indexOf(':');
610    if (idx <= 0) continue;
611    const prop = part.slice(0, idx).trim();
612    let value = part.slice(idx + 1).trim();
613    const important = /!important\s*$/i.test(value);
614    value = value.replace(/\s*!important\s*$/i, '').trim();
615    decls.push({ prop, value, important, order: orderBase + decls.length });
616  }
617  return decls;
618}
619
620function collectStaticCssRules(cssText, csstree) {
621  const rules = [];
622  let ast;
623  try {
624    ast = csstree.parse(cssText, { positions: false, parseValue: true, parseCustomProperty: false });
625  } catch {
626    return rules;
627  }
628  let order = 0;
629  const walkList = (list, atRuleStack = []) => {
630    list?.forEach?.(node => {
631      if (node.type === 'Rule' && node.block) {
632        if (atRuleStack.some(name => /keyframes$/i.test(name))) return;
633        const selectorText = csstree.generate(node.prelude).trim();
634        const declarations = [];
635        node.block.children?.forEach?.(child => {
636          if (child.type !== 'Declaration') return;
637          declarations.push({
638            prop: child.property,
639            value: csstree.generate(child.value).trim(),
640            important: !!child.important,
641          });
642        });
643        for (const selector of splitCssList(selectorText)) {
644          if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });
645        }
646        return;
647      }
648      if (node.type === 'Atrule' && node.block) {
649        const name = String(node.name || '').toLowerCase();
650        if (name === 'media' || name === 'supports' || name === 'layer') {
651          walkList(node.block.children, [...atRuleStack, name]);
652        }
653      }
654    });
655  };
656  walkList(ast.children);
657  return rules;
658}
659
660class StaticElement {
661  constructor(node, doc) {
662    this.node = node;
663    this._doc = doc;
664    this.nodeType = 1;
665    this.tagName = String(node.name || '').toUpperCase();
666    this.nodeName = this.tagName;
667  }
668  get parentElement() {
669    let cur = this.node.parent;
670    while (cur && cur.type !== 'tag') cur = cur.parent;
671    return cur ? this._doc.wrap(cur) : null;
672  }
673  get previousElementSibling() {
674    let cur = this.node.prev;
675    while (cur && cur.type !== 'tag') cur = cur.prev;
676    return cur ? this._doc.wrap(cur) : null;
677  }
678  get children() {
679    return (this.node.children || []).filter(child => child.type === 'tag').map(child => this._doc.wrap(child));
680  }
681  get childNodes() {
682    return (this.node.children || []).map(child => {
683      if (child.type === 'text') return { nodeType: 3, textContent: child.data || '' };
684      if (child.type === 'tag') return this._doc.wrap(child);
685      return { nodeType: 8, textContent: child.data || '' };
686    });
687  }
688  get textContent() {
689    return this._doc.domutils.textContent(this.node);
690  }
691  get className() {
692    return this.getAttribute('class') || '';
693  }
694  get id() {
695    return this.getAttribute('id') || '';
696  }
697  getAttribute(name) {
698    return this.node.attribs?.[name] ?? null;
699  }
700  querySelector(selector) {
701    try {
702      const found = this._doc.selectOne(selector, this.node.children || []);
703      return found ? this._doc.wrap(found) : null;
704    } catch {
705      return null;
706    }
707  }
708  querySelectorAll(selector) {
709    try {
710      return this._doc.selectAll(selector, this.node.children || []).map(node => this._doc.wrap(node));
711    } catch {
712      return [];
713    }
714  }
715  closest(selector) {
716    let cur = this.node;
717    while (cur && cur.type === 'tag') {
718      try {
719        if (this._doc.is(cur, selector)) return this._doc.wrap(cur);
720      } catch {
721        return null;
722      }
723      cur = cur.parent;
724      while (cur && cur.type !== 'tag') cur = cur.parent;
725    }
726    return null;
727  }
728  contains(other) {
729    let cur = other?.node || null;
730    while (cur) {
731      if (cur === this.node) return true;
732      cur = cur.parent;
733    }
734    return false;
735  }
736}
737
738class StaticDocument {
739  constructor(root, modules) {
740    this.root = root;
741    this.selectAll = modules.selectAll;
742    this.selectOne = modules.selectOne;
743    this.is = modules.is;
744    this.domutils = modules.domutils;
745    this._wrappers = new WeakMap();
746    this._styleMap = new WeakMap();
747  }
748  wrap(node) {
749    let wrapped = this._wrappers.get(node);
750    if (!wrapped) {
751      wrapped = new StaticElement(node, this);
752      this._wrappers.set(node, wrapped);
753    }
754    return wrapped;
755  }
756  querySelectorAll(selector) {
757    try {
758      return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));
759    } catch {
760      return [];
761    }
762  }
763  querySelector(selector) {
764    try {
765      const found = this.selectOne(selector, this.root.children || []);
766      return found ? this.wrap(found) : null;
767    } catch {
768      return null;
769    }
770  }
771  get documentElement() {
772    return this.querySelector('html');
773  }
774  get body() {
775    return this.querySelector('body');
776  }
777  setStyle(node, style) {
778    this._styleMap.set(node, style);
779  }
780  getStyle(el) {
781    return this._styleMap.get(el.node) || makeStaticStyle();
782  }
783}
784
785function makeStaticStyle(values = {}) {
786  const style = { ...STATIC_DEFAULT_STYLE, ...values };
787  style.getPropertyValue = (prop) => {
788    const key = cssPropToCamel(prop);
789    return style[key] || style[prop] || '';
790  };
791  return style;
792}
793
794function buildStaticWindow(staticDoc) {
795  return {
796    document: staticDoc,
797    getComputedStyle: (el) => staticDoc.getStyle(el),
798  };
799}
800
801function collectStaticCssText(root, fileDir, profile, filePath, modules) {
802  const styleTexts = [];
803  for (const styleEl of modules.selectAll('style', root.children || [])) {
804    styleTexts.push(modules.domutils.textContent(styleEl));
805  }
806  const links = modules.selectAll('link', root.children || []);
807  for (const link of links) {
808    const rel = link.attribs?.rel || '';
809    const href = link.attribs?.href || '';
810    if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
811    const cssPath = path.resolve(fileDir, href);
812    try {
813      const css = profileStep(profile, {
814        engine: 'static-html',
815        phase: 'preprocess',
816        ruleId: 'inline-linked-stylesheet',
817        target: filePath,
818        detail: href,
819      }, () => fs.readFileSync(cssPath, 'utf-8'));
820      styleTexts.push(css);
821    } catch { /* skip unreadable */ }
822  }
823  return styleTexts.join('\n');
824}
825
826function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
827  const specified = new Map();
828  const allNodes = modules.selectAll('*', root.children || []);
829  const rules = profileStep(profile, {
830    engine: 'static-html',
831    phase: 'parse-css',
832    ruleId: 'css-rules',
833    target: filePath,
834  }, () => collectStaticCssRules(cssText, modules.csstree));
835
836  profileStep(profile, {
837    engine: 'static-html',
838    phase: 'selector-match',
839    ruleId: 'css-selectors',
840    target: filePath,
841  }, () => {
842    for (const rule of rules) {
843      let matched;
844      try {
845        matched = modules.selectAll(rule.selector, root.children || []);
846      } catch {
847        recordProfileEvent(profile, {
848          engine: 'static-html',
849          phase: 'selector-match',
850          ruleId: 'unsupported-selector',
851          target: filePath,
852          ms: 0,
853          findings: 0,
854          detail: rule.selector,
855        });
856        continue;
857      }
858      for (const node of matched) {
859        for (const decl of rule.declarations) {
860          applyStaticDeclaration(specified, node, decl.prop, decl.value, {
861            important: decl.important,
862            specificity: rule.specificity,
863            order: rule.order,
864            inline: false,
865          });
866        }
867      }
868    }
869
870    let inlineOrder = rules.length + 1;
871    for (const node of allNodes) {
872      const styleText = node.attribs?.style;
873      if (!styleText) continue;
874      for (const decl of parseStaticStyleAttribute(styleText, inlineOrder)) {
875        applyStaticDeclaration(specified, node, decl.prop, decl.value, {
876          important: decl.important,
877          specificity: [1, 0, 0],
878          order: decl.order,
879          inline: true,
880        });
881      }
882      inlineOrder += 1000;
883    }
884  });
885
886  const computeNode = (node, parentStyle = null, parentCustom = new Map()) => {
887    const specifiedMap = specified.get(node) || new Map();
888    const customProps = new Map(parentCustom);
889    for (const [prop, decl] of specifiedMap) {
890      if (prop.startsWith('--')) customProps.set(prop, resolveVarRefs(decl.value, customProps));
891    }
892    const values = {};
893    for (const prop of Object.keys(STATIC_DEFAULT_STYLE)) {
894      if (STATIC_INHERITED_PROPS.has(prop) && parentStyle?.[prop] != null) values[prop] = parentStyle[prop];
895      else values[prop] = STATIC_DEFAULT_STYLE[prop];
896    }
897    for (const [prop, decl] of specifiedMap) {
898      if (prop.startsWith('--')) continue;
899      values[prop] = normalizeStaticCssValue(prop, decl.value, customProps, parentStyle, values);
900    }
901    const style = makeStaticStyle(values);
902    staticDoc.setStyle(node, style);
903    for (const child of node.children || []) {
904      if (child.type === 'tag') computeNode(child, style, customProps);
905    }
906  };
907
908  profileStep(profile, {
909    engine: 'static-html',
910    phase: 'cascade',
911    ruleId: 'compute-styles',
912    target: filePath,
913  }, () => {
914    for (const child of root.children || []) {
915      if (child.type === 'tag') computeNode(child);
916    }
917  });
918}
919
920export {
921  BORDER_SHORTHAND_RE,
922  NAMED_COLORS,
923  normalizeColorForCheck,
924  buildBorderOverrideMap,
925  unwrapCssAtLayer,
926  STATIC_INHERITED_PROPS,
927  STATIC_DEFAULT_STYLE,
928  STATIC_PROP_MAP,
929  STATIC_NAMED_COLORS,
930  splitCssList,
931  splitCssTokens,
932  cssPropToCamel,
933  staticColorToCss,
934  parseStaticColor,
935  extractStaticColor,
936  normalizeStaticCssValue,
937  expandStaticBoxValues,
938  parseStaticBorder,
939  parseStaticFont,
940  parseStaticTransition,
941  parseStaticAnimation,
942  expandStaticDeclaration,
943  compareStaticPriority,
944  staticSpecificity,
945  applyStaticDeclaration,
946  parseStaticStyleAttribute,
947  collectStaticCssRules,
948  StaticElement,
949  StaticDocument,
950  makeStaticStyle,
951  buildStaticWindow,
952  collectStaticCssText,
953  buildStaticStyleMap,
954};