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,252 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {
if (options?.visualContrast === false) return [];
const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates)
? options.visualContrastMaxCandidates
: 12;
const scrollOffscreen = options?.visualContrastScrollOffscreen !== false;
const existingLowContrastSelectors = new Set(
serializedGroups
.filter(group => group.findings?.some(f => f.type === 'low-contrast'))
.map(group => group.selector)
.filter(Boolean)
);
let browserAnalyses = [];
const findings = [];
if (options?.visualContrastBrowser !== false) {
const browserFindings = await profileFindingsAsync(profile, {
engine: 'browser',
phase: 'visual-contrast',
ruleId: 'browser-fallback',
target,
}, async () => {
browserAnalyses = await page.evaluate(async ({ maxCandidates, scrollOffscreen }) => {
if (typeof window.impeccableAnalyzeVisualContrast !== 'function') return [];
return window.impeccableAnalyzeVisualContrast({ maxCandidates, scrollOffscreen });
}, { maxCandidates, scrollOffscreen });
return browserAnalyses
.filter(result => result.finding && !existingLowContrastSelectors.has(result.selector))
.map(result => result.finding);
});
findings.push(...browserFindings);
}
let candidates = browserAnalyses.length > 0 ? browserAnalyses : [];
if (candidates.length === 0) {
candidates = await profileStepAsync(profile, {
engine: 'browser',
phase: 'visual-contrast',
ruleId: 'collect-candidates',
target,
}, () => page.evaluate(({ maxCandidates }) => {
if (typeof window.impeccableCollectVisualContrastCandidates !== 'function') return [];
return window.impeccableCollectVisualContrastCandidates({ maxCandidates });
}, { maxCandidates }));
}
const viewport = options?.viewport || { width: 1280, height: 800 };
const browserResolvedSelectors = new Set(
browserAnalyses
.filter(result => result.status === 'fail' || result.status === 'pass')
.map(result => result.selector)
.filter(Boolean)
);
const filtered = candidates.filter(candidate =>
!existingLowContrastSelectors.has(candidate.selector) &&
!browserResolvedSelectors.has(candidate.selector)
);
if (options?.visualContrastPixel === false) return findings;
for (const candidate of filtered) {
const result = await profileFindingsAsync(profile, {
engine: 'browser',
phase: 'visual-contrast',
ruleId: 'pixel-diff',
target,
}, async () => {
const finding = await captureVisualContrastCandidate(page, candidate, viewport);
return finding ? [finding] : [];
});
findings.push(...result);
}
return findings;
}
// ---------------------------------------------------------------------------
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
const viewport = options?.viewport || { width: 1280, height: 800 };
const externalBrowser = options?.browser || null;
let puppeteer;
if (!externalBrowser) {
try {
puppeteer = await profileStepAsync(profile, {
engine: 'browser',
phase: 'setup',
ruleId: 'import-puppeteer',
target: url,
}, () => import('puppeteer'));
} catch {
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
}
}
// Read the browser detection script — reuse it instead of reimplementing
const browserScriptPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'detect-antipatterns-browser.js'
);
let browserScript;
try {
browserScript = profileStep(profile, {
engine: 'browser',
phase: 'setup',
ruleId: 'read-browser-script',
target: url,
}, () => fs.readFileSync(browserScriptPath, 'utf-8'));
} catch {
throw new Error(`Browser script not found at ${browserScriptPath}`);
}
// CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so
// Chrome can't initialize its sandbox there. Disable the sandbox only when
// running in CI; local users keep the default hardened launch.
const launchArgs = process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
const browser = externalBrowser || await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'launch-browser',
target: url,
}, () => puppeteer.default.launch({ headless: true, args: launchArgs }));
const page = await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'new-page',
target: url,
}, () => browser.newPage());
let results = [];
try {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: `goto:${waitUntil}`,
target: url,
}, () => page.goto(url, { waitUntil, timeout: 30000 }));
if (settleMs > 0) {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'settle',
target: url,
}, () => new Promise(resolve => setTimeout(resolve, settleMs)));
}
// Inject the browser detection script and collect results
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'configure-pure-detect',
target: url,
}, () => page.evaluate(() => {
window.__IMPECCABLE_CONFIG__ = {
...(window.__IMPECCABLE_CONFIG__ || {}),
autoScan: false,
};
}));
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'inject-browser-script',
target: url,
}, () => page.evaluate(browserScript));
let serializedGroups = [];
results = await profileFindingsAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'browser-scan',
target: url,
}, async () => {
serializedGroups = await page.evaluate(() => {
if (!window.impeccableDetect) return [];
return window.impeccableDetect({ decorate: false, serialize: true });
});
return serializedGroups.flatMap(({ findings }) =>
findings.map(f => ({ id: f.type, snippet: f.detail }))
);
});
const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
results.push(...visualFindings);
} finally {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'close-page',
target: url,
}, () => page.close().catch(() => {}));
if (!externalBrowser) {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'close-browser',
target: url,
}, () => browser.close());
}
}
return filterByProviders(results.map(f => finding(f.id, url, f.snippet)), options.providers);
}
async function createBrowserDetector(options = {}) {
let puppeteer;
try {
puppeteer = await import('puppeteer');
} catch {
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
}
const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []);
const browser = options.browser || await puppeteer.default.launch({
headless: options.headless ?? true,
args: launchArgs,
});
const ownsBrowser = !options.browser;
const defaults = {
waitUntil: options.waitUntil || 'load',
settleMs: Number.isFinite(options.settleMs) ? options.settleMs : 100,
viewport: options.viewport || { width: 1280, height: 800 },
};
return {
browser,
async detectUrl(url, scanOptions = {}) {
return detectUrl(url, {
...defaults,
...scanOptions,
browser,
});
},
async close() {
if (ownsBrowser) await browser.close().catch(() => {});
},
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector };

