返回 AiToEarn
detect-text.mjs
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
536 lines Plain Text