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            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}