1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
14
15const NAME: &str = "html5";
16
17#[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}