ssg/audit/gates/
performance.rs1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
12use super::hreflang_attr;
13
14const NAME: &str = "performance";
15
16#[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 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 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 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}