View file

@ -0,0 +1,535 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
// ---------------------------------------------------------------------------
// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)
// ---------------------------------------------------------------------------
const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
const hasBorderRadius = (line) => /border-radius/i.test(line);
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
/** Strip HTML to plain text drops script/style/comments/tags so
* content-text analyzers don't false-positive on code or CSS. */
function stripHtmlToText(html) {
return html
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ');
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
if (shex) {
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
return false;
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,
test: (m) => +m[1] >= 3,
fmt: (m) => m[0] },
// --- Border accent on rounded ---
{ id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g,
test: (m, line) => hasRounded(line) && +m[1] >= 1,
fmt: (m) => m[0] },
{ id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi,
test: (m, line) => +m[1] >= 3 && hasBorderRadius(line),
fmt: (m) => m[0] },
// --- Overused font ---
{ id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica|Fraunces|Geist Sans|Geist Mono|Geist|Mona Sans|Plus Jakarta Sans|Space Grotesk|Recoleta|Instrument Sans|Instrument Serif)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Instrument\+Serif|Mona\+Sans|Geist)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
// --- Gradient text ---
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
test: (m, line) => /gradient/i.test(line),
fmt: () => 'background-clip: text + gradient' },
// --- Gradient text (Tailwind) ---
{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,
test: (m, line) => /\bbg-gradient-to-/i.test(line),
fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
// --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
fmt: (m) => `${m[0]} on heading` },
{ id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line),
fmt: (m) => `${m[0]} gradient` },
// --- Bounce/elastic easing ---
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1;
},
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
// --- Layout property transition ---
{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
test: (m) => {
const val = m[1].toLowerCase();
if (/\ball\b/.test(val)) return false;
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
},
fmt: (m) => {
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
return `transition: ${found ? found.join(', ') : m[1].trim()}`;
} },
{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
test: (m) => {
const val = m[1].toLowerCase();
if (/\ball\b/.test(val)) return false;
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
},
fmt: (m) => {
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
} },
// --- Broken image: src="" or src="#" or src=" " ---
{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
test: () => true,
fmt: (m) => m[0].slice(0, 100) },
// --- Broken image: <img> with no src attribute at all ---
{ id: 'broken-image', regex: /<img\b(?:(?!\bsrc\s*=)[^>])*>/gi,
test: (m) => !/\bsrc\s*=/i.test(m[0]),
fmt: (m) => m[0].slice(0, 100) },
];
const REGEX_ANALYZERS = [
// Single font
(content, filePath) => {
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
const fonts = new Set();
let m;
while ((m = fontFamilyRe.exec(content)) !== null) {
for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
}
}
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
}
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
const name = [...fonts][0];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
return [finding('single-font', filePath, `only font used is ${name}`, line)];
},
// Flat type hierarchy
(content, filePath) => {
const sizes = new Set();
const REM = 16;
let m;
const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
while ((m = sizeRe.exec(content)) !== null) {
const px = m[2] === 'px' ? +m[1] : +m[1] * REM;
if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
}
const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
while ((m = clampRe.exec(content)) !== null) {
sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);
sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);
}
const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 };
for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }
if (sizes.size < 3) return [];
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
if (ratio >= 2.0) return [];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } }
return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
},
// Monotonous spacing (regex)
(content, filePath) => {
const vals = [];
let m;
const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }
const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }
const gapRe = /gap\s*:\s*(\d+)px/gi;
while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);
const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);
const rounded = vals.map(v => Math.round(v / 4) * 4);
if (rounded.length < 10) return [];
const counts = {};
for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
const maxCount = Math.max(...Object.values(counts));
const pct = maxCount / rounded.length;
const unique = [...new Set(rounded)].filter(v => v > 0);
if (pct <= 0.6 || unique.length > 3) return [];
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
},
// Em-dash overuse: 5+ em-dashes or "--" in body text content
// (occasional em-dash use in prose is fine; the pattern fires only
// when count crosses into AI-cadence territory).
(content, filePath) => {
const text = stripHtmlToText(content);
let count = 0;
const re = /[—]|--(?=\S)/g;
while (re.exec(text) !== null) count++;
if (count < 5) return [];
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
},
// Marketing buzzwords: SaaS phrase list
(content, filePath) => {
const text = stripHtmlToText(content);
const lower = text.toLowerCase();
const BUZZWORDS = [
'streamline your', 'empower your', 'supercharge your',
'unleash your', 'unleash the power', 'leverage the power',
'built for the modern', 'trusted by leading', 'trusted by the world',
'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade',
'next-generation', 'cutting-edge', 'transform your business',
'revolutionize', 'game-changer', 'game changing',
'mission-critical', 'best of breed', 'future-proof', 'future proof',
'seamless experience', 'seamlessly integrate',
'drive engagement', 'drive growth', 'drive results',
'harness the power',
];
let count = 0;
let firstSample = '';
for (const phrase of BUZZWORDS) {
let from = 0;
while (true) {
const idx = lower.indexOf(phrase, from);
if (idx === -1) break;
count++;
if (!firstSample) {
firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim();
}
from = idx + phrase.length;
}
}
if (count === 0) return [];
return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)];
},
// Numbered section markers (01 / 02 / 03 ...)
(content, filePath) => {
const text = stripHtmlToText(content);
const re = /\b(0[1-9]|1[0-2])\b/g;
const seen = new Set();
let m;
while ((m = re.exec(text)) !== null) seen.add(m[1]);
if (seen.size < 3) return [];
const sorted = [...seen].sort();
let sequential = 0;
for (let i = 1; i < sorted.length; i++) {
if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++;
}
if (sequential < 2) return [];
return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)];
},
// Aphoristic cadence: manufactured-contrast + short-rebuttal
(content, filePath) => {
const text = stripHtmlToText(content);
const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g;
const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g;
let count = 0;
let firstSample = '';
let m;
NOT_A_RE.lastIndex = 0;
while ((m = NOT_A_RE.exec(text)) !== null) {
count++;
if (!firstSample) firstSample = m[0].trim().slice(0, 80);
}
SHORT_REBUTTAL_RE.lastIndex = 0;
while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) {
count++;
if (!firstSample) firstSample = m[0].trim().slice(0, 80);
}
if (count < 3) return [];
return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)];
},
// Dark glow (page-level: dark bg + colored box-shadow with blur)
(content, filePath) => {
// Check if page has a dark background
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
if (!hasDarkBg) return [];
// Check for colored box-shadow with blur > 4px
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let m;
while ((m = shadowRe.exec(content)) !== null) {
const val = m[1];
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!colorMatch) continue;
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
// Check blur: look for pattern like "0 0 20px" (third number > 4)
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
if (pxVals.length >= 3 && pxVals[2] > 4) {
const lines = content.substring(0, m.index).split('\n');
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
}
}
return [];
},
];
// ---------------------------------------------------------------------------
// Style block extraction (Vue/Svelte <style> blocks)
// ---------------------------------------------------------------------------
function extractStyleBlocks(content, ext) {
ext = ext.toLowerCase();
if (ext !== '.vue' && ext !== '.svelte') return [];
const blocks = [];
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
let m;
while ((m = re.exec(content)) !== null) {
const before = content.substring(0, m.index);
const startLine = before.split('\n').length + 1;
blocks.push({ content: m[1], startLine });
}
return blocks;
}
// ---------------------------------------------------------------------------
// CSS-in-JS extraction (styled-components, emotion)
// ---------------------------------------------------------------------------
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
function extractCSSinJS(content, ext) {
ext = ext.toLowerCase();
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
const blocks = [];
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
let m;
while ((m = re.exec(content)) !== null) {
const before = content.substring(0, m.index);
const startLine = before.split('\n').length;
blocks.push({ content: m[1], startLine });
}
return blocks;
}
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
const { profile, phase = 'regex-matchers' } = options || {};
const findings = [];
if (!profile) {
for (const matcher of REGEX_MATCHERS) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
matcher.regex.lastIndex = 0;
let m;
while ((m = matcher.regex.exec(line)) !== null) {
// For extracted blocks, use nearby lines as context for multi-line CSS patterns
const context = blockContext
? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
: line;
if (matcher.test(m, context)) {
findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
}
}
}
}
return findings;
}
for (const matcher of REGEX_MATCHERS) {
const matcherFindings = profileFindings(profile, {
engine: 'regex',
phase,
ruleId: matcher.id,
target: filePath,
}, () => {
const matches = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
matcher.regex.lastIndex = 0;
let m;
while ((m = matcher.regex.exec(line)) !== null) {
// For extracted blocks, use nearby lines as context for multi-line CSS patterns
const context = blockContext
? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
: line;
if (matcher.test(m, context)) {
matches.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
}
}
}
return matches;
});
findings.push(...matcherFindings);
}
return findings;
}
/** Page-level analyzers that scan rendered text content (em-dash use,
* buzzword phrases, numbered section markers, aphoristic cadence).
* These are detector-agnostic they work on any HTML/text source
* and don't need a parsed DOM. Exported so detectHtml can call them
* for `.html` files (which otherwise skip the regex engine). */
const TEXT_CONTENT_ANALYZER_IDS = [
'em-dash-overuse',
'marketing-buzzword',
'numbered-section-markers',
'aphoristic-cadence',
];
function runTextContentAnalyzers(content, filePath, options = {}) {
const profile = options?.profile;
if (!isFullPage(content)) return [];
// The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS.
const findings = [];
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
const analyzer = REGEX_ANALYZERS[3 + i];
const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];
findings.push(...profileFindings(profile, {
engine: 'regex',
phase: 'text-content',
ruleId,
target: filePath,
}, () => analyzer(content, filePath)));
}
return findings;
}
function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
// Enable block context for CSS files where related properties span multiple lines
const cssLike = new Set(['.css', '.scss', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
// Extract and scan <style> blocks from Vue/Svelte SFCs
const styleBlocks = profile
? profileStep(profile, {
engine: 'regex',
phase: 'extract',
ruleId: 'style-blocks',
target: filePath,
}, () => extractStyleBlocks(content, ext))
: extractStyleBlocks(content, ext);
for (const block of styleBlocks) {
const blockLines = block.content.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'style-block',
}));
}
// Extract and scan CSS-in-JS template literals
const cssJsBlocks = profile
? profileStep(profile, {
engine: 'regex',
phase: 'extract',
ruleId: 'css-in-js',
target: filePath,
}, () => extractCSSinJS(content, ext))
: extractCSSinJS(content, ext);
for (const block of cssJsBlocks) {
const blockLines = block.content.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'css-in-js',
}));
}
// Deduplicate findings (same antipattern + similar snippet, within 2 lines)
const deduped = [];
for (const f of findings) {
const isDupe = deduped.some(d =>
d.antipattern === f.antipattern &&
d.snippet === f.snippet &&
Math.abs(d.line - f.line) <= 2
);
if (!isDupe) deduped.push(f);
}
// Page-level analyzers only run on full pages
if (isFullPage(content)) {
const analyzerIds = [
'single-font',
'flat-type-hierarchy',
'monotonous-spacing',
'em-dash-overuse',
'marketing-buzzword',
'numbered-section-markers',
'aphoristic-cadence',
'dark-glow',
];
for (let i = 0; i < REGEX_ANALYZERS.length; i++) {
const analyzer = REGEX_ANALYZERS[i];
deduped.push(...profileFindings(profile, {
engine: 'regex',
phase: 'page-analyzer',
ruleId: analyzerIds[i] || `analyzer-${i + 1}`,
target: filePath,
}, () => analyzer(content, filePath)));
}
}
return filterByProviders(deduped, options?.providers);
}
export {
REGEX_MATCHERS,
REGEX_ANALYZERS,
TEXT_CONTENT_ANALYZER_IDS,
extractStyleBlocks,
extractCSSinJS,
runRegexMatchers,
runTextContentAnalyzers,
detectText,
};

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

View file

@ -0,0 +1,189 @@
function sanitizeScreenshotClip(clip, viewport) {
if (!clip) return null;
const x = Math.max(0, Math.floor(clip.x || 0));
const y = Math.max(0, Math.floor(clip.y || 0));
const width = Math.min(
Math.max(1, Math.ceil(clip.width || 0)),
Math.max(1, viewport?.width || 1600),
);
const height = Math.min(
Math.max(1, Math.ceil(clip.height || 0)),
320,
);
if (width < 1 || height < 1) return null;
return { x, y, width, height };
}
async function compareScreenshotContrast(page, beforeBase64, afterBase64, candidate) {
return page.evaluate(async ({ beforeBase64, afterBase64, candidate }) => {
const loadImage = (base64) => new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Could not decode contrast screenshot'));
img.src = `data:image/png;base64,${base64}`;
});
const [before, after] = await Promise.all([loadImage(beforeBase64), loadImage(afterBase64)]);
const width = Math.min(before.width, after.width);
const height = Math.min(before.height, after.height);
if (width < 1 || height < 1) return null;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(before, 0, 0, width, height);
const beforePixels = ctx.getImageData(0, 0, width, height).data;
ctx.clearRect(0, 0, width, height);
ctx.drawImage(after, 0, 0, width, height);
const afterPixels = ctx.getImageData(0, 0, width, height).data;
const luminance = ({ r, g, b }) => {
const convert = c => {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b);
};
const ratio = (a, b) => {
const l1 = luminance(a);
const l2 = luminance(b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
};
const cssTextColor = candidate.textColor && !candidate.preferRenderedForeground
? {
r: candidate.textColor.r,
g: candidate.textColor.g,
b: candidate.textColor.b,
}
: null;
const ratios = [];
let glyphPixels = 0;
let strongestDelta = 0;
for (let i = 0; i < beforePixels.length; i += 4) {
const delta = Math.abs(beforePixels[i] - afterPixels[i])
+ Math.abs(beforePixels[i + 1] - afterPixels[i + 1])
+ Math.abs(beforePixels[i + 2] - afterPixels[i + 2])
+ Math.abs(beforePixels[i + 3] - afterPixels[i + 3]);
strongestDelta = Math.max(strongestDelta, delta);
if (delta < 10) continue;
glyphPixels++;
const fg = cssTextColor || {
r: beforePixels[i],
g: beforePixels[i + 1],
b: beforePixels[i + 2],
};
const bg = {
r: afterPixels[i],
g: afterPixels[i + 1],
b: afterPixels[i + 2],
};
ratios.push(ratio(fg, bg));
}
if (ratios.length < 8) {
return {
glyphPixels,
strongestDelta,
worstRatio: null,
p10Ratio: null,
medianRatio: null,
};
}
ratios.sort((a, b) => a - b);
const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];
return {
glyphPixels,
strongestDelta,
worstRatio: ratios[0],
p10Ratio: pick(10),
medianRatio: pick(50),
};
}, { beforeBase64, afterBase64, candidate });
}
async function captureVisualContrastCandidate(page, candidate, viewport) {
const clip = sanitizeScreenshotClip(candidate.clip, viewport);
if (!clip) return null;
const beforeBase64 = await page.screenshot({
encoding: 'base64',
clip,
captureBeyondViewport: true,
});
const token = `impeccable-contrast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const applied = await page.evaluate(({ selector, token, backgroundClipText }) => {
let el;
try {
el = document.querySelector(selector);
} catch {
return false;
}
if (!el) return false;
let style = document.getElementById('impeccable-visual-contrast-hide-style');
if (!style) {
style = document.createElement('style');
style.id = 'impeccable-visual-contrast-hide-style';
style.textContent = [
'[data-impeccable-visual-contrast-target] {',
' color: transparent !important;',
' -webkit-text-fill-color: transparent !important;',
' text-shadow: none !important;',
'}',
'[data-impeccable-visual-contrast-target][data-impeccable-bgclip-text="true"] {',
' background-image: none !important;',
'}',
].join('\n');
document.head.appendChild(style);
}
el.setAttribute('data-impeccable-visual-contrast-target', token);
if (backgroundClipText) el.setAttribute('data-impeccable-bgclip-text', 'true');
return true;
}, {
selector: candidate.selector,
token,
backgroundClipText: candidate.backgroundClipText,
});
if (!applied) return null;
let afterBase64;
try {
afterBase64 = await page.screenshot({
encoding: 'base64',
clip,
captureBeyondViewport: true,
});
} finally {
await page.evaluate(({ selector }) => {
try {
const el = document.querySelector(selector);
if (el) {
el.removeAttribute('data-impeccable-visual-contrast-target');
el.removeAttribute('data-impeccable-bgclip-text');
}
} catch {
// Ignore invalid or stale selectors during cleanup.
}
}, { selector: candidate.selector }).catch(() => {});
}
const metrics = await compareScreenshotContrast(page, beforeBase64, afterBase64, candidate);
if (!metrics || !Number.isFinite(metrics.p10Ratio) || metrics.glyphPixels < 8) return null;
const measuredRatio = metrics.p10Ratio;
if (measuredRatio >= candidate.threshold) return null;
const textLabel = candidate.text ? ` "${candidate.text}"` : '';
const reasonLabel = (candidate.reasons || []).slice(0, 3).join(', ') || 'visual background';
return {
id: 'low-contrast',
snippet: `pixel contrast ${measuredRatio.toFixed(1)}:1 median ${metrics.medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) on ${reasonLabel}${textLabel}`,
};
}
export {
sanitizeScreenshotClip,
compareScreenshotContrast,
captureVisualContrastCandidate,
};