use std::collections::{BTreeSet, HashMap, HashSet};
use super::docs::DocPage;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct BrokenLink {
pub source_rel_path: String,
pub target: String,
pub reason: String,
}
pub fn check(pages: &[DocPage], base_path: &str) -> Vec<BrokenLink> {
let routes: HashSet<&str> = pages.iter().map(|page| page.route_path.as_str()).collect();
let anchors: HashMap<&str, HashSet<&str>> = pages
.iter()
.map(|page| {
(
page.route_path.as_str(),
page.headings.iter().map(|heading| heading.id.as_str()).collect(),
)
})
.collect();
let mut broken = BTreeSet::new();
for page in pages {
for target in link_targets(&page.markdown) {
let trimmed = target.trim();
if trimmed.is_empty() || is_external(trimmed) {
continue;
}
let (path, anchor) = match trimmed.split_once('#') {
Some((path, anchor)) => (path, Some(anchor)),
None => (trimmed, None),
};
if !path.is_empty() && !path.ends_with(".md") {
continue;
}
let route = if path.is_empty() {
page.route_path.clone()
} else {
match resolve_route(page, path, base_path) {
Some(route) => route,
None => {
broken.insert(BrokenLink {
source_rel_path: page.source_rel_path.clone(),
target: trimmed.to_string(),
reason: "leaves the docs folder".to_string(),
});
continue;
}
}
};
if !routes.contains(route.as_str()) {
broken.insert(BrokenLink {
source_rel_path: page.source_rel_path.clone(),
target: trimmed.to_string(),
reason: format!("no page answers at {route}"),
});
continue;
}
if let Some(anchor) = anchor.map(str::trim).filter(|anchor| !anchor.is_empty())
&& !anchors.get(route.as_str()).is_some_and(|ids| ids.contains(anchor))
{
broken.insert(BrokenLink {
source_rel_path: page.source_rel_path.clone(),
target: trimmed.to_string(),
reason: format!("{route} has no heading '{anchor}'"),
});
}
}
}
broken.into_iter().collect()
}
pub fn report(broken: &[BrokenLink]) -> String {
let mut out = format!(
"{} broken {} in the docs folder:",
broken.len(),
if broken.len() == 1 { "link" } else { "links" }
);
for link in broken {
out.push_str(&format!("\n {} -> {} ({})", link.source_rel_path, link.target, link.reason));
}
out
}
fn is_external(target: &str) -> bool {
let lower = target.to_ascii_lowercase();
lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.starts_with("mailto:")
|| lower.starts_with("tel:")
|| lower.starts_with("//")
|| lower.starts_with("data:")
}
pub fn page_href(page: &DocPage, target: &str, base_path: &str) -> Option<String> {
let trimmed = target.trim();
if trimmed.is_empty() || is_external(trimmed) {
return None;
}
let (path, anchor) = match trimmed.split_once('#') {
Some((path, anchor)) => (path, Some(anchor)),
None => (trimmed, None),
};
if !path.ends_with(".md") {
return None;
}
let route = resolve_route(page, path, base_path)?;
Some(match anchor {
Some(anchor) => format!("{route}#{anchor}"),
None => route,
})
}
fn resolve_route(page: &DocPage, path: &str, base_path: &str) -> Option<String> {
let mut segments: Vec<&str> = page.source_rel_path.split('/').collect();
segments.pop(); if path.starts_with('/') {
segments.clear();
}
for part in path.trim_start_matches('/').split('/') {
match part {
"" | "." => {}
".." => {
segments.pop()?;
}
other => segments.push(other),
}
}
let joined = segments.join("/");
let mut slug: Vec<&str> = joined.trim_end_matches(".md").split('/').filter(|s| !s.is_empty()).collect();
if slug.last() == Some(&"index") {
slug.pop();
}
let base = base_path.trim_end_matches('/');
Some(if slug.is_empty() { format!("{base}/") } else { format!("{base}/{}/", slug.join("/")) })
}
fn link_targets(markdown: &str) -> Vec<String> {
let bytes = markdown.as_bytes();
let mut out = Vec::new();
let mut i = 0;
let mut in_fence = false;
for line in markdown.lines() {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
}
if in_fence {
i += line.len() + 1;
continue;
}
let start = i;
let end = (i + line.len()).min(bytes.len());
let mut cursor = start;
while let Some(found) = markdown[cursor..end].find("](") {
let open = cursor + found + 2;
let Some(close_rel) = markdown[open..end].find(')') else {
break;
};
let target = &markdown[open..open + close_rel];
let target = target.split_whitespace().next().unwrap_or("");
if !target.is_empty() {
out.push(target.to_string());
}
cursor = open + close_rel + 1;
}
i += line.len() + 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::nodes::basic::web::site::docs::DocHeading;
fn page(rel: &str, markdown: &str, headings: &[&str]) -> DocPage {
let slug: Vec<String> = {
let mut parts: Vec<String> =
rel.trim_end_matches(".md").split('/').filter(|s| !s.is_empty()).map(ToString::to_string).collect();
if parts.last().map(|p| p == "index").unwrap_or(false) {
parts.pop();
}
parts
};
let route = if slug.is_empty() { "/".to_string() } else { format!("/{}/", slug.join("/")) };
DocPage {
slug_segments: slug,
title: rel.to_string(),
description: String::new(),
keywords: Vec::new(),
canonical: None,
noindex: false,
blocks: super::super::docs_blocks::parse(markdown),
markdown: markdown.to_string(),
headings: headings
.iter()
.map(|id| DocHeading { level: 2, id: (*id).to_string(), text: (*id).to_string() })
.collect(),
source_rel_path: rel.to_string(),
output_rel_path: format!("{}index.html", route.trim_start_matches('/')),
route_path: route,
order: None,
source_modified: None,
}
}
#[test]
fn a_relative_link_between_pages_resolves() {
let pages = vec and [up](./index.md).", &[]),
page("guides/writing.md", "Back to [home](../index.md).", &["front-matter"]),
];
assert_eq!(check(&pages, "/"), Vec::new());
}
#[test]
fn a_missing_page_and_a_missing_heading_are_both_reported() {
let pages = vec and [heading](guides/writing.md#nowhere)", &[]),
page("guides/writing.md", "nothing here", &["front-matter"]),
];
let broken = check(&pages, "/");
assert_eq!(broken.len(), 2, "{broken:?}");
assert!(broken.iter().any(|link| link.reason.contains("no page answers at /guides/gone/")));
assert!(broken.iter().any(|link| link.reason.contains("has no heading 'nowhere'")));
}
#[test]
fn an_anchor_on_this_page_is_checked_against_this_page() {
let pages = vec and [there](#missing)", &["the-proof"])];
let broken = check(&pages, "/");
assert_eq!(broken.len(), 1);
assert!(broken[0].reason.contains("has no heading 'missing'"), "{:?}", broken[0]);
}
#[test]
fn external_links_and_code_blocks_are_left_alone() {
let pages = vec [mail](mailto:nobody@example.com)\n\n```md\n[in code](nowhere.md)\n```\n",
&[],
)];
assert_eq!(check(&pages, "/"), Vec::new());
}
#[test]
fn a_file_that_is_not_a_page_is_not_a_link() {
let pages = vec\n\n<PotoPlayer src=\"stories/intro.poto\" />\n\n[a download](../files/report.pdf)",
&[],
)];
assert_eq!(check(&pages, "/"), Vec::new());
}
#[test]
fn under_a_base_path_links_resolve_and_are_written_as_addresses() {
let mut pages = vec.", &[]),
page("guides/writing.md", "Back to [home](../index.md).", &["front-matter"]),
];
for page in pages.iter_mut() {
page.route_path = format!("/docs/0.11{}", page.route_path);
}
assert_eq!(check(&pages, "/docs/0.11/"), Vec::new());
assert!(!check(&pages, "/").is_empty(), "the same links under the wrong base answer nothing");
assert_eq!(page_href(&pages[0], "guides/writing.md#front-matter", "/docs/0.11/").as_deref(), Some("/docs/0.11/guides/writing/#front-matter"));
assert_eq!(page_href(&pages[1], "../index.md", "/docs/0.11/").as_deref(), Some("/docs/0.11/"));
assert_eq!(page_href(&pages[1], "/index.md", "/").as_deref(), Some("/"));
assert_eq!(page_href(&pages[0], "diagram.png", "/docs/"), None);
assert_eq!(page_href(&pages[0], "https://example.com/a.md", "/docs/"), None);
assert_eq!(page_href(&pages[0], "../../out.md", "/docs/"), None);
}
#[test]
fn a_link_out_of_the_folder_is_reported() {
let pages = vec", &[])];
let broken = check(&pages, "/");
assert_eq!(broken.len(), 1);
assert_eq!(broken[0].reason, "leaves the docs folder");
}
}