chore: update vendored skills and record provenance
humanizer 2.2.0 -> 2.9.1 (blader/humanizer, MIT): adds a Voice Calibration section and a passive-voice pattern, reworks negative parallelisms and em dashes. Version moved to metadata.version upstream. impeccable 3.6.0 -> 4.0.4 (pbakaus/impeccable, Apache-2.0): the repo tags the skill and the npm CLI separately, so 3.6.0 was a real release and npm's 3.5.0 was never the comparison. Adds native-platform reference briefs. Each now carries an UPSTREAM file; bin/check-vendored.sh reports drift.
This commit is contained in:
@@ -3,9 +3,87 @@ 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';
|
||||
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
|
||||
|
||||
// On Windows, puppeteer's bundled Chrome lives in a user-writable cache
|
||||
// directory. Its GPU process can be denied (STATUS_ACCESS_DENIED) by security
|
||||
// software or the GPU sandbox because it launches from an untrusted path.
|
||||
// Chrome then crash-loops the GPU process, and each relaunch briefly flashes a
|
||||
// compositor surface, the black window users report during `detect <url>`
|
||||
// (issue #372). The system-installed Chrome runs from a trusted location with a
|
||||
// healthy GPU, so channel:'chrome' avoids the crash entirely; both use hardware
|
||||
// GPU, so contrast measurement is unaffected. Scope this to Windows only: other
|
||||
// platforms do not have the bug, so they keep the pinned bundled build for
|
||||
// consistent measurement across machines. Fall back to bundled when the switch
|
||||
// fails (Chrome not installed, or channel resolution fails). If the bundled
|
||||
// launch then also fails, surface the original system-Chrome error as the
|
||||
// cause so the real failure is not lost.
|
||||
async function launchBrowser(puppeteer, { headless = true, args = [] } = {}) {
|
||||
let channelError;
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
return await puppeteer.default.launch({ channel: 'chrome', headless, args });
|
||||
} catch (err) {
|
||||
// System Chrome unavailable or unlaunchable; fall through to the bundled
|
||||
// browser, but keep the error in case the fallback fails too.
|
||||
channelError = err;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await puppeteer.default.launch({ headless, args });
|
||||
} catch (err) {
|
||||
if (channelError && err && err.cause === undefined) err.cause = channelError;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Reveal sweep + invisible-text measurement for the content-hidden-at-rest
|
||||
// rule. Scrolls through the document with instant jumps (bypasses CSS
|
||||
// scroll-behavior: smooth) so IntersectionObserver / scroll reveal handlers
|
||||
// get every chance to fire, returns to the top, lets transitions settle,
|
||||
// then measures how much text still renders invisible. A healthy
|
||||
// reveal-on-scroll page drops to ~0 after the sweep; a page whose reveal
|
||||
// script died keeps most of its text at opacity 0.
|
||||
async function measureContentHiddenAfterReveal(page) {
|
||||
await page.evaluate(async () => {
|
||||
const step = Math.max(200, Math.floor(window.innerHeight * 0.7));
|
||||
const max = Math.max(
|
||||
document.documentElement.scrollHeight || 0,
|
||||
document.body?.scrollHeight || 0,
|
||||
);
|
||||
for (let y = 0; y <= max; y += step) {
|
||||
window.scrollTo({ top: y, left: 0, behavior: 'instant' });
|
||||
await new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 40)));
|
||||
}
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
||||
await new Promise(resolve => setTimeout(resolve, 700));
|
||||
});
|
||||
return page.evaluate(() => {
|
||||
if (typeof window.impeccableMeasureHiddenText !== 'function') return null;
|
||||
return window.impeccableMeasureHiddenText();
|
||||
});
|
||||
}
|
||||
|
||||
function serializeDesignSystemForBrowser(designSystem) {
|
||||
if (!designSystem?.present) return null;
|
||||
return {
|
||||
present: true,
|
||||
hasFonts: designSystem.hasFonts === true,
|
||||
allowedFonts: Array.from(designSystem.allowedFonts || []),
|
||||
hasColors: designSystem.hasColors === true,
|
||||
allowedColors: Array.from(designSystem.allowedColorKeys?.values?.() || [])
|
||||
.map(entry => entry?.color)
|
||||
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
|
||||
.map(color => ({ r: color.r, g: color.g, b: color.b })),
|
||||
hasRadii: designSystem.hasRadii === true,
|
||||
allowedRadii: (designSystem.allowedRadii || [])
|
||||
.map(entry => Number(entry?.px))
|
||||
.filter(px => Number.isFinite(px)),
|
||||
hasPillRadius: designSystem.hasPillRadius === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {
|
||||
if (options?.visualContrast === false) return [];
|
||||
@@ -132,13 +210,26 @@ async function detectUrl(url, options = {}) {
|
||||
phase: 'load',
|
||||
ruleId: 'launch-browser',
|
||||
target: url,
|
||||
}, () => puppeteer.default.launch({ headless: true, args: launchArgs }));
|
||||
}, () => launchBrowser(puppeteer, { headless: options?.headless ?? true, args: launchArgs }));
|
||||
const page = await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: 'new-page',
|
||||
target: url,
|
||||
}, () => browser.newPage());
|
||||
|
||||
// Uncaught exceptions and parse errors surface as pageerror events. The
|
||||
// listener must attach before goto: a syntax error fires during the
|
||||
// initial parse, long before the load event. Dedupe by message; a single
|
||||
// broken loop can otherwise throw hundreds of identical errors.
|
||||
const pageErrors = [];
|
||||
if (options?.scriptErrors !== false) {
|
||||
page.on('pageerror', (err) => {
|
||||
const message = String(err?.message || err).split('\n')[0].trim().slice(0, 160);
|
||||
if (message && !pageErrors.includes(message)) pageErrors.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
let results = [];
|
||||
try {
|
||||
await profileStepAsync(profile, {
|
||||
@@ -163,17 +254,19 @@ async function detectUrl(url, options = {}) {
|
||||
}
|
||||
|
||||
// Inject the browser detection script and collect results
|
||||
const browserDesignSystem = serializeDesignSystemForBrowser(options?.designSystem);
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'scan',
|
||||
ruleId: 'configure-pure-detect',
|
||||
target: url,
|
||||
}, () => page.evaluate(() => {
|
||||
}, () => page.evaluate((designSystem) => {
|
||||
window.__IMPECCABLE_CONFIG__ = {
|
||||
...(window.__IMPECCABLE_CONFIG__ || {}),
|
||||
autoScan: false,
|
||||
...(designSystem ? { designSystem } : {}),
|
||||
};
|
||||
}));
|
||||
}, browserDesignSystem));
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'scan',
|
||||
@@ -192,9 +285,29 @@ async function detectUrl(url, options = {}) {
|
||||
return window.impeccableDetect({ decorate: false, serialize: true });
|
||||
});
|
||||
return serializedGroups.flatMap(({ findings }) =>
|
||||
findings.map(f => ({ id: f.type, snippet: f.detail }))
|
||||
findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '', severity: f.severity || '' }))
|
||||
);
|
||||
});
|
||||
// Content invisible at rest: reveal sweep, then re-measure. Runs after
|
||||
// the main scan (which must see the true at-rest state) and before the
|
||||
// visual contrast fallback (the sweep restores scroll to the top).
|
||||
if (options?.contentHidden !== false) {
|
||||
const hiddenFindings = await profileFindingsAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'scan',
|
||||
ruleId: 'content-hidden-at-rest',
|
||||
target: url,
|
||||
}, async () => {
|
||||
const measured = await measureContentHiddenAfterReveal(page);
|
||||
return measured ? checkContentHiddenAtRest(measured) : [];
|
||||
});
|
||||
results.push(...hiddenFindings);
|
||||
}
|
||||
|
||||
for (const message of pageErrors.slice(0, 3)) {
|
||||
results.push({ id: 'script-error', snippet: message });
|
||||
}
|
||||
|
||||
const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
|
||||
results.push(...visualFindings);
|
||||
} finally {
|
||||
@@ -213,7 +326,14 @@ async function detectUrl(url, options = {}) {
|
||||
}, () => browser.close());
|
||||
}
|
||||
}
|
||||
return filterByProviders(results.map(f => finding(f.id, url, f.snippet)), options.providers);
|
||||
return results.map(f => {
|
||||
const item = finding(f.id, url, f.snippet);
|
||||
if (f.ignoreValue) item.ignoreValue = f.ignoreValue;
|
||||
// Per-finding severity promotion (e.g. hero-region pulsing dot)
|
||||
// overrides the registry default carried by finding().
|
||||
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
async function createBrowserDetector(options = {}) {
|
||||
@@ -224,7 +344,7 @@ async function createBrowserDetector(options = {}) {
|
||||
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({
|
||||
const browser = options.browser || await launchBrowser(puppeteer, {
|
||||
headless: options.headless ?? true,
|
||||
args: launchArgs,
|
||||
});
|
||||
@@ -249,4 +369,4 @@ async function createBrowserDetector(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export { runVisualContrastFallback, detectUrl, createBrowserDetector };
|
||||
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { GENERIC_FONTS } from '../../shared/constants.mjs';
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS, EM_DASH_FLOOR, EM_DASH_CHARS_PER_DASH } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
import { scanCssTextForGlow, scanCssTextForGridBackground, scanCssTextForMarquee, scanCssTextForPseudoStripe, scanCssTextForRadialHalo } from '../../rules/checks.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.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 hasRounded = (line) =>
|
||||
/\brounded(?:-\w+)?\b/.test(line.replace(/\brounded-none\b/g, ''));
|
||||
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) {
|
||||
@@ -35,31 +41,107 @@ function shouldRunPageAnalyzers(content, filePath) {
|
||||
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
||||
}
|
||||
|
||||
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;
|
||||
function firstOverusedGoogleFont(text) {
|
||||
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
||||
}
|
||||
|
||||
// CSS named colors whose channels are equal (achromatic). Anything outside
|
||||
// this set falls through to the format parsers, and an unrecognized spelling
|
||||
// stays non-neutral so a real accent is never skipped.
|
||||
const NEUTRAL_COLOR_KEYWORDS = new Set([
|
||||
'transparent', 'currentcolor',
|
||||
'black', 'white', 'gray', 'grey', 'silver',
|
||||
'dimgray', 'dimgrey', 'darkgray', 'darkgrey', 'lightgray', 'lightgrey',
|
||||
'gainsboro', 'whitesmoke',
|
||||
]);
|
||||
|
||||
function hexChannels(color) {
|
||||
const long = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})(?:[0-9a-f]{2})?$/i);
|
||||
if (long) return [parseInt(long[1], 16), parseInt(long[2], 16), parseInt(long[3], 16)];
|
||||
const short = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])(?:[0-9a-f])?$/i);
|
||||
if (short) return [1, 2, 3].map((i) => parseInt(short[i] + short[i], 16));
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split one box-shadow layer into top-level tokens.
|
||||
*
|
||||
* Whitespace inside parens does not separate tokens: `rgb(0 0 0)` and
|
||||
* `var(--x, 4px)` are each a single value, and splitting them on spaces would
|
||||
* read their innards as separate lengths.
|
||||
*/
|
||||
function tokenizeShadowLayer(layer) {
|
||||
const tokens = [];
|
||||
let depth = 0;
|
||||
let current = '';
|
||||
for (const char of String(layer || '')) {
|
||||
if (char === '(') depth++;
|
||||
else if (char === ')') depth--;
|
||||
else if (depth === 0 && /\s/.test(char)) {
|
||||
if (current) tokens.push(current);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
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;
|
||||
if (current) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function lastMatch(text, re) {
|
||||
const all = [...String(text || '').matchAll(re)];
|
||||
return all.length ? all[all.length - 1] : null;
|
||||
}
|
||||
|
||||
function isShadowLength(token) {
|
||||
return /^-?\d*\.?\d+(?:px)?$/i.test(String(token || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutrality test for colors as written in source CSS.
|
||||
*
|
||||
* shared/color.mjs's isNeutralColor only parses the computed function forms a
|
||||
* browser or jsdom emits (rgb/oklch/lab/...) and deliberately reports every
|
||||
* other spelling as chromatic so an unknown format is never silently skipped.
|
||||
* That default is wrong for authored CSS, where `#000` and `black` are the
|
||||
* normal spellings: calling it directly reports a plain black hairline as a
|
||||
* colored stripe. Handle hex and named neutrals here, then defer.
|
||||
*/
|
||||
function isNeutralAuthoredColor(rawColor) {
|
||||
const c = String(rawColor || '').trim().toLowerCase();
|
||||
if (!c) return false;
|
||||
if (NEUTRAL_COLOR_KEYWORDS.has(c)) return true;
|
||||
// Modern rgb() takes space-separated channels (`rgb(0 0 0)`). shared/color.mjs
|
||||
// parses only the comma form a browser's getComputedStyle emits, so authored
|
||||
// space-separated neutrals fell through it and reported as chromatic — the
|
||||
// exemption this function exists for, missed. Normalize before delegating.
|
||||
if (/^rgba?\(/i.test(c)) {
|
||||
const channels = c.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i);
|
||||
if (channels) {
|
||||
const values = [1, 2, 3].map((i) => Number(channels[i]));
|
||||
return (Math.max(...values) - Math.min(...values)) < 30;
|
||||
}
|
||||
return isNeutralColor(c);
|
||||
}
|
||||
if (/^(?:hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
|
||||
const channels = hexChannels(c);
|
||||
if (channels) return (Math.max(...channels) - Math.min(...channels)) < 30;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isNeutralBorderColor(str) {
|
||||
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
|
||||
if (!m) return false;
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
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; },
|
||||
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : 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; },
|
||||
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : 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,
|
||||
@@ -84,9 +166,12 @@ const REGEX_MATCHERS = [
|
||||
{ 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, ' ')}` },
|
||||
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi,
|
||||
test: (m) => {
|
||||
m.overusedGoogleFont = firstOverusedGoogleFont(m[0]);
|
||||
return Boolean(m.overusedGoogleFont);
|
||||
},
|
||||
fmt: (m) => `Google Fonts: ${m.overusedGoogleFont || firstOverusedGoogleFont(m[0])}` },
|
||||
// --- 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),
|
||||
@@ -156,27 +241,6 @@ const REGEX_MATCHERS = [
|
||||
];
|
||||
|
||||
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();
|
||||
@@ -226,15 +290,34 @@ const REGEX_ANALYZERS = [
|
||||
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).
|
||||
// Em-dash overuse (ADVISORY): the AI cadence tell is em-dash *saturation*,
|
||||
// not the occasional dash. Humans use em-dashes legitimately, so this rule is
|
||||
// advisory (surfaced separately, never a failure, hook-skipped by default) and
|
||||
// its threshold is deliberately conservative. Two gates must both hold:
|
||||
// 1. Absolute floor of EM_DASH_FLOOR (8) dashes — a page with a handful
|
||||
// never fires, no matter how short.
|
||||
// 2. Density: at least one dash per EM_DASH_CHARS_PER_DASH (500) characters
|
||||
// of body text, so a long article that uses eight across several thousand
|
||||
// words is left alone while a short, dash-per-clause landing page is not.
|
||||
// Raised from the old flat 5-dash floor, which fired on ordinary long prose.
|
||||
//
|
||||
// stripHtmlToText drops tags but leaves character-entity escapes intact, so
|
||||
// a model that writes `—`, `—`, or `—` renders an em-dash
|
||||
// the counter never saw. Decode the em-dash entities (named, zero-padded
|
||||
// decimal, upper/lower hex) to the literal glyph first. En-dash entities are
|
||||
// deliberately left alone: the rule counts em-dashes, and the literal `–`
|
||||
// was never counted either.
|
||||
(content, filePath) => {
|
||||
const text = stripHtmlToText(content);
|
||||
const text = stripHtmlToText(content)
|
||||
.replace(/—|�*8212;|�*2014;/gi, '—');
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(text) !== null) count++;
|
||||
if (count < 5) return [];
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
// Saturation gate: dashes must be dense in the prose, not sprinkled through
|
||||
// a long document. textLength <= count * chars-per-dash means the density is
|
||||
// at or above the threshold.
|
||||
if (text.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
|
||||
},
|
||||
// Marketing buzzwords: SaaS phrase list
|
||||
@@ -270,22 +353,6 @@ const REGEX_ANALYZERS = [
|
||||
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);
|
||||
@@ -307,41 +374,143 @@ const REGEX_ANALYZERS = [
|
||||
if (count < 3) return [];
|
||||
return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)];
|
||||
},
|
||||
// Dark glow (page-level: dark bg + colored box-shadow with blur)
|
||||
// Dark glow / chromatic halo shadows (page-level). Shared scanner handles
|
||||
// any color format, single-level var() resolution, zero-offset halos on
|
||||
// any background, and text-shadow glows.
|
||||
(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 [];
|
||||
const hits = scanCssTextForGlow(content);
|
||||
if (hits.length === 0) return [];
|
||||
const lines = content.substring(0, hits[0].index).split('\n');
|
||||
return [finding('dark-glow', filePath, hits[0].snippet, lines.length)];
|
||||
},
|
||||
// Radial-gradient background halo on a dark page (the gradient sibling
|
||||
// of the dark-glow shadow tell).
|
||||
(content, filePath) => {
|
||||
const hits = scanCssTextForRadialHalo(content);
|
||||
if (hits.length === 0) return [];
|
||||
const lines = content.substring(0, hits[0].index).split('\n');
|
||||
return [finding('radial-halo', filePath, hits[0].snippet, lines.length)];
|
||||
},
|
||||
// Auto-scrolling marquees (<marquee> or infinite horizontal loop
|
||||
// animations).
|
||||
(content, filePath) => scanCssTextForMarquee(content).map(hit => finding('marquee', filePath, hit.snippet)),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style block extraction (Vue/Svelte <style> blocks)
|
||||
// Structural CSS checks used by source files whose styles are not parsed by
|
||||
// the static HTML engine.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CHROMATIC_SHADOW_TOKEN_RE = /(?:^|-)(?:accent|kinpaku|patina|gold|red|orange|amber|yellow|lime|green|emerald|teal|cyan|blue|indigo|violet|purple|magenta|pink|rose|coral|aqua|mint|burgundy|crimson|scarlet)(?:-|$)/i;
|
||||
|
||||
function insetStripeColorIsChromatic(rawColor) {
|
||||
const color = String(rawColor || '').trim().replace(/\s*!important\s*$/i, '');
|
||||
if (/^(?:currentcolor|transparent|inherit|unset)$/i.test(color)) return false;
|
||||
const variable = color.match(/^var\(\s*(--[\w-]+)/i);
|
||||
if (variable) return CHROMATIC_SHADOW_TOKEN_RE.test(variable[1]);
|
||||
if (!/^(?:#|rgba?\(|hsla?\(|hwb\(|oklch\(|oklab\(|lch\(|lab\(|color\(|[a-z]+$)/i.test(color)) return false;
|
||||
return !isNeutralAuthoredColor(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank out comment bodies while preserving every byte offset (and therefore
|
||||
* every line number) so commented-out CSS is not scanned as live rules.
|
||||
*/
|
||||
function blankCssComments(css) {
|
||||
return css.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' '));
|
||||
}
|
||||
|
||||
function scanInsetStripeCss(rawContent, filePath, lineOffset = 0) {
|
||||
const content = blankCssComments(rawContent);
|
||||
const findings = [];
|
||||
const ruleRe = /([^{};]+)\{([^{}]*)\}/g;
|
||||
let match;
|
||||
// Deriving each line with content.slice(0, offset).split('\n') re-scans the
|
||||
// whole prefix per rule, which is O(n^2) on a large stylesheet. Rule matches
|
||||
// arrive in source order, so carry a monotonic cursor instead: one pass total.
|
||||
let scanOffset = 0;
|
||||
let scanLine = 1;
|
||||
const lineAtOffset = (offset) => {
|
||||
while (scanOffset < offset) {
|
||||
if (content[scanOffset] === '\n') scanLine++;
|
||||
scanOffset++;
|
||||
}
|
||||
return scanLine;
|
||||
};
|
||||
while ((match = ruleRe.exec(content)) !== null) {
|
||||
// The selector group is `[^{};]+`, which greedily absorbs the whitespace and
|
||||
// newlines trailing the previous rule. Advance past that run before deriving
|
||||
// the line, or every rule after the first reports the preceding line.
|
||||
const selectorStart = match.index + (match[1].length - match[1].trimStart().length);
|
||||
const selector = match[1].trim().replace(/\s+/g, ' ');
|
||||
if (!selector) continue;
|
||||
if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue;
|
||||
if (/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)) continue;
|
||||
if (/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)) continue;
|
||||
if (/(?:^|[\s._[-])(?:active|current|selected)(?![\w])/i.test(selector)) continue;
|
||||
if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue;
|
||||
|
||||
// Read the last of a repeated declaration, not the first: that is what the
|
||||
// cascade paints. Taking the first both flagged stripes that a later
|
||||
// `box-shadow: none` had cancelled and missed stripes that overrode an
|
||||
// earlier value, and mis-skipped rules whose narrow width was overridden.
|
||||
const width = lastMatch(match[2], /(?:^|;)\s*(?:width|inline-size)\s*:\s*(\d+(?:\.\d+)?)px/gi);
|
||||
if (width && Number(width[1]) <= 40) continue;
|
||||
const declaration = lastMatch(match[2], /(?:^|;)\s*box-shadow\s*:\s*([^;]+)/gi);
|
||||
if (!declaration || !/\binset\b/i.test(declaration[1])) continue;
|
||||
// `!important` qualifies the declaration, not the shadow value, so strip it
|
||||
// before the layers are read. Tokenizing split it into its own token, which
|
||||
// made the color count wrong and silently stopped flagging stripes declared
|
||||
// with it — a shape the previous regex handled.
|
||||
const shadowValue = declaration[1].replace(/\s*!\s*important\s*$/i, '').trim();
|
||||
|
||||
for (const rawLayer of shadowValue.split(/,(?![^(]*\))/)) {
|
||||
const layer = rawLayer.trim();
|
||||
// Parse the layer by its grammar rather than by one spelling of it.
|
||||
// A box-shadow layer is `inset? && <length>{2,4} && <color>?` in any
|
||||
// order, so `inset 4px 0 red`, `4px 0 0 red inset`, and `red 4px 0 inset`
|
||||
// all paint the same stripe. Matching a fixed token order missed three
|
||||
// valid spellings in a row; enumerate the tokens instead. Tokenizing must
|
||||
// respect parens: `rgb(0 0 0)` is one color token, and splitting it on
|
||||
// whitespace would read its channels as lengths.
|
||||
const tokens = tokenizeShadowLayer(layer);
|
||||
if (!tokens.some((token) => /^inset$/i.test(token))) continue;
|
||||
const rest = tokens.filter((token) => !/^inset$/i.test(token));
|
||||
const lengths = rest.filter(isShadowLength);
|
||||
const colors = rest.filter((token) => !isShadowLength(token));
|
||||
// Only the two offsets are required; omitted blur/spread default to 0,
|
||||
// which is exactly the stripe shape. More than one non-length token is a
|
||||
// layer shape we do not claim to understand, so leave it alone.
|
||||
if (lengths.length < 2 || lengths.length > 4 || colors.length !== 1) continue;
|
||||
const values = lengths.map((token) => ({
|
||||
n: Number(token.replace(/px$/i, '')),
|
||||
hasPx: /px$/i.test(token),
|
||||
}));
|
||||
const x = values[0];
|
||||
const y = values[1];
|
||||
const blur = values[2] ? values[2].n : 0;
|
||||
const spread = values[3] ? values[3].n : 0;
|
||||
if ((x.n !== 0 && !x.hasPx) || (y.n !== 0 && !y.hasPx) || blur !== 0 || spread !== 0) continue;
|
||||
const ax = Math.abs(x.n);
|
||||
const ay = Math.abs(y.n);
|
||||
if (!((ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0))) continue;
|
||||
if (!insetStripeColorIsChromatic(colors[0])) continue;
|
||||
const edge = ay === 0 ? (x.n > 0 ? 'left' : 'right') : (y.n > 0 ? 'top' : 'bottom');
|
||||
const line = lineOffset + lineAtOffset(selectorStart);
|
||||
findings.push(finding('side-tab', filePath, `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`, line));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style block extraction (Astro/Vue/Svelte <style> blocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractStyleBlocks(content, ext) {
|
||||
ext = ext.toLowerCase();
|
||||
if (ext !== '.vue' && ext !== '.svelte') return [];
|
||||
if (ext !== '.astro' && ext !== '.vue' && ext !== '.svelte') return [];
|
||||
const blocks = [];
|
||||
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
||||
let m;
|
||||
@@ -426,24 +595,24 @@ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null,
|
||||
}
|
||||
|
||||
/** Page-level analyzers that scan rendered text content (em-dash use,
|
||||
* buzzword phrases, numbered section markers, aphoristic cadence).
|
||||
* buzzword phrases, 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 (!shouldRunPageAnalyzers(content, filePath)) return [];
|
||||
// The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS.
|
||||
// The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS
|
||||
// (single-font's removal on 2026-07-29 shifted every index down one).
|
||||
const findings = [];
|
||||
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
|
||||
const analyzer = REGEX_ANALYZERS[3 + i];
|
||||
const analyzer = REGEX_ANALYZERS[2 + i];
|
||||
const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];
|
||||
findings.push(...profileFindings(profile, {
|
||||
engine: 'regex',
|
||||
@@ -468,8 +637,36 @@ function detectText(content, filePath, options = {}) {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
// Pseudo-element stripes (::before/::after absolute bars) carry the same
|
||||
// side-tab silhouette without any border token, so the line matchers can't
|
||||
// see them (issue #394). The shared scanner already runs on full HTML pages
|
||||
// via checkHtmlPatterns; give standalone stylesheets, component style
|
||||
// blocks, and CSS-in-JS templates the same coverage. Each hit carries the
|
||||
// rule's source offset, so the finding gets a real line and line-scoped
|
||||
// inline ignores keep working.
|
||||
const pseudoStripeFindings = (text, lineOffset) =>
|
||||
scanCssTextForPseudoStripe(text).map(hit =>
|
||||
finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length));
|
||||
|
||||
// Extract and scan <style> blocks from Vue/Svelte SFCs
|
||||
if (cssLike.has(ext)) {
|
||||
findings.push(...scanInsetStripeCss(content, filePath));
|
||||
findings.push(...pseudoStripeFindings(content, 0));
|
||||
}
|
||||
|
||||
// Block-level CSS checks that need multiple declarations must run over the
|
||||
// complete source, not line-by-line. This covers standalone stylesheets,
|
||||
// component style blocks, inline styles, and CSS-in-JS templates.
|
||||
findings.push(...profileFindings(profile, {
|
||||
engine: 'regex',
|
||||
phase: 'source',
|
||||
ruleId: 'codex-grid-background',
|
||||
target: filePath,
|
||||
}, () => scanCssTextForGridBackground(content).map(hit => {
|
||||
const line = content.substring(0, hit.index).split('\n').length;
|
||||
return finding('codex-grid-background', filePath, hit.snippet, line);
|
||||
})));
|
||||
|
||||
// Extract and scan <style> blocks from Astro/Vue/Svelte components.
|
||||
const styleBlocks = profile
|
||||
? profileStep(profile, {
|
||||
engine: 'regex',
|
||||
@@ -484,6 +681,14 @@ function detectText(content, filePath, options = {}) {
|
||||
profile,
|
||||
phase: 'style-block',
|
||||
}));
|
||||
// block.startLine is the first line *after* the <style> tag, but block.content
|
||||
// begins at the character right after that tag — so its own line 1 sits on the
|
||||
// tag's line, whether or not a newline follows immediately. lineAtOffset is
|
||||
// 1-based, so the offset is startLine - 2; startLine - 1 double-counted and
|
||||
// reported every selector one line low. runRegexMatchers keeps startLine - 1
|
||||
// because it indexes its split lines from zero.
|
||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
@@ -501,6 +706,17 @@ function detectText(content, filePath, options = {}) {
|
||||
profile,
|
||||
phase: 'css-in-js',
|
||||
}));
|
||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
|
||||
}
|
||||
|
||||
if (options?.designSystem) {
|
||||
findings.push(...profileFindings(profile, {
|
||||
engine: 'regex',
|
||||
phase: 'source',
|
||||
ruleId: 'design-system',
|
||||
target: filePath,
|
||||
}, () => checkSourceDesignSystem(content, filePath, { designSystem: options.designSystem })));
|
||||
}
|
||||
|
||||
// Deduplicate findings (same antipattern + similar snippet, within 2 lines)
|
||||
@@ -517,12 +733,10 @@ function detectText(content, filePath, options = {}) {
|
||||
// Page-level analyzers only run on full pages
|
||||
if (shouldRunPageAnalyzers(content, filePath)) {
|
||||
const analyzerIds = [
|
||||
'single-font',
|
||||
'flat-type-hierarchy',
|
||||
'monotonous-spacing',
|
||||
'em-dash-overuse',
|
||||
'marketing-buzzword',
|
||||
'numbered-section-markers',
|
||||
'aphoristic-cadence',
|
||||
'dark-glow',
|
||||
];
|
||||
@@ -537,7 +751,9 @@ function detectText(content, filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(deduped, options?.providers);
|
||||
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
|
||||
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
|
||||
return options?.inlineIgnores === false ? deduped : applyInlineIgnores(deduped, content);
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import { CSS_NAMED_COLORS, collectCssCustomProps, cssLengthToPx, parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// jsdom CSS-variable border override map
|
||||
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATIC_INHERITED_PROPS = new Set([
|
||||
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
|
||||
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
|
||||
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
|
||||
'webkitHyphens',
|
||||
]);
|
||||
@@ -245,9 +245,14 @@ const STATIC_DEFAULT_STYLE = {
|
||||
outlineColor: 'rgb(0, 0, 0)',
|
||||
outlineStyle: 'none',
|
||||
boxShadow: 'none',
|
||||
// NOT in STATIC_INHERITED_PROPS even though text-shadow inherits in real
|
||||
// CSS: the glow check only needs to fire once, on the element that
|
||||
// declares the shadow, not on every descendant.
|
||||
textShadow: 'none',
|
||||
fontFamily: '',
|
||||
fontSize: '16px',
|
||||
fontStyle: 'normal',
|
||||
fontVariant: 'normal',
|
||||
fontWeight: '400',
|
||||
lineHeight: 'normal',
|
||||
letterSpacing: 'normal',
|
||||
@@ -272,6 +277,7 @@ const STATIC_DEFAULT_STYLE = {
|
||||
marginBottom: '0px',
|
||||
marginLeft: '0px',
|
||||
position: 'static',
|
||||
visibility: 'visible',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
@@ -301,6 +307,7 @@ const STATIC_PROP_MAP = {
|
||||
'outline-color': 'outlineColor',
|
||||
'outline-style': 'outlineStyle',
|
||||
'box-shadow': 'boxShadow',
|
||||
'text-shadow': 'textShadow',
|
||||
'font-family': 'fontFamily',
|
||||
'font-size': 'fontSize',
|
||||
'font-style': 'fontStyle',
|
||||
@@ -326,6 +333,7 @@ const STATIC_PROP_MAP = {
|
||||
'margin-bottom': 'marginBottom',
|
||||
'margin-left': 'marginLeft',
|
||||
'position': 'position',
|
||||
'visibility': 'visibility',
|
||||
'top': 'top',
|
||||
'right': 'right',
|
||||
'bottom': 'bottom',
|
||||
@@ -337,18 +345,29 @@ const STATIC_PROP_MAP = {
|
||||
'overflow-y': 'overflowY',
|
||||
};
|
||||
|
||||
// parseStaticColor tries parseAnyColor first, which already resolves every
|
||||
// name in the shared CSS_NAMED_COLORS table. This fallback only carries the
|
||||
// keywords parseAnyColor deliberately returns null for: the cascade needs
|
||||
// `transparent` to read as an actual zero-alpha color.
|
||||
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 },
|
||||
};
|
||||
|
||||
// Named-color alternation for plucking a color token out of shorthand values
|
||||
// (issue #359: a hardcoded 9-name list here silently dropped `purple`,
|
||||
// `crimson`, `teal`, ... from border shorthands, so the side defaulted to
|
||||
// neutral black and side-tab never fired on .html files). Derived from the
|
||||
// same table parseAnyColor resolves against, so extraction and parsing can't
|
||||
// drift apart. Longest-first so names containing other names as substrings
|
||||
// (rebeccapurple) are matched whole.
|
||||
const NAMED_COLOR_TOKENS = [...Object.keys(CSS_NAMED_COLORS), ...Object.keys(STATIC_NAMED_COLORS)]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.join('|');
|
||||
const STATIC_COLOR_TOKEN_RE = new RegExp(
|
||||
`(?:rgba?\\([^)]+\\)|oklch\\([^)]+\\)|oklab\\([^)]+\\)|lch\\([^)]+\\)|lab\\([^)]+\\)|hsla?\\([^)]+\\)|hwb\\([^)]+\\)|#[0-9a-f]{3,8}\\b|\\b(?:${NAMED_COLOR_TOKENS})\\b)`,
|
||||
'i'
|
||||
);
|
||||
|
||||
function splitCssList(value) {
|
||||
const parts = [];
|
||||
let depth = 0, quote = '', start = 0;
|
||||
@@ -418,7 +437,23 @@ 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);
|
||||
// color-mix(...) needs balanced-paren capture (its arguments regularly
|
||||
// contain nested var()/oklch() calls AND the keyword `transparent`, which
|
||||
// the flat regex below would otherwise pluck out of the middle of the
|
||||
// expression and report as the whole color).
|
||||
const mixStart = raw.search(/color-mix\(/i);
|
||||
if (mixStart !== -1) {
|
||||
let depth = 0;
|
||||
for (let i = raw.indexOf('(', mixStart); i < raw.length; i++) {
|
||||
if (raw[i] === '(') depth++;
|
||||
else if (raw[i] === ')') {
|
||||
depth--;
|
||||
if (depth === 0) return raw.slice(mixStart, i + 1);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const colorLike = raw.match(STATIC_COLOR_TOKEN_RE);
|
||||
if (!colorLike) return '';
|
||||
return colorLike[0];
|
||||
}
|
||||
@@ -530,6 +565,15 @@ function expandStaticDeclaration(prop, value) {
|
||||
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]);
|
||||
// The `background` shorthand resets every longhand it does not set.
|
||||
// Without this, `pre code { background: none }` leaves an earlier
|
||||
// `background: var(--surface)` color standing and the contrast checks
|
||||
// measure text against a surface the browser never paints. var() values
|
||||
// stay untouched: they may resolve to a color later in the pipeline.
|
||||
if (!color && !hasImage && !/var\(/i.test(v)) {
|
||||
out.push(['backgroundColor', 'rgba(0, 0, 0, 0)']);
|
||||
out.push(['backgroundImage', 'none']);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (p === 'border') {
|
||||
@@ -700,7 +744,20 @@ function collectStaticCssRules(cssText, csstree) {
|
||||
});
|
||||
});
|
||||
for (const selector of splitCssList(selectorText)) {
|
||||
if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });
|
||||
if (!selector) continue;
|
||||
// :hover rules can't be matched statically as-is (no interaction
|
||||
// state), but they carry real cascade weight while hovered. Tag
|
||||
// them and record a state-stripped selector so the hover pass can
|
||||
// find their targets; specificity stays computed from the ORIGINAL
|
||||
// selector (per CSS, :hover counts as a class).
|
||||
const isHover = /:hover\b/i.test(selector);
|
||||
let matchSelector = null;
|
||||
if (isHover) {
|
||||
matchSelector = selector.replace(/:hover\b/gi, '').trim();
|
||||
if (!matchSelector || /[>+~]\s*$/.test(matchSelector)) matchSelector = null;
|
||||
else matchSelector = matchSelector.replace(/(^|[\s>+~])(?=$|[\s>+~])/g, '$1*');
|
||||
}
|
||||
rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++, isHover, matchSelector });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -803,6 +860,13 @@ class StaticDocument {
|
||||
this.domutils = modules.domutils;
|
||||
this._wrappers = new WeakMap();
|
||||
this._styleMap = new WeakMap();
|
||||
this._hoverStyleMap = new WeakMap();
|
||||
this._accentDashPseudo = new WeakSet();
|
||||
// Elements whose ::before/::after paints a full-cover opaque surface
|
||||
// (position absolute/fixed + inset 0 + solid background). The pseudo is
|
||||
// the element's visible background for contrast purposes even though it
|
||||
// never joins the element cascade.
|
||||
this._pseudoSurface = new WeakMap();
|
||||
}
|
||||
wrap(node) {
|
||||
let wrapped = this._wrappers.get(node);
|
||||
@@ -839,6 +903,24 @@ class StaticDocument {
|
||||
getStyle(el) {
|
||||
return this._styleMap.get(el.node) || makeStaticStyle();
|
||||
}
|
||||
setHoverStyle(node, style) {
|
||||
this._hoverStyleMap.set(node, style);
|
||||
}
|
||||
getHoverStyle(el) {
|
||||
return this._hoverStyleMap.get(el.node) || null;
|
||||
}
|
||||
setAccentDashPseudo(node) {
|
||||
this._accentDashPseudo.add(node);
|
||||
}
|
||||
hasAccentDashPseudo(el) {
|
||||
return this._accentDashPseudo.has(el.node);
|
||||
}
|
||||
setPseudoSurface(node, color) {
|
||||
this._pseudoSurface.set(node, color);
|
||||
}
|
||||
getPseudoSurface(el) {
|
||||
return this._pseudoSurface.get(el.node) || null;
|
||||
}
|
||||
}
|
||||
|
||||
function makeStaticStyle(values = {}) {
|
||||
@@ -854,6 +936,9 @@ function buildStaticWindow(staticDoc) {
|
||||
return {
|
||||
document: staticDoc,
|
||||
getComputedStyle: (el) => staticDoc.getStyle(el),
|
||||
getHoverStyle: (el) => staticDoc.getHoverStyle(el),
|
||||
hasAccentDashPseudo: (el) => staticDoc.hasAccentDashPseudo(el),
|
||||
getPseudoSurface: (el) => staticDoc.getPseudoSurface(el),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -867,7 +952,10 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
|
||||
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);
|
||||
// Cache-busting hrefs (styles.css?v=3) resolve to the file, not to a
|
||||
// literal path with the query in it; a versioned link otherwise made the
|
||||
// whole stylesheet invisible to every element-level check.
|
||||
const cssPath = path.resolve(fileDir, href.split(/[?#]/)[0]);
|
||||
try {
|
||||
const css = profileStep(profile, {
|
||||
engine: 'static-html',
|
||||
@@ -884,6 +972,13 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
|
||||
|
||||
function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
|
||||
const specified = new Map();
|
||||
// Declarations from :hover rules, matched via their state-stripped
|
||||
// selectors. Merged per-property against the resting cascade in
|
||||
// computeNode — a hover declaration only takes effect if it would win
|
||||
// the cascade while the element is hovered (all resting rules still
|
||||
// apply in that state).
|
||||
const hoverSpecified = new Map();
|
||||
const rootCustomProps = collectCssCustomProps(cssText);
|
||||
const allNodes = modules.selectAll('*', root.children || []);
|
||||
const rules = profileStep(profile, {
|
||||
engine: 'static-html',
|
||||
@@ -899,9 +994,65 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
|
||||
target: filePath,
|
||||
}, () => {
|
||||
for (const rule of rules) {
|
||||
// ::before/::after rules can't join the element cascade (pseudo
|
||||
// elements aren't DOM nodes), but one shape matters to the eyebrow
|
||||
// check: the short chromatic "kicker dash" (content box 8-80px wide,
|
||||
// 1-6px tall, accent-colored fill). Mark the base-selector matches
|
||||
// so checkElementHeroEyebrow can see the dash.
|
||||
if (!rule.isHover) {
|
||||
const pm = rule.selector.match(/^(.+?)\s*::?(?:before|after)$/i);
|
||||
if (pm) {
|
||||
const decls = new Map();
|
||||
for (const d of rule.declarations) decls.set(d.prop.toLowerCase(), d.value);
|
||||
const w = cssLengthToPx(resolveVarRefs(decls.get('width') || decls.get('inline-size') || '', rootCustomProps));
|
||||
const h = cssLengthToPx(resolveVarRefs(decls.get('height') || decls.get('block-size') || '', rootCustomProps));
|
||||
if (w != null && h != null && w >= 8 && w <= 80 && h >= 1 && h <= 6) {
|
||||
const bgRaw = String(resolveVarRefs(decls.get('background-color') || decls.get('background') || '', rootCustomProps));
|
||||
const token = bgRaw.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b/i);
|
||||
const c = parseAnyColor(token ? token[0] : bgRaw);
|
||||
if (c && (c.a ?? 1) >= 0.1 && Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b) >= 30) {
|
||||
try {
|
||||
for (const node of modules.selectAll(pm[1], root.children || [])) {
|
||||
staticDoc.setAccentDashPseudo(node);
|
||||
}
|
||||
} catch { /* unsupported base selector */ }
|
||||
}
|
||||
}
|
||||
// Full-cover surface pseudo: the CTA construction where the
|
||||
// element itself stays transparent and a ::before/::after with
|
||||
// position absolute/fixed + inset 0 (or all four sides 0, or
|
||||
// 100% width and height) plus an opaque background paints the
|
||||
// visible surface. Mark base-selector matches so the contrast
|
||||
// checks measure text against the surface the browser renders.
|
||||
const pseudoPos = String(decls.get('position') || '').toLowerCase();
|
||||
if (pseudoPos === 'absolute' || pseudoPos === 'fixed') {
|
||||
const zeroLen = v => v != null && /^0(?:px)?$/.test(String(v).trim());
|
||||
const insetRaw = String(decls.get('inset') || '').trim();
|
||||
const coversBox = (insetRaw !== '' && insetRaw.split(/\s+/).every(t => /^0(?:px)?$/.test(t)))
|
||||
|| ['top', 'right', 'bottom', 'left'].every(side => zeroLen(decls.get(side)))
|
||||
|| (String(decls.get('width') || '').trim() === '100%'
|
||||
&& String(decls.get('height') || '').trim() === '100%');
|
||||
if (coversBox && decls.has('content')) {
|
||||
const surfRaw = String(resolveVarRefs(decls.get('background-color') || decls.get('background') || '', rootCustomProps));
|
||||
const surfToken = surfRaw.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b/i);
|
||||
const surf = parseAnyColor(surfToken ? surfToken[0] : surfRaw);
|
||||
if (surf && (surf.a ?? 1) >= 0.9 && !/gradient/i.test(surfRaw)) {
|
||||
try {
|
||||
for (const node of modules.selectAll(pm[1], root.children || [])) {
|
||||
staticDoc.setPseudoSurface(node, surf);
|
||||
}
|
||||
} catch { /* unsupported base selector */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const matchSelector = rule.isHover ? rule.matchSelector : rule.selector;
|
||||
if (!matchSelector) continue;
|
||||
let matched;
|
||||
try {
|
||||
matched = modules.selectAll(rule.selector, root.children || []);
|
||||
matched = modules.selectAll(matchSelector, root.children || []);
|
||||
} catch {
|
||||
recordProfileEvent(profile, {
|
||||
engine: 'static-html',
|
||||
@@ -910,13 +1061,13 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
|
||||
target: filePath,
|
||||
ms: 0,
|
||||
findings: 0,
|
||||
detail: rule.selector,
|
||||
detail: matchSelector,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const node of matched) {
|
||||
for (const decl of rule.declarations) {
|
||||
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
|
||||
applyStaticDeclaration(rule.isHover ? hoverSpecified : specified, node, decl.prop, decl.value, {
|
||||
important: decl.important,
|
||||
specificity: rule.specificity,
|
||||
order: rule.order,
|
||||
@@ -959,6 +1110,28 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
|
||||
}
|
||||
const style = makeStaticStyle(values);
|
||||
staticDoc.setStyle(node, style);
|
||||
|
||||
// Hover pass: limited to the two properties the hover-contrast check
|
||||
// consumes. A hover declaration wins only if it beats the resting
|
||||
// winner for that property under normal cascade rules (specificity /
|
||||
// order / importance) — exactly what a browser computes while the
|
||||
// element is hovered.
|
||||
const hoverMap = hoverSpecified.get(node);
|
||||
if (hoverMap) {
|
||||
let hoverValues = null;
|
||||
for (const prop of ['color', 'backgroundColor']) {
|
||||
const hoverDecl = hoverMap.get(prop);
|
||||
if (!hoverDecl) continue;
|
||||
const restingDecl = specifiedMap.get(prop);
|
||||
if (!compareStaticPriority(restingDecl, hoverDecl)) continue;
|
||||
const next = normalizeStaticCssValue(prop, hoverDecl.value, customProps, parentStyle, values);
|
||||
if (next === values[prop]) continue;
|
||||
if (!hoverValues) hoverValues = { ...values };
|
||||
hoverValues[prop] = next;
|
||||
}
|
||||
if (hoverValues) staticDoc.setHoverStyle(node, makeStaticStyle(hoverValues));
|
||||
}
|
||||
|
||||
for (const child of node.children || []) {
|
||||
if (child.type === 'tag') computeNode(child, style, customProps);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import {
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
mergeDesignSystemFindings,
|
||||
} from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import {
|
||||
@@ -12,20 +18,23 @@ import {
|
||||
checkElementGlow,
|
||||
checkElementGptBorderShadow,
|
||||
checkElementHeroEyebrow,
|
||||
checkElementHoverContrast,
|
||||
checkElementIconTile,
|
||||
checkElementItalicSerif,
|
||||
checkElementMotion,
|
||||
checkElementOversizedH1,
|
||||
checkElementQuality,
|
||||
checkElementRadialSpotlight,
|
||||
checkCreamPalette,
|
||||
checkHtmlPatterns,
|
||||
checkKickerAboveHeadingFromDoc,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkPageLayout,
|
||||
checkPageQualityFromDoc,
|
||||
checkRepeatedSectionKickersFromDoc,
|
||||
checkRepeatedContainerTextFromDoc,
|
||||
resolveBackground,
|
||||
resolveBorderRadiusPx,
|
||||
} from '../../rules/checks.mjs';
|
||||
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs';
|
||||
import {
|
||||
StaticDocument,
|
||||
@@ -51,9 +60,6 @@ function checkStaticPageTypography(document, window) {
|
||||
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);
|
||||
@@ -84,8 +90,9 @@ function checkElementBrokenImage(el) {
|
||||
}
|
||||
|
||||
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: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window), el) },
|
||||
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
|
||||
{ id: 'hover-color-rules', selector: '*', run: (el, tag, style, window) => checkElementHoverContrast(el, style, tag, window) },
|
||||
{ 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) },
|
||||
@@ -96,6 +103,7 @@ const STATIC_ELEMENT_RULES = [
|
||||
{ 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) },
|
||||
{ id: 'radial-spotlight-glow', selector: '*', run: (el, tag, style, window) => checkElementRadialSpotlight(el, style, tag, window) },
|
||||
];
|
||||
|
||||
async function detectHtml(filePath, options = {}) {
|
||||
@@ -168,6 +176,22 @@ async function detectHtml(filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.designSystem) {
|
||||
const sourceDesignFindings = profileFindings(profile, {
|
||||
engine: 'static-html',
|
||||
phase: 'source',
|
||||
ruleId: 'design-system',
|
||||
target: filePath,
|
||||
}, () => checkSourceDesignSystem(html, filePath, { designSystem: options.designSystem }));
|
||||
const staticDesignFindings = profileFindings(profile, {
|
||||
engine: 'static-html',
|
||||
phase: 'page',
|
||||
ruleId: 'design-system',
|
||||
target: filePath,
|
||||
}, () => collectStaticDesignSystemFindings(document, window, filePath, options.designSystem));
|
||||
findings.push(...mergeDesignSystemFindings(staticDesignFindings, sourceDesignFindings));
|
||||
}
|
||||
|
||||
if (isFullPage(html)) {
|
||||
const runPageCheck = (ruleId, callback) => profile
|
||||
? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback)
|
||||
@@ -175,7 +199,13 @@ async function detectHtml(filePath, options = {}) {
|
||||
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))) {
|
||||
for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('repeated-container-text', () => checkRepeatedContainerTextFromDoc(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('layout-rules', () => checkPageLayout(document, window))) {
|
||||
@@ -187,10 +217,33 @@ async function detectHtml(filePath, options = {}) {
|
||||
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 =>
|
||||
// Scoped corpora for the pattern checks (see buildHtmlPatternCorpora in
|
||||
// rules/checks.mjs): CSS-property regexes must not fire on prose ABOUT
|
||||
// css — `<code>background-clip: text</code>` in a changelog is
|
||||
// documentation, not styling. cssText already carries the <style>
|
||||
// blocks and any linked local stylesheets; style/class attributes come
|
||||
// from the parsed document, so escaped code samples never contribute.
|
||||
const styleAttrParts = [];
|
||||
const classAttrParts = [];
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
const styleAttr = el.getAttribute('style');
|
||||
if (styleAttr) styleAttrParts.push(`style="${styleAttr}"`);
|
||||
const classAttr = el.getAttribute('class');
|
||||
if (classAttr) classAttrParts.push(classAttr);
|
||||
}
|
||||
const patternCorpora = {
|
||||
styleText: [cssText, ...styleAttrParts].join('\n'),
|
||||
classText: classAttrParts.join('\n'),
|
||||
};
|
||||
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html, patternCorpora).filter(item =>
|
||||
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
|
||||
))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
const item = finding(f.id, filePath, f.snippet);
|
||||
// Position-aware severity promotion: checks may attach a per-finding
|
||||
// severity (e.g. a pulsing dot inside a header/nav landmark) that
|
||||
// overrides the registry default.
|
||||
if (f.severity) item.severity = f.severity;
|
||||
findings.push(item);
|
||||
}
|
||||
// Text-content analyzers (em-dash overuse, marketing buzzwords,
|
||||
// numbered section markers, aphoristic cadence) live in the regex
|
||||
@@ -202,7 +255,10 @@ async function detectHtml(filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(findings, options.providers);
|
||||
// Static-HTML findings carry no line number, so only whole-file
|
||||
// `impeccable-disable` directives apply here — exactly the standalone-document
|
||||
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
|
||||
return options?.inlineIgnores === false ? findings : applyInlineIgnores(findings, html);
|
||||
}
|
||||
|
||||
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
|
||||
|
||||
Reference in New Issue
Block a user