返回 AiToEarn
css-cascade.mjs
1 import fs from 'node:fs';
2 import path from 'node:path';
3
4 import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
5 import { 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
39 const 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).
46 const 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
52 function 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
74 function 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.
187 function 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
225 const STATIC_INHERITED_PROPS = new Set([
226 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
227 'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
228 'webkitHyphens',
229 ]);
230
231 const 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 outlineWidth: '0px',
245 outlineColor: 'rgb(0, 0, 0)',
246 outlineStyle: 'none',
247 boxShadow: 'none',
248 fontFamily: '',
249 fontSize: '16px',
250 fontStyle: 'normal',
251 fontWeight: '400',
252 lineHeight: 'normal',
253 letterSpacing: 'normal',
254 textTransform: 'none',
255 textAlign: 'start',
256 hyphens: 'manual',
257 webkitHyphens: 'manual',
258 transitionProperty: '',
259 transitionTimingFunction: '',
260 animationName: '',
261 animationTimingFunction: '',
262 webkitBackgroundClip: '',
263 backgroundClip: '',
264 width: '',
265 height: '',
266 paddingTop: '0px',
267 paddingRight: '0px',
268 paddingBottom: '0px',
269 paddingLeft: '0px',
270 position: 'static',
271 display: '',
272 overflow: 'visible',
273 overflowX: 'visible',
274 overflowY: 'visible',
275 };
276
277 const STATIC_PROP_MAP = {
278 'background-color': 'backgroundColor',
279 'background-image': 'backgroundImage',
280 'background-clip': 'backgroundClip',
281 '-webkit-background-clip': 'webkitBackgroundClip',
282 'border-radius': 'borderRadius',
283 'border-top-width': 'borderTopWidth',
284 'border-right-width': 'borderRightWidth',
285 'border-bottom-width': 'borderBottomWidth',
286 'border-left-width': 'borderLeftWidth',
287 'border-top-color': 'borderTopColor',
288 'border-right-color': 'borderRightColor',
289 'border-bottom-color': 'borderBottomColor',
290 'border-left-color': 'borderLeftColor',
291 'outline-width': 'outlineWidth',
292 'outline-color': 'outlineColor',
293 'outline-style': 'outlineStyle',
294 'box-shadow': 'boxShadow',
295 'font-family': 'fontFamily',
296 'font-size': 'fontSize',
297 'font-style': 'fontStyle',
298 'font-weight': 'fontWeight',
299 'line-height': 'lineHeight',
300 'letter-spacing': 'letterSpacing',
301 'text-transform': 'textTransform',
302 'text-align': 'textAlign',
303 'hyphens': 'hyphens',
304 '-webkit-hyphens': 'webkitHyphens',
305 'transition-property': 'transitionProperty',
306 'transition-timing-function': 'transitionTimingFunction',
307 'animation-name': 'animationName',
308 'animation-timing-function': 'animationTimingFunction',
309 'width': 'width',
310 'height': 'height',
311 'padding-top': 'paddingTop',
312 'padding-right': 'paddingRight',
313 'padding-bottom': 'paddingBottom',
314 'padding-left': 'paddingLeft',
315 'position': 'position',
316 'display': 'display',
317 'overflow': 'overflow',
318 'overflow-x': 'overflowX',
319 'overflow-y': 'overflowY',
320 };
321
322 const STATIC_NAMED_COLORS = {
323 black: { r: 0, g: 0, b: 0, a: 1 },
324 white: { r: 255, g: 255, b: 255, a: 1 },
325 transparent: { r: 0, g: 0, b: 0, a: 0 },
326 gray: { r: 128, g: 128, b: 128, a: 1 },
327 grey: { r: 128, g: 128, b: 128, a: 1 },
328 silver: { r: 192, g: 192, b: 192, a: 1 },
329 red: { r: 255, g: 0, b: 0, a: 1 },
330 green: { r: 0, g: 128, b: 0, a: 1 },
331 blue: { r: 0, g: 0, b: 255, a: 1 },
332 };
333
334 function splitCssList(value) {
335 const parts = [];
336 let depth = 0, quote = '', start = 0;
337 for (let i = 0; i < value.length; i++) {
338 const ch = value[i];
339 if (quote) {
340 if (ch === quote && value[i - 1] !== '\\') quote = '';
341 continue;
342 }
343 if (ch === '"' || ch === "'") { quote = ch; continue; }
344 if (ch === '(' || ch === '[') depth++;
345 else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
346 else if (ch === ',' && depth === 0) {
347 parts.push(value.slice(start, i).trim());
348 start = i + 1;
349 }
350 }
351 const tail = value.slice(start).trim();
352 if (tail) parts.push(tail);
353 return parts;
354 }
355
356 function splitCssTokens(value) {
357 const tokens = [];
358 let depth = 0, quote = '', current = '';
359 for (let i = 0; i < value.length; i++) {
360 const ch = value[i];
361 if (quote) {
362 current += ch;
363 if (ch === quote && value[i - 1] !== '\\') quote = '';
364 continue;
365 }
366 if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
367 if (ch === '(') { depth++; current += ch; continue; }
368 if (ch === ')') { depth = Math.max(0, depth - 1); current += ch; continue; }
369 if (/\s/.test(ch) && depth === 0) {
370 if (current) { tokens.push(current); current = ''; }
371 continue;
372 }
373 current += ch;
374 }
375 if (current) tokens.push(current);
376 return tokens;
377 }
378
379 function cssPropToCamel(prop) {
380 if (!prop) return prop;
381 const mapped = STATIC_PROP_MAP[prop];
382 if (mapped) return mapped;
383 return prop.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
384 }
385
386 function staticColorToCss(c) {
387 if (!c) return '';
388 if (c.a != null && c.a < 1) return `rgba(${c.r}, ${c.g}, ${c.b}, ${Number(c.a.toFixed(3))})`;
389 return `rgb(${c.r}, ${c.g}, ${c.b})`;
390 }
391
392 function parseStaticColor(value) {
393 const parsed = parseAnyColor(value);
394 if (parsed) return parsed;
395 const named = STATIC_NAMED_COLORS[String(value || '').trim().toLowerCase()];
396 return named ? { ...named } : null;
397 }
398
399 function extractStaticColor(value) {
400 if (!value) return '';
401 const raw = String(value).trim();
402 if (/^var\(/i.test(raw)) return raw;
403 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);
404 if (!colorLike) return '';
405 return colorLike[0];
406 }
407
408 function normalizeStaticCssValue(prop, value, customProps, parentStyle, currentStyle = null) {
409 let resolved = resolveVarRefs(String(value || '').trim(), customProps);
410 if (resolved === 'inherit') return parentStyle?.[prop] || STATIC_DEFAULT_STYLE[prop] || '';
411 const isModernBorderColor = /^border[A-Z][a-z]+Color$/.test(prop) && /^(?:oklch|oklab|lch|lab|hsl|hwb)\(/i.test(resolved);
412 if (!isModernBorderColor && (/color$/i.test(prop) || prop === 'color' || prop === 'backgroundColor')) {
413 const parsed = parseStaticColor(resolved);
414 if (parsed) resolved = staticColorToCss(parsed);
415 }
416 if (prop === 'fontSize') {
417 const base = parseFloat(parentStyle?.fontSize) || 16;
418 const px = resolveLengthPx(resolved, base);
419 if (px != null) resolved = `${px}px`;
420 }
421 if (prop === 'letterSpacing') {
422 const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
423 const px = resolveLengthPx(resolved, base);
424 if (px != null) resolved = `${px}px`;
425 }
426 if (prop === 'lineHeight' && resolved !== 'normal') {
427 const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
428 const px = resolveLengthPx(resolved, base);
429 if (px != null) resolved = `${px}px`;
430 }
431 return resolved;
432 }
433
434 function expandStaticBoxValues(tokens) {
435 if (tokens.length === 0) return ['0px', '0px', '0px', '0px'];
436 if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]];
437 if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]];
438 if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]];
439 return [tokens[0], tokens[1], tokens[2], tokens[3]];
440 }
441
442 function parseStaticBorder(value) {
443 const tokens = splitCssTokens(value);
444 let width = '', color = '';
445 for (const token of tokens) {
446 if (!width && /^-?[\d.]+(?:px|rem|em|%)$/.test(token)) width = token;
447 if (!color) color = extractStaticColor(token);
448 }
449 return { width, color };
450 }
451
452 function parseStaticFont(value) {
453 const out = [];
454 const slashParts = value.match(/(?:^|\s)([\d.]+(?:px|rem|em|%))(?:\/([^\s]+))?/);
455 if (/\bitalic\b/i.test(value)) out.push(['fontStyle', 'italic']);
456 const weight = value.match(/\b([1-9]00|bold|normal|lighter|bolder)\b/i);
457 if (weight) out.push(['fontWeight', weight[1]]);
458 if (slashParts) {
459 out.push(['fontSize', slashParts[1]]);
460 if (slashParts[2]) out.push(['lineHeight', slashParts[2]]);
461 const familyStart = value.indexOf(slashParts[0]) + slashParts[0].length;
462 const family = value.slice(familyStart).trim();
463 if (family) out.push(['fontFamily', family]);
464 }
465 return out;
466 }
467
468 function parseStaticTransition(value) {
469 const props = [];
470 const timings = [];
471 for (const item of splitCssList(value)) {
472 const tokens = splitCssTokens(item);
473 const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
474 if (timing) timings.push(timing);
475 const prop = tokens.find(token => /^[a-z-]+$/i.test(token) && !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none)$/.test(token) && !/s$/.test(token));
476 if (prop) props.push(prop);
477 }
478 return {
479 property: props.join(', '),
480 timing: timings.join(', '),
481 };
482 }
483
484 function parseStaticAnimation(value) {
485 const names = [];
486 const timings = [];
487 for (const item of splitCssList(value)) {
488 const tokens = splitCssTokens(item);
489 const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
490 if (timing) timings.push(timing);
491 const name = tokens.find(token =>
492 /^[a-z_-][\w-]*$/i.test(token) &&
493 !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none|running|paused)$/.test(token)
494 );
495 if (name) names.push(name);
496 }
497 return {
498 name: names.join(', '),
499 timing: timings.join(', '),
500 };
501 }
502
503 function expandStaticDeclaration(prop, value) {
504 const p = prop.toLowerCase();
505 const v = String(value || '').trim();
506 if (!v) return [];
507 if (p.startsWith('--')) return [[p, v]];
508 if (p === 'background') {
509 const out = [];
510 const hasImage = /gradient|url\(/i.test(v);
511 if (hasImage) out.push(['backgroundImage', v]);
512 const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;
513 const color = extractStaticColor(hasImage ? beforeImage : v);
514 if (color) out.push(['backgroundColor', color]);
515 return out;
516 }
517 if (p === 'border') {
518 const parsed = parseStaticBorder(v);
519 const out = [];
520 for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
521 if (parsed.width) out.push([`border${side}Width`, parsed.width]);
522 if (parsed.color) out.push([`border${side}Color`, parsed.color]);
523 }
524 return out;
525 }
526 if (p === 'outline') {
527 // `outline` shorthand: width | style | color, in any order. Reuse the
528 // border parser for width + color, then sniff a style keyword from the
529 // tokens (solid|dashed|...). `outline: 0` (single-token zero) zeros
530 // the width and effectively hides the outline.
531 const tokens = splitCssTokens(v);
532 const parsed = parseStaticBorder(v);
533 const styleToken = tokens.find(t =>
534 /^(none|hidden|solid|dashed|dotted|double|groove|ridge|inset|outset)$/i.test(t)
535 );
536 const out = [];
537 if (parsed.width) out.push(['outlineWidth', parsed.width]);
538 if (parsed.color) out.push(['outlineColor', parsed.color]);
539 if (styleToken) out.push(['outlineStyle', styleToken.toLowerCase()]);
540 // `outline: 0` with no other tokens: explicit zero width.
541 if (!parsed.width && /^0(?:px|rem|em|%)?$/.test(v.trim())) {
542 out.push(['outlineWidth', '0px']);
543 }
544 return out;
545 }
546 const sideMatch = p.match(/^border-(top|right|bottom|left)$/);
547 if (sideMatch) {
548 const parsed = parseStaticBorder(v);
549 const side = sideMatch[1][0].toUpperCase() + sideMatch[1].slice(1);
550 return [
551 ...(parsed.width ? [[`border${side}Width`, parsed.width]] : []),
552 ...(parsed.color ? [[`border${side}Color`, parsed.color]] : []),
553 ];
554 }
555 if (p === 'border-width') {
556 const vals = expandStaticBoxValues(splitCssTokens(v));
557 return [
558 ['borderTopWidth', vals[0]],
559 ['borderRightWidth', vals[1]],
560 ['borderBottomWidth', vals[2]],
561 ['borderLeftWidth', vals[3]],
562 ];
563 }
564 if (p === 'border-color') {
565 const vals = expandStaticBoxValues(splitCssTokens(v));
566 return [
567 ['borderTopColor', vals[0]],
568 ['borderRightColor', vals[1]],
569 ['borderBottomColor', vals[2]],
570 ['borderLeftColor', vals[3]],
571 ];
572 }
573 if (p === 'padding') {
574 const vals = expandStaticBoxValues(splitCssTokens(v));
575 return [
576 ['paddingTop', vals[0]],
577 ['paddingRight', vals[1]],
578 ['paddingBottom', vals[2]],
579 ['paddingLeft', vals[3]],
580 ];
581 }
582 if (p === 'font') return parseStaticFont(v);
583 if (p === 'transition') {
584 const parsed = parseStaticTransition(v);
585 return [
586 ...(parsed.property ? [['transitionProperty', parsed.property]] : []),
587 ...(parsed.timing ? [['transitionTimingFunction', parsed.timing]] : []),
588 ];
589 }
590 if (p === 'animation') {
591 const parsed = parseStaticAnimation(v);
592 return [
593 ...(parsed.name ? [['animationName', parsed.name]] : []),
594 ...(parsed.timing ? [['animationTimingFunction', parsed.timing]] : []),
595 ];
596 }
597 const mapped = cssPropToCamel(p);
598 if (STATIC_DEFAULT_STYLE[mapped] != null || STATIC_INHERITED_PROPS.has(mapped)) {
599 return [[mapped, v]];
600 }
601 return [];
602 }
603
604 function compareStaticPriority(a, b) {
605 if (!a) return true;
606 if (!!b.important !== !!a.important) return !!b.important;
607 if (!!b.inline !== !!a.inline) return !!b.inline;
608 for (let i = 0; i < 3; i++) {
609 if ((b.specificity[i] || 0) !== (a.specificity[i] || 0)) {
610 return (b.specificity[i] || 0) > (a.specificity[i] || 0);
611 }
612 }
613 return b.order >= a.order;
614 }
615
616 function staticSpecificity(selector) {
617 const noWhere = selector.replace(/:where\([^)]*\)/g, '');
618 const ids = (noWhere.match(/#[\w-]+/g) || []).length;
619 const classes = (noWhere.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]*\))?/g) || []).length;
620 const stripped = noWhere
621 .replace(/#[\w-]+/g, ' ')
622 .replace(/\.[\w-]+|\[[^\]]+\]|:{1,2}[\w-]+(?:\([^)]*\))?/g, ' ')
623 .replace(/[*>+~(),]/g, ' ');
624 const types = (stripped.match(/\b[a-zA-Z][\w-]*\b/g) || []).length;
625 return [ids, classes, types];
626 }
627
628 function applyStaticDeclaration(specified, node, prop, value, meta) {
629 let map = specified.get(node);
630 if (!map) { map = new Map(); specified.set(node, map); }
631 for (const [expandedProp, expandedValue] of expandStaticDeclaration(prop, value)) {
632 const existing = map.get(expandedProp);
633 const next = { ...meta, prop: expandedProp, value: expandedValue };
634 if (compareStaticPriority(existing, next)) map.set(expandedProp, next);
635 }
636 }
637
638 function parseStaticStyleAttribute(styleText, orderBase = 0) {
639 const decls = [];
640 for (const part of String(styleText || '').split(';')) {
641 const idx = part.indexOf(':');
642 if (idx <= 0) continue;
643 const prop = part.slice(0, idx).trim();
644 let value = part.slice(idx + 1).trim();
645 const important = /!important\s*$/i.test(value);
646 value = value.replace(/\s*!important\s*$/i, '').trim();
647 decls.push({ prop, value, important, order: orderBase + decls.length });
648 }
649 return decls;
650 }
651
652 function collectStaticCssRules(cssText, csstree) {
653 const rules = [];
654 let ast;
655 try {
656 ast = csstree.parse(cssText, { positions: false, parseValue: true, parseCustomProperty: false });
657 } catch {
658 return rules;
659 }
660 let order = 0;
661 const walkList = (list, atRuleStack = []) => {
662 list?.forEach?.(node => {
663 if (node.type === 'Rule' && node.block) {
664 if (atRuleStack.some(name => /keyframes$/i.test(name))) return;
665 const selectorText = csstree.generate(node.prelude).trim();
666 const declarations = [];
667 node.block.children?.forEach?.(child => {
668 if (child.type !== 'Declaration') return;
669 declarations.push({
670 prop: child.property,
671 value: csstree.generate(child.value).trim(),
672 important: !!child.important,
673 });
674 });
675 for (const selector of splitCssList(selectorText)) {
676 if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });
677 }
678 return;
679 }
680 if (node.type === 'Atrule' && node.block) {
681 const name = String(node.name || '').toLowerCase();
682 if (name === 'media' || name === 'supports' || name === 'layer') {
683 walkList(node.block.children, [...atRuleStack, name]);
684 }
685 }
686 });
687 };
688 walkList(ast.children);
689 return rules;
690 }
691
692 class StaticElement {
693 constructor(node, doc) {
694 this.node = node;
695 this._doc = doc;
696 this.nodeType = 1;
697 this.tagName = String(node.name || '').toUpperCase();
698 this.nodeName = this.tagName;
699 }
700 get parentElement() {
701 let cur = this.node.parent;
702 while (cur && cur.type !== 'tag') cur = cur.parent;
703 return cur ? this._doc.wrap(cur) : null;
704 }
705 get previousElementSibling() {
706 let cur = this.node.prev;
707 while (cur && cur.type !== 'tag') cur = cur.prev;
708 return cur ? this._doc.wrap(cur) : null;
709 }
710 get children() {
711 return (this.node.children || []).filter(child => child.type === 'tag').map(child => this._doc.wrap(child));
712 }
713 get childNodes() {
714 return (this.node.children || []).map(child => {
715 if (child.type === 'text') return { nodeType: 3, textContent: child.data || '' };
716 if (child.type === 'tag') return this._doc.wrap(child);
717 return { nodeType: 8, textContent: child.data || '' };
718 });
719 }
720 get textContent() {
721 return this._doc.domutils.textContent(this.node);
722 }
723 get className() {
724 return this.getAttribute('class') || '';
725 }
726 get id() {
727 return this.getAttribute('id') || '';
728 }
729 getAttribute(name) {
730 return this.node.attribs?.[name] ?? null;
731 }
732 querySelector(selector) {
733 try {
734 const found = this._doc.selectOne(selector, this.node.children || []);
735 return found ? this._doc.wrap(found) : null;
736 } catch {
737 return null;
738 }
739 }
740 querySelectorAll(selector) {
741 try {
742 return this._doc.selectAll(selector, this.node.children || []).map(node => this._doc.wrap(node));
743 } catch {
744 return [];
745 }
746 }
747 closest(selector) {
748 let cur = this.node;
749 while (cur && cur.type === 'tag') {
750 try {
751 if (this._doc.is(cur, selector)) return this._doc.wrap(cur);
752 } catch {
753 return null;
754 }
755 cur = cur.parent;
756 while (cur && cur.type !== 'tag') cur = cur.parent;
757 }
758 return null;
759 }
760 contains(other) {
761 let cur = other?.node || null;
762 while (cur) {
763 if (cur === this.node) return true;
764 cur = cur.parent;
765 }
766 return false;
767 }
768 }
769
770 class StaticDocument {
771 constructor(root, modules) {
772 this.root = root;
773 this.selectAll = modules.selectAll;
774 this.selectOne = modules.selectOne;
775 this.is = modules.is;
776 this.domutils = modules.domutils;
777 this._wrappers = new WeakMap();
778 this._styleMap = new WeakMap();
779 }
780 wrap(node) {
781 let wrapped = this._wrappers.get(node);
782 if (!wrapped) {
783 wrapped = new StaticElement(node, this);
784 this._wrappers.set(node, wrapped);
785 }
786 return wrapped;
787 }
788 querySelectorAll(selector) {
789 try {
790 return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));
791 } catch {
792 return [];
793 }
794 }
795 querySelector(selector) {
796 try {
797 const found = this.selectOne(selector, this.root.children || []);
798 return found ? this.wrap(found) : null;
799 } catch {
800 return null;
801 }
802 }
803 get documentElement() {
804 return this.querySelector('html');
805 }
806 get body() {
807 return this.querySelector('body');
808 }
809 setStyle(node, style) {
810 this._styleMap.set(node, style);
811 }
812 getStyle(el) {
813 return this._styleMap.get(el.node) || makeStaticStyle();
814 }
815 }
816
817 function makeStaticStyle(values = {}) {
818 const style = { ...STATIC_DEFAULT_STYLE, ...values };
819 style.getPropertyValue = (prop) => {
820 const key = cssPropToCamel(prop);
821 return style[key] || style[prop] || '';
822 };
823 return style;
824 }
825
826 function buildStaticWindow(staticDoc) {
827 return {
828 document: staticDoc,
829 getComputedStyle: (el) => staticDoc.getStyle(el),
830 };
831 }
832
833 function collectStaticCssText(root, fileDir, profile, filePath, modules) {
834 const styleTexts = [];
835 for (const styleEl of modules.selectAll('style', root.children || [])) {
836 styleTexts.push(modules.domutils.textContent(styleEl));
837 }
838 const links = modules.selectAll('link', root.children || []);
839 for (const link of links) {
840 const rel = link.attribs?.rel || '';
841 const href = link.attribs?.href || '';
842 if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
843 const cssPath = path.resolve(fileDir, href);
844 try {
845 const css = profileStep(profile, {
846 engine: 'static-html',
847 phase: 'preprocess',
848 ruleId: 'inline-linked-stylesheet',
849 target: filePath,
850 detail: href,
851 }, () => fs.readFileSync(cssPath, 'utf-8'));
852 styleTexts.push(css);
853 } catch { /* skip unreadable */ }
854 }
855 return styleTexts.join('\n');
856 }
857
858 function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
859 const specified = new Map();
860 const allNodes = modules.selectAll('*', root.children || []);
861 const rules = profileStep(profile, {
862 engine: 'static-html',
863 phase: 'parse-css',
864 ruleId: 'css-rules',
865 target: filePath,
866 }, () => collectStaticCssRules(cssText, modules.csstree));
867
868 profileStep(profile, {
869 engine: 'static-html',
870 phase: 'selector-match',
871 ruleId: 'css-selectors',
872 target: filePath,
873 }, () => {
874 for (const rule of rules) {
875 let matched;
876 try {
877 matched = modules.selectAll(rule.selector, root.children || []);
878 } catch {
879 recordProfileEvent(profile, {
880 engine: 'static-html',
881 phase: 'selector-match',
882 ruleId: 'unsupported-selector',
883 target: filePath,
884 ms: 0,
885 findings: 0,
886 detail: rule.selector,
887 });
888 continue;
889 }
890 for (const node of matched) {
891 for (const decl of rule.declarations) {
892 applyStaticDeclaration(specified, node, decl.prop, decl.value, {
893 important: decl.important,
894 specificity: rule.specificity,
895 order: rule.order,
896 inline: false,
897 });
898 }
899 }
900 }
901
902 let inlineOrder = rules.length + 1;
903 for (const node of allNodes) {
904 const styleText = node.attribs?.style;
905 if (!styleText) continue;
906 for (const decl of parseStaticStyleAttribute(styleText, inlineOrder)) {
907 applyStaticDeclaration(specified, node, decl.prop, decl.value, {
908 important: decl.important,
909 specificity: [1, 0, 0],
910 order: decl.order,
911 inline: true,
912 });
913 }
914 inlineOrder += 1000;
915 }
916 });
917
918 const computeNode = (node, parentStyle = null, parentCustom = new Map()) => {
919 const specifiedMap = specified.get(node) || new Map();
920 const customProps = new Map(parentCustom);
921 for (const [prop, decl] of specifiedMap) {
922 if (prop.startsWith('--')) customProps.set(prop, resolveVarRefs(decl.value, customProps));
923 }
924 const values = {};
925 for (const prop of Object.keys(STATIC_DEFAULT_STYLE)) {
926 if (STATIC_INHERITED_PROPS.has(prop) && parentStyle?.[prop] != null) values[prop] = parentStyle[prop];
927 else values[prop] = STATIC_DEFAULT_STYLE[prop];
928 }
929 for (const [prop, decl] of specifiedMap) {
930 if (prop.startsWith('--')) continue;
931 values[prop] = normalizeStaticCssValue(prop, decl.value, customProps, parentStyle, values);
932 }
933 const style = makeStaticStyle(values);
934 staticDoc.setStyle(node, style);
935 for (const child of node.children || []) {
936 if (child.type === 'tag') computeNode(child, style, customProps);
937 }
938 };
939
940 profileStep(profile, {
941 engine: 'static-html',
942 phase: 'cascade',
943 ruleId: 'compute-styles',
944 target: filePath,
945 }, () => {
946 for (const child of root.children || []) {
947 if (child.type === 'tag') computeNode(child);
948 }
949 });
950 }
951
952 export {
953 BORDER_SHORTHAND_RE,
954 NAMED_COLORS,
955 normalizeColorForCheck,
956 buildBorderOverrideMap,
957 unwrapCssAtLayer,
958 STATIC_INHERITED_PROPS,
959 STATIC_DEFAULT_STYLE,
960 STATIC_PROP_MAP,
961 STATIC_NAMED_COLORS,
962 splitCssList,
963 splitCssTokens,
964 cssPropToCamel,
965 staticColorToCss,
966 parseStaticColor,
967 extractStaticColor,
968 normalizeStaticCssValue,
969 expandStaticBoxValues,
970 parseStaticBorder,
971 parseStaticFont,
972 parseStaticTransition,
973 parseStaticAnimation,
974 expandStaticDeclaration,
975 compareStaticPriority,
976 staticSpecificity,
977 applyStaticDeclaration,
978 parseStaticStyleAttribute,
979 collectStaticCssRules,
980 StaticElement,
981 StaticDocument,
982 makeStaticStyle,
983 buildStaticWindow,
984 collectStaticCssText,
985 buildStaticStyleMap,
986 };
987
987 lines Plain Text