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