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(/]*>[\s\S]*?<\/script>/gi, ' ') .replace(/]*>[\s\S]*?<\/style>/gi, ' ') .replace(//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| `${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: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, test: () => true, fmt: (m) => m[0].slice(0, 100) }, // --- Broken image: with no src attribute at all --- { id: 'broken-image', regex: /])*>/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 ( 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? && {2,4} && ?` 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