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};