use std::sync::Arc;
pub type MarkdownPageProbe = Arc<dyn Fn(&str) -> bool + Send + Sync>;
pub fn filesystem_markdown_page_probe(
resolver: crate::path_resolver::OwnedPathResolverConfig,
) -> MarkdownPageProbe {
Arc::new(move |absolute_url: &str| {
let request_path = crate::path_resolver::normalize_link_target(absolute_url);
matches!(
crate::path_resolver::resolve_request_path(&resolver.as_config(), &request_path),
crate::path_resolver::ResolvedPath::MarkdownFile(_)
)
})
}
#[derive(Clone)]
pub struct LinkTransformConfig {
pub markdown_extensions: Vec<String>,
pub index_file: String,
pub is_index_file: bool,
pub url_depth: Option<usize>,
pub current_page_url: String,
pub markdown_page_probe: Option<MarkdownPageProbe>,
}
impl std::fmt::Debug for LinkTransformConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LinkTransformConfig")
.field("markdown_extensions", &self.markdown_extensions)
.field("index_file", &self.index_file)
.field("is_index_file", &self.is_index_file)
.field("url_depth", &self.url_depth)
.field("current_page_url", &self.current_page_url)
.field("markdown_page_probe", &self.markdown_page_probe.is_some())
.finish()
}
}
impl Default for LinkTransformConfig {
fn default() -> Self {
Self {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
}
}
}
pub fn transform_link(url: &str, config: &LinkTransformConfig) -> String {
if url.is_empty() || url.trim().is_empty() {
return url.to_string();
}
if url.starts_with('#') {
return url.to_string();
}
if crate::url_path::is_external_url(url) {
return url.to_string();
}
if url.starts_with('/') {
return match config.url_depth {
Some(depth) => make_relative_url(url, depth),
None => url.to_string(),
};
}
let (path, suffix) = split_url_parts(url);
if path.is_empty() {
return url.to_string();
}
let path = path.strip_prefix("./").unwrap_or(&path);
let (parent_count, remaining_path) = count_parent_traversals(path);
if remaining_path.is_empty() {
let prefix = if config.is_index_file {
"../".repeat(parent_count)
} else {
"../".repeat(parent_count + 1)
};
return format!("{}{}", prefix, suffix);
}
if let Some(base_path) = strip_markdown_extension(remaining_path, &config.markdown_extensions) {
let index_stem = config
.index_file
.strip_suffix(".md")
.or_else(|| config.index_file.strip_suffix(".markdown"))
.unwrap_or(&config.index_file);
let is_index_target =
base_path == index_stem || base_path.ends_with(&format!("/{}", index_stem));
let final_path = if is_index_target {
let stripped = base_path
.strip_suffix(index_stem)
.unwrap_or(base_path)
.trim_end_matches('/');
if stripped.is_empty() {
"".to_string()
} else {
format!("{}/", stripped)
}
} else {
format!("{}/", base_path)
};
let prefix = if config.is_index_file {
"../".repeat(parent_count)
} else {
"../".repeat(parent_count + 1)
};
if final_path.is_empty() && prefix.is_empty() {
return format!("./{}", suffix);
}
return format!("{}{}{}", prefix, final_path, suffix);
}
let prefix = if config.is_index_file {
"../".repeat(parent_count)
} else {
"../".repeat(parent_count + 1)
};
if !remaining_path.ends_with('/') && resolves_to_markdown_page(path, config) {
return format!("{}{}/{}", prefix, remaining_path, suffix);
}
format!("{}{}{}", prefix, remaining_path, suffix)
}
fn resolves_to_markdown_page(authored_path: &str, config: &LinkTransformConfig) -> bool {
let Some(probe) = &config.markdown_page_probe else {
return false;
};
if config.current_page_url.is_empty() {
return false;
}
match crate::link_index::resolve_relative_url_checked(
&config.current_page_url,
authored_path,
config.is_index_file,
) {
None => false,
Some(absolute) => probe(&absolute),
}
}
fn split_url_parts(url: &str) -> (String, String) {
let anchor_pos = url.find('#');
let query_pos = url.find('?');
let split_pos = match (anchor_pos, query_pos) {
(Some(a), Some(q)) => Some(a.min(q)),
(Some(a), None) => Some(a),
(None, Some(q)) => Some(q),
(None, None) => None,
};
match split_pos {
Some(pos) => (url[..pos].to_string(), url[pos..].to_string()),
None => (url.to_string(), String::new()),
}
}
fn count_parent_traversals(path: &str) -> (usize, &str) {
let mut count = 0;
let mut remaining = path;
while let Some(rest) = remaining.strip_prefix("../") {
count += 1;
remaining = rest;
}
(count, remaining)
}
fn strip_markdown_extension<'a>(path: &'a str, extensions: &[String]) -> Option<&'a str> {
for ext in extensions {
let suffix = format!(".{}", ext);
if path.ends_with(&suffix) {
return Some(&path[..path.len() - suffix.len()]);
}
}
None
}
pub fn make_relative_url(absolute_url: &str, depth: usize) -> String {
let target = absolute_url.trim_start_matches('/');
if target.is_empty() {
if depth == 0 {
"./".to_string()
} else {
"../".repeat(depth)
}
} else {
if depth == 0 {
target.to_string()
} else {
format!("{}{}", "../".repeat(depth), target)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn regular_config() -> LinkTransformConfig {
LinkTransformConfig {
markdown_extensions: vec!["md".to_string(), "markdown".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
}
}
fn index_config() -> LinkTransformConfig {
LinkTransformConfig {
is_index_file: true,
..regular_config()
}
}
#[test]
fn test_simple_relative_md() {
assert_eq!(transform_link("other.md", ®ular_config()), "../other/");
}
#[test]
fn test_subdirectory_md() {
assert_eq!(
transform_link("sub/doc.md", ®ular_config()),
"../sub/doc/"
);
}
#[test]
fn test_parent_traversal() {
assert_eq!(
transform_link("../other.md", ®ular_config()),
"../../other/"
);
}
#[test]
fn test_double_parent() {
assert_eq!(
transform_link("../../root.md", ®ular_config()),
"../../../root/"
);
}
#[test]
fn test_index_collapse() {
assert_eq!(
transform_link("folder/index.md", ®ular_config()),
"../folder/"
);
}
#[test]
fn test_nested_index_collapse() {
assert_eq!(transform_link("a/b/index.md", ®ular_config()), "../a/b/");
}
#[test]
fn test_just_index_md() {
assert_eq!(transform_link("index.md", ®ular_config()), "../");
}
#[test]
fn test_index_lookalike_stems_keep_their_own_url() {
let cases = [
("site-index.md", "../site-index/"),
("myindex.md", "../myindex/"),
("reindex.md", "../reindex/"),
("subindex.md", "../subindex/"),
("docs/site-index.md", "../docs/site-index/"),
];
for (input, expected) in cases {
assert_eq!(transform_link(input, ®ular_config()), expected);
}
assert_eq!(
transform_link("docs/index.md", ®ular_config()),
"../docs/"
);
assert_eq!(transform_link("index.md", ®ular_config()), "../");
}
#[test]
fn test_index_lookalike_stems_from_index_page() {
assert_eq!(transform_link("subindex.md", &index_config()), "subindex/");
assert_eq!(transform_link("docs/index.md", &index_config()), "docs/");
}
#[test]
fn test_static_file() {
assert_eq!(
transform_link("image.png", ®ular_config()),
"../image.png"
);
}
#[test]
fn test_nested_static() {
assert_eq!(
transform_link("assets/img.png", ®ular_config()),
"../assets/img.png"
);
}
#[test]
fn test_md_with_anchor() {
assert_eq!(
transform_link("other.md#section", ®ular_config()),
"../other/#section"
);
}
#[test]
fn test_md_with_query() {
assert_eq!(
transform_link("other.md?foo=bar", ®ular_config()),
"../other/?foo=bar"
);
}
#[test]
fn test_md_with_query_and_anchor() {
assert_eq!(
transform_link("other.md?foo=bar#section", ®ular_config()),
"../other/?foo=bar#section"
);
}
#[test]
fn test_explicit_current_dir() {
assert_eq!(transform_link("./other.md", ®ular_config()), "../other/");
}
#[test]
fn test_alternate_extension() {
assert_eq!(
transform_link("other.markdown", ®ular_config()),
"../other/"
);
}
#[test]
fn test_parent_static_file() {
assert_eq!(
transform_link("../image.png", ®ular_config()),
"../../image.png"
);
}
#[test]
fn test_index_simple_relative_md() {
assert_eq!(transform_link("other.md", &index_config()), "other/");
}
#[test]
fn test_index_subdirectory_md() {
assert_eq!(transform_link("sub/doc.md", &index_config()), "sub/doc/");
}
#[test]
fn test_index_parent_traversal() {
assert_eq!(transform_link("../other.md", &index_config()), "../other/");
}
#[test]
fn test_index_double_parent() {
assert_eq!(
transform_link("../../root.md", &index_config()),
"../../root/"
);
}
#[test]
fn test_index_static_file() {
assert_eq!(transform_link("image.png", &index_config()), "image.png");
}
#[test]
fn test_index_nested_static() {
assert_eq!(
transform_link("assets/img.png", &index_config()),
"assets/img.png"
);
}
#[test]
fn test_index_md_with_anchor() {
assert_eq!(
transform_link("other.md#section", &index_config()),
"other/#section"
);
}
#[test]
fn test_index_parent_static() {
assert_eq!(
transform_link("../image.png", &index_config()),
"../image.png"
);
}
#[test]
fn test_index_to_index_collapse() {
assert_eq!(
transform_link("folder/index.md", &index_config()),
"folder/"
);
}
#[test]
fn test_absolute_https() {
let url = "https://example.com/path";
assert_eq!(transform_link(url, ®ular_config()), url);
assert_eq!(transform_link(url, &index_config()), url);
}
#[test]
fn test_absolute_http() {
let url = "http://example.com/path";
assert_eq!(transform_link(url, ®ular_config()), url);
assert_eq!(transform_link(url, &index_config()), url);
}
#[test]
fn test_protocol_relative() {
let url = "//cdn.example.com/file.js";
assert_eq!(transform_link(url, ®ular_config()), url);
assert_eq!(transform_link(url, &index_config()), url);
}
#[test]
fn test_root_relative() {
let url = "/docs/guide/";
assert_eq!(transform_link(url, ®ular_config()), url);
assert_eq!(transform_link(url, &index_config()), url);
}
#[test]
fn test_anchor_only() {
let url = "#section";
assert_eq!(transform_link(url, ®ular_config()), url);
assert_eq!(transform_link(url, &index_config()), url);
}
#[test]
fn test_empty_link() {
assert_eq!(transform_link("", ®ular_config()), "");
assert_eq!(transform_link("", &index_config()), "");
}
#[test]
fn test_data_url() {
let url = "data:image/png;base64,abc123";
assert_eq!(transform_link(url, ®ular_config()), url);
}
#[test]
fn test_data_image_url_unchanged() {
let url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ";
assert_eq!(transform_link(url, ®ular_config()), url);
assert_eq!(transform_link(url, &index_config()), url);
}
#[test]
fn test_blob_url_unchanged() {
let url = "blob:http://localhost:5220/550e8400-e29b-41d4-a716-446655440000";
assert_eq!(transform_link(url, ®ular_config()), url);
assert_eq!(transform_link(url, &index_config()), url);
}
#[test]
fn test_javascript_url() {
let url = "javascript:void(0)";
assert_eq!(transform_link(url, ®ular_config()), url);
}
#[test]
fn test_mailto_url() {
let url = "mailto:test@example.com";
assert_eq!(transform_link(url, ®ular_config()), url);
}
#[test]
fn test_ftp_url() {
let url = "ftp://ftp.example.com/file.txt";
assert_eq!(transform_link(url, ®ular_config()), url);
}
#[test]
fn test_scheme_urls_unchanged() {
for url in [
"ftps://ftp.example.com/file.txt",
"magnet:?xt=urn:btih:c12fe1c06bba254a9dc9",
"sms:+15555550123",
"callto:+15555550123",
"ssh://git@example.com/repo.git",
] {
assert_eq!(transform_link(url, ®ular_config()), url);
assert_eq!(transform_link(url, &index_config()), url);
}
}
#[test]
fn test_colon_in_relative_path_is_still_transformed() {
assert_eq!(
transform_link("docs/a:b.md", ®ular_config()),
"../docs/a:b/"
);
}
#[test]
fn test_file_with_dots_in_name() {
assert_eq!(
transform_link("my.file.md", ®ular_config()),
"../my.file/"
);
}
#[test]
fn test_non_md_extension() {
assert_eq!(
transform_link("readme.txt", ®ular_config()),
"../readme.txt"
);
}
#[test]
fn test_just_query() {
assert_eq!(transform_link("?foo=bar", ®ular_config()), "?foo=bar");
}
#[test]
fn test_deeply_nested_path() {
assert_eq!(
transform_link("a/b/c/d/file.md", ®ular_config()),
"../a/b/c/d/file/"
);
}
#[test]
fn test_mixed_traversal_and_descent() {
assert_eq!(
transform_link("../sibling/doc.md", ®ular_config()),
"../../sibling/doc/"
);
}
fn build_config(depth: usize) -> LinkTransformConfig {
LinkTransformConfig {
url_depth: Some(depth),
..regular_config()
}
}
#[test]
fn test_root_relative_with_depth_0() {
assert_eq!(
transform_link("/videos/demo.mp4", &build_config(0)),
"videos/demo.mp4"
);
}
#[test]
fn test_root_relative_with_depth_1() {
assert_eq!(
transform_link("/videos/demo.mp4", &build_config(1)),
"../videos/demo.mp4"
);
}
#[test]
fn test_root_relative_with_depth_2() {
assert_eq!(
transform_link("/videos/demo.mp4", &build_config(2)),
"../../videos/demo.mp4"
);
}
#[test]
fn test_root_relative_to_root_with_depth() {
assert_eq!(transform_link("/", &build_config(0)), "./");
assert_eq!(transform_link("/", &build_config(1)), "../");
assert_eq!(transform_link("/", &build_config(2)), "../../");
}
#[test]
fn test_root_relative_tag_link_with_depth() {
assert_eq!(
transform_link("/tags/rust/", &build_config(2)),
"../../tags/rust/"
);
}
#[test]
fn test_root_relative_unchanged_without_depth() {
assert_eq!(
transform_link("/videos/demo.mp4", ®ular_config()),
"/videos/demo.mp4"
);
assert_eq!(
transform_link("/tags/rust/", ®ular_config()),
"/tags/rust/"
);
}
#[test]
fn test_make_relative_url_to_root() {
assert_eq!(make_relative_url("/", 0), "./");
assert_eq!(make_relative_url("/", 1), "../");
assert_eq!(make_relative_url("/", 2), "../../");
}
#[test]
fn test_make_relative_url_to_path() {
assert_eq!(make_relative_url("/docs/", 0), "docs/");
assert_eq!(make_relative_url("/docs/guide/", 0), "docs/guide/");
assert_eq!(make_relative_url("/docs/", 1), "../docs/");
assert_eq!(make_relative_url("/other/", 1), "../other/");
assert_eq!(make_relative_url("/docs/", 2), "../../docs/");
assert_eq!(make_relative_url("/docs/guide/", 2), "../../docs/guide/");
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
fn regular_config() -> LinkTransformConfig {
LinkTransformConfig {
markdown_extensions: vec!["md".to_string(), "markdown".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
}
}
fn index_config() -> LinkTransformConfig {
LinkTransformConfig {
is_index_file: true,
..regular_config()
}
}
proptest! {
#[test]
fn prop_deterministic(url in ".*") {
let config = regular_config();
let r1 = transform_link(&url, &config);
let r2 = transform_link(&url, &config);
prop_assert_eq!(r1, r2);
}
#[test]
fn prop_https_unchanged(path in "[a-zA-Z0-9./_-]*") {
let url = format!("https://example.com/{}", path);
let config = regular_config();
prop_assert_eq!(transform_link(&url, &config), url);
}
#[test]
fn prop_http_unchanged(path in "[a-zA-Z0-9./_-]*") {
let url = format!("http://example.com/{}", path);
let config = regular_config();
prop_assert_eq!(transform_link(&url, &config), url);
}
#[test]
fn prop_protocol_relative_unchanged(path in "[a-zA-Z0-9./_-]*") {
let url = format!("//cdn.example.com/{}", path);
let config = regular_config();
prop_assert_eq!(transform_link(&url, &config), url);
}
#[test]
fn prop_root_relative_unchanged(path in "/[a-zA-Z0-9./_-]*") {
let config = regular_config();
prop_assert_eq!(transform_link(&path, &config), path);
}
#[test]
fn prop_root_relative_relativized(
path in "[a-zA-Z][a-zA-Z0-9/_-]{0,20}",
depth in 0usize..5
) {
let url = format!("/{}", path);
let mut config = regular_config();
config.url_depth = Some(depth);
let result = transform_link(&url, &config);
prop_assert!(!result.starts_with('/'), "Should be relative: {}", result);
prop_assert!(result.ends_with(&path), "Should end with path {}: {}", path, result);
}
#[test]
fn prop_anchor_only_unchanged(anchor in "#[a-zA-Z0-9_-]*") {
let config = regular_config();
prop_assert_eq!(transform_link(&anchor, &config), anchor);
}
#[test]
fn prop_empty_unchanged(_dummy in 0..1i32) {
let config = regular_config();
prop_assert_eq!(transform_link("", &config), "");
}
#[test]
fn prop_regular_md_gets_parent(name in "[a-zA-Z][a-zA-Z0-9_-]{0,20}") {
let url = format!("{}.md", name);
let config = regular_config();
let result = transform_link(&url, &config);
prop_assert!(result.starts_with("../"), "Expected ../ prefix: {}", result);
}
#[test]
fn prop_index_md_no_extra_parent(name in "[a-zA-Z][a-zA-Z0-9_-]{0,20}") {
let url = format!("{}.md", name);
let config = index_config();
let result = transform_link(&url, &config);
prop_assert!(!result.starts_with("../"), "Should not have ../ prefix: {}", result);
}
#[test]
fn prop_md_ends_with_slash(name in "[a-zA-Z][a-zA-Z0-9_-]{0,20}") {
let url = format!("{}.md", name);
let config = regular_config();
let result = transform_link(&url, &config);
let base = result.split(&['?', '#'][..]).next().unwrap();
prop_assert!(base.ends_with('/'), "Path should end with /: {}", base);
}
#[test]
fn prop_anchor_preserved(
name in "[a-zA-Z][a-zA-Z0-9_-]{0,10}",
anchor in "[a-zA-Z][a-zA-Z0-9_-]{0,10}"
) {
let url = format!("{}.md#{}", name, anchor);
let config = regular_config();
let result = transform_link(&url, &config);
prop_assert!(result.contains(&format!("#{}", anchor)), "Anchor not preserved: {}", result);
}
#[test]
fn prop_query_preserved(
name in "[a-zA-Z][a-zA-Z0-9_-]{0,10}",
query in "[a-zA-Z][a-zA-Z0-9_=-]{0,10}"
) {
let url = format!("{}.md?{}", name, query);
let config = regular_config();
let result = transform_link(&url, &config);
prop_assert!(result.contains(&format!("?{}", query)), "Query not preserved: {}", result);
}
}
}
#[cfg(test)]
mod browser_resolution_tests {
use super::*;
use crate::path_resolver::OwnedPathResolverConfig;
use std::path::Path;
use tempfile::TempDir;
fn remove_dot_segments(path: &str) -> String {
let mut input = path.to_string();
let mut output = String::new();
fn pop_last_segment(output: &mut String) {
match output.rfind('/') {
Some(index) => output.truncate(index),
None => output.clear(),
}
}
while !input.is_empty() {
if let Some(rest) = input.strip_prefix("../") {
input = rest.to_string();
} else if let Some(rest) = input.strip_prefix("./") {
input = rest.to_string();
} else if let Some(rest) = input.strip_prefix("/./") {
input = format!("/{rest}");
} else if input == "/." {
input = "/".to_string();
} else if let Some(rest) = input.strip_prefix("/../") {
input = format!("/{rest}");
pop_last_segment(&mut output);
} else if input == "/.." {
input = "/".to_string();
pop_last_segment(&mut output);
} else if input == "." || input == ".." {
input.clear();
} else {
let end = if let Some(rest) = input.strip_prefix('/') {
rest.find('/').map(|i| i + 1).unwrap_or(input.len())
} else {
input.find('/').unwrap_or(input.len())
};
output.push_str(&input[..end]);
input = input[end..].to_string();
}
}
output
}
fn merge(base_path: &str, reference_path: &str) -> String {
match base_path.rfind('/') {
Some(index) => format!("{}{}", &base_path[..=index], reference_path),
None => format!("/{reference_path}"),
}
}
fn resolve_in_browser(base: &str, reference: &str) -> String {
let (without_fragment, fragment) = match reference.split_once('#') {
Some((head, tail)) => (head, format!("#{tail}")),
None => (reference, String::new()),
};
let (ref_path, query) = match without_fragment.split_once('?') {
Some((head, tail)) => (head, format!("?{tail}")),
None => (without_fragment, String::new()),
};
let target = if ref_path.is_empty() {
base.to_string()
} else if ref_path.starts_with('/') {
remove_dot_segments(ref_path)
} else {
remove_dot_segments(&merge(base, ref_path))
};
format!("{target}{query}{fragment}")
}
fn fixture() -> TempDir {
let dir = TempDir::new().expect("temp repo");
let root = dir.path();
for folder in ["docs", "folder", "a/b", "static"] {
std::fs::create_dir_all(root.join(folder)).expect("create dir");
}
for page in [
"index.md",
"root.md",
"docs/index.md",
"docs/guide.md",
"docs/other.md",
"folder/file.md",
"folder/sibling.md",
"a/b/c.md",
"a/b/d.md",
] {
std::fs::write(root.join(page), "# page").expect("write page");
}
for asset in ["LICENSE", "docs/Makefile", "folder/Dockerfile"] {
std::fs::write(root.join(asset), "text").expect("write asset");
}
std::fs::write(root.join("docs/photo.png"), b"\x89PNG").expect("write image");
dir
}
fn transform_config(root: &Path, source_rel: &str) -> LinkTransformConfig {
let source = root.join(source_rel);
let is_index_file = source
.file_name()
.and_then(|f| f.to_str())
.is_some_and(|f| f == "index.md");
LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file,
url_depth: None,
current_page_url: crate::repo::build_markdown_url_path(&source, root, "index.md"),
markdown_page_probe: Some(filesystem_markdown_page_probe(OwnedPathResolverConfig {
base_dir: root.to_path_buf(),
canonical_base_dir: root.canonicalize().ok(),
static_folder: "static".to_string(),
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
tag_sources: Vec::new(),
})),
}
}
fn page_url(root: &Path, source_rel: &str) -> String {
crate::repo::build_markdown_url_path(&root.join(source_rel), root, "index.md")
}
fn follow(root: &Path, source_rel: &str, href: &str) -> (String, String) {
let config = transform_config(root, source_rel);
let emitted = transform_link(href, &config);
let landed = resolve_in_browser(&config.current_page_url, &emitted);
(emitted, landed)
}
fn assert_lands(root: &Path, source_rel: &str, href: &str, expected: &str) {
let (emitted, landed) = follow(root, source_rel, href);
assert_eq!(
landed, expected,
"[{source_rel}] `{href}` emitted `{emitted}` and landed on `{landed}`, \
expected `{expected}`"
);
}
#[test]
fn browser_model_matches_rfc_3986_examples() {
assert_eq!(resolve_in_browser("/b/c/d;p", "g"), "/b/c/g");
assert_eq!(resolve_in_browser("/b/c/d;p", "./g"), "/b/c/g");
assert_eq!(resolve_in_browser("/b/c/d;p", "g/"), "/b/c/g/");
assert_eq!(resolve_in_browser("/b/c/d;p", "/g"), "/g");
assert_eq!(resolve_in_browser("/b/c/d;p", "../g"), "/b/g");
assert_eq!(resolve_in_browser("/b/c/d;p", "../../g"), "/g");
assert_eq!(resolve_in_browser("/b/c/d;p", "../../../g"), "/g");
assert_eq!(resolve_in_browser("/b/c/d;p", "../../../../g"), "/g");
assert_eq!(resolve_in_browser("/b/c/d;p", "g?y"), "/b/c/g?y");
assert_eq!(resolve_in_browser("/b/c/d;p", "g#s"), "/b/c/g#s");
assert_eq!(resolve_in_browser("/b/c/d;p", "g?y#s"), "/b/c/g?y#s");
assert_eq!(
resolve_in_browser("/docs/guide/", "../other/"),
"/docs/other/"
);
}
#[test]
fn every_link_form_lands_on_its_canonical_url() {
let dir = fixture();
let root = dir.path();
let cases: &[(&str, &str, &str)] = &[
("root.md", "docs/guide.md", "/docs/guide/"),
("root.md", "docs/guide", "/docs/guide/"),
("root.md", "docs/guide/", "/docs/guide/"),
("root.md", "./docs/guide.md", "/docs/guide/"),
("root.md", "docs/index.md", "/docs/"),
("root.md", "docs/guide.md#anchor", "/docs/guide/#anchor"),
("root.md", "docs/guide.md?q=1", "/docs/guide/?q=1"),
("root.md", "LICENSE", "/LICENSE"),
("root.md", "docs/photo.png", "/docs/photo.png"),
("index.md", "docs/guide.md", "/docs/guide/"),
("index.md", "docs/guide", "/docs/guide/"),
("index.md", "root.md", "/root/"),
("index.md", "LICENSE", "/LICENSE"),
("docs/guide.md", "other.md", "/docs/other/"),
("docs/guide.md", "other", "/docs/other/"),
("docs/guide.md", "other/", "/docs/other/"),
("docs/guide.md", "./other.md", "/docs/other/"),
("docs/guide.md", "index.md", "/docs/"),
("docs/guide.md", "../root.md", "/root/"),
("docs/guide.md", "../root", "/root/"),
("docs/guide.md", "../folder/file.md", "/folder/file/"),
("docs/guide.md", "../folder/file", "/folder/file/"),
("docs/guide.md", "../index.md", "/"),
("docs/guide.md", "other.md#anchor", "/docs/other/#anchor"),
("docs/guide.md", "other.md?q=1", "/docs/other/?q=1"),
("docs/guide.md", "other#anchor", "/docs/other/#anchor"),
("docs/guide.md", "Makefile", "/docs/Makefile"),
("docs/guide.md", "../LICENSE", "/LICENSE"),
(
"docs/guide.md",
"../folder/Dockerfile",
"/folder/Dockerfile",
),
("docs/guide.md", "photo.png", "/docs/photo.png"),
("docs/index.md", "guide.md", "/docs/guide/"),
("docs/index.md", "guide", "/docs/guide/"),
("docs/index.md", "guide/", "/docs/guide/"),
("docs/index.md", "./guide.md", "/docs/guide/"),
("docs/index.md", "../root.md", "/root/"),
("docs/index.md", "../root", "/root/"),
("docs/index.md", "../folder/file", "/folder/file/"),
("docs/index.md", "Makefile", "/docs/Makefile"),
("docs/index.md", "../LICENSE", "/LICENSE"),
("docs/index.md", "guide.md#anchor", "/docs/guide/#anchor"),
("a/b/c.md", "d.md", "/a/b/d/"),
("a/b/c.md", "d", "/a/b/d/"),
("a/b/c.md", "./d.md", "/a/b/d/"),
("a/b/c.md", "../../root.md", "/root/"),
("a/b/c.md", "../../root", "/root/"),
("a/b/c.md", "../../docs/guide.md", "/docs/guide/"),
("a/b/c.md", "../../docs/guide", "/docs/guide/"),
("a/b/c.md", "../../LICENSE", "/LICENSE"),
("a/b/c.md", "../../index.md", "/"),
("a/b/c.md", "d.md?q=1#anchor", "/a/b/d/?q=1#anchor"),
];
for (source, href, expected) in cases {
assert_lands(root, source, href, expected);
}
}
#[test]
fn extensionless_markdown_link_lands_where_the_dot_md_form_does() {
let dir = fixture();
let root = dir.path();
let (with_ext, landed_with_ext) = follow(root, "docs/guide.md", "../folder/file.md");
let (without_ext, landed_without_ext) = follow(root, "docs/guide.md", "../folder/file");
assert_eq!(with_ext, "../../folder/file/");
assert_eq!(
without_ext, "../../folder/file/",
"an extension-less markdown target must get the trailing slash its \
canonical URL has"
);
assert_eq!(landed_with_ext, "/folder/file/");
assert_eq!(landed_without_ext, "/folder/file/");
}
#[test]
fn links_on_the_landed_page_still_resolve() {
let dir = fixture();
let root = dir.path();
for authored in ["../folder/file.md", "../folder/file", "../folder/file/"] {
let (_, landed) = follow(root, "docs/guide.md", authored);
assert_eq!(
landed,
page_url(root, "folder/file.md"),
"`{authored}` must land on the target's canonical URL"
);
let (emitted, second) = follow(root, "folder/file.md", "sibling.md");
assert_eq!(
second, "/folder/sibling/",
"after following `{authored}`, `sibling.md` (emitted `{emitted}`) \
must still reach /folder/sibling/ — this is the hop the \
trailing-slash defect breaks"
);
let non_canonical = landed.trim_end_matches('/');
assert_ne!(
resolve_in_browser(non_canonical, &emitted),
"/folder/sibling/",
"sanity: a slashless landing URL must break the next hop, or \
this test proves nothing"
);
}
}
#[test]
fn extensionless_target_is_unchanged_without_a_probe() {
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: "/docs/guide/".to_string(),
markdown_page_probe: None,
};
assert_eq!(
transform_link("../folder/file", &config),
"../../folder/file"
);
assert_eq!(transform_link("../LICENSE", &config), "../../LICENSE");
}
#[test]
fn probe_is_not_consulted_without_a_current_page_url() {
let dir = fixture();
let root = dir.path();
let mut config = transform_config(root, "docs/guide.md");
config.current_page_url = String::new();
assert_eq!(
transform_link("../folder/file", &config),
"../../folder/file"
);
}
#[test]
fn above_root_traversal_is_not_dressed_up_as_a_page() {
let dir = fixture();
let root = dir.path();
let (emitted, landed) = follow(root, "docs/guide.md", "../../escape/target");
assert_eq!(emitted, "../../../escape/target");
assert!(
!emitted.ends_with('/'),
"an above-root target must not be given a page's trailing slash: {emitted}"
);
assert_eq!(landed, "/escape/target");
}
#[test]
fn build_mode_root_relative_links_land_on_the_same_url() {
let dir = fixture();
let root = dir.path();
for source in [
"root.md",
"index.md",
"docs/guide.md",
"docs/index.md",
"a/b/c.md",
] {
let base = page_url(root, source);
let depth = base
.trim_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.count();
let mut config = transform_config(root, source);
config.url_depth = Some(depth);
for target in ["/docs/guide/", "/folder/file/", "/"] {
let emitted = transform_link(target, &config);
assert_eq!(
resolve_in_browser(&base, &emitted),
target,
"[{source}] root-relative `{target}` emitted `{emitted}`"
);
}
}
}
}