#[derive(Debug, Clone)]
pub struct LinkTransformConfig {
pub markdown_extensions: Vec<String>,
pub index_file: String,
pub is_index_file: bool,
pub url_depth: Option<usize>,
}
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,
}
}
}
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 is_absolute_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(),
};
}
if url.starts_with("data:") || url.starts_with("javascript:") {
return url.to_string();
}
if url.starts_with("mailto:")
|| url.starts_with("tel:")
|| url.starts_with("sms:")
|| url.starts_with("callto:")
{
return 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 final_path = if base_path.ends_with(index_stem) {
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)
};
format!("{}{}{}", prefix, remaining_path, suffix)
}
fn is_absolute_url(url: &str) -> bool {
url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("//")
|| url.starts_with("ftp://")
|| url.starts_with("file://")
}
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,
}
}
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_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_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_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,
}
}
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);
}
}
}