| 1 | import fs from 'node:fs'; |
| 2 | import path from 'node:path'; |
| 3 | |
| 4 | import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs'; |
| 5 | import { isFullPage } from '../../shared/page.mjs'; |
| 6 | import { finding } from '../../findings.mjs'; |
| 7 | import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; |
| 8 | import { |
| 9 | checkElementBorders, |
| 10 | checkElementClippedOverflow, |
| 11 | checkElementColors, |
| 12 | checkElementGlow, |
| 13 | checkElementGptBorderShadow, |
| 14 | checkElementHeroEyebrow, |
| 15 | checkElementIconTile, |
| 16 | checkElementItalicSerif, |
| 17 | checkElementMotion, |
| 18 | checkElementOversizedH1, |
| 19 | checkElementQuality, |
| 20 | checkCreamPalette, |
| 21 | checkHtmlPatterns, |
| 22 | checkPageLayout, |
| 23 | checkPageQualityFromDoc, |
| 24 | checkRepeatedSectionKickersFromDoc, |
| 25 | resolveBackground, |
| 26 | resolveBorderRadiusPx, |
| 27 | } from '../../rules/checks.mjs'; |
| 28 | import { filterByProviders } from '../../registry/antipatterns.mjs'; |
| 29 | import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; |
| 30 | import { |
| 31 | StaticDocument, |
| 32 | buildStaticStyleMap, |
| 33 | buildStaticWindow, |
| 34 | collectStaticCssText, |
| 35 | } from './css-cascade.mjs'; |
| 36 | |
| 37 | function checkStaticPageTypography(document, window) { |
| 38 | const findings = []; |
| 39 | const fonts = new Set(); |
| 40 | const overusedFound = new Set(); |
| 41 | for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) { |
| 42 | const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0); |
| 43 | if (!hasText) continue; |
| 44 | const ff = window.getComputedStyle(el).fontFamily || ''; |
| 45 | const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); |
| 46 | const primary = stack.find(f => f && !GENERIC_FONTS.has(f)); |
| 47 | if (!primary) continue; |
| 48 | fonts.add(primary); |
| 49 | if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary); |
| 50 | } |
| 51 | for (const font of overusedFound) { |
| 52 | findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); |
| 53 | } |
| 54 | if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) { |
| 55 | findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` }); |
| 56 | } |
| 57 | const sizes = new Set(); |
| 58 | for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { |
| 59 | const fontSize = parseFloat(window.getComputedStyle(el).fontSize); |
| 60 | if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); |
| 61 | } |
| 62 | if (sizes.size >= 3) { |
| 63 | const sorted = [...sizes].sort((a, b) => a - b); |
| 64 | const ratio = sorted[sorted.length - 1] / sorted[0]; |
| 65 | if (ratio < 2.0) { |
| 66 | findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); |
| 67 | } |
| 68 | } |
| 69 | return findings; |
| 70 | } |
| 71 | |
| 72 | function checkElementBrokenImage(el) { |
| 73 | const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; |
| 74 | // Missing src attribute entirely |
| 75 | if (src === undefined || src === null) { |
| 76 | return [{ id: 'broken-image', snippet: '<img> with no src attribute' }]; |
| 77 | } |
| 78 | const trimmed = String(src).trim(); |
| 79 | // Empty or placeholder-only src values |
| 80 | if (trimmed === '' || trimmed === '#') { |
| 81 | return [{ id: 'broken-image', snippet: `<img src="${src}">` }]; |
| 82 | } |
| 83 | return []; |
| 84 | } |
| 85 | |
| 86 | const STATIC_ELEMENT_RULES = [ |
| 87 | { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, |
| 88 | { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, |
| 89 | { id: 'dark-glow', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window, customPropMap)) }, |
| 90 | { id: 'motion-rules', selector: '*', run: (el, tag, style) => checkElementMotion(tag, style) }, |
| 91 | { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, |
| 92 | { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, |
| 93 | { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, |
| 94 | { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, |
| 95 | { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, |
| 96 | { id: 'oversized-h1', selector: 'h1', run: (el, tag, style, window) => checkElementOversizedH1(el, style, tag, window) }, |
| 97 | { id: 'clipped-overflow-container', selector: '*', run: (el, tag, style, window) => checkElementClippedOverflow(el, style, tag, window) }, |
| 98 | { id: 'gpt-thin-border-wide-shadow', selector: '*', run: (el, tag, style) => checkElementGptBorderShadow(el, style) }, |
| 99 | ]; |
| 100 | |
| 101 | async function detectHtml(filePath, options = {}) { |
| 102 | const profile = options?.profile; |
| 103 | const html = profileStep(profile, { |
| 104 | engine: 'static-html', |
| 105 | phase: 'setup', |
| 106 | ruleId: 'read-html', |
| 107 | target: filePath, |
| 108 | }, () => fs.readFileSync(filePath, 'utf-8')); |
| 109 | |
| 110 | let modules; |
| 111 | try { |
| 112 | modules = await profileStepAsync(profile, { |
| 113 | engine: 'static-html', |
| 114 | phase: 'setup', |
| 115 | ruleId: 'import-static-parser', |
| 116 | target: filePath, |
| 117 | }, async () => { |
| 118 | const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([ |
| 119 | import('htmlparser2'), |
| 120 | import('css-select'), |
| 121 | import('css-tree'), |
| 122 | import('domutils'), |
| 123 | ]); |
| 124 | return { |
| 125 | parseDocument: htmlparser2.parseDocument, |
| 126 | selectAll: cssSelect.selectAll, |
| 127 | selectOne: cssSelect.selectOne, |
| 128 | is: cssSelect.is, |
| 129 | csstree, |
| 130 | domutils, |
| 131 | }; |
| 132 | }); |
| 133 | } catch { |
| 134 | return detectText(html, filePath, options); |
| 135 | } |
| 136 | |
| 137 | const resolvedPath = path.resolve(filePath); |
| 138 | const fileDir = path.dirname(resolvedPath); |
| 139 | const root = profileStep(profile, { |
| 140 | engine: 'static-html', |
| 141 | phase: 'parse-html', |
| 142 | ruleId: 'parse-document', |
| 143 | target: filePath, |
| 144 | }, () => modules.parseDocument(html, { lowerCaseAttributeNames: false, lowerCaseTags: true })); |
| 145 | |
| 146 | const cssText = collectStaticCssText(root, fileDir, profile, filePath, modules); |
| 147 | const document = new StaticDocument(root, modules); |
| 148 | buildStaticStyleMap(root, document, cssText, modules, profile, filePath); |
| 149 | const window = buildStaticWindow(document); |
| 150 | |
| 151 | const customPropMap = null; |
| 152 | |
| 153 | const findings = []; |
| 154 | const runElementCheck = (ruleId, callback) => profile |
| 155 | ? profileFindings(profile, { engine: 'static-html', phase: 'element', ruleId, target: filePath }, callback) |
| 156 | : callback(); |
| 157 | |
| 158 | const visitedByRule = new Map(); |
| 159 | for (const rule of STATIC_ELEMENT_RULES) { |
| 160 | const elements = document.querySelectorAll(rule.selector); |
| 161 | visitedByRule.set(rule.id, elements.length); |
| 162 | for (const el of elements) { |
| 163 | const tag = el.tagName.toLowerCase(); |
| 164 | const style = window.getComputedStyle(el); |
| 165 | for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) { |
| 166 | findings.push(finding(f.id, filePath, f.snippet)); |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | if (isFullPage(html)) { |
| 172 | const runPageCheck = (ruleId, callback) => profile |
| 173 | ? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback) |
| 174 | : callback(); |
| 175 | for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) { |
| 176 | findings.push(finding(f.id, filePath, f.snippet)); |
| 177 | } |
| 178 | for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) { |
| 179 | findings.push(finding(f.id, filePath, f.snippet)); |
| 180 | } |
| 181 | for (const f of runPageCheck('layout-rules', () => checkPageLayout(document, window))) { |
| 182 | findings.push(finding(f.id, filePath, f.snippet)); |
| 183 | } |
| 184 | for (const f of runPageCheck('cream-palette', () => checkCreamPalette(document, window))) { |
| 185 | findings.push(finding(f.id, filePath, f.snippet)); |
| 186 | } |
| 187 | for (const f of runPageCheck('skipped-heading', () => checkPageQualityFromDoc(document))) { |
| 188 | findings.push(finding(f.id, filePath, f.snippet)); |
| 189 | } |
| 190 | for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html).filter(item => |
| 191 | item.id !== 'bounce-easing' && item.id !== 'layout-transition' |
| 192 | ))) { |
| 193 | findings.push(finding(f.id, filePath, f.snippet)); |
| 194 | } |
| 195 | // Text-content analyzers (em-dash overuse, marketing buzzwords, |
| 196 | // numbered section markers, aphoristic cadence) live in the regex |
| 197 | // engine. Call them from here so .html files get the same coverage |
| 198 | // as .css/.tsx files. These are scoped to text content only and |
| 199 | // don't overlap with static-html's element/page rules. |
| 200 | for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { |
| 201 | findings.push(finding(f.antipattern, filePath, f.snippet)); |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | return filterByProviders(findings, options.providers); |
| 206 | } |
| 207 | |
| 208 | export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml }; |
| 209 |