Skip to main content

moss_core/
media.rs

1//! Unified media reference resolution and display attributes.
2//!
3//! All media reference contexts in moss (frontmatter cover, hero, gallery,
4//! inline images, wikilink embeds) call into this module. It parses pipe-
5//! separated display attributes (`object-fit`, `object-position`) and
6//! resolves paths via the [`ContentGraph`].
7//!
8//! Pure Rust, zero I/O.
9
10use std::collections::BTreeMap;
11
12use crate::content_graph::ContentGraph;
13
14// ---------------------------------------------------------------------------
15// Fit — maps to CSS `object-fit`
16// ---------------------------------------------------------------------------
17
18/// CSS `object-fit` values for media display.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Fit {
21    Cover,
22    Contain,
23    Fill,
24    None,
25    ScaleDown,
26}
27
28impl Fit {
29    /// Return the CSS `object-fit` value.
30    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    /// Parse from a keyword string (case-insensitive).
41    ///
42    /// Accepts both CSS syntax (`"scale-down"`) and space-free forms (`"scaledown"`).
43    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// ---------------------------------------------------------------------------
56// Position — maps to CSS `object-position`
57// ---------------------------------------------------------------------------
58
59/// CSS `object-position` values for media display.
60#[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    /// Return the CSS `object-position` value.
75    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    /// Parse from a keyword string (case-insensitive).
90    ///
91    /// Accepts hyphenated (`"top-left"`), concatenated (`"topleft"`), and
92    /// space-separated (`"top left"`) forms.
93    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// ---------------------------------------------------------------------------
110// AlignSide — editorial runaround alignment (text wraps around half-width image)
111// ---------------------------------------------------------------------------
112
113/// Image alignment for editorial runaround layout. Mirrors WordPress's
114/// `alignleft` / `alignright` block-editor convention; the moss CSS class
115/// is `moss-align-left` / `moss-align-right`. Float behavior plus mobile
116/// collapse (≤48rem) live in `src-tauri/src/assets/css/site.css`.
117///
118/// Hyphenated `align-left` is the canonical pipe-keyword form; unhyphenated
119/// `alignleft` (matching the WP class name) is a forgiveness alias.
120/// Bare `left` / `right` are also accepted, because Stage 1 emits them as
121/// the value of an explicit `align=` key in TitleParams (e.g. `align=left`),
122/// where ambiguity with [`Position`]'s `left` / `right` does not arise.
123///
124/// Note: in [`parse_media_attrs`]'s space-separated keyword parser, bare
125/// `left` / `right` still match [`Position::from_keyword`] FIRST and never
126/// reach this function, so the disambiguation rule for the pipe-keyword
127/// layer is preserved.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum AlignSide {
130    Left,
131    Right,
132}
133
134impl AlignSide {
135    /// Parse from a keyword string (case-insensitive).
136    ///
137    /// Accepts:
138    /// - hyphenated `align-left` / `align-right` (canonical pipe keyword)
139    /// - concatenated `alignleft` / `alignright` (WordPress class alias)
140    /// - bare `left` / `right` (Stage 1 TitleParams `align=` value)
141    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    /// CSS class name emitted on the `<img>` (and escalated to the
150    /// wrapping `<figure>` via `:has()` in site.css). Kept in lockstep
151    /// with the entries in `crate::contract::components::COMPONENTS`.
152    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// ---------------------------------------------------------------------------
161// MediaAttrs
162// ---------------------------------------------------------------------------
163
164/// Parsed display attributes for a media reference.
165///
166/// In addition to moss's recognized vocabulary (`fit` / `position` / `align`),
167/// `class_names` and `extra_attrs` carry author-provided passthroughs from
168/// Pandoc attribute blocks (`{.theme-rounded key=value}`). The moss-vocabulary
169/// fields map to typed enums and `moss-*` classes / inline style; the
170/// passthrough fields flow through to the emitted HTML unmodified (classes
171/// joined as a space-separated list, extras as additional attributes in
172/// deterministic alphabetical order).
173///
174/// See `docs/architecture/unified-image-emission.md` Decision #10.
175#[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    /// Author-provided class names that aren't in moss's recognized
181    /// vocabulary (`.align-left` / `.alignleft` get folded into `align`
182    /// upstream; everything else lands here). Joined with spaces by
183    /// [`Self::class_attr`] after any `moss-*` class from `css_class()`.
184    pub class_names: Vec<String>,
185    /// Author-provided `key=value` attributes from Pandoc attribute blocks
186    /// that aren't recognized moss vocabulary. Emitted as title-params
187    /// (`![alt](src "moss:k=v")`) by the wikilink Stage 1 translator in
188    /// deterministic alphabetical order (BTreeMap iteration is sorted).
189    pub extra_attrs: BTreeMap<String, String>,
190}
191
192impl MediaAttrs {
193    /// True when no display attributes or passthroughs are set.
194    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    /// Build an inline CSS style string, or `None` if empty.
203    ///
204    /// Example output: `"object-fit:contain;object-position:left"`.
205    /// `align` does NOT contribute — it emits as a class (see [`Self::css_class`]).
206    /// `class_names` and `extra_attrs` are also out of style: classes ride on
207    /// the `class` attribute, extras ride on their own attribute slots.
208    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    /// CSS class name for the moss-recognized vocabulary, or `None` if no
224    /// class-bearing attribute is set. Today only `align` produces a class;
225    /// future class-bearing attributes can extend this method.
226    ///
227    /// This is the moss-prefixed half — see [`Self::class_attr`] for the
228    /// merged value that includes author-provided `class_names`.
229    pub fn css_class(&self) -> Option<&'static str> {
230        self.align.map(AlignSide::css_class)
231    }
232
233    /// Build the full `class` attribute value, merging the moss-vocabulary
234    /// class (from [`Self::css_class`]) with author-provided `class_names`.
235    /// Returns `None` if both sources are empty.
236    ///
237    /// Order: moss-vocabulary class first (e.g. `moss-align-left`), then
238    /// `class_names` in author-provided order. Both halves are joined with a
239    /// single space.
240    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// ---------------------------------------------------------------------------
257// ResolvedMedia
258// ---------------------------------------------------------------------------
259
260/// A fully resolved media reference: path + display attributes.
261/// Not yet consumed outside tests — kept `pub(crate)` until a real caller exists.
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub(crate) struct ResolvedMedia {
264    /// Root-relative path (no leading `/`) or external URL.
265    pub path: String,
266    /// Parsed display attributes.
267    pub attrs: MediaAttrs,
268}
269
270// ---------------------------------------------------------------------------
271// Parsing functions
272// ---------------------------------------------------------------------------
273
274/// Strip `[[` and `]]` brackets from a wikilink reference, if present.
275///
276/// Returns the inner text. If brackets are not present, returns the input
277/// unchanged.
278pub 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
286/// Split a media reference on the first `|`, returning `(path, attrs_str)`.
287///
288/// If there is no `|`, `attrs_str` is an empty string.
289pub fn split_pipe(raw: &str) -> (&str, &str) {
290    raw.split_once('|').unwrap_or((raw, ""))
291}
292
293/// Parse space-separated display-attribute keywords from the portion after `|`.
294///
295/// Recognized keywords map to [`Fit`] and [`Position`] variants.
296/// Unknown tokens are silently ignored (callers may add diagnostic reporting).
297///
298/// Two-word position keywords like `"top left"` are handled: if a bare
299/// directional keyword (`top`, `bottom`) is followed by another (`left`,
300/// `right`), they are combined.
301pub 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        // Try combining with next token for two-word positions.
313        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        // Single-token fit.
323        if let Some(f) = Fit::from_keyword(token) {
324            fit = Some(f);
325            i += 1;
326            continue;
327        }
328
329        // Single-token position.
330        if let Some(pos) = Position::from_keyword(token) {
331            position = Some(pos);
332            i += 1;
333            continue;
334        }
335
336        // Single-token align (editorial runaround: align-left / align-right).
337        if let Some(side) = AlignSide::from_keyword(token) {
338            align = Some(side);
339            i += 1;
340            continue;
341        }
342
343        // Unknown token — skip.
344        i += 1;
345    }
346
347    MediaAttrs {
348        fit,
349        position,
350        align,
351        ..Default::default()
352    }
353}
354
355/// Recognize the spec § P9 width tokens (`body | wide | page | screen | full`).
356///
357/// `full` is the author-facing alias for `screen` — both at the fenced-div
358/// AttrBlock layer (see [`crate::ast::attrs::match_width_token`]) and here at
359/// the wikilink pipe-alias layer. The returned `&'static str` is the
360/// canonical value-space term emitted as `data-width="..."`.
361///
362/// The check is exact-match on the full input (case-sensitive ASCII): a string
363/// like `"wide screen"` returns `None` so that multi-word captions like
364/// `![[img|wide angle shot]]` are not silently classified as a width hint.
365/// Callers that handle multi-pipe wikilink aliases should split on `|` and
366/// call this on each trimmed segment individually.
367pub 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
377/// Parse a wikilink alias for an embedded width token plus the remaining
378/// alias content.
379///
380/// The wikilink parser (`parse_wikilink_inner`) splits on the first `|` only,
381/// so when an author writes `![[img|caption|full]]`, the resulting `alias`
382/// string is `"caption|full"`. This helper splits the alias on `|` and pulls
383/// out a bare width-token segment (per [`match_width_token`]) without
384/// reordering the others. The remaining segments are rejoined with `|`.
385///
386/// Returns `(width, remaining_alias)`:
387///
388/// - `width = Some("body|wide|page|screen")` if exactly one segment matched
389///   a width token (per the "entire alias-segment is exactly one of the
390///   tokens" rule). Width tokens never shadow longer captions.
391/// - `remaining_alias` is the trimmed concatenation of non-width segments,
392///   joined with `|`. Empty if the only segment was the width token.
393///
394/// If no width token is found, returns `(None, alias.to_string())` — the
395/// caller falls through to its existing alias handling.
396pub 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
415/// Return `true` if every token in `text` is a recognized display keyword.
416///
417/// Handles single-token keywords (`"left"`, `"contain"`) and two-word position
418/// keywords (`"top left"`).  An empty string returns `false`.
419pub 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        // Try combining current token with next for two-word positions.
428        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
457/// True when every whitespace-separated token in `alias` is either a
458/// recognized display keyword (fit / position / align) OR a canonical
459/// width token (body / wide / page / screen / full).
460///
461/// This is the structural-vs-caption classifier for image aliases: a
462/// fully-structural alias contributes only to display params; anything else
463/// becomes caption / alt text. The [`is_all_display_keywords`] half is
464/// unchanged (covers two-word position tokens like `top left`); the
465/// width-token half lets authors write `align-left wide` without breaking
466/// the pipe.
467///
468/// Lifted from `resolve::embed_renderer` (Phase 1 of the image-embed
469/// synth-collapse) so it survives `ImageRenderer`'s deletion — it is the
470/// load-bearing half of [`classify_image_alias`].
471pub(crate) fn is_structural_alias(alias: &str) -> bool {
472    // Fast path: any caption-like text fails `is_all_display_keywords`
473    // and would also fail the per-token loop below.
474    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    // Walk tokens; admit width tokens, otherwise defer to display-keyword
482    // recognition (per-token, since position tokens may pair across two).
483    let mut i = 0;
484    while i < tokens.len() {
485        // Width token: single-token, simple admit.
486        if match_width_token(tokens[i]).is_some() {
487            i += 1;
488            continue;
489        }
490        // Two-word position (e.g. `top left`).
491        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        // Single-token display keyword.
499        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/// Classification of an image-embed pipe alias into its display-vs-caption
512/// role.
513///
514/// The pipe alias of `![[photo.jpg|<alias>]]` is one of three things:
515/// a run of structural display keywords (`cover`, `wide cover`), human-
516/// readable caption prose (`My nice photo`), or absent/empty. This struct
517/// captures the disambiguation so every image-embed call site classifies
518/// identically.
519#[derive(Debug, Clone, PartialEq, Eq)]
520pub(crate) struct ImageAliasClass {
521    /// Structural display-keyword run (e.g. `"cover"`, `"wide cover"`) to be
522    /// fed to `parse_media_attrs`; `None` when the alias is a caption or
523    /// empty.
524    pub display_keywords: Option<String>,
525    /// Caption text (also used as `alt`) when the alias is human-readable
526    /// prose; `None` for structural/empty aliases.
527    ///
528    /// **Invariant:** never `Some("")`. An empty alias yields `None` so
529    /// callers never emit an empty `<figcaption>`.
530    pub caption: Option<String>,
531}
532
533/// Classify an image-embed pipe alias into [`ImageAliasClass`].
534///
535/// Mirrors the 3-way split previously inlined in
536/// `ImageRenderer::render_to_markdown` (now lifted so it survives that
537/// struct's deletion in the image-embed synth-collapse):
538///
539/// - `None`                       → both `None`
540/// - `Some("")` (empty)           → both `None` (no empty figcaption)
541/// - `Some(s)` and structural     → `display_keywords = Some(s)`, `caption = None`
542/// - `Some(other)`                → `display_keywords = None`, `caption = Some(other)`
543pub(crate) fn classify_image_alias(alias: Option<&str>) -> ImageAliasClass {
544    match alias {
545        // Empty alias (`![[file|]]`) is treated as no alias. Matches the
546        // historical `alias.is_empty()` guard exactly (no extra trimming).
547        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
566/// Escape a string for safe use in HTML text or attribute values.
567///
568/// Replaces `&`, `"`, `'`, `<`, and `>` with their HTML entities.
569pub 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("&amp;"),
574            '"' => out.push_str("&quot;"),
575            '\'' => out.push_str("&#39;"),
576            '<' => out.push_str("&lt;"),
577            '>' => out.push_str("&gt;"),
578            _ => out.push(ch),
579        }
580    }
581    out
582}
583
584// ---------------------------------------------------------------------------
585// Resolution
586// ---------------------------------------------------------------------------
587
588/// Returns `true` if the path looks like an external URL or data URI.
589fn 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
596/// Full pipeline: strip wikilink → split pipe → resolve path → parse attrs.
597///
598/// - External URLs (`http://`, `https://`, `//`, `data:`) pass through unchanged.
599/// - Root-relative paths (leading `/`) have the slash stripped.
600/// - Everything else is resolved via [`ContentGraph::resolve_path`], falling
601///   back to the raw path if unresolved.
602pub(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        // External URL — passthrough.
610        path_trimmed.to_string()
611    } else if let Some(stripped) = path_trimmed.strip_prefix('/') {
612        // Root-relative — strip leading slash.
613        stripped.to_string()
614    } else {
615        // Resolve via content graph, fall back to raw path.
616        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// ---------------------------------------------------------------------------
628// Tests
629// ---------------------------------------------------------------------------
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634    use crate::content_graph::ContentGraphBuilder;
635
636    // -- Fit ----------------------------------------------------------------
637
638    #[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); // trailing space — not trimmed
670    }
671
672    // -- AlignSide ----------------------------------------------------------
673
674    #[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        // WordPress-style unhyphenated alias.
679        assert_eq!(AlignSide::from_keyword("alignleft"), Some(AlignSide::Left));
680        assert_eq!(AlignSide::from_keyword("alignright"), Some(AlignSide::Right));
681        // Case-insensitive.
682        assert_eq!(AlignSide::from_keyword("ALIGN-LEFT"), Some(AlignSide::Left));
683        assert_eq!(AlignSide::from_keyword("AlignRight"), Some(AlignSide::Right));
684        // Empty input never matches.
685        assert_eq!(AlignSide::from_keyword(""), None);
686    }
687
688    #[test]
689    fn test_align_side_from_keyword_bare_directional() {
690        // Bare `left` / `right` are accepted because Stage 1 emits them as
691        // the value of an explicit `align=` key (TitleParams), where the
692        // key disambiguates from Position context. The existing pipe-
693        // keyword space-separated parser (`parse_media_attrs`) still tries
694        // Position::from_keyword first and never reaches AlignSide for
695        // bare directionals — see test_parse_attrs_bare_left_is_position.
696        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        // In the pipe-keyword (`![[img|cover left]]`) parser, bare `left`
705        // / `right` resolve as Position (object-position keyword), NOT as
706        // AlignSide. Position::from_keyword is tried first in
707        // `parse_media_attrs`; this test pins that ordering invariant so
708        // a future refactor that re-orders the matchers will fail loudly.
709        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    // -- Position -----------------------------------------------------------
725
726    #[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        // Hyphenated
751        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        // Concatenated
757        assert_eq!(Position::from_keyword("topleft"), Some(Position::TopLeft));
758        assert_eq!(Position::from_keyword("bottomright"), Some(Position::BottomRight));
759
760        // Space-separated (used when caller pre-joins tokens)
761        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    // -- MediaAttrs ---------------------------------------------------------
779
780    #[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    // -- strip_wikilink -----------------------------------------------------
865
866    #[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        // Only opening bracket — no stripping.
891        assert_eq!(strip_wikilink("[[photo.jpg"), "[[photo.jpg");
892        // Only closing bracket — no stripping.
893        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    // -- split_pipe ---------------------------------------------------------
903
904    #[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        // Only split on the first pipe.
922        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    // -- parse_media_attrs --------------------------------------------------
932
933    #[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        // If multiple fit keywords appear, the last one wins.
1011        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    // -- resolve_media_ref --------------------------------------------------
1022
1023    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        // ContentGraph returns None → fallback to raw path.
1103        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    // -- is_all_display_keywords -------------------------------------------
1138
1139    #[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    // -- html_escape --------------------------------------------------
1176
1177    #[test]
1178    fn test_html_escape_basic() {
1179        assert_eq!(html_escape("hello"), "hello");
1180        assert_eq!(html_escape("a&b"), "a&amp;b");
1181        assert_eq!(html_escape("a\"b"), "a&quot;b");
1182        assert_eq!(html_escape("a'b"), "a&#39;b");
1183        assert_eq!(html_escape("a<b>c"), "a&lt;b&gt;c");
1184        assert_eq!(
1185            html_escape("<div class=\"x\">&'</div>"),
1186            "&lt;div class=&quot;x&quot;&gt;&amp;&#39;&lt;/div&gt;"
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        // Order-free composition with Fit.
1201        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        // Contradictory align keywords resolve last-wins (no error, no warning).
1212        // Locked here so a future refactor can't silently flip to first-wins or
1213        // None-on-conflict.
1214        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        // Composes with Position too.
1228        assert!(is_all_display_keywords("align-left top"));
1229    }
1230
1231    // -- match_width_token / extract_width_from_alias ---------------------
1232
1233    #[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        // `full` is the author-facing alias for `screen` (canonical value).
1240        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        // Multi-token strings are exact-match only — no caption shadowing.
1249        assert_eq!(match_width_token("wide angle"), None);
1250        // Display keywords aren't width tokens.
1251        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        // No width token — alias passes through unchanged.
1265        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        // Multi-pipe alias `caption|full` (the wikilink parser hands us
1273        // the post-first-`|` slice intact).
1274        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        // The phrase "caption that says wide" must NOT trigger width
1289        // recognition — a width token only fires when an entire alias
1290        // segment is exactly the token.
1291        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        // If two width tokens appear, only the first one is canonical-ised;
1299        // the second stays in the caption text. Authors writing two width
1300        // tokens is malformed input, and rather than silently merging we
1301        // preserve the surplus for diagnostic visibility downstream.
1302        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        // Authors who write `caption | full` should still get width
1310        // recognition — leading/trailing whitespace on a segment is
1311        // ignored for the token check but preserved in the rejoined rest.
1312        let (w, rest) = extract_width_from_alias("caption | full");
1313        assert_eq!(w, Some("screen"));
1314        assert_eq!(rest, "caption ");
1315    }
1316
1317    // -- MediaAttrs passthroughs: class_names + extra_attrs ----------------
1318
1319    #[test]
1320    fn test_media_attrs_class_names_preserved() {
1321        // Author-provided class names (not in moss vocabulary) survive on
1322        // MediaAttrs; the wikilink Stage 1 translator and downstream Stage 2
1323        // dispatcher consume `class_attr()` to compose the final class list.
1324        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        // align (typed) and class_names (passthrough) compose into the same
1341        // class list. Stage 2 dispatcher recomposes them into the final
1342        // `class="moss-image moss-align-left theme-rounded"`.
1343        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        // extra_attrs make MediaAttrs non-empty so callers know to round-trip
1359        // them through the wikilink title-params channel.
1360        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        // BTreeMap iteration is deterministic alphabetical (data-id < data-zoom).
1372        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    // -- classify_image_alias (Phase 1: lifted from ImageRenderer) ----------
1377
1378    #[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        // THE invariant: an empty alias yields caption=None, never Some(""),
1388        // so no caller emits an empty <figcaption>.
1389        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        // `wide cover` = width token + fit keyword — fully structural.
1404        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        // A bare width token alone is structural, not a caption.
1412        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        // Sanity: the lifted helper agrees with the classifier's branch.
1427        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}