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