Skip to main content

ssg/audit/gates/
broken_links.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Broken internal/external link gate.
5//!
6//! Walks every `<a href>` (and `<img src>`) on every page. Internal
7//! links are resolved against the site root and reported as errors
8//! when their target does not exist. External links are reported as
9//! info when `--skip-network` is set (the default), and probed via
10//! HTTP HEAD only when explicitly opted in.
11
12use 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/// Broken internal/external link gate.
19///
20/// # Examples
21///
22/// ```
23/// use ssg::audit::AuditGate;
24/// use ssg::audit::gates::broken_links::BrokenLinksGate;
25/// assert_eq!(BrokenLinksGate.name(), "links");
26/// assert!(BrokenLinksGate.explain().contains("href"));
27/// ```
28#[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    // Blank out <script>/<style> contents first: markup embedded in JS
110    // string literals (e.g. the search overlay building result rows
111    // with '<a href="'+esc(lp+e.url)+'">') is not a document link.
112    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    // NOTE: probing `<candidate>/index.html` here would be dead code —
153    // an existing `index.html` child implies `candidate` is an
154    // existing, traversable directory, which `exists()` above already
155    // returned true for.
156    // /foo (no extension) -> /foo.html
157    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            // root.join(rel) always has a parent directory.
178            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        // The broken internal link keeps `f` non-empty so the
359        // no-external-skip predicate actually evaluates.
360        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        // Regression: 10 false LINK-INTERNAL-MISSING from the search
408        // overlay's JS building '<a … href="'+esc(lp+e.url)+'">'.
409        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        // True positive preserved: a real broken <a href> in the body
437        // must still fire even when the page carries <script> blocks.
438        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        // find('>') used to cut the tag inside quoted values.
461        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        // <a> without href / <img> without src are skipped.
487        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        // Covers line 134 — `href_clean.is_empty()` early return.
496        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        // Covers line 143 — `else { root.join(href_clean) }` arm when
506        // the page has no parent.
507        let tmp = tempfile::tempdir().unwrap();
508        std::fs::write(tmp.path().join("target.html"), "x").unwrap();
509        // Pass the root itself as the page; root has no parent
510        // outside the tempdir, but its `parent()` is still Some — to
511        // force the else arm we use an empty-component Path.
512        // The simplest deterministic path: the function strips '?'
513        // and '#' then joins root + href_clean.
514        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        // A directory target resolves via `candidate.exists()`.
524        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        // Trailing-slash directory hrefs resolve via `exists()` too.
534        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        // Extension present: the `.html` fallback must not engage.
544        let tmp = tempfile::tempdir().unwrap();
545        assert!(!internal_target_exists(
546            tmp.path(),
547            tmp.path(),
548            "/ghost.png"
549        ));
550    }
551}