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