1use std::sync::Arc;
22
23pub type MarkdownPageProbe = Arc<dyn Fn(&str) -> bool + Send + Sync>;
29
30pub fn filesystem_markdown_page_probe(
42 resolver: crate::path_resolver::OwnedPathResolverConfig,
43) -> MarkdownPageProbe {
44 Arc::new(move |absolute_url: &str| {
45 let request_path = crate::path_resolver::normalize_link_target(absolute_url);
46 matches!(
47 crate::path_resolver::resolve_request_path(&resolver.as_config(), &request_path),
48 crate::path_resolver::ResolvedPath::MarkdownFile(_)
49 )
50 })
51}
52
53#[derive(Clone)]
55pub struct LinkTransformConfig {
56 pub markdown_extensions: Vec<String>,
58 pub index_file: String,
60 pub is_index_file: bool,
62 pub url_depth: Option<usize>,
65 pub current_page_url: String,
72 pub markdown_page_probe: Option<MarkdownPageProbe>,
87}
88
89impl std::fmt::Debug for LinkTransformConfig {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.debug_struct("LinkTransformConfig")
94 .field("markdown_extensions", &self.markdown_extensions)
95 .field("index_file", &self.index_file)
96 .field("is_index_file", &self.is_index_file)
97 .field("url_depth", &self.url_depth)
98 .field("current_page_url", &self.current_page_url)
99 .field("markdown_page_probe", &self.markdown_page_probe.is_some())
100 .finish()
101 }
102}
103
104impl Default for LinkTransformConfig {
105 fn default() -> Self {
106 Self {
107 markdown_extensions: vec!["md".to_string()],
108 index_file: "index.md".to_string(),
109 is_index_file: false,
110 url_depth: None,
111 current_page_url: String::new(),
112 markdown_page_probe: None,
113 }
114 }
115}
116
117pub fn transform_link(url: &str, config: &LinkTransformConfig) -> String {
157 if url.is_empty() || url.trim().is_empty() {
159 return url.to_string();
160 }
161
162 if url.starts_with('#') {
164 return url.to_string();
165 }
166
167 if crate::url_path::is_external_url(url) {
174 return url.to_string();
175 }
176
177 if url.starts_with('/') {
179 return match config.url_depth {
180 Some(depth) => make_relative_url(url, depth),
181 None => url.to_string(),
182 };
183 }
184
185 let (path, suffix) = split_url_parts(url);
187
188 if path.is_empty() {
190 return url.to_string();
191 }
192
193 let path = path.strip_prefix("./").unwrap_or(&path);
195
196 let (parent_count, remaining_path) = count_parent_traversals(path);
198
199 if remaining_path.is_empty() {
201 let prefix = if config.is_index_file {
202 "../".repeat(parent_count)
203 } else {
204 "../".repeat(parent_count + 1)
205 };
206 return format!("{}{}", prefix, suffix);
207 }
208
209 if let Some(base_path) = strip_markdown_extension(remaining_path, &config.markdown_extensions) {
211 let index_stem = config
213 .index_file
214 .strip_suffix(".md")
215 .or_else(|| config.index_file.strip_suffix(".markdown"))
216 .unwrap_or(&config.index_file);
217
218 let is_index_target =
224 base_path == index_stem || base_path.ends_with(&format!("/{}", index_stem));
225
226 let final_path = if is_index_target {
227 let stripped = base_path
229 .strip_suffix(index_stem)
230 .unwrap_or(base_path)
231 .trim_end_matches('/');
232 if stripped.is_empty() {
233 "".to_string()
235 } else {
236 format!("{}/", stripped)
237 }
238 } else {
239 format!("{}/", base_path)
240 };
241
242 let prefix = if config.is_index_file {
244 "../".repeat(parent_count)
245 } else {
246 "../".repeat(parent_count + 1)
247 };
248
249 if final_path.is_empty() && prefix.is_empty() {
251 return format!("./{}", suffix);
252 }
253
254 return format!("{}{}{}", prefix, final_path, suffix);
255 }
256
257 let prefix = if config.is_index_file {
266 "../".repeat(parent_count)
267 } else {
268 "../".repeat(parent_count + 1)
269 };
270
271 if !remaining_path.ends_with('/') && resolves_to_markdown_page(path, config) {
272 return format!("{}{}/{}", prefix, remaining_path, suffix);
273 }
274
275 format!("{}{}{}", prefix, remaining_path, suffix)
276}
277
278fn resolves_to_markdown_page(authored_path: &str, config: &LinkTransformConfig) -> bool {
287 let Some(probe) = &config.markdown_page_probe else {
288 return false;
289 };
290 if config.current_page_url.is_empty() {
293 return false;
294 }
295 match crate::link_index::resolve_relative_url_checked(
296 &config.current_page_url,
297 authored_path,
298 config.is_index_file,
299 ) {
300 None => false,
303 Some(absolute) => probe(&absolute),
304 }
305}
306
307fn split_url_parts(url: &str) -> (String, String) {
310 let anchor_pos = url.find('#');
312 let query_pos = url.find('?');
313
314 let split_pos = match (anchor_pos, query_pos) {
315 (Some(a), Some(q)) => Some(a.min(q)),
316 (Some(a), None) => Some(a),
317 (None, Some(q)) => Some(q),
318 (None, None) => None,
319 };
320
321 match split_pos {
322 Some(pos) => (url[..pos].to_string(), url[pos..].to_string()),
323 None => (url.to_string(), String::new()),
324 }
325}
326
327fn count_parent_traversals(path: &str) -> (usize, &str) {
329 let mut count = 0;
330 let mut remaining = path;
331
332 while let Some(rest) = remaining.strip_prefix("../") {
333 count += 1;
334 remaining = rest;
335 }
336
337 (count, remaining)
338}
339
340fn strip_markdown_extension<'a>(path: &'a str, extensions: &[String]) -> Option<&'a str> {
342 for ext in extensions {
343 let suffix = format!(".{}", ext);
344 if path.ends_with(&suffix) {
345 return Some(&path[..path.len() - suffix.len()]);
346 }
347 }
348 None
349}
350
351pub fn make_relative_url(absolute_url: &str, depth: usize) -> String {
358 let target = absolute_url.trim_start_matches('/');
359 if target.is_empty() {
360 if depth == 0 {
362 "./".to_string()
363 } else {
364 "../".repeat(depth)
365 }
366 } else {
367 if depth == 0 {
369 target.to_string()
370 } else {
371 format!("{}{}", "../".repeat(depth), target)
372 }
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 fn regular_config() -> LinkTransformConfig {
381 LinkTransformConfig {
382 markdown_extensions: vec!["md".to_string(), "markdown".to_string()],
383 index_file: "index.md".to_string(),
384 is_index_file: false,
385 url_depth: None,
386 current_page_url: String::new(),
387 markdown_page_probe: None,
388 }
389 }
390
391 fn index_config() -> LinkTransformConfig {
392 LinkTransformConfig {
393 is_index_file: true,
394 ..regular_config()
395 }
396 }
397
398 #[test]
403 fn test_simple_relative_md() {
404 assert_eq!(transform_link("other.md", ®ular_config()), "../other/");
405 }
406
407 #[test]
408 fn test_subdirectory_md() {
409 assert_eq!(
410 transform_link("sub/doc.md", ®ular_config()),
411 "../sub/doc/"
412 );
413 }
414
415 #[test]
416 fn test_parent_traversal() {
417 assert_eq!(
418 transform_link("../other.md", ®ular_config()),
419 "../../other/"
420 );
421 }
422
423 #[test]
424 fn test_double_parent() {
425 assert_eq!(
426 transform_link("../../root.md", ®ular_config()),
427 "../../../root/"
428 );
429 }
430
431 #[test]
432 fn test_index_collapse() {
433 assert_eq!(
434 transform_link("folder/index.md", ®ular_config()),
435 "../folder/"
436 );
437 }
438
439 #[test]
440 fn test_nested_index_collapse() {
441 assert_eq!(transform_link("a/b/index.md", ®ular_config()), "../a/b/");
442 }
443
444 #[test]
445 fn test_just_index_md() {
446 assert_eq!(transform_link("index.md", ®ular_config()), "../");
448 }
449
450 #[test]
456 fn test_index_lookalike_stems_keep_their_own_url() {
457 let cases = [
458 ("site-index.md", "../site-index/"),
459 ("myindex.md", "../myindex/"),
460 ("reindex.md", "../reindex/"),
461 ("subindex.md", "../subindex/"),
462 ("docs/site-index.md", "../docs/site-index/"),
463 ];
464
465 for (input, expected) in cases {
466 assert_eq!(transform_link(input, ®ular_config()), expected);
467 }
468
469 assert_eq!(
471 transform_link("docs/index.md", ®ular_config()),
472 "../docs/"
473 );
474 assert_eq!(transform_link("index.md", ®ular_config()), "../");
475 }
476
477 #[test]
478 fn test_index_lookalike_stems_from_index_page() {
479 assert_eq!(transform_link("subindex.md", &index_config()), "subindex/");
480 assert_eq!(transform_link("docs/index.md", &index_config()), "docs/");
481 }
482
483 #[test]
484 fn test_static_file() {
485 assert_eq!(
486 transform_link("image.png", ®ular_config()),
487 "../image.png"
488 );
489 }
490
491 #[test]
492 fn test_nested_static() {
493 assert_eq!(
494 transform_link("assets/img.png", ®ular_config()),
495 "../assets/img.png"
496 );
497 }
498
499 #[test]
500 fn test_md_with_anchor() {
501 assert_eq!(
502 transform_link("other.md#section", ®ular_config()),
503 "../other/#section"
504 );
505 }
506
507 #[test]
508 fn test_md_with_query() {
509 assert_eq!(
510 transform_link("other.md?foo=bar", ®ular_config()),
511 "../other/?foo=bar"
512 );
513 }
514
515 #[test]
516 fn test_md_with_query_and_anchor() {
517 assert_eq!(
518 transform_link("other.md?foo=bar#section", ®ular_config()),
519 "../other/?foo=bar#section"
520 );
521 }
522
523 #[test]
524 fn test_explicit_current_dir() {
525 assert_eq!(transform_link("./other.md", ®ular_config()), "../other/");
526 }
527
528 #[test]
529 fn test_alternate_extension() {
530 assert_eq!(
531 transform_link("other.markdown", ®ular_config()),
532 "../other/"
533 );
534 }
535
536 #[test]
537 fn test_parent_static_file() {
538 assert_eq!(
539 transform_link("../image.png", ®ular_config()),
540 "../../image.png"
541 );
542 }
543
544 #[test]
549 fn test_index_simple_relative_md() {
550 assert_eq!(transform_link("other.md", &index_config()), "other/");
551 }
552
553 #[test]
554 fn test_index_subdirectory_md() {
555 assert_eq!(transform_link("sub/doc.md", &index_config()), "sub/doc/");
556 }
557
558 #[test]
559 fn test_index_parent_traversal() {
560 assert_eq!(transform_link("../other.md", &index_config()), "../other/");
561 }
562
563 #[test]
564 fn test_index_double_parent() {
565 assert_eq!(
566 transform_link("../../root.md", &index_config()),
567 "../../root/"
568 );
569 }
570
571 #[test]
572 fn test_index_static_file() {
573 assert_eq!(transform_link("image.png", &index_config()), "image.png");
575 }
576
577 #[test]
578 fn test_index_nested_static() {
579 assert_eq!(
580 transform_link("assets/img.png", &index_config()),
581 "assets/img.png"
582 );
583 }
584
585 #[test]
586 fn test_index_md_with_anchor() {
587 assert_eq!(
588 transform_link("other.md#section", &index_config()),
589 "other/#section"
590 );
591 }
592
593 #[test]
594 fn test_index_parent_static() {
595 assert_eq!(
596 transform_link("../image.png", &index_config()),
597 "../image.png"
598 );
599 }
600
601 #[test]
602 fn test_index_to_index_collapse() {
603 assert_eq!(
604 transform_link("folder/index.md", &index_config()),
605 "folder/"
606 );
607 }
608
609 #[test]
614 fn test_absolute_https() {
615 let url = "https://example.com/path";
616 assert_eq!(transform_link(url, ®ular_config()), url);
617 assert_eq!(transform_link(url, &index_config()), url);
618 }
619
620 #[test]
621 fn test_absolute_http() {
622 let url = "http://example.com/path";
623 assert_eq!(transform_link(url, ®ular_config()), url);
624 assert_eq!(transform_link(url, &index_config()), url);
625 }
626
627 #[test]
628 fn test_protocol_relative() {
629 let url = "//cdn.example.com/file.js";
630 assert_eq!(transform_link(url, ®ular_config()), url);
631 assert_eq!(transform_link(url, &index_config()), url);
632 }
633
634 #[test]
635 fn test_root_relative() {
636 let url = "/docs/guide/";
637 assert_eq!(transform_link(url, ®ular_config()), url);
638 assert_eq!(transform_link(url, &index_config()), url);
639 }
640
641 #[test]
642 fn test_anchor_only() {
643 let url = "#section";
644 assert_eq!(transform_link(url, ®ular_config()), url);
645 assert_eq!(transform_link(url, &index_config()), url);
646 }
647
648 #[test]
649 fn test_empty_link() {
650 assert_eq!(transform_link("", ®ular_config()), "");
651 assert_eq!(transform_link("", &index_config()), "");
652 }
653
654 #[test]
655 fn test_data_url() {
656 let url = "data:image/png;base64,abc123";
657 assert_eq!(transform_link(url, ®ular_config()), url);
658 }
659
660 #[test]
661 fn test_data_image_url_unchanged() {
662 let url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ";
664 assert_eq!(transform_link(url, ®ular_config()), url);
665 assert_eq!(transform_link(url, &index_config()), url);
666 }
667
668 #[test]
669 fn test_blob_url_unchanged() {
670 let url = "blob:http://localhost:5220/550e8400-e29b-41d4-a716-446655440000";
673 assert_eq!(transform_link(url, ®ular_config()), url);
674 assert_eq!(transform_link(url, &index_config()), url);
675 }
676
677 #[test]
678 fn test_javascript_url() {
679 let url = "javascript:void(0)";
680 assert_eq!(transform_link(url, ®ular_config()), url);
681 }
682
683 #[test]
684 fn test_mailto_url() {
685 let url = "mailto:test@example.com";
686 assert_eq!(transform_link(url, ®ular_config()), url);
687 }
688
689 #[test]
690 fn test_ftp_url() {
691 let url = "ftp://ftp.example.com/file.txt";
692 assert_eq!(transform_link(url, ®ular_config()), url);
693 }
694
695 #[test]
696 fn test_scheme_urls_unchanged() {
697 for url in [
700 "ftps://ftp.example.com/file.txt",
701 "magnet:?xt=urn:btih:c12fe1c06bba254a9dc9",
702 "sms:+15555550123",
703 "callto:+15555550123",
704 "ssh://git@example.com/repo.git",
705 ] {
706 assert_eq!(transform_link(url, ®ular_config()), url);
707 assert_eq!(transform_link(url, &index_config()), url);
708 }
709 }
710
711 #[test]
712 fn test_colon_in_relative_path_is_still_transformed() {
713 assert_eq!(
716 transform_link("docs/a:b.md", ®ular_config()),
717 "../docs/a:b/"
718 );
719 }
720
721 #[test]
726 fn test_file_with_dots_in_name() {
727 assert_eq!(
729 transform_link("my.file.md", ®ular_config()),
730 "../my.file/"
731 );
732 }
733
734 #[test]
735 fn test_non_md_extension() {
736 assert_eq!(
737 transform_link("readme.txt", ®ular_config()),
738 "../readme.txt"
739 );
740 }
741
742 #[test]
743 fn test_just_query() {
744 assert_eq!(transform_link("?foo=bar", ®ular_config()), "?foo=bar");
746 }
747
748 #[test]
749 fn test_deeply_nested_path() {
750 assert_eq!(
751 transform_link("a/b/c/d/file.md", ®ular_config()),
752 "../a/b/c/d/file/"
753 );
754 }
755
756 #[test]
757 fn test_mixed_traversal_and_descent() {
758 assert_eq!(
759 transform_link("../sibling/doc.md", ®ular_config()),
760 "../../sibling/doc/"
761 );
762 }
763
764 fn build_config(depth: usize) -> LinkTransformConfig {
769 LinkTransformConfig {
770 url_depth: Some(depth),
771 ..regular_config()
772 }
773 }
774
775 #[test]
776 fn test_root_relative_with_depth_0() {
777 assert_eq!(
778 transform_link("/videos/demo.mp4", &build_config(0)),
779 "videos/demo.mp4"
780 );
781 }
782
783 #[test]
784 fn test_root_relative_with_depth_1() {
785 assert_eq!(
786 transform_link("/videos/demo.mp4", &build_config(1)),
787 "../videos/demo.mp4"
788 );
789 }
790
791 #[test]
792 fn test_root_relative_with_depth_2() {
793 assert_eq!(
794 transform_link("/videos/demo.mp4", &build_config(2)),
795 "../../videos/demo.mp4"
796 );
797 }
798
799 #[test]
800 fn test_root_relative_to_root_with_depth() {
801 assert_eq!(transform_link("/", &build_config(0)), "./");
802 assert_eq!(transform_link("/", &build_config(1)), "../");
803 assert_eq!(transform_link("/", &build_config(2)), "../../");
804 }
805
806 #[test]
807 fn test_root_relative_tag_link_with_depth() {
808 assert_eq!(
809 transform_link("/tags/rust/", &build_config(2)),
810 "../../tags/rust/"
811 );
812 }
813
814 #[test]
815 fn test_root_relative_unchanged_without_depth() {
816 assert_eq!(
818 transform_link("/videos/demo.mp4", ®ular_config()),
819 "/videos/demo.mp4"
820 );
821 assert_eq!(
822 transform_link("/tags/rust/", ®ular_config()),
823 "/tags/rust/"
824 );
825 }
826
827 #[test]
832 fn test_make_relative_url_to_root() {
833 assert_eq!(make_relative_url("/", 0), "./");
834 assert_eq!(make_relative_url("/", 1), "../");
835 assert_eq!(make_relative_url("/", 2), "../../");
836 }
837
838 #[test]
839 fn test_make_relative_url_to_path() {
840 assert_eq!(make_relative_url("/docs/", 0), "docs/");
841 assert_eq!(make_relative_url("/docs/guide/", 0), "docs/guide/");
842 assert_eq!(make_relative_url("/docs/", 1), "../docs/");
843 assert_eq!(make_relative_url("/other/", 1), "../other/");
844 assert_eq!(make_relative_url("/docs/", 2), "../../docs/");
845 assert_eq!(make_relative_url("/docs/guide/", 2), "../../docs/guide/");
846 }
847}
848
849#[cfg(test)]
850mod proptests {
851 use super::*;
852 use proptest::prelude::*;
853
854 fn regular_config() -> LinkTransformConfig {
855 LinkTransformConfig {
856 markdown_extensions: vec!["md".to_string(), "markdown".to_string()],
857 index_file: "index.md".to_string(),
858 is_index_file: false,
859 url_depth: None,
860 current_page_url: String::new(),
861 markdown_page_probe: None,
862 }
863 }
864
865 fn index_config() -> LinkTransformConfig {
866 LinkTransformConfig {
867 is_index_file: true,
868 ..regular_config()
869 }
870 }
871
872 proptest! {
873 #[test]
875 fn prop_deterministic(url in ".*") {
876 let config = regular_config();
877 let r1 = transform_link(&url, &config);
878 let r2 = transform_link(&url, &config);
879 prop_assert_eq!(r1, r2);
880 }
881
882 #[test]
884 fn prop_https_unchanged(path in "[a-zA-Z0-9./_-]*") {
885 let url = format!("https://example.com/{}", path);
886 let config = regular_config();
887 prop_assert_eq!(transform_link(&url, &config), url);
888 }
889
890 #[test]
892 fn prop_http_unchanged(path in "[a-zA-Z0-9./_-]*") {
893 let url = format!("http://example.com/{}", path);
894 let config = regular_config();
895 prop_assert_eq!(transform_link(&url, &config), url);
896 }
897
898 #[test]
900 fn prop_protocol_relative_unchanged(path in "[a-zA-Z0-9./_-]*") {
901 let url = format!("//cdn.example.com/{}", path);
902 let config = regular_config();
903 prop_assert_eq!(transform_link(&url, &config), url);
904 }
905
906 #[test]
908 fn prop_root_relative_unchanged(path in "/[a-zA-Z0-9./_-]*") {
909 let config = regular_config();
910 prop_assert_eq!(transform_link(&path, &config), path);
911 }
912
913 #[test]
915 fn prop_root_relative_relativized(
916 path in "[a-zA-Z][a-zA-Z0-9/_-]{0,20}",
917 depth in 0usize..5
918 ) {
919 let url = format!("/{}", path);
920 let mut config = regular_config();
921 config.url_depth = Some(depth);
922 let result = transform_link(&url, &config);
923 prop_assert!(!result.starts_with('/'), "Should be relative: {}", result);
925 prop_assert!(result.ends_with(&path), "Should end with path {}: {}", path, result);
927 }
928
929 #[test]
931 fn prop_anchor_only_unchanged(anchor in "#[a-zA-Z0-9_-]*") {
932 let config = regular_config();
933 prop_assert_eq!(transform_link(&anchor, &config), anchor);
934 }
935
936 #[test]
938 fn prop_empty_unchanged(_dummy in 0..1i32) {
939 let config = regular_config();
940 prop_assert_eq!(transform_link("", &config), "");
941 }
942
943 #[test]
945 fn prop_regular_md_gets_parent(name in "[a-zA-Z][a-zA-Z0-9_-]{0,20}") {
946 let url = format!("{}.md", name);
947 let config = regular_config();
948 let result = transform_link(&url, &config);
949 prop_assert!(result.starts_with("../"), "Expected ../ prefix: {}", result);
950 }
951
952 #[test]
954 fn prop_index_md_no_extra_parent(name in "[a-zA-Z][a-zA-Z0-9_-]{0,20}") {
955 let url = format!("{}.md", name);
956 let config = index_config();
957 let result = transform_link(&url, &config);
958 prop_assert!(!result.starts_with("../"), "Should not have ../ prefix: {}", result);
959 }
960
961 #[test]
963 fn prop_md_ends_with_slash(name in "[a-zA-Z][a-zA-Z0-9_-]{0,20}") {
964 let url = format!("{}.md", name);
965 let config = regular_config();
966 let result = transform_link(&url, &config);
967 let base = result.split(&['?', '#'][..]).next().unwrap();
969 prop_assert!(base.ends_with('/'), "Path should end with /: {}", base);
970 }
971
972 #[test]
974 fn prop_anchor_preserved(
975 name in "[a-zA-Z][a-zA-Z0-9_-]{0,10}",
976 anchor in "[a-zA-Z][a-zA-Z0-9_-]{0,10}"
977 ) {
978 let url = format!("{}.md#{}", name, anchor);
979 let config = regular_config();
980 let result = transform_link(&url, &config);
981 prop_assert!(result.contains(&format!("#{}", anchor)), "Anchor not preserved: {}", result);
982 }
983
984 #[test]
986 fn prop_query_preserved(
987 name in "[a-zA-Z][a-zA-Z0-9_-]{0,10}",
988 query in "[a-zA-Z][a-zA-Z0-9_=-]{0,10}"
989 ) {
990 let url = format!("{}.md?{}", name, query);
991 let config = regular_config();
992 let result = transform_link(&url, &config);
993 prop_assert!(result.contains(&format!("?{}", query)), "Query not preserved: {}", result);
994 }
995 }
996}
997
998#[cfg(test)]
1009mod browser_resolution_tests {
1010 use super::*;
1011 use crate::path_resolver::OwnedPathResolverConfig;
1012 use std::path::Path;
1013 use tempfile::TempDir;
1014
1015 fn remove_dot_segments(path: &str) -> String {
1026 let mut input = path.to_string();
1027 let mut output = String::new();
1028
1029 fn pop_last_segment(output: &mut String) {
1030 match output.rfind('/') {
1031 Some(index) => output.truncate(index),
1032 None => output.clear(),
1033 }
1034 }
1035
1036 while !input.is_empty() {
1037 if let Some(rest) = input.strip_prefix("../") {
1038 input = rest.to_string();
1039 } else if let Some(rest) = input.strip_prefix("./") {
1040 input = rest.to_string();
1041 } else if let Some(rest) = input.strip_prefix("/./") {
1042 input = format!("/{rest}");
1043 } else if input == "/." {
1044 input = "/".to_string();
1045 } else if let Some(rest) = input.strip_prefix("/../") {
1046 input = format!("/{rest}");
1047 pop_last_segment(&mut output);
1048 } else if input == "/.." {
1049 input = "/".to_string();
1050 pop_last_segment(&mut output);
1051 } else if input == "." || input == ".." {
1052 input.clear();
1053 } else {
1054 let end = if let Some(rest) = input.strip_prefix('/') {
1056 rest.find('/').map(|i| i + 1).unwrap_or(input.len())
1057 } else {
1058 input.find('/').unwrap_or(input.len())
1059 };
1060 output.push_str(&input[..end]);
1061 input = input[end..].to_string();
1062 }
1063 }
1064
1065 output
1066 }
1067
1068 fn merge(base_path: &str, reference_path: &str) -> String {
1071 match base_path.rfind('/') {
1072 Some(index) => format!("{}{}", &base_path[..=index], reference_path),
1073 None => format!("/{reference_path}"),
1074 }
1075 }
1076
1077 fn resolve_in_browser(base: &str, reference: &str) -> String {
1080 let (without_fragment, fragment) = match reference.split_once('#') {
1081 Some((head, tail)) => (head, format!("#{tail}")),
1082 None => (reference, String::new()),
1083 };
1084 let (ref_path, query) = match without_fragment.split_once('?') {
1085 Some((head, tail)) => (head, format!("?{tail}")),
1086 None => (without_fragment, String::new()),
1087 };
1088
1089 let target = if ref_path.is_empty() {
1090 base.to_string()
1091 } else if ref_path.starts_with('/') {
1092 remove_dot_segments(ref_path)
1093 } else {
1094 remove_dot_segments(&merge(base, ref_path))
1095 };
1096
1097 format!("{target}{query}{fragment}")
1098 }
1099
1100 fn fixture() -> TempDir {
1107 let dir = TempDir::new().expect("temp repo");
1108 let root = dir.path();
1109 for folder in ["docs", "folder", "a/b", "static"] {
1110 std::fs::create_dir_all(root.join(folder)).expect("create dir");
1111 }
1112 for page in [
1113 "index.md",
1114 "root.md",
1115 "docs/index.md",
1116 "docs/guide.md",
1117 "docs/other.md",
1118 "folder/file.md",
1119 "folder/sibling.md",
1120 "a/b/c.md",
1121 "a/b/d.md",
1122 ] {
1123 std::fs::write(root.join(page), "# page").expect("write page");
1124 }
1125 for asset in ["LICENSE", "docs/Makefile", "folder/Dockerfile"] {
1128 std::fs::write(root.join(asset), "text").expect("write asset");
1129 }
1130 std::fs::write(root.join("docs/photo.png"), b"\x89PNG").expect("write image");
1131 dir
1132 }
1133
1134 fn transform_config(root: &Path, source_rel: &str) -> LinkTransformConfig {
1135 let source = root.join(source_rel);
1136 let is_index_file = source
1137 .file_name()
1138 .and_then(|f| f.to_str())
1139 .is_some_and(|f| f == "index.md");
1140 LinkTransformConfig {
1141 markdown_extensions: vec!["md".to_string()],
1142 index_file: "index.md".to_string(),
1143 is_index_file,
1144 url_depth: None,
1145 current_page_url: crate::repo::build_markdown_url_path(&source, root, "index.md"),
1146 markdown_page_probe: Some(filesystem_markdown_page_probe(OwnedPathResolverConfig {
1147 vault: std::sync::Arc::new(crate::vault::LocalVault::new(root)),
1148 static_vault: None,
1149 static_folder: "static".to_string(),
1150 markdown_extensions: vec!["md".to_string()],
1151 index_file: "index.md".to_string(),
1152 tag_sources: Vec::new(),
1153 exempt_hidden_dirs: Vec::new(),
1154 })),
1155 }
1156 }
1157
1158 fn page_url(root: &Path, source_rel: &str) -> String {
1160 crate::repo::build_markdown_url_path(&root.join(source_rel), root, "index.md")
1161 }
1162
1163 fn follow(root: &Path, source_rel: &str, href: &str) -> (String, String) {
1166 let config = transform_config(root, source_rel);
1167 let emitted = transform_link(href, &config);
1168 let landed = resolve_in_browser(&config.current_page_url, &emitted);
1169 (emitted, landed)
1170 }
1171
1172 fn assert_lands(root: &Path, source_rel: &str, href: &str, expected: &str) {
1173 let (emitted, landed) = follow(root, source_rel, href);
1174 assert_eq!(
1175 landed, expected,
1176 "[{source_rel}] `{href}` emitted `{emitted}` and landed on `{landed}`, \
1177 expected `{expected}`"
1178 );
1179 }
1180
1181 #[test]
1186 fn browser_model_matches_rfc_3986_examples() {
1187 assert_eq!(resolve_in_browser("/b/c/d;p", "g"), "/b/c/g");
1189 assert_eq!(resolve_in_browser("/b/c/d;p", "./g"), "/b/c/g");
1190 assert_eq!(resolve_in_browser("/b/c/d;p", "g/"), "/b/c/g/");
1191 assert_eq!(resolve_in_browser("/b/c/d;p", "/g"), "/g");
1192 assert_eq!(resolve_in_browser("/b/c/d;p", "../g"), "/b/g");
1193 assert_eq!(resolve_in_browser("/b/c/d;p", "../../g"), "/g");
1194 assert_eq!(resolve_in_browser("/b/c/d;p", "../../../g"), "/g");
1197 assert_eq!(resolve_in_browser("/b/c/d;p", "../../../../g"), "/g");
1198 assert_eq!(resolve_in_browser("/b/c/d;p", "g?y"), "/b/c/g?y");
1200 assert_eq!(resolve_in_browser("/b/c/d;p", "g#s"), "/b/c/g#s");
1201 assert_eq!(resolve_in_browser("/b/c/d;p", "g?y#s"), "/b/c/g?y#s");
1202 assert_eq!(
1204 resolve_in_browser("/docs/guide/", "../other/"),
1205 "/docs/other/"
1206 );
1207 }
1208
1209 #[test]
1214 fn every_link_form_lands_on_its_canonical_url() {
1215 let dir = fixture();
1216 let root = dir.path();
1217
1218 let cases: &[(&str, &str, &str)] = &[
1220 ("root.md", "docs/guide.md", "/docs/guide/"),
1222 ("root.md", "docs/guide", "/docs/guide/"),
1223 ("root.md", "docs/guide/", "/docs/guide/"),
1224 ("root.md", "./docs/guide.md", "/docs/guide/"),
1225 ("root.md", "docs/index.md", "/docs/"),
1226 ("root.md", "docs/guide.md#anchor", "/docs/guide/#anchor"),
1227 ("root.md", "docs/guide.md?q=1", "/docs/guide/?q=1"),
1228 ("root.md", "LICENSE", "/LICENSE"),
1229 ("root.md", "docs/photo.png", "/docs/photo.png"),
1230 ("index.md", "docs/guide.md", "/docs/guide/"),
1232 ("index.md", "docs/guide", "/docs/guide/"),
1233 ("index.md", "root.md", "/root/"),
1234 ("index.md", "LICENSE", "/LICENSE"),
1235 ("docs/guide.md", "other.md", "/docs/other/"),
1237 ("docs/guide.md", "other", "/docs/other/"),
1238 ("docs/guide.md", "other/", "/docs/other/"),
1239 ("docs/guide.md", "./other.md", "/docs/other/"),
1240 ("docs/guide.md", "index.md", "/docs/"),
1241 ("docs/guide.md", "../root.md", "/root/"),
1242 ("docs/guide.md", "../root", "/root/"),
1243 ("docs/guide.md", "../folder/file.md", "/folder/file/"),
1246 ("docs/guide.md", "../folder/file", "/folder/file/"),
1247 ("docs/guide.md", "../index.md", "/"),
1248 ("docs/guide.md", "other.md#anchor", "/docs/other/#anchor"),
1249 ("docs/guide.md", "other.md?q=1", "/docs/other/?q=1"),
1250 ("docs/guide.md", "other#anchor", "/docs/other/#anchor"),
1251 ("docs/guide.md", "Makefile", "/docs/Makefile"),
1253 ("docs/guide.md", "../LICENSE", "/LICENSE"),
1254 (
1255 "docs/guide.md",
1256 "../folder/Dockerfile",
1257 "/folder/Dockerfile",
1258 ),
1259 ("docs/guide.md", "photo.png", "/docs/photo.png"),
1260 ("docs/index.md", "guide.md", "/docs/guide/"),
1262 ("docs/index.md", "guide", "/docs/guide/"),
1263 ("docs/index.md", "guide/", "/docs/guide/"),
1264 ("docs/index.md", "./guide.md", "/docs/guide/"),
1265 ("docs/index.md", "../root.md", "/root/"),
1266 ("docs/index.md", "../root", "/root/"),
1267 ("docs/index.md", "../folder/file", "/folder/file/"),
1268 ("docs/index.md", "Makefile", "/docs/Makefile"),
1269 ("docs/index.md", "../LICENSE", "/LICENSE"),
1270 ("docs/index.md", "guide.md#anchor", "/docs/guide/#anchor"),
1271 ("a/b/c.md", "d.md", "/a/b/d/"),
1273 ("a/b/c.md", "d", "/a/b/d/"),
1274 ("a/b/c.md", "./d.md", "/a/b/d/"),
1275 ("a/b/c.md", "../../root.md", "/root/"),
1276 ("a/b/c.md", "../../root", "/root/"),
1277 ("a/b/c.md", "../../docs/guide.md", "/docs/guide/"),
1278 ("a/b/c.md", "../../docs/guide", "/docs/guide/"),
1279 ("a/b/c.md", "../../LICENSE", "/LICENSE"),
1280 ("a/b/c.md", "../../index.md", "/"),
1281 ("a/b/c.md", "d.md?q=1#anchor", "/a/b/d/?q=1#anchor"),
1282 ];
1283
1284 for (source, href, expected) in cases {
1285 assert_lands(root, source, href, expected);
1286 }
1287 }
1288
1289 #[test]
1292 fn extensionless_markdown_link_lands_where_the_dot_md_form_does() {
1293 let dir = fixture();
1294 let root = dir.path();
1295
1296 let (with_ext, landed_with_ext) = follow(root, "docs/guide.md", "../folder/file.md");
1297 let (without_ext, landed_without_ext) = follow(root, "docs/guide.md", "../folder/file");
1298
1299 assert_eq!(with_ext, "../../folder/file/");
1300 assert_eq!(
1301 without_ext, "../../folder/file/",
1302 "an extension-less markdown target must get the trailing slash its \
1303 canonical URL has"
1304 );
1305 assert_eq!(landed_with_ext, "/folder/file/");
1306 assert_eq!(landed_without_ext, "/folder/file/");
1307 }
1308
1309 #[test]
1314 fn links_on_the_landed_page_still_resolve() {
1315 let dir = fixture();
1316 let root = dir.path();
1317
1318 for authored in ["../folder/file.md", "../folder/file", "../folder/file/"] {
1319 let (_, landed) = follow(root, "docs/guide.md", authored);
1320 assert_eq!(
1321 landed,
1322 page_url(root, "folder/file.md"),
1323 "`{authored}` must land on the target's canonical URL"
1324 );
1325
1326 let (emitted, second) = follow(root, "folder/file.md", "sibling.md");
1328 assert_eq!(
1329 second, "/folder/sibling/",
1330 "after following `{authored}`, `sibling.md` (emitted `{emitted}`) \
1331 must still reach /folder/sibling/ — this is the hop the \
1332 trailing-slash defect breaks"
1333 );
1334
1335 let non_canonical = landed.trim_end_matches('/');
1339 assert_ne!(
1340 resolve_in_browser(non_canonical, &emitted),
1341 "/folder/sibling/",
1342 "sanity: a slashless landing URL must break the next hop, or \
1343 this test proves nothing"
1344 );
1345 }
1346 }
1347
1348 #[test]
1352 fn extensionless_target_is_unchanged_without_a_probe() {
1353 let config = LinkTransformConfig {
1354 markdown_extensions: vec!["md".to_string()],
1355 index_file: "index.md".to_string(),
1356 is_index_file: false,
1357 url_depth: None,
1358 current_page_url: "/docs/guide/".to_string(),
1359 markdown_page_probe: None,
1360 };
1361 assert_eq!(
1362 transform_link("../folder/file", &config),
1363 "../../folder/file"
1364 );
1365 assert_eq!(transform_link("../LICENSE", &config), "../../LICENSE");
1366 }
1367
1368 #[test]
1371 fn probe_is_not_consulted_without_a_current_page_url() {
1372 let dir = fixture();
1373 let root = dir.path();
1374 let mut config = transform_config(root, "docs/guide.md");
1375 config.current_page_url = String::new();
1376 assert_eq!(
1377 transform_link("../folder/file", &config),
1378 "../../folder/file"
1379 );
1380 }
1381
1382 #[test]
1387 fn above_root_traversal_is_not_dressed_up_as_a_page() {
1388 let dir = fixture();
1389 let root = dir.path();
1390
1391 let (emitted, landed) = follow(root, "docs/guide.md", "../../escape/target");
1392 assert_eq!(emitted, "../../../escape/target");
1393 assert!(
1394 !emitted.ends_with('/'),
1395 "an above-root target must not be given a page's trailing slash: {emitted}"
1396 );
1397 assert_eq!(landed, "/escape/target");
1401 }
1402
1403 #[test]
1406 fn build_mode_root_relative_links_land_on_the_same_url() {
1407 let dir = fixture();
1408 let root = dir.path();
1409
1410 for source in [
1411 "root.md",
1412 "index.md",
1413 "docs/guide.md",
1414 "docs/index.md",
1415 "a/b/c.md",
1416 ] {
1417 let base = page_url(root, source);
1418 let depth = base
1419 .trim_matches('/')
1420 .split('/')
1421 .filter(|s| !s.is_empty())
1422 .count();
1423 let mut config = transform_config(root, source);
1424 config.url_depth = Some(depth);
1425
1426 for target in ["/docs/guide/", "/folder/file/", "/"] {
1427 let emitted = transform_link(target, &config);
1428 assert_eq!(
1429 resolve_in_browser(&base, &emitted),
1430 target,
1431 "[{source}] root-relative `{target}` emitted `{emitted}`"
1432 );
1433 }
1434 }
1435 }
1436}