Skip to main content

ssg/audit/gates/
performance.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Performance budget gate.
5//!
6//! Per page: enforces HTML weight + inline `<style>` against
7//! `page_weight_budget` and the sum of inline `<script>` + first-party
8//! referenced JS file sizes against `js_budget` (both configurable via
9//! `[audit.budgets]`).
10
11use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
12use super::hreflang_attr;
13
14const NAME: &str = "performance";
15
16/// Performance budget gate.
17///
18/// # Examples
19///
20/// ```
21/// use ssg::audit::AuditGate;
22/// use ssg::audit::gates::performance::PerformanceGate;
23/// assert_eq!(PerformanceGate.name(), "performance");
24/// ```
25#[derive(Debug, Clone, Copy)]
26pub struct PerformanceGate;
27
28impl AuditGate for PerformanceGate {
29    fn name(&self) -> &'static str {
30        NAME
31    }
32
33    fn explain(&self) -> &'static str {
34        "Enforces per-page weight + JS budgets. Default page weight \
35         budget is 100 KiB (HTML + inline CSS); default JS budget is \
36         50 KiB (inline + first-party referenced bundles). Tune via \
37         the [audit.budgets] table in ssg.toml."
38    }
39
40    fn run(&self, site: &Site, opts: &AuditOptions) -> Vec<Finding> {
41        let mut findings = Vec::new();
42        for path in &site.html_files {
43            let Ok(html) = site.read(path) else { continue };
44            let rel = site.rel(path);
45
46            let html_weight = html.len();
47            if html_weight > opts.page_weight_budget {
48                findings.push(
49                    Finding::new(
50                        NAME,
51                        Severity::Warn,
52                        format!(
53                            "page weight {html_weight} bytes exceeds budget {} bytes",
54                            opts.page_weight_budget
55                        ),
56                    )
57                    .with_code("PERF-PAGE-OVER")
58                    .with_path(rel.clone()),
59                );
60            }
61
62            let js_weight = inline_js_bytes(&html)
63                + referenced_js_bytes(&site.root, path, &html);
64            if js_weight > opts.js_budget {
65                findings.push(
66                    Finding::new(
67                        NAME,
68                        Severity::Warn,
69                        format!(
70                            "JS weight {js_weight} bytes exceeds budget {} bytes",
71                            opts.js_budget
72                        ),
73                    )
74                    .with_code("PERF-JS-OVER")
75                    .with_path(rel.clone()),
76                );
77            }
78        }
79        findings
80    }
81}
82
83fn inline_js_bytes(html: &str) -> usize {
84    let lower = html.to_lowercase();
85    let mut total = 0usize;
86    let mut cursor = 0;
87    while let Some(rel) = lower[cursor..].find("<script") {
88        let abs = cursor + rel;
89        let open_end =
90            lower[abs..].find('>').map_or(lower.len(), |e| abs + e + 1);
91        let tag = &html[abs..open_end];
92        if hreflang_attr(tag, "src").is_some() {
93            cursor = open_end;
94            continue;
95        }
96        cursor = open_end;
97        let Some(close) = lower[cursor..].find("</script>") else {
98            break;
99        };
100        total += close;
101        cursor += close + "</script>".len();
102    }
103    total
104}
105
106fn referenced_js_bytes(
107    root: &std::path::Path,
108    page: &std::path::Path,
109    html: &str,
110) -> usize {
111    let lower = html.to_lowercase();
112    let mut total = 0usize;
113    let mut cursor = 0;
114    while let Some(rel) = lower[cursor..].find("<script") {
115        let abs = cursor + rel;
116        let end = lower[abs..].find('>').map_or(lower.len(), |e| abs + e + 1);
117        let tag = &html[abs..end];
118        cursor = end;
119        let Some(src) = hreflang_attr(tag, "src") else {
120            continue;
121        };
122        if src.starts_with("http://")
123            || src.starts_with("https://")
124            || src.starts_with("//")
125        {
126            continue;
127        }
128        let candidate = if let Some(s) = src.strip_prefix('/') {
129            root.join(s)
130        } else if let Some(parent) = page.parent() {
131            parent.join(&src)
132        } else {
133            root.join(&src)
134        };
135        if let Ok(meta) = std::fs::metadata(&candidate) {
136            total += meta.len() as usize;
137        }
138    }
139    total
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    fn site(html: &str) -> Site {
147        let tmp = tempfile::tempdir().unwrap();
148        let p = tmp.path().join("page.html");
149        std::fs::write(&p, html).unwrap();
150        let root = tmp.path().to_path_buf();
151        std::mem::forget(tmp);
152        Site {
153            root,
154            html_files: vec![p],
155        }
156    }
157
158    #[test]
159    fn small_page_passes() {
160        let html = "<html><body>tiny</body></html>";
161        let f = PerformanceGate.run(&site(html), &AuditOptions::default());
162        assert!(f.is_empty(), "got {f:?}");
163    }
164
165    #[test]
166    fn over_budget_page_flagged() {
167        let html = "<html>".to_string() + &"x".repeat(2000) + "</html>";
168        let opts = AuditOptions {
169            page_weight_budget: 100,
170            ..AuditOptions::default()
171        };
172        let f = PerformanceGate.run(&site(&html), &opts);
173        assert!(f
174            .iter()
175            .any(|x| x.code.as_deref() == Some("PERF-PAGE-OVER")));
176    }
177
178    #[test]
179    fn over_budget_inline_js_flagged() {
180        let html = format!(
181            "<html><body><script>{}</script></body></html>",
182            "x".repeat(2000)
183        );
184        let opts = AuditOptions {
185            js_budget: 100,
186            page_weight_budget: 1_000_000,
187            ..AuditOptions::default()
188        };
189        let f = PerformanceGate.run(&site(&html), &opts);
190        assert!(f.iter().any(|x| x.code.as_deref() == Some("PERF-JS-OVER")));
191    }
192
193    #[test]
194    fn external_script_src_does_not_count_toward_js_budget() {
195        // Tiny page-weight budget yields PERF-PAGE-OVER, keeping `f`
196        // non-empty so the no-JS-OVER predicate actually evaluates.
197        let html = r#"<html><body><script src="https://cdn.example/big.js"></script></body></html>"#;
198        let opts = AuditOptions {
199            js_budget: 50,
200            page_weight_budget: 10,
201            ..AuditOptions::default()
202        };
203        let f = PerformanceGate.run(&site(html), &opts);
204        assert!(
205            f.iter().all(|x| x.code.as_deref() != Some("PERF-JS-OVER")),
206            "external scripts must not count; got {f:?}"
207        );
208    }
209
210    #[test]
211    fn protocol_relative_script_src_is_external() {
212        // Tiny page-weight budget keeps `f` non-empty (see above).
213        let html = r#"<html><body><script src="//cdn.example/big.js"></script></body></html>"#;
214        let opts = AuditOptions {
215            js_budget: 1,
216            page_weight_budget: 10,
217            ..AuditOptions::default()
218        };
219        let f = PerformanceGate.run(&site(html), &opts);
220        assert!(f.iter().all(|x| x.code.as_deref() != Some("PERF-JS-OVER")));
221    }
222
223    #[test]
224    fn referenced_root_relative_js_is_summed() {
225        let tmp = tempfile::tempdir().unwrap();
226        let root = tmp.path().to_path_buf();
227        std::fs::write(root.join("big.js"), vec![0u8; 5_000]).unwrap();
228        let html_path = root.join("page.html");
229        std::fs::write(
230            &html_path,
231            r#"<html><body><script src="/big.js"></script></body></html>"#,
232        )
233        .unwrap();
234        std::mem::forget(tmp);
235        let s = Site {
236            root,
237            html_files: vec![html_path],
238        };
239        let opts = AuditOptions {
240            js_budget: 100,
241            page_weight_budget: 1_000_000,
242            ..AuditOptions::default()
243        };
244        let f = PerformanceGate.run(&s, &opts);
245        assert!(
246            f.iter().any(|x| x.code.as_deref() == Some("PERF-JS-OVER")),
247            "expected JS-OVER from referenced file; got {f:?}"
248        );
249    }
250
251    #[test]
252    fn referenced_relative_js_uses_page_parent() {
253        let tmp = tempfile::tempdir().unwrap();
254        let root = tmp.path().to_path_buf();
255        let sub = root.join("sub");
256        std::fs::create_dir_all(&sub).unwrap();
257        std::fs::write(sub.join("local.js"), vec![0u8; 5_000]).unwrap();
258        let html_path = sub.join("page.html");
259        std::fs::write(
260            &html_path,
261            r#"<html><body><script src="local.js"></script></body></html>"#,
262        )
263        .unwrap();
264        std::mem::forget(tmp);
265        let s = Site {
266            root,
267            html_files: vec![html_path],
268        };
269        let opts = AuditOptions {
270            js_budget: 100,
271            page_weight_budget: 1_000_000,
272            ..AuditOptions::default()
273        };
274        let f = PerformanceGate.run(&s, &opts);
275        assert!(
276            f.iter().any(|x| x.code.as_deref() == Some("PERF-JS-OVER")),
277            "expected JS-OVER from page-relative ref; got {f:?}"
278        );
279    }
280
281    #[test]
282    fn unreadable_html_file_is_skipped() {
283        let tmp = tempfile::tempdir().unwrap();
284        let bogus = tmp.path().join("ghost.html");
285        let s = Site {
286            root: tmp.path().to_path_buf(),
287            html_files: vec![bogus],
288        };
289        std::mem::forget(tmp);
290        let f = PerformanceGate.run(&s, &AuditOptions::default());
291        assert!(f.is_empty());
292    }
293
294    #[test]
295    fn inline_js_is_counted_when_no_src_attribute() {
296        let html = format!(
297            "<html><body><script>{}</script><script>nested</script></body></html>",
298            "y".repeat(200)
299        );
300        let opts = AuditOptions {
301            js_budget: 100,
302            page_weight_budget: 1_000_000,
303            ..AuditOptions::default()
304        };
305        let f = PerformanceGate.run(&site(&html), &opts);
306        assert!(f.iter().any(|x| x.code.as_deref() == Some("PERF-JS-OVER")));
307    }
308
309    #[test]
310    fn unterminated_inline_script_stops_counting() {
311        // No closing </script>: the scanner bails instead of looping.
312        assert_eq!(inline_js_bytes("<html><script>var x = 1;"), 0);
313    }
314
315    #[test]
316    fn missing_referenced_js_file_adds_nothing() {
317        let tmp = tempfile::tempdir().unwrap();
318        let root = tmp.path().to_path_buf();
319        let html = r#"<script src="ghost.js"></script>"#;
320        let page = root.join("page.html");
321        let got = referenced_js_bytes(&root, &page, html);
322        std::mem::forget(tmp);
323        assert_eq!(got, 0);
324    }
325
326    #[test]
327    fn referenced_js_for_parentless_page_falls_back_to_root() {
328        let tmp = tempfile::tempdir().unwrap();
329        let root = tmp.path().to_path_buf();
330        std::fs::write(root.join("app.js"), vec![0u8; 123]).unwrap();
331        let html = r#"<script src="app.js"></script>"#;
332        let got = referenced_js_bytes(&root, std::path::Path::new(""), html);
333        std::mem::forget(tmp);
334        assert_eq!(got, 123);
335    }
336
337    #[test]
338    fn metadata_methods_exposed() {
339        let g = PerformanceGate;
340        assert_eq!(g.name(), "performance");
341        assert!(g.explain().contains("budget"));
342        let _copy: PerformanceGate = g;
343        let _clone = g;
344        assert!(format!("{g:?}").contains("PerformanceGate"));
345    }
346}