ssg/audit/gates/
metadata.rs1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
13use super::hreflang_attr;
14
15const NAME: &str = "metadata";
16
17#[derive(Debug, Clone, Copy)]
27pub struct MetadataGate;
28
29impl AuditGate for MetadataGate {
30 fn name(&self) -> &'static str {
31 NAME
32 }
33
34 fn explain(&self) -> &'static str {
35 "Asserts every page declares <title>, <meta name=description>, \
36 and the Open Graph trio og:title, og:type, og:image plus \
37 twitter:card. These power link-preview cards on Slack, \
38 Twitter, LinkedIn, iMessage, and search-engine SERP previews."
39 }
40
41 fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
42 let mut findings = Vec::new();
43 for path in &site.html_files {
44 let Ok(html) = site.read(path) else { continue };
45 let rel = site.rel(path);
46
47 check_title(&html, &rel, &mut findings);
48 for required in ["description"] {
49 if !has_named_meta(&html, required) {
50 findings.push(
51 Finding::new(
52 NAME,
53 Severity::Error,
54 format!(
55 "<meta name=\"{required}\"> missing or empty"
56 ),
57 )
58 .with_code(format!("META-{}", required.to_uppercase()))
59 .with_path(rel.clone()),
60 );
61 }
62 }
63 for required in ["og:title", "og:type", "og:image"] {
64 if !has_property_meta(&html, required) {
65 findings.push(
66 Finding::new(
67 NAME,
68 Severity::Warn,
69 format!("<meta property=\"{required}\"> missing"),
70 )
71 .with_code(format!(
72 "OG-{}",
73 required
74 .split(':')
75 .next_back()
76 .unwrap_or("")
77 .to_uppercase()
78 ))
79 .with_path(rel.clone()),
80 );
81 }
82 }
83 if !has_named_meta(&html, "twitter:card") {
84 findings.push(
85 Finding::new(
86 NAME,
87 Severity::Warn,
88 "<meta name=\"twitter:card\"> missing",
89 )
90 .with_code("TWITTER-CARD")
91 .with_path(rel.clone()),
92 );
93 }
94 }
95 findings
96 }
97}
98
99fn check_title(html: &str, rel: &str, findings: &mut Vec<Finding>) {
100 let lower = html.to_lowercase();
101 let Some(start) = lower.find("<title") else {
102 findings.push(
103 Finding::new(NAME, Severity::Error, "Missing <title>")
104 .with_code("META-TITLE")
105 .with_path(rel.to_string()),
106 );
107 return;
108 };
109 let end = lower[start..].find("</title>");
110 let Some(end) = end else {
111 findings.push(
112 Finding::new(NAME, Severity::Error, "<title> not closed")
113 .with_code("META-TITLE")
114 .with_path(rel.to_string()),
115 );
116 return;
117 };
118 let block = &lower[start..start + end];
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(NAME, Severity::Error, "<title> is empty")
124 .with_code("META-TITLE")
125 .with_path(rel.to_string()),
126 );
127 }
128}
129
130fn has_named_meta(html: &str, name: &str) -> bool {
131 has_meta_with(html, "name", name)
132}
133
134fn has_property_meta(html: &str, name: &str) -> bool {
135 has_meta_with(html, "property", name)
136}
137
138fn has_meta_with(html: &str, key: &str, value: &str) -> bool {
139 let lower = html.to_lowercase();
140 let mut cursor = 0;
141 while let Some(rel) = lower[cursor..].find("<meta") {
142 let abs = cursor + rel;
143 let end = lower[abs..].find('>').map_or(lower.len(), |e| abs + e + 1);
144 let tag = &html[abs..end];
145 cursor = end;
146 let Some(actual_key) = hreflang_attr(tag, key) else {
147 continue;
148 };
149 if !actual_key.eq_ignore_ascii_case(value) {
150 continue;
151 }
152 let Some(content) = hreflang_attr(tag, "content") else {
153 continue;
154 };
155 if !content.trim().is_empty() {
156 return true;
157 }
158 }
159 false
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 fn site(html: &str) -> Site {
167 let tmp = tempfile::tempdir().unwrap();
168 let p = tmp.path().join("page.html");
169 std::fs::write(&p, html).unwrap();
170 let root = tmp.path().to_path_buf();
171 std::mem::forget(tmp);
172 Site {
173 root,
174 html_files: vec![p],
175 }
176 }
177
178 #[test]
179 fn complete_metadata_is_clean() {
180 let html = r#"<html><head>
181 <title>x</title>
182 <meta name="description" content="a">
183 <meta property="og:title" content="x">
184 <meta property="og:type" content="website">
185 <meta property="og:image" content="/a.png">
186 <meta name="twitter:card" content="summary">
187 </head><body></body></html>"#;
188 let f = MetadataGate.run(&site(html), &AuditOptions::default());
189 assert!(f.is_empty(), "got {f:?}");
190 }
191
192 #[test]
193 fn missing_og_trio_flagged() {
194 let html = r#"<html><head><title>x</title></head><body></body></html>"#;
195 let f = MetadataGate.run(&site(html), &AuditOptions::default());
196 let codes: Vec<_> =
197 f.iter().filter_map(|x| x.code.as_deref()).collect();
198 assert!(codes.contains(&"OG-TITLE"));
199 assert!(codes.contains(&"OG-TYPE"));
200 assert!(codes.contains(&"OG-IMAGE"));
201 assert!(codes.contains(&"META-DESCRIPTION"));
202 }
203
204 #[test]
205 fn missing_title_flagged() {
206 let html = r#"<html><head></head><body></body></html>"#;
207 let f = MetadataGate.run(&site(html), &AuditOptions::default());
208 assert!(f.iter().any(|x| x.code.as_deref() == Some("META-TITLE")));
209 }
210
211 #[test]
212 fn unclosed_title_flagged() {
213 let html = r#"<html><head><title>oops</head><body></body></html>"#;
214 let f = MetadataGate.run(&site(html), &AuditOptions::default());
215 assert!(
216 f.iter().any(|x| x.code.as_deref() == Some("META-TITLE")),
217 "got {f:?}"
218 );
219 }
220
221 #[test]
222 fn empty_title_flagged() {
223 let html =
224 r#"<html><head><title> </title></head><body></body></html>"#;
225 let f = MetadataGate.run(&site(html), &AuditOptions::default());
226 assert!(f.iter().any(|x| x.code.as_deref() == Some("META-TITLE")));
227 }
228
229 #[test]
230 fn missing_twitter_card_warns() {
231 let html = r#"<html><head>
232 <title>x</title>
233 <meta name="description" content="d">
234 <meta property="og:title" content="x">
235 <meta property="og:type" content="website">
236 <meta property="og:image" content="/a.png">
237 </head><body></body></html>"#;
238 let f = MetadataGate.run(&site(html), &AuditOptions::default());
239 assert!(
240 f.iter().any(|x| x.code.as_deref() == Some("TWITTER-CARD")),
241 "got {f:?}"
242 );
243 assert!(f.iter().any(|x| matches!(x.severity, Severity::Warn)));
244 }
245
246 #[test]
247 fn empty_description_content_flagged() {
248 let html = r#"<html><head>
249 <title>x</title>
250 <meta name="description" content="">
251 <meta property="og:title" content="x">
252 <meta property="og:type" content="website">
253 <meta property="og:image" content="/a.png">
254 <meta name="twitter:card" content="summary">
255 </head><body></body></html>"#;
256 let f = MetadataGate.run(&site(html), &AuditOptions::default());
257 assert!(
258 f.iter()
259 .any(|x| x.code.as_deref() == Some("META-DESCRIPTION")),
260 "got {f:?}"
261 );
262 }
263
264 #[test]
265 fn meta_missing_content_attr_flagged() {
266 let html = r#"<html><head>
267 <title>x</title>
268 <meta name="description">
269 <meta property="og:title" content="x">
270 <meta property="og:type" content="website">
271 <meta property="og:image" content="/a.png">
272 <meta name="twitter:card" content="summary">
273 </head><body></body></html>"#;
274 let f = MetadataGate.run(&site(html), &AuditOptions::default());
275 assert!(f
276 .iter()
277 .any(|x| x.code.as_deref() == Some("META-DESCRIPTION")));
278 }
279
280 #[test]
281 fn metadata_methods_exposed() {
282 let g = MetadataGate;
283 assert_eq!(g.name(), "metadata");
284 assert!(g.explain().to_lowercase().contains("open graph"));
285 let _copy: MetadataGate = g;
286 let _clone = g;
287 let dbg = format!("{g:?}");
288 assert!(dbg.contains("MetadataGate"));
289 }
290
291 #[test]
292 fn unreadable_html_skipped() {
293 let tmp = tempfile::tempdir().unwrap();
294 let root = tmp.path().to_path_buf();
295 let dir_as_file = root.join("page.html");
296 std::fs::create_dir_all(&dir_as_file).unwrap();
297 let s = Site {
298 root,
299 html_files: vec![dir_as_file],
300 };
301 let _ = MetadataGate.run(&s, &AuditOptions::default());
302 std::mem::forget(tmp);
303 }
304
305 #[test]
306 fn empty_site_returns_no_findings() {
307 let tmp = tempfile::tempdir().unwrap();
308 let root = tmp.path().to_path_buf();
309 std::mem::forget(tmp);
310 let s = Site {
311 root,
312 html_files: Vec::new(),
313 };
314 let f = MetadataGate.run(&s, &AuditOptions::default());
315 assert!(f.is_empty());
316 }
317}