use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
pub fn root_prefix(dest_filename: &str) -> String {
let depth = dest_filename.matches('/').count();
if depth == 0 {
String::new()
} else {
"../".repeat(depth)
}
}
pub fn transform_links(
html: &str,
current_path: &Path,
path_to_filename: &HashMap<PathBuf, String>,
workspace_dir: &Path,
dest_filename: &str,
) -> String {
transform_links_with_files(
html,
current_path,
path_to_filename,
workspace_dir,
dest_filename,
None,
)
}
pub fn transform_links_with_files(
html: &str,
current_path: &Path,
path_to_filename: &HashMap<PathBuf, String>,
workspace_dir: &Path,
dest_filename: &str,
published_files: Option<&HashSet<String>>,
) -> String {
let prefix = root_prefix(dest_filename);
let html = rewrite_document_links(
html,
current_path,
path_to_filename,
workspace_dir,
dest_filename,
);
let html = match published_files {
Some(published) => {
let current_relative = current_path
.strip_prefix(workspace_dir)
.unwrap_or(current_path);
mark_unpublished_files(&html, current_relative, published)
}
None => html,
};
rebase_root_absolute(&html, &prefix)
}
const FILE_TAGS: &[(&str, &str, bool)] = &[
("a", "href", true),
("img", "src", false),
("video", "src", true),
("audio", "src", true),
("iframe", "src", true),
];
fn is_generated_asset(canonical: &str) -> bool {
matches!(
canonical,
"style.css" | "feed.xml" | "rss.xml" | "sitemap.xml" | "robots.txt"
) || canonical == crate::html::ISLAND_CHILD_SCRIPT_FILENAME
|| (canonical.starts_with("favicon.") && !canonical.contains('/'))
}
fn mark_unpublished_files(
html: &str,
current_relative: &Path,
published: &HashSet<String>,
) -> String {
let mut result = String::with_capacity(html.len());
let mut remaining = html;
while let Some(lt) = remaining.find('<') {
result.push_str(&remaining[..lt]);
let after = &remaining[lt..];
let Some(gt) = after.find('>') else {
result.push_str(after);
return result;
};
let open_tag = &after[..=gt];
let tail = &after[gt + 1..];
let tag_name = open_tag[1..]
.split(|c: char| c.is_whitespace() || c == '>' || c == '/')
.next()
.unwrap_or("")
.to_ascii_lowercase();
let Some((_, attr, closes)) = FILE_TAGS.iter().find(|(t, _, _)| *t == tag_name) else {
result.push_str(open_tag);
remaining = tail;
continue;
};
let Some((start, end)) = find_attr_value(open_tag, attr) else {
result.push_str(open_tag);
remaining = tail;
continue;
};
let Some(canonical) = file_link_canonical(&open_tag[start..end], current_relative) else {
result.push_str(open_tag);
remaining = tail;
continue;
};
if published.contains(&canonical) || is_generated_asset(&canonical) {
result.push_str(open_tag);
remaining = tail;
continue;
}
let name = canonical
.rsplit('/')
.next()
.unwrap_or(&canonical)
.to_string();
let (inner, rest) = if *closes {
let close = format!("</{tag_name}>");
match tail.find(&close) {
Some(at) => (tail[..at].to_string(), &tail[at + close.len()..]),
None => (String::new(), tail),
}
} else {
(String::new(), tail)
};
let text = if tag_name == "a" && !inner.trim().is_empty() {
inner
} else {
find_attr_value(open_tag, "alt")
.map(|(s, e)| open_tag[s..e].to_string())
.filter(|alt| !alt.trim().is_empty())
.unwrap_or_else(|| crate::page::html_escape(&name))
};
result.push_str(r#"<span class="unpublished-link" title="This file isn’t published">"#);
result.push_str(&text);
result.push_str("</span>");
remaining = rest;
}
result.push_str(remaining);
result
}
fn file_link_canonical(raw: &str, current_relative: &Path) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with("//")
|| trimmed.split_once(':').is_some_and(|(scheme, _)| {
!scheme.is_empty()
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
})
{
return None;
}
let path = &trimmed[..trimmed.find(['?', '#']).unwrap_or(trimmed.len())];
let decoded = percent_decode(path);
if decoded.is_empty() || prov::ContentFormat::from_extension(Path::new(&decoded)).is_some() {
return None;
}
let target = prov::Link::parse_path_only(&decoded).target;
Some(
prov::link::resolve(current_relative, &target)
.to_string_lossy()
.into_owned(),
)
}
fn rewrite_document_links(
html: &str,
current_path: &Path,
path_to_filename: &HashMap<PathBuf, String>,
workspace_dir: &Path,
dest_filename: &str,
) -> String {
let prefix = root_prefix(dest_filename);
let current_relative = current_path
.strip_prefix(workspace_dir)
.unwrap_or(current_path);
let destinations: HashSet<&str> = path_to_filename.values().map(String::as_str).collect();
let mut result = String::with_capacity(html.len());
let mut remaining = html;
while let Some(tag_start) = remaining.find("<a ") {
result.push_str(&remaining[..tag_start]);
let after = &remaining[tag_start..];
let Some(gt) = after.find('>') else {
result.push_str(after);
remaining = "";
break;
};
let open_tag = &after[..=gt];
let tail = &after[gt + 1..];
let canonical =
extract_href(open_tag).and_then(|href| document_link_canonical(href, current_relative));
match canonical {
None => {
result.push_str(open_tag);
remaining = tail;
}
Some((canonical, written, suffix)) => {
let Some(close) = tail.find("</a>") else {
result.push_str(open_tag);
remaining = tail;
continue;
};
let inner = &tail[..close];
let after_close = &tail[close + "</a>".len()..];
let key = workspace_dir.join(sanitize_rel_path(&canonical));
match path_to_filename.get(&key) {
Some(html_path) => {
result.push_str(&replace_href(
open_tag,
&format!("{prefix}{html_path}{suffix}"),
));
result.push_str(inner);
result.push_str("</a>");
}
None if destinations.contains(written.as_str()) => {
result.push_str(&replace_href(
open_tag,
&format!("{prefix}{written}{suffix}"),
));
result.push_str(inner);
result.push_str("</a>");
}
None => {
result.push_str(
r#"<span class="unpublished-link" title="This page isn’t published">"#,
);
result.push_str(inner);
result.push_str("</span>");
}
}
remaining = after_close;
}
}
}
result.push_str(remaining);
result
}
fn extract_href(open_tag: &str) -> Option<&str> {
let start = open_tag.find("href=\"")? + 6;
let rest = &open_tag[start..];
let end = rest.find('"')?;
Some(&rest[..end])
}
fn document_link_canonical<'h>(
raw_href: &'h str,
current_relative: &Path,
) -> Option<(String, String, &'h str)> {
if raw_href.starts_with("http://")
|| raw_href.starts_with("https://")
|| raw_href.starts_with('#')
{
return None;
}
let (path, suffix) = raw_href.split_at(raw_href.find(['?', '#']).unwrap_or(raw_href.len()));
let decoded = percent_decode(path);
prov::ContentFormat::from_extension(Path::new(decoded.trim()))?;
let target = prov::Link::parse_path_only(decoded.trim()).target;
Some((
prov::link::resolve(current_relative, &target)
.to_string_lossy()
.into_owned(),
decoded.trim().to_string(),
suffix,
))
}
fn replace_href(open_tag: &str, new_value: &str) -> String {
let Some(start) = open_tag.find("href=\"") else {
return open_tag.to_string();
};
let value_start = start + 6;
let rest = &open_tag[value_start..];
let Some(end) = rest.find('"') else {
return open_tag.to_string();
};
format!("{}{}{}", &open_tag[..value_start], new_value, &rest[end..])
}
fn rebase_root_absolute(html: &str, prefix: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut remaining = html;
while let Some(lt) = remaining.find('<') {
result.push_str(&remaining[..lt]);
let after = &remaining[lt..];
let Some(gt) = after.find('>') else {
result.push_str(after);
return result;
};
let mut tag = after[..=gt].to_string();
for name in ["href", "src"] {
let Some((start, end)) = find_attr_value(&tag, name) else {
continue;
};
let value = &tag[start..end];
if !value.starts_with('/') || value.starts_with("//") || value.len() == 1 {
continue;
}
let rebased = format!("{prefix}{}", &value[1..]);
tag.replace_range(start..end, &rebased);
}
result.push_str(&tag);
remaining = &after[gt + 1..];
}
result.push_str(remaining);
result
}
pub fn absolutize_html(html: &str, dest_filename: &str, base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
if base.is_empty() {
return html.to_string();
}
let dir = dest_filename.rsplit_once('/').map_or("", |(dir, _)| dir);
let mut result = String::with_capacity(html.len());
let mut remaining = html;
while let Some(lt) = remaining.find('<') {
result.push_str(&remaining[..lt]);
let after = &remaining[lt..];
let Some(gt) = after.find('>') else {
result.push_str(after);
return result;
};
result.push_str(&absolutize_tag(&after[..=gt], dir, base));
remaining = &after[gt + 1..];
}
result.push_str(remaining);
result
}
fn absolutize_tag(tag: &str, dir: &str, base: &str) -> String {
let mut out = tag.to_string();
for name in ["href", "src"] {
let Some((start, end)) = find_attr_value(&out, name) else {
continue;
};
let Some(absolute) = absolutize_url(&out[start..end], dir, base) else {
continue;
};
out.replace_range(start..end, &absolute);
}
out
}
fn find_attr_value(tag: &str, name: &str) -> Option<(usize, usize)> {
let pattern = format!("{name}=\"");
let mut from = 0;
while let Some(offset) = tag[from..].find(&pattern) {
let at = from + offset;
let start = at + pattern.len();
let end = start + tag[start..].find('"')?;
if at == 0
|| tag[..at]
.chars()
.next_back()
.is_some_and(char::is_whitespace)
{
return Some((start, end));
}
from = end + 1;
}
None
}
fn absolutize_url(value: &str, dir: &str, base: &str) -> Option<String> {
if value.is_empty() || value.starts_with('#') || value.starts_with('/') || has_scheme(value) {
return None;
}
let (path, suffix) = value.split_at(value.find(['?', '#']).unwrap_or(value.len()));
if path.is_empty() {
return None;
}
let joined = if dir.is_empty() {
path.to_string()
} else {
format!("{dir}/{path}")
};
Some(format!("{base}/{}{suffix}", normalize_rel_path(&joined)?))
}
fn normalize_rel_path(path: &str) -> Option<String> {
let mut segments: Vec<&str> = Vec::new();
for segment in path.split('/') {
match segment {
"" | "." => {}
".." => {
segments.pop()?;
}
other => segments.push(other),
}
}
if segments.is_empty() {
return None;
}
let mut joined = segments.join("/");
if path.ends_with('/') {
joined.push('/');
}
Some(joined)
}
fn has_scheme(value: &str) -> bool {
let Some(colon) = value.find(':') else {
return false;
};
let scheme = &value[..colon];
scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
pub fn sanitize_path_component(s: &str) -> String {
s.chars()
.filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_' || *c == '.')
.collect()
}
pub fn sanitize_rel_path(path: &str) -> String {
let sanitized: PathBuf = Path::new(path)
.components()
.map(|c| match c {
std::path::Component::Normal(s) => {
std::ffi::OsString::from(sanitize_path_component(&s.to_string_lossy()))
}
other => other.as_os_str().to_owned(),
})
.collect();
sanitized.to_string_lossy().into_owned()
}
pub fn percent_decode(input: &str) -> String {
let mut result = Vec::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
{
result.push(hi << 4 | lo);
i += 3;
continue;
}
result.push(bytes[i]);
i += 1;
}
String::from_utf8(result).unwrap_or_else(|_| input.to_string())
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn root_prefix_depth() {
assert_eq!(root_prefix("index.html"), "");
assert_eq!(root_prefix("a/b.html"), "../");
assert_eq!(root_prefix("a/b/c.html"), "../../");
}
#[test]
fn percent_decode_cases() {
assert_eq!(percent_decode("hello"), "hello");
assert_eq!(percent_decode("hello%20world"), "hello world");
assert_eq!(
percent_decode("Message%20for%20my%20family.md"),
"Message for my family.md"
);
assert_eq!(percent_decode("%2Fpath%2Fto%2Ffile"), "/path/to/file");
assert_eq!(percent_decode("hello%2"), "hello%2");
assert_eq!(percent_decode("hello%"), "hello%");
assert_eq!(percent_decode("hello%ZZ"), "hello%ZZ");
}
#[test]
fn transform_links_rewrites_known_md_target() {
let workspace = Path::new("/ws");
let mut map = HashMap::new();
map.insert(
PathBuf::from("/ws/notes/target.md"),
"notes/target.html".to_string(),
);
let html = r#"<a href="target.md">x</a>"#;
let current = Path::new("/ws/notes/source.md");
let out = transform_links(html, current, &map, workspace, "notes/source.html");
assert_eq!(out, r#"<a href="../notes/target.html">x</a>"#);
}
#[test]
fn transform_links_unknown_md_is_stripped_and_marked() {
let workspace = Path::new("/ws");
let map = HashMap::new();
let html = r#"<a href="missing.md">link text</a>"#;
let current = Path::new("/ws/source.md");
let out = transform_links(html, current, &map, workspace, "source.html");
assert_eq!(
out,
r#"<span class="unpublished-link" title="This page isn’t published">link text</span>"#
);
}
#[test]
fn transform_links_rewrites_a_link_carrying_a_fragment() {
let workspace = Path::new("");
let mut map = HashMap::new();
map.insert(
PathBuf::from("about/index.md"),
"about/index.html".to_string(),
);
let html = r##"<a href="about/index.md#projects">p</a>"##;
let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
assert_eq!(out, r##"<a href="about/index.html#projects">p</a>"##);
let html = r##"<a href="/about/index.md?v=2#sec">p</a>"##;
let out = transform_links(
html,
Path::new("notes/deep.md"),
&map,
workspace,
"notes/deep.html",
);
assert_eq!(out, r##"<a href="../about/index.html?v=2#sec">p</a>"##);
}
#[test]
fn transform_links_strips_an_unpublished_target_with_a_fragment() {
let workspace = Path::new("");
let map = HashMap::new();
let html = r##"<a href="gone.md#sec">text</a>"##;
let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
assert_eq!(
out,
r#"<span class="unpublished-link" title="This page isn’t published">text</span>"#
);
}
#[test]
fn transform_links_resolves_sanitized_target() {
let workspace = Path::new("");
let mut map = HashMap::new();
map.insert(
PathBuf::from("First post.md"),
"First post.html".to_string(),
);
let html = r#"<a href="First%20post!.md">x</a>"#;
let current = Path::new("source.md");
let out = transform_links(html, current, &map, workspace, "source.html");
assert_eq!(out, r#"<a href="First post.html">x</a>"#);
}
#[test]
fn transform_links_preserves_inner_markup_when_stripping() {
let workspace = Path::new("");
let map = HashMap::new();
let html = r#"<a href="gone.md">see <em>this</em></a>"#;
let current = Path::new("source.md");
let out = transform_links(html, current, &map, workspace, "source.html");
assert!(out.contains(r#"<span class="unpublished-link""#));
assert!(out.contains("see <em>this</em></span>"));
assert!(!out.contains("<a "));
}
#[test]
fn absolutize_rewrites_href_and_src_from_the_root() {
let html = r#"<a href="post.html">x</a><img src="_attachments/a.jpg">"#;
let out = absolutize_html(html, "index.html", "https://ex.com");
assert_eq!(
out,
r#"<a href="https://ex.com/post.html">x</a><img src="https://ex.com/_attachments/a.jpg">"#
);
}
#[test]
fn absolutize_resolves_against_the_pages_own_directory() {
let html = r#"<a href="../sibling.html">s</a><a href="deeper/d.html">d</a>"#;
let out = absolutize_html(html, "a/b/c.html", "https://ex.com/");
assert!(out.contains(r#"href="https://ex.com/a/sibling.html""#));
assert!(out.contains(r#"href="https://ex.com/a/b/deeper/d.html""#));
}
#[test]
fn absolutize_rebases_under_a_base_url_that_has_a_path() {
let html = r#"<img src="../_attachments/scan.jpg">"#;
let out = absolutize_html(html, "notes/entry.html", "https://ex.com/sites/ns/letters");
assert_eq!(
out,
r#"<img src="https://ex.com/sites/ns/letters/_attachments/scan.jpg">"#
);
}
#[test]
fn absolutize_leaves_absolute_root_relative_and_fragment_links() {
let html = r##"<a href="https://x.com/a">e</a><a href="//cdn/x.png">p</a><a href="/about">r</a><a href="#sec">f</a><a href="mailto:a@b.c">m</a>"##;
assert_eq!(absolutize_html(html, "index.html", "https://ex.com"), html);
}
#[test]
fn absolutize_keeps_query_and_fragment_suffixes() {
let html = r##"<a href="post.html#note-1">n</a><a href="p.html?v=2">q</a>"##;
let out = absolutize_html(html, "index.html", "https://ex.com");
assert!(out.contains(r#"href="https://ex.com/post.html#note-1""#));
assert!(out.contains(r#"href="https://ex.com/p.html?v=2""#));
}
#[test]
fn absolutize_leaves_a_path_that_climbs_above_the_root() {
let html = r#"<a href="../../nope.html">x</a>"#;
assert_eq!(absolutize_html(html, "a/b.html", "https://ex.com"), html);
}
#[test]
fn absolutize_does_not_match_a_suffixed_attribute_name() {
let html = r#"<img data-src="a.jpg" src="b.jpg">"#;
let out = absolutize_html(html, "index.html", "https://ex.com");
assert!(out.contains(r#"data-src="a.jpg""#));
assert!(out.contains(r#"src="https://ex.com/b.jpg""#));
}
#[test]
fn absolutize_leaves_a_colon_in_a_filename_alone() {
let html = r#"<a href="notes/9:15.html">t</a>"#;
let out = absolutize_html(html, "index.html", "https://ex.com");
assert_eq!(out, r#"<a href="https://ex.com/notes/9:15.html">t</a>"#);
}
#[test]
fn absolutize_without_a_base_is_a_no_op() {
let html = r#"<a href="post.html">x</a>"#;
assert_eq!(absolutize_html(html, "index.html", ""), html);
}
#[test]
fn absolutize_leaves_text_between_tags_untouched() {
let html = r#"<p>see href="post.html" below</p><a href="post.html">x</a>"#;
let out = absolutize_html(html, "index.html", "https://ex.com");
assert!(out.contains(r#"see href="post.html" below"#));
assert!(out.contains(r#"<a href="https://ex.com/post.html">"#));
}
#[test]
fn transform_links_rebases_root_absolute_attachments() {
let workspace = Path::new("");
let map = HashMap::new();
let html = r#"<img src="/img/photo.png" alt="a">"#;
let out = transform_links(html, Path::new("post.md"), &map, workspace, "post.html");
assert_eq!(out, r#"<img src="img/photo.png" alt="a">"#);
let out = transform_links(
html,
Path::new("notes/deep.md"),
&map,
workspace,
"notes/deep.html",
);
assert_eq!(out, r#"<img src="../img/photo.png" alt="a">"#);
}
#[test]
fn a_reference_to_a_withheld_file_is_marked_like_an_unpublished_page() {
let workspace = Path::new("");
let map = HashMap::new();
let html = concat!(
r#"<img src="attachments/private.jpg" alt="A private picture">"#,
r#"<img src="/attachments/shipped.jpg" alt="ok">"#,
r#"<a href="attachments/private.pdf">Read the scan</a>"#,
r#"<video controls src="attachments/private.mp4"></video>"#,
r#"<a href="https://example.com/x.jpg">out</a>"#,
r#"<a href="/feed.xml">feed</a>"#,
r#"<img src="attachments/nameless.png" alt="">"#,
);
let published: HashSet<String> = ["attachments/shipped.jpg".to_string()].into();
let out = transform_links_with_files(
html,
Path::new("index.md"),
&map,
workspace,
"index.html",
Some(&published),
);
assert_eq!(
out,
concat!(
r#"<span class="unpublished-link" title="This file isn’t published">A private picture</span>"#,
r#"<img src="attachments/shipped.jpg" alt="ok">"#,
r#"<span class="unpublished-link" title="This file isn’t published">Read the scan</span>"#,
r#"<span class="unpublished-link" title="This file isn’t published">private.mp4</span>"#,
r#"<a href="https://example.com/x.jpg">out</a>"#,
r#"<a href="feed.xml">feed</a>"#,
r#"<span class="unpublished-link" title="This file isn’t published">nameless.png</span>"#,
)
);
let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
assert!(
out.contains(r#"<img src="attachments/private.jpg""#),
"{out}"
);
}
#[test]
fn a_destination_href_is_a_link_to_the_page_it_names() {
let workspace = Path::new("");
let mut map = HashMap::new();
map.insert(PathBuf::from("index.md"), "index.html".to_string());
map.insert(
PathBuf::from("notes/entry.md"),
"notes/entry.html".to_string(),
);
let html = r##"<a href="notes/entry.html#top">E</a> <a href="notes/gone.html">G</a>"##;
let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
assert_eq!(
out,
r##"<a href="notes/entry.html#top">E</a> <span class="unpublished-link" title="This page isn’t published">G</span>"##
);
let out = transform_links(
html,
Path::new("notes/entry.md"),
&map,
workspace,
"notes/entry.html",
);
assert!(
out.starts_with(r##"<a href="../notes/entry.html#top">E</a>"##),
"{out}"
);
}
#[test]
fn a_source_html_document_is_resolved_as_a_source() {
let workspace = Path::new("");
let mut map = HashMap::new();
map.insert(
PathBuf::from("notes/artifact.html"),
"notes/artifact.html".to_string(),
);
let html = r#"<a href="artifact.html">A</a>"#;
let out = transform_links(
html,
Path::new("notes/entry.md"),
&map,
workspace,
"notes/entry.html",
);
assert_eq!(out, r#"<a href="../notes/artifact.html">A</a>"#);
}
#[test]
fn transform_links_rebases_every_root_absolute_src_and_href() {
let workspace = Path::new("");
let map = HashMap::new();
let html = r#"<iframe class="diaryx-island" src="/att/page.html"></iframe><a href="/att/scan.pdf">s</a>"#;
let out = transform_links(
html,
Path::new("notes/deep.md"),
&map,
workspace,
"notes/deep.html",
);
assert!(out.contains(r#"src="../att/page.html""#), "got {out}");
assert!(out.contains(r#"href="../att/scan.pdf""#), "got {out}");
}
#[test]
fn transform_links_resolves_a_root_absolute_document_before_rebasing() {
let workspace = Path::new("");
let mut map = HashMap::new();
map.insert(PathBuf::from("post.md"), "post.html".to_string());
let html = r#"<a href="/post.md">x</a>"#;
let out = transform_links(
html,
Path::new("notes/deep.md"),
&map,
workspace,
"notes/deep.html",
);
assert_eq!(out, r#"<a href="../post.html">x</a>"#);
}
#[test]
fn transform_links_leaves_protocol_relative_and_bare_slash() {
let workspace = Path::new("");
let map = HashMap::new();
let html = r#"<img src="//cdn.example/x.png"><a href="/">home</a>"#;
let out = transform_links(
html,
Path::new("notes/deep.md"),
&map,
workspace,
"notes/deep.html",
);
assert_eq!(out, html);
}
#[test]
fn transform_links_leaves_external_and_anchors() {
let workspace = Path::new("/ws");
let map = HashMap::new();
let current = Path::new("/ws/source.md");
let html =
r##"<a href="https://x.com/a.md">e</a><a href="#frag">f</a><a href="img.png">g</a>"##;
let out = transform_links(html, current, &map, workspace, "source.html");
assert_eq!(out, html);
}
}