mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 13:46:32 +03:00
* Fix detector URL and advisory handling Recover joined URL arguments without splitting local paths, derive advisory behavior from registry severity across consumers, inspect readable linked CSS in URL scans, and report only the dominant primary font. AI assistance disclosure: Implemented and verified with Codex under maintainer direction. * Filter linked CSS to rendered selectors Flatten linked stylesheet grouping rules and collect only selector rules that target the live DOM, preventing unused grouped and selector-less patterns from leaking into URL findings. AI assistance disclosure: Implemented and verified with Codex under maintainer direction. * Fix detector review edge cases AI assistance disclosure: Codex implemented and verified these fixes under maintainer direction. * Preserve unresolved linked CSS selectors AI assistance disclosure: Codex implemented and verified this fix under maintainer direction. * Fix linked CSS selector filtering Resolve pseudo-element selectors to live hosts, reject unresolvable linked CSS findings, and make the regression assertions independent. Also ignore comment delimiters when recovering CSS rule selectors. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Skip unresolved container query CSS Exclude linked container-query groups when their current applicability cannot be resolved, with a browser regression proving inactive styles do not leak. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Detect active container query CSS Use a temporary custom-property probe so the browser decides whether a nested style rule actually applies in the current container layout. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Filter inactive linked CSS states Keep valid empty pseudo-class matches authoritative and omit selector-less linked at-rules that cannot be tied to rendered nodes. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Parse pseudo-elements without rewriting literals Preserve quoted attribute values and escaped identifiers while resolving real pseudo-elements to live hosts. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Restore live linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Handle grouped linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Respect keyframe definition order AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Resolve effective linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Fix keyframe easing detection Serialize effective per-keyframe easing back into the linked stylesheet corpus so overshoot motion is detected. Add a browser regression with a neutral animation name.\n\nAI assistance disclosure: Codex helped implement and test this fix under maintainer direction.
279 lines
12 KiB
JavaScript
279 lines
12 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { OVERUSED_FONTS, primaryFontFace } 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 { deriveAdvisoryFlag, finding } from '../../findings.mjs';
|
|
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
|
import {
|
|
checkElementBorders,
|
|
checkElementClippedOverflow,
|
|
checkElementColors,
|
|
checkElementGlow,
|
|
checkElementGptBorderShadow,
|
|
checkElementHeroEyebrow,
|
|
checkElementHoverContrast,
|
|
checkElementIconTile,
|
|
checkElementItalicSerif,
|
|
checkElementMotion,
|
|
checkElementOversizedH1,
|
|
checkElementQuality,
|
|
checkElementRadialSpotlight,
|
|
checkFlatTypeHierarchyFromDoc,
|
|
checkCreamPalette,
|
|
checkHtmlPatterns,
|
|
checkKickerAboveHeadingFromDoc,
|
|
scopedIgnoreActive,
|
|
checkNumberedSectionLabelsFromDoc,
|
|
checkPageLayout,
|
|
checkPageQualityFromDoc,
|
|
checkRepeatedContainerTextFromDoc,
|
|
resolveBackground,
|
|
resolveBorderRadiusPx,
|
|
} from '../../rules/checks.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 primary = primaryFontFace(window.getComputedStyle(el).fontFamily);
|
|
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}` });
|
|
}
|
|
findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el)));
|
|
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), 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) },
|
|
{ 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) },
|
|
{ id: 'radial-spotlight-glow', selector: '*', run: (el, tag, style, window) => checkElementRadialSpotlight(el, style, tag, window) },
|
|
];
|
|
|
|
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,
|
|
compile: cssSelect.compile,
|
|
csstree,
|
|
domutils,
|
|
};
|
|
});
|
|
} catch (err) {
|
|
if (!globalThis.__impeccableStaticHtmlWarned) {
|
|
globalThis.__impeccableStaticHtmlWarned = true;
|
|
|
|
process.stderr.write(
|
|
'impeccable detect: DEGRADED - HTML parser modules unavailable ' +
|
|
'(htmlparser2, css-select, css-tree, domutils).\n' +
|
|
'Falling back to regex matching. Custom properties, selector matching and computed ' +
|
|
'contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n'
|
|
);
|
|
}
|
|
|
|
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))) {
|
|
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
|
// matching findings for its subtree, same as the browser walk.
|
|
if (scopedIgnoreActive(el, f.id)) continue;
|
|
findings.push(finding(f.id, filePath, f.snippet));
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
: callback();
|
|
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
|
|
findings.push(finding(f.id, filePath, f.snippet));
|
|
}
|
|
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))) {
|
|
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));
|
|
}
|
|
// 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'
|
|
))) {
|
|
// Selector-backed page findings honor scoped waivers here too, matching
|
|
// the browser pass: resolve the selector and drop the finding when an
|
|
// ignoring ancestor covers a match. Unlike the browser, an unmatched
|
|
// selector keeps the finding — static scans see partial documents.
|
|
if (f.selector) {
|
|
let matches = null;
|
|
try {
|
|
matches = document.querySelectorAll(String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim());
|
|
} catch { matches = null; }
|
|
if (matches && matches.length > 0 && [...matches].every(el => scopedIgnoreActive(el, f.id))) continue;
|
|
}
|
|
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(deriveAdvisoryFlag(item));
|
|
}
|
|
// 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));
|
|
}
|
|
}
|
|
|
|
// 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 };
|