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)]
176pub struct MediaAttrs {
177 pub fit: Option<Fit>,
178 pub position: Option<Position>,
179 pub align: Option<AlignSide>,
180 pub class_names: Vec<String>,
185 pub extra_attrs: BTreeMap<String, String>,
190}
191
192impl MediaAttrs {
193 pub fn is_empty(&self) -> bool {
195 self.fit.is_none()
196 && self.position.is_none()
197 && self.align.is_none()
198 && self.class_names.is_empty()
199 && self.extra_attrs.is_empty()
200 }
201
202 pub fn to_inline_style(&self) -> Option<String> {
209 if self.fit.is_none() && self.position.is_none() {
210 return None;
211 }
212
213 let mut parts = Vec::new();
214 if let Some(ref fit) = self.fit {
215 parts.push(format!("object-fit:{}", fit.to_css_value()));
216 }
217 if let Some(ref pos) = self.position {
218 parts.push(format!("object-position:{}", pos.to_css_value()));
219 }
220 Some(parts.join(";"))
221 }
222
223 pub fn css_class(&self) -> Option<&'static str> {
230 self.align.map(AlignSide::css_class)
231 }
232
233 pub fn class_attr(&self) -> Option<String> {
241 let moss_class = self.css_class();
242 if moss_class.is_none() && self.class_names.is_empty() {
243 return None;
244 }
245 let mut parts: Vec<&str> = Vec::new();
246 if let Some(c) = moss_class {
247 parts.push(c);
248 }
249 for c in &self.class_names {
250 parts.push(c.as_str());
251 }
252 Some(parts.join(" "))
253 }
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
263pub(crate) struct ResolvedMedia {
264 pub path: String,
266 pub attrs: MediaAttrs,
268}
269
270pub fn strip_wikilink(raw: &str) -> &str {
279 let trimmed = raw.trim();
280 trimmed
281 .strip_prefix("[[")
282 .and_then(|s| s.strip_suffix("]]"))
283 .unwrap_or(trimmed)
284}
285
286pub fn split_pipe(raw: &str) -> (&str, &str) {
290 raw.split_once('|').unwrap_or((raw, ""))
291}
292
293pub fn parse_media_attrs(raw: &str) -> MediaAttrs {
302 let mut fit: Option<Fit> = None;
303 let mut position: Option<Position> = None;
304 let mut align: Option<AlignSide> = None;
305
306 let tokens: Vec<&str> = raw.split_whitespace().collect();
307 let mut i = 0;
308
309 while i < tokens.len() {
310 let token = tokens[i];
311
312 if i + 1 < tokens.len() {
314 let combined = format!("{} {}", token, tokens[i + 1]);
315 if let Some(pos) = Position::from_keyword(&combined) {
316 position = Some(pos);
317 i += 2;
318 continue;
319 }
320 }
321
322 if let Some(f) = Fit::from_keyword(token) {
324 fit = Some(f);
325 i += 1;
326 continue;
327 }
328
329 if let Some(pos) = Position::from_keyword(token) {
331 position = Some(pos);
332 i += 1;
333 continue;
334 }
335
336 if let Some(side) = AlignSide::from_keyword(token) {
338 align = Some(side);
339 i += 1;
340 continue;
341 }
342
343 i += 1;
345 }
346
347 MediaAttrs {
348 fit,
349 position,
350 align,
351 ..Default::default()
352 }
353}
354
355pub fn match_width_token(s: &str) -> Option<&'static str> {
368 match s {
369 "body" => Some("body"),
370 "wide" => Some("wide"),
371 "page" => Some("page"),
372 "screen" | "full" => Some("screen"),
373 _ => None,
374 }
375}
376
377pub fn extract_width_from_alias(alias: &str) -> (Option<&'static str>, String) {
397 let segments: Vec<&str> = alias.split('|').collect();
398 let mut width: Option<&'static str> = None;
399 let mut remaining: Vec<&str> = Vec::with_capacity(segments.len());
400
401 for seg in &segments {
402 let trimmed = seg.trim();
403 if width.is_none() {
404 if let Some(canonical) = match_width_token(trimmed) {
405 width = Some(canonical);
406 continue;
407 }
408 }
409 remaining.push(seg);
410 }
411
412 (width, remaining.join("|"))
413}
414
415pub fn is_all_display_keywords(text: &str) -> bool {
420 let tokens: Vec<&str> = text.split_whitespace().collect();
421 if tokens.is_empty() {
422 return false;
423 }
424
425 let mut i = 0;
426 while i < tokens.len() {
427 if i + 1 < tokens.len() {
429 let combined = format!("{} {}", tokens[i], tokens[i + 1]);
430 if Position::from_keyword(&combined).is_some() {
431 i += 2;
432 continue;
433 }
434 }
435
436 if Fit::from_keyword(tokens[i]).is_some() {
437 i += 1;
438 continue;
439 }
440
441 if Position::from_keyword(tokens[i]).is_some() {
442 i += 1;
443 continue;
444 }
445
446 if AlignSide::from_keyword(tokens[i]).is_some() {
447 i += 1;
448 continue;
449 }
450
451 return false;
452 }
453
454 true
455}
456
457pub(crate) fn is_structural_alias(alias: &str) -> bool {
472 if is_all_display_keywords(alias) {
475 return true;
476 }
477 let tokens: Vec<&str> = alias.split_whitespace().collect();
478 if tokens.is_empty() {
479 return false;
480 }
481 let mut i = 0;
484 while i < tokens.len() {
485 if match_width_token(tokens[i]).is_some() {
487 i += 1;
488 continue;
489 }
490 if i + 1 < tokens.len() {
492 let combined = format!("{} {}", tokens[i], tokens[i + 1]);
493 if Position::from_keyword(&combined).is_some() {
494 i += 2;
495 continue;
496 }
497 }
498 if Fit::from_keyword(tokens[i]).is_some()
500 || Position::from_keyword(tokens[i]).is_some()
501 || AlignSide::from_keyword(tokens[i]).is_some()
502 {
503 i += 1;
504 continue;
505 }
506 return false;
507 }
508 true
509}
510
511#[derive(Debug, Clone, PartialEq, Eq)]
520pub(crate) struct ImageAliasClass {
521 pub display_keywords: Option<String>,
525 pub caption: Option<String>,
531}
532
533pub(crate) fn classify_image_alias(alias: Option<&str>) -> ImageAliasClass {
544 match alias {
545 Some(a) if a.is_empty() => ImageAliasClass {
548 display_keywords: None,
549 caption: None,
550 },
551 Some(a) if is_structural_alias(a) => ImageAliasClass {
552 display_keywords: Some(a.to_string()),
553 caption: None,
554 },
555 Some(other) => ImageAliasClass {
556 display_keywords: None,
557 caption: Some(other.to_string()),
558 },
559 None => ImageAliasClass {
560 display_keywords: None,
561 caption: None,
562 },
563 }
564}
565
566pub fn html_escape(s: &str) -> String {
570 let mut out = String::with_capacity(s.len());
571 for ch in s.chars() {
572 match ch {
573 '&' => out.push_str("&"),
574 '"' => out.push_str("""),
575 '\'' => out.push_str("'"),
576 '<' => out.push_str("<"),
577 '>' => out.push_str(">"),
578 _ => out.push(ch),
579 }
580 }
581 out
582}
583
584fn is_external(path: &str) -> bool {
590 path.starts_with("http://")
591 || path.starts_with("https://")
592 || path.starts_with("//")
593 || path.starts_with("data:")
594}
595
596pub(crate) fn resolve_media_ref(raw: &str, source_path: &str, graph: &ContentGraph) -> ResolvedMedia {
603 let inner = strip_wikilink(raw);
604 let (path_part, attrs_str) = split_pipe(inner);
605 let path_trimmed = path_part.trim();
606 let attrs = parse_media_attrs(attrs_str);
607
608 let resolved_path = if is_external(path_trimmed) {
609 path_trimmed.to_string()
611 } else if let Some(stripped) = path_trimmed.strip_prefix('/') {
612 stripped.to_string()
614 } else {
615 graph
617 .resolve_path(path_trimmed, source_path)
618 .unwrap_or_else(|| path_trimmed.to_string())
619 };
620
621 ResolvedMedia {
622 path: resolved_path,
623 attrs,
624 }
625}
626
627#[cfg(test)]
632mod tests {
633 use super::*;
634 use crate::content_graph::ContentGraphBuilder;
635
636 #[test]
639 fn test_fit_to_css_value() {
640 assert_eq!(Fit::Cover.to_css_value(), "cover");
641 assert_eq!(Fit::Contain.to_css_value(), "contain");
642 assert_eq!(Fit::Fill.to_css_value(), "fill");
643 assert_eq!(Fit::None.to_css_value(), "none");
644 assert_eq!(Fit::ScaleDown.to_css_value(), "scale-down");
645 }
646
647 #[test]
648 fn test_fit_from_keyword() {
649 assert_eq!(Fit::from_keyword("cover"), Some(Fit::Cover));
650 assert_eq!(Fit::from_keyword("contain"), Some(Fit::Contain));
651 assert_eq!(Fit::from_keyword("fill"), Some(Fit::Fill));
652 assert_eq!(Fit::from_keyword("none"), Some(Fit::None));
653 assert_eq!(Fit::from_keyword("scale-down"), Some(Fit::ScaleDown));
654 assert_eq!(Fit::from_keyword("scaledown"), Some(Fit::ScaleDown));
655 }
656
657 #[test]
658 fn test_fit_from_keyword_case_insensitive() {
659 assert_eq!(Fit::from_keyword("COVER"), Some(Fit::Cover));
660 assert_eq!(Fit::from_keyword("Contain"), Some(Fit::Contain));
661 assert_eq!(Fit::from_keyword("Scale-Down"), Some(Fit::ScaleDown));
662 assert_eq!(Fit::from_keyword("SCALEDOWN"), Some(Fit::ScaleDown));
663 }
664
665 #[test]
666 fn test_fit_from_keyword_unknown() {
667 assert_eq!(Fit::from_keyword("zoom"), None);
668 assert_eq!(Fit::from_keyword(""), None);
669 assert_eq!(Fit::from_keyword("cover "), None); }
671
672 #[test]
675 fn test_align_side_from_keyword() {
676 assert_eq!(AlignSide::from_keyword("align-left"), Some(AlignSide::Left));
677 assert_eq!(AlignSide::from_keyword("align-right"), Some(AlignSide::Right));
678 assert_eq!(AlignSide::from_keyword("alignleft"), Some(AlignSide::Left));
680 assert_eq!(AlignSide::from_keyword("alignright"), Some(AlignSide::Right));
681 assert_eq!(AlignSide::from_keyword("ALIGN-LEFT"), Some(AlignSide::Left));
683 assert_eq!(AlignSide::from_keyword("AlignRight"), Some(AlignSide::Right));
684 assert_eq!(AlignSide::from_keyword(""), None);
686 }
687
688 #[test]
689 fn test_align_side_from_keyword_bare_directional() {
690 assert_eq!(AlignSide::from_keyword("left"), Some(AlignSide::Left));
697 assert_eq!(AlignSide::from_keyword("right"), Some(AlignSide::Right));
698 assert_eq!(AlignSide::from_keyword("LEFT"), Some(AlignSide::Left));
699 assert_eq!(AlignSide::from_keyword("Right"), Some(AlignSide::Right));
700 }
701
702 #[test]
703 fn test_parse_attrs_bare_left_is_position() {
704 let attrs = parse_media_attrs("left");
710 assert_eq!(attrs.position, Some(Position::Left));
711 assert_eq!(attrs.align, None);
712
713 let attrs = parse_media_attrs("right");
714 assert_eq!(attrs.position, Some(Position::Right));
715 assert_eq!(attrs.align, None);
716 }
717
718 #[test]
719 fn test_align_side_css_class() {
720 assert_eq!(AlignSide::Left.css_class(), "moss-align-left");
721 assert_eq!(AlignSide::Right.css_class(), "moss-align-right");
722 }
723
724 #[test]
727 fn test_position_to_css_value() {
728 assert_eq!(Position::Center.to_css_value(), "center");
729 assert_eq!(Position::Left.to_css_value(), "left");
730 assert_eq!(Position::Right.to_css_value(), "right");
731 assert_eq!(Position::Top.to_css_value(), "top");
732 assert_eq!(Position::Bottom.to_css_value(), "bottom");
733 assert_eq!(Position::TopLeft.to_css_value(), "top left");
734 assert_eq!(Position::TopRight.to_css_value(), "top right");
735 assert_eq!(Position::BottomLeft.to_css_value(), "bottom left");
736 assert_eq!(Position::BottomRight.to_css_value(), "bottom right");
737 }
738
739 #[test]
740 fn test_position_from_keyword_single() {
741 assert_eq!(Position::from_keyword("center"), Some(Position::Center));
742 assert_eq!(Position::from_keyword("left"), Some(Position::Left));
743 assert_eq!(Position::from_keyword("right"), Some(Position::Right));
744 assert_eq!(Position::from_keyword("top"), Some(Position::Top));
745 assert_eq!(Position::from_keyword("bottom"), Some(Position::Bottom));
746 }
747
748 #[test]
749 fn test_position_from_keyword_compound() {
750 assert_eq!(Position::from_keyword("top-left"), Some(Position::TopLeft));
752 assert_eq!(Position::from_keyword("top-right"), Some(Position::TopRight));
753 assert_eq!(Position::from_keyword("bottom-left"), Some(Position::BottomLeft));
754 assert_eq!(Position::from_keyword("bottom-right"), Some(Position::BottomRight));
755
756 assert_eq!(Position::from_keyword("topleft"), Some(Position::TopLeft));
758 assert_eq!(Position::from_keyword("bottomright"), Some(Position::BottomRight));
759
760 assert_eq!(Position::from_keyword("top left"), Some(Position::TopLeft));
762 assert_eq!(Position::from_keyword("bottom right"), Some(Position::BottomRight));
763 }
764
765 #[test]
766 fn test_position_from_keyword_case_insensitive() {
767 assert_eq!(Position::from_keyword("CENTER"), Some(Position::Center));
768 assert_eq!(Position::from_keyword("Top-Left"), Some(Position::TopLeft));
769 assert_eq!(Position::from_keyword("BOTTOMRIGHT"), Some(Position::BottomRight));
770 }
771
772 #[test]
773 fn test_position_from_keyword_unknown() {
774 assert_eq!(Position::from_keyword("middle"), None);
775 assert_eq!(Position::from_keyword(""), None);
776 }
777
778 #[test]
781 fn test_media_attrs_is_empty() {
782 let empty = MediaAttrs {
783 fit: None,
784 position: None,
785 align: None,
786 class_names: Vec::new(),
787 extra_attrs: BTreeMap::new(),
788 };
789 assert!(empty.is_empty());
790
791 let with_fit = MediaAttrs {
792 fit: Some(Fit::Cover),
793 position: None,
794 align: None,
795 class_names: Vec::new(),
796 extra_attrs: BTreeMap::new(),
797 };
798 assert!(!with_fit.is_empty());
799
800 let with_pos = MediaAttrs {
801 fit: None,
802 position: Some(Position::Center),
803 align: None,
804 class_names: Vec::new(),
805 extra_attrs: BTreeMap::new(),
806 };
807 assert!(!with_pos.is_empty());
808 }
809
810 #[test]
811 fn test_to_inline_style_empty() {
812 let attrs = MediaAttrs {
813 fit: None,
814 position: None,
815 align: None,
816 class_names: Vec::new(),
817 extra_attrs: BTreeMap::new(),
818 };
819 assert_eq!(attrs.to_inline_style(), None);
820 }
821
822 #[test]
823 fn test_to_inline_style_fit_only() {
824 let attrs = MediaAttrs {
825 fit: Some(Fit::Contain),
826 position: None,
827 align: None,
828 class_names: Vec::new(),
829 extra_attrs: BTreeMap::new(),
830 };
831 assert_eq!(attrs.to_inline_style(), Some("object-fit:contain".into()));
832 }
833
834 #[test]
835 fn test_to_inline_style_position_only() {
836 let attrs = MediaAttrs {
837 fit: None,
838 position: Some(Position::Left),
839 align: None,
840 class_names: Vec::new(),
841 extra_attrs: BTreeMap::new(),
842 };
843 assert_eq!(
844 attrs.to_inline_style(),
845 Some("object-position:left".into())
846 );
847 }
848
849 #[test]
850 fn test_to_inline_style_both() {
851 let attrs = MediaAttrs {
852 fit: Some(Fit::Cover),
853 position: Some(Position::TopLeft),
854 align: None,
855 class_names: Vec::new(),
856 extra_attrs: BTreeMap::new(),
857 };
858 assert_eq!(
859 attrs.to_inline_style(),
860 Some("object-fit:cover;object-position:top left".into())
861 );
862 }
863
864 #[test]
867 fn test_strip_wikilink_with_brackets() {
868 assert_eq!(strip_wikilink("[[photo.jpg]]"), "photo.jpg");
869 assert_eq!(strip_wikilink("[[path/to/image.png]]"), "path/to/image.png");
870 }
871
872 #[test]
873 fn test_strip_wikilink_without_brackets() {
874 assert_eq!(strip_wikilink("photo.jpg"), "photo.jpg");
875 assert_eq!(strip_wikilink("path/to/image.png"), "path/to/image.png");
876 }
877
878 #[test]
879 fn test_strip_wikilink_with_pipe() {
880 assert_eq!(strip_wikilink("[[photo.jpg|cover]]"), "photo.jpg|cover");
881 }
882
883 #[test]
884 fn test_strip_wikilink_with_whitespace() {
885 assert_eq!(strip_wikilink(" [[photo.jpg]] "), "photo.jpg");
886 }
887
888 #[test]
889 fn test_strip_wikilink_partial_brackets() {
890 assert_eq!(strip_wikilink("[[photo.jpg"), "[[photo.jpg");
892 assert_eq!(strip_wikilink("photo.jpg]]"), "photo.jpg]]");
894 }
895
896 #[test]
897 fn test_strip_wikilink_empty() {
898 assert_eq!(strip_wikilink("[[]]"), "");
899 assert_eq!(strip_wikilink(""), "");
900 }
901
902 #[test]
905 fn test_split_pipe_with_pipe() {
906 assert_eq!(split_pipe("photo.jpg|cover"), ("photo.jpg", "cover"));
907 assert_eq!(
908 split_pipe("path/to/img.png|contain center"),
909 ("path/to/img.png", "contain center")
910 );
911 }
912
913 #[test]
914 fn test_split_pipe_no_pipe() {
915 assert_eq!(split_pipe("photo.jpg"), ("photo.jpg", ""));
916 assert_eq!(split_pipe(""), ("", ""));
917 }
918
919 #[test]
920 fn test_split_pipe_multiple_pipes() {
921 assert_eq!(split_pipe("a|b|c"), ("a", "b|c"));
923 }
924
925 #[test]
926 fn test_split_pipe_pipe_at_edges() {
927 assert_eq!(split_pipe("|cover"), ("", "cover"));
928 assert_eq!(split_pipe("photo.jpg|"), ("photo.jpg", ""));
929 }
930
931 #[test]
934 fn test_parse_attrs_fit_only() {
935 let attrs = parse_media_attrs("cover");
936 assert_eq!(attrs.fit, Some(Fit::Cover));
937 assert_eq!(attrs.position, None);
938 }
939
940 #[test]
941 fn test_parse_attrs_position_only() {
942 let attrs = parse_media_attrs("center");
943 assert_eq!(attrs.fit, None);
944 assert_eq!(attrs.position, Some(Position::Center));
945 }
946
947 #[test]
948 fn test_parse_attrs_fit_and_position() {
949 let attrs = parse_media_attrs("contain left");
950 assert_eq!(attrs.fit, Some(Fit::Contain));
951 assert_eq!(attrs.position, Some(Position::Left));
952 }
953
954 #[test]
955 fn test_parse_attrs_two_word_position() {
956 let attrs = parse_media_attrs("top left");
957 assert_eq!(attrs.fit, None);
958 assert_eq!(attrs.position, Some(Position::TopLeft));
959
960 let attrs2 = parse_media_attrs("cover bottom right");
961 assert_eq!(attrs2.fit, Some(Fit::Cover));
962 assert_eq!(attrs2.position, Some(Position::BottomRight));
963 }
964
965 #[test]
966 fn test_parse_attrs_hyphenated_compound_position() {
967 let attrs = parse_media_attrs("top-right");
968 assert_eq!(attrs.fit, None);
969 assert_eq!(attrs.position, Some(Position::TopRight));
970
971 let attrs2 = parse_media_attrs("fill bottom-left");
972 assert_eq!(attrs2.fit, Some(Fit::Fill));
973 assert_eq!(attrs2.position, Some(Position::BottomLeft));
974 }
975
976 #[test]
977 fn test_parse_attrs_unknown_tokens_ignored() {
978 let attrs = parse_media_attrs("cover unknown-token left");
979 assert_eq!(attrs.fit, Some(Fit::Cover));
980 assert_eq!(attrs.position, Some(Position::Left));
981 }
982
983 #[test]
984 fn test_parse_attrs_empty_string() {
985 let attrs = parse_media_attrs("");
986 assert!(attrs.is_empty());
987 }
988
989 #[test]
990 fn test_parse_attrs_only_whitespace() {
991 let attrs = parse_media_attrs(" ");
992 assert!(attrs.is_empty());
993 }
994
995 #[test]
996 fn test_parse_attrs_all_unknown() {
997 let attrs = parse_media_attrs("foo bar baz");
998 assert!(attrs.is_empty());
999 }
1000
1001 #[test]
1002 fn test_parse_attrs_case_insensitive() {
1003 let attrs = parse_media_attrs("COVER CENTER");
1004 assert_eq!(attrs.fit, Some(Fit::Cover));
1005 assert_eq!(attrs.position, Some(Position::Center));
1006 }
1007
1008 #[test]
1009 fn test_parse_attrs_last_wins_for_duplicates() {
1010 let attrs = parse_media_attrs("cover contain");
1012 assert_eq!(attrs.fit, Some(Fit::Contain));
1013 }
1014
1015 #[test]
1016 fn test_parse_attrs_scale_down() {
1017 let attrs = parse_media_attrs("scale-down");
1018 assert_eq!(attrs.fit, Some(Fit::ScaleDown));
1019 }
1020
1021 fn sample_graph() -> ContentGraph {
1024 let mut b = ContentGraphBuilder::new();
1025 b.add_file("images/photo.jpg", "images/photo");
1026 b.add_file("assets/banner.png", "assets/banner");
1027 b.add_file("posts/hello.md", "posts/hello");
1028 b.build()
1029 }
1030
1031 #[test]
1032 fn test_resolve_simple_path() {
1033 let graph = sample_graph();
1034 let result = resolve_media_ref("photo.jpg", "posts/hello.md", &graph);
1035 assert_eq!(result.path, "images/photo.jpg");
1036 assert!(result.attrs.is_empty());
1037 }
1038
1039 #[test]
1040 fn test_resolve_with_attrs() {
1041 let graph = sample_graph();
1042 let result = resolve_media_ref("photo.jpg|cover center", "posts/hello.md", &graph);
1043 assert_eq!(result.path, "images/photo.jpg");
1044 assert_eq!(result.attrs.fit, Some(Fit::Cover));
1045 assert_eq!(result.attrs.position, Some(Position::Center));
1046 }
1047
1048 #[test]
1049 fn test_resolve_wikilink() {
1050 let graph = sample_graph();
1051 let result = resolve_media_ref("[[photo.jpg|contain]]", "posts/hello.md", &graph);
1052 assert_eq!(result.path, "images/photo.jpg");
1053 assert_eq!(result.attrs.fit, Some(Fit::Contain));
1054 }
1055
1056 #[test]
1057 fn test_resolve_wikilink_no_attrs() {
1058 let graph = sample_graph();
1059 let result = resolve_media_ref("[[photo.jpg]]", "posts/hello.md", &graph);
1060 assert_eq!(result.path, "images/photo.jpg");
1061 assert!(result.attrs.is_empty());
1062 }
1063
1064 #[test]
1065 fn test_resolve_external_http() {
1066 let graph = sample_graph();
1067 let result = resolve_media_ref(
1068 "https://example.com/img.jpg|cover",
1069 "posts/hello.md",
1070 &graph,
1071 );
1072 assert_eq!(result.path, "https://example.com/img.jpg");
1073 assert_eq!(result.attrs.fit, Some(Fit::Cover));
1074 }
1075
1076 #[test]
1077 fn test_resolve_external_protocol_relative() {
1078 let graph = sample_graph();
1079 let result = resolve_media_ref("//cdn.example.com/img.jpg", "posts/hello.md", &graph);
1080 assert_eq!(result.path, "//cdn.example.com/img.jpg");
1081 }
1082
1083 #[test]
1084 fn test_resolve_external_data_uri() {
1085 let graph = sample_graph();
1086 let result = resolve_media_ref("data:image/png;base64,abc", "posts/hello.md", &graph);
1087 assert_eq!(result.path, "data:image/png;base64,abc");
1088 }
1089
1090 #[test]
1091 fn test_resolve_root_relative() {
1092 let graph = sample_graph();
1093 let result = resolve_media_ref("/images/photo.jpg|fill", "posts/hello.md", &graph);
1094 assert_eq!(result.path, "images/photo.jpg");
1095 assert_eq!(result.attrs.fit, Some(Fit::Fill));
1096 }
1097
1098 #[test]
1099 fn test_resolve_unresolved_fallback() {
1100 let graph = sample_graph();
1101 let result = resolve_media_ref("missing.jpg", "posts/hello.md", &graph);
1102 assert_eq!(result.path, "missing.jpg");
1104 assert!(result.attrs.is_empty());
1105 }
1106
1107 #[test]
1108 fn test_resolve_wikilink_with_two_word_position() {
1109 let graph = sample_graph();
1110 let result =
1111 resolve_media_ref("[[banner.png|cover top left]]", "posts/hello.md", &graph);
1112 assert_eq!(result.path, "assets/banner.png");
1113 assert_eq!(result.attrs.fit, Some(Fit::Cover));
1114 assert_eq!(result.attrs.position, Some(Position::TopLeft));
1115 }
1116
1117 #[test]
1118 fn test_resolve_external_in_wikilink() {
1119 let graph = sample_graph();
1120 let result = resolve_media_ref(
1121 "[[https://example.com/img.jpg|contain]]",
1122 "posts/hello.md",
1123 &graph,
1124 );
1125 assert_eq!(result.path, "https://example.com/img.jpg");
1126 assert_eq!(result.attrs.fit, Some(Fit::Contain));
1127 }
1128
1129 #[test]
1130 fn test_resolve_path_with_spaces_trimmed() {
1131 let graph = sample_graph();
1132 let result = resolve_media_ref(" photo.jpg | cover ", "posts/hello.md", &graph);
1133 assert_eq!(result.path, "images/photo.jpg");
1134 assert_eq!(result.attrs.fit, Some(Fit::Cover));
1135 }
1136
1137 #[test]
1140 fn test_is_all_display_keywords_positions() {
1141 assert!(is_all_display_keywords("left"));
1142 assert!(is_all_display_keywords("right"));
1143 assert!(is_all_display_keywords("center"));
1144 assert!(is_all_display_keywords("top"));
1145 assert!(is_all_display_keywords("bottom"));
1146 assert!(is_all_display_keywords("top left"));
1147 assert!(is_all_display_keywords("bottom right"));
1148 }
1149
1150 #[test]
1151 fn test_is_all_display_keywords_fits() {
1152 assert!(is_all_display_keywords("cover"));
1153 assert!(is_all_display_keywords("contain"));
1154 assert!(is_all_display_keywords("fill"));
1155 assert!(is_all_display_keywords("none"));
1156 assert!(is_all_display_keywords("scale-down"));
1157 }
1158
1159 #[test]
1160 fn test_is_all_display_keywords_combined() {
1161 assert!(is_all_display_keywords("contain left"));
1162 assert!(is_all_display_keywords("cover top left"));
1163 assert!(is_all_display_keywords("cover top-right"));
1164 assert!(is_all_display_keywords("scale-down bottom-left"));
1165 }
1166
1167 #[test]
1168 fn test_is_all_display_keywords_rejects_non_keywords() {
1169 assert!(!is_all_display_keywords("A beautiful sunset"));
1170 assert!(!is_all_display_keywords("left side"));
1171 assert!(!is_all_display_keywords(""));
1172 assert!(!is_all_display_keywords(" "));
1173 }
1174
1175 #[test]
1178 fn test_html_escape_basic() {
1179 assert_eq!(html_escape("hello"), "hello");
1180 assert_eq!(html_escape("a&b"), "a&b");
1181 assert_eq!(html_escape("a\"b"), "a"b");
1182 assert_eq!(html_escape("a'b"), "a'b");
1183 assert_eq!(html_escape("a<b>c"), "a<b>c");
1184 assert_eq!(
1185 html_escape("<div class=\"x\">&'</div>"),
1186 "<div class="x">&'</div>"
1187 );
1188 }
1189
1190 #[test]
1191 fn test_parse_media_attrs_align_alone() {
1192 let attrs = parse_media_attrs("align-left");
1193 assert_eq!(attrs.align, Some(AlignSide::Left));
1194 assert_eq!(attrs.fit, None);
1195 assert_eq!(attrs.position, None);
1196 }
1197
1198 #[test]
1199 fn test_parse_media_attrs_align_with_cover() {
1200 let a = parse_media_attrs("cover align-right");
1202 assert_eq!(a.fit, Some(Fit::Cover));
1203 assert_eq!(a.align, Some(AlignSide::Right));
1204
1205 let b = parse_media_attrs("align-right cover");
1206 assert_eq!(b, a);
1207 }
1208
1209 #[test]
1210 fn test_parse_media_attrs_align_last_wins() {
1211 let attrs = parse_media_attrs("align-left align-right");
1215 assert_eq!(attrs.align, Some(AlignSide::Right));
1216
1217 let attrs = parse_media_attrs("align-right align-left");
1218 assert_eq!(attrs.align, Some(AlignSide::Left));
1219 }
1220
1221 #[test]
1222 fn test_is_all_display_keywords_align() {
1223 assert!(is_all_display_keywords("align-left"));
1224 assert!(is_all_display_keywords("align-right"));
1225 assert!(is_all_display_keywords("cover align-left"));
1226 assert!(is_all_display_keywords("align-left cover"));
1227 assert!(is_all_display_keywords("align-left top"));
1229 }
1230
1231 #[test]
1234 fn test_match_width_token_recognized() {
1235 assert_eq!(match_width_token("body"), Some("body"));
1236 assert_eq!(match_width_token("wide"), Some("wide"));
1237 assert_eq!(match_width_token("page"), Some("page"));
1238 assert_eq!(match_width_token("screen"), Some("screen"));
1239 assert_eq!(match_width_token("full"), Some("screen"));
1241 }
1242
1243 #[test]
1244 fn test_match_width_token_rejects_non_width() {
1245 assert_eq!(match_width_token(""), None);
1246 assert_eq!(match_width_token("BODY"), None);
1247 assert_eq!(match_width_token("widely"), None);
1248 assert_eq!(match_width_token("wide angle"), None);
1250 assert_eq!(match_width_token("contain"), None);
1252 assert_eq!(match_width_token("left"), None);
1253 }
1254
1255 #[test]
1256 fn test_extract_width_from_alias_single_segment_width() {
1257 let (w, rest) = extract_width_from_alias("full");
1258 assert_eq!(w, Some("screen"));
1259 assert_eq!(rest, "");
1260 }
1261
1262 #[test]
1263 fn test_extract_width_from_alias_caption_only() {
1264 let (w, rest) = extract_width_from_alias("A beautiful sunset");
1266 assert_eq!(w, None);
1267 assert_eq!(rest, "A beautiful sunset");
1268 }
1269
1270 #[test]
1271 fn test_extract_width_from_alias_caption_then_width() {
1272 let (w, rest) = extract_width_from_alias("A nice photo|full");
1275 assert_eq!(w, Some("screen"));
1276 assert_eq!(rest, "A nice photo");
1277 }
1278
1279 #[test]
1280 fn test_extract_width_from_alias_width_then_caption() {
1281 let (w, rest) = extract_width_from_alias("wide|A nice photo");
1282 assert_eq!(w, Some("wide"));
1283 assert_eq!(rest, "A nice photo");
1284 }
1285
1286 #[test]
1287 fn test_extract_width_from_alias_caption_with_width_word_not_shadowed() {
1288 let (w, rest) = extract_width_from_alias("caption that says wide");
1292 assert_eq!(w, None);
1293 assert_eq!(rest, "caption that says wide");
1294 }
1295
1296 #[test]
1297 fn test_extract_width_from_alias_only_first_width_extracted() {
1298 let (w, rest) = extract_width_from_alias("full|wide");
1303 assert_eq!(w, Some("screen"));
1304 assert_eq!(rest, "wide");
1305 }
1306
1307 #[test]
1308 fn test_extract_width_from_alias_segment_whitespace_trimmed() {
1309 let (w, rest) = extract_width_from_alias("caption | full");
1313 assert_eq!(w, Some("screen"));
1314 assert_eq!(rest, "caption ");
1315 }
1316
1317 #[test]
1320 fn test_media_attrs_class_names_preserved() {
1321 let attrs = MediaAttrs {
1325 fit: None,
1326 position: None,
1327 align: None,
1328 class_names: vec!["theme-rounded".to_string(), "shadow-lg".to_string()],
1329 extra_attrs: BTreeMap::new(),
1330 };
1331 assert!(!attrs.is_empty());
1332 assert_eq!(
1333 attrs.class_attr(),
1334 Some("theme-rounded shadow-lg".to_string())
1335 );
1336 }
1337
1338 #[test]
1339 fn test_media_attrs_class_names_compose_with_align() {
1340 let attrs = MediaAttrs {
1344 fit: None,
1345 position: None,
1346 align: Some(AlignSide::Left),
1347 class_names: vec!["theme-rounded".to_string()],
1348 extra_attrs: BTreeMap::new(),
1349 };
1350 assert_eq!(
1351 attrs.class_attr(),
1352 Some("moss-align-left theme-rounded".to_string())
1353 );
1354 }
1355
1356 #[test]
1357 fn test_media_attrs_extra_attrs_non_empty() {
1358 let mut extras = BTreeMap::new();
1361 extras.insert("data-zoom".to_string(), "true".to_string());
1362 extras.insert("data-id".to_string(), "42".to_string());
1363 let attrs = MediaAttrs {
1364 fit: None,
1365 position: None,
1366 align: None,
1367 class_names: vec![],
1368 extra_attrs: extras,
1369 };
1370 assert!(!attrs.is_empty());
1371 let keys: Vec<&str> = attrs.extra_attrs.keys().map(String::as_str).collect();
1373 assert_eq!(keys, vec!["data-id", "data-zoom"]);
1374 }
1375
1376 #[test]
1379 fn test_classify_image_alias_none() {
1380 let c = classify_image_alias(None);
1381 assert_eq!(c.display_keywords, None);
1382 assert_eq!(c.caption, None);
1383 }
1384
1385 #[test]
1386 fn test_classify_image_alias_empty_is_none_never_some_empty() {
1387 let c = classify_image_alias(Some(""));
1390 assert_eq!(c.display_keywords, None);
1391 assert_eq!(c.caption, None);
1392 }
1393
1394 #[test]
1395 fn test_classify_image_alias_structural_single_keyword() {
1396 let c = classify_image_alias(Some("cover"));
1397 assert_eq!(c.display_keywords.as_deref(), Some("cover"));
1398 assert_eq!(c.caption, None);
1399 }
1400
1401 #[test]
1402 fn test_classify_image_alias_structural_compound() {
1403 let c = classify_image_alias(Some("wide cover"));
1405 assert_eq!(c.display_keywords.as_deref(), Some("wide cover"));
1406 assert_eq!(c.caption, None);
1407 }
1408
1409 #[test]
1410 fn test_classify_image_alias_pure_width_token_is_structural() {
1411 let c = classify_image_alias(Some("wide"));
1413 assert_eq!(c.display_keywords.as_deref(), Some("wide"));
1414 assert_eq!(c.caption, None);
1415 }
1416
1417 #[test]
1418 fn test_classify_image_alias_caption_text() {
1419 let c = classify_image_alias(Some("My nice photo"));
1420 assert_eq!(c.display_keywords, None);
1421 assert_eq!(c.caption.as_deref(), Some("My nice photo"));
1422 }
1423
1424 #[test]
1425 fn test_is_structural_alias_matches_classifier() {
1426 assert!(is_structural_alias("cover"));
1428 assert!(is_structural_alias("wide cover"));
1429 assert!(is_structural_alias("top left"));
1430 assert!(!is_structural_alias("My nice photo"));
1431 assert!(!is_structural_alias(""));
1432 }
1433}