use comrak::nodes::{AstNode, NodeValue};
use crate::wikilink::SlugSet;
pub fn transform_asset_urls<'a>(
root: &'a AstNode<'a>,
base: &str,
source_dir: &str,
slugs: &SlugSet,
) {
for node in root.descendants() {
let mut data = node.data.borrow_mut();
match &mut data.value {
NodeValue::Image(link) => {
if let Some(rewritten) = rewrite_url(&link.url, base, source_dir, true) {
link.url = rewritten;
}
}
NodeValue::Link(link) => {
if let Some(rewritten) = rewrite_url(&link.url, base, source_dir, false) {
link.url = rewritten;
} else if let Some(rewritten) =
rewrite_page_link(&link.url, base, source_dir, slugs)
{
link.url = rewritten;
}
}
_ => {}
}
}
}
fn split_relative(url: &str) -> Option<(&str, &str)> {
let url = url.trim();
if url.is_empty() {
return None;
}
if url.starts_with('/') || url.starts_with('#') || url.starts_with('?') {
return None;
}
if has_scheme(url) {
return None;
}
let (path_part, suffix) = split_suffix(url);
if path_part.is_empty() {
return None;
}
Some((path_part, suffix))
}
fn rewrite_url(url: &str, base: &str, source_dir: &str, is_image: bool) -> Option<String> {
let (path_part, suffix) = split_relative(url)?;
if !is_image && !is_asset_path(path_part) {
return None;
}
let resolved = normalize_join(source_dir, path_part);
Some(format!("{base}/{resolved}{suffix}"))
}
fn rewrite_page_link(url: &str, base: &str, source_dir: &str, slugs: &SlugSet) -> Option<String> {
let (path_part, suffix) = split_relative(url)?;
let stem = match path_part.strip_suffix(".md") {
Some(s) => s,
None if !is_asset_path(path_part) => path_part,
None => return None,
};
if stem.is_empty() {
return None;
}
let resolved = normalize_join(source_dir, stem);
if !slugs.contains(&resolved) {
return None;
}
Some(format!("{base}/{resolved}{suffix}"))
}
fn has_scheme(url: &str) -> bool {
let mut chars = url.char_indices();
match chars.next() {
Some((_, c)) if c.is_ascii_alphabetic() => {}
_ => return false,
}
for (_, c) in chars {
if c == ':' {
return true;
}
if c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.' {
continue;
}
return false;
}
false
}
fn split_suffix(url: &str) -> (&str, &str) {
match url.find(['#', '?']) {
Some(i) => (&url[..i], &url[i..]),
None => (url, ""),
}
}
fn is_asset_path(path: &str) -> bool {
let last = path.rsplit('/').next().unwrap_or(path);
match last.rsplit_once('.') {
Some((stem, ext)) => !stem.is_empty() && !ext.is_empty() && !ext.eq_ignore_ascii_case("md"),
None => false,
}
}
fn normalize_join(source_dir: &str, path: &str) -> String {
let mut segments: Vec<&str> = Vec::new();
let combined = if source_dir.is_empty() {
path.to_string()
} else {
format!("{source_dir}/{path}")
};
for seg in combined.split('/') {
match seg {
"" | "." => {}
".." => {
segments.pop();
}
s => segments.push(s),
}
}
segments.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown::{comrak_options, format_ast};
use comrak::{parse_document, Arena};
fn render(md: &str, base: &str, source_dir: &str) -> String {
render_with_slugs(md, base, source_dir, &SlugSet::new())
}
fn render_with_slugs(md: &str, base: &str, source_dir: &str, slugs: &SlugSet) -> String {
let arena = Arena::new();
let opts = comrak_options();
let root = parse_document(&arena, md, &opts);
transform_asset_urls(root, base, source_dir, slugs);
format_ast(root, &opts)
}
fn slugset(items: &[&str]) -> SlugSet {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn relative_image_resolves_against_source_dir() {
let html = render("\n", "", "system");
assert!(
html.contains(r#"src="/system/attachments/image.png""#),
"{html}"
);
}
#[test]
fn relative_image_without_dot_prefix() {
let html = render("\n", "", "system");
assert!(
html.contains(r#"src="/system/attachments/image.png""#),
"{html}"
);
}
#[test]
fn base_prefix_is_applied() {
let html = render("\n", "/docs", "system");
assert!(
html.contains(r#"src="/docs/system/attachments/image.png""#),
"{html}"
);
}
#[test]
fn parent_dir_traversal_is_resolved() {
let html = render("\n", "", "system/sub");
assert!(html.contains(r#"src="/system/shared/logo.png""#), "{html}");
}
#[test]
fn root_level_page_has_no_dir_prefix() {
let html = render("\n", "", "");
assert!(html.contains(r#"src="/img/x.png""#), "{html}");
}
#[test]
fn absolute_and_external_images_untouched() {
let abs = render("\n", "", "system");
assert!(abs.contains(r#"src="/already/abs.png""#), "{abs}");
let ext = render("\n", "", "system");
assert!(ext.contains(r#"src="https://example.com/x.png""#), "{ext}");
let data = render("\n", "", "system");
assert!(
data.contains("src=\"data:image/png;base64,AAAA\""),
"{data}"
);
}
#[test]
fn protocol_relative_image_untouched() {
let html = render("\n", "", "system");
assert!(html.contains(r#"src="//cdn.example.com/x.png""#), "{html}");
}
#[test]
fn relative_link_to_asset_is_rewritten() {
let html = render("[report](./files/report.pdf)\n", "", "system");
assert!(
html.contains(r#"href="/system/files/report.pdf""#),
"{html}"
);
}
#[test]
fn relative_md_link_resolves_to_clean_url() {
let slugs = slugset(&["system/index", "system/other"]);
let html = render_with_slugs("[other](./other.md)\n", "", "system", &slugs);
assert!(html.contains(r#"href="/system/other""#), "{html}");
}
#[test]
fn relative_md_link_parent_dir_resolves() {
let slugs = slugset(&["guide/intro"]);
let html = render_with_slugs("[x](../guide/intro.md)\n", "", "system", &slugs);
assert!(html.contains(r#"href="/guide/intro""#), "{html}");
}
#[test]
fn extensionless_page_link_resolves_when_known() {
let slugs = slugset(&["guide/intro"]);
let html = render_with_slugs("[x](../guide/intro)\n", "", "system", &slugs);
assert!(html.contains(r#"href="/guide/intro""#), "{html}");
}
#[test]
fn page_link_base_prefix_and_fragment_preserved() {
let slugs = slugset(&["system/other"]);
let html = render_with_slugs("[x](./other.md#sec)\n", "/docs", "system", &slugs);
assert!(html.contains(r#"href="/docs/system/other#sec""#), "{html}");
}
#[test]
fn unknown_page_link_is_left_untouched() {
let slugs = slugset(&["system/index"]);
let md = render_with_slugs("[nope](./nope.md)\n", "", "system", &slugs);
assert!(md.contains(r#"href="./nope.md""#), "{md}");
let ext = render_with_slugs("[x](../guide/intro)\n", "", "system", &slugs);
assert!(ext.contains(r#"href="../guide/intro""#), "{ext}");
}
#[test]
fn asset_link_still_wins_over_page_link() {
let slugs = slugset(&["system/report"]);
let html = render_with_slugs("[report](./report.pdf)\n", "", "system", &slugs);
assert!(html.contains(r#"href="/system/report.pdf""#), "{html}");
}
#[test]
fn external_and_absolute_page_links_untouched() {
let slugs = slugset(&["system/other"]);
let ext = render_with_slugs("[x](https://example.com/other.md)\n", "", "system", &slugs);
assert!(
ext.contains(r#"href="https://example.com/other.md""#),
"{ext}"
);
let abs = render_with_slugs("[x](/system/other.md)\n", "", "system", &slugs);
assert!(abs.contains(r#"href="/system/other.md""#), "{abs}");
}
#[test]
fn query_and_fragment_are_preserved() {
let q = render("\n", "", "system");
assert!(q.contains(r#"src="/system/x.png?v=2""#), "{q}");
let f = render("[dl](./x.pdf#page=3)\n", "", "system");
assert!(f.contains(r#"href="/system/x.pdf#page=3""#), "{f}");
}
}