Skip to main content

ssg/audit/gates/
metadata.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Metadata + Open Graph + Twitter card gate.
5//!
6//! Per page:
7//! - `<title>` present + non-empty.
8//! - `<meta name="description">` present + non-empty.
9//! - `og:title`, `og:type`, `og:image`.
10//! - `twitter:card`.
11
12use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
13use super::hreflang_attr;
14
15const NAME: &str = "metadata";
16
17/// Metadata + Open Graph + Twitter card gate.
18///
19/// # Examples
20///
21/// ```
22/// use ssg::audit::AuditGate;
23/// use ssg::audit::gates::metadata::MetadataGate;
24/// assert_eq!(MetadataGate.name(), "metadata");
25/// ```
26#[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}