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 { profileFindings, profileStep } from '../../profile/profiler.mjs';
|
|
// ---------------------------------------------------------------------------
|
// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)
|
// ---------------------------------------------------------------------------
|
|
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) {
|
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, ' ');
|
}
|
|
const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']);
|
|
function extFromFilePath(filePath) {
|
return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
|
}
|
|
function shouldRunPageAnalyzers(content, filePath) {
|
if (!isFullPage(content)) return false;
|
const ext = extFromFilePath(filePath);
|
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
}
|
|
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
|
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
|
|
function isInsideOpeningJsxTag(source) {
|
const tagStart = source.lastIndexOf('<');
|
if (tagStart === -1 || !/^<[A-Za-z][\w.:-]*/.test(source.slice(tagStart))) return false;
|
|
let quote = '';
|
for (let cursor = tagStart + 1; cursor < source.length; cursor++) {
|
const char = source[cursor];
|
if (quote) {
|
if (char === '\\') cursor++;
|
else if (char === quote) quote = '';
|
} else if (char === "'" || char === '"') {
|
quote = char;
|
} else if (char === '>') {
|
return false;
|
}
|
}
|
return true;
|
}
|
|
/**
|
* Blank JavaScript comments without moving any following source. Regex
|
* findings keep their original line numbers, while prose examples inside
|
* comments cannot masquerade as rendered markup.
|
*/
|
function stripJsComments(content, options = {}) {
|
let state = 'code';
|
let output = '';
|
let lastSignificant = '';
|
let previousSignificant = '';
|
let antePreviousSignificant = '';
|
let currentWord = '';
|
let currentWordPrefix = '';
|
let wordSeparated = false;
|
let regexCharClass = false;
|
let jsxExpressionDepth = 0;
|
let lastClosedBraceKind = '';
|
const braceKinds = [];
|
const templateExpressionDepths = [];
|
|
const braceKind = (startsJsxExpression = false) => (
|
!startsJsxExpression && (
|
!lastSignificant ||
|
lastSignificant === ')' ||
|
lastSignificant === ';' ||
|
lastSignificant === '}' ||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
|
) ? 'block' : 'expression'
|
);
|
|
const recordSignificant = (char) => {
|
if (/\s/.test(char)) {
|
wordSeparated = true;
|
return;
|
}
|
const isWordChar = /[\w$]/.test(char);
|
if (isWordChar && (wordSeparated || !currentWord)) {
|
currentWord = '';
|
currentWordPrefix = lastSignificant;
|
} else if (!isWordChar) {
|
currentWordPrefix = '';
|
}
|
wordSeparated = false;
|
antePreviousSignificant = previousSignificant;
|
previousSignificant = lastSignificant;
|
lastSignificant = char;
|
currentWord = isWordChar ? currentWord + char : '';
|
};
|
|
for (let i = 0; i < content.length; i++) {
|
const char = content[i];
|
const next = content[i + 1];
|
|
if (state === 'line-comment') {
|
if (char === '\n') {
|
output += char;
|
state = 'code';
|
} else {
|
output += ' ';
|
}
|
continue;
|
}
|
|
if (state === 'block-comment') {
|
if (char === '*' && next === '/') {
|
output += ' ';
|
i++;
|
state = 'code';
|
} else {
|
output += char === '\n' ? '\n' : ' ';
|
}
|
continue;
|
}
|
|
if (state === 'regex') {
|
output += char;
|
if (char === '\\' && next) {
|
output += next;
|
i++;
|
} else if (char === '[') {
|
regexCharClass = true;
|
} else if (char === ']') {
|
regexCharClass = false;
|
} else if (char === '/' && !regexCharClass) {
|
state = 'code';
|
recordSignificant('/');
|
}
|
continue;
|
}
|
|
if (state === 'template' && char === '$' && next === '{') {
|
output += '${';
|
i++;
|
recordSignificant('$');
|
recordSignificant('{');
|
templateExpressionDepths.push(1);
|
braceKinds.push('expression');
|
if (jsxExpressionDepth) jsxExpressionDepth++;
|
state = 'code';
|
continue;
|
}
|
|
if (state !== 'code') {
|
output += char;
|
if (char === '\\' && next) {
|
output += next;
|
i++;
|
} else if (
|
(state === 'single-quote' && char === "'") ||
|
(state === 'double-quote' && char === '"') ||
|
(state === 'template' && char === '`')
|
) {
|
state = 'code';
|
recordSignificant(char);
|
}
|
continue;
|
}
|
|
const jsxUrlSeparator = options.jsx && char === '/' && next === '/' &&
|
jsxExpressionDepth === 0 &&
|
(output.endsWith('http:') ||
|
output.endsWith('https:') ||
|
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1)) &&
|
/^[\w.-]+\.[A-Za-z]{2,}(?=[:/?#\s<]|$)/.test(content.slice(i + 2))));
|
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
|
previousSignificant === lastSignificant &&
|
antePreviousSignificant !== lastSignificant;
|
if (char === '/' && next === '/' && jsxUrlSeparator) {
|
output += '//';
|
i++;
|
recordSignificant('/');
|
recordSignificant('/');
|
} else if (char === '/' && next === '/') {
|
output += ' ';
|
i++;
|
state = 'line-comment';
|
} else if (char === '/' && next === '*') {
|
output += ' ';
|
i++;
|
state = 'block-comment';
|
} else if (templateExpressionDepths.length && char === '{') {
|
output += char;
|
templateExpressionDepths[templateExpressionDepths.length - 1]++;
|
braceKinds.push(braceKind());
|
if (jsxExpressionDepth) jsxExpressionDepth++;
|
recordSignificant(char);
|
} else if (templateExpressionDepths.length && char === '}') {
|
output += char;
|
const depthIndex = templateExpressionDepths.length - 1;
|
templateExpressionDepths[depthIndex]--;
|
lastClosedBraceKind = braceKinds.pop() || '';
|
if (jsxExpressionDepth) jsxExpressionDepth--;
|
recordSignificant(char);
|
if (templateExpressionDepths[depthIndex] === 0) {
|
templateExpressionDepths.pop();
|
state = 'template';
|
}
|
} else if (
|
char === '/' &&
|
(!lastSignificant ||
|
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
|
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
|
) {
|
output += char;
|
state = 'regex';
|
regexCharClass = false;
|
} else {
|
output += char;
|
const startsJsxExpression = options.jsx && char === '{' && jsxExpressionDepth === 0 &&
|
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1, -1)) ||
|
isInsideOpeningJsxTag(output.slice(0, -1)));
|
if (char === '{') braceKinds.push(braceKind(startsJsxExpression));
|
else if (char === '}') lastClosedBraceKind = braceKinds.pop() || '';
|
if (char === '{' && (jsxExpressionDepth || startsJsxExpression)) jsxExpressionDepth++;
|
else if (char === '}' && jsxExpressionDepth) jsxExpressionDepth--;
|
recordSignificant(char);
|
if (char === "'") state = 'single-quote';
|
else if (char === '"') state = 'double-quote';
|
else if (char === '`') state = 'template';
|
}
|
}
|
|
return output;
|
}
|
|
function stripCssComments(content) {
|
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
|
}
|
|
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;
|
}
|
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 >= 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 >= 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,
|
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?\?[^"'\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),
|
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*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
|
test: () => true,
|
fmt: (m) => {
|
const token = m[1]
|
.split(/[,\s]+/)
|
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
return `animation: ${token || m[1].trim()}`;
|
} },
|
{ 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 = [
|
// 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 (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)
|
.replace(/—|�*8212;|�*2014;/gi, '—');
|
let count = 0;
|
const re = /[—]|--(?=\S)/g;
|
while (re.exec(text) !== null) count++;
|
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
|
(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}"`)];
|
},
|
// 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 / 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) => {
|
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)),
|
];
|
|
// ---------------------------------------------------------------------------
|
// 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 !== '.astro' && 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 findQuotedStringEnd(content, start, quote) {
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
if (content[cursor] === '\\') cursor++;
|
else if (content[cursor] === quote) return cursor;
|
}
|
return -1;
|
}
|
|
function findRegexLiteralEnd(content, start) {
|
let inCharacterClass = false;
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
const char = content[cursor];
|
if (char === '\\') {
|
cursor++;
|
} else if (char === '[') {
|
inCharacterClass = true;
|
} else if (char === ']') {
|
inCharacterClass = false;
|
} else if (char === '/' && !inCharacterClass) {
|
while (/[A-Za-z]/.test(content[cursor + 1] || '')) cursor++;
|
return cursor;
|
} else if (char === '\n' || char === '\r') {
|
return -1;
|
}
|
}
|
return -1;
|
}
|
|
function findTemplateExpressionEnd(content, start) {
|
let depth = 1;
|
let lastSignificant = '';
|
let previousSignificant = '';
|
let antePreviousSignificant = '';
|
let currentWord = '';
|
let currentWordPrefix = '';
|
let wordSeparated = false;
|
let lastClosedBraceKind = '';
|
const braceKinds = [];
|
|
const braceKind = () => (
|
lastSignificant === ')' ||
|
lastSignificant === ';' ||
|
lastSignificant === '}' ||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
|
? 'block'
|
: 'expression'
|
);
|
|
const recordSignificant = (char) => {
|
if (/\s/.test(char)) {
|
wordSeparated = true;
|
return;
|
}
|
const isWordChar = /[\w$]/.test(char);
|
if (isWordChar && (wordSeparated || !currentWord)) {
|
currentWord = '';
|
currentWordPrefix = lastSignificant;
|
} else if (!isWordChar) {
|
currentWordPrefix = '';
|
}
|
wordSeparated = false;
|
antePreviousSignificant = previousSignificant;
|
previousSignificant = lastSignificant;
|
lastSignificant = char;
|
currentWord = isWordChar ? currentWord + char : '';
|
};
|
|
for (let cursor = start; cursor < content.length; cursor++) {
|
const char = content[cursor];
|
const next = content[cursor + 1];
|
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
|
previousSignificant === lastSignificant &&
|
antePreviousSignificant !== lastSignificant;
|
if (char === "'" || char === '"') {
|
cursor = findQuotedStringEnd(content, cursor, char);
|
if (cursor === -1) return -1;
|
recordSignificant(')');
|
} else if (char === '/' && next === '/') {
|
const lineEnd = content.indexOf('\n', cursor + 2);
|
if (lineEnd === -1) return -1;
|
cursor = lineEnd;
|
} else if (char === '/' && next === '*') {
|
const commentEnd = content.indexOf('*/', cursor + 2);
|
if (commentEnd === -1) return -1;
|
cursor = commentEnd + 1;
|
} else if (
|
char === '/' &&
|
(!lastSignificant ||
|
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
|
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
|
(previousSignificant === '=' && lastSignificant === '>') ||
|
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
|
) {
|
cursor = findRegexLiteralEnd(content, cursor);
|
if (cursor === -1) return -1;
|
recordSignificant(')');
|
} else if (char === '`') {
|
cursor = findTemplateLiteralEnd(content, cursor);
|
if (cursor === -1) return -1;
|
recordSignificant(')');
|
} else if (char === '{') {
|
depth++;
|
braceKinds.push(braceKind());
|
recordSignificant(char);
|
} else if (char === '}') {
|
depth--;
|
if (depth === 0) return cursor;
|
lastClosedBraceKind = braceKinds.pop() || '';
|
recordSignificant(char);
|
} else {
|
recordSignificant(char);
|
}
|
}
|
return -1;
|
}
|
|
function findTemplateLiteralEnd(content, start) {
|
for (let cursor = start + 1; cursor < content.length; cursor++) {
|
const char = content[cursor];
|
if (char === '\\') {
|
cursor++;
|
} else if (char === '`') {
|
return cursor;
|
} else if (char === '$' && content[cursor + 1] === '{') {
|
cursor = findTemplateExpressionEnd(content, cursor + 2);
|
if (cursor === -1) return -1;
|
}
|
}
|
return -1;
|
}
|
|
function findCSSinJSTemplates(content) {
|
const templates = [];
|
const tagRe = /\b(?:styled(?:\.\w+|\([^)]+\))|css)/g;
|
let match;
|
while ((match = tagRe.exec(content)) !== null) {
|
let cursor = match.index + match[0].length;
|
while (/\s/.test(content[cursor] || '')) cursor++;
|
|
if (content[cursor] === '<') {
|
let depth = 0;
|
while (cursor < content.length) {
|
const char = content[cursor];
|
if (char === '<') depth++;
|
else if (char === '>' && content[cursor - 1] !== '=') depth--;
|
cursor++;
|
if (depth === 0) break;
|
}
|
if (depth !== 0) continue;
|
while (/\s/.test(content[cursor] || '')) cursor++;
|
}
|
|
if (content[cursor] !== '`') continue;
|
const contentStart = cursor + 1;
|
cursor = findTemplateLiteralEnd(content, cursor);
|
if (cursor === -1) continue;
|
|
templates.push({
|
tagStart: match.index,
|
contentStart,
|
contentEnd: cursor,
|
});
|
tagRe.lastIndex = cursor + 1;
|
}
|
return templates;
|
}
|
|
function extractCSSinJS(content, ext) {
|
ext = ext.toLowerCase();
|
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
return findCSSinJSTemplates(content).map((template) => {
|
const before = content.substring(0, template.tagStart);
|
const startLine = before.split('\n').length;
|
return {
|
content: content.slice(template.contentStart, template.contentEnd),
|
startLine,
|
};
|
});
|
}
|
|
function stripCssInJsComments(content, ext) {
|
if (!CSS_IN_JS_EXTENSIONS.has(ext.toLowerCase())) return content;
|
const templates = findCSSinJSTemplates(content);
|
let output = '';
|
let cursor = 0;
|
for (const template of templates) {
|
output += content.slice(cursor, template.contentStart);
|
output += stripCssComments(content.slice(template.contentStart, template.contentEnd));
|
cursor = template.contentEnd;
|
}
|
return output + content.slice(cursor);
|
}
|
|
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, 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',
|
'aphoristic-cadence',
|
];
|
|
function runTextContentAnalyzers(content, filePath, options = {}) {
|
const profile = options?.profile;
|
if (!shouldRunPageAnalyzers(content, filePath)) return [];
|
// 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[2 + 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 ext = extFromFilePath(filePath);
|
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
|
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
|
}) : content;
|
const source = stripCssInJsComments(commentStrippedSource, ext);
|
const lines = source.split('\n');
|
|
// 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', '.sass', '.less']);
|
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
|
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));
|
|
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(source).map(hit => {
|
const line = source.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',
|
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',
|
}));
|
// 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
|
const cssJsBlocks = profile
|
? profileStep(profile, {
|
engine: 'regex',
|
phase: 'extract',
|
ruleId: 'css-in-js',
|
target: filePath,
|
}, () => extractCSSinJS(source, ext))
|
: extractCSSinJS(source, ext);
|
for (const block of cssJsBlocks) {
|
const blockContent = stripCssComments(block.content);
|
const blockLines = blockContent.split('\n');
|
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
profile,
|
phase: 'css-in-js',
|
}));
|
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 1));
|
findings.push(...pseudoStripeFindings(blockContent, 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)
|
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 (shouldRunPageAnalyzers(content, filePath)) {
|
const analyzerIds = [
|
'flat-type-hierarchy',
|
'monotonous-spacing',
|
'em-dash-overuse',
|
'marketing-buzzword',
|
'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)));
|
}
|
}
|
|
// 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 {
|
REGEX_MATCHERS,
|
REGEX_ANALYZERS,
|
TEXT_CONTENT_ANALYZER_IDS,
|
extractStyleBlocks,
|
extractCSSinJS,
|
runRegexMatchers,
|
runTextContentAnalyzers,
|
detectText,
|
};
|