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,
asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
) {
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, asset_urls)
{
link.url = rewritten;
}
}
NodeValue::Link(link) => {
if let Some(rewritten) = rewrite_url(&link.url, base, source_dir, false, asset_urls)
{
link.url = rewritten;
} else if let Some(rewritten) =
rewrite_page_link(&link.url, base, source_dir, slugs)
{
link.url = rewritten;
}
}
_ => {}
}
}
}
pub 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,
asset_urls: Option<&dyn crate::asseturl::AssetUrlResolver>,
) -> 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);
if let Some(resolver) = asset_urls {
if let Some(public_url) = resolver.resolve(&resolved) {
return Some(format!("{public_url}{suffix}"));
}
}
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, ""),
}
}
pub 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,
}
}
pub 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, None);
format_ast(root, &opts)
}
fn slugset(items: &[&str]) -> SlugSet {
items.iter().map(|s| s.to_string()).collect()
}
struct MapResolver(std::collections::HashMap<String, String>);
impl crate::asseturl::AssetUrlResolver for MapResolver {
fn resolve(&self, rel_path: &str) -> Option<String> {
self.0.get(rel_path).cloned()
}
}
fn render_with_resolver(
md: &str,
base: &str,
source_dir: &str,
resolver: &dyn crate::asseturl::AssetUrlResolver,
) -> String {
let arena = Arena::new();
let opts = comrak_options();
let root = parse_document(&arena, md, &opts);
transform_asset_urls(root, base, source_dir, &SlugSet::new(), Some(resolver));
format_ast(root, &opts)
}
#[test]
fn resolver_rewrites_image_to_public_url() {
let mut m = std::collections::HashMap::new();
m.insert(
"system/attachments/image.png".to_string(),
"https://cdn.example.com/docs-assets/system/attachments/image.abcdef0123456789.png"
.to_string(),
);
let html = render_with_resolver(
"\n",
"",
"system",
&MapResolver(m),
);
assert!(
html.contains(
r#"src="https://cdn.example.com/docs-assets/system/attachments/image.abcdef0123456789.png""#
),
"{html}"
);
}
#[test]
fn resolver_miss_falls_back_to_base_absolute() {
let html = render_with_resolver(
"\n",
"",
"system",
&MapResolver(std::collections::HashMap::new()),
);
assert!(
html.contains(r#"src="/system/attachments/image.png""#),
"{html}"
);
}
#[test]
fn resolver_not_consulted_for_page_links() {
let mut m = std::collections::HashMap::new();
m.insert(
"system/other".to_string(),
"https://cdn.example.com/WRONG".to_string(),
);
let slugs = slugset(&["system/other"]);
let arena = Arena::new();
let opts = comrak_options();
let root = parse_document(&arena, "[x](./other.md)\n", &opts);
transform_asset_urls(root, "", "system", &slugs, Some(&MapResolver(m)));
let html = format_ast(root, &opts);
assert!(html.contains(r#"href="/system/other""#), "{html}");
}
#[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}");
}
}