diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index 8bce32525..1545ea060 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,27 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankAstroFrontmatterComments(text) { + if (!text.startsWith('---')) return text; + const close = text.indexOf('\n---', 3); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + let next = stripCssComments(blankHtmlComments(text)); + if (ext === '.astro') next = blankAstroFrontmatterComments(next); + return next; + } + if (STYLESHEET_EXTS.has(ext)) return stripCssComments(text); + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1050,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1071,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1099,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = stripCssComments(block.content); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1111,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index 2c7141365..b78b51a0f 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -382,6 +382,119 @@ describe('detectText — broken images in source comments', () => { expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0); }); + + test('ignores img tags in Astro style block comments', () => { + const source = [ + '---', + 'const title = "Hero";', + '---', + '', + ].join('\n'); + + const findings = detectText(source, 'hero.astro'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0); + }); + + test('ignores img tags in Astro HTML comments', () => { + const source = [ + '---', + 'const title = "Hero";', + '---', + '', + 'Logo', + ].join('\n'); + + const findings = detectText(source, 'hero.astro'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0); + }); + + test('ignores img tags in Astro frontmatter line comments', () => { + const source = [ + '---', + '// Comment-only image', + 'const site = "https://example.com";', + '---', + 'Logo', + ].join('\n'); + + const findings = detectText(source, 'hero.astro'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0); + }); + + test('ignores img tags in CSS block comments', () => { + const source = [ + '/*', + ' * Example markup: ', + ' */', + '.hero { color: red; }', + ].join('\n'); + + const findings = detectText(source, 'hero.css'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0); + }); + + test('still detects real img tags after an HTML comment in Astro', () => { + const source = [ + '---', + 'const title = "Hero";', + '---', + '', + 'Empty source', + ].join('\n'); + + const findings = detectText(source, 'hero.astro'); + + const broken = findings.filter(r => r.antipattern === 'broken-image'); + expect(broken).toHaveLength(1); + expect(broken[0].line).toBe(5); + }); + + test('does not blank https URLs in Astro frontmatter', () => { + const source = [ + '---', + 'const site = "https://example.com/logo.png";', + '---', + 'Empty source', + ].join('\n'); + + const findings = detectText(source, 'hero.astro'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(1); + }); + + test('keeps same-line img visible after a bare https URL in Astro markup', () => { + const source = '

https://example.com Empty source

'; + + const findings = detectText(source, 'hero.astro'); + + expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(1); + }); + + test('preserves line numbers after comment blanking in Astro', () => { + const source = [ + '---', + 'const title = "Hero";', + '---', + '', + '

Intro copy

', + 'Empty source', + ].join('\n'); + + const findings = detectText(source, 'hero.astro'); + + const broken = findings.filter(r => r.antipattern === 'broken-image'); + expect(broken).toHaveLength(1); + expect(broken[0].line).toBe(6); + }); }); describe('detectText — CSS borders', () => {