1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
13use super::{find_tag_end, hreflang_attr, strip_script_and_style};
14use std::path::PathBuf;
15
16const NAME: &str = "links";
17
18#[derive(Debug, Clone, Copy)]
29pub struct BrokenLinksGate;
30
31impl AuditGate for BrokenLinksGate {
32 fn name(&self) -> &'static str {
33 NAME
34 }
35
36 fn explain(&self) -> &'static str {
37 "Walks every <a href> and <img src> in the site. Internal \
38 targets must resolve under the site root or an error is \
39 emitted. External targets are skipped by default (set \
40 skip_network=false to enable HEAD probing). Anchor-only \
41 hrefs (#fragment) and `mailto:` / `tel:` URIs are ignored."
42 }
43
44 fn run(&self, site: &Site, opts: &AuditOptions) -> Vec<Finding> {
45 let mut findings = Vec::new();
46 let mut external_skipped = 0usize;
47
48 for path in &site.html_files {
49 let Ok(html) = site.read(path) else { continue };
50 let rel = site.rel(path);
51 for href in extract_link_targets(&html) {
52 if is_ignorable(&href) {
53 continue;
54 }
55 if is_external(&href) {
56 if opts.skip_network {
57 external_skipped += 1;
58 }
59 continue;
60 }
61 if !internal_target_exists(&site.root, path, &href) {
62 findings.push(
63 Finding::new(
64 NAME,
65 Severity::Error,
66 format!("internal link `{href}` does not resolve"),
67 )
68 .with_code("LINK-INTERNAL-MISSING")
69 .with_path(rel.clone()),
70 );
71 }
72 }
73 }
74
75 if external_skipped > 0 {
76 findings.push(
77 Finding::new(
78 NAME,
79 Severity::Info,
80 format!(
81 "{external_skipped} external link(s) skipped (--skip-network)"
82 ),
83 )
84 .with_code("LINK-EXTERNAL-SKIPPED"),
85 );
86 }
87
88 findings
89 }
90}
91
92fn is_ignorable(href: &str) -> bool {
93 href.starts_with('#')
94 || href.starts_with("mailto:")
95 || href.starts_with("tel:")
96 || href.starts_with("javascript:")
97 || href.starts_with("data:")
98 || href.is_empty()
99}
100
101fn is_external(href: &str) -> bool {
102 href.starts_with("http://")
103 || href.starts_with("https://")
104 || href.starts_with("//")
105}
106
107fn extract_link_targets(html: &str) -> Vec<String> {
108 let mut out = Vec::new();
109 let html = strip_script_and_style(html);
113 let lower = html.to_ascii_lowercase();
114 for (open, attr) in &[("<a ", "href"), ("<img", "src")] {
115 let mut cursor = 0;
116 while let Some(rel) = lower[cursor..].find(open) {
117 let abs = cursor + rel;
118 let end = find_tag_end(&html, abs);
119 let tag = &html[abs..end];
120 cursor = end;
121 if let Some(v) = hreflang_attr(tag, attr) {
122 out.push(v);
123 }
124 }
125 }
126 out
127}
128
129fn internal_target_exists(
130 root: &std::path::Path,
131 page: &std::path::Path,
132 href: &str,
133) -> bool {
134 let href_clean = href.split('?').next().unwrap_or(href);
135 let href_clean = href_clean.split('#').next().unwrap_or(href_clean);
136 if href_clean.is_empty() {
137 return true;
138 }
139
140 let candidate: PathBuf =
141 if let Some(stripped) = href_clean.strip_prefix('/') {
142 root.join(stripped)
143 } else if let Some(parent) = page.parent() {
144 parent.join(href_clean)
145 } else {
146 root.join(href_clean)
147 };
148
149 if candidate.exists() {
150 return true;
151 }
152 let mut html_candidate = candidate;
158 if html_candidate.extension().is_none() {
159 let _ = html_candidate.set_extension("html");
160 if html_candidate.exists() {
161 return true;
162 }
163 }
164 false
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 fn site_with(pages: &[(&str, &str)]) -> Site {
172 let tmp = tempfile::tempdir().unwrap();
173 let root = tmp.path().to_path_buf();
174 let mut files = Vec::new();
175 for (rel, html) in pages {
176 let p = root.join(rel);
177 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
179 std::fs::write(&p, html).unwrap();
180 files.push(p);
181 }
182 std::mem::forget(tmp);
183 Site {
184 root,
185 html_files: files,
186 }
187 }
188
189 #[test]
190 fn passing_internal_link_is_clean() {
191 let pages = &[
192 (
193 "index.html",
194 r#"<html><body><a href="/about/">about</a><a href="https://ext.example/">ext</a></body></html>"#,
195 ),
196 ("about/index.html", "<html><body>about</body></html>"),
197 ];
198 let f = BrokenLinksGate.run(
199 &site_with(pages),
200 &AuditOptions {
201 skip_network: true,
202 ..AuditOptions::default()
203 },
204 );
205 let errors: Vec<_> =
206 f.iter().filter(|x| x.severity == Severity::Error).collect();
207 assert!(errors.is_empty(), "got {errors:?}");
208 }
209
210 #[test]
211 fn broken_internal_link_flagged() {
212 let pages = &[(
213 "index.html",
214 r#"<html><body><a href="/missing/">x</a></body></html>"#,
215 )];
216 let f = BrokenLinksGate.run(
217 &site_with(pages),
218 &AuditOptions {
219 skip_network: true,
220 ..AuditOptions::default()
221 },
222 );
223 assert!(f
224 .iter()
225 .any(|x| x.code.as_deref() == Some("LINK-INTERNAL-MISSING")));
226 }
227
228 #[test]
229 fn skip_network_emits_info_for_externals() {
230 let pages = &[(
231 "index.html",
232 r#"<html><body><a href="https://example.com">x</a></body></html>"#,
233 )];
234 let f = BrokenLinksGate.run(
235 &site_with(pages),
236 &AuditOptions {
237 skip_network: true,
238 ..AuditOptions::default()
239 },
240 );
241 assert!(f
242 .iter()
243 .any(|x| x.code.as_deref() == Some("LINK-EXTERNAL-SKIPPED")));
244 }
245
246 #[test]
247 fn ignorable_schemes_are_silent() {
248 let pages = &[(
249 "index.html",
250 r##"<html><body>
251 <a href="#anchor">a</a>
252 <a href="mailto:[email protected]">m</a>
253 <a href="tel:+1">t</a>
254 <a href="javascript:void(0)">j</a>
255 <a href="data:image/png;base64,xx">d</a>
256 <a href="">e</a>
257 <a href="https://ext.example/">keeps f non-empty</a>
258 </body></html>"##,
259 )];
260 let f = BrokenLinksGate.run(
261 &site_with(pages),
262 &AuditOptions {
263 skip_network: true,
264 ..AuditOptions::default()
265 },
266 );
267 assert!(
268 f.iter()
269 .all(|x| x.code.as_deref() != Some("LINK-INTERNAL-MISSING")),
270 "ignorable schemes flagged: {f:?}"
271 );
272 }
273
274 #[test]
275 fn protocol_relative_link_treated_as_external() {
276 let pages = &[(
277 "index.html",
278 r#"<html><body><a href="//cdn.example/x">x</a></body></html>"#,
279 )];
280 let f = BrokenLinksGate.run(
281 &site_with(pages),
282 &AuditOptions {
283 skip_network: true,
284 ..AuditOptions::default()
285 },
286 );
287 assert!(f
288 .iter()
289 .any(|x| x.code.as_deref() == Some("LINK-EXTERNAL-SKIPPED")));
290 }
291
292 #[test]
293 fn img_src_links_are_checked() {
294 let pages = &[(
295 "index.html",
296 r#"<html><body><img src="/missing.png" alt="x"></body></html>"#,
297 )];
298 let f = BrokenLinksGate.run(
299 &site_with(pages),
300 &AuditOptions {
301 skip_network: true,
302 ..AuditOptions::default()
303 },
304 );
305 assert!(f
306 .iter()
307 .any(|x| x.code.as_deref() == Some("LINK-INTERNAL-MISSING")));
308 }
309
310 #[test]
311 fn relative_link_with_query_and_fragment_strips_correctly() {
312 let pages = &[
313 (
314 "index.html",
315 r#"<html><body><a href="about.html?x=1#sec">a</a><a href="https://ext.example/">ext</a></body></html>"#,
316 ),
317 ("about.html", "<html></html>"),
318 ];
319 let f = BrokenLinksGate.run(
320 &site_with(pages),
321 &AuditOptions {
322 skip_network: true,
323 ..AuditOptions::default()
324 },
325 );
326 assert!(
327 f.iter()
328 .all(|x| x.code.as_deref() != Some("LINK-INTERNAL-MISSING")),
329 "query/fragment must strip: {f:?}"
330 );
331 }
332
333 #[test]
334 fn extensionless_internal_link_resolves_via_html_extension() {
335 let pages = &[
336 (
337 "index.html",
338 r#"<html><body><a href="/about">a</a><a href="https://ext.example/">ext</a></body></html>"#,
339 ),
340 ("about.html", "<html></html>"),
341 ];
342 let f = BrokenLinksGate.run(
343 &site_with(pages),
344 &AuditOptions {
345 skip_network: true,
346 ..AuditOptions::default()
347 },
348 );
349 assert!(
350 f.iter()
351 .all(|x| x.code.as_deref() != Some("LINK-INTERNAL-MISSING")),
352 "extensionless resolution failed: {f:?}"
353 );
354 }
355
356 #[test]
357 fn no_skip_network_does_not_emit_external_skip_finding() {
358 let pages = &[(
361 "index.html",
362 r#"<html><body><a href="https://example.com">x</a><a href="/missing/">m</a></body></html>"#,
363 )];
364 let f = BrokenLinksGate.run(
365 &site_with(pages),
366 &AuditOptions {
367 skip_network: false,
368 ..AuditOptions::default()
369 },
370 );
371 assert!(f
372 .iter()
373 .all(|x| x.code.as_deref() != Some("LINK-EXTERNAL-SKIPPED")));
374 }
375
376 #[test]
377 fn unreadable_html_skipped_no_panic() {
378 let tmp = tempfile::tempdir().unwrap();
379 let bogus = tmp.path().join("ghost.html");
380 let s = Site {
381 root: tmp.path().to_path_buf(),
382 html_files: vec![bogus],
383 };
384 std::mem::forget(tmp);
385 let f = BrokenLinksGate.run(
386 &s,
387 &AuditOptions {
388 skip_network: true,
389 ..AuditOptions::default()
390 },
391 );
392 assert!(f.is_empty());
393 }
394
395 #[test]
396 fn metadata_methods_exposed() {
397 let g = BrokenLinksGate;
398 assert_eq!(g.name(), "links");
399 assert!(g.explain().contains("Internal"));
400 let _copy: BrokenLinksGate = g;
401 let _clone = g;
402 assert!(format!("{g:?}").contains("BrokenLinksGate"));
403 }
404
405 #[test]
406 fn anchor_markup_inside_script_string_is_ignored() {
407 let pages = &[
410 (
411 "index.html",
412 "<html><body><script>\nvar html='';\
413 html+='<a class=\"ssg-result\" href=\"'+esc(lp+e.url)+'\">'\
414 +'<div>x</div></a>';\n</script>\
415 <a href=\"/exists/\">real</a>\
416 <a href=\"https://ext.example/\">ext</a></body></html>",
417 ),
418 ("exists/index.html", "<html><body>t</body></html>"),
419 ];
420 let f = BrokenLinksGate.run(
421 &site_with(pages),
422 &AuditOptions {
423 skip_network: true,
424 ..AuditOptions::default()
425 },
426 );
427 assert!(
428 f.iter()
429 .all(|x| x.code.as_deref() != Some("LINK-INTERNAL-MISSING")),
430 "JS-string anchors must be ignored: {f:?}"
431 );
432 }
433
434 #[test]
435 fn broken_link_in_body_still_fires_when_page_has_scripts() {
436 let pages = &[(
439 "index.html",
440 "<html><body><script>var x='<a href=\"/js-only/\">';</script>\
441 <a href=\"/really-missing/\">broken</a></body></html>",
442 )];
443 let f = BrokenLinksGate.run(
444 &site_with(pages),
445 &AuditOptions {
446 skip_network: true,
447 ..AuditOptions::default()
448 },
449 );
450 let missing: Vec<_> = f
451 .iter()
452 .filter(|x| x.code.as_deref() == Some("LINK-INTERNAL-MISSING"))
453 .collect();
454 assert_eq!(missing.len(), 1, "exactly the body link: {f:?}");
455 assert!(missing[0].message.contains("/really-missing/"));
456 }
457
458 #[test]
459 fn href_with_raw_gt_in_quoted_value_does_not_truncate_tag() {
460 let pages = &[
462 (
463 "index.html",
464 "<html><body><img src=\"data:image/svg+xml;utf8,<svg viewBox='0 0 1 1'></svg>\" alt=\"x\">\
465 <a href=\"/ok/\">ok</a>\
466 <a href=\"https://ext.example/\">ext</a></body></html>",
467 ),
468 ("ok/index.html", "<html><body>t</body></html>"),
469 ];
470 let f = BrokenLinksGate.run(
471 &site_with(pages),
472 &AuditOptions {
473 skip_network: true,
474 ..AuditOptions::default()
475 },
476 );
477 assert!(
478 f.iter()
479 .all(|x| x.code.as_deref() != Some("LINK-INTERNAL-MISSING")),
480 "quoted `>` must not truncate tags: {f:?}"
481 );
482 }
483
484 #[test]
485 fn tags_without_target_attribute_yield_no_links() {
486 let targets = extract_link_targets(
488 r#"<a id="top">anchor</a><img alt="deco"><a href="/x">x</a>"#,
489 );
490 assert_eq!(targets, vec!["/x".to_string()]);
491 }
492
493 #[test]
494 fn internal_target_exists_empty_href_is_ok() {
495 let tmp = tempfile::tempdir().unwrap();
497 assert!(internal_target_exists(tmp.path(), tmp.path(), "#"));
498 assert!(internal_target_exists(tmp.path(), tmp.path(), "?"));
499 assert!(internal_target_exists(tmp.path(), tmp.path(), ""));
500 }
501
502 #[test]
503 fn internal_target_exists_resolves_relative_from_root_when_page_has_no_parent(
504 ) {
505 let tmp = tempfile::tempdir().unwrap();
508 std::fs::write(tmp.path().join("target.html"), "x").unwrap();
509 assert!(internal_target_exists(
515 tmp.path(),
516 std::path::Path::new(""),
517 "target.html"
518 ));
519 }
520
521 #[test]
522 fn internal_target_exists_dir_with_index_html() {
523 let tmp = tempfile::tempdir().unwrap();
525 let sub = tmp.path().join("docs");
526 std::fs::create_dir_all(&sub).unwrap();
527 std::fs::write(sub.join("index.html"), "<html/>").unwrap();
528 assert!(internal_target_exists(tmp.path(), tmp.path(), "/docs"));
529 }
530
531 #[test]
532 fn internal_target_exists_via_trailing_slash_dir() {
533 let tmp = tempfile::tempdir().unwrap();
535 let sub = tmp.path().join("section");
536 std::fs::create_dir_all(&sub).unwrap();
537 std::fs::write(sub.join("index.html"), "<html/>").unwrap();
538 assert!(internal_target_exists(tmp.path(), tmp.path(), "/section/"));
539 }
540
541 #[test]
542 fn internal_target_with_extension_and_missing_is_false() {
543 let tmp = tempfile::tempdir().unwrap();
545 assert!(!internal_target_exists(
546 tmp.path(),
547 tmp.path(),
548 "/ghost.png"
549 ));
550 }
551}