use std::collections::BTreeMap;
use crate::content_graph::ContentGraph;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Fit {
Cover,
Contain,
Fill,
None,
ScaleDown,
}
impl Fit {
pub fn to_css_value(&self) -> &str {
match self {
Fit::Cover => "cover",
Fit::Contain => "contain",
Fit::Fill => "fill",
Fit::None => "none",
Fit::ScaleDown => "scale-down",
}
}
pub fn from_keyword(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"cover" => Some(Fit::Cover),
"contain" => Some(Fit::Contain),
"fill" => Some(Fit::Fill),
"none" => Some(Fit::None),
"scale-down" | "scaledown" => Some(Fit::ScaleDown),
_ => Option::None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Position {
Center,
Left,
Right,
Top,
Bottom,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
impl Position {
pub fn to_css_value(&self) -> &str {
match self {
Position::Center => "center",
Position::Left => "left",
Position::Right => "right",
Position::Top => "top",
Position::Bottom => "bottom",
Position::TopLeft => "top left",
Position::TopRight => "top right",
Position::BottomLeft => "bottom left",
Position::BottomRight => "bottom right",
}
}
pub fn from_keyword(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"center" => Some(Position::Center),
"left" => Some(Position::Left),
"right" => Some(Position::Right),
"top" => Some(Position::Top),
"bottom" => Some(Position::Bottom),
"top-left" | "topleft" | "top left" => Some(Position::TopLeft),
"top-right" | "topright" | "top right" => Some(Position::TopRight),
"bottom-left" | "bottomleft" | "bottom left" => Some(Position::BottomLeft),
"bottom-right" | "bottomright" | "bottom right" => Some(Position::BottomRight),
_ => Option::None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlignSide {
Left,
Right,
}
impl AlignSide {
pub fn from_keyword(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"align-left" | "alignleft" | "left" => Some(AlignSide::Left),
"align-right" | "alignright" | "right" => Some(AlignSide::Right),
_ => None,
}
}
pub fn css_class(self) -> &'static str {
match self {
AlignSide::Left => "moss-align-left",
AlignSide::Right => "moss-align-right",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MediaAttrs {
pub fit: Option<Fit>,
pub position: Option<Position>,
pub align: Option<AlignSide>,
pub class_names: Vec<String>,
pub extra_attrs: BTreeMap<String, String>,
}
impl MediaAttrs {
pub fn is_empty(&self) -> bool {
self.fit.is_none()
&& self.position.is_none()
&& self.align.is_none()
&& self.class_names.is_empty()
&& self.extra_attrs.is_empty()
}
pub fn to_inline_style(&self) -> Option<String> {
if self.fit.is_none() && self.position.is_none() {
return None;
}
let mut parts = Vec::new();
if let Some(ref fit) = self.fit {
parts.push(format!("object-fit:{}", fit.to_css_value()));
}
if let Some(ref pos) = self.position {
parts.push(format!("object-position:{}", pos.to_css_value()));
}
Some(parts.join(";"))
}
pub fn css_class(&self) -> Option<&'static str> {
self.align.map(AlignSide::css_class)
}
pub fn class_attr(&self) -> Option<String> {
let moss_class = self.css_class();
if moss_class.is_none() && self.class_names.is_empty() {
return None;
}
let mut parts: Vec<&str> = Vec::new();
if let Some(c) = moss_class {
parts.push(c);
}
for c in &self.class_names {
parts.push(c.as_str());
}
Some(parts.join(" "))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedMedia {
pub path: String,
pub attrs: MediaAttrs,
}
pub fn strip_wikilink(raw: &str) -> &str {
let trimmed = raw.trim();
trimmed
.strip_prefix("[[")
.and_then(|s| s.strip_suffix("]]"))
.unwrap_or(trimmed)
}
pub fn split_pipe(raw: &str) -> (&str, &str) {
raw.split_once('|').unwrap_or((raw, ""))
}
pub fn parse_media_attrs(raw: &str) -> MediaAttrs {
let mut fit: Option<Fit> = None;
let mut position: Option<Position> = None;
let mut align: Option<AlignSide> = None;
let tokens: Vec<&str> = raw.split_whitespace().collect();
let mut i = 0;
while i < tokens.len() {
let token = tokens[i];
if i + 1 < tokens.len() {
let combined = format!("{} {}", token, tokens[i + 1]);
if let Some(pos) = Position::from_keyword(&combined) {
position = Some(pos);
i += 2;
continue;
}
}
if let Some(f) = Fit::from_keyword(token) {
fit = Some(f);
i += 1;
continue;
}
if let Some(pos) = Position::from_keyword(token) {
position = Some(pos);
i += 1;
continue;
}
if let Some(side) = AlignSide::from_keyword(token) {
align = Some(side);
i += 1;
continue;
}
i += 1;
}
MediaAttrs {
fit,
position,
align,
..Default::default()
}
}
pub fn match_width_token(s: &str) -> Option<&'static str> {
match s {
"body" => Some("body"),
"wide" => Some("wide"),
"page" => Some("page"),
"screen" | "full" => Some("screen"),
_ => None,
}
}
pub fn extract_width_from_alias(alias: &str) -> (Option<&'static str>, String) {
let segments: Vec<&str> = alias.split('|').collect();
let mut width: Option<&'static str> = None;
let mut remaining: Vec<&str> = Vec::with_capacity(segments.len());
for seg in &segments {
let trimmed = seg.trim();
if width.is_none() {
if let Some(canonical) = match_width_token(trimmed) {
width = Some(canonical);
continue;
}
}
remaining.push(seg);
}
(width, remaining.join("|"))
}
pub fn is_all_display_keywords(text: &str) -> bool {
let tokens: Vec<&str> = text.split_whitespace().collect();
if tokens.is_empty() {
return false;
}
let mut i = 0;
while i < tokens.len() {
if i + 1 < tokens.len() {
let combined = format!("{} {}", tokens[i], tokens[i + 1]);
if Position::from_keyword(&combined).is_some() {
i += 2;
continue;
}
}
if Fit::from_keyword(tokens[i]).is_some() {
i += 1;
continue;
}
if Position::from_keyword(tokens[i]).is_some() {
i += 1;
continue;
}
if AlignSide::from_keyword(tokens[i]).is_some() {
i += 1;
continue;
}
return false;
}
true
}
pub(crate) fn is_structural_alias(alias: &str) -> bool {
if is_all_display_keywords(alias) {
return true;
}
let tokens: Vec<&str> = alias.split_whitespace().collect();
if tokens.is_empty() {
return false;
}
let mut i = 0;
while i < tokens.len() {
if match_width_token(tokens[i]).is_some() {
i += 1;
continue;
}
if i + 1 < tokens.len() {
let combined = format!("{} {}", tokens[i], tokens[i + 1]);
if Position::from_keyword(&combined).is_some() {
i += 2;
continue;
}
}
if Fit::from_keyword(tokens[i]).is_some()
|| Position::from_keyword(tokens[i]).is_some()
|| AlignSide::from_keyword(tokens[i]).is_some()
{
i += 1;
continue;
}
return false;
}
true
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ImageAliasClass {
pub display_keywords: Option<String>,
pub caption: Option<String>,
}
pub(crate) fn classify_image_alias(alias: Option<&str>) -> ImageAliasClass {
match alias {
Some(a) if a.is_empty() => ImageAliasClass {
display_keywords: None,
caption: None,
},
Some(a) if is_structural_alias(a) => ImageAliasClass {
display_keywords: Some(a.to_string()),
caption: None,
},
Some(other) => ImageAliasClass {
display_keywords: None,
caption: Some(other.to_string()),
},
None => ImageAliasClass {
display_keywords: None,
caption: None,
},
}
}
pub fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
out
}
fn is_external(path: &str) -> bool {
path.starts_with("http://")
|| path.starts_with("https://")
|| path.starts_with("//")
|| path.starts_with("data:")
}
pub(crate) fn resolve_media_ref(raw: &str, source_path: &str, graph: &ContentGraph) -> ResolvedMedia {
let inner = strip_wikilink(raw);
let (path_part, attrs_str) = split_pipe(inner);
let path_trimmed = path_part.trim();
let attrs = parse_media_attrs(attrs_str);
let resolved_path = if is_external(path_trimmed) {
path_trimmed.to_string()
} else if let Some(stripped) = path_trimmed.strip_prefix('/') {
stripped.to_string()
} else {
graph
.resolve_path(path_trimmed, source_path)
.unwrap_or_else(|| path_trimmed.to_string())
};
ResolvedMedia {
path: resolved_path,
attrs,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::content_graph::ContentGraphBuilder;
#[test]
fn test_fit_to_css_value() {
assert_eq!(Fit::Cover.to_css_value(), "cover");
assert_eq!(Fit::Contain.to_css_value(), "contain");
assert_eq!(Fit::Fill.to_css_value(), "fill");
assert_eq!(Fit::None.to_css_value(), "none");
assert_eq!(Fit::ScaleDown.to_css_value(), "scale-down");
}
#[test]
fn test_fit_from_keyword() {
assert_eq!(Fit::from_keyword("cover"), Some(Fit::Cover));
assert_eq!(Fit::from_keyword("contain"), Some(Fit::Contain));
assert_eq!(Fit::from_keyword("fill"), Some(Fit::Fill));
assert_eq!(Fit::from_keyword("none"), Some(Fit::None));
assert_eq!(Fit::from_keyword("scale-down"), Some(Fit::ScaleDown));
assert_eq!(Fit::from_keyword("scaledown"), Some(Fit::ScaleDown));
}
#[test]
fn test_fit_from_keyword_case_insensitive() {
assert_eq!(Fit::from_keyword("COVER"), Some(Fit::Cover));
assert_eq!(Fit::from_keyword("Contain"), Some(Fit::Contain));
assert_eq!(Fit::from_keyword("Scale-Down"), Some(Fit::ScaleDown));
assert_eq!(Fit::from_keyword("SCALEDOWN"), Some(Fit::ScaleDown));
}
#[test]
fn test_fit_from_keyword_unknown() {
assert_eq!(Fit::from_keyword("zoom"), None);
assert_eq!(Fit::from_keyword(""), None);
assert_eq!(Fit::from_keyword("cover "), None); }
#[test]
fn test_align_side_from_keyword() {
assert_eq!(AlignSide::from_keyword("align-left"), Some(AlignSide::Left));
assert_eq!(AlignSide::from_keyword("align-right"), Some(AlignSide::Right));
assert_eq!(AlignSide::from_keyword("alignleft"), Some(AlignSide::Left));
assert_eq!(AlignSide::from_keyword("alignright"), Some(AlignSide::Right));
assert_eq!(AlignSide::from_keyword("ALIGN-LEFT"), Some(AlignSide::Left));
assert_eq!(AlignSide::from_keyword("AlignRight"), Some(AlignSide::Right));
assert_eq!(AlignSide::from_keyword(""), None);
}
#[test]
fn test_align_side_from_keyword_bare_directional() {
assert_eq!(AlignSide::from_keyword("left"), Some(AlignSide::Left));
assert_eq!(AlignSide::from_keyword("right"), Some(AlignSide::Right));
assert_eq!(AlignSide::from_keyword("LEFT"), Some(AlignSide::Left));
assert_eq!(AlignSide::from_keyword("Right"), Some(AlignSide::Right));
}
#[test]
fn test_parse_attrs_bare_left_is_position() {
let attrs = parse_media_attrs("left");
assert_eq!(attrs.position, Some(Position::Left));
assert_eq!(attrs.align, None);
let attrs = parse_media_attrs("right");
assert_eq!(attrs.position, Some(Position::Right));
assert_eq!(attrs.align, None);
}
#[test]
fn test_align_side_css_class() {
assert_eq!(AlignSide::Left.css_class(), "moss-align-left");
assert_eq!(AlignSide::Right.css_class(), "moss-align-right");
}
#[test]
fn test_position_to_css_value() {
assert_eq!(Position::Center.to_css_value(), "center");
assert_eq!(Position::Left.to_css_value(), "left");
assert_eq!(Position::Right.to_css_value(), "right");
assert_eq!(Position::Top.to_css_value(), "top");
assert_eq!(Position::Bottom.to_css_value(), "bottom");
assert_eq!(Position::TopLeft.to_css_value(), "top left");
assert_eq!(Position::TopRight.to_css_value(), "top right");
assert_eq!(Position::BottomLeft.to_css_value(), "bottom left");
assert_eq!(Position::BottomRight.to_css_value(), "bottom right");
}
#[test]
fn test_position_from_keyword_single() {
assert_eq!(Position::from_keyword("center"), Some(Position::Center));
assert_eq!(Position::from_keyword("left"), Some(Position::Left));
assert_eq!(Position::from_keyword("right"), Some(Position::Right));
assert_eq!(Position::from_keyword("top"), Some(Position::Top));
assert_eq!(Position::from_keyword("bottom"), Some(Position::Bottom));
}
#[test]
fn test_position_from_keyword_compound() {
assert_eq!(Position::from_keyword("top-left"), Some(Position::TopLeft));
assert_eq!(Position::from_keyword("top-right"), Some(Position::TopRight));
assert_eq!(Position::from_keyword("bottom-left"), Some(Position::BottomLeft));
assert_eq!(Position::from_keyword("bottom-right"), Some(Position::BottomRight));
assert_eq!(Position::from_keyword("topleft"), Some(Position::TopLeft));
assert_eq!(Position::from_keyword("bottomright"), Some(Position::BottomRight));
assert_eq!(Position::from_keyword("top left"), Some(Position::TopLeft));
assert_eq!(Position::from_keyword("bottom right"), Some(Position::BottomRight));
}
#[test]
fn test_position_from_keyword_case_insensitive() {
assert_eq!(Position::from_keyword("CENTER"), Some(Position::Center));
assert_eq!(Position::from_keyword("Top-Left"), Some(Position::TopLeft));
assert_eq!(Position::from_keyword("BOTTOMRIGHT"), Some(Position::BottomRight));
}
#[test]
fn test_position_from_keyword_unknown() {
assert_eq!(Position::from_keyword("middle"), None);
assert_eq!(Position::from_keyword(""), None);
}
#[test]
fn test_media_attrs_is_empty() {
let empty = MediaAttrs {
fit: None,
position: None,
align: None,
class_names: Vec::new(),
extra_attrs: BTreeMap::new(),
};
assert!(empty.is_empty());
let with_fit = MediaAttrs {
fit: Some(Fit::Cover),
position: None,
align: None,
class_names: Vec::new(),
extra_attrs: BTreeMap::new(),
};
assert!(!with_fit.is_empty());
let with_pos = MediaAttrs {
fit: None,
position: Some(Position::Center),
align: None,
class_names: Vec::new(),
extra_attrs: BTreeMap::new(),
};
assert!(!with_pos.is_empty());
}
#[test]
fn test_to_inline_style_empty() {
let attrs = MediaAttrs {
fit: None,
position: None,
align: None,
class_names: Vec::new(),
extra_attrs: BTreeMap::new(),
};
assert_eq!(attrs.to_inline_style(), None);
}
#[test]
fn test_to_inline_style_fit_only() {
let attrs = MediaAttrs {
fit: Some(Fit::Contain),
position: None,
align: None,
class_names: Vec::new(),
extra_attrs: BTreeMap::new(),
};
assert_eq!(attrs.to_inline_style(), Some("object-fit:contain".into()));
}
#[test]
fn test_to_inline_style_position_only() {
let attrs = MediaAttrs {
fit: None,
position: Some(Position::Left),
align: None,
class_names: Vec::new(),
extra_attrs: BTreeMap::new(),
};
assert_eq!(
attrs.to_inline_style(),
Some("object-position:left".into())
);
}
#[test]
fn test_to_inline_style_both() {
let attrs = MediaAttrs {
fit: Some(Fit::Cover),
position: Some(Position::TopLeft),
align: None,
class_names: Vec::new(),
extra_attrs: BTreeMap::new(),
};
assert_eq!(
attrs.to_inline_style(),
Some("object-fit:cover;object-position:top left".into())
);
}
#[test]
fn test_strip_wikilink_with_brackets() {
assert_eq!(strip_wikilink("[[photo.jpg]]"), "photo.jpg");
assert_eq!(strip_wikilink("[[path/to/image.png]]"), "path/to/image.png");
}
#[test]
fn test_strip_wikilink_without_brackets() {
assert_eq!(strip_wikilink("photo.jpg"), "photo.jpg");
assert_eq!(strip_wikilink("path/to/image.png"), "path/to/image.png");
}
#[test]
fn test_strip_wikilink_with_pipe() {
assert_eq!(strip_wikilink("[[photo.jpg|cover]]"), "photo.jpg|cover");
}
#[test]
fn test_strip_wikilink_with_whitespace() {
assert_eq!(strip_wikilink(" [[photo.jpg]] "), "photo.jpg");
}
#[test]
fn test_strip_wikilink_partial_brackets() {
assert_eq!(strip_wikilink("[[photo.jpg"), "[[photo.jpg");
assert_eq!(strip_wikilink("photo.jpg]]"), "photo.jpg]]");
}
#[test]
fn test_strip_wikilink_empty() {
assert_eq!(strip_wikilink("[[]]"), "");
assert_eq!(strip_wikilink(""), "");
}
#[test]
fn test_split_pipe_with_pipe() {
assert_eq!(split_pipe("photo.jpg|cover"), ("photo.jpg", "cover"));
assert_eq!(
split_pipe("path/to/img.png|contain center"),
("path/to/img.png", "contain center")
);
}
#[test]
fn test_split_pipe_no_pipe() {
assert_eq!(split_pipe("photo.jpg"), ("photo.jpg", ""));
assert_eq!(split_pipe(""), ("", ""));
}
#[test]
fn test_split_pipe_multiple_pipes() {
assert_eq!(split_pipe("a|b|c"), ("a", "b|c"));
}
#[test]
fn test_split_pipe_pipe_at_edges() {
assert_eq!(split_pipe("|cover"), ("", "cover"));
assert_eq!(split_pipe("photo.jpg|"), ("photo.jpg", ""));
}
#[test]
fn test_parse_attrs_fit_only() {
let attrs = parse_media_attrs("cover");
assert_eq!(attrs.fit, Some(Fit::Cover));
assert_eq!(attrs.position, None);
}
#[test]
fn test_parse_attrs_position_only() {
let attrs = parse_media_attrs("center");
assert_eq!(attrs.fit, None);
assert_eq!(attrs.position, Some(Position::Center));
}
#[test]
fn test_parse_attrs_fit_and_position() {
let attrs = parse_media_attrs("contain left");
assert_eq!(attrs.fit, Some(Fit::Contain));
assert_eq!(attrs.position, Some(Position::Left));
}
#[test]
fn test_parse_attrs_two_word_position() {
let attrs = parse_media_attrs("top left");
assert_eq!(attrs.fit, None);
assert_eq!(attrs.position, Some(Position::TopLeft));
let attrs2 = parse_media_attrs("cover bottom right");
assert_eq!(attrs2.fit, Some(Fit::Cover));
assert_eq!(attrs2.position, Some(Position::BottomRight));
}
#[test]
fn test_parse_attrs_hyphenated_compound_position() {
let attrs = parse_media_attrs("top-right");
assert_eq!(attrs.fit, None);
assert_eq!(attrs.position, Some(Position::TopRight));
let attrs2 = parse_media_attrs("fill bottom-left");
assert_eq!(attrs2.fit, Some(Fit::Fill));
assert_eq!(attrs2.position, Some(Position::BottomLeft));
}
#[test]
fn test_parse_attrs_unknown_tokens_ignored() {
let attrs = parse_media_attrs("cover unknown-token left");
assert_eq!(attrs.fit, Some(Fit::Cover));
assert_eq!(attrs.position, Some(Position::Left));
}
#[test]
fn test_parse_attrs_empty_string() {
let attrs = parse_media_attrs("");
assert!(attrs.is_empty());
}
#[test]
fn test_parse_attrs_only_whitespace() {
let attrs = parse_media_attrs(" ");
assert!(attrs.is_empty());
}
#[test]
fn test_parse_attrs_all_unknown() {
let attrs = parse_media_attrs("foo bar baz");
assert!(attrs.is_empty());
}
#[test]
fn test_parse_attrs_case_insensitive() {
let attrs = parse_media_attrs("COVER CENTER");
assert_eq!(attrs.fit, Some(Fit::Cover));
assert_eq!(attrs.position, Some(Position::Center));
}
#[test]
fn test_parse_attrs_last_wins_for_duplicates() {
let attrs = parse_media_attrs("cover contain");
assert_eq!(attrs.fit, Some(Fit::Contain));
}
#[test]
fn test_parse_attrs_scale_down() {
let attrs = parse_media_attrs("scale-down");
assert_eq!(attrs.fit, Some(Fit::ScaleDown));
}
fn sample_graph() -> ContentGraph {
let mut b = ContentGraphBuilder::new();
b.add_file("images/photo.jpg", "images/photo");
b.add_file("assets/banner.png", "assets/banner");
b.add_file("posts/hello.md", "posts/hello");
b.build()
}
#[test]
fn test_resolve_simple_path() {
let graph = sample_graph();
let result = resolve_media_ref("photo.jpg", "posts/hello.md", &graph);
assert_eq!(result.path, "images/photo.jpg");
assert!(result.attrs.is_empty());
}
#[test]
fn test_resolve_with_attrs() {
let graph = sample_graph();
let result = resolve_media_ref("photo.jpg|cover center", "posts/hello.md", &graph);
assert_eq!(result.path, "images/photo.jpg");
assert_eq!(result.attrs.fit, Some(Fit::Cover));
assert_eq!(result.attrs.position, Some(Position::Center));
}
#[test]
fn test_resolve_wikilink() {
let graph = sample_graph();
let result = resolve_media_ref("[[photo.jpg|contain]]", "posts/hello.md", &graph);
assert_eq!(result.path, "images/photo.jpg");
assert_eq!(result.attrs.fit, Some(Fit::Contain));
}
#[test]
fn test_resolve_wikilink_no_attrs() {
let graph = sample_graph();
let result = resolve_media_ref("[[photo.jpg]]", "posts/hello.md", &graph);
assert_eq!(result.path, "images/photo.jpg");
assert!(result.attrs.is_empty());
}
#[test]
fn test_resolve_external_http() {
let graph = sample_graph();
let result = resolve_media_ref(
"https://example.com/img.jpg|cover",
"posts/hello.md",
&graph,
);
assert_eq!(result.path, "https://example.com/img.jpg");
assert_eq!(result.attrs.fit, Some(Fit::Cover));
}
#[test]
fn test_resolve_external_protocol_relative() {
let graph = sample_graph();
let result = resolve_media_ref("//cdn.example.com/img.jpg", "posts/hello.md", &graph);
assert_eq!(result.path, "//cdn.example.com/img.jpg");
}
#[test]
fn test_resolve_external_data_uri() {
let graph = sample_graph();
let result = resolve_media_ref("data:image/png;base64,abc", "posts/hello.md", &graph);
assert_eq!(result.path, "data:image/png;base64,abc");
}
#[test]
fn test_resolve_root_relative() {
let graph = sample_graph();
let result = resolve_media_ref("/images/photo.jpg|fill", "posts/hello.md", &graph);
assert_eq!(result.path, "images/photo.jpg");
assert_eq!(result.attrs.fit, Some(Fit::Fill));
}
#[test]
fn test_resolve_unresolved_fallback() {
let graph = sample_graph();
let result = resolve_media_ref("missing.jpg", "posts/hello.md", &graph);
assert_eq!(result.path, "missing.jpg");
assert!(result.attrs.is_empty());
}
#[test]
fn test_resolve_wikilink_with_two_word_position() {
let graph = sample_graph();
let result =
resolve_media_ref("[[banner.png|cover top left]]", "posts/hello.md", &graph);
assert_eq!(result.path, "assets/banner.png");
assert_eq!(result.attrs.fit, Some(Fit::Cover));
assert_eq!(result.attrs.position, Some(Position::TopLeft));
}
#[test]
fn test_resolve_external_in_wikilink() {
let graph = sample_graph();
let result = resolve_media_ref(
"[[https://example.com/img.jpg|contain]]",
"posts/hello.md",
&graph,
);
assert_eq!(result.path, "https://example.com/img.jpg");
assert_eq!(result.attrs.fit, Some(Fit::Contain));
}
#[test]
fn test_resolve_path_with_spaces_trimmed() {
let graph = sample_graph();
let result = resolve_media_ref(" photo.jpg | cover ", "posts/hello.md", &graph);
assert_eq!(result.path, "images/photo.jpg");
assert_eq!(result.attrs.fit, Some(Fit::Cover));
}
#[test]
fn test_is_all_display_keywords_positions() {
assert!(is_all_display_keywords("left"));
assert!(is_all_display_keywords("right"));
assert!(is_all_display_keywords("center"));
assert!(is_all_display_keywords("top"));
assert!(is_all_display_keywords("bottom"));
assert!(is_all_display_keywords("top left"));
assert!(is_all_display_keywords("bottom right"));
}
#[test]
fn test_is_all_display_keywords_fits() {
assert!(is_all_display_keywords("cover"));
assert!(is_all_display_keywords("contain"));
assert!(is_all_display_keywords("fill"));
assert!(is_all_display_keywords("none"));
assert!(is_all_display_keywords("scale-down"));
}
#[test]
fn test_is_all_display_keywords_combined() {
assert!(is_all_display_keywords("contain left"));
assert!(is_all_display_keywords("cover top left"));
assert!(is_all_display_keywords("cover top-right"));
assert!(is_all_display_keywords("scale-down bottom-left"));
}
#[test]
fn test_is_all_display_keywords_rejects_non_keywords() {
assert!(!is_all_display_keywords("A beautiful sunset"));
assert!(!is_all_display_keywords("left side"));
assert!(!is_all_display_keywords(""));
assert!(!is_all_display_keywords(" "));
}
#[test]
fn test_html_escape_basic() {
assert_eq!(html_escape("hello"), "hello");
assert_eq!(html_escape("a&b"), "a&b");
assert_eq!(html_escape("a\"b"), "a"b");
assert_eq!(html_escape("a'b"), "a'b");
assert_eq!(html_escape("a<b>c"), "a<b>c");
assert_eq!(
html_escape("<div class=\"x\">&'</div>"),
"<div class="x">&'</div>"
);
}
#[test]
fn test_parse_media_attrs_align_alone() {
let attrs = parse_media_attrs("align-left");
assert_eq!(attrs.align, Some(AlignSide::Left));
assert_eq!(attrs.fit, None);
assert_eq!(attrs.position, None);
}
#[test]
fn test_parse_media_attrs_align_with_cover() {
let a = parse_media_attrs("cover align-right");
assert_eq!(a.fit, Some(Fit::Cover));
assert_eq!(a.align, Some(AlignSide::Right));
let b = parse_media_attrs("align-right cover");
assert_eq!(b, a);
}
#[test]
fn test_parse_media_attrs_align_last_wins() {
let attrs = parse_media_attrs("align-left align-right");
assert_eq!(attrs.align, Some(AlignSide::Right));
let attrs = parse_media_attrs("align-right align-left");
assert_eq!(attrs.align, Some(AlignSide::Left));
}
#[test]
fn test_is_all_display_keywords_align() {
assert!(is_all_display_keywords("align-left"));
assert!(is_all_display_keywords("align-right"));
assert!(is_all_display_keywords("cover align-left"));
assert!(is_all_display_keywords("align-left cover"));
assert!(is_all_display_keywords("align-left top"));
}
#[test]
fn test_match_width_token_recognized() {
assert_eq!(match_width_token("body"), Some("body"));
assert_eq!(match_width_token("wide"), Some("wide"));
assert_eq!(match_width_token("page"), Some("page"));
assert_eq!(match_width_token("screen"), Some("screen"));
assert_eq!(match_width_token("full"), Some("screen"));
}
#[test]
fn test_match_width_token_rejects_non_width() {
assert_eq!(match_width_token(""), None);
assert_eq!(match_width_token("BODY"), None);
assert_eq!(match_width_token("widely"), None);
assert_eq!(match_width_token("wide angle"), None);
assert_eq!(match_width_token("contain"), None);
assert_eq!(match_width_token("left"), None);
}
#[test]
fn test_extract_width_from_alias_single_segment_width() {
let (w, rest) = extract_width_from_alias("full");
assert_eq!(w, Some("screen"));
assert_eq!(rest, "");
}
#[test]
fn test_extract_width_from_alias_caption_only() {
let (w, rest) = extract_width_from_alias("A beautiful sunset");
assert_eq!(w, None);
assert_eq!(rest, "A beautiful sunset");
}
#[test]
fn test_extract_width_from_alias_caption_then_width() {
let (w, rest) = extract_width_from_alias("A nice photo|full");
assert_eq!(w, Some("screen"));
assert_eq!(rest, "A nice photo");
}
#[test]
fn test_extract_width_from_alias_width_then_caption() {
let (w, rest) = extract_width_from_alias("wide|A nice photo");
assert_eq!(w, Some("wide"));
assert_eq!(rest, "A nice photo");
}
#[test]
fn test_extract_width_from_alias_caption_with_width_word_not_shadowed() {
let (w, rest) = extract_width_from_alias("caption that says wide");
assert_eq!(w, None);
assert_eq!(rest, "caption that says wide");
}
#[test]
fn test_extract_width_from_alias_only_first_width_extracted() {
let (w, rest) = extract_width_from_alias("full|wide");
assert_eq!(w, Some("screen"));
assert_eq!(rest, "wide");
}
#[test]
fn test_extract_width_from_alias_segment_whitespace_trimmed() {
let (w, rest) = extract_width_from_alias("caption | full");
assert_eq!(w, Some("screen"));
assert_eq!(rest, "caption ");
}
#[test]
fn test_media_attrs_class_names_preserved() {
let attrs = MediaAttrs {
fit: None,
position: None,
align: None,
class_names: vec!["theme-rounded".to_string(), "shadow-lg".to_string()],
extra_attrs: BTreeMap::new(),
};
assert!(!attrs.is_empty());
assert_eq!(
attrs.class_attr(),
Some("theme-rounded shadow-lg".to_string())
);
}
#[test]
fn test_media_attrs_class_names_compose_with_align() {
let attrs = MediaAttrs {
fit: None,
position: None,
align: Some(AlignSide::Left),
class_names: vec!["theme-rounded".to_string()],
extra_attrs: BTreeMap::new(),
};
assert_eq!(
attrs.class_attr(),
Some("moss-align-left theme-rounded".to_string())
);
}
#[test]
fn test_media_attrs_extra_attrs_non_empty() {
let mut extras = BTreeMap::new();
extras.insert("data-zoom".to_string(), "true".to_string());
extras.insert("data-id".to_string(), "42".to_string());
let attrs = MediaAttrs {
fit: None,
position: None,
align: None,
class_names: vec![],
extra_attrs: extras,
};
assert!(!attrs.is_empty());
let keys: Vec<&str> = attrs.extra_attrs.keys().map(String::as_str).collect();
assert_eq!(keys, vec!["data-id", "data-zoom"]);
}
#[test]
fn test_classify_image_alias_none() {
let c = classify_image_alias(None);
assert_eq!(c.display_keywords, None);
assert_eq!(c.caption, None);
}
#[test]
fn test_classify_image_alias_empty_is_none_never_some_empty() {
let c = classify_image_alias(Some(""));
assert_eq!(c.display_keywords, None);
assert_eq!(c.caption, None);
}
#[test]
fn test_classify_image_alias_structural_single_keyword() {
let c = classify_image_alias(Some("cover"));
assert_eq!(c.display_keywords.as_deref(), Some("cover"));
assert_eq!(c.caption, None);
}
#[test]
fn test_classify_image_alias_structural_compound() {
let c = classify_image_alias(Some("wide cover"));
assert_eq!(c.display_keywords.as_deref(), Some("wide cover"));
assert_eq!(c.caption, None);
}
#[test]
fn test_classify_image_alias_pure_width_token_is_structural() {
let c = classify_image_alias(Some("wide"));
assert_eq!(c.display_keywords.as_deref(), Some("wide"));
assert_eq!(c.caption, None);
}
#[test]
fn test_classify_image_alias_caption_text() {
let c = classify_image_alias(Some("My nice photo"));
assert_eq!(c.display_keywords, None);
assert_eq!(c.caption.as_deref(), Some("My nice photo"));
}
#[test]
fn test_is_structural_alias_matches_classifier() {
assert!(is_structural_alias("cover"));
assert!(is_structural_alias("wide cover"));
assert!(is_structural_alias("top left"));
assert!(!is_structural_alias("My nice photo"));
assert!(!is_structural_alias(""));
}
}