1use std::collections::BTreeMap;
11
12use crate::content_graph::ContentGraph;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Fit {
21 Cover,
22 Contain,
23 Fill,
24 None,
25 ScaleDown,
26}
27
28impl Fit {
29 pub fn to_css_value(&self) -> &str {
31 match self {
32 Fit::Cover => "cover",
33 Fit::Contain => "contain",
34 Fit::Fill => "fill",
35 Fit::None => "none",
36 Fit::ScaleDown => "scale-down",
37 }
38 }
39
40 pub fn from_keyword(s: &str) -> Option<Self> {
44 match s.to_lowercase().as_str() {
45 "cover" => Some(Fit::Cover),
46 "contain" => Some(Fit::Contain),
47 "fill" => Some(Fit::Fill),
48 "none" => Some(Fit::None),
49 "scale-down" | "scaledown" => Some(Fit::ScaleDown),
50 _ => Option::None,
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Position {
62 Center,
63 Left,
64 Right,
65 Top,
66 Bottom,
67 TopLeft,
68 TopRight,
69 BottomLeft,
70 BottomRight,
71}
72
73impl Position {
74 pub fn to_css_value(&self) -> &str {
76 match self {
77 Position::Center => "center",
78 Position::Left => "left",
79 Position::Right => "right",
80 Position::Top => "top",
81 Position::Bottom => "bottom",
82 Position::TopLeft => "top left",
83 Position::TopRight => "top right",
84 Position::BottomLeft => "bottom left",
85 Position::BottomRight => "bottom right",
86 }
87 }
88
89 pub fn from_keyword(s: &str) -> Option<Self> {
94 match s.to_lowercase().as_str() {
95 "center" => Some(Position::Center),
96 "left" => Some(Position::Left),
97 "right" => Some(Position::Right),
98 "top" => Some(Position::Top),
99 "bottom" => Some(Position::Bottom),
100 "top-left" | "topleft" | "top left" => Some(Position::TopLeft),
101 "top-right" | "topright" | "top right" => Some(Position::TopRight),
102 "bottom-left" | "bottomleft" | "bottom left" => Some(Position::BottomLeft),
103 "bottom-right" | "bottomright" | "bottom right" => Some(Position::BottomRight),
104 _ => Option::None,
105 }
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum AlignSide {
130 Left,
131 Right,
132}
133
134impl AlignSide {
135 pub fn from_keyword(s: &str) -> Option<Self> {
142 match s.to_lowercase().as_str() {
143 "align-left" | "alignleft" | "left" => Some(AlignSide::Left),
144 "align-right" | "alignright" | "right" => Some(AlignSide::Right),
145 _ => None,
146 }
147 }
148
149 pub fn css_class(self) -> &'static str {
153 match self {
154 AlignSide::Left => "moss-align-left",
155 AlignSide::Right => "moss-align-right",
156 }
157 }
158}
159
160#[derive(Debug, Clone, Default, PartialEq, Eq)]
178pub struct MediaAttrs {
179 pub fit: Option<Fit>,
180 pub position: Option<Position>,
181 pub align: Option<AlignSide>,
182 pub color: Option<String>,
189 pub class_names: Vec<String>,
194 pub extra_attrs: BTreeMap<String, String>,
199}
200
201impl MediaAttrs {
202 pub fn is_empty(&self) -> bool {
204 self.fit.is_none()
205 && self.position.is_none()
206 && self.align.is_none()
207 && self.color.is_none()
208 && self.class_names.is_empty()
209 && self.extra_attrs.is_empty()
210 }
211
212 pub fn to_inline_style(&self) -> Option<String> {
219 if self.fit.is_none() && self.position.is_none() {
220 return None;
221 }
222
223 let mut parts = Vec::new();
224 if let Some(ref fit) = self.fit {
225 parts.push(format!("object-fit:{}", fit.to_css_value()));
226 }
227 if let Some(ref pos) = self.position {
228 parts.push(format!("object-position:{}", pos.to_css_value()));
229 }
230 Some(parts.join(";"))
231 }
232
233 pub fn css_class(&self) -> Option<&'static str> {
240 self.align.map(AlignSide::css_class)
241 }
242
243 pub fn class_attr(&self) -> Option<String> {
251 let moss_class = self.css_class();
252 if moss_class.is_none() && self.class_names.is_empty() {
253 return None;
254 }
255 let mut parts: Vec<&str> = Vec::new();
256 if let Some(c) = moss_class {
257 parts.push(c);
258 }
259 for c in &self.class_names {
260 parts.push(c.as_str());
261 }
262 Some(parts.join(" "))
263 }
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
273pub(crate) struct ResolvedMedia {
274 pub path: String,
276 pub attrs: MediaAttrs,
278}
279
280pub fn strip_wikilink(raw: &str) -> &str {
289 let trimmed = raw.trim();
290 trimmed
291 .strip_prefix("[[")
292 .and_then(|s| s.strip_suffix("]]"))
293 .unwrap_or(trimmed)
294}
295
296pub fn split_pipe(raw: &str) -> (&str, &str) {
300 raw.split_once('|').unwrap_or((raw, ""))
301}
302
303pub fn parse_media_attrs(raw: &str) -> MediaAttrs {
316 let mut fit: Option<Fit> = None;
317 let mut position: Option<Position> = None;
318 let mut align: Option<AlignSide> = None;
319 let mut color: Option<String> = None;
320
321 let tokens: Vec<&str> = raw.split_whitespace().collect();
322 let mut i = 0;
323
324 while i < tokens.len() {
325 let token = tokens[i];
326
327 if i + 1 < tokens.len() {
329 let combined = format!("{} {}", token, tokens[i + 1]);
330 if let Some(pos) = Position::from_keyword(&combined) {
331 position = Some(pos);
332 i += 2;
333 continue;
334 }
335 }
336
337 if let Some(f) = Fit::from_keyword(token) {
339 fit = Some(f);
340 i += 1;
341 continue;
342 }
343
344 if let Some(pos) = Position::from_keyword(token) {
346 position = Some(pos);
347 i += 1;
348 continue;
349 }
350
351 if let Some(side) = AlignSide::from_keyword(token) {
353 align = Some(side);
354 i += 1;
355 continue;
356 }
357
358 if let Some(value) = token.strip_prefix("color=") {
360 if !value.is_empty() {
361 color = Some(value.to_string());
362 }
363 i += 1;
364 continue;
365 }
366
367 i += 1;
369 }
370
371 MediaAttrs {
372 fit,
373 position,
374 align,
375 color,
376 ..Default::default()
377 }
378}
379
380pub fn match_width_token(s: &str) -> Option<&'static str> {
393 match s {
394 "body" => Some("body"),
395 "wide" => Some("wide"),
396 "page" => Some("page"),
397 "screen" | "full" => Some("screen"),
398 _ => None,
399 }
400}
401
402pub fn parse_image_width(seg: &str) -> Option<String> {
422 let s = seg.trim();
423 if s.is_empty() {
424 return None;
425 }
426 if let Some(named) = match_width_token(s) {
427 return Some(named.to_string());
428 }
429 if let Some(rest) = s.strip_suffix('%') {
431 let v: f64 = rest.trim().parse().ok()?;
432 if v <= 0.0 {
433 return None;
434 }
435 let clamped = v.min(100.0);
436 let text = if clamped.fract() == 0.0 {
438 format!("{}%", clamped as i64)
439 } else {
440 format!("{}%", clamped)
441 };
442 return Some(text);
443 }
444 None
445}
446
447pub fn split_alt_width(text: &str) -> (String, Option<String>) {
455 let mut width: Option<String> = None;
456 let mut remaining: Vec<&str> = Vec::new();
457 for seg in text.split('|') {
458 if width.is_none() {
459 if let Some(w) = parse_image_width(seg) {
460 width = Some(w);
461 continue;
462 }
463 }
464 remaining.push(seg);
465 }
466 (remaining.join("|"), width)
467}
468
469pub fn set_image_width(image_md: &str, width: Option<&str>) -> String {
481 let new_width: Option<String> = width.and_then(parse_image_width);
484
485 if let Some(inner) = image_md
487 .strip_prefix("![[")
488 .and_then(|s| s.strip_suffix("]]"))
489 {
490 let (path, pothole) = inner.split_once('|').unwrap_or((inner, ""));
491 let (rest, _old) = split_alt_width(pothole);
493 let segments: Vec<&str> = rest.split('|').filter(|s| !s.is_empty()).collect();
494 let mut parts: Vec<String> = segments.iter().map(|s| s.to_string()).collect();
495 if let Some(w) = new_width {
496 parts.push(w);
497 }
498 return if parts.is_empty() {
499 format!("![[{}]]", path)
500 } else {
501 format!("![[{}|{}]]", path, parts.join("|"))
502 };
503 }
504
505 if image_md.starts_with(" {
508 if image_md.ends_with(')') {
509 let alt_raw = &image_md[2..close];
510 let url = &image_md[close + 2..image_md.len() - 1];
511 let (rest_alt, _old) = split_alt_width(alt_raw);
512 let alt_out = match new_width {
517 Some(w) => format!("{}|{}", rest_alt, w),
518 None => rest_alt,
519 };
520 return format!("", alt_out, url);
521 }
522 }
523 }
524
525 image_md.to_string()
526}
527
528pub fn extract_width_from_alias(alias: &str) -> (Option<&'static str>, String) {
548 let segments: Vec<&str> = alias.split('|').collect();
549 let mut width: Option<&'static str> = None;
550 let mut remaining: Vec<&str> = Vec::with_capacity(segments.len());
551
552 for seg in &segments {
553 let trimmed = seg.trim();
554 if width.is_none() {
555 if let Some(canonical) = match_width_token(trimmed) {
556 width = Some(canonical);
557 continue;
558 }
559 }
560 remaining.push(seg);
561 }
562
563 (width, remaining.join("|"))
564}
565
566pub fn is_all_display_keywords(text: &str) -> bool {
571 let tokens: Vec<&str> = text.split_whitespace().collect();
572 if tokens.is_empty() {
573 return false;
574 }
575
576 let mut i = 0;
577 while i < tokens.len() {
578 if i + 1 < tokens.len() {
580 let combined = format!("{} {}", tokens[i], tokens[i + 1]);
581 if Position::from_keyword(&combined).is_some() {
582 i += 2;
583 continue;
584 }
585 }
586
587 if Fit::from_keyword(tokens[i]).is_some() {
588 i += 1;
589 continue;
590 }
591
592 if Position::from_keyword(tokens[i]).is_some() {
593 i += 1;
594 continue;
595 }
596
597 if AlignSide::from_keyword(tokens[i]).is_some() {
598 i += 1;
599 continue;
600 }
601
602 return false;
603 }
604
605 true
606}
607
608pub(crate) fn is_structural_alias(alias: &str) -> bool {
623 if is_all_display_keywords(alias) {
626 return true;
627 }
628 let tokens: Vec<&str> = alias.split_whitespace().collect();
629 if tokens.is_empty() {
630 return false;
631 }
632 let mut i = 0;
635 while i < tokens.len() {
636 if match_width_token(tokens[i]).is_some() {
638 i += 1;
639 continue;
640 }
641 if i + 1 < tokens.len() {
643 let combined = format!("{} {}", tokens[i], tokens[i + 1]);
644 if Position::from_keyword(&combined).is_some() {
645 i += 2;
646 continue;
647 }
648 }
649 if Fit::from_keyword(tokens[i]).is_some()
651 || Position::from_keyword(tokens[i]).is_some()
652 || AlignSide::from_keyword(tokens[i]).is_some()
653 {
654 i += 1;
655 continue;
656 }
657 return false;
658 }
659 true
660}
661
662#[derive(Debug, Clone, PartialEq, Eq)]
671pub(crate) struct ImageAliasClass {
672 pub display_keywords: Option<String>,
676 pub caption: Option<String>,
682}
683
684pub(crate) fn classify_image_alias(alias: Option<&str>) -> ImageAliasClass {
695 match alias {
696 Some(a) if a.is_empty() => ImageAliasClass {
699 display_keywords: None,
700 caption: None,
701 },
702 Some(a) if is_structural_alias(a) => ImageAliasClass {
703 display_keywords: Some(a.to_string()),
704 caption: None,
705 },
706 Some(other) => ImageAliasClass {
707 display_keywords: None,
708 caption: Some(other.to_string()),
709 },
710 None => ImageAliasClass {
711 display_keywords: None,
712 caption: None,
713 },
714 }
715}
716
717pub fn html_escape(s: &str) -> String {
721 let mut out = String::with_capacity(s.len());
722 for ch in s.chars() {
723 match ch {
724 '&' => out.push_str("&"),
725 '"' => out.push_str("""),
726 '\'' => out.push_str("'"),
727 '<' => out.push_str("<"),
728 '>' => out.push_str(">"),
729 _ => out.push(ch),
730 }
731 }
732 out
733}
734
735fn is_external(path: &str) -> bool {
741 path.starts_with("http://")
742 || path.starts_with("https://")
743 || path.starts_with("//")
744 || path.starts_with("data:")
745}
746
747pub(crate) fn resolve_media_ref(raw: &str, source_path: &str, graph: &ContentGraph) -> ResolvedMedia {
754 let inner = strip_wikilink(raw);
755 let (path_part, attrs_str) = split_pipe(inner);
756 let path_trimmed = path_part.trim();
757 let attrs = parse_media_attrs(attrs_str);
758
759 let resolved_path = if is_external(path_trimmed) {
760 path_trimmed.to_string()
762 } else if let Some(stripped) = path_trimmed.strip_prefix('/') {
763 stripped.to_string()
765 } else {
766 graph
768 .resolve_path(path_trimmed, source_path)
769 .unwrap_or_else(|| path_trimmed.to_string())
770 };
771
772 ResolvedMedia {
773 path: resolved_path,
774 attrs,
775 }
776}
777
778#[cfg(test)]
783mod tests {
784 use super::*;
785 use crate::content_graph::ContentGraphBuilder;
786
787 #[test]
790 fn test_fit_to_css_value() {
791 assert_eq!(Fit::Cover.to_css_value(), "cover");
792 assert_eq!(Fit::Contain.to_css_value(), "contain");
793 assert_eq!(Fit::Fill.to_css_value(), "fill");
794 assert_eq!(Fit::None.to_css_value(), "none");
795 assert_eq!(Fit::ScaleDown.to_css_value(), "scale-down");
796 }
797
798 #[test]
799 fn test_fit_from_keyword() {
800 assert_eq!(Fit::from_keyword("cover"), Some(Fit::Cover));
801 assert_eq!(Fit::from_keyword("contain"), Some(Fit::Contain));
802 assert_eq!(Fit::from_keyword("fill"), Some(Fit::Fill));
803 assert_eq!(Fit::from_keyword("none"), Some(Fit::None));
804 assert_eq!(Fit::from_keyword("scale-down"), Some(Fit::ScaleDown));
805 assert_eq!(Fit::from_keyword("scaledown"), Some(Fit::ScaleDown));
806 }
807
808 #[test]
809 fn test_fit_from_keyword_case_insensitive() {
810 assert_eq!(Fit::from_keyword("COVER"), Some(Fit::Cover));
811 assert_eq!(Fit::from_keyword("Contain"), Some(Fit::Contain));
812 assert_eq!(Fit::from_keyword("Scale-Down"), Some(Fit::ScaleDown));
813 assert_eq!(Fit::from_keyword("SCALEDOWN"), Some(Fit::ScaleDown));
814 }
815
816 #[test]
817 fn test_fit_from_keyword_unknown() {
818 assert_eq!(Fit::from_keyword("zoom"), None);
819 assert_eq!(Fit::from_keyword(""), None);
820 assert_eq!(Fit::from_keyword("cover "), None); }
822
823 #[test]
826 fn test_align_side_from_keyword() {
827 assert_eq!(AlignSide::from_keyword("align-left"), Some(AlignSide::Left));
828 assert_eq!(AlignSide::from_keyword("align-right"), Some(AlignSide::Right));
829 assert_eq!(AlignSide::from_keyword("alignleft"), Some(AlignSide::Left));
831 assert_eq!(AlignSide::from_keyword("alignright"), Some(AlignSide::Right));
832 assert_eq!(AlignSide::from_keyword("ALIGN-LEFT"), Some(AlignSide::Left));
834 assert_eq!(AlignSide::from_keyword("AlignRight"), Some(AlignSide::Right));
835 assert_eq!(AlignSide::from_keyword(""), None);
837 }
838
839 #[test]
840 fn test_align_side_from_keyword_bare_directional() {
841 assert_eq!(AlignSide::from_keyword("left"), Some(AlignSide::Left));
848 assert_eq!(AlignSide::from_keyword("right"), Some(AlignSide::Right));
849 assert_eq!(AlignSide::from_keyword("LEFT"), Some(AlignSide::Left));
850 assert_eq!(AlignSide::from_keyword("Right"), Some(AlignSide::Right));
851 }
852
853 #[test]
854 fn test_parse_attrs_bare_left_is_position() {
855 let attrs = parse_media_attrs("left");
861 assert_eq!(attrs.position, Some(Position::Left));
862 assert_eq!(attrs.align, None);
863
864 let attrs = parse_media_attrs("right");
865 assert_eq!(attrs.position, Some(Position::Right));
866 assert_eq!(attrs.align, None);
867 }
868
869 #[test]
870 fn test_align_side_css_class() {
871 assert_eq!(AlignSide::Left.css_class(), "moss-align-left");
872 assert_eq!(AlignSide::Right.css_class(), "moss-align-right");
873 }
874
875 #[test]
878 fn test_position_to_css_value() {
879 assert_eq!(Position::Center.to_css_value(), "center");
880 assert_eq!(Position::Left.to_css_value(), "left");
881 assert_eq!(Position::Right.to_css_value(), "right");
882 assert_eq!(Position::Top.to_css_value(), "top");
883 assert_eq!(Position::Bottom.to_css_value(), "bottom");
884 assert_eq!(Position::TopLeft.to_css_value(), "top left");
885 assert_eq!(Position::TopRight.to_css_value(), "top right");
886 assert_eq!(Position::BottomLeft.to_css_value(), "bottom left");
887 assert_eq!(Position::BottomRight.to_css_value(), "bottom right");
888 }
889
890 #[test]
891 fn test_position_from_keyword_single() {
892 assert_eq!(Position::from_keyword("center"), Some(Position::Center));
893 assert_eq!(Position::from_keyword("left"), Some(Position::Left));
894 assert_eq!(Position::from_keyword("right"), Some(Position::Right));
895 assert_eq!(Position::from_keyword("top"), Some(Position::Top));
896 assert_eq!(Position::from_keyword("bottom"), Some(Position::Bottom));
897 }
898
899 #[test]
900 fn test_position_from_keyword_compound() {
901 assert_eq!(Position::from_keyword("top-left"), Some(Position::TopLeft));
903 assert_eq!(Position::from_keyword("top-right"), Some(Position::TopRight));
904 assert_eq!(Position::from_keyword("bottom-left"), Some(Position::BottomLeft));
905 assert_eq!(Position::from_keyword("bottom-right"), Some(Position::BottomRight));
906
907 assert_eq!(Position::from_keyword("topleft"), Some(Position::TopLeft));
909 assert_eq!(Position::from_keyword("bottomright"), Some(Position::BottomRight));
910
911 assert_eq!(Position::from_keyword("top left"), Some(Position::TopLeft));
913 assert_eq!(Position::from_keyword("bottom right"), Some(Position::BottomRight));
914 }
915
916 #[test]
917 fn test_position_from_keyword_case_insensitive() {
918 assert_eq!(Position::from_keyword("CENTER"), Some(Position::Center));
919 assert_eq!(Position::from_keyword("Top-Left"), Some(Position::TopLeft));
920 assert_eq!(Position::from_keyword("BOTTOMRIGHT"), Some(Position::BottomRight));
921 }
922
923 #[test]
924 fn test_position_from_keyword_unknown() {
925 assert_eq!(Position::from_keyword("middle"), None);
926 assert_eq!(Position::from_keyword(""), None);
927 }
928
929 #[test]
932 fn test_media_attrs_is_empty() {
933 let empty = MediaAttrs {
934 fit: None,
935 position: None,
936 align: None,
937 color: None,
938 class_names: Vec::new(),
939 extra_attrs: BTreeMap::new(),
940 };
941 assert!(empty.is_empty());
942
943 let with_fit = MediaAttrs {
944 fit: Some(Fit::Cover),
945 position: None,
946 align: None,
947 color: None,
948 class_names: Vec::new(),
949 extra_attrs: BTreeMap::new(),
950 };
951 assert!(!with_fit.is_empty());
952
953 let with_pos = MediaAttrs {
954 fit: None,
955 position: Some(Position::Center),
956 align: None,
957 color: None,
958 class_names: Vec::new(),
959 extra_attrs: BTreeMap::new(),
960 };
961 assert!(!with_pos.is_empty());
962 }
963
964 #[test]
965 fn test_to_inline_style_empty() {
966 let attrs = MediaAttrs {
967 fit: None,
968 position: None,
969 align: None,
970 color: None,
971 class_names: Vec::new(),
972 extra_attrs: BTreeMap::new(),
973 };
974 assert_eq!(attrs.to_inline_style(), None);
975 }
976
977 #[test]
978 fn test_to_inline_style_fit_only() {
979 let attrs = MediaAttrs {
980 fit: Some(Fit::Contain),
981 position: None,
982 align: None,
983 color: None,
984 class_names: Vec::new(),
985 extra_attrs: BTreeMap::new(),
986 };
987 assert_eq!(attrs.to_inline_style(), Some("object-fit:contain".into()));
988 }
989
990 #[test]
991 fn test_to_inline_style_position_only() {
992 let attrs = MediaAttrs {
993 fit: None,
994 position: Some(Position::Left),
995 align: None,
996 color: None,
997 class_names: Vec::new(),
998 extra_attrs: BTreeMap::new(),
999 };
1000 assert_eq!(
1001 attrs.to_inline_style(),
1002 Some("object-position:left".into())
1003 );
1004 }
1005
1006 #[test]
1007 fn test_to_inline_style_both() {
1008 let attrs = MediaAttrs {
1009 fit: Some(Fit::Cover),
1010 position: Some(Position::TopLeft),
1011 align: None,
1012 color: None,
1013 class_names: Vec::new(),
1014 extra_attrs: BTreeMap::new(),
1015 };
1016 assert_eq!(
1017 attrs.to_inline_style(),
1018 Some("object-fit:cover;object-position:top left".into())
1019 );
1020 }
1021
1022 #[test]
1025 fn test_strip_wikilink_with_brackets() {
1026 assert_eq!(strip_wikilink("[[photo.jpg]]"), "photo.jpg");
1027 assert_eq!(strip_wikilink("[[path/to/image.png]]"), "path/to/image.png");
1028 }
1029
1030 #[test]
1031 fn test_strip_wikilink_without_brackets() {
1032 assert_eq!(strip_wikilink("photo.jpg"), "photo.jpg");
1033 assert_eq!(strip_wikilink("path/to/image.png"), "path/to/image.png");
1034 }
1035
1036 #[test]
1037 fn test_strip_wikilink_with_pipe() {
1038 assert_eq!(strip_wikilink("[[photo.jpg|cover]]"), "photo.jpg|cover");
1039 }
1040
1041 #[test]
1042 fn test_strip_wikilink_with_whitespace() {
1043 assert_eq!(strip_wikilink(" [[photo.jpg]] "), "photo.jpg");
1044 }
1045
1046 #[test]
1047 fn test_strip_wikilink_partial_brackets() {
1048 assert_eq!(strip_wikilink("[[photo.jpg"), "[[photo.jpg");
1050 assert_eq!(strip_wikilink("photo.jpg]]"), "photo.jpg]]");
1052 }
1053
1054 #[test]
1055 fn test_strip_wikilink_empty() {
1056 assert_eq!(strip_wikilink("[[]]"), "");
1057 assert_eq!(strip_wikilink(""), "");
1058 }
1059
1060 #[test]
1063 fn test_split_pipe_with_pipe() {
1064 assert_eq!(split_pipe("photo.jpg|cover"), ("photo.jpg", "cover"));
1065 assert_eq!(
1066 split_pipe("path/to/img.png|contain center"),
1067 ("path/to/img.png", "contain center")
1068 );
1069 }
1070
1071 #[test]
1072 fn test_split_pipe_no_pipe() {
1073 assert_eq!(split_pipe("photo.jpg"), ("photo.jpg", ""));
1074 assert_eq!(split_pipe(""), ("", ""));
1075 }
1076
1077 #[test]
1078 fn test_split_pipe_multiple_pipes() {
1079 assert_eq!(split_pipe("a|b|c"), ("a", "b|c"));
1081 }
1082
1083 #[test]
1084 fn test_split_pipe_pipe_at_edges() {
1085 assert_eq!(split_pipe("|cover"), ("", "cover"));
1086 assert_eq!(split_pipe("photo.jpg|"), ("photo.jpg", ""));
1087 }
1088
1089 #[test]
1092 fn test_parse_attrs_fit_only() {
1093 let attrs = parse_media_attrs("cover");
1094 assert_eq!(attrs.fit, Some(Fit::Cover));
1095 assert_eq!(attrs.position, None);
1096 }
1097
1098 #[test]
1099 fn test_parse_attrs_position_only() {
1100 let attrs = parse_media_attrs("center");
1101 assert_eq!(attrs.fit, None);
1102 assert_eq!(attrs.position, Some(Position::Center));
1103 }
1104
1105 #[test]
1106 fn test_parse_attrs_fit_and_position() {
1107 let attrs = parse_media_attrs("contain left");
1108 assert_eq!(attrs.fit, Some(Fit::Contain));
1109 assert_eq!(attrs.position, Some(Position::Left));
1110 }
1111
1112 #[test]
1113 fn test_parse_attrs_two_word_position() {
1114 let attrs = parse_media_attrs("top left");
1115 assert_eq!(attrs.fit, None);
1116 assert_eq!(attrs.position, Some(Position::TopLeft));
1117
1118 let attrs2 = parse_media_attrs("cover bottom right");
1119 assert_eq!(attrs2.fit, Some(Fit::Cover));
1120 assert_eq!(attrs2.position, Some(Position::BottomRight));
1121 }
1122
1123 #[test]
1124 fn test_parse_attrs_hyphenated_compound_position() {
1125 let attrs = parse_media_attrs("top-right");
1126 assert_eq!(attrs.fit, None);
1127 assert_eq!(attrs.position, Some(Position::TopRight));
1128
1129 let attrs2 = parse_media_attrs("fill bottom-left");
1130 assert_eq!(attrs2.fit, Some(Fit::Fill));
1131 assert_eq!(attrs2.position, Some(Position::BottomLeft));
1132 }
1133
1134 #[test]
1135 fn test_parse_attrs_unknown_tokens_ignored() {
1136 let attrs = parse_media_attrs("cover unknown-token left");
1137 assert_eq!(attrs.fit, Some(Fit::Cover));
1138 assert_eq!(attrs.position, Some(Position::Left));
1139 }
1140
1141 #[test]
1142 fn test_parse_attrs_empty_string() {
1143 let attrs = parse_media_attrs("");
1144 assert!(attrs.is_empty());
1145 }
1146
1147 #[test]
1148 fn test_parse_attrs_only_whitespace() {
1149 let attrs = parse_media_attrs(" ");
1150 assert!(attrs.is_empty());
1151 }
1152
1153 #[test]
1154 fn test_parse_attrs_all_unknown() {
1155 let attrs = parse_media_attrs("foo bar baz");
1156 assert!(attrs.is_empty());
1157 }
1158
1159 #[test]
1160 fn test_parse_attrs_case_insensitive() {
1161 let attrs = parse_media_attrs("COVER CENTER");
1162 assert_eq!(attrs.fit, Some(Fit::Cover));
1163 assert_eq!(attrs.position, Some(Position::Center));
1164 }
1165
1166 #[test]
1167 fn test_parse_attrs_last_wins_for_duplicates() {
1168 let attrs = parse_media_attrs("cover contain");
1170 assert_eq!(attrs.fit, Some(Fit::Contain));
1171 }
1172
1173 #[test]
1174 fn test_parse_attrs_scale_down() {
1175 let attrs = parse_media_attrs("scale-down");
1176 assert_eq!(attrs.fit, Some(Fit::ScaleDown));
1177 }
1178
1179 fn sample_graph() -> ContentGraph {
1182 let mut b = ContentGraphBuilder::new();
1183 b.add_file("images/photo.jpg", "images/photo");
1184 b.add_file("assets/banner.png", "assets/banner");
1185 b.add_file("posts/hello.md", "posts/hello");
1186 b.build()
1187 }
1188
1189 #[test]
1190 fn test_resolve_simple_path() {
1191 let graph = sample_graph();
1192 let result = resolve_media_ref("photo.jpg", "posts/hello.md", &graph);
1193 assert_eq!(result.path, "images/photo.jpg");
1194 assert!(result.attrs.is_empty());
1195 }
1196
1197 #[test]
1198 fn test_resolve_with_attrs() {
1199 let graph = sample_graph();
1200 let result = resolve_media_ref("photo.jpg|cover center", "posts/hello.md", &graph);
1201 assert_eq!(result.path, "images/photo.jpg");
1202 assert_eq!(result.attrs.fit, Some(Fit::Cover));
1203 assert_eq!(result.attrs.position, Some(Position::Center));
1204 }
1205
1206 #[test]
1207 fn test_resolve_wikilink() {
1208 let graph = sample_graph();
1209 let result = resolve_media_ref("[[photo.jpg|contain]]", "posts/hello.md", &graph);
1210 assert_eq!(result.path, "images/photo.jpg");
1211 assert_eq!(result.attrs.fit, Some(Fit::Contain));
1212 }
1213
1214 #[test]
1215 fn test_resolve_wikilink_no_attrs() {
1216 let graph = sample_graph();
1217 let result = resolve_media_ref("[[photo.jpg]]", "posts/hello.md", &graph);
1218 assert_eq!(result.path, "images/photo.jpg");
1219 assert!(result.attrs.is_empty());
1220 }
1221
1222 #[test]
1223 fn test_resolve_external_http() {
1224 let graph = sample_graph();
1225 let result = resolve_media_ref(
1226 "https://example.com/img.jpg|cover",
1227 "posts/hello.md",
1228 &graph,
1229 );
1230 assert_eq!(result.path, "https://example.com/img.jpg");
1231 assert_eq!(result.attrs.fit, Some(Fit::Cover));
1232 }
1233
1234 #[test]
1235 fn test_resolve_external_protocol_relative() {
1236 let graph = sample_graph();
1237 let result = resolve_media_ref("//cdn.example.com/img.jpg", "posts/hello.md", &graph);
1238 assert_eq!(result.path, "//cdn.example.com/img.jpg");
1239 }
1240
1241 #[test]
1242 fn test_resolve_external_data_uri() {
1243 let graph = sample_graph();
1244 let result = resolve_media_ref("data:image/png;base64,abc", "posts/hello.md", &graph);
1245 assert_eq!(result.path, "data:image/png;base64,abc");
1246 }
1247
1248 #[test]
1249 fn test_resolve_root_relative() {
1250 let graph = sample_graph();
1251 let result = resolve_media_ref("/images/photo.jpg|fill", "posts/hello.md", &graph);
1252 assert_eq!(result.path, "images/photo.jpg");
1253 assert_eq!(result.attrs.fit, Some(Fit::Fill));
1254 }
1255
1256 #[test]
1257 fn test_resolve_unresolved_fallback() {
1258 let graph = sample_graph();
1259 let result = resolve_media_ref("missing.jpg", "posts/hello.md", &graph);
1260 assert_eq!(result.path, "missing.jpg");
1262 assert!(result.attrs.is_empty());
1263 }
1264
1265 #[test]
1266 fn test_resolve_wikilink_with_two_word_position() {
1267 let graph = sample_graph();
1268 let result =
1269 resolve_media_ref("[[banner.png|cover top left]]", "posts/hello.md", &graph);
1270 assert_eq!(result.path, "assets/banner.png");
1271 assert_eq!(result.attrs.fit, Some(Fit::Cover));
1272 assert_eq!(result.attrs.position, Some(Position::TopLeft));
1273 }
1274
1275 #[test]
1276 fn test_resolve_external_in_wikilink() {
1277 let graph = sample_graph();
1278 let result = resolve_media_ref(
1279 "[[https://example.com/img.jpg|contain]]",
1280 "posts/hello.md",
1281 &graph,
1282 );
1283 assert_eq!(result.path, "https://example.com/img.jpg");
1284 assert_eq!(result.attrs.fit, Some(Fit::Contain));
1285 }
1286
1287 #[test]
1288 fn test_resolve_path_with_spaces_trimmed() {
1289 let graph = sample_graph();
1290 let result = resolve_media_ref(" photo.jpg | cover ", "posts/hello.md", &graph);
1291 assert_eq!(result.path, "images/photo.jpg");
1292 assert_eq!(result.attrs.fit, Some(Fit::Cover));
1293 }
1294
1295 #[test]
1298 fn test_is_all_display_keywords_positions() {
1299 assert!(is_all_display_keywords("left"));
1300 assert!(is_all_display_keywords("right"));
1301 assert!(is_all_display_keywords("center"));
1302 assert!(is_all_display_keywords("top"));
1303 assert!(is_all_display_keywords("bottom"));
1304 assert!(is_all_display_keywords("top left"));
1305 assert!(is_all_display_keywords("bottom right"));
1306 }
1307
1308 #[test]
1309 fn test_is_all_display_keywords_fits() {
1310 assert!(is_all_display_keywords("cover"));
1311 assert!(is_all_display_keywords("contain"));
1312 assert!(is_all_display_keywords("fill"));
1313 assert!(is_all_display_keywords("none"));
1314 assert!(is_all_display_keywords("scale-down"));
1315 }
1316
1317 #[test]
1318 fn test_is_all_display_keywords_combined() {
1319 assert!(is_all_display_keywords("contain left"));
1320 assert!(is_all_display_keywords("cover top left"));
1321 assert!(is_all_display_keywords("cover top-right"));
1322 assert!(is_all_display_keywords("scale-down bottom-left"));
1323 }
1324
1325 #[test]
1326 fn test_is_all_display_keywords_rejects_non_keywords() {
1327 assert!(!is_all_display_keywords("A beautiful sunset"));
1328 assert!(!is_all_display_keywords("left side"));
1329 assert!(!is_all_display_keywords(""));
1330 assert!(!is_all_display_keywords(" "));
1331 }
1332
1333 #[test]
1336 fn test_html_escape_basic() {
1337 assert_eq!(html_escape("hello"), "hello");
1338 assert_eq!(html_escape("a&b"), "a&b");
1339 assert_eq!(html_escape("a\"b"), "a"b");
1340 assert_eq!(html_escape("a'b"), "a'b");
1341 assert_eq!(html_escape("a<b>c"), "a<b>c");
1342 assert_eq!(
1343 html_escape("<div class=\"x\">&'</div>"),
1344 "<div class="x">&'</div>"
1345 );
1346 }
1347
1348 #[test]
1349 fn test_parse_media_attrs_align_alone() {
1350 let attrs = parse_media_attrs("align-left");
1351 assert_eq!(attrs.align, Some(AlignSide::Left));
1352 assert_eq!(attrs.fit, None);
1353 assert_eq!(attrs.position, None);
1354 }
1355
1356 #[test]
1357 fn test_parse_media_attrs_align_with_cover() {
1358 let a = parse_media_attrs("cover align-right");
1360 assert_eq!(a.fit, Some(Fit::Cover));
1361 assert_eq!(a.align, Some(AlignSide::Right));
1362
1363 let b = parse_media_attrs("align-right cover");
1364 assert_eq!(b, a);
1365 }
1366
1367 #[test]
1368 fn test_parse_media_attrs_align_last_wins() {
1369 let attrs = parse_media_attrs("align-left align-right");
1373 assert_eq!(attrs.align, Some(AlignSide::Right));
1374
1375 let attrs = parse_media_attrs("align-right align-left");
1376 assert_eq!(attrs.align, Some(AlignSide::Left));
1377 }
1378
1379 #[test]
1380 fn test_is_all_display_keywords_align() {
1381 assert!(is_all_display_keywords("align-left"));
1382 assert!(is_all_display_keywords("align-right"));
1383 assert!(is_all_display_keywords("cover align-left"));
1384 assert!(is_all_display_keywords("align-left cover"));
1385 assert!(is_all_display_keywords("align-left top"));
1387 }
1388
1389 #[test]
1392 fn test_match_width_token_recognized() {
1393 assert_eq!(match_width_token("body"), Some("body"));
1394 assert_eq!(match_width_token("wide"), Some("wide"));
1395 assert_eq!(match_width_token("page"), Some("page"));
1396 assert_eq!(match_width_token("screen"), Some("screen"));
1397 assert_eq!(match_width_token("full"), Some("screen"));
1399 }
1400
1401 #[test]
1402 fn test_match_width_token_rejects_non_width() {
1403 assert_eq!(match_width_token(""), None);
1404 assert_eq!(match_width_token("BODY"), None);
1405 assert_eq!(match_width_token("widely"), None);
1406 assert_eq!(match_width_token("wide angle"), None);
1408 assert_eq!(match_width_token("contain"), None);
1410 assert_eq!(match_width_token("left"), None);
1411 }
1412
1413 #[test]
1414 fn test_extract_width_from_alias_single_segment_width() {
1415 let (w, rest) = extract_width_from_alias("full");
1416 assert_eq!(w, Some("screen"));
1417 assert_eq!(rest, "");
1418 }
1419
1420 #[test]
1421 fn test_extract_width_from_alias_caption_only() {
1422 let (w, rest) = extract_width_from_alias("A beautiful sunset");
1424 assert_eq!(w, None);
1425 assert_eq!(rest, "A beautiful sunset");
1426 }
1427
1428 #[test]
1429 fn test_extract_width_from_alias_caption_then_width() {
1430 let (w, rest) = extract_width_from_alias("A nice photo|full");
1433 assert_eq!(w, Some("screen"));
1434 assert_eq!(rest, "A nice photo");
1435 }
1436
1437 #[test]
1438 fn test_extract_width_from_alias_width_then_caption() {
1439 let (w, rest) = extract_width_from_alias("wide|A nice photo");
1440 assert_eq!(w, Some("wide"));
1441 assert_eq!(rest, "A nice photo");
1442 }
1443
1444 #[test]
1445 fn test_extract_width_from_alias_caption_with_width_word_not_shadowed() {
1446 let (w, rest) = extract_width_from_alias("caption that says wide");
1450 assert_eq!(w, None);
1451 assert_eq!(rest, "caption that says wide");
1452 }
1453
1454 #[test]
1455 fn test_extract_width_from_alias_only_first_width_extracted() {
1456 let (w, rest) = extract_width_from_alias("full|wide");
1461 assert_eq!(w, Some("screen"));
1462 assert_eq!(rest, "wide");
1463 }
1464
1465 #[test]
1466 fn test_extract_width_from_alias_segment_whitespace_trimmed() {
1467 let (w, rest) = extract_width_from_alias("caption | full");
1471 assert_eq!(w, Some("screen"));
1472 assert_eq!(rest, "caption ");
1473 }
1474
1475 #[test]
1478 fn test_media_attrs_class_names_preserved() {
1479 let attrs = MediaAttrs {
1483 fit: None,
1484 position: None,
1485 align: None,
1486 color: None,
1487 class_names: vec!["theme-rounded".to_string(), "shadow-lg".to_string()],
1488 extra_attrs: BTreeMap::new(),
1489 };
1490 assert!(!attrs.is_empty());
1491 assert_eq!(
1492 attrs.class_attr(),
1493 Some("theme-rounded shadow-lg".to_string())
1494 );
1495 }
1496
1497 #[test]
1498 fn test_media_attrs_class_names_compose_with_align() {
1499 let attrs = MediaAttrs {
1503 fit: None,
1504 position: None,
1505 align: Some(AlignSide::Left),
1506 color: None,
1507 class_names: vec!["theme-rounded".to_string()],
1508 extra_attrs: BTreeMap::new(),
1509 };
1510 assert_eq!(
1511 attrs.class_attr(),
1512 Some("moss-align-left theme-rounded".to_string())
1513 );
1514 }
1515
1516 #[test]
1517 fn test_media_attrs_extra_attrs_non_empty() {
1518 let mut extras = BTreeMap::new();
1521 extras.insert("data-zoom".to_string(), "true".to_string());
1522 extras.insert("data-id".to_string(), "42".to_string());
1523 let attrs = MediaAttrs {
1524 fit: None,
1525 position: None,
1526 align: None,
1527 color: None,
1528 class_names: vec![],
1529 extra_attrs: extras,
1530 };
1531 assert!(!attrs.is_empty());
1532 let keys: Vec<&str> = attrs.extra_attrs.keys().map(String::as_str).collect();
1534 assert_eq!(keys, vec!["data-id", "data-zoom"]);
1535 }
1536
1537 #[test]
1540 fn test_classify_image_alias_none() {
1541 let c = classify_image_alias(None);
1542 assert_eq!(c.display_keywords, None);
1543 assert_eq!(c.caption, None);
1544 }
1545
1546 #[test]
1547 fn test_classify_image_alias_empty_is_none_never_some_empty() {
1548 let c = classify_image_alias(Some(""));
1551 assert_eq!(c.display_keywords, None);
1552 assert_eq!(c.caption, None);
1553 }
1554
1555 #[test]
1556 fn test_classify_image_alias_structural_single_keyword() {
1557 let c = classify_image_alias(Some("cover"));
1558 assert_eq!(c.display_keywords.as_deref(), Some("cover"));
1559 assert_eq!(c.caption, None);
1560 }
1561
1562 #[test]
1563 fn test_classify_image_alias_structural_compound() {
1564 let c = classify_image_alias(Some("wide cover"));
1566 assert_eq!(c.display_keywords.as_deref(), Some("wide cover"));
1567 assert_eq!(c.caption, None);
1568 }
1569
1570 #[test]
1571 fn test_classify_image_alias_pure_width_token_is_structural() {
1572 let c = classify_image_alias(Some("wide"));
1574 assert_eq!(c.display_keywords.as_deref(), Some("wide"));
1575 assert_eq!(c.caption, None);
1576 }
1577
1578 #[test]
1579 fn test_classify_image_alias_caption_text() {
1580 let c = classify_image_alias(Some("My nice photo"));
1581 assert_eq!(c.display_keywords, None);
1582 assert_eq!(c.caption.as_deref(), Some("My nice photo"));
1583 }
1584
1585 #[test]
1586 fn test_is_structural_alias_matches_classifier() {
1587 assert!(is_structural_alias("cover"));
1589 assert!(is_structural_alias("wide cover"));
1590 assert!(is_structural_alias("top left"));
1591 assert!(!is_structural_alias("My nice photo"));
1592 assert!(!is_structural_alias(""));
1593 }
1594
1595 #[test]
1598 fn parse_image_width_named_tokens() {
1599 assert_eq!(parse_image_width("wide").as_deref(), Some("wide"));
1600 assert_eq!(parse_image_width("full").as_deref(), Some("screen")); assert_eq!(parse_image_width("body").as_deref(), Some("body"));
1602 }
1603
1604 #[test]
1605 fn parse_image_width_percent() {
1606 assert_eq!(parse_image_width("55%").as_deref(), Some("55%"));
1607 assert_eq!(parse_image_width("100%").as_deref(), Some("100%"));
1608 assert_eq!(parse_image_width(" 40% ").as_deref(), Some("40%")); }
1610
1611 #[test]
1612 fn parse_image_width_clamps_and_rejects() {
1613 assert_eq!(parse_image_width("150%").as_deref(), Some("100%")); assert_eq!(parse_image_width("0%"), None); assert_eq!(parse_image_width("-5%"), None); assert_eq!(parse_image_width("50.5%").as_deref(), Some("50.5%")); }
1618
1619 #[test]
1620 fn parse_image_width_rejects_non_width() {
1621 assert_eq!(parse_image_width("wide angle photo"), None); assert_eq!(parse_image_width("200x150"), None); assert_eq!(parse_image_width("hello"), None);
1624 assert_eq!(parse_image_width(""), None);
1625 }
1626
1627 #[test]
1630 fn split_alt_width_extracts_and_preserves() {
1631 assert_eq!(
1633 split_alt_width("My caption|55%"),
1634 ("My caption".to_string(), Some("55%".to_string()))
1635 );
1636 assert_eq!(
1637 split_alt_width("55%"),
1638 (String::new(), Some("55%".to_string()))
1639 );
1640 assert_eq!(
1641 split_alt_width("wide"),
1642 (String::new(), Some("wide".to_string()))
1643 );
1644 assert_eq!(
1646 split_alt_width("just a caption"),
1647 ("just a caption".to_string(), None)
1648 );
1649 assert_eq!(
1651 split_alt_width("cap|55%|extra"),
1652 ("cap|extra".to_string(), Some("55%".to_string()))
1653 );
1654 assert_eq!(
1656 split_alt_width("40%|60%"),
1657 ("60%".to_string(), Some("40%".to_string()))
1658 );
1659 }
1660
1661 #[test]
1664 fn set_image_width_standard_markdown() {
1665 assert_eq!(
1667 set_image_width("", Some("55%")),
1668 ""
1669 );
1670 assert_eq!(
1672 set_image_width("", Some("55%")),
1673 ""
1674 );
1675 assert_eq!(
1677 set_image_width("", Some("55%")),
1678 ""
1679 );
1680 assert_eq!(
1682 set_image_width("", None),
1683 ""
1684 );
1685 assert_eq!(
1687 set_image_width("", Some("55%")),
1688 ""
1689 );
1690 assert_eq!(
1692 set_image_width("", Some("55%")),
1693 ""
1694 );
1695 }
1696
1697 #[test]
1698 fn set_image_width_wikilink() {
1699 assert_eq!(
1700 set_image_width("![[pic.jpg]]", Some("55%")),
1701 "![[pic.jpg|55%]]"
1702 );
1703 assert_eq!(
1704 set_image_width("![[pic.jpg|30%]]", Some("55%")),
1705 "![[pic.jpg|55%]]"
1706 );
1707 assert_eq!(
1708 set_image_width("![[pic.jpg|wide]]", Some("55%")),
1709 "![[pic.jpg|55%]]"
1710 );
1711 assert_eq!(set_image_width("![[pic.jpg|55%]]", None), "![[pic.jpg]]");
1712 assert_eq!(
1714 set_image_width("![[pic.jpg|My cap|30%]]", Some("55%")),
1715 "![[pic.jpg|My cap|55%]]"
1716 );
1717 assert_eq!(
1718 set_image_width("![[pic.jpg|My cap]]", Some("55%")),
1719 "![[pic.jpg|My cap|55%]]"
1720 );
1721 }
1722
1723 #[test]
1724 fn set_image_width_validates() {
1725 assert_eq!(
1727 set_image_width("", Some("150%")),
1728 ""
1729 );
1730 assert_eq!(
1732 set_image_width("", Some("garbage")),
1733 ""
1734 );
1735 }
1736
1737 #[test]
1738 fn set_image_width_passthrough_non_image() {
1739 assert_eq!(set_image_width("plain text", Some("55%")), "plain text");
1741 }
1742
1743 #[test]
1746 fn parse_color_attr() {
1747 let attrs = parse_media_attrs("color=black");
1748 assert_eq!(attrs.color.as_deref(), Some("black"));
1749
1750 let attrs = parse_media_attrs("color=#0a0a0a");
1751 assert_eq!(attrs.color.as_deref(), Some("#0a0a0a"));
1752 }
1753
1754 #[test]
1755 fn parse_color_attr_alongside_keywords() {
1756 let attrs = parse_media_attrs("contain color=rgb(10,10,10) top left");
1757 assert_eq!(attrs.color.as_deref(), Some("rgb(10,10,10)"));
1758 assert!(attrs.fit.is_some(), "fit keyword must still parse");
1759 assert!(attrs.position.is_some(), "position keywords must still parse");
1760 }
1761
1762 #[test]
1763 fn parse_empty_color_attr_is_none() {
1764 let attrs = parse_media_attrs("color=");
1765 assert_eq!(attrs.color, None);
1766 assert!(attrs.is_empty());
1767 }
1768
1769 #[test]
1770 fn color_attr_alone_is_not_empty() {
1771 let attrs = parse_media_attrs("color=black");
1772 assert!(!attrs.is_empty());
1773 }
1774
1775 #[test]
1776 fn color_attr_does_not_leak_into_style_or_class() {
1777 let attrs = parse_media_attrs("color=black");
1778 assert_eq!(attrs.to_inline_style(), None);
1779 assert_eq!(attrs.class_attr(), None);
1780 }
1781
1782 #[test]
1783 fn repeated_color_attr_last_wins() {
1784 let attrs = parse_media_attrs("color=red color=blue");
1786 assert_eq!(attrs.color.as_deref(), Some("blue"));
1787 }
1788}