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