1import fs from 'node:fs';
2import path from 'node:path';
3
4import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
5import { isFullPage } from '../../shared/page.mjs';
6import { finding } from '../../findings.mjs';
7import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
8import {
9 checkElementBorders,
10 checkElementColors,
11 checkElementGlow,
12 checkElementHeroEyebrow,
13 checkElementIconTile,
14 checkElementItalicSerif,
15 checkElementMotion,
16 checkElementQuality,
17 checkHtmlPatterns,
18 checkPageLayout,
19 checkPageQualityFromDoc,
20 checkRepeatedSectionKickersFromDoc,
21 resolveBackground,
22 resolveBorderRadiusPx,
23} from '../../rules/checks.mjs';
24import { detectText } from '../regex/detect-text.mjs';
25import {
26 StaticDocument,
27 buildStaticStyleMap,
28 buildStaticWindow,
29 collectStaticCssText,
30} from './css-cascade.mjs';
31
32function checkStaticPageTypography(document, window) {
33 const findings = [];
34 const fonts = new Set();
35 const overusedFound = new Set();
36 for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) {
37 const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
38 if (!hasText) continue;
39 const ff = window.getComputedStyle(el).fontFamily || '';
40 const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
41 const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
42 if (!primary) continue;
43 fonts.add(primary);
44 if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
45 }
46 for (const font of overusedFound) {
47 findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
48 }
49 if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
50 findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
51 }
52 const sizes = new Set();
53 for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) {
54 const fontSize = parseFloat(window.getComputedStyle(el).fontSize);
55 if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
56 }
57 if (sizes.size >= 3) {
58 const sorted = [...sizes].sort((a, b) => a - b);
59 const ratio = sorted[sorted.length - 1] / sorted[0];
60 if (ratio < 2.0) {
61 findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
62 }
63 }
64 return findings;
65}
66
67const STATIC_ELEMENT_RULES = [
68 { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) },
69 { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
70 { id: 'dark-glow', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window, customPropMap)) },
71 { id: 'motion-rules', selector: '*', run: (el, tag, style) => checkElementMotion(tag, style) },
72 { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) },
73 { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) },
74 { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) },
75 { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) },
76];
77
78async function detectHtml(filePath, options = {}) {
79 const profile = options?.profile;
80 const html = profileStep(profile, {
81 engine: 'static-html',
82 phase: 'setup',
83 ruleId: 'read-html',
84 target: filePath,
85 }, () => fs.readFileSync(filePath, 'utf-8'));
86
87 let modules;
88 try {
89 modules = await profileStepAsync(profile, {
90 engine: 'static-html',
91 phase: 'setup',
92 ruleId: 'import-static-parser',
93 target: filePath,
94 }, async () => {
95 const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([
96 import('htmlparser2'),
97 import('css-select'),
98 import('css-tree'),
99 import('domutils'),
100 ]);
101 return {
102 parseDocument: htmlparser2.parseDocument,
103 selectAll: cssSelect.selectAll,
104 selectOne: cssSelect.selectOne,
105 is: cssSelect.is,
106 csstree,
107 domutils,
108 };
109 });
110 } catch {
111 return detectText(html, filePath, options);
112 }
113
114 const resolvedPath = path.resolve(filePath);
115 const fileDir = path.dirname(resolvedPath);
116 const root = profileStep(profile, {
117 engine: 'static-html',
118 phase: 'parse-html',
119 ruleId: 'parse-document',
120 target: filePath,
121 }, () => modules.parseDocument(html, { lowerCaseAttributeNames: false, lowerCaseTags: true }));
122
123 const cssText = collectStaticCssText(root, fileDir, profile, filePath, modules);
124 const document = new StaticDocument(root, modules);
125 buildStaticStyleMap(root, document, cssText, modules, profile, filePath);
126 const window = buildStaticWindow(document);
127
128 const customPropMap = null;
129
130 const findings = [];
131 const runElementCheck = (ruleId, callback) => profile
132 ? profileFindings(profile, { engine: 'static-html', phase: 'element', ruleId, target: filePath }, callback)
133 : callback();
134
135 const visitedByRule = new Map();
136 for (const rule of STATIC_ELEMENT_RULES) {
137 const elements = document.querySelectorAll(rule.selector);
138 visitedByRule.set(rule.id, elements.length);
139 for (const el of elements) {
140 const tag = el.tagName.toLowerCase();
141 const style = window.getComputedStyle(el);
142 for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
143 findings.push(finding(f.id, filePath, f.snippet));
144 }
145 }
146 }
147
148 if (isFullPage(html)) {
149 const runPageCheck = (ruleId, callback) => profile
150 ? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback)
151 : callback();
152 for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
153 findings.push(finding(f.id, filePath, f.snippet));
154 }
155 for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
156 findings.push(finding(f.id, filePath, f.snippet));
157 }
158 for (const f of runPageCheck('layout-rules', () => checkPageLayout(document, window))) {
159 findings.push(finding(f.id, filePath, f.snippet));
160 }
161 for (const f of runPageCheck('skipped-heading', () => checkPageQualityFromDoc(document))) {
162 findings.push(finding(f.id, filePath, f.snippet));
163 }
164 for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html).filter(item =>
165 item.id !== 'bounce-easing' && item.id !== 'layout-transition'
166 ))) {
167 findings.push(finding(f.id, filePath, f.snippet));
168 }
169 }
170
171 return findings;
172}
173
174export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };