Install Impeccable skill for Codex
Some checks are pending
CI / Validate (push) Waiting to run

This commit is contained in:
dirtydishes 2026-05-29 03:59:27 -04:00
parent 739a534ac2
commit f237916291
165 changed files with 79237 additions and 0 deletions

View file

@ -0,0 +1,986 @@
import fs from 'node:fs';
import path from 'node:path';
import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
import { parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
// ---------------------------------------------------------------------------
// jsdom CSS-variable border override map
// ---------------------------------------------------------------------------
//
// jsdom's CSSOM silently drops any border shorthand that contains a var()
// reference — the computed style for the element then shows empty width,
// empty style, and a default black color. That's enough to hide the most
// common real-world side-tab pattern in AI-generated pages:
//
// :root { --brand: #87a8ff; }
// .card { border-left: 5px solid var(--brand); border-radius: 4px; }
//
// Real browsers (and therefore the browser detector path) resolve var()
// natively, so this only affects the Node jsdom path.
//
// This pre-pass walks the stylesheets, finds any rule whose per-side or
// all-sides border property contains var(), resolves the var() against
// :root-level custom properties (read from the documentElement's computed
// style, which jsdom DOES handle correctly), and attaches the resolved
// width+color to every element that matches the rule's selector. The
// Node-side `checkElementBorders` adapter consumes that map as a fallback
// whenever jsdom's computed style came back empty.
//
// Limitations (intentional, to keep the pass simple):
// * Only :root-level custom properties are resolved. Scoped overrides on
// descendants are not tracked — uncommon in practice and would require
// a per-element cascade walk.
// * @media / @supports wrapped rules are ignored (jsdom often mishandles
// these anyway).
// * The fallback only fills sides that jsdom left empty, so any rule
// whose border parses normally still wins via the computed style.
const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i;
// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb().
// CSS variables typically hold hex or named colors, so normalize those to
// rgb() before handing the value off to the shared check. Anything we don't
// recognise is passed through unchanged — isNeutralColor then treats it as
// non-neutral, which is the safer default (matches the oklch-era bugfix).
const NAMED_COLORS = {
white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128],
grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0],
green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0],
};
function normalizeColorForCheck(value) {
if (!value) return value;
const v = value.trim();
const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (hex6) {
const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
return `rgb(${r}, ${g}, ${b})`;
}
const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
if (hex3) {
const [r, g, b] = [
parseInt(hex3[1] + hex3[1], 16),
parseInt(hex3[2] + hex3[2], 16),
parseInt(hex3[3] + hex3[3], 16),
];
return `rgb(${r}, ${g}, ${b})`;
}
const named = NAMED_COLORS[v.toLowerCase()];
if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`;
return v;
}
function buildBorderOverrideMap(document, window) {
const map = new Map();
const rootStyle = window.getComputedStyle(document.documentElement);
function resolveVar(value, depth = 0) {
if (!value || depth > 10 || !value.includes('var(')) return value;
return value.replace(
/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g,
(_, name, fallback) => {
const v = rootStyle.getPropertyValue(name).trim();
if (v) return resolveVar(v, depth + 1);
if (fallback) return resolveVar(fallback.trim(), depth + 1);
return '';
}
);
}
function parseShorthand(text) {
const m = text.trim().match(BORDER_SHORTHAND_RE);
if (!m) return null;
return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) };
}
// Read from the per-property accessors on rule.style. jsdom preserves
// each border-* shorthand it parsed, even when the overall cssText has
// been truncated (e.g. a `border: 1px solid var(...)` followed by a
// `border-left: ...` loses the first declaration but keeps the second).
const SIDE_PROPS = [
['borderLeft', 'Left'],
['borderRight', 'Right'],
['borderTop', 'Top'],
['borderBottom', 'Bottom'],
['borderInlineStart', 'Left'],
['borderInlineEnd', 'Right'],
];
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules || []; } catch { continue; }
for (const rule of rules) {
// CSSStyleRule only; skip @media / @keyframes / @supports wrappers.
if (rule.type !== 1 || !rule.style || !rule.selectorText) continue;
const perSide = {};
for (const [prop, side] of SIDE_PROPS) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const parsed = parseShorthand(resolveVar(val));
if (parsed && parsed.color) perSide[side] = parsed;
}
// Uniform `border: <w> <style> var(...)` applies to every side the
// per-side map didn't already claim.
const borderAll = rule.style.border;
if (borderAll && borderAll.includes('var(')) {
const parsed = parseShorthand(resolveVar(borderAll));
if (parsed && parsed.color) {
for (const s of ['Top', 'Right', 'Bottom', 'Left']) {
if (!perSide[s]) perSide[s] = parsed;
}
}
}
// Longhand `border-*-color: var(...)` with width/style in separate
// declarations. Rare in AI-generated pages, but cheap to cover.
for (const [prop, side] of [
['borderLeftColor', 'Left'],
['borderRightColor', 'Right'],
['borderTopColor', 'Top'],
['borderBottomColor', 'Bottom'],
]) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const resolved = resolveVar(val).trim();
if (!resolved) continue;
// Width may or may not come from this rule — that's fine; the
// adapter only substitutes the color when jsdom left it as a
// literal var() string.
if (!perSide[side]) perSide[side] = { width: 0, color: normalizeColorForCheck(resolved) };
}
if (Object.keys(perSide).length === 0) continue;
let matched;
try { matched = document.querySelectorAll(rule.selectorText); }
catch { continue; }
for (const el of matched) {
const existing = map.get(el);
if (existing) {
// Later rules overwrite earlier ones — approximates source-order
// cascade for equal-specificity rules and is good enough for the
// uncontested var()-dropped sides we're trying to recover.
Object.assign(existing, perSide);
} else {
map.set(el, { ...perSide });
}
}
}
}
return map;
}
// Strip `@layer NAME { … }` wrappers from a CSS / HTML source, leaving
// the inner rules as flat CSS. jsdom doesn't implement CSS @layer, so
// any rule inside a layer block becomes invisible to getComputedStyle.
// Tailwind v4 makes this ubiquitous: every utility class lives in
// `@layer utilities`, and Preflight lives in `@layer base`. Without
// unwrapping, every Tailwind-styled element returns empty computed
// styles. We walk the source character-by-character, balancing braces
// so we correctly handle nested style rules inside the layer block.
function unwrapCssAtLayer(source) {
if (!source || !source.includes('@layer')) return source;
// Find `@layer <name>? {` openers. The match starts at the @, and
// we then balance braces from the opening { onward.
const re = /@layer\b[^{;]*\{/g;
let out = '';
let lastIdx = 0;
let m;
while ((m = re.exec(source)) !== null) {
const openStart = m.index;
const openEnd = m.index + m[0].length; // position right after `{`
let depth = 1;
let i = openEnd;
while (i < source.length && depth > 0) {
const c = source.charCodeAt(i);
if (c === 0x7b /* { */) depth++;
else if (c === 0x7d /* } */) depth--;
i++;
}
if (depth !== 0) {
// Unbalanced — bail and return source unchanged.
return source;
}
// Emit everything before the @layer, then the inner contents
// (between the opening { and the matched closing }), then advance.
out += source.slice(lastIdx, openStart);
out += source.slice(openEnd, i - 1); // i-1 = position of the closing }
lastIdx = i;
re.lastIndex = i;
}
out += source.slice(lastIdx);
return out;
}
// ---------------------------------------------------------------------------
// Static HTML/CSS detection (default for local HTML files)
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
const STATIC_DEFAULT_STYLE = {
color: 'rgb(0, 0, 0)',
backgroundColor: 'rgba(0, 0, 0, 0)',
backgroundImage: 'none',
borderTopWidth: '0px',
borderRightWidth: '0px',
borderBottomWidth: '0px',
borderLeftWidth: '0px',
borderTopColor: 'rgb(0, 0, 0)',
borderRightColor: 'rgb(0, 0, 0)',
borderBottomColor: 'rgb(0, 0, 0)',
borderLeftColor: 'rgb(0, 0, 0)',
borderRadius: '0px',
outlineWidth: '0px',
outlineColor: 'rgb(0, 0, 0)',
outlineStyle: 'none',
boxShadow: 'none',
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
textTransform: 'none',
textAlign: 'start',
hyphens: 'manual',
webkitHyphens: 'manual',
transitionProperty: '',
transitionTimingFunction: '',
animationName: '',
animationTimingFunction: '',
webkitBackgroundClip: '',
backgroundClip: '',
width: '',
height: '',
paddingTop: '0px',
paddingRight: '0px',
paddingBottom: '0px',
paddingLeft: '0px',
position: 'static',
display: '',
overflow: 'visible',
overflowX: 'visible',
overflowY: 'visible',
};
const STATIC_PROP_MAP = {
'background-color': 'backgroundColor',
'background-image': 'backgroundImage',
'background-clip': 'backgroundClip',
'-webkit-background-clip': 'webkitBackgroundClip',
'border-radius': 'borderRadius',
'border-top-width': 'borderTopWidth',
'border-right-width': 'borderRightWidth',
'border-bottom-width': 'borderBottomWidth',
'border-left-width': 'borderLeftWidth',
'border-top-color': 'borderTopColor',
'border-right-color': 'borderRightColor',
'border-bottom-color': 'borderBottomColor',
'border-left-color': 'borderLeftColor',
'outline-width': 'outlineWidth',
'outline-color': 'outlineColor',
'outline-style': 'outlineStyle',
'box-shadow': 'boxShadow',
'font-family': 'fontFamily',
'font-size': 'fontSize',
'font-style': 'fontStyle',
'font-weight': 'fontWeight',
'line-height': 'lineHeight',
'letter-spacing': 'letterSpacing',
'text-transform': 'textTransform',
'text-align': 'textAlign',
'hyphens': 'hyphens',
'-webkit-hyphens': 'webkitHyphens',
'transition-property': 'transitionProperty',
'transition-timing-function': 'transitionTimingFunction',
'animation-name': 'animationName',
'animation-timing-function': 'animationTimingFunction',
'width': 'width',
'height': 'height',
'padding-top': 'paddingTop',
'padding-right': 'paddingRight',
'padding-bottom': 'paddingBottom',
'padding-left': 'paddingLeft',
'position': 'position',
'display': 'display',
'overflow': 'overflow',
'overflow-x': 'overflowX',
'overflow-y': 'overflowY',
};
const STATIC_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0, a: 1 },
white: { r: 255, g: 255, b: 255, a: 1 },
transparent: { r: 0, g: 0, b: 0, a: 0 },
gray: { r: 128, g: 128, b: 128, a: 1 },
grey: { r: 128, g: 128, b: 128, a: 1 },
silver: { r: 192, g: 192, b: 192, a: 1 },
red: { r: 255, g: 0, b: 0, a: 1 },
green: { r: 0, g: 128, b: 0, a: 1 },
blue: { r: 0, g: 0, b: 255, a: 1 },
};
function splitCssList(value) {
const parts = [];
let depth = 0, quote = '', start = 0;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; continue; }
if (ch === '(' || ch === '[') depth++;
else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(value.slice(start, i).trim());
start = i + 1;
}
}
const tail = value.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
function splitCssTokens(value) {
const tokens = [];
let depth = 0, quote = '', current = '';
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
current += ch;
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
if (ch === '(') { depth++; current += ch; continue; }
if (ch === ')') { depth = Math.max(0, depth - 1); current += ch; continue; }
if (/\s/.test(ch) && depth === 0) {
if (current) { tokens.push(current); current = ''; }
continue;
}
current += ch;
}
if (current) tokens.push(current);
return tokens;
}
function cssPropToCamel(prop) {
if (!prop) return prop;
const mapped = STATIC_PROP_MAP[prop];
if (mapped) return mapped;
return prop.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
}
function staticColorToCss(c) {
if (!c) return '';
if (c.a != null && c.a < 1) return `rgba(${c.r}, ${c.g}, ${c.b}, ${Number(c.a.toFixed(3))})`;
return `rgb(${c.r}, ${c.g}, ${c.b})`;
}
function parseStaticColor(value) {
const parsed = parseAnyColor(value);
if (parsed) return parsed;
const named = STATIC_NAMED_COLORS[String(value || '').trim().toLowerCase()];
return named ? { ...named } : null;
}
function extractStaticColor(value) {
if (!value) return '';
const raw = String(value).trim();
if (/^var\(/i.test(raw)) return raw;
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);
if (!colorLike) return '';
return colorLike[0];
}
function normalizeStaticCssValue(prop, value, customProps, parentStyle, currentStyle = null) {
let resolved = resolveVarRefs(String(value || '').trim(), customProps);
if (resolved === 'inherit') return parentStyle?.[prop] || STATIC_DEFAULT_STYLE[prop] || '';
const isModernBorderColor = /^border[A-Z][a-z]+Color$/.test(prop) && /^(?:oklch|oklab|lch|lab|hsl|hwb)\(/i.test(resolved);
if (!isModernBorderColor && (/color$/i.test(prop) || prop === 'color' || prop === 'backgroundColor')) {
const parsed = parseStaticColor(resolved);
if (parsed) resolved = staticColorToCss(parsed);
}
if (prop === 'fontSize') {
const base = parseFloat(parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'letterSpacing') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'lineHeight' && resolved !== 'normal') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
return resolved;
}
function expandStaticBoxValues(tokens) {
if (tokens.length === 0) return ['0px', '0px', '0px', '0px'];
if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]];
if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]];
if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]];
return [tokens[0], tokens[1], tokens[2], tokens[3]];
}
function parseStaticBorder(value) {
const tokens = splitCssTokens(value);
let width = '', color = '';
for (const token of tokens) {
if (!width && /^-?[\d.]+(?:px|rem|em|%)$/.test(token)) width = token;
if (!color) color = extractStaticColor(token);
}
return { width, color };
}
function parseStaticFont(value) {
const out = [];
const slashParts = value.match(/(?:^|\s)([\d.]+(?:px|rem|em|%))(?:\/([^\s]+))?/);
if (/\bitalic\b/i.test(value)) out.push(['fontStyle', 'italic']);
const weight = value.match(/\b([1-9]00|bold|normal|lighter|bolder)\b/i);
if (weight) out.push(['fontWeight', weight[1]]);
if (slashParts) {
out.push(['fontSize', slashParts[1]]);
if (slashParts[2]) out.push(['lineHeight', slashParts[2]]);
const familyStart = value.indexOf(slashParts[0]) + slashParts[0].length;
const family = value.slice(familyStart).trim();
if (family) out.push(['fontFamily', family]);
}
return out;
}
function parseStaticTransition(value) {
const props = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const prop = tokens.find(token => /^[a-z-]+$/i.test(token) && !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none)$/.test(token) && !/s$/.test(token));
if (prop) props.push(prop);
}
return {
property: props.join(', '),
timing: timings.join(', '),
};
}
function parseStaticAnimation(value) {
const names = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const name = tokens.find(token =>
/^[a-z_-][\w-]*$/i.test(token) &&
!/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none|running|paused)$/.test(token)
);
if (name) names.push(name);
}
return {
name: names.join(', '),
timing: timings.join(', '),
};
}
function expandStaticDeclaration(prop, value) {
const p = prop.toLowerCase();
const v = String(value || '').trim();
if (!v) return [];
if (p.startsWith('--')) return [[p, v]];
if (p === 'background') {
const out = [];
const hasImage = /gradient|url\(/i.test(v);
if (hasImage) out.push(['backgroundImage', v]);
const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;
const color = extractStaticColor(hasImage ? beforeImage : v);
if (color) out.push(['backgroundColor', color]);
return out;
}
if (p === 'border') {
const parsed = parseStaticBorder(v);
const out = [];
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if (parsed.width) out.push([`border${side}Width`, parsed.width]);
if (parsed.color) out.push([`border${side}Color`, parsed.color]);
}
return out;
}
if (p === 'outline') {
// `outline` shorthand: width | style | color, in any order. Reuse the
// border parser for width + color, then sniff a style keyword from the
// tokens (solid|dashed|...). `outline: 0` (single-token zero) zeros
// the width and effectively hides the outline.
const tokens = splitCssTokens(v);
const parsed = parseStaticBorder(v);
const styleToken = tokens.find(t =>
/^(none|hidden|solid|dashed|dotted|double|groove|ridge|inset|outset)$/i.test(t)
);
const out = [];
if (parsed.width) out.push(['outlineWidth', parsed.width]);
if (parsed.color) out.push(['outlineColor', parsed.color]);
if (styleToken) out.push(['outlineStyle', styleToken.toLowerCase()]);
// `outline: 0` with no other tokens: explicit zero width.
if (!parsed.width && /^0(?:px|rem|em|%)?$/.test(v.trim())) {
out.push(['outlineWidth', '0px']);
}
return out;
}
const sideMatch = p.match(/^border-(top|right|bottom|left)$/);
if (sideMatch) {
const parsed = parseStaticBorder(v);
const side = sideMatch[1][0].toUpperCase() + sideMatch[1].slice(1);
return [
...(parsed.width ? [[`border${side}Width`, parsed.width]] : []),
...(parsed.color ? [[`border${side}Color`, parsed.color]] : []),
];
}
if (p === 'border-width') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopWidth', vals[0]],
['borderRightWidth', vals[1]],
['borderBottomWidth', vals[2]],
['borderLeftWidth', vals[3]],
];
}
if (p === 'border-color') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopColor', vals[0]],
['borderRightColor', vals[1]],
['borderBottomColor', vals[2]],
['borderLeftColor', vals[3]],
];
}
if (p === 'padding') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['paddingTop', vals[0]],
['paddingRight', vals[1]],
['paddingBottom', vals[2]],
['paddingLeft', vals[3]],
];
}
if (p === 'font') return parseStaticFont(v);
if (p === 'transition') {
const parsed = parseStaticTransition(v);
return [
...(parsed.property ? [['transitionProperty', parsed.property]] : []),
...(parsed.timing ? [['transitionTimingFunction', parsed.timing]] : []),
];
}
if (p === 'animation') {
const parsed = parseStaticAnimation(v);
return [
...(parsed.name ? [['animationName', parsed.name]] : []),
...(parsed.timing ? [['animationTimingFunction', parsed.timing]] : []),
];
}
const mapped = cssPropToCamel(p);
if (STATIC_DEFAULT_STYLE[mapped] != null || STATIC_INHERITED_PROPS.has(mapped)) {
return [[mapped, v]];
}
return [];
}
function compareStaticPriority(a, b) {
if (!a) return true;
if (!!b.important !== !!a.important) return !!b.important;
if (!!b.inline !== !!a.inline) return !!b.inline;
for (let i = 0; i < 3; i++) {
if ((b.specificity[i] || 0) !== (a.specificity[i] || 0)) {
return (b.specificity[i] || 0) > (a.specificity[i] || 0);
}
}
return b.order >= a.order;
}
function staticSpecificity(selector) {
const noWhere = selector.replace(/:where\([^)]*\)/g, '');
const ids = (noWhere.match(/#[\w-]+/g) || []).length;
const classes = (noWhere.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]*\))?/g) || []).length;
const stripped = noWhere
.replace(/#[\w-]+/g, ' ')
.replace(/\.[\w-]+|\[[^\]]+\]|:{1,2}[\w-]+(?:\([^)]*\))?/g, ' ')
.replace(/[*>+~(),]/g, ' ');
const types = (stripped.match(/\b[a-zA-Z][\w-]*\b/g) || []).length;
return [ids, classes, types];
}
function applyStaticDeclaration(specified, node, prop, value, meta) {
let map = specified.get(node);
if (!map) { map = new Map(); specified.set(node, map); }
for (const [expandedProp, expandedValue] of expandStaticDeclaration(prop, value)) {
const existing = map.get(expandedProp);
const next = { ...meta, prop: expandedProp, value: expandedValue };
if (compareStaticPriority(existing, next)) map.set(expandedProp, next);
}
}
function parseStaticStyleAttribute(styleText, orderBase = 0) {
const decls = [];
for (const part of String(styleText || '').split(';')) {
const idx = part.indexOf(':');
if (idx <= 0) continue;
const prop = part.slice(0, idx).trim();
let value = part.slice(idx + 1).trim();
const important = /!important\s*$/i.test(value);
value = value.replace(/\s*!important\s*$/i, '').trim();
decls.push({ prop, value, important, order: orderBase + decls.length });
}
return decls;
}
function collectStaticCssRules(cssText, csstree) {
const rules = [];
let ast;
try {
ast = csstree.parse(cssText, { positions: false, parseValue: true, parseCustomProperty: false });
} catch {
return rules;
}
let order = 0;
const walkList = (list, atRuleStack = []) => {
list?.forEach?.(node => {
if (node.type === 'Rule' && node.block) {
if (atRuleStack.some(name => /keyframes$/i.test(name))) return;
const selectorText = csstree.generate(node.prelude).trim();
const declarations = [];
node.block.children?.forEach?.(child => {
if (child.type !== 'Declaration') return;
declarations.push({
prop: child.property,
value: csstree.generate(child.value).trim(),
important: !!child.important,
});
});
for (const selector of splitCssList(selectorText)) {
if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });
}
return;
}
if (node.type === 'Atrule' && node.block) {
const name = String(node.name || '').toLowerCase();
if (name === 'media' || name === 'supports' || name === 'layer') {
walkList(node.block.children, [...atRuleStack, name]);
}
}
});
};
walkList(ast.children);
return rules;
}
class StaticElement {
constructor(node, doc) {
this.node = node;
this._doc = doc;
this.nodeType = 1;
this.tagName = String(node.name || '').toUpperCase();
this.nodeName = this.tagName;
}
get parentElement() {
let cur = this.node.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
return cur ? this._doc.wrap(cur) : null;
}
get previousElementSibling() {
let cur = this.node.prev;
while (cur && cur.type !== 'tag') cur = cur.prev;
return cur ? this._doc.wrap(cur) : null;
}
get children() {
return (this.node.children || []).filter(child => child.type === 'tag').map(child => this._doc.wrap(child));
}
get childNodes() {
return (this.node.children || []).map(child => {
if (child.type === 'text') return { nodeType: 3, textContent: child.data || '' };
if (child.type === 'tag') return this._doc.wrap(child);
return { nodeType: 8, textContent: child.data || '' };
});
}
get textContent() {
return this._doc.domutils.textContent(this.node);
}
get className() {
return this.getAttribute('class') || '';
}
get id() {
return this.getAttribute('id') || '';
}
getAttribute(name) {
return this.node.attribs?.[name] ?? null;
}
querySelector(selector) {
try {
const found = this._doc.selectOne(selector, this.node.children || []);
return found ? this._doc.wrap(found) : null;
} catch {
return null;
}
}
querySelectorAll(selector) {
try {
return this._doc.selectAll(selector, this.node.children || []).map(node => this._doc.wrap(node));
} catch {
return [];
}
}
closest(selector) {
let cur = this.node;
while (cur && cur.type === 'tag') {
try {
if (this._doc.is(cur, selector)) return this._doc.wrap(cur);
} catch {
return null;
}
cur = cur.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
}
return null;
}
contains(other) {
let cur = other?.node || null;
while (cur) {
if (cur === this.node) return true;
cur = cur.parent;
}
return false;
}
}
class StaticDocument {
constructor(root, modules) {
this.root = root;
this.selectAll = modules.selectAll;
this.selectOne = modules.selectOne;
this.is = modules.is;
this.domutils = modules.domutils;
this._wrappers = new WeakMap();
this._styleMap = new WeakMap();
}
wrap(node) {
let wrapped = this._wrappers.get(node);
if (!wrapped) {
wrapped = new StaticElement(node, this);
this._wrappers.set(node, wrapped);
}
return wrapped;
}
querySelectorAll(selector) {
try {
return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));
} catch {
return [];
}
}
querySelector(selector) {
try {
const found = this.selectOne(selector, this.root.children || []);
return found ? this.wrap(found) : null;
} catch {
return null;
}
}
get documentElement() {
return this.querySelector('html');
}
get body() {
return this.querySelector('body');
}
setStyle(node, style) {
this._styleMap.set(node, style);
}
getStyle(el) {
return this._styleMap.get(el.node) || makeStaticStyle();
}
}
function makeStaticStyle(values = {}) {
const style = { ...STATIC_DEFAULT_STYLE, ...values };
style.getPropertyValue = (prop) => {
const key = cssPropToCamel(prop);
return style[key] || style[prop] || '';
};
return style;
}
function buildStaticWindow(staticDoc) {
return {
document: staticDoc,
getComputedStyle: (el) => staticDoc.getStyle(el),
};
}
function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const styleTexts = [];
for (const styleEl of modules.selectAll('style', root.children || [])) {
styleTexts.push(modules.domutils.textContent(styleEl));
}
const links = modules.selectAll('link', root.children || []);
for (const link of links) {
const rel = link.attribs?.rel || '';
const href = link.attribs?.href || '';
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
const cssPath = path.resolve(fileDir, href);
try {
const css = profileStep(profile, {
engine: 'static-html',
phase: 'preprocess',
ruleId: 'inline-linked-stylesheet',
target: filePath,
detail: href,
}, () => fs.readFileSync(cssPath, 'utf-8'));
styleTexts.push(css);
} catch { /* skip unreadable */ }
}
return styleTexts.join('\n');
}
function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
const specified = new Map();
const allNodes = modules.selectAll('*', root.children || []);
const rules = profileStep(profile, {
engine: 'static-html',
phase: 'parse-css',
ruleId: 'css-rules',
target: filePath,
}, () => collectStaticCssRules(cssText, modules.csstree));
profileStep(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'css-selectors',
target: filePath,
}, () => {
for (const rule of rules) {
let matched;
try {
matched = modules.selectAll(rule.selector, root.children || []);
} catch {
recordProfileEvent(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'unsupported-selector',
target: filePath,
ms: 0,
findings: 0,
detail: rule.selector,
});
continue;
}
for (const node of matched) {
for (const decl of rule.declarations) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: rule.specificity,
order: rule.order,
inline: false,
});
}
}
}
let inlineOrder = rules.length + 1;
for (const node of allNodes) {
const styleText = node.attribs?.style;
if (!styleText) continue;
for (const decl of parseStaticStyleAttribute(styleText, inlineOrder)) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: [1, 0, 0],
order: decl.order,
inline: true,
});
}
inlineOrder += 1000;
}
});
const computeNode = (node, parentStyle = null, parentCustom = new Map()) => {
const specifiedMap = specified.get(node) || new Map();
const customProps = new Map(parentCustom);
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) customProps.set(prop, resolveVarRefs(decl.value, customProps));
}
const values = {};
for (const prop of Object.keys(STATIC_DEFAULT_STYLE)) {
if (STATIC_INHERITED_PROPS.has(prop) && parentStyle?.[prop] != null) values[prop] = parentStyle[prop];
else values[prop] = STATIC_DEFAULT_STYLE[prop];
}
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) continue;
values[prop] = normalizeStaticCssValue(prop, decl.value, customProps, parentStyle, values);
}
const style = makeStaticStyle(values);
staticDoc.setStyle(node, style);
for (const child of node.children || []) {
if (child.type === 'tag') computeNode(child, style, customProps);
}
};
profileStep(profile, {
engine: 'static-html',
phase: 'cascade',
ruleId: 'compute-styles',
target: filePath,
}, () => {
for (const child of root.children || []) {
if (child.type === 'tag') computeNode(child);
}
});
}
export {
BORDER_SHORTHAND_RE,
NAMED_COLORS,
normalizeColorForCheck,
buildBorderOverrideMap,
unwrapCssAtLayer,
STATIC_INHERITED_PROPS,
STATIC_DEFAULT_STYLE,
STATIC_PROP_MAP,
STATIC_NAMED_COLORS,
splitCssList,
splitCssTokens,
cssPropToCamel,
staticColorToCss,
parseStaticColor,
extractStaticColor,
normalizeStaticCssValue,
expandStaticBoxValues,
parseStaticBorder,
parseStaticFont,
parseStaticTransition,
parseStaticAnimation,
expandStaticDeclaration,
compareStaticPriority,
staticSpecificity,
applyStaticDeclaration,
parseStaticStyleAttribute,
collectStaticCssRules,
StaticElement,
StaticDocument,
makeStaticStyle,
buildStaticWindow,
collectStaticCssText,
buildStaticStyleMap,
};

View file

@ -0,0 +1,208 @@
import fs from 'node:fs';
import path from 'node:path';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
checkElementClippedOverflow,
checkElementColors,
checkElementGlow,
checkElementGptBorderShadow,
checkElementHeroEyebrow,
checkElementIconTile,
checkElementItalicSerif,
checkElementMotion,
checkElementOversizedH1,
checkElementQuality,
checkCreamPalette,
checkHtmlPatterns,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs';
import {
StaticDocument,
buildStaticStyleMap,
buildStaticWindow,
collectStaticCssText,
} from './css-cascade.mjs';
function checkStaticPageTypography(document, window) {
const findings = [];
const fonts = new Set();
const overusedFound = new Set();
for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) {
const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
if (!hasText) continue;
const ff = window.getComputedStyle(el).fontFamily || '';
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
if (!primary) continue;
fonts.add(primary);
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
}
for (const font of overusedFound) {
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
}
if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
}
const sizes = new Set();
for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) {
const fontSize = parseFloat(window.getComputedStyle(el).fontSize);
if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
}
if (sizes.size >= 3) {
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
if (ratio < 2.0) {
findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
}
}
return findings;
}
function checkElementBrokenImage(el) {
const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src;
// Missing src attribute entirely
if (src === undefined || src === null) {
return [{ id: 'broken-image', snippet: '<img> with no src attribute' }];
}
const trimmed = String(src).trim();
// Empty or placeholder-only src values
if (trimmed === '' || trimmed === '#') {
return [{ id: 'broken-image', snippet: `<img src="${src}">` }];
}
return [];
}
const STATIC_ELEMENT_RULES = [
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) },
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
{ id: 'dark-glow', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window, customPropMap)) },
{ id: 'motion-rules', selector: '*', run: (el, tag, style) => checkElementMotion(tag, style) },
{ id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) },
{ id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) },
{ id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) },
{ id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) },
{ id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) },
{ id: 'oversized-h1', selector: 'h1', run: (el, tag, style, window) => checkElementOversizedH1(el, style, tag, window) },
{ id: 'clipped-overflow-container', selector: '*', run: (el, tag, style, window) => checkElementClippedOverflow(el, style, tag, window) },
{ id: 'gpt-thin-border-wide-shadow', selector: '*', run: (el, tag, style) => checkElementGptBorderShadow(el, style) },
];
async function detectHtml(filePath, options = {}) {
const profile = options?.profile;
const html = profileStep(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'read-html',
target: filePath,
}, () => fs.readFileSync(filePath, 'utf-8'));
let modules;
try {
modules = await profileStepAsync(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'import-static-parser',
target: filePath,
}, async () => {
const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([
import('htmlparser2'),
import('css-select'),
import('css-tree'),
import('domutils'),
]);
return {
parseDocument: htmlparser2.parseDocument,
selectAll: cssSelect.selectAll,
selectOne: cssSelect.selectOne,
is: cssSelect.is,
csstree,
domutils,
};
});
} catch {
return detectText(html, filePath, options);
}
const resolvedPath = path.resolve(filePath);
const fileDir = path.dirname(resolvedPath);
const root = profileStep(profile, {
engine: 'static-html',
phase: 'parse-html',
ruleId: 'parse-document',
target: filePath,
}, () => modules.parseDocument(html, { lowerCaseAttributeNames: false, lowerCaseTags: true }));
const cssText = collectStaticCssText(root, fileDir, profile, filePath, modules);
const document = new StaticDocument(root, modules);
buildStaticStyleMap(root, document, cssText, modules, profile, filePath);
const window = buildStaticWindow(document);
const customPropMap = null;
const findings = [];
const runElementCheck = (ruleId, callback) => profile
? profileFindings(profile, { engine: 'static-html', phase: 'element', ruleId, target: filePath }, callback)
: callback();
const visitedByRule = new Map();
for (const rule of STATIC_ELEMENT_RULES) {
const elements = document.querySelectorAll(rule.selector);
visitedByRule.set(rule.id, elements.length);
for (const el of elements) {
const tag = el.tagName.toLowerCase();
const style = window.getComputedStyle(el);
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
}
if (isFullPage(html)) {
const runPageCheck = (ruleId, callback) => profile
? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback)
: callback();
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('layout-rules', () => checkPageLayout(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('cream-palette', () => checkCreamPalette(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('skipped-heading', () => checkPageQualityFromDoc(document))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html).filter(item =>
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
))) {
findings.push(finding(f.id, filePath, f.snippet));
}
// Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex
// engine. Call them from here so .html files get the same coverage
// as .css/.tsx files. These are scoped to text content only and
// don't overlap with static-html's element/page rules.
for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) {
findings.push(finding(f.antipattern, filePath, f.snippet));
}
}
return filterByProviders(findings, options.providers);
}
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };