1use std::collections::{HashMap, HashSet};
6use std::path::{Path, PathBuf};
7
8pub fn root_prefix(dest_filename: &str) -> String {
12 let depth = dest_filename.matches('/').count();
13 if depth == 0 {
14 String::new()
15 } else {
16 "../".repeat(depth)
17 }
18}
19
20pub fn transform_links(
48 html: &str,
49 current_path: &Path,
50 path_to_filename: &HashMap<PathBuf, String>,
51 workspace_dir: &Path,
52 dest_filename: &str,
53) -> String {
54 transform_links_with_files(
55 html,
56 current_path,
57 path_to_filename,
58 workspace_dir,
59 dest_filename,
60 None,
61 )
62}
63
64pub fn transform_links_with_files(
70 html: &str,
71 current_path: &Path,
72 path_to_filename: &HashMap<PathBuf, String>,
73 workspace_dir: &Path,
74 dest_filename: &str,
75 published_files: Option<&HashSet<String>>,
76) -> String {
77 let prefix = root_prefix(dest_filename);
78 let html = rewrite_document_links(
79 html,
80 current_path,
81 path_to_filename,
82 workspace_dir,
83 dest_filename,
84 );
85 let html = match published_files {
86 Some(published) => {
87 let current_relative = current_path
88 .strip_prefix(workspace_dir)
89 .unwrap_or(current_path);
90 mark_unpublished_files(&html, current_relative, published)
91 }
92 None => html,
93 };
94 rebase_root_absolute(&html, &prefix)
95}
96
97const FILE_TAGS: &[(&str, &str, bool)] = &[
100 ("a", "href", true),
101 ("img", "src", false),
102 ("video", "src", true),
103 ("audio", "src", true),
104 ("iframe", "src", true),
105];
106
107fn is_generated_asset(canonical: &str) -> bool {
110 matches!(
111 canonical,
112 "style.css" | "feed.xml" | "rss.xml" | "sitemap.xml" | "robots.txt"
113 ) || canonical == crate::html::ISLAND_CHILD_SCRIPT_FILENAME
114 || (canonical.starts_with("favicon.") && !canonical.contains('/'))
115}
116
117fn mark_unpublished_files(
126 html: &str,
127 current_relative: &Path,
128 published: &HashSet<String>,
129) -> String {
130 let mut result = String::with_capacity(html.len());
131 let mut remaining = html;
132
133 while let Some(lt) = remaining.find('<') {
134 result.push_str(&remaining[..lt]);
135 let after = &remaining[lt..];
136 let Some(gt) = after.find('>') else {
137 result.push_str(after);
138 return result;
139 };
140 let open_tag = &after[..=gt];
141 let tail = &after[gt + 1..];
142
143 let tag_name = open_tag[1..]
144 .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
145 .next()
146 .unwrap_or("")
147 .to_ascii_lowercase();
148 let Some((_, attr, closes)) = FILE_TAGS.iter().find(|(t, _, _)| *t == tag_name) else {
149 result.push_str(open_tag);
150 remaining = tail;
151 continue;
152 };
153 let Some((start, end)) = find_attr_value(open_tag, attr) else {
154 result.push_str(open_tag);
155 remaining = tail;
156 continue;
157 };
158 let Some(canonical) = file_link_canonical(&open_tag[start..end], current_relative) else {
159 result.push_str(open_tag);
160 remaining = tail;
161 continue;
162 };
163 if published.contains(&canonical) || is_generated_asset(&canonical) {
164 result.push_str(open_tag);
165 remaining = tail;
166 continue;
167 }
168
169 let name = canonical
172 .rsplit('/')
173 .next()
174 .unwrap_or(&canonical)
175 .to_string();
176 let (inner, rest) = if *closes {
177 let close = format!("</{tag_name}>");
178 match tail.find(&close) {
179 Some(at) => (tail[..at].to_string(), &tail[at + close.len()..]),
180 None => (String::new(), tail),
181 }
182 } else {
183 (String::new(), tail)
184 };
185 let text = if tag_name == "a" && !inner.trim().is_empty() {
186 inner
187 } else {
188 find_attr_value(open_tag, "alt")
189 .map(|(s, e)| open_tag[s..e].to_string())
190 .filter(|alt| !alt.trim().is_empty())
191 .unwrap_or_else(|| crate::page::html_escape(&name))
192 };
193 result.push_str(r#"<span class="unpublished-link" title="This file isn’t published">"#);
194 result.push_str(&text);
195 result.push_str("</span>");
196 remaining = rest;
197 }
198 result.push_str(remaining);
199 result
200}
201
202fn file_link_canonical(raw: &str, current_relative: &Path) -> Option<String> {
206 let trimmed = raw.trim();
207 if trimmed.is_empty()
208 || trimmed.starts_with('#')
209 || trimmed.starts_with("//")
210 || trimmed.split_once(':').is_some_and(|(scheme, _)| {
211 !scheme.is_empty()
212 && scheme
213 .chars()
214 .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
215 })
216 {
217 return None;
218 }
219 let path = &trimmed[..trimmed.find(['?', '#']).unwrap_or(trimmed.len())];
220 let decoded = percent_decode(path);
221 if decoded.is_empty() || prov::ContentFormat::from_extension(Path::new(&decoded)).is_some() {
222 return None;
223 }
224 let target = prov::Link::parse_path_only(&decoded).target;
225 Some(
226 prov::link::resolve(current_relative, &target)
227 .to_string_lossy()
228 .into_owned(),
229 )
230}
231
232fn rewrite_document_links(
241 html: &str,
242 current_path: &Path,
243 path_to_filename: &HashMap<PathBuf, String>,
244 workspace_dir: &Path,
245 dest_filename: &str,
246) -> String {
247 let prefix = root_prefix(dest_filename);
248 let current_relative = current_path
250 .strip_prefix(workspace_dir)
251 .unwrap_or(current_path);
252
253 let destinations: HashSet<&str> = path_to_filename.values().map(String::as_str).collect();
255
256 let mut result = String::with_capacity(html.len());
257 let mut remaining = html;
258
259 while let Some(tag_start) = remaining.find("<a ") {
260 result.push_str(&remaining[..tag_start]);
262 let after = &remaining[tag_start..];
263
264 let Some(gt) = after.find('>') else {
267 result.push_str(after);
268 remaining = "";
269 break;
270 };
271 let open_tag = &after[..=gt];
272 let tail = &after[gt + 1..];
273
274 let canonical =
276 extract_href(open_tag).and_then(|href| document_link_canonical(href, current_relative));
277
278 match canonical {
279 None => {
280 result.push_str(open_tag);
282 remaining = tail;
283 }
284 Some((canonical, written, suffix)) => {
285 let Some(close) = tail.find("</a>") else {
287 result.push_str(open_tag);
288 remaining = tail;
289 continue;
290 };
291 let inner = &tail[..close];
292 let after_close = &tail[close + "</a>".len()..];
293
294 let key = workspace_dir.join(sanitize_rel_path(&canonical));
295 match path_to_filename.get(&key) {
296 Some(html_path) => {
297 result.push_str(&replace_href(
299 open_tag,
300 &format!("{prefix}{html_path}{suffix}"),
301 ));
302 result.push_str(inner);
303 result.push_str("</a>");
304 }
305 None if destinations.contains(written.as_str()) => {
306 result.push_str(&replace_href(
309 open_tag,
310 &format!("{prefix}{written}{suffix}"),
311 ));
312 result.push_str(inner);
313 result.push_str("</a>");
314 }
315 None => {
316 result.push_str(
318 r#"<span class="unpublished-link" title="This page isn’t published">"#,
319 );
320 result.push_str(inner);
321 result.push_str("</span>");
322 }
323 }
324 remaining = after_close;
325 }
326 }
327 }
328 result.push_str(remaining);
329
330 result
331}
332
333fn extract_href(open_tag: &str) -> Option<&str> {
335 let start = open_tag.find("href=\"")? + 6;
336 let rest = &open_tag[start..];
337 let end = rest.find('"')?;
338 Some(&rest[..end])
339}
340
341fn document_link_canonical<'h>(
359 raw_href: &'h str,
360 current_relative: &Path,
361) -> Option<(String, String, &'h str)> {
362 if raw_href.starts_with("http://")
363 || raw_href.starts_with("https://")
364 || raw_href.starts_with('#')
365 {
366 return None;
367 }
368 let (path, suffix) = raw_href.split_at(raw_href.find(['?', '#']).unwrap_or(raw_href.len()));
369 let decoded = percent_decode(path);
370 prov::ContentFormat::from_extension(Path::new(decoded.trim()))?;
373 let target = prov::Link::parse_path_only(decoded.trim()).target;
374 Some((
375 prov::link::resolve(current_relative, &target)
376 .to_string_lossy()
377 .into_owned(),
378 decoded.trim().to_string(),
379 suffix,
380 ))
381}
382
383fn replace_href(open_tag: &str, new_value: &str) -> String {
385 let Some(start) = open_tag.find("href=\"") else {
386 return open_tag.to_string();
387 };
388 let value_start = start + 6;
389 let rest = &open_tag[value_start..];
390 let Some(end) = rest.find('"') else {
391 return open_tag.to_string();
392 };
393 format!("{}{}{}", &open_tag[..value_start], new_value, &rest[end..])
394}
395
396fn rebase_root_absolute(html: &str, prefix: &str) -> String {
424 let mut result = String::with_capacity(html.len());
425 let mut remaining = html;
426
427 while let Some(lt) = remaining.find('<') {
428 result.push_str(&remaining[..lt]);
429 let after = &remaining[lt..];
430
431 let Some(gt) = after.find('>') else {
434 result.push_str(after);
435 return result;
436 };
437
438 let mut tag = after[..=gt].to_string();
439 for name in ["href", "src"] {
440 let Some((start, end)) = find_attr_value(&tag, name) else {
441 continue;
442 };
443 let value = &tag[start..end];
444 if !value.starts_with('/') || value.starts_with("//") || value.len() == 1 {
445 continue;
446 }
447 let rebased = format!("{prefix}{}", &value[1..]);
448 tag.replace_range(start..end, &rebased);
449 }
450 result.push_str(&tag);
451 remaining = &after[gt + 1..];
452 }
453 result.push_str(remaining);
454
455 result
456}
457
458pub fn absolutize_html(html: &str, dest_filename: &str, base_url: &str) -> String {
492 let base = base_url.trim_end_matches('/');
493 if base.is_empty() {
494 return html.to_string();
495 }
496 let dir = dest_filename.rsplit_once('/').map_or("", |(dir, _)| dir);
497
498 let mut result = String::with_capacity(html.len());
499 let mut remaining = html;
500
501 while let Some(lt) = remaining.find('<') {
502 result.push_str(&remaining[..lt]);
503 let after = &remaining[lt..];
504
505 let Some(gt) = after.find('>') else {
508 result.push_str(after);
509 return result;
510 };
511
512 result.push_str(&absolutize_tag(&after[..=gt], dir, base));
513 remaining = &after[gt + 1..];
514 }
515 result.push_str(remaining);
516
517 result
518}
519
520fn absolutize_tag(tag: &str, dir: &str, base: &str) -> String {
522 let mut out = tag.to_string();
523 for name in ["href", "src"] {
524 let Some((start, end)) = find_attr_value(&out, name) else {
525 continue;
526 };
527 let Some(absolute) = absolutize_url(&out[start..end], dir, base) else {
528 continue;
529 };
530 out.replace_range(start..end, &absolute);
531 }
532 out
533}
534
535fn find_attr_value(tag: &str, name: &str) -> Option<(usize, usize)> {
538 let pattern = format!("{name}=\"");
539 let mut from = 0;
540 while let Some(offset) = tag[from..].find(&pattern) {
541 let at = from + offset;
542 let start = at + pattern.len();
543 let end = start + tag[start..].find('"')?;
544 if at == 0
545 || tag[..at]
546 .chars()
547 .next_back()
548 .is_some_and(char::is_whitespace)
549 {
550 return Some((start, end));
551 }
552 from = end + 1;
553 }
554 None
555}
556
557fn absolutize_url(value: &str, dir: &str, base: &str) -> Option<String> {
560 if value.is_empty() || value.starts_with('#') || value.starts_with('/') || has_scheme(value) {
561 return None;
562 }
563
564 let (path, suffix) = value.split_at(value.find(['?', '#']).unwrap_or(value.len()));
566 if path.is_empty() {
567 return None;
568 }
569
570 let joined = if dir.is_empty() {
571 path.to_string()
572 } else {
573 format!("{dir}/{path}")
574 };
575 Some(format!("{base}/{}{suffix}", normalize_rel_path(&joined)?))
576}
577
578fn normalize_rel_path(path: &str) -> Option<String> {
580 let mut segments: Vec<&str> = Vec::new();
581 for segment in path.split('/') {
582 match segment {
583 "" | "." => {}
584 ".." => {
585 segments.pop()?;
586 }
587 other => segments.push(other),
588 }
589 }
590 if segments.is_empty() {
591 return None;
592 }
593 let mut joined = segments.join("/");
594 if path.ends_with('/') {
595 joined.push('/');
596 }
597 Some(joined)
598}
599
600fn has_scheme(value: &str) -> bool {
604 let Some(colon) = value.find(':') else {
605 return false;
606 };
607 let scheme = &value[..colon];
608 scheme.starts_with(|c: char| c.is_ascii_alphabetic())
609 && scheme
610 .chars()
611 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
612}
613
614pub fn sanitize_path_component(s: &str) -> String {
619 s.chars()
620 .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_' || *c == '.')
621 .collect()
622}
623
624pub fn sanitize_rel_path(path: &str) -> String {
628 let sanitized: PathBuf = Path::new(path)
629 .components()
630 .map(|c| match c {
631 std::path::Component::Normal(s) => {
632 std::ffi::OsString::from(sanitize_path_component(&s.to_string_lossy()))
633 }
634 other => other.as_os_str().to_owned(),
635 })
636 .collect();
637 sanitized.to_string_lossy().into_owned()
638}
639
640pub fn percent_decode(input: &str) -> String {
642 let mut result = Vec::with_capacity(input.len());
643 let bytes = input.as_bytes();
644 let mut i = 0;
645 while i < bytes.len() {
646 if bytes[i] == b'%'
647 && i + 2 < bytes.len()
648 && let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
649 {
650 result.push(hi << 4 | lo);
651 i += 3;
652 continue;
653 }
654 result.push(bytes[i]);
655 i += 1;
656 }
657 String::from_utf8(result).unwrap_or_else(|_| input.to_string())
658}
659
660fn hex_val(b: u8) -> Option<u8> {
661 match b {
662 b'0'..=b'9' => Some(b - b'0'),
663 b'a'..=b'f' => Some(b - b'a' + 10),
664 b'A'..=b'F' => Some(b - b'A' + 10),
665 _ => None,
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672
673 #[test]
674 fn root_prefix_depth() {
675 assert_eq!(root_prefix("index.html"), "");
676 assert_eq!(root_prefix("a/b.html"), "../");
677 assert_eq!(root_prefix("a/b/c.html"), "../../");
678 }
679
680 #[test]
681 fn percent_decode_cases() {
682 assert_eq!(percent_decode("hello"), "hello");
683 assert_eq!(percent_decode("hello%20world"), "hello world");
684 assert_eq!(
685 percent_decode("Message%20for%20my%20family.md"),
686 "Message for my family.md"
687 );
688 assert_eq!(percent_decode("%2Fpath%2Fto%2Ffile"), "/path/to/file");
689 assert_eq!(percent_decode("hello%2"), "hello%2");
691 assert_eq!(percent_decode("hello%"), "hello%");
692 assert_eq!(percent_decode("hello%ZZ"), "hello%ZZ");
694 }
695
696 #[test]
697 fn transform_links_rewrites_known_md_target() {
698 let workspace = Path::new("/ws");
699 let mut map = HashMap::new();
700 map.insert(
701 PathBuf::from("/ws/notes/target.md"),
702 "notes/target.html".to_string(),
703 );
704
705 let html = r#"<a href="target.md">x</a>"#;
706 let current = Path::new("/ws/notes/source.md");
707 let out = transform_links(html, current, &map, workspace, "notes/source.html");
708 assert_eq!(out, r#"<a href="../notes/target.html">x</a>"#);
710 }
711
712 #[test]
713 fn transform_links_unknown_md_is_stripped_and_marked() {
714 let workspace = Path::new("/ws");
718 let map = HashMap::new();
719 let html = r#"<a href="missing.md">link text</a>"#;
720 let current = Path::new("/ws/source.md");
721 let out = transform_links(html, current, &map, workspace, "source.html");
722 assert_eq!(
723 out,
724 r#"<span class="unpublished-link" title="This page isn’t published">link text</span>"#
725 );
726 }
727
728 #[test]
733 fn transform_links_rewrites_a_link_carrying_a_fragment() {
734 let workspace = Path::new("");
735 let mut map = HashMap::new();
736 map.insert(
737 PathBuf::from("about/index.md"),
738 "about/index.html".to_string(),
739 );
740
741 let html = r##"<a href="about/index.md#projects">p</a>"##;
742 let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
743 assert_eq!(out, r##"<a href="about/index.html#projects">p</a>"##);
744
745 let html = r##"<a href="/about/index.md?v=2#sec">p</a>"##;
748 let out = transform_links(
749 html,
750 Path::new("notes/deep.md"),
751 &map,
752 workspace,
753 "notes/deep.html",
754 );
755 assert_eq!(out, r##"<a href="../about/index.html?v=2#sec">p</a>"##);
756 }
757
758 #[test]
761 fn transform_links_strips_an_unpublished_target_with_a_fragment() {
762 let workspace = Path::new("");
763 let map = HashMap::new();
764 let html = r##"<a href="gone.md#sec">text</a>"##;
765 let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
766 assert_eq!(
767 out,
768 r#"<span class="unpublished-link" title="This page isn’t published">text</span>"#
769 );
770 }
771
772 #[test]
773 fn transform_links_resolves_sanitized_target() {
774 let workspace = Path::new("");
778 let mut map = HashMap::new();
779 map.insert(
780 PathBuf::from("First post.md"),
781 "First post.html".to_string(),
782 );
783 let html = r#"<a href="First%20post!.md">x</a>"#;
784 let current = Path::new("source.md");
785 let out = transform_links(html, current, &map, workspace, "source.html");
786 assert_eq!(out, r#"<a href="First post.html">x</a>"#);
787 }
788
789 #[test]
790 fn transform_links_preserves_inner_markup_when_stripping() {
791 let workspace = Path::new("");
792 let map = HashMap::new();
793 let html = r#"<a href="gone.md">see <em>this</em></a>"#;
794 let current = Path::new("source.md");
795 let out = transform_links(html, current, &map, workspace, "source.html");
796 assert!(out.contains(r#"<span class="unpublished-link""#));
797 assert!(out.contains("see <em>this</em></span>"));
798 assert!(!out.contains("<a "));
799 }
800
801 #[test]
802 fn absolutize_rewrites_href_and_src_from_the_root() {
803 let html = r#"<a href="post.html">x</a><img src="_attachments/a.jpg">"#;
804 let out = absolutize_html(html, "index.html", "https://ex.com");
805 assert_eq!(
806 out,
807 r#"<a href="https://ex.com/post.html">x</a><img src="https://ex.com/_attachments/a.jpg">"#
808 );
809 }
810
811 #[test]
812 fn absolutize_resolves_against_the_pages_own_directory() {
813 let html = r#"<a href="../sibling.html">s</a><a href="deeper/d.html">d</a>"#;
816 let out = absolutize_html(html, "a/b/c.html", "https://ex.com/");
817 assert!(out.contains(r#"href="https://ex.com/a/sibling.html""#));
818 assert!(out.contains(r#"href="https://ex.com/a/b/deeper/d.html""#));
819 }
820
821 #[test]
822 fn absolutize_rebases_under_a_base_url_that_has_a_path() {
823 let html = r#"<img src="../_attachments/scan.jpg">"#;
825 let out = absolutize_html(html, "notes/entry.html", "https://ex.com/sites/ns/letters");
826 assert_eq!(
827 out,
828 r#"<img src="https://ex.com/sites/ns/letters/_attachments/scan.jpg">"#
829 );
830 }
831
832 #[test]
833 fn absolutize_leaves_absolute_root_relative_and_fragment_links() {
834 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>"##;
835 assert_eq!(absolutize_html(html, "index.html", "https://ex.com"), html);
836 }
837
838 #[test]
839 fn absolutize_keeps_query_and_fragment_suffixes() {
840 let html = r##"<a href="post.html#note-1">n</a><a href="p.html?v=2">q</a>"##;
841 let out = absolutize_html(html, "index.html", "https://ex.com");
842 assert!(out.contains(r#"href="https://ex.com/post.html#note-1""#));
843 assert!(out.contains(r#"href="https://ex.com/p.html?v=2""#));
844 }
845
846 #[test]
847 fn absolutize_leaves_a_path_that_climbs_above_the_root() {
848 let html = r#"<a href="../../nope.html">x</a>"#;
850 assert_eq!(absolutize_html(html, "a/b.html", "https://ex.com"), html);
851 }
852
853 #[test]
854 fn absolutize_does_not_match_a_suffixed_attribute_name() {
855 let html = r#"<img data-src="a.jpg" src="b.jpg">"#;
856 let out = absolutize_html(html, "index.html", "https://ex.com");
857 assert!(out.contains(r#"data-src="a.jpg""#));
858 assert!(out.contains(r#"src="https://ex.com/b.jpg""#));
859 }
860
861 #[test]
862 fn absolutize_leaves_a_colon_in_a_filename_alone() {
863 let html = r#"<a href="notes/9:15.html">t</a>"#;
864 let out = absolutize_html(html, "index.html", "https://ex.com");
865 assert_eq!(out, r#"<a href="https://ex.com/notes/9:15.html">t</a>"#);
866 }
867
868 #[test]
869 fn absolutize_without_a_base_is_a_no_op() {
870 let html = r#"<a href="post.html">x</a>"#;
871 assert_eq!(absolutize_html(html, "index.html", ""), html);
872 }
873
874 #[test]
875 fn absolutize_leaves_text_between_tags_untouched() {
876 let html = r#"<p>see href="post.html" below</p><a href="post.html">x</a>"#;
877 let out = absolutize_html(html, "index.html", "https://ex.com");
878 assert!(out.contains(r#"see href="post.html" below"#));
879 assert!(out.contains(r#"<a href="https://ex.com/post.html">"#));
880 }
881
882 #[test]
886 fn transform_links_rebases_root_absolute_attachments() {
887 let workspace = Path::new("");
888 let map = HashMap::new();
889 let html = r#"<img src="/img/photo.png" alt="a">"#;
890
891 let out = transform_links(html, Path::new("post.md"), &map, workspace, "post.html");
893 assert_eq!(out, r#"<img src="img/photo.png" alt="a">"#);
894
895 let out = transform_links(
897 html,
898 Path::new("notes/deep.md"),
899 &map,
900 workspace,
901 "notes/deep.html",
902 );
903 assert_eq!(out, r#"<img src="../img/photo.png" alt="a">"#);
904 }
905
906 #[test]
912 fn a_reference_to_a_withheld_file_is_marked_like_an_unpublished_page() {
913 let workspace = Path::new("");
914 let map = HashMap::new();
915 let html = concat!(
916 r#"<img src="attachments/private.jpg" alt="A private picture">"#,
917 r#"<img src="/attachments/shipped.jpg" alt="ok">"#,
918 r#"<a href="attachments/private.pdf">Read the scan</a>"#,
919 r#"<video controls src="attachments/private.mp4"></video>"#,
920 r#"<a href="https://example.com/x.jpg">out</a>"#,
921 r#"<a href="/feed.xml">feed</a>"#,
922 r#"<img src="attachments/nameless.png" alt="">"#,
923 );
924 let published: HashSet<String> = ["attachments/shipped.jpg".to_string()].into();
925
926 let out = transform_links_with_files(
927 html,
928 Path::new("index.md"),
929 &map,
930 workspace,
931 "index.html",
932 Some(&published),
933 );
934 assert_eq!(
935 out,
936 concat!(
937 r#"<span class="unpublished-link" title="This file isn’t published">A private picture</span>"#,
938 r#"<img src="attachments/shipped.jpg" alt="ok">"#,
939 r#"<span class="unpublished-link" title="This file isn’t published">Read the scan</span>"#,
940 r#"<span class="unpublished-link" title="This file isn’t published">private.mp4</span>"#,
941 r#"<a href="https://example.com/x.jpg">out</a>"#,
942 r#"<a href="feed.xml">feed</a>"#,
943 r#"<span class="unpublished-link" title="This file isn’t published">nameless.png</span>"#,
944 )
945 );
946
947 let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
949 assert!(
950 out.contains(r#"<img src="attachments/private.jpg""#),
951 "{out}"
952 );
953 }
954
955 #[test]
959 fn a_destination_href_is_a_link_to_the_page_it_names() {
960 let workspace = Path::new("");
961 let mut map = HashMap::new();
962 map.insert(PathBuf::from("index.md"), "index.html".to_string());
963 map.insert(
964 PathBuf::from("notes/entry.md"),
965 "notes/entry.html".to_string(),
966 );
967 let html = r##"<a href="notes/entry.html#top">E</a> <a href="notes/gone.html">G</a>"##;
968
969 let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
970 assert_eq!(
971 out,
972 r##"<a href="notes/entry.html#top">E</a> <span class="unpublished-link" title="This page isn’t published">G</span>"##
973 );
974
975 let out = transform_links(
979 html,
980 Path::new("notes/entry.md"),
981 &map,
982 workspace,
983 "notes/entry.html",
984 );
985 assert!(
986 out.starts_with(r##"<a href="../notes/entry.html#top">E</a>"##),
987 "{out}"
988 );
989 }
990
991 #[test]
994 fn a_source_html_document_is_resolved_as_a_source() {
995 let workspace = Path::new("");
996 let mut map = HashMap::new();
997 map.insert(
998 PathBuf::from("notes/artifact.html"),
999 "notes/artifact.html".to_string(),
1000 );
1001 let html = r#"<a href="artifact.html">A</a>"#;
1002 let out = transform_links(
1003 html,
1004 Path::new("notes/entry.md"),
1005 &map,
1006 workspace,
1007 "notes/entry.html",
1008 );
1009 assert_eq!(out, r#"<a href="../notes/artifact.html">A</a>"#);
1010 }
1011
1012 #[test]
1015 fn transform_links_rebases_every_root_absolute_src_and_href() {
1016 let workspace = Path::new("");
1017 let map = HashMap::new();
1018 let html = r#"<iframe class="diaryx-island" src="/att/page.html"></iframe><a href="/att/scan.pdf">s</a>"#;
1019 let out = transform_links(
1020 html,
1021 Path::new("notes/deep.md"),
1022 &map,
1023 workspace,
1024 "notes/deep.html",
1025 );
1026 assert!(out.contains(r#"src="../att/page.html""#), "got {out}");
1027 assert!(out.contains(r#"href="../att/scan.pdf""#), "got {out}");
1028 }
1029
1030 #[test]
1034 fn transform_links_resolves_a_root_absolute_document_before_rebasing() {
1035 let workspace = Path::new("");
1036 let mut map = HashMap::new();
1037 map.insert(PathBuf::from("post.md"), "post.html".to_string());
1038 let html = r#"<a href="/post.md">x</a>"#;
1039 let out = transform_links(
1040 html,
1041 Path::new("notes/deep.md"),
1042 &map,
1043 workspace,
1044 "notes/deep.html",
1045 );
1046 assert_eq!(out, r#"<a href="../post.html">x</a>"#);
1047 }
1048
1049 #[test]
1052 fn transform_links_leaves_protocol_relative_and_bare_slash() {
1053 let workspace = Path::new("");
1054 let map = HashMap::new();
1055 let html = r#"<img src="//cdn.example/x.png"><a href="/">home</a>"#;
1056 let out = transform_links(
1057 html,
1058 Path::new("notes/deep.md"),
1059 &map,
1060 workspace,
1061 "notes/deep.html",
1062 );
1063 assert_eq!(out, html);
1064 }
1065
1066 #[test]
1067 fn transform_links_leaves_external_and_anchors() {
1068 let workspace = Path::new("/ws");
1069 let map = HashMap::new();
1070 let current = Path::new("/ws/source.md");
1071 let html =
1072 r##"<a href="https://x.com/a.md">e</a><a href="#frag">f</a><a href="img.png">g</a>"##;
1073 let out = transform_links(html, current, &map, workspace, "source.html");
1074 assert_eq!(out, html);
1075 }
1076}