//! Port of `cli/engine/engines/static-html/detect-html.mjs#detectHtml`: the
//! whole static engine run for one HTML file, in the exact JS finding order
//! (element rules in `STATIC_ELEMENT_RULES` order, design-system findings,
//! then the page-level checks for full pages), then inline ignores.
//!
//! Two pieces the JS reaches into other engines for are exposed as hooks on
//! [`DetectHtmlOptions`] instead of being duplicated here:
//! `runTextContentAnalyzers` (regex engine) and the design-system trio
//! (`checkSourceDesignSystem`, `collectStaticDesignSystemFindings`,
//! `mergeDesignSystemFindings`). The `detect` crate wires them.
use crate::adapters::{
check_element_borders, check_element_broken_image, check_element_clipped_overflow,
check_element_colors, check_element_glow, check_element_gpt_border_shadow,
check_element_hero_eyebrow, check_element_hover_contrast, check_element_icon_tile,
check_element_italic_serif, check_element_motion, check_element_oversized_h1,
check_element_radial_spotlight, check_kicker_above_heading_from_doc,
check_numbered_section_labels_from_doc, scoped_ignore_active,
};
use crate::background::{resolve_background, resolve_border_radius_px, sv};
use crate::cascade::{build_static_style_map, collect_static_css_text};
use crate::dom::{StaticDocument, StaticElement};
use crate::page::{
check_cream_palette, check_page_layout, check_repeated_container_text_from_doc,
check_static_page_typography,
};
use crate::profile::{self, Meta, ProfileSink};
use crate::quality::{check_element_quality, check_page_quality_from_doc, pf0};
use impeccable_core::checks::html_patterns::{check_html_patterns, HtmlPatternCorpora};
use impeccable_core::checks::rules::RuleHit;
use impeccable_core::findings::{try_finding, Finding};
use impeccable_core::inline_ignores::apply_inline_ignores;
use impeccable_core::page::is_full_page;
use once_cell::sync::Lazy;
use regex::Regex;
use std::path::Path;
/// The design-system pieces of `detectHtml` (`design-system.mjs`), supplied
/// by the orchestrating crate when a project design system is loaded.
pub trait DesignSystemHook {
/// JS `checkSourceDesignSystem(html, filePath, { designSystem })`.
fn check_source(&self, html: &str, file_path: &str) -> Vec;
/// JS `collectStaticDesignSystemFindings(document, window, filePath, designSystem)`.
fn collect_static(&self, doc: &StaticDocument, file_path: &str) -> Vec;
/// JS `mergeDesignSystemFindings(staticDesignFindings, sourceDesignFindings)`.
fn merge(&self, static_findings: Vec, source_findings: Vec) -> Vec;
}
/// JS `runTextContentAnalyzers(html, filePath, options)` from the regex
/// engine: em-dash overuse, marketing buzzwords, aphoristic cadence.
pub type TextContentAnalyzers<'a> = &'a dyn Fn(&str, &str) -> Vec;
/// The static engine's half of a rule pack (`impeccable_core::rule_pack`):
/// rules written against the parsed page. The [`StaticDocument`] model is
/// this crate's, so this hook cannot live on the engine-wide `RulePack`
/// trait; a pack implements both and hands the same value to both fields of
/// [`DetectHtmlOptions`].
///
/// Findings come back as full [`Finding`] values, built with
/// `impeccable_core::findings::finding_for(row, file_path, snippet, line)` so
/// they carry the pack's own registry metadata.
pub trait StaticRulePack: Send + Sync + std::fmt::Debug {
/// Runs once per HTML file, after every built-in pass and before inline
/// ignores.
fn check_document(&self, doc: &StaticDocument, file_path: &str) -> Vec;
}
/// The `options` object `detectHtml` reads.
#[derive(Default, Clone, Copy)]
pub struct DetectHtmlOptions<'a> {
/// JS `options.inlineIgnores === false` disables the whole-file
/// `impeccable-disable` waivers; anything else applies them.
pub inline_ignores_disabled: bool,
/// JS `options.designSystem` (present only when a DESIGN.md loaded).
pub design_system: Option<&'a dyn DesignSystemHook>,
/// The regex engine's text-content analyzers; `None` skips them.
pub text_content_analyzers: Option>,
/// JS `options.profile`.
pub profile: Option<&'a dyn ProfileSink>,
/// Sink for the JS `process.stderr.write` notices (unreadable linked
/// stylesheets); `None` drops them.
pub warn: Option<&'a dyn Fn(&str)>,
/// A rule pack's static-document hook: rules over the parsed page.
pub static_rule_pack: Option<&'static dyn StaticRulePack>,
/// The same pack's engine-wide text hook. An HTML file gets **one** pack
/// pass: `static_rule_pack` when it is set, otherwise this one over the
/// raw HTML source (which is how a text-only pack still covers `.html`
/// files, the way the built-in text-content analyzers do). A pack that
/// implements both therefore never reports twice for the same file.
pub rule_pack: Option<&'static dyn impeccable_core::rule_pack::RulePack>,
}
/// Errors of the static engine.
#[derive(Debug, thiserror::Error)]
pub enum HtmlEngineError {
#[error("cannot read {path}: {source}")]
Read {
path: String,
#[source]
source: std::io::Error,
},
}
/// The per-element rules of `STATIC_ELEMENT_RULES`, in table order.
const STATIC_ELEMENT_RULES: &[(&str, &str)] = &[
("border-rules", "*"),
("color-rules", "*"),
("hover-color-rules", "*"),
("dark-glow", "*"),
("motion-rules", "*"),
("icon-tile-stack", "h1,h2,h3,h4,h5,h6"),
("italic-serif-display", "h1,h2"),
("hero-eyebrow-chip", "h1"),
("broken-image", "img"),
("quality-rules", "*"),
("oversized-h1", "h1"),
("clipped-overflow-container", "*"),
("gpt-thin-border-wide-shadow", "*"),
("radial-spotlight-glow", "*"),
];
fn run_rule(rule_id: &str, el: &StaticElement<'_>, tag: &str) -> Vec {
let style = el.style();
match rule_id {
"border-rules" => {
let radius = resolve_border_radius_px(style, pf0(sv(style, "width")));
check_element_borders(tag, style, radius, el)
}
"color-rules" => check_element_colors(el, style, tag, None),
"hover-color-rules" => check_element_hover_contrast(el, style, tag),
"dark-glow" => {
let base = el.parent_element().unwrap_or(*el);
check_element_glow(style, resolve_background(&base, None))
}
"motion-rules" => check_element_motion(tag, style),
"icon-tile-stack" => check_element_icon_tile(el, tag),
"italic-serif-display" => check_element_italic_serif(el, style, tag),
"hero-eyebrow-chip" => check_element_hero_eyebrow(el, style, tag),
"broken-image" => check_element_broken_image(el),
"quality-rules" => check_element_quality(el, style, tag),
"oversized-h1" => check_element_oversized_h1(el, tag),
"clipped-overflow-container" => check_element_clipped_overflow(el, style),
"gpt-thin-border-wide-shadow" => check_element_gpt_border_shadow(style),
"radial-spotlight-glow" => check_element_radial_spotlight(el, style),
_ => Vec::new(),
}
}
static PSEUDO_STRIP_RE: Lazy =
Lazy::new(|| Regex::new(r"::?[a-zA-Z-]+(\([^)]*\))?").expect("PSEUDO_STRIP_RE"));
fn read_source(file_path: &Path) -> Result {
let bytes = std::fs::read(file_path).map_err(|source| HtmlEngineError::Read {
path: file_path.to_string_lossy().into_owned(),
source,
})?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
/// JS `detectHtml(filePath, options)`: read the file and scan it.
pub fn detect_html(
file_path: &Path,
options: &DetectHtmlOptions<'_>,
) -> Result, HtmlEngineError> {
let file_str = file_path.to_string_lossy().into_owned();
let html = profile::step(
options.profile,
Meta::new("setup", "read-html", &file_str),
|| read_source(file_path),
)?;
Ok(detect_html_source(&html, file_path, options))
}
/// `detectHtml` for HTML already in memory. `file_path` names the file in
/// findings and resolves linked stylesheets (its parent directory).
pub fn detect_html_source(
html: &str,
file_path: &Path,
options: &DetectHtmlOptions<'_>,
) -> Vec {
let profile = options.profile;
let file_str = file_path.to_string_lossy().into_owned();
let fp = file_str.as_str();
// JS loads htmlparser2 / css-select / css-tree / domutils here (and falls
// back to the regex engine with a DEGRADED notice when they are missing);
// the port links them in, so the step is only kept for the profile shape.
profile::step(
profile,
Meta::new("setup", "import-static-parser", fp),
|| (),
);
// JS `path.dirname(path.resolve(filePath))`.
let resolved = if file_path.is_absolute() {
file_path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(file_path))
.unwrap_or_else(|_| file_path.to_path_buf())
};
let file_dir = resolved
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| resolved.clone());
let mut doc = profile::step(
profile,
Meta::new("parse-html", "parse-document", fp),
|| StaticDocument::parse(html),
);
let css_text = collect_static_css_text(&doc, &file_dir, profile, fp, options.warn);
build_static_style_map(&mut doc, css_text.as_str(), profile, fp);
let doc = doc;
let mut findings: Vec = Vec::new();
let mk = |id: &str, snippet: &str| try_finding(id, fp, snippet, 0.0);
for (rule_id, selector) in STATIC_ELEMENT_RULES {
let elements = doc.query_selector_all(selector);
for el in &elements {
let tag = el.tag_lower();
let hits = profile::findings(
profile,
Meta::new("element", rule_id, fp),
|h: &RuleHit| h.id.as_str(),
|| run_rule(rule_id, el, &tag),
);
for h in hits {
if scoped_ignore_active(el, &h.id) {
continue;
}
if let Some(f) = mk(&h.id, &h.snippet) {
findings.push(f);
}
}
}
}
if let Some(ds) = options.design_system {
let source_design = profile::findings(
profile,
Meta::new("source", "design-system", fp),
|f: &Finding| f.antipattern.as_str(),
|| ds.check_source(html, fp),
);
let static_design = profile::findings(
profile,
Meta::new("page", "design-system", fp),
|f: &Finding| f.antipattern.as_str(),
|| ds.collect_static(&doc, fp),
);
findings.extend(ds.merge(static_design, source_design));
}
if is_full_page(html) {
let page = |rule_id: &str, f: &dyn Fn() -> Vec| -> Vec {
profile::findings(
profile,
Meta::new("page", rule_id, fp),
|h: &RuleHit| h.id.as_str(),
f,
)
};
let mut push_hits = |hits: Vec| {
for h in hits {
if let Some(f) = mk(&h.id, &h.snippet) {
findings.push(f);
}
}
};
push_hits(page("typography-rules", &|| {
check_static_page_typography(&doc)
}));
push_hits(page("kicker-above-heading", &|| {
check_kicker_above_heading_from_doc(&doc)
}));
push_hits(page("numbered-section-labels", &|| {
check_numbered_section_labels_from_doc(&doc)
}));
push_hits(page("repeated-container-text", &|| {
check_repeated_container_text_from_doc(&doc)
}));
push_hits(page("layout-rules", &|| check_page_layout(&doc)));
push_hits(page("cream-palette", &|| check_cream_palette(&doc)));
push_hits(page("skipped-heading", &|| {
check_page_quality_from_doc(&doc)
}));
// Scoped corpora for the pattern checks: cssText already carries the
//