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 { checkSourceDesignSystem } from '../../design-system.mjs';3import { isFullPage } from '../../shared/page.mjs';4import { finding } from '../../findings.mjs';5import { filterByProviders } from '../../registry/antipatterns.mjs';6import { profileFindings, profileStep } from '../../profile/profiler.mjs';78// ---------------------------------------------------------------------------9// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)10// ---------------------------------------------------------------------------1112const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);13const hasBorderRadius = (line) => /border-radius/i.test(line);14const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);1516/** Strip HTML to plain text — drops script/style/comments/tags so17* content-text analyzers don't false-positive on code or CSS. */18function stripHtmlToText(html) {19return html20.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')21.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')22.replace(/<!--[\s\S]*?-->/g, ' ')23.replace(/<[^>]+>/g, ' ')24.replace(/\s+/g, ' ');25}2627const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']);2829function extFromFilePath(filePath) {30return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';31}3233function shouldRunPageAnalyzers(content, filePath) {34if (!isFullPage(content)) return false;35const ext = extFromFilePath(filePath);36return !ext || PAGE_ANALYZER_EXTS.has(ext);37}3839function isNeutralBorderColor(str) {40const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);41if (!m) return false;42const c = m[1].toLowerCase();43if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;44const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);45if (hex) {46const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];47return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;48}49const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);50if (shex) {51const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];52return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;53}54return false;55}5657const REGEX_MATCHERS = [58// --- Side-tab ---59{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,60test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },61fmt: (m) => m[0] },62{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,63test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },64fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },65{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,66test: (m, line) => !isSafeElement(line) && +m[1] >= 3,67fmt: (m) => m[0] },68{ id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,69test: (m, line) => !isSafeElement(line) && +m[1] >= 3,70fmt: (m) => m[0] },71{ id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,72test: (m, line) => !isSafeElement(line) && +m[1] >= 3,73fmt: (m) => m[0] },74{ id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,75test: (m) => +m[1] >= 3,76fmt: (m) => m[0] },77// --- Border accent on rounded ---78{ id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g,79test: (m, line) => hasRounded(line) && +m[1] >= 1,80fmt: (m) => m[0] },81{ id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi,82test: (m, line) => +m[1] >= 3 && hasBorderRadius(line),83fmt: (m) => m[0] },84// --- Overused font ---85{ 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,86test: () => true,87fmt: (m) => m[0] },88{ 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,89test: () => true,90fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },91// --- Gradient text ---92{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,93test: (m, line) => /gradient/i.test(line),94fmt: () => 'background-clip: text + gradient' },95// --- Gradient text (Tailwind) ---96{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,97test: (m, line) => /\bbg-gradient-to-/i.test(line),98fmt: () => 'bg-clip-text + bg-gradient' },99// --- Tailwind gray on colored bg ---100{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,101test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),102fmt: (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] || '?'}`; } },103// --- Tailwind AI palette ---104{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,105test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),106fmt: (m) => `${m[0]} on heading` },107{ id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g,108test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line),109fmt: (m) => `${m[0]} gradient` },110// --- Bounce/elastic easing ---111{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,112test: () => true,113fmt: () => 'animate-bounce (Tailwind)' },114{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,115test: () => true,116fmt: (m) => {117const token = m[1]118.split(/[,\s]+/)119.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));120return `animation: ${token || m[1].trim()}`;121} },122{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,123test: (m) => {124const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);125return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1;126},127fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },128// --- Layout property transition ---129{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,130test: (m) => {131const val = m[1].toLowerCase();132if (/\ball\b/.test(val)) return false;133return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);134},135fmt: (m) => {136const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);137return `transition: ${found ? found.join(', ') : m[1].trim()}`;138} },139{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,140test: (m) => {141const val = m[1].toLowerCase();142if (/\ball\b/.test(val)) return false;143return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);144},145fmt: (m) => {146const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);147return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;148} },149// --- Broken image: src="" or src="#" or src=" " ---150{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,151test: () => true,152fmt: (m) => m[0].slice(0, 100) },153// --- Broken image: <img> with no src attribute at all ---154{ id: 'broken-image', regex: /<img\b(?:(?!\bsrc\s*=)[^>])*>/gi,155test: (m) => !/\bsrc\s*=/i.test(m[0]),156fmt: (m) => m[0].slice(0, 100) },157];158159const REGEX_ANALYZERS = [160// Single font161(content, filePath) => {162const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;163const fonts = new Set();164let m;165while ((m = fontFamilyRe.exec(content)) !== null) {166for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {167if (f && !GENERIC_FONTS.has(f)) fonts.add(f);168}169}170const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;171while ((m = gfRe.exec(content)) !== null) {172for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);173}174if (fonts.size !== 1 || content.split('\n').length < 20) return [];175const name = [...fonts][0];176const lines = content.split('\n');177let line = 1;178for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }179return [finding('single-font', filePath, `only font used is ${name}`, line)];180},181// Flat type hierarchy182(content, filePath) => {183const sizes = new Set();184const REM = 16;185let m;186const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;187while ((m = sizeRe.exec(content)) !== null) {188const px = m[2] === 'px' ? +m[1] : +m[1] * REM;189if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);190}191const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;192while ((m = clampRe.exec(content)) !== null) {193sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);194sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);195}196const 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 };197for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }198if (sizes.size < 3) return [];199const sorted = [...sizes].sort((a, b) => a - b);200const ratio = sorted[sorted.length - 1] / sorted[0];201if (ratio >= 2.0) return [];202const lines = content.split('\n');203let line = 1;204for (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; } }205return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];206},207// Monotonous spacing (regex)208(content, filePath) => {209const vals = [];210let m;211const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;212while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }213const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;214while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }215const gapRe = /gap\s*:\s*(\d+)px/gi;216while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);217const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;218while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);219const rounded = vals.map(v => Math.round(v / 4) * 4);220if (rounded.length < 10) return [];221const counts = {};222for (const v of rounded) counts[v] = (counts[v] || 0) + 1;223const maxCount = Math.max(...Object.values(counts));224const pct = maxCount / rounded.length;225const unique = [...new Set(rounded)].filter(v => v > 0);226if (pct <= 0.6 || unique.length > 3) return [];227const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];228return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];229},230// Em-dash overuse: 5+ em-dashes or "--" in body text content231// (occasional em-dash use in prose is fine; the pattern fires only232// when count crosses into AI-cadence territory).233(content, filePath) => {234const text = stripHtmlToText(content);235let count = 0;236const re = /[—]|--(?=\S)/g;237while (re.exec(text) !== null) count++;238if (count < 5) return [];239return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];240},241// Marketing buzzwords: SaaS phrase list242(content, filePath) => {243const text = stripHtmlToText(content);244const lower = text.toLowerCase();245const BUZZWORDS = [246'streamline your', 'empower your', 'supercharge your',247'unleash your', 'unleash the power', 'leverage the power',248'built for the modern', 'trusted by leading', 'trusted by the world',249'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade',250'next-generation', 'cutting-edge', 'transform your business',251'revolutionize', 'game-changer', 'game changing',252'mission-critical', 'best of breed', 'future-proof', 'future proof',253'seamless experience', 'seamlessly integrate',254'drive engagement', 'drive growth', 'drive results',255'harness the power',256];257let count = 0;258let firstSample = '';259for (const phrase of BUZZWORDS) {260let from = 0;261while (true) {262const idx = lower.indexOf(phrase, from);263if (idx === -1) break;264count++;265if (!firstSample) {266firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim();267}268from = idx + phrase.length;269}270}271if (count === 0) return [];272return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)];273},274// Numbered section markers (01 / 02 / 03 ...)275(content, filePath) => {276const text = stripHtmlToText(content);277const re = /\b(0[1-9]|1[0-2])\b/g;278const seen = new Set();279let m;280while ((m = re.exec(text)) !== null) seen.add(m[1]);281if (seen.size < 3) return [];282const sorted = [...seen].sort();283let sequential = 0;284for (let i = 1; i < sorted.length; i++) {285if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++;286}287if (sequential < 2) return [];288return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)];289},290// Aphoristic cadence: manufactured-contrast + short-rebuttal291(content, filePath) => {292const text = stripHtmlToText(content);293const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g;294const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g;295let count = 0;296let firstSample = '';297let m;298NOT_A_RE.lastIndex = 0;299while ((m = NOT_A_RE.exec(text)) !== null) {300count++;301if (!firstSample) firstSample = m[0].trim().slice(0, 80);302}303SHORT_REBUTTAL_RE.lastIndex = 0;304while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) {305count++;306if (!firstSample) firstSample = m[0].trim().slice(0, 80);307}308if (count < 3) return [];309return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)];310},311// Dark glow (page-level: dark bg + colored box-shadow with blur)312(content, filePath) => {313// Check if page has a dark background314const 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;315const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;316const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);317if (!hasDarkBg) return [];318319// Check for colored box-shadow with blur > 4px320const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;321let m;322while ((m = shadowRe.exec(content)) !== null) {323const val = m[1];324const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);325if (!colorMatch) continue;326const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];327if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray328// Check blur: look for pattern like "0 0 20px" (third number > 4)329const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));330if (pxVals.length >= 3 && pxVals[2] > 4) {331const lines = content.substring(0, m.index).split('\n');332return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];333}334}335return [];336},337];338339// ---------------------------------------------------------------------------340// Style block extraction (Vue/Svelte <style> blocks)341// ---------------------------------------------------------------------------342343function extractStyleBlocks(content, ext) {344ext = ext.toLowerCase();345if (ext !== '.vue' && ext !== '.svelte') return [];346const blocks = [];347const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;348let m;349while ((m = re.exec(content)) !== null) {350const before = content.substring(0, m.index);351const startLine = before.split('\n').length + 1;352blocks.push({ content: m[1], startLine });353}354return blocks;355}356357// ---------------------------------------------------------------------------358// CSS-in-JS extraction (styled-components, emotion)359// ---------------------------------------------------------------------------360361const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);362363function extractCSSinJS(content, ext) {364ext = ext.toLowerCase();365if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];366const blocks = [];367const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;368let m;369while ((m = re.exec(content)) !== null) {370const before = content.substring(0, m.index);371const startLine = before.split('\n').length;372blocks.push({ content: m[1], startLine });373}374return blocks;375}376377function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {378const { profile, phase = 'regex-matchers' } = options || {};379const findings = [];380if (!profile) {381for (const matcher of REGEX_MATCHERS) {382for (let i = 0; i < lines.length; i++) {383const line = lines[i];384matcher.regex.lastIndex = 0;385let m;386while ((m = matcher.regex.exec(line)) !== null) {387// For extracted blocks, use nearby lines as context for multi-line CSS patterns388const context = blockContext389? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')390: line;391if (matcher.test(m, context)) {392findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));393}394}395}396}397return findings;398}399400for (const matcher of REGEX_MATCHERS) {401const matcherFindings = profileFindings(profile, {402engine: 'regex',403phase,404ruleId: matcher.id,405target: filePath,406}, () => {407const matches = [];408for (let i = 0; i < lines.length; i++) {409const line = lines[i];410matcher.regex.lastIndex = 0;411let m;412while ((m = matcher.regex.exec(line)) !== null) {413// For extracted blocks, use nearby lines as context for multi-line CSS patterns414const context = blockContext415? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')416: line;417if (matcher.test(m, context)) {418matches.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));419}420}421}422return matches;423});424findings.push(...matcherFindings);425}426return findings;427}428429/** Page-level analyzers that scan rendered text content (em-dash use,430* buzzword phrases, numbered section markers, aphoristic cadence).431* These are detector-agnostic — they work on any HTML/text source432* and don't need a parsed DOM. Exported so detectHtml can call them433* for `.html` files (which otherwise skip the regex engine). */434const TEXT_CONTENT_ANALYZER_IDS = [435'em-dash-overuse',436'marketing-buzzword',437'numbered-section-markers',438'aphoristic-cadence',439];440441function runTextContentAnalyzers(content, filePath, options = {}) {442const profile = options?.profile;443if (!shouldRunPageAnalyzers(content, filePath)) return [];444// The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS.445const findings = [];446for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {447const analyzer = REGEX_ANALYZERS[3 + i];448const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];449findings.push(...profileFindings(profile, {450engine: 'regex',451phase: 'text-content',452ruleId,453target: filePath,454}, () => analyzer(content, filePath)));455}456return findings;457}458459function detectText(content, filePath, options = {}) {460const profile = options?.profile;461const findings = [];462const lines = content.split('\n');463const ext = extFromFilePath(filePath);464465// Run regex matchers on the full file content (catches Tailwind classes, inline styles)466// Enable block context for CSS files where related properties span multiple lines467const cssLike = new Set(['.css', '.scss', '.sass', '.less']);468findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {469profile,470phase: 'source',471}));472473// Extract and scan <style> blocks from Vue/Svelte SFCs474const styleBlocks = profile475? profileStep(profile, {476engine: 'regex',477phase: 'extract',478ruleId: 'style-blocks',479target: filePath,480}, () => extractStyleBlocks(content, ext))481: extractStyleBlocks(content, ext);482for (const block of styleBlocks) {483const blockLines = block.content.split('\n');484findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {485profile,486phase: 'style-block',487}));488}489490// Extract and scan CSS-in-JS template literals491const cssJsBlocks = profile492? profileStep(profile, {493engine: 'regex',494phase: 'extract',495ruleId: 'css-in-js',496target: filePath,497}, () => extractCSSinJS(content, ext))498: extractCSSinJS(content, ext);499for (const block of cssJsBlocks) {500const blockLines = block.content.split('\n');501findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {502profile,503phase: 'css-in-js',504}));505}506507if (options?.designSystem) {508findings.push(...profileFindings(profile, {509engine: 'regex',510phase: 'source',511ruleId: 'design-system',512target: filePath,513}, () => checkSourceDesignSystem(content, filePath, { designSystem: options.designSystem })));514}515516// Deduplicate findings (same antipattern + similar snippet, within 2 lines)517const deduped = [];518for (const f of findings) {519const isDupe = deduped.some(d =>520d.antipattern === f.antipattern &&521d.snippet === f.snippet &&522Math.abs(d.line - f.line) <= 2523);524if (!isDupe) deduped.push(f);525}526527// Page-level analyzers only run on full pages528if (shouldRunPageAnalyzers(content, filePath)) {529const analyzerIds = [530'single-font',531'flat-type-hierarchy',532'monotonous-spacing',533'em-dash-overuse',534'marketing-buzzword',535'numbered-section-markers',536'aphoristic-cadence',537'dark-glow',538];539for (let i = 0; i < REGEX_ANALYZERS.length; i++) {540const analyzer = REGEX_ANALYZERS[i];541deduped.push(...profileFindings(profile, {542engine: 'regex',543phase: 'page-analyzer',544ruleId: analyzerIds[i] || `analyzer-${i + 1}`,545target: filePath,546}, () => analyzer(content, filePath)));547}548}549550return filterByProviders(deduped, options?.providers);551}552553export {554REGEX_MATCHERS,555REGEX_ANALYZERS,556TEXT_CONTENT_ANALYZER_IDS,557extractStyleBlocks,558extractCSSinJS,559runRegexMatchers,560runTextContentAnalyzers,561detectText,562};563