Skip to main content

ssg/audit/gates/
html5.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! HTML5 structural validation gate.
5//!
6//! Per page:
7//! - Exactly one `<h1>` element.
8//! - A `<main>` landmark exists.
9//! - `<title>` is present and non-empty.
10//! - `<meta charset>` is present.
11//! - `<!doctype html>` declaration is present.
12
13use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
14
15const NAME: &str = "html5";
16
17/// HTML5 structural validation gate.
18///
19/// # Examples
20///
21/// ```
22/// use ssg::audit::AuditGate;
23/// use ssg::audit::gates::html5::Html5Gate;
24/// assert_eq!(Html5Gate.name(), "html5");
25/// ```
26#[derive(Debug, Clone, Copy)]
27pub struct Html5Gate;
28
29impl AuditGate for Html5Gate {
30    fn name(&self) -> &'static str {
31        NAME
32    }
33
34    fn explain(&self) -> &'static str {
35        "Validates HTML5 structural invariants on every page: exactly \
36         one <h1>, a <main> landmark, a non-empty <title>, a \
37         <meta charset>, and a <!doctype html> at the top. Structural \
38         omissions are emitted as errors — runtime browsers will \
39         silently coerce missing pieces but Lighthouse and a11y tools \
40         demand them."
41    }
42
43    fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
44        let mut findings = Vec::new();
45        for path in &site.html_files {
46            let Ok(html) = site.read(path) else { continue };
47            let rel = site.rel(path);
48            let lower = html.to_lowercase();
49
50            if !lower.trim_start().starts_with("<!doctype html") {
51                findings.push(
52                    Finding::new(
53                        NAME,
54                        Severity::Error,
55                        "Missing <!doctype html> at top of file",
56                    )
57                    .with_code("HTML5-DOCTYPE")
58                    .with_path(rel.clone()),
59                );
60            }
61
62            let h1_count = lower.matches("<h1").count();
63            if h1_count == 0 {
64                findings.push(
65                    Finding::new(NAME, Severity::Error, "No <h1> element")
66                        .with_code("HTML5-H1-MISSING")
67                        .with_path(rel.clone()),
68                );
69            } else if h1_count > 1 {
70                findings.push(
71                    Finding::new(
72                        NAME,
73                        Severity::Warn,
74                        format!("Multiple <h1> elements ({h1_count})"),
75                    )
76                    .with_code("HTML5-H1-MULTIPLE")
77                    .with_path(rel.clone()),
78                );
79            }
80
81            if !lower.contains("<main") {
82                findings.push(
83                    Finding::new(
84                        NAME,
85                        Severity::Warn,
86                        "No <main> landmark element",
87                    )
88                    .with_code("HTML5-MAIN-MISSING")
89                    .with_path(rel.clone()),
90                );
91            }
92
93            if !lower.contains("<meta charset") {
94                findings.push(
95                    Finding::new(
96                        NAME,
97                        Severity::Error,
98                        "Missing <meta charset>",
99                    )
100                    .with_code("HTML5-CHARSET")
101                    .with_path(rel.clone()),
102                );
103            }
104
105            if let Some(start) = lower.find("<title") {
106                let close = lower[start..].find("</title>").unwrap_or(0);
107                if close == 0 {
108                    findings.push(
109                        Finding::new(
110                            NAME,
111                            Severity::Error,
112                            "Unclosed or missing <title>",
113                        )
114                        .with_code("HTML5-TITLE-UNCLOSED")
115                        .with_path(rel.clone()),
116                    );
117                } else {
118                    let block = &lower[start..start + close];
119                    let gt = block.find('>').unwrap_or(0);
120                    let text = block[gt + 1..].trim();
121                    if text.is_empty() {
122                        findings.push(
123                            Finding::new(
124                                NAME,
125                                Severity::Error,
126                                "<title> is empty",
127                            )
128                            .with_code("HTML5-TITLE-EMPTY")
129                            .with_path(rel.clone()),
130                        );
131                    }
132                }
133            } else {
134                findings.push(
135                    Finding::new(NAME, Severity::Error, "Missing <title>")
136                        .with_code("HTML5-TITLE-MISSING")
137                        .with_path(rel.clone()),
138                );
139            }
140        }
141        findings
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    fn site(html: &str) -> Site {
150        let tmp = tempfile::tempdir().unwrap();
151        let p = tmp.path().join("page.html");
152        std::fs::write(&p, html).unwrap();
153        let root = tmp.path().to_path_buf();
154        std::mem::forget(tmp);
155        Site {
156            root,
157            html_files: vec![p],
158        }
159    }
160
161    #[test]
162    fn well_formed_page_has_no_findings() {
163        let html = "<!doctype html><html><head><meta charset=\"utf-8\"><title>x</title></head><body><main><h1>x</h1></main></body></html>";
164        let f = Html5Gate.run(&site(html), &AuditOptions::default());
165        assert!(f.is_empty(), "got {f:?}");
166    }
167
168    #[test]
169    fn missing_doctype_charset_title_h1_flagged() {
170        let html = "<html><head></head><body><p>x</p></body></html>";
171        let f = Html5Gate.run(&site(html), &AuditOptions::default());
172        let codes: Vec<_> =
173            f.iter().filter_map(|x| x.code.as_deref()).collect();
174        assert!(codes.contains(&"HTML5-DOCTYPE"));
175        assert!(codes.contains(&"HTML5-H1-MISSING"));
176        assert!(codes.contains(&"HTML5-CHARSET"));
177        assert!(codes.contains(&"HTML5-TITLE-MISSING"));
178    }
179
180    #[test]
181    fn multiple_h1_warns() {
182        let html = "<!doctype html><html><head><meta charset=\"utf-8\"><title>x</title></head><body><main><h1>a</h1><h1>b</h1><h1>c</h1></main></body></html>";
183        let f = Html5Gate.run(&site(html), &AuditOptions::default());
184        assert!(f
185            .iter()
186            .any(|x| x.code.as_deref() == Some("HTML5-H1-MULTIPLE")));
187        assert!(f.iter().any(|x| matches!(x.severity, Severity::Warn)));
188    }
189
190    #[test]
191    fn missing_main_warns() {
192        let html = "<!doctype html><html><head><meta charset=\"utf-8\"><title>x</title></head><body><h1>x</h1></body></html>";
193        let f = Html5Gate.run(&site(html), &AuditOptions::default());
194        assert!(
195            f.iter()
196                .any(|x| x.code.as_deref() == Some("HTML5-MAIN-MISSING")),
197            "got {f:?}"
198        );
199    }
200
201    #[test]
202    fn empty_title_flagged() {
203        let html = "<!doctype html><html><head><meta charset=\"utf-8\"><title>   </title></head><body><main><h1>x</h1></main></body></html>";
204        let f = Html5Gate.run(&site(html), &AuditOptions::default());
205        assert!(
206            f.iter()
207                .any(|x| x.code.as_deref() == Some("HTML5-TITLE-EMPTY")),
208            "got {f:?}"
209        );
210    }
211
212    #[test]
213    fn unclosed_title_flagged() {
214        let html = "<!doctype html><html><head><meta charset=\"utf-8\"><title>oops</head><body><main><h1>x</h1></main></body></html>";
215        let f = Html5Gate.run(&site(html), &AuditOptions::default());
216        assert!(
217            f.iter()
218                .any(|x| x.code.as_deref() == Some("HTML5-TITLE-UNCLOSED")),
219            "got {f:?}"
220        );
221    }
222
223    #[test]
224    fn doctype_case_insensitive() {
225        let html = "<!DOCTYPE HTML><html><head><meta charset=\"utf-8\"><title>x</title></head><body><main><h1>x</h1></main></body></html>";
226        let f = Html5Gate.run(&site(html), &AuditOptions::default());
227        assert!(f.is_empty(), "got {f:?}");
228    }
229
230    #[test]
231    fn metadata_methods_exposed() {
232        let g = Html5Gate;
233        assert_eq!(g.name(), "html5");
234        assert!(g.explain().to_lowercase().contains("doctype"));
235        let _copy: Html5Gate = g;
236        let _clone = g;
237        let dbg = format!("{g:?}");
238        assert!(dbg.contains("Html5Gate"));
239    }
240
241    #[test]
242    fn empty_site_returns_no_findings() {
243        let tmp = tempfile::tempdir().unwrap();
244        let root = tmp.path().to_path_buf();
245        std::mem::forget(tmp);
246        let s = Site {
247            root,
248            html_files: Vec::new(),
249        };
250        let f = Html5Gate.run(&s, &AuditOptions::default());
251        assert!(f.is_empty());
252    }
253
254    #[test]
255    fn unreadable_html_skipped() {
256        let tmp = tempfile::tempdir().unwrap();
257        let root = tmp.path().to_path_buf();
258        let dir_as_file = root.join("page.html");
259        std::fs::create_dir_all(&dir_as_file).unwrap();
260        let s = Site {
261            root,
262            html_files: vec![dir_as_file],
263        };
264        let _ = Html5Gate.run(&s, &AuditOptions::default());
265        std::mem::forget(tmp);
266    }
267
268    #[test]
269    fn leading_whitespace_before_doctype_ok() {
270        let html = "   \n  <!doctype html><html><head><meta charset=\"utf-8\"><title>x</title></head><body><main><h1>x</h1></main></body></html>";
271        let f = Html5Gate.run(&site(html), &AuditOptions::default());
272        assert!(f.is_empty(), "got {f:?}");
273    }
274}