Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/detector/engines/regex/detect-text.mjs
1import { GENERIC_FONTS } from '../../shared/constants.mjs';2import { isNeutralColor } from '../../shared/color.mjs';3import { checkSourceDesignSystem } from '../../design-system.mjs';4import { isFullPage } from '../../shared/page.mjs';5import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';6import { finding } from '../../findings.mjs';7import { filterByProviders } from '../../registry/antipatterns.mjs';8import { profileFindings, profileStep } from '../../profile/profiler.mjs';910// ---------------------------------------------------------------------------11// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)12// ---------------------------------------------------------------------------1314const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);15const hasBorderRadius = (line) => /border-radius/i.test(line);16const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);1718/** Strip HTML to plain text — drops script/style/comments/tags so19* content-text analyzers don't false-positive on code or CSS. */20function stripHtmlToText(html) {21return html22.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')23.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')24.replace(/<!--[\s\S]*?-->/g, ' ')25.replace(/<[^>]+>/g, ' ')26.replace(/\s+/g, ' ');27}2829const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']);3031function extFromFilePath(filePath) {32return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';33}3435function shouldRunPageAnalyzers(content, filePath) {36if (!isFullPage(content)) return false;37const ext = extFromFilePath(filePath);38return !ext || PAGE_ANALYZER_EXTS.has(ext);39}4041function isNeutralBorderColor(str) {42const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);43if (!m) return false;44const c = m[1].toLowerCase();45if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;46if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);47const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);48if (hex) {49const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];50return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;51}52const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);53if (shex) {54const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];55return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;56}57return false;58}5960const REGEX_MATCHERS = [61// --- Side-tab ---62{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,63test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },64fmt: (m) => m[0] },65{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,66test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },67fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },68{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,69test: (m, line) => !isSafeElement(line) && +m[1] >= 3,70fmt: (m) => m[0] },71{ id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,72test: (m, line) => !isSafeElement(line) && +m[1] >= 3,73fmt: (m) => m[0] },74{ id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,75test: (m, line) => !isSafeElement(line) && +m[1] >= 3,76fmt: (m) => m[0] },77{ id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,78test: (m) => +m[1] >= 3,79fmt: (m) => m[0] },80// --- Border accent on rounded ---81{ id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g,82test: (m, line) => hasRounded(line) && +m[1] >= 1,83fmt: (m) => m[0] },84{ id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi,85test: (m, line) => +m[1] >= 3 && hasBorderRadius(line),86fmt: (m) => m[0] },87// --- Overused font ---88{ 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,89test: () => true,90fmt: (m) => m[0] },91{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Instrument\+Serif|Mona\+Sans|Geist)\b/gi,92test: () => true,93fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },94// --- Gradient text ---95{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,96test: (m, line) => /gradient/i.test(line),97fmt: () => 'background-clip: text + gradient' },98// --- Gradient text (Tailwind) ---99{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,100test: (m, line) => /\bbg-gradient-to-/i.test(line),101fmt: () => 'bg-clip-text + bg-gradient' },102// --- Tailwind gray on colored bg ---103{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,104test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),105fmt: (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] || '?'}`; } },106// --- Tailwind AI palette ---107{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,108test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),109fmt: (m) => `${m[0]} on heading` },110{ id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g,111test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line),112fmt: (m) => `${m[0]} gradient` },113// --- Bounce/elastic easing ---114{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,115test: () => true,116fmt: () => 'animate-bounce (Tailwind)' },117{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,118test: () => true,119fmt: (m) => {120const token = m[1]121.split(/[,\s]+/)122.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));123return `animation: ${token || m[1].trim()}`;124} },125{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,126test: (m) => {127const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);128return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1;129},130fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },131// --- Layout property transition ---132{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,133test: (m) => {134const val = m[1].toLowerCase();135if (/\ball\b/.test(val)) return false;136return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);137},138fmt: (m) => {139const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);140return `transition: ${found ? found.join(', ') : m[1].trim()}`;141} },142{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,143test: (m) => {144const val = m[1].toLowerCase();145if (/\ball\b/.test(val)) return false;146return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);147},148fmt: (m) => {149const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);150return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;151} },152// --- Broken image: src="" or src="#" or src=" " ---153{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,154test: () => true,155fmt: (m) => m[0].slice(0, 100) },156// --- Broken image: <img> with no src attribute at all ---157{ id: 'broken-image', regex: /<img\b(?:(?!\bsrc\s*=)[^>])*>/gi,158test: (m) => !/\bsrc\s*=/i.test(m[0]),159fmt: (m) => m[0].slice(0, 100) },160];161162const REGEX_ANALYZERS = [163// Single font164(content, filePath) => {165const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;166const fonts = new Set();167let m;168while ((m = fontFamilyRe.exec(content)) !== null) {169for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {170if (f && !GENERIC_FONTS.has(f)) fonts.add(f);171}172}173const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;174while ((m = gfRe.exec(content)) !== null) {175for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);176}177if (fonts.size !== 1 || content.split('\n').length < 20) return [];178const name = [...fonts][0];179const lines = content.split('\n');180let line = 1;181for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }182return [finding('single-font', filePath, `only font used is ${name}`, line)];183},184// Flat type hierarchy185(content, filePath) => {186const sizes = new Set();187const REM = 16;188let m;189const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;190while ((m = sizeRe.exec(content)) !== null) {191const px = m[2] === 'px' ? +m[1] : +m[1] * REM;192if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);193}194const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;195while ((m = clampRe.exec(content)) !== null) {196sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);197sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);198}199const 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 };200for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }201if (sizes.size < 3) return [];202const sorted = [...sizes].sort((a, b) => a - b);203const ratio = sorted[sorted.length - 1] / sorted[0];204if (ratio >= 2.0) return [];205const lines = content.split('\n');206let line = 1;207for (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; } }208return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];209},210// Monotonous spacing (regex)211(content, filePath) => {212const vals = [];213let m;214const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;215while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }216const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;217while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }218const gapRe = /gap\s*:\s*(\d+)px/gi;219while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);220const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;221while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);222const rounded = vals.map(v => Math.round(v / 4) * 4);223if (rounded.length < 10) return [];224const counts = {};225for (const v of rounded) counts[v] = (counts[v] || 0) + 1;226const maxCount = Math.max(...Object.values(counts));227const pct = maxCount / rounded.length;228const unique = [...new Set(rounded)].filter(v => v > 0);229if (pct <= 0.6 || unique.length > 3) return [];230const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];231return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];232},233// Em-dash overuse: 5+ em-dashes or "--" in body text content234// (occasional em-dash use in prose is fine; the pattern fires only235// when count crosses into AI-cadence territory).236(content, filePath) => {237const text = stripHtmlToText(content);238let count = 0;239const re = /[—]|--(?=\S)/g;240while (re.exec(text) !== null) count++;241if (count < 5) return [];242return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];243},244// Marketing buzzwords: SaaS phrase list245(content, filePath) => {246const text = stripHtmlToText(content);247const lower = text.toLowerCase();248const BUZZWORDS = [249'streamline your', 'empower your', 'supercharge your',250'unleash your', 'unleash the power', 'leverage the power',251'built for the modern', 'trusted by leading', 'trusted by the world',252'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade',253'next-generation', 'cutting-edge', 'transform your business',254'revolutionize', 'game-changer', 'game changing',255'mission-critical', 'best of breed', 'future-proof', 'future proof',256'seamless experience', 'seamlessly integrate',257'drive engagement', 'drive growth', 'drive results',258'harness the power',259];260let count = 0;261let firstSample = '';262for (const phrase of BUZZWORDS) {263let from = 0;264while (true) {265const idx = lower.indexOf(phrase, from);266if (idx === -1) break;267count++;268if (!firstSample) {269firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim();270}271from = idx + phrase.length;272}273}274if (count === 0) return [];275return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)];276},277// Numbered section markers (01 / 02 / 03 ...)278(content, filePath) => {279const text = stripHtmlToText(content);280const re = /\b(0[1-9]|1[0-2])\b/g;281const seen = new Set();282let m;283while ((m = re.exec(text)) !== null) seen.add(m[1]);284if (seen.size < 3) return [];285const sorted = [...seen].sort();286let sequential = 0;287for (let i = 1; i < sorted.length; i++) {288if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++;289}290if (sequential < 2) return [];291return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)];292},293// Aphoristic cadence: manufactured-contrast + short-rebuttal294(content, filePath) => {295const text = stripHtmlToText(content);296const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g;297const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g;298let count = 0;299let firstSample = '';300let m;301NOT_A_RE.lastIndex = 0;302while ((m = NOT_A_RE.exec(text)) !== null) {303count++;304if (!firstSample) firstSample = m[0].trim().slice(0, 80);305}306SHORT_REBUTTAL_RE.lastIndex = 0;307while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) {308count++;309if (!firstSample) firstSample = m[0].trim().slice(0, 80);310}311if (count < 3) return [];312return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)];313},314// Dark glow (page-level: dark bg + colored box-shadow with blur)315(content, filePath) => {316// Check if page has a dark background317const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;318const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;319const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);320if (!hasDarkBg) return [];321322// Check for colored box-shadow with blur > 4px323const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;324let m;325while ((m = shadowRe.exec(content)) !== null) {326const val = m[1];327const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);328if (!colorMatch) continue;329const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];330if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray331// Check blur: look for pattern like "0 0 20px" (third number > 4)332const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));333if (pxVals.length >= 3 && pxVals[2] > 4) {334const lines = content.substring(0, m.index).split('\n');335return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];336}337}338return [];339},340];341342// ---------------------------------------------------------------------------343// Style block extraction (Vue/Svelte <style> blocks)344// ---------------------------------------------------------------------------345346function extractStyleBlocks(content, ext) {347ext = ext.toLowerCase();348if (ext !== '.vue' && ext !== '.svelte') return [];349const blocks = [];350const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;351let m;352while ((m = re.exec(content)) !== null) {353const before = content.substring(0, m.index);354const startLine = before.split('\n').length + 1;355blocks.push({ content: m[1], startLine });356}357return blocks;358}359360// ---------------------------------------------------------------------------361// CSS-in-JS extraction (styled-components, emotion)362// ---------------------------------------------------------------------------363364const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);365366function extractCSSinJS(content, ext) {367ext = ext.toLowerCase();368if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];369const blocks = [];370const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;371let m;372while ((m = re.exec(content)) !== null) {373const before = content.substring(0, m.index);374const startLine = before.split('\n').length;375blocks.push({ content: m[1], startLine });376}377return blocks;378}379380function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {381const { profile, phase = 'regex-matchers' } = options || {};382const findings = [];383if (!profile) {384for (const matcher of REGEX_MATCHERS) {385for (let i = 0; i < lines.length; i++) {386const line = lines[i];387matcher.regex.lastIndex = 0;388let m;389while ((m = matcher.regex.exec(line)) !== null) {390// For extracted blocks, use nearby lines as context for multi-line CSS patterns391const context = blockContext392? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')393: line;394if (matcher.test(m, context)) {395findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));396}397}398}399}400return findings;401}402403for (const matcher of REGEX_MATCHERS) {404const matcherFindings = profileFindings(profile, {405engine: 'regex',406phase,407ruleId: matcher.id,408target: filePath,409}, () => {410const matches = [];411for (let i = 0; i < lines.length; i++) {412const line = lines[i];413matcher.regex.lastIndex = 0;414let m;415while ((m = matcher.regex.exec(line)) !== null) {416// For extracted blocks, use nearby lines as context for multi-line CSS patterns417const context = blockContext418? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')419: line;420if (matcher.test(m, context)) {421matches.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));422}423}424}425return matches;426});427findings.push(...matcherFindings);428}429return findings;430}431432/** Page-level analyzers that scan rendered text content (em-dash use,433* buzzword phrases, numbered section markers, aphoristic cadence).434* These are detector-agnostic — they work on any HTML/text source435* and don't need a parsed DOM. Exported so detectHtml can call them436* for `.html` files (which otherwise skip the regex engine). */437const TEXT_CONTENT_ANALYZER_IDS = [438'em-dash-overuse',439'marketing-buzzword',440'numbered-section-markers',441'aphoristic-cadence',442];443444function runTextContentAnalyzers(content, filePath, options = {}) {445const profile = options?.profile;446if (!shouldRunPageAnalyzers(content, filePath)) return [];447// The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS.448const findings = [];449for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {450const analyzer = REGEX_ANALYZERS[3 + i];451const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];452findings.push(...profileFindings(profile, {453engine: 'regex',454phase: 'text-content',455ruleId,456target: filePath,457}, () => analyzer(content, filePath)));458}459return findings;460}461462function detectText(content, filePath, options = {}) {463const profile = options?.profile;464const findings = [];465const lines = content.split('\n');466const ext = extFromFilePath(filePath);467468// Run regex matchers on the full file content (catches Tailwind classes, inline styles)469// Enable block context for CSS files where related properties span multiple lines470const cssLike = new Set(['.css', '.scss', '.sass', '.less']);471findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {472profile,473phase: 'source',474}));475476// Extract and scan <style> blocks from Vue/Svelte SFCs477const styleBlocks = profile478? profileStep(profile, {479engine: 'regex',480phase: 'extract',481ruleId: 'style-blocks',482target: filePath,483}, () => extractStyleBlocks(content, ext))484: extractStyleBlocks(content, ext);485for (const block of styleBlocks) {486const blockLines = block.content.split('\n');487findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {488profile,489phase: 'style-block',490}));491}492493// Extract and scan CSS-in-JS template literals494const cssJsBlocks = profile495? profileStep(profile, {496engine: 'regex',497phase: 'extract',498ruleId: 'css-in-js',499target: filePath,500}, () => extractCSSinJS(content, ext))501: extractCSSinJS(content, ext);502for (const block of cssJsBlocks) {503const blockLines = block.content.split('\n');504findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {505profile,506phase: 'css-in-js',507}));508}509510if (options?.designSystem) {511findings.push(...profileFindings(profile, {512engine: 'regex',513phase: 'source',514ruleId: 'design-system',515target: filePath,516}, () => checkSourceDesignSystem(content, filePath, { designSystem: options.designSystem })));517}518519// Deduplicate findings (same antipattern + similar snippet, within 2 lines)520const deduped = [];521for (const f of findings) {522const isDupe = deduped.some(d =>523d.antipattern === f.antipattern &&524d.snippet === f.snippet &&525Math.abs(d.line - f.line) <= 2526);527if (!isDupe) deduped.push(f);528}529530// Page-level analyzers only run on full pages531if (shouldRunPageAnalyzers(content, filePath)) {532const analyzerIds = [533'single-font',534'flat-type-hierarchy',535'monotonous-spacing',536'em-dash-overuse',537'marketing-buzzword',538'numbered-section-markers',539'aphoristic-cadence',540'dark-glow',541];542for (let i = 0; i < REGEX_ANALYZERS.length; i++) {543const analyzer = REGEX_ANALYZERS[i];544deduped.push(...profileFindings(profile, {545engine: 'regex',546phase: 'page-analyzer',547ruleId: analyzerIds[i] || `analyzer-${i + 1}`,548target: filePath,549}, () => analyzer(content, filePath)));550}551}552553const byProvider = filterByProviders(deduped, options?.providers);554// Inline `impeccable-disable*` waivers travel with the file; honor them unless555// explicitly bypassed (`--no-config` / `--no-inline-ignores`).556return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content);557}558559export {560REGEX_MATCHERS,561REGEX_ANALYZERS,562TEXT_CONTENT_ANALYZER_IDS,563extractStyleBlocks,564extractCSSinJS,565runRegexMatchers,566runTextContentAnalyzers,567detectText,568};569