Setting the file. One moment.
Detect Text · Impeccable · pbakaus/impeccable · Skills Docs
ContentsBack to the top of the page function firstOverusedGoogleFont
— line 259
This file
Number 1.52
Position 52 of 144
Type JavaScript
Size 47 KB
Lines 1,168 scripts/detector/engines/regex/ detect-text.mjs
JavaScript · 1,168 lines · 47 KB
7 import { applyInlineIgnores } from '../../shared/inline-ignores.mjs' ;
8 import { finding } from '../../findings.mjs' ;
9 import { profileFindings, profileStep } from '../../profile/profiler.mjs' ;
10
11 // ---------------------------------------------------------------------------
12 // Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)
13 // ---------------------------------------------------------------------------
14
15 const hasRounded = ( line ) =>
16 / \b rounded(?:- \w + ) ?\b / . test (line. replace ( / \b rounded-none \b / g , '' ));
17 const hasBorderRadius = ( line ) => /border-radius/ i . test (line);
18 const isSafeElement = ( line ) => /<(?:blockquote | nav [\s>] | pre [\s>] | code [\s>] | a \s | input [\s>] | span [\s>] )/ i . test (line);
19
20
21 /** Strip HTML to plain text — drops script/style/comments/tags so
22 * content-text analyzers don't false-positive on code or CSS. */
23 function stripHtmlToText ( html ) {
24 return html
25 . replace ( /<script \b [ ^ >] * > [\s\S] *? < \/ script>/ gi , ' ' )
26 . replace ( /<style \b [ ^ >] * > [\s\S] *? < \/ style>/ gi , ' ' )
27 . replace ( /<!-- [\s\S] *? -->/ g , ' ' )
28 . replace ( /< [ ^ >] + >/ g , ' ' )
29 . replace ( / \s + / g , ' ' );
30 }
31
32 const PAGE_ANALYZER_EXTS = new Set ([ '.html' , '.htm' , '.astro' , '.vue' , '.svelte' ]);
33
34 function extFromFilePath ( filePath ) {
35 return filePath ? (filePath. match ( / \. \w +$ / )?.[ 0 ] || '' ). toLowerCase () : '' ;
36 }
37
38 function shouldRunPageAnalyzers ( content , filePath ) {
39 if ( ! isFullPage (content)) return false ;
40 const ext = extFromFilePath (filePath);
41 return ! ext || PAGE_ANALYZER_EXTS . has (ext);
42 }
43
44 const JS_SOURCE_EXTS = new Set ([ '.js' , '.jsx' , '.ts' , '.tsx' , '.mjs' , '.cjs' ]);
45 const REGEX_PREFIX_KEYWORDS = new Set ([ 'await' , 'case' , 'default' , 'delete' , 'do' , 'else' , 'in' , 'instanceof' , 'new' , 'of' , 'return' , 'throw' , 'typeof' , 'void' , 'yield' ]);
46 const BLOCK_BRACE_PREFIX_KEYWORDS = new Set ([ 'do' , 'else' , 'finally' , 'try' ]);
47
48 function isInsideOpeningJsxTag ( source ) {
49 const tagStart = source. lastIndexOf ( '<' );
50 if (tagStart === - 1 || ! / ^ < [A-Za-z][\w.:-] * / . test (source. slice (tagStart))) return false ;
51
52 let quote = '' ;
53 for ( let cursor = tagStart + 1 ; cursor < source. length ; cursor ++ ) {
54 const char = source[cursor];
55 if (quote) {
56 if (char === ' \\ ' ) cursor ++ ;
57 else if (char === quote) quote = '' ;
58 } else if (char === "'" || char === '"' ) {
59 quote = char;
60 } else if (char === '>' ) {
61 return false ;
62 }
63 }
64 return true ;
65 }
66
67 /**
68 * Blank JavaScript comments without moving any following source. Regex
69 * findings keep their original line numbers, while prose examples inside
70 * comments cannot masquerade as rendered markup.
71 */
72 function stripJsComments ( content , options = {}) {
73 let state = 'code' ;
74 let output = '' ;
75 let lastSignificant = '' ;
76 let previousSignificant = '' ;
77 let antePreviousSignificant = '' ;
78 let currentWord = '' ;
79 let currentWordPrefix = '' ;
80 let wordSeparated = false ;
81 let regexCharClass = false ;
82 let jsxExpressionDepth = 0 ;
83 let lastClosedBraceKind = '' ;
84 const braceKinds = [];
85 const templateExpressionDepths = [];
86
87 const braceKind = ( startsJsxExpression = false ) => (
88 ! startsJsxExpression && (
89 ! lastSignificant ||
90 lastSignificant === ')' ||
91 lastSignificant === ';' ||
92 lastSignificant === '}' ||
93 (previousSignificant === '=' && lastSignificant === '>' ) ||
94 BLOCK_BRACE_PREFIX_KEYWORDS . has (currentWord)
95 ) ? 'block' : 'expression'
96 );
97
98 const recordSignificant = ( char ) => {
99 if ( / \s / . test (char)) {
100 wordSeparated = true ;
101 return ;
102 }
103 const isWordChar = / [\w$] / . test (char);
104 if (isWordChar && (wordSeparated || ! currentWord)) {
105 currentWord = '' ;
106 currentWordPrefix = lastSignificant;
107 } else if ( ! isWordChar) {
108 currentWordPrefix = '' ;
109 }
110 wordSeparated = false ;
111 antePreviousSignificant = previousSignificant;
112 previousSignificant = lastSignificant;
113 lastSignificant = char;
114 currentWord = isWordChar ? currentWord + char : '' ;
115 };
116
117 for ( let i = 0 ; i < content. length ; i ++ ) {
118 const char = content[i];
119 const next = content[i + 1 ];
120
121 if (state === 'line-comment' ) {
122 if (char === ' \n ' ) {
123 output += char;
124 state = 'code' ;
125 } else {
126 output += ' ' ;
127 }
128 continue ;
129 }
130
131 if (state === 'block-comment' ) {
132 if (char === '*' && next === '/' ) {
133 output += ' ' ;
134 i ++ ;
135 state = 'code' ;
136 } else {
137 output += char === ' \n ' ? ' \n ' : ' ' ;
138 }
139 continue ;
140 }
141
142 if (state === 'regex' ) {
143 output += char;
144 if (char === ' \\ ' && next) {
145 output += next;
146 i ++ ;
147 } else if (char === '[' ) {
148 regexCharClass = true ;
149 } else if (char === ']' ) {
150 regexCharClass = false ;
151 } else if (char === '/' && ! regexCharClass) {
152 state = 'code' ;
153 recordSignificant ( '/' );
154 }
155 continue ;
156 }
157
158 if (state === 'template' && char === '$' && next === '{' ) {
159 output += '${' ;
160 i ++ ;
161 recordSignificant ( '$' );
162 recordSignificant ( '{' );
163 templateExpressionDepths. push ( 1 );
164 braceKinds. push ( 'expression' );
165 if (jsxExpressionDepth) jsxExpressionDepth ++ ;
166 state = 'code' ;
167 continue ;
168 }
169
170 if (state !== 'code' ) {
171 output += char;
172 if (char === ' \\ ' && next) {
173 output += next;
174 i ++ ;
175 } else if (
176 (state === 'single-quote' && char === "'" ) ||
177 (state === 'double-quote' && char === '"' ) ||
178 (state === 'template' && char === '`' )
179 ) {
180 state = 'code' ;
181 recordSignificant (char);
182 }
183 continue ;
184 }
185
186 const jsxUrlSeparator = options.jsx && char === '/' && next === '/' &&
187 jsxExpressionDepth === 0 &&
188 (output. endsWith ( 'http:' ) ||
189 output. endsWith ( 'https:' ) ||
190 ( /< [A-Za-z] (?: [ ^ >] * [ ^ /] ) ? > [ ^ <] *$ / . test (output. slice (output. lastIndexOf ( ' \n ' ) + 1 )) &&
191 / ^ [\w.-] + \. [A-Za-z] {2,} (?= [:/?#\s<] |$ )/ . test (content. slice (i + 2 ))));
192 const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-' ) &&
193 previousSignificant === lastSignificant &&
194 antePreviousSignificant !== lastSignificant;
195 if (char === '/' && next === '/' && jsxUrlSeparator) {
196 output += '//' ;
197 i ++ ;
198 recordSignificant ( '/' );
199 recordSignificant ( '/' );
200 } else if (char === '/' && next === '/' ) {
201 output += ' ' ;
202 i ++ ;
203 state = 'line-comment' ;
204 } else if (char === '/' && next === '*' ) {
205 output += ' ' ;
206 i ++ ;
207 state = 'block-comment' ;
208 } else if (templateExpressionDepths. length && char === '{' ) {
209 output += char;
210 templateExpressionDepths[templateExpressionDepths. length - 1 ] ++ ;
211 braceKinds. push ( braceKind ());
212 if (jsxExpressionDepth) jsxExpressionDepth ++ ;
213 recordSignificant (char);
214 } else if (templateExpressionDepths. length && char === '}' ) {
215 output += char;
216 const depthIndex = templateExpressionDepths. length - 1 ;
217 templateExpressionDepths[depthIndex] -- ;
218 lastClosedBraceKind = braceKinds. pop () || '' ;
219 if (jsxExpressionDepth) jsxExpressionDepth -- ;
220 recordSignificant (char);
221 if (templateExpressionDepths[depthIndex] === 0 ) {
222 templateExpressionDepths. pop ();
223 state = 'template' ;
224 }
225 } else if (
226 char === '/' &&
227 ( ! lastSignificant ||
228 ( / [=([{!?:;,&|+\-*%^~<>] / . test (lastSignificant) && ! afterPostfixUpdate) ||
229 (lastSignificant === '}' && lastClosedBraceKind === 'block' ) ||
230 (previousSignificant === '=' && lastSignificant === '>' ) ||
231 (currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS . has (currentWord)))
232 ) {
233 output += char;
234 state = 'regex' ;
235 regexCharClass = false ;
236 } else {
237 output += char;
238 const startsJsxExpression = options.jsx && char === '{' && jsxExpressionDepth === 0 &&
239 ( /< [A-Za-z] (?: [ ^ >] * [ ^ /] ) ? > [ ^ <] *$ / . test (output. slice (output. lastIndexOf ( ' \n ' ) + 1 , - 1 )) ||
240 isInsideOpeningJsxTag (output. slice ( 0 , - 1 )));
241 if (char === '{' ) braceKinds. push ( braceKind (startsJsxExpression));
242 else if (char === '}' ) lastClosedBraceKind = braceKinds. pop () || '' ;
243 if (char === '{' && (jsxExpressionDepth || startsJsxExpression)) jsxExpressionDepth ++ ;
244 else if (char === '}' && jsxExpressionDepth) jsxExpressionDepth -- ;
245 recordSignificant (char);
246 if (char === "'" ) state = 'single-quote' ;
247 else if (char === '"' ) state = 'double-quote' ;
248 else if (char === '`' ) state = 'template' ;
249 }
250 }
251
252 return output;
253 }
254
255 function stripCssComments ( content ) {
256 return content. replace ( / \/\* [\s\S] *? \*\/ / g , comment => comment. replace ( / [ ^ \n] / g , ' ' ));
257 }
258
259 function firstOverusedGoogleFont ( text ) {
260 return extractGoogleFontFamilies (text). find ( f => OVERUSED_FONTS . has (f)) || '' ;
261 }
262
263 // CSS named colors whose channels are equal (achromatic). Anything outside
264 // this set falls through to the format parsers, and an unrecognized spelling
265 // stays non-neutral so a real accent is never skipped.
266 const NEUTRAL_COLOR_KEYWORDS = new Set ([
267 'transparent' , 'currentcolor' ,
268 'black' , 'white' , 'gray' , 'grey' , 'silver' ,
269 'dimgray' , 'dimgrey' , 'darkgray' , 'darkgrey' , 'lightgray' , 'lightgrey' ,
270 'gainsboro' , 'whitesmoke' ,
271 ]);
272
273 function hexChannels ( color ) {
274 const long = color. match ( / ^ #( [0-9a-f] {2} )( [0-9a-f] {2} )( [0-9a-f] {2} )(?: [0-9a-f] {2} ) ?$ / i );
275 if (long) return [ parseInt (long[ 1 ], 16 ), parseInt (long[ 2 ], 16 ), parseInt (long[ 3 ], 16 )];
276 const short = color. match ( / ^ #( [0-9a-f] )( [0-9a-f] )( [0-9a-f] )(?: [0-9a-f] ) ?$ / i );
277 if (short) return [ 1 , 2 , 3 ]. map (( i ) => parseInt (short[i] + short[i], 16 ));
278 return null ;
279 }
280
281 /**
282 * Split one box-shadow layer into top-level tokens.
283 *
284 * Whitespace inside parens does not separate tokens: `rgb(0 0 0)` and
285 * `var(--x, 4px)` are each a single value, and splitting them on spaces would
286 * read their innards as separate lengths.
287 */
288 function tokenizeShadowLayer ( layer ) {
289 const tokens = [];
290 let depth = 0 ;
291 let current = '' ;
292 for ( const char of String (layer || '' )) {
293 if (char === '(' ) depth ++ ;
294 else if (char === ')' ) depth -- ;
295 else if (depth === 0 && / \s / . test (char)) {
296 if (current) tokens. push (current);
297 current = '' ;
298 continue ;
299 }
300 current += char;
301 }
302 if (current) tokens. push (current);
303 return tokens;
304 }
305
306 function lastMatch ( text , re ) {
307 const all = [ ... String (text || '' ). matchAll (re)];
308 return all. length ? all[all. length - 1 ] : null ;
309 }
310
311 function isShadowLength ( token ) {
312 return / ^ - ? \d * \. ? \d + (?:px) ?$ / i . test ( String (token || '' ));
313 }
314
315 /**
316 * Neutrality test for colors as written in source CSS.
317 *
318 * shared/color.mjs's isNeutralColor only parses the computed function forms a
319 * browser or jsdom emits (rgb/oklch/lab/...) and deliberately reports every
320 * other spelling as chromatic so an unknown format is never silently skipped.
321 * That default is wrong for authored CSS, where `#000` and `black` are the
322 * normal spellings: calling it directly reports a plain black hairline as a
323 * colored stripe. Handle hex and named neutrals here, then defer.
324 */
325 function isNeutralAuthoredColor ( rawColor ) {
326 const c = String (rawColor || '' ). trim (). toLowerCase ();
327 if ( ! c) return false ;
328 if ( NEUTRAL_COLOR_KEYWORDS . has (c)) return true ;
329 // Modern rgb() takes space-separated channels (`rgb(0 0 0)`). shared/color.mjs
330 // parses only the comma form a browser's getComputedStyle emits, so authored
331 // space-separated neutrals fell through it and reported as chromatic — the
332 // exemption this function exists for, missed. Normalize before delegating.
333 if ( / ^ rgba ? \( / i . test (c)) {
334 const channels = c. match ( / ^ rgba ? \( \s * ( [\d.] + ) [\s,] + ( [\d.] + ) [\s,] + ( [\d.] + )/ i );
335 if (channels) {
336 const values = [ 1 , 2 , 3 ]. map (( i ) => Number (channels[i]));
337 return (Math. max ( ... values) - Math. min ( ... values)) < 30 ;
338 }
339 return isNeutralColor (c);
340 }
341 if ( / ^ (?:hsla ?| oklch | oklab | lab | lch | hwb) \( / i . test (c)) return isNeutralColor (c);
342 const channels = hexChannels (c);
343 if (channels) return (Math. max ( ... channels) - Math. min ( ... channels)) < 30 ;
344 return false ;
345 }
346
347 function isNeutralBorderColor ( str ) {
348 const m = str. match ( /solid \s + ((?:rgba ?| hsla ?| oklch | oklab | lab | lch | hwb | color) \( [ ^ )] * \) | # [0-9a-f] {3,8}\b| [a-z] + )/ i );
349 if ( ! m) return false ;
350 return isNeutralAuthoredColor (m[ 1 ]);
351 }
352
353 const REGEX_MATCHERS = [
354 // --- Side-tab ---
355 { id: 'side-tab' , regex: / \b border- [lrse] -( \d + ) \b / g ,
356 test : ( m , line ) => { const n = + m[ 1 ]; return hasRounded (line) ? n >= 2 : n >= 4 ; },
357 fmt : ( m ) => m[ 0 ] },
358 { id: 'side-tab' , regex: /border-(?:left | right) \s * : \s * ( \d + )px \s + solid [ ^ ;] * / gi ,
359 test : ( m , line ) => { if ( isSafeElement (line)) return false ; if ( isNeutralBorderColor (m[ 0 ])) return false ; const n = + m[ 1 ]; return hasBorderRadius (line) ? n >= 2 : n >= 3 ; },
360 fmt : ( m ) => m[ 0 ]. replace ( / \s * ; ? \s *$ / , '' ) },
361 { id: 'side-tab' , regex: /border-(?:left | right)-width \s * : \s * ( \d + )px/ gi ,
362 test : ( m , line ) => ! isSafeElement (line) && + m[ 1 ] >= 3 ,
363 fmt : ( m ) => m[ 0 ] },
364 { id: 'side-tab' , regex: /border-inline-(?:start | end) \s * : \s * ( \d + )px \s + solid/ gi ,
365 test : ( m , line ) => ! isSafeElement (line) && + m[ 1 ] >= 3 ,
366 fmt : ( m ) => m[ 0 ] },
367 { id: 'side-tab' , regex: /border-inline-(?:start | end)-width \s * : \s * ( \d + )px/ gi ,
368 test : ( m , line ) => ! isSafeElement (line) && + m[ 1 ] >= 3 ,
369 fmt : ( m ) => m[ 0 ] },
370 { id: 'side-tab' , regex: /border(?:Left | Right) \s * [:=]\s * ["'`] ( \d + )px \s + solid/ g ,
371 test : ( m ) => + m[ 1 ] >= 3 ,
372 fmt : ( m ) => m[ 0 ] },
373 // --- Border accent on rounded ---
374 { id: 'border-accent-on-rounded' , regex: / \b border- [tb] -( \d + ) \b / g ,
375 test : ( m , line ) => hasRounded (line) && + m[ 1 ] >= 1 ,
376 fmt : ( m ) => m[ 0 ] },
377 { id: 'border-accent-on-rounded' , regex: /border-(?:top | bottom) \s * : \s * ( \d + )px \s + solid/ gi ,
378 test : ( m , line ) => + m[ 1 ] >= 3 && hasBorderRadius (line),
379 fmt : ( m ) => m[ 0 ] },
380 // --- Overused font ---
381 { 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 ,
382 test : () => true ,
383 fmt : ( m ) => m[ 0 ] },
384 { id: 'overused-font' , regex: /fonts \. googleapis \. com \/ css2 ? \? [ ^ "'\s)<>] * / gi ,
385 test : ( m ) => {
386 m.overusedGoogleFont = firstOverusedGoogleFont (m[ 0 ]);
387 return Boolean (m.overusedGoogleFont);
388 },
389 fmt : ( m ) => `Google Fonts: ${ m . overusedGoogleFont || firstOverusedGoogleFont ( m [ 0 ]) }` },
390 // --- Gradient text ---
391 { id: 'gradient-text' , regex: /background-clip \s * : \s * text | -webkit-background-clip \s * : \s * text/ gi ,
392 test : ( m , line ) => /gradient/ i . test (line),
393 fmt : () => 'background-clip: text + gradient' },
394 // --- Gradient text (Tailwind) ---
395 { id: 'gradient-text' , regex: / \b bg-clip-text \b / g ,
396 test : ( m , line ) => / \b bg-gradient-to-/ i . test (line),
397 fmt : () => 'bg-clip-text + bg-gradient' },
398 // --- Tailwind gray on colored bg ---
399 { id: 'gray-on-color' , regex: / \b text-(?:gray | slate | zinc | neutral | stone)-( \d + ) \b / g ,
400 test : ( m , line ) => / \b bg-(?:red | orange | amber | yellow | lime | green | emerald | teal | cyan | sky | blue | indigo | violet | purple | fuchsia | pink | rose)- \d +\b / . test (line),
401 fmt : ( m , line ) => { const bg = line. match ( / \b bg-(?: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 ] || '?'}` ; } },
402 // --- Tailwind AI palette ---
403 { id: 'ai-color-palette' , regex: / \b text-(?:purple | violet | indigo)-( \d + ) \b / g ,
404 test : ( m , line ) => / \b text-(?: [2-9] xl | [3-9] xl) \b| <h [1-3] / i . test (line),
405 fmt : ( m ) => `${ m [ 0 ] } on heading` },
406 { id: 'ai-color-palette' , regex: / \b from-(?:purple | violet | indigo)-( \d + ) \b / g ,
407 test : ( m , line ) => / \b to-(?:purple | violet | indigo | blue | cyan | pink | fuchsia)- \d +\b / . test (line),
408 fmt : ( m ) => `${ m [ 0 ] } gradient` },
409 // --- Bounce/elastic easing ---
410 { id: 'bounce-easing' , regex: / \b animate-bounce \b / g ,
411 test : () => true ,
412 fmt : () => 'animate-bounce (Tailwind)' },
413 { id: 'bounce-easing' , regex: /animation(?:-name) ? \s * : \s * ( [ ^ ;{}] * (?:bounce | elastic | wobble | jiggle | spring) [ ^ ;{}] * )/ gi ,
414 test : () => true ,
415 fmt : ( m ) => {
416 const token = m[ 1 ]
417 . split ( / [,\s] + / )
418 . find (( part ) => /bounce | elastic | wobble | jiggle | spring/ i . test (part));
419 return `animation: ${ token || m [ 1 ]. trim () }` ;
420 } },
421 { id: 'bounce-easing' , regex: /cubic-bezier \( \s * ( [\d.-] + ) \s * , \s * ( [\d.-] + ) \s * , \s * ( [\d.-] + ) \s * , \s * ( [\d.-] + ) \s * \) / g ,
422 test : ( m ) => {
423 const y1 = parseFloat (m[ 2 ]), y2 = parseFloat (m[ 4 ]);
424 return y1 < - 0.1 || y1 > 1.1 || y2 < - 0.1 || y2 > 1.1 ;
425 },
426 fmt : ( m ) => `cubic-bezier(${ m [ 1 ] }, ${ m [ 2 ] }, ${ m [ 3 ] }, ${ m [ 4 ] })` },
427 // --- Layout property transition ---
428 { id: 'layout-transition' , regex: /transition \s * : \s * ( [ ^ ;{}] + )/ gi ,
429 test : ( m ) => {
430 const val = m[ 1 ]. toLowerCase ();
431 if ( / \b all \b / . test (val)) return false ;
432 return / \b (?:(?:max | min)-) ? (?:width | height) \b|\b padding \b|\b margin \b / . test (val);
433 },
434 fmt : ( m ) => {
435 const found = m[ 1 ]. match ( / \b (?:(?:max | min)-) ? (?:width | height) \b|\b padding(?:-(?:top | right | bottom | left)) ?\b|\b margin(?:-(?:top | right | bottom | left)) ?\b / gi );
436 return `transition: ${ found ? found . join ( ', ' ) : m [ 1 ]. trim () }` ;
437 } },
438 { id: 'layout-transition' , regex: /transition-property \s * : \s * ( [ ^ ;{}] + )/ gi ,
439 test : ( m ) => {
440 const val = m[ 1 ]. toLowerCase ();
441 if ( / \b all \b / . test (val)) return false ;
442 return / \b (?:(?:max | min)-) ? (?:width | height) \b|\b padding \b|\b margin \b / . test (val);
443 },
444 fmt : ( m ) => {
445 const found = m[ 1 ]. match ( / \b (?:(?:max | min)-) ? (?:width | height) \b|\b padding(?:-(?:top | right | bottom | left)) ?\b|\b margin(?:-(?:top | right | bottom | left)) ?\b / gi );
446 return `transition-property: ${ found ? found . join ( ', ' ) : m [ 1 ]. trim () }` ;
447 } },
448 // --- Broken image: src="" or src="#" or src=" " ---
449 { id: 'broken-image' , regex: /<img \b [ ^ >] *?\b src \s * = \s * (?:"" | '' | " \s + " | ' \s + ' | "#" | '#')/ gi ,
450 test : () => true ,
451 fmt : ( m ) => m[ 0 ]. slice ( 0 , 100 ) },
452 // --- Broken image: <img> with no src attribute at all ---
453 { id: 'broken-image' , regex: /<img \b (?:(?! \b src \s * =) [ ^ >] ) * >/ gi ,
454 test : ( m ) => ! / \b src \s * =/ i . test (m[ 0 ]),
455 fmt : ( m ) => m[ 0 ]. slice ( 0 , 100 ) },
456 ];
457
458 const REGEX_ANALYZERS = [
459 // Flat type hierarchy
460 ( content , filePath ) => {
461 const sizes = new Set ();
462 const REM = 16 ;
463 let m;
464 const sizeRe = /font-size \s * : \s * ( [\d.] + )(px | rem | em) \b / gi ;
465 while ((m = sizeRe. exec (content)) !== null ) {
466 const px = m[ 2 ] === 'px' ? + m[ 1 ] : + m[ 1 ] * REM ;
467 if (px > 0 && px < 200 ) sizes. add (Math. round (px * 10 ) / 10 );
468 }
469 const clampRe = /font-size \s * : \s * clamp \( \s * ( [\d.] + )(px | rem | em) \s * , \s * [ ^ ,] + , \s * ( [\d.] + )(px | rem | em) \s * \) / gi ;
470 while ((m = clampRe. exec (content)) !== null ) {
471 sizes. add (Math. round ((m[ 2 ] === 'px' ? + m[ 1 ] : + m[ 1 ] * REM ) * 10 ) / 10 );
472 sizes. add (Math. round ((m[ 4 ] === 'px' ? + m[ 3 ] : + m[ 3 ] * REM ) * 10 ) / 10 );
473 }
474 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 };
475 for ( const [ cls , px ] of Object. entries ( TW )) { if ( new RegExp ( ` \\ b${ cls } \\ b` ). test (content)) sizes. add (px); }
476 if (sizes.size < 3 ) return [];
477 const sorted = [ ... sizes]. sort (( a , b ) => a - b);
478 const ratio = sorted[sorted. length - 1 ] / sorted[ 0 ];
479 if (ratio >= 2.0 ) return [];
480 const lines = content. split ( ' \n ' );
481 let line = 1 ;
482 for ( let i = 0 ; i < lines. length ; i ++ ) { if ( /font-size/ i . test (lines[i]) || / \b text-(?:xs | sm | base | lg | xl | \d )/ i . test (lines[i])) { line = i + 1 ; break ; } }
483 return [ finding ( 'flat-type-hierarchy' , filePath, `Sizes: ${ sorted . map ( s => s + 'px' ). join ( ', ' ) } (ratio ${ ratio . toFixed ( 1 ) }:1)` , line)];
484 },
485 // Monotonous spacing (regex)
486 ( content , filePath ) => {
487 const vals = [];
488 let m;
489 const pxRe = /(?:padding | margin)(?:-(?:top | right | bottom | left)) ? \s * : \s * ( \d + )px/ gi ;
490 while ((m = pxRe. exec (content)) !== null ) { const v = + m[ 1 ]; if (v > 0 && v < 200 ) vals. push (v); }
491 const remRe = /(?:padding | margin)(?:-(?:top | right | bottom | left)) ? \s * : \s * ( [\d.] + )rem/ gi ;
492 while ((m = remRe. exec (content)) !== null ) { const v = Math. round ( parseFloat (m[ 1 ]) * 16 ); if (v > 0 && v < 200 ) vals. push (v); }
493 const gapRe = /gap \s * : \s * ( \d + )px/ gi ;
494 while ((m = gapRe. exec (content)) !== null ) vals. push ( + m[ 1 ]);
495 const twRe = / \b (?:p | px | py | pt | pb | pl | pr | m | mx | my | mt | mb | ml | mr | gap)-( \d + ) \b / g ;
496 while ((m = twRe. exec (content)) !== null ) vals. push ( + m[ 1 ] * 4 );
497 const rounded = vals. map ( v => Math. round (v / 4 ) * 4 );
498 if (rounded. length < 10 ) return [];
499 const counts = {};
500 for ( const v of rounded) counts[v] = (counts[v] || 0 ) + 1 ;
501 const maxCount = Math. max ( ... Object. values (counts));
502 const pct = maxCount / rounded. length ;
503 const unique = [ ...new Set (rounded)]. filter ( v => v > 0 );
504 if (pct <= 0.6 || unique. length > 3 ) return [];
505 const dominant = Object. entries (counts). sort (( a , b ) => b[ 1 ] - a[ 1 ])[ 0 ][ 0 ];
506 return [ finding ( 'monotonous-spacing' , filePath, `~${ dominant }px used ${ maxCount }/${ rounded . length } times (${ Math . round ( pct * 100 ) }%)` )];
507 },
508 // Em-dash overuse (ADVISORY): the AI cadence tell is em-dash *saturation*,
509 // not the occasional dash. Humans use em-dashes legitimately, so this rule is
510 // advisory (surfaced separately, never a failure, hook-skipped by default) and
511 // its threshold is deliberately conservative. Two gates must both hold:
512 // 1. Absolute floor of EM_DASH_FLOOR (8) dashes — a page with a handful
513 // never fires, no matter how short.
514 // 2. Density: at least one dash per EM_DASH_CHARS_PER_DASH (500) characters
515 // of body text, so a long article that uses eight across several thousand
516 // words is left alone while a short, dash-per-clause landing page is not.
517 // Raised from the old flat 5-dash floor, which fired on ordinary long prose.
518 //
519 // stripHtmlToText drops tags but leaves character-entity escapes intact, so
520 // a model that writes `—`, `—`, or `—` renders an em-dash
521 // the counter never saw. Decode the em-dash entities (named, zero-padded
522 // decimal, upper/lower hex) to the literal glyph first. En-dash entities are
523 // deliberately left alone: the rule counts em-dashes, and the literal `–`
524 // was never counted either.
525 ( content , filePath ) => {
526 const text = stripHtmlToText (content)
527 . replace ( /— | � * 8212; | � * 2014;/ gi , '—' );
528 let count = 0 ;
529 const re = / [—] | --(?= \S )/ g ;
530 while (re. exec (text) !== null ) count ++ ;
531 if (count < EM_DASH_FLOOR ) return [];
532 // Saturation gate: dashes must be dense in the prose, not sprinkled through
533 // a long document. textLength <= count * chars-per-dash means the density is
534 // at or above the threshold.
535 if (text. length > count * EM_DASH_CHARS_PER_DASH ) return [];
536 return [ finding ( 'em-dash-overuse' , filePath, `${ count } em-dashes in body text` )];
537 },
538 // Marketing buzzwords: SaaS phrase list
539 ( content , filePath ) => {
540 const text = stripHtmlToText (content);
541 const lower = text. toLowerCase ();
542 const BUZZWORDS = [
543 'streamline your' , 'empower your' , 'supercharge your' ,
544 'unleash your' , 'unleash the power' , 'leverage the power' ,
545 'built for the modern' , 'trusted by leading' , 'trusted by the world' ,
546 'best-in-class' , 'industry-leading' , 'world-class' , 'enterprise-grade' ,
547 'next-generation' , 'cutting-edge' , 'transform your business' ,
548 'revolutionize' , 'game-changer' , 'game changing' ,
549 'mission-critical' , 'best of breed' , 'future-proof' , 'future proof' ,
550 'seamless experience' , 'seamlessly integrate' ,
551 'drive engagement' , 'drive growth' , 'drive results' ,
552 'harness the power' ,
553 ];
554 let count = 0 ;
555 let firstSample = '' ;
556 for ( const phrase of BUZZWORDS ) {
557 let from = 0 ;
558 while ( true ) {
559 const idx = lower. indexOf (phrase, from);
560 if (idx === - 1 ) break ;
561 count ++ ;
562 if ( ! firstSample) {
563 firstSample = text. slice (Math. max ( 0 , idx - 12 ), Math. min (text. length , idx + phrase. length + 12 )). trim ();
564 }
565 from = idx + phrase. length ;
566 }
567 }
568 if (count === 0 ) return [];
569 return [ finding ( 'marketing-buzzword' , filePath, `${ count } buzzword phrase${ count === 1 ? '' : 's'}: "${ firstSample }"` )];
570 },
571 // Aphoristic cadence: manufactured-contrast + short-rebuttal
572 ( content , filePath ) => {
573 const text = stripHtmlToText (content);
574 const NOT_A_RE = / \b Not an ? [a-z][ ^ .!?] {1,40} [.!]\s + [A-Z][ ^ .!?] {1,60} [.!] / g ;
575 const SHORT_REBUTTAL_RE = / \b [A-Z][ ^ .!?] {4,80} [.!]\s + (No | Just) \s + [a-z][ ^ .!?] {2,60} [.!] / g ;
576 let count = 0 ;
577 let firstSample = '' ;
578 let m;
579 NOT_A_RE .lastIndex = 0 ;
580 while ((m = NOT_A_RE . exec (text)) !== null ) {
581 count ++ ;
582 if ( ! firstSample) firstSample = m[ 0 ]. trim (). slice ( 0 , 80 );
583 }
584 SHORT_REBUTTAL_RE .lastIndex = 0 ;
585 while ((m = SHORT_REBUTTAL_RE . exec (text)) !== null ) {
586 count ++ ;
587 if ( ! firstSample) firstSample = m[ 0 ]. trim (). slice ( 0 , 80 );
588 }
589 if (count < 3 ) return [];
590 return [ finding ( 'aphoristic-cadence' , filePath, `${ count } aphoristic constructions: "${ firstSample }"` )];
591 },
592 // Dark glow / chromatic halo shadows (page-level). Shared scanner handles
593 // any color format, single-level var() resolution, zero-offset halos on
594 // any background, and text-shadow glows.
595 ( content , filePath ) => {
596 const hits = scanCssTextForGlow (content);
597 if (hits. length === 0 ) return [];
598 const lines = content. substring ( 0 , hits[ 0 ].index). split ( ' \n ' );
599 return [ finding ( 'dark-glow' , filePath, hits[ 0 ].snippet, lines. length )];
600 },
601 // Radial-gradient background halo on a dark page (the gradient sibling
602 // of the dark-glow shadow tell).
603 ( content , filePath ) => {
604 const hits = scanCssTextForRadialHalo (content);
605 if (hits. length === 0 ) return [];
606 const lines = content. substring ( 0 , hits[ 0 ].index). split ( ' \n ' );
607 return [ finding ( 'radial-halo' , filePath, hits[ 0 ].snippet, lines. length )];
608 },
609 // Auto-scrolling marquees (<marquee> or infinite horizontal loop
610 // animations).
611 ( content , filePath ) => scanCssTextForMarquee (content). map ( hit => finding ( 'marquee' , filePath, hit.snippet)),
612 ];
613
614 // ---------------------------------------------------------------------------
615 // Structural CSS checks used by source files whose styles are not parsed by
616 // the static HTML engine.
617 // ---------------------------------------------------------------------------
618
619 const CHROMATIC_SHADOW_TOKEN_RE = /(?: ^| -)(?:accent | kinpaku | patina | gold | red | orange | amber | yellow | lime | green | emerald | teal | cyan | blue | indigo | violet | purple | magenta | pink | rose | coral | aqua | mint | burgundy | crimson | scarlet)(?:- |$ )/ i ;
620
621 function insetStripeColorIsChromatic ( rawColor ) {
622 const color = String (rawColor || '' ). trim (). replace ( / \s * !important \s *$ / i , '' );
623 if ( / ^ (?:currentcolor | transparent | inherit | unset) $ / i . test (color)) return false ;
624 const variable = color. match ( / ^ var \( \s * (-- [\w-] + )/ i );
625 if (variable) return CHROMATIC_SHADOW_TOKEN_RE . test (variable[ 1 ]);
626 if ( ! / ^ (?:# | rgba ? \( | hsla ? \( | hwb \( | oklch \( | oklab \( | lch \( | lab \( | color \( | [a-z] +$ )/ i . test (color)) return false ;
627 return ! isNeutralAuthoredColor (color);
628 }
629
630 /**
631 * Blank out comment bodies while preserving every byte offset (and therefore
632 * every line number) so commented-out CSS is not scanned as live rules.
633 */
634 function blankCssComments ( css ) {
635 return css. replace ( / \/\* [\s\S] *? \*\/ / g , ( block ) => block. replace ( / [ ^ \n] / g , ' ' ));
636 }
637
638 function scanInsetStripeCss ( rawContent , filePath , lineOffset = 0 ) {
639 const content = blankCssComments (rawContent);
640 const findings = [];
641 const ruleRe = /( [ ^ {};] + ) \{ ( [ ^ {}] * ) \} / g ;
642 let match;
643 // Deriving each line with content.slice(0, offset).split('\n') re-scans the
644 // whole prefix per rule, which is O(n^2) on a large stylesheet. Rule matches
645 // arrive in source order, so carry a monotonic cursor instead: one pass total.
646 let scanOffset = 0 ;
647 let scanLine = 1 ;
648 const lineAtOffset = ( offset ) => {
649 while (scanOffset < offset) {
650 if (content[scanOffset] === ' \n ' ) scanLine ++ ;
651 scanOffset ++ ;
652 }
653 return scanLine;
654 };
655 while ((match = ruleRe. exec (content)) !== null ) {
656 // The selector group is `[^{};]+`, which greedily absorbs the whitespace and
657 // newlines trailing the previous rule. Advance past that run before deriving
658 // the line, or every rule after the first reports the preceding line.
659 const selectorStart = match.index + (match[ 1 ]. length - match[ 1 ]. trimStart (). length );
660 const selector = match[ 1 ]. trim (). replace ( / \s + / g , ' ' );
661 if ( ! selector) continue ;
662 if ( /:(?:hover | focus | focus-visible | focus-within | active | checked | target) \b / i . test (selector)) continue ;
663 if ( / \[ aria-selected \s * [*^$|~] ? = \s * ["'] ? true/ i . test (selector)) continue ;
664 if ( / \[ aria-current(?! \s * [*^$|~] ? = \s * ["'] ? false)/ i . test (selector)) continue ;
665 if ( /(?: ^| [\s._[-] )(?:active | current | selected)(?! [\w] )/ i . test (selector)) continue ;
666 if ( /(?: ^| [\s>+~,(] )(?:button | hr | tr | td | th | table | blockquote | pre | code)(?! [\w-] )/ i . test (selector)) continue ;
667
668 // Read the last of a repeated declaration, not the first: that is what the
669 // cascade paints. Taking the first both flagged stripes that a later
670 // `box-shadow: none` had cancelled and missed stripes that overrode an
671 // earlier value, and mis-skipped rules whose narrow width was overridden.
672 const width = lastMatch (match[ 2 ], /(?: ^| ;) \s * (?:width | inline-size) \s * : \s * ( \d + (?: \. \d + ) ? )px/ gi );
673 if (width && Number (width[ 1 ]) <= 40 ) continue ;
674 const declaration = lastMatch (match[ 2 ], /(?: ^| ;) \s * box-shadow \s * : \s * ( [ ^ ;] + )/ gi );
675 if ( ! declaration || ! / \b inset \b / i . test (declaration[ 1 ])) continue ;
676 // `!important` qualifies the declaration, not the shadow value, so strip it
677 // before the layers are read. Tokenizing split it into its own token, which
678 // made the color count wrong and silently stopped flagging stripes declared
679 // with it — a shape the previous regex handled.
680 const shadowValue = declaration[ 1 ]. replace ( / \s * ! \s * important \s *$ / i , '' ). trim ();
681
682 for ( const rawLayer of shadowValue. split ( /,(?! [ ^ (] * \) )/ )) {
683 const layer = rawLayer. trim ();
684 // Parse the layer by its grammar rather than by one spelling of it.
685 // A box-shadow layer is `inset? && <length>{2,4} && <color>?` in any
686 // order, so `inset 4px 0 red`, `4px 0 0 red inset`, and `red 4px 0 inset`
687 // all paint the same stripe. Matching a fixed token order missed three
688 // valid spellings in a row; enumerate the tokens instead. Tokenizing must
689 // respect parens: `rgb(0 0 0)` is one color token, and splitting it on
690 // whitespace would read its channels as lengths.
691 const tokens = tokenizeShadowLayer (layer);
692 if ( ! tokens. some (( token ) => / ^ inset $ / i . test (token))) continue ;
693 const rest = tokens. filter (( token ) => ! / ^ inset $ / i . test (token));
694 const lengths = rest. filter (isShadowLength);
695 const colors = rest. filter (( token ) => ! isShadowLength (token));
696 // Only the two offsets are required; omitted blur/spread default to 0,
697 // which is exactly the stripe shape. More than one non-length token is a
698 // layer shape we do not claim to understand, so leave it alone.
699 if (lengths. length < 2 || lengths. length > 4 || colors. length !== 1 ) continue ;
700 const values = lengths. map (( token ) => ({
701 n: Number (token. replace ( /px $ / i , '' )),
702 hasPx: /px $ / i . test (token),
703 }));
704 const x = values[ 0 ];
705 const y = values[ 1 ];
706 const blur = values[ 2 ] ? values[ 2 ].n : 0 ;
707 const spread = values[ 3 ] ? values[ 3 ].n : 0 ;
708 if ((x.n !== 0 && ! x.hasPx) || (y.n !== 0 && ! y.hasPx) || blur !== 0 || spread !== 0 ) continue ;
709 const ax = Math. abs (x.n);
710 const ay = Math. abs (y.n);
711 if ( ! ((ax >= 3 && ax <= 12 && ay === 0 ) || (ay >= 3 && ay <= 12 && ax === 0 ))) continue ;
712 if ( ! insetStripeColorIsChromatic (colors[ 0 ])) continue ;
713 const edge = ay === 0 ? (x.n > 0 ? 'left' : 'right' ) : (y.n > 0 ? 'top' : 'bottom' );
714 const line = lineOffset + lineAtOffset (selectorStart);
715 findings. push ( finding ( 'side-tab' , filePath, `${ selector } — inset box-shadow ${ ay === 0 ? ax : ay }px stripe (${ edge })` , line));
716 break ;
717 }
718 }
719 return findings;
720 }
721
722 // ---------------------------------------------------------------------------
723 // Style block extraction (Astro/Vue/Svelte <style> blocks)
724 // ---------------------------------------------------------------------------
725
726 function extractStyleBlocks ( content , ext ) {
727 ext = ext. toLowerCase ();
728 if (ext !== '.astro' && ext !== '.vue' && ext !== '.svelte' ) return [];
729 const blocks = [];
730 const re = /<style [ ^ >] * >( [\s\S] *? )< \/ style>/ gi ;
731 let m;
732 while ((m = re. exec (content)) !== null ) {
733 const before = content. substring ( 0 , m.index);
734 const startLine = before. split ( ' \n ' ). length + 1 ;
735 blocks. push ({ content: m[ 1 ], startLine });
736 }
737 return blocks;
738 }
739
740 // ---------------------------------------------------------------------------
741 // CSS-in-JS extraction (styled-components, emotion)
742 // ---------------------------------------------------------------------------
743
744 const CSS_IN_JS_EXTENSIONS = new Set ([ '.js' , '.ts' , '.jsx' , '.tsx' ]);
745
746 function findQuotedStringEnd ( content , start , quote ) {
747 for ( let cursor = start + 1 ; cursor < content. length ; cursor ++ ) {
748 if (content[cursor] === ' \\ ' ) cursor ++ ;
749 else if (content[cursor] === quote) return cursor;
750 }
751 return - 1 ;
752 }
753
754 function findRegexLiteralEnd ( content , start ) {
755 let inCharacterClass = false ;
756 for ( let cursor = start + 1 ; cursor < content. length ; cursor ++ ) {
757 const char = content[cursor];
758 if (char === ' \\ ' ) {
759 cursor ++ ;
760 } else if (char === '[' ) {
761 inCharacterClass = true ;
762 } else if (char === ']' ) {
763 inCharacterClass = false ;
764 } else if (char === '/' && ! inCharacterClass) {
765 while ( / [A-Za-z] / . test (content[cursor + 1 ] || '' )) cursor ++ ;
766 return cursor;
767 } else if (char === ' \n ' || char === ' \r ' ) {
768 return - 1 ;
769 }
770 }
771 return - 1 ;
772 }
773
774 function findTemplateExpressionEnd ( content , start ) {
775 let depth = 1 ;
776 let lastSignificant = '' ;
777 let previousSignificant = '' ;
778 let antePreviousSignificant = '' ;
779 let currentWord = '' ;
780 let currentWordPrefix = '' ;
781 let wordSeparated = false ;
782 let lastClosedBraceKind = '' ;
783 const braceKinds = [];
784
785 const braceKind = () => (
786 lastSignificant === ')' ||
787 lastSignificant === ';' ||
788 lastSignificant === '}' ||
789 (previousSignificant === '=' && lastSignificant === '>' ) ||
790 BLOCK_BRACE_PREFIX_KEYWORDS . has (currentWord)
791 ? 'block'
792 : 'expression'
793 );
794
795 const recordSignificant = ( char ) => {
796 if ( / \s / . test (char)) {
797 wordSeparated = true ;
798 return ;
799 }
800 const isWordChar = / [\w$] / . test (char);
801 if (isWordChar && (wordSeparated || ! currentWord)) {
802 currentWord = '' ;
803 currentWordPrefix = lastSignificant;
804 } else if ( ! isWordChar) {
805 currentWordPrefix = '' ;
806 }
807 wordSeparated = false ;
808 antePreviousSignificant = previousSignificant;
809 previousSignificant = lastSignificant;
810 lastSignificant = char;
811 currentWord = isWordChar ? currentWord + char : '' ;
812 };
813
814 for ( let cursor = start; cursor < content. length ; cursor ++ ) {
815 const char = content[cursor];
816 const next = content[cursor + 1 ];
817 const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-' ) &&
818 previousSignificant === lastSignificant &&
819 antePreviousSignificant !== lastSignificant;
820 if (char === "'" || char === '"' ) {
821 cursor = findQuotedStringEnd (content, cursor, char);
822 if (cursor === - 1 ) return - 1 ;
823 recordSignificant ( ')' );
824 } else if (char === '/' && next === '/' ) {
825 const lineEnd = content. indexOf ( ' \n ' , cursor + 2 );
826 if (lineEnd === - 1 ) return - 1 ;
827 cursor = lineEnd;
828 } else if (char === '/' && next === '*' ) {
829 const commentEnd = content. indexOf ( '*/' , cursor + 2 );
830 if (commentEnd === - 1 ) return - 1 ;
831 cursor = commentEnd + 1 ;
832 } else if (
833 char === '/' &&
834 ( ! lastSignificant ||
835 ( / [=([{!?:;,&|+\-*%^~<>] / . test (lastSignificant) && ! afterPostfixUpdate) ||
836 (lastSignificant === '}' && lastClosedBraceKind === 'block' ) ||
837 (previousSignificant === '=' && lastSignificant === '>' ) ||
838 (currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS . has (currentWord)))
839 ) {
840 cursor = findRegexLiteralEnd (content, cursor);
841 if (cursor === - 1 ) return - 1 ;
842 recordSignificant ( ')' );
843 } else if (char === '`' ) {
844 cursor = findTemplateLiteralEnd (content, cursor);
845 if (cursor === - 1 ) return - 1 ;
846 recordSignificant ( ')' );
847 } else if (char === '{' ) {
848 depth ++ ;
849 braceKinds. push ( braceKind ());
850 recordSignificant (char);
851 } else if (char === '}' ) {
852 depth -- ;
853 if (depth === 0 ) return cursor;
854 lastClosedBraceKind = braceKinds. pop () || '' ;
855 recordSignificant (char);
856 } else {
857 recordSignificant (char);
858 }
859 }
860 return - 1 ;
861 }
862
863 function findTemplateLiteralEnd ( content , start ) {
864 for ( let cursor = start + 1 ; cursor < content. length ; cursor ++ ) {
865 const char = content[cursor];
866 if (char === ' \\ ' ) {
867 cursor ++ ;
868 } else if (char === '`' ) {
869 return cursor;
870 } else if (char === '$' && content[cursor + 1 ] === '{' ) {
871 cursor = findTemplateExpressionEnd (content, cursor + 2 );
872 if (cursor === - 1 ) return - 1 ;
873 }
874 }
875 return - 1 ;
876 }
877
878 function findCSSinJSTemplates ( content ) {
879 const templates = [];
880 const tagRe = / \b (?:styled(?: \. \w +| \( [ ^ )] + \) ) | css)/ g ;
881 let match;
882 while ((match = tagRe. exec (content)) !== null ) {
883 let cursor = match.index + match[ 0 ]. length ;
884 while ( / \s / . test (content[cursor] || '' )) cursor ++ ;
885
886 if (content[cursor] === '<' ) {
887 let depth = 0 ;
888 while (cursor < content. length ) {
889 const char = content[cursor];
890 if (char === '<' ) depth ++ ;
891 else if (char === '>' && content[cursor - 1 ] !== '=' ) depth -- ;
892 cursor ++ ;
893 if (depth === 0 ) break ;
894 }
895 if (depth !== 0 ) continue ;
896 while ( / \s / . test (content[cursor] || '' )) cursor ++ ;
897 }
898
899 if (content[cursor] !== '`' ) continue ;
900 const contentStart = cursor + 1 ;
901 cursor = findTemplateLiteralEnd (content, cursor);
902 if (cursor === - 1 ) continue ;
903
904 templates. push ({
905 tagStart: match.index,
906 contentStart,
907 contentEnd: cursor,
908 });
909 tagRe.lastIndex = cursor + 1 ;
910 }
911 return templates;
912 }
913
914 function extractCSSinJS ( content , ext ) {
915 ext = ext. toLowerCase ();
916 if ( ! CSS_IN_JS_EXTENSIONS . has (ext)) return [];
917 return findCSSinJSTemplates (content). map (( template ) => {
918 const before = content. substring ( 0 , template.tagStart);
919 const startLine = before. split ( ' \n ' ). length ;
920 return {
921 content: content. slice (template.contentStart, template.contentEnd),
922 startLine,
923 };
924 });
925 }
926
927 function stripCssInJsComments ( content , ext ) {
928 if ( ! CSS_IN_JS_EXTENSIONS . has (ext. toLowerCase ())) return content;
929 const templates = findCSSinJSTemplates (content);
930 let output = '' ;
931 let cursor = 0 ;
932 for ( const template of templates) {
933 output += content. slice (cursor, template.contentStart);
934 output += stripCssComments (content. slice (template.contentStart, template.contentEnd));
935 cursor = template.contentEnd;
936 }
937 return output + content. slice (cursor);
938 }
939
940 function runRegexMatchers ( lines , filePath , lineOffset = 0 , blockContext = null , options = {}) {
941 const { profile , phase = 'regex-matchers' } = options || {};
942 const findings = [];
943 if ( ! profile) {
944 for ( const matcher of REGEX_MATCHERS ) {
945 for ( let i = 0 ; i < lines. length ; i ++ ) {
946 const line = lines[i];
947 matcher.regex.lastIndex = 0 ;
948 let m;
949 while ((m = matcher.regex. exec (line)) !== null ) {
950 // For extracted blocks, use nearby lines as context for multi-line CSS patterns
951 const context = blockContext
952 ? lines. slice (Math. max ( 0 , i - 3 ), Math. min (lines. length , i + 4 )). join ( ' ' )
953 : line;
954 if (matcher. test (m, context)) {
955 findings. push ( finding (matcher.id, filePath, matcher. fmt (m, context), i + 1 + lineOffset));
956 }
957 }
958 }
959 }
960 return findings;
961 }
962
963 for ( const matcher of REGEX_MATCHERS ) {
964 const matcherFindings = profileFindings (profile, {
965 engine: 'regex' ,
966 phase,
967 ruleId: matcher.id,
968 target: filePath,
969 }, () => {
970 const matches = [];
971 for ( let i = 0 ; i < lines. length ; i ++ ) {
972 const line = lines[i];
973 matcher.regex.lastIndex = 0 ;
974 let m;
975 while ((m = matcher.regex. exec (line)) !== null ) {
976 // For extracted blocks, use nearby lines as context for multi-line CSS patterns
977 const context = blockContext
978 ? lines. slice (Math. max ( 0 , i - 3 ), Math. min (lines. length , i + 4 )). join ( ' ' )
979 : line;
980 if (matcher. test (m, context)) {
981 matches. push ( finding (matcher.id, filePath, matcher. fmt (m, context), i + 1 + lineOffset));
982 }
983 }
984 }
985 return matches;
986 });
987 findings. push ( ... matcherFindings);
988 }
989 return findings;
990 }
991
992 /** Page-level analyzers that scan rendered text content (em-dash use,
993 * buzzword phrases, aphoristic cadence).
994 * These are detector-agnostic — they work on any HTML/text source
995 * and don't need a parsed DOM. Exported so detectHtml can call them
996 * for `.html` files (which otherwise skip the regex engine). */
997 const TEXT_CONTENT_ANALYZER_IDS = [
998 'em-dash-overuse' ,
999 'marketing-buzzword' ,
1000 'aphoristic-cadence' ,
1001 ];
1002
1003 function runTextContentAnalyzers ( content , filePath , options = {}) {
1004 const profile = options?.profile;
1005 if ( ! shouldRunPageAnalyzers (content, filePath)) return [];
1006 // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS
1007 // (single-font's removal on 2026-07-29 shifted every index down one).
1008 const findings = [];
1009 for ( let i = 0 ; i < TEXT_CONTENT_ANALYZER_IDS . length ; i ++ ) {
1010 const analyzer = REGEX_ANALYZERS [ 2 + i];
1011 const ruleId = TEXT_CONTENT_ANALYZER_IDS [i];
1012 findings. push ( ... profileFindings (profile, {
1013 engine: 'regex' ,
1014 phase: 'text-content' ,
1015 ruleId,
1016 target: filePath,
1017 }, () => analyzer (content, filePath)));
1018 }
1019 return findings;
1020 }
1021
1022 function detectText ( content , filePath , options = {}) {
1023 const profile = options?.profile;
1024 const findings = [];
1025 const ext = extFromFilePath (filePath);
1026 const commentStrippedSource = JS_SOURCE_EXTS . has (ext) ? stripJsComments (content, {
1027 jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx' ,
1028 }) : content;
1029 const source = stripCssInJsComments (commentStrippedSource, ext);
1030 const lines = source. split ( ' \n ' );
1031
1032 // Run regex matchers on the full file content (catches Tailwind classes, inline styles)
1033 // Enable block context for CSS files where related properties span multiple lines
1034 const cssLike = new Set ([ '.css' , '.scss' , '.sass' , '.less' ]);
1035 findings. push ( ... runRegexMatchers (lines, filePath, 0 , cssLike. has (ext) || null , {
1036 profile,
1037 phase: 'source' ,
1038 }));
1039 // Pseudo-element stripes (::before/::after absolute bars) carry the same
1040 // side-tab silhouette without any border token, so the line matchers can't
1041 // see them (issue #394). The shared scanner already runs on full HTML pages
1042 // via checkHtmlPatterns; give standalone stylesheets, component style
1043 // blocks, and CSS-in-JS templates the same coverage. Each hit carries the
1044 // rule's source offset, so the finding gets a real line and line-scoped
1045 // inline ignores keep working.
1046 const pseudoStripeFindings = ( text , lineOffset ) =>
1047 scanCssTextForPseudoStripe (text). map ( hit =>
1048 finding (hit.id, filePath, hit.snippet, lineOffset + text. slice ( 0 , hit.index). split ( ' \n ' ). length ));
1049
1050 if (cssLike. has (ext)) {
1051 findings. push ( ... scanInsetStripeCss (content, filePath));
1052 findings. push ( ... pseudoStripeFindings (content, 0 ));
1053 }
1054
1055 // Block-level CSS checks that need multiple declarations must run over the
1056 // complete source, not line-by-line. This covers standalone stylesheets,
1057 // component style blocks, inline styles, and CSS-in-JS templates.
1058 findings. push ( ... profileFindings (profile, {
1059 engine: 'regex' ,
1060 phase: 'source' ,
1061 ruleId: 'codex-grid-background' ,
1062 target: filePath,
1063 }, () => scanCssTextForGridBackground (source). map ( hit => {
1064 const line = source. substring ( 0 , hit.index). split ( ' \n ' ). length ;
1065 return finding ( 'codex-grid-background' , filePath, hit.snippet, line);
1066 })));
1067
1068 // Extract and scan <style> blocks from Astro/Vue/Svelte components.
1069 const styleBlocks = profile
1070 ? profileStep (profile, {
1071 engine: 'regex' ,
1072 phase: 'extract' ,
1073 ruleId: 'style-blocks' ,
1074 target: filePath,
1075 }, () => extractStyleBlocks (content, ext))
1076 : extractStyleBlocks (content, ext);
1077 for ( const block of styleBlocks) {
1078 const blockLines = block.content. split ( ' \n ' );
1079 findings. push ( ... runRegexMatchers (blockLines, filePath, block.startLine - 1 , true , {
1080 profile,
1081 phase: 'style-block' ,
1082 }));
1083 // block.startLine is the first line *after* the <style> tag, but block.content
1084 // begins at the character right after that tag — so its own line 1 sits on the
1085 // tag's line, whether or not a newline follows immediately. lineAtOffset is
1086 // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and
1087 // reported every selector one line low. runRegexMatchers keeps startLine - 1
1088 // because it indexes its split lines from zero.
1089 findings. push ( ... scanInsetStripeCss (block.content, filePath, block.startLine - 2 ));
1090 findings. push ( ... pseudoStripeFindings (block.content, block.startLine - 2 ));
1091 }
1092
1093 // Extract and scan CSS-in-JS template literals
1094 const cssJsBlocks = profile
1095 ? profileStep (profile, {
1096 engine: 'regex' ,
1097 phase: 'extract' ,
1098 ruleId: 'css-in-js' ,
1099 target: filePath,
1100 }, () => extractCSSinJS (source, ext))
1101 : extractCSSinJS (source, ext);
1102 for ( const block of cssJsBlocks) {
1103 const blockContent = stripCssComments (block.content);
1104 const blockLines = blockContent. split ( ' \n ' );
1105 findings. push ( ... runRegexMatchers (blockLines, filePath, block.startLine - 1 , true , {
1106 profile,
1107 phase: 'css-in-js' ,
1108 }));
1109 findings. push ( ... scanInsetStripeCss (blockContent, filePath, block.startLine - 1 ));
1110 findings. push ( ... pseudoStripeFindings (blockContent, block.startLine - 1 ));
1111 }
1112
1113 if (options?.designSystem) {
1114 findings. push ( ... profileFindings (profile, {
1115 engine: 'regex' ,
1116 phase: 'source' ,
1117 ruleId: 'design-system' ,
1118 target: filePath,
1119 }, () => checkSourceDesignSystem (content, filePath, { designSystem: options.designSystem })));
1120 }
1121
1122 // Deduplicate findings (same antipattern + similar snippet, within 2 lines)
1123 const deduped = [];
1124 for ( const f of findings) {
1125 const isDupe = deduped. some ( d =>
1126 d.antipattern === f.antipattern &&
1127 d.snippet === f.snippet &&
1128 Math. abs (d.line - f.line) <= 2
1129 );
1130 if ( ! isDupe) deduped. push (f);
1131 }
1132
1133 // Page-level analyzers only run on full pages
1134 if ( shouldRunPageAnalyzers (content, filePath)) {
1135 const analyzerIds = [
1136 'flat-type-hierarchy' ,
1137 'monotonous-spacing' ,
1138 'em-dash-overuse' ,
1139 'marketing-buzzword' ,
1140 'aphoristic-cadence' ,
1141 'dark-glow' ,
1142 ];
1143 for ( let i = 0 ; i < REGEX_ANALYZERS . length ; i ++ ) {
1144 const analyzer = REGEX_ANALYZERS [i];
1145 deduped. push ( ... profileFindings (profile, {
1146 engine: 'regex' ,
1147 phase: 'page-analyzer' ,
1148 ruleId: analyzerIds[i] || `analyzer-${ i + 1 }` ,
1149 target: filePath,
1150 }, () => analyzer (content, filePath)));
1151 }
1152 }
1153
1154 // Inline `impeccable-disable*` waivers travel with the file; honor them unless
1155 // explicitly bypassed (`--no-config` / `--no-inline-ignores`).
1156 return options?.inlineIgnores === false ? deduped : applyInlineIgnores (deduped, content);
1157 }
1158
1159 export {
1160 REGEX_MATCHERS,
1161 REGEX_ANALYZERS,
1162 TEXT_CONTENT_ANALYZER_IDS,
1163 extractStyleBlocks,
1164 extractCSSinJS,
1165 runRegexMatchers,
1166 runTextContentAnalyzers,
1167 detectText,
1168 };