1use std::collections::HashMap;
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(
40 html: &str,
41 current_path: &Path,
42 path_to_filename: &HashMap<PathBuf, String>,
43 workspace_dir: &Path,
44 dest_filename: &str,
45) -> String {
46 let prefix = root_prefix(dest_filename);
47 let html = &rewrite_document_links(
48 html,
49 current_path,
50 path_to_filename,
51 workspace_dir,
52 dest_filename,
53 );
54 rebase_root_absolute(html, &prefix)
55}
56
57fn rewrite_document_links(
66 html: &str,
67 current_path: &Path,
68 path_to_filename: &HashMap<PathBuf, String>,
69 workspace_dir: &Path,
70 dest_filename: &str,
71) -> String {
72 let prefix = root_prefix(dest_filename);
73 let current_relative = current_path
75 .strip_prefix(workspace_dir)
76 .unwrap_or(current_path);
77
78 let mut result = String::with_capacity(html.len());
79 let mut remaining = html;
80
81 while let Some(tag_start) = remaining.find("<a ") {
82 result.push_str(&remaining[..tag_start]);
84 let after = &remaining[tag_start..];
85
86 let Some(gt) = after.find('>') else {
89 result.push_str(after);
90 remaining = "";
91 break;
92 };
93 let open_tag = &after[..=gt];
94 let tail = &after[gt + 1..];
95
96 let canonical =
98 extract_href(open_tag).and_then(|href| document_link_canonical(href, current_relative));
99
100 match canonical {
101 None => {
102 result.push_str(open_tag);
104 remaining = tail;
105 }
106 Some((canonical, suffix)) => {
107 let Some(close) = tail.find("</a>") else {
109 result.push_str(open_tag);
110 remaining = tail;
111 continue;
112 };
113 let inner = &tail[..close];
114 let after_close = &tail[close + "</a>".len()..];
115
116 let key = workspace_dir.join(sanitize_rel_path(&canonical));
117 match path_to_filename.get(&key) {
118 Some(html_path) => {
119 result.push_str(&replace_href(
121 open_tag,
122 &format!("{prefix}{html_path}{suffix}"),
123 ));
124 result.push_str(inner);
125 result.push_str("</a>");
126 }
127 None => {
128 result.push_str(
130 r#"<span class="unpublished-link" title="This page isn’t published">"#,
131 );
132 result.push_str(inner);
133 result.push_str("</span>");
134 }
135 }
136 remaining = after_close;
137 }
138 }
139 }
140 result.push_str(remaining);
141
142 result
143}
144
145fn extract_href(open_tag: &str) -> Option<&str> {
147 let start = open_tag.find("href=\"")? + 6;
148 let rest = &open_tag[start..];
149 let end = rest.find('"')?;
150 Some(&rest[..end])
151}
152
153fn document_link_canonical<'h>(
171 raw_href: &'h str,
172 current_relative: &Path,
173) -> Option<(String, &'h str)> {
174 if raw_href.starts_with("http://")
175 || raw_href.starts_with("https://")
176 || raw_href.starts_with('#')
177 {
178 return None;
179 }
180 let (path, suffix) = raw_href.split_at(raw_href.find(['?', '#']).unwrap_or(raw_href.len()));
181 let decoded = percent_decode(path);
182 prov::ContentFormat::from_extension(Path::new(decoded.trim()))?;
185 let target = prov::Link::parse_path_only(decoded.trim()).target;
186 Some((
187 prov::link::resolve(current_relative, &target)
188 .to_string_lossy()
189 .into_owned(),
190 suffix,
191 ))
192}
193
194fn replace_href(open_tag: &str, new_value: &str) -> String {
196 let Some(start) = open_tag.find("href=\"") else {
197 return open_tag.to_string();
198 };
199 let value_start = start + 6;
200 let rest = &open_tag[value_start..];
201 let Some(end) = rest.find('"') else {
202 return open_tag.to_string();
203 };
204 format!("{}{}{}", &open_tag[..value_start], new_value, &rest[end..])
205}
206
207fn rebase_root_absolute(html: &str, prefix: &str) -> String {
235 let mut result = String::with_capacity(html.len());
236 let mut remaining = html;
237
238 while let Some(lt) = remaining.find('<') {
239 result.push_str(&remaining[..lt]);
240 let after = &remaining[lt..];
241
242 let Some(gt) = after.find('>') else {
245 result.push_str(after);
246 return result;
247 };
248
249 let mut tag = after[..=gt].to_string();
250 for name in ["href", "src"] {
251 let Some((start, end)) = find_attr_value(&tag, name) else {
252 continue;
253 };
254 let value = &tag[start..end];
255 if !value.starts_with('/') || value.starts_with("//") || value.len() == 1 {
256 continue;
257 }
258 let rebased = format!("{prefix}{}", &value[1..]);
259 tag.replace_range(start..end, &rebased);
260 }
261 result.push_str(&tag);
262 remaining = &after[gt + 1..];
263 }
264 result.push_str(remaining);
265
266 result
267}
268
269pub fn absolutize_html(html: &str, dest_filename: &str, base_url: &str) -> String {
303 let base = base_url.trim_end_matches('/');
304 if base.is_empty() {
305 return html.to_string();
306 }
307 let dir = dest_filename.rsplit_once('/').map_or("", |(dir, _)| dir);
308
309 let mut result = String::with_capacity(html.len());
310 let mut remaining = html;
311
312 while let Some(lt) = remaining.find('<') {
313 result.push_str(&remaining[..lt]);
314 let after = &remaining[lt..];
315
316 let Some(gt) = after.find('>') else {
319 result.push_str(after);
320 return result;
321 };
322
323 result.push_str(&absolutize_tag(&after[..=gt], dir, base));
324 remaining = &after[gt + 1..];
325 }
326 result.push_str(remaining);
327
328 result
329}
330
331fn absolutize_tag(tag: &str, dir: &str, base: &str) -> String {
333 let mut out = tag.to_string();
334 for name in ["href", "src"] {
335 let Some((start, end)) = find_attr_value(&out, name) else {
336 continue;
337 };
338 let Some(absolute) = absolutize_url(&out[start..end], dir, base) else {
339 continue;
340 };
341 out.replace_range(start..end, &absolute);
342 }
343 out
344}
345
346fn find_attr_value(tag: &str, name: &str) -> Option<(usize, usize)> {
349 let pattern = format!("{name}=\"");
350 let mut from = 0;
351 while let Some(offset) = tag[from..].find(&pattern) {
352 let at = from + offset;
353 let start = at + pattern.len();
354 let end = start + tag[start..].find('"')?;
355 if at == 0
356 || tag[..at]
357 .chars()
358 .next_back()
359 .is_some_and(char::is_whitespace)
360 {
361 return Some((start, end));
362 }
363 from = end + 1;
364 }
365 None
366}
367
368fn absolutize_url(value: &str, dir: &str, base: &str) -> Option<String> {
371 if value.is_empty() || value.starts_with('#') || value.starts_with('/') || has_scheme(value) {
372 return None;
373 }
374
375 let (path, suffix) = value.split_at(value.find(['?', '#']).unwrap_or(value.len()));
377 if path.is_empty() {
378 return None;
379 }
380
381 let joined = if dir.is_empty() {
382 path.to_string()
383 } else {
384 format!("{dir}/{path}")
385 };
386 Some(format!("{base}/{}{suffix}", normalize_rel_path(&joined)?))
387}
388
389fn normalize_rel_path(path: &str) -> Option<String> {
391 let mut segments: Vec<&str> = Vec::new();
392 for segment in path.split('/') {
393 match segment {
394 "" | "." => {}
395 ".." => {
396 segments.pop()?;
397 }
398 other => segments.push(other),
399 }
400 }
401 if segments.is_empty() {
402 return None;
403 }
404 let mut joined = segments.join("/");
405 if path.ends_with('/') {
406 joined.push('/');
407 }
408 Some(joined)
409}
410
411fn has_scheme(value: &str) -> bool {
415 let Some(colon) = value.find(':') else {
416 return false;
417 };
418 let scheme = &value[..colon];
419 scheme.starts_with(|c: char| c.is_ascii_alphabetic())
420 && scheme
421 .chars()
422 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
423}
424
425pub fn sanitize_path_component(s: &str) -> String {
430 s.chars()
431 .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_' || *c == '.')
432 .collect()
433}
434
435pub fn sanitize_rel_path(path: &str) -> String {
439 let sanitized: PathBuf = Path::new(path)
440 .components()
441 .map(|c| match c {
442 std::path::Component::Normal(s) => {
443 std::ffi::OsString::from(sanitize_path_component(&s.to_string_lossy()))
444 }
445 other => other.as_os_str().to_owned(),
446 })
447 .collect();
448 sanitized.to_string_lossy().into_owned()
449}
450
451pub fn percent_decode(input: &str) -> String {
453 let mut result = Vec::with_capacity(input.len());
454 let bytes = input.as_bytes();
455 let mut i = 0;
456 while i < bytes.len() {
457 if bytes[i] == b'%'
458 && i + 2 < bytes.len()
459 && let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
460 {
461 result.push(hi << 4 | lo);
462 i += 3;
463 continue;
464 }
465 result.push(bytes[i]);
466 i += 1;
467 }
468 String::from_utf8(result).unwrap_or_else(|_| input.to_string())
469}
470
471fn hex_val(b: u8) -> Option<u8> {
472 match b {
473 b'0'..=b'9' => Some(b - b'0'),
474 b'a'..=b'f' => Some(b - b'a' + 10),
475 b'A'..=b'F' => Some(b - b'A' + 10),
476 _ => None,
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 #[test]
485 fn root_prefix_depth() {
486 assert_eq!(root_prefix("index.html"), "");
487 assert_eq!(root_prefix("a/b.html"), "../");
488 assert_eq!(root_prefix("a/b/c.html"), "../../");
489 }
490
491 #[test]
492 fn percent_decode_cases() {
493 assert_eq!(percent_decode("hello"), "hello");
494 assert_eq!(percent_decode("hello%20world"), "hello world");
495 assert_eq!(
496 percent_decode("Message%20for%20my%20family.md"),
497 "Message for my family.md"
498 );
499 assert_eq!(percent_decode("%2Fpath%2Fto%2Ffile"), "/path/to/file");
500 assert_eq!(percent_decode("hello%2"), "hello%2");
502 assert_eq!(percent_decode("hello%"), "hello%");
503 assert_eq!(percent_decode("hello%ZZ"), "hello%ZZ");
505 }
506
507 #[test]
508 fn transform_links_rewrites_known_md_target() {
509 let workspace = Path::new("/ws");
510 let mut map = HashMap::new();
511 map.insert(
512 PathBuf::from("/ws/notes/target.md"),
513 "notes/target.html".to_string(),
514 );
515
516 let html = r#"<a href="target.md">x</a>"#;
517 let current = Path::new("/ws/notes/source.md");
518 let out = transform_links(html, current, &map, workspace, "notes/source.html");
519 assert_eq!(out, r#"<a href="../notes/target.html">x</a>"#);
521 }
522
523 #[test]
524 fn transform_links_unknown_md_is_stripped_and_marked() {
525 let workspace = Path::new("/ws");
529 let map = HashMap::new();
530 let html = r#"<a href="missing.md">link text</a>"#;
531 let current = Path::new("/ws/source.md");
532 let out = transform_links(html, current, &map, workspace, "source.html");
533 assert_eq!(
534 out,
535 r#"<span class="unpublished-link" title="This page isn’t published">link text</span>"#
536 );
537 }
538
539 #[test]
544 fn transform_links_rewrites_a_link_carrying_a_fragment() {
545 let workspace = Path::new("");
546 let mut map = HashMap::new();
547 map.insert(
548 PathBuf::from("about/index.md"),
549 "about/index.html".to_string(),
550 );
551
552 let html = r##"<a href="about/index.md#projects">p</a>"##;
553 let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
554 assert_eq!(out, r##"<a href="about/index.html#projects">p</a>"##);
555
556 let html = r##"<a href="/about/index.md?v=2#sec">p</a>"##;
559 let out = transform_links(
560 html,
561 Path::new("notes/deep.md"),
562 &map,
563 workspace,
564 "notes/deep.html",
565 );
566 assert_eq!(out, r##"<a href="../about/index.html?v=2#sec">p</a>"##);
567 }
568
569 #[test]
572 fn transform_links_strips_an_unpublished_target_with_a_fragment() {
573 let workspace = Path::new("");
574 let map = HashMap::new();
575 let html = r##"<a href="gone.md#sec">text</a>"##;
576 let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
577 assert_eq!(
578 out,
579 r#"<span class="unpublished-link" title="This page isn’t published">text</span>"#
580 );
581 }
582
583 #[test]
584 fn transform_links_resolves_sanitized_target() {
585 let workspace = Path::new("");
589 let mut map = HashMap::new();
590 map.insert(
591 PathBuf::from("First post.md"),
592 "First post.html".to_string(),
593 );
594 let html = r#"<a href="First%20post!.md">x</a>"#;
595 let current = Path::new("source.md");
596 let out = transform_links(html, current, &map, workspace, "source.html");
597 assert_eq!(out, r#"<a href="First post.html">x</a>"#);
598 }
599
600 #[test]
601 fn transform_links_preserves_inner_markup_when_stripping() {
602 let workspace = Path::new("");
603 let map = HashMap::new();
604 let html = r#"<a href="gone.md">see <em>this</em></a>"#;
605 let current = Path::new("source.md");
606 let out = transform_links(html, current, &map, workspace, "source.html");
607 assert!(out.contains(r#"<span class="unpublished-link""#));
608 assert!(out.contains("see <em>this</em></span>"));
609 assert!(!out.contains("<a "));
610 }
611
612 #[test]
613 fn absolutize_rewrites_href_and_src_from_the_root() {
614 let html = r#"<a href="post.html">x</a><img src="_attachments/a.jpg">"#;
615 let out = absolutize_html(html, "index.html", "https://ex.com");
616 assert_eq!(
617 out,
618 r#"<a href="https://ex.com/post.html">x</a><img src="https://ex.com/_attachments/a.jpg">"#
619 );
620 }
621
622 #[test]
623 fn absolutize_resolves_against_the_pages_own_directory() {
624 let html = r#"<a href="../sibling.html">s</a><a href="deeper/d.html">d</a>"#;
627 let out = absolutize_html(html, "a/b/c.html", "https://ex.com/");
628 assert!(out.contains(r#"href="https://ex.com/a/sibling.html""#));
629 assert!(out.contains(r#"href="https://ex.com/a/b/deeper/d.html""#));
630 }
631
632 #[test]
633 fn absolutize_rebases_under_a_base_url_that_has_a_path() {
634 let html = r#"<img src="../_attachments/scan.jpg">"#;
636 let out = absolutize_html(html, "notes/entry.html", "https://ex.com/sites/ns/letters");
637 assert_eq!(
638 out,
639 r#"<img src="https://ex.com/sites/ns/letters/_attachments/scan.jpg">"#
640 );
641 }
642
643 #[test]
644 fn absolutize_leaves_absolute_root_relative_and_fragment_links() {
645 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>"##;
646 assert_eq!(absolutize_html(html, "index.html", "https://ex.com"), html);
647 }
648
649 #[test]
650 fn absolutize_keeps_query_and_fragment_suffixes() {
651 let html = r##"<a href="post.html#note-1">n</a><a href="p.html?v=2">q</a>"##;
652 let out = absolutize_html(html, "index.html", "https://ex.com");
653 assert!(out.contains(r#"href="https://ex.com/post.html#note-1""#));
654 assert!(out.contains(r#"href="https://ex.com/p.html?v=2""#));
655 }
656
657 #[test]
658 fn absolutize_leaves_a_path_that_climbs_above_the_root() {
659 let html = r#"<a href="../../nope.html">x</a>"#;
661 assert_eq!(absolutize_html(html, "a/b.html", "https://ex.com"), html);
662 }
663
664 #[test]
665 fn absolutize_does_not_match_a_suffixed_attribute_name() {
666 let html = r#"<img data-src="a.jpg" src="b.jpg">"#;
667 let out = absolutize_html(html, "index.html", "https://ex.com");
668 assert!(out.contains(r#"data-src="a.jpg""#));
669 assert!(out.contains(r#"src="https://ex.com/b.jpg""#));
670 }
671
672 #[test]
673 fn absolutize_leaves_a_colon_in_a_filename_alone() {
674 let html = r#"<a href="notes/9:15.html">t</a>"#;
675 let out = absolutize_html(html, "index.html", "https://ex.com");
676 assert_eq!(out, r#"<a href="https://ex.com/notes/9:15.html">t</a>"#);
677 }
678
679 #[test]
680 fn absolutize_without_a_base_is_a_no_op() {
681 let html = r#"<a href="post.html">x</a>"#;
682 assert_eq!(absolutize_html(html, "index.html", ""), html);
683 }
684
685 #[test]
686 fn absolutize_leaves_text_between_tags_untouched() {
687 let html = r#"<p>see href="post.html" below</p><a href="post.html">x</a>"#;
688 let out = absolutize_html(html, "index.html", "https://ex.com");
689 assert!(out.contains(r#"see href="post.html" below"#));
690 assert!(out.contains(r#"<a href="https://ex.com/post.html">"#));
691 }
692
693 #[test]
697 fn transform_links_rebases_root_absolute_attachments() {
698 let workspace = Path::new("");
699 let map = HashMap::new();
700 let html = r#"<img src="/img/photo.png" alt="a">"#;
701
702 let out = transform_links(html, Path::new("post.md"), &map, workspace, "post.html");
704 assert_eq!(out, r#"<img src="img/photo.png" alt="a">"#);
705
706 let out = transform_links(
708 html,
709 Path::new("notes/deep.md"),
710 &map,
711 workspace,
712 "notes/deep.html",
713 );
714 assert_eq!(out, r#"<img src="../img/photo.png" alt="a">"#);
715 }
716
717 #[test]
720 fn transform_links_rebases_every_root_absolute_src_and_href() {
721 let workspace = Path::new("");
722 let map = HashMap::new();
723 let html = r#"<iframe class="diaryx-island" src="/att/page.html"></iframe><a href="/att/scan.pdf">s</a>"#;
724 let out = transform_links(
725 html,
726 Path::new("notes/deep.md"),
727 &map,
728 workspace,
729 "notes/deep.html",
730 );
731 assert!(out.contains(r#"src="../att/page.html""#), "got {out}");
732 assert!(out.contains(r#"href="../att/scan.pdf""#), "got {out}");
733 }
734
735 #[test]
739 fn transform_links_resolves_a_root_absolute_document_before_rebasing() {
740 let workspace = Path::new("");
741 let mut map = HashMap::new();
742 map.insert(PathBuf::from("post.md"), "post.html".to_string());
743 let html = r#"<a href="/post.md">x</a>"#;
744 let out = transform_links(
745 html,
746 Path::new("notes/deep.md"),
747 &map,
748 workspace,
749 "notes/deep.html",
750 );
751 assert_eq!(out, r#"<a href="../post.html">x</a>"#);
752 }
753
754 #[test]
757 fn transform_links_leaves_protocol_relative_and_bare_slash() {
758 let workspace = Path::new("");
759 let map = HashMap::new();
760 let html = r#"<img src="//cdn.example/x.png"><a href="/">home</a>"#;
761 let out = transform_links(
762 html,
763 Path::new("notes/deep.md"),
764 &map,
765 workspace,
766 "notes/deep.html",
767 );
768 assert_eq!(out, html);
769 }
770
771 #[test]
772 fn transform_links_leaves_external_and_anchors() {
773 let workspace = Path::new("/ws");
774 let map = HashMap::new();
775 let current = Path::new("/ws/source.md");
776 let html =
777 r##"<a href="https://x.com/a.md">e</a><a href="#frag">f</a><a href="img.png">g</a>"##;
778 let out = transform_links(html, current, &map, workspace, "source.html");
779 assert_eq!(out, html);
780 }
781}