detect-html.mjs

  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 };