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/// `color` is also moss-vocabulary — parsed into [`MediaAttrs::color`] for
174/// the build's cover-color ladder, never emitted as class or inline style.
175///
176/// See `docs/architecture/unified-image-emission.md` Decision #10.
177#[derive(Debug, Clone, Default, PartialEq, Eq)]
178pub struct MediaAttrs {
179    pub fit: Option<Fit>,
180    pub position: Option<Position>,
181    pub align: Option<AlignSide>,
182    /// Cover band color override from a `color=<css-color>` pipe attr
183    /// (`cover: page.html|color=black`). Consumed by the build's
184    /// cover-color ladder (`resolve_card_color`); never emitted as inline
185    /// style or class. The value must be space-free — pipe attrs are
186    /// whitespace-tokenized — so `#0a0a0a`, `black`, and `rgb(10,10,10)`
187    /// work; `rgb(10, 10, 10)` does not.
188    pub color: Option<String>,
189    /// Author-provided class names that aren't in moss's recognized
190    /// vocabulary (`.align-left` / `.alignleft` get folded into `align`
191    /// upstream; everything else lands here). Joined with spaces by
192    /// [`Self::class_attr`] after any `moss-*` class from `css_class()`.
193    pub class_names: Vec<String>,
194    /// Author-provided `key=value` attributes from Pandoc attribute blocks
195    /// that aren't recognized moss vocabulary. Emitted as title-params
196    /// (`![alt](src "moss:k=v")`) by the wikilink Stage 1 translator in
197    /// deterministic alphabetical order (BTreeMap iteration is sorted).
198    pub extra_attrs: BTreeMap<String, String>,
199}
200
201impl MediaAttrs {
202    /// True when no display attributes or passthroughs are set.
203    pub fn is_empty(&self) -> bool {
204        self.fit.is_none()
205            && self.position.is_none()
206            && self.align.is_none()
207            && self.color.is_none()
208            && self.class_names.is_empty()
209            && self.extra_attrs.is_empty()
210    }
211
212    /// Build an inline CSS style string, or `None` if empty.
213    ///
214    /// Example output: `"object-fit:contain;object-position:left"`.
215    /// `align` does NOT contribute — it emits as a class (see [`Self::css_class`]).
216    /// `class_names` and `extra_attrs` are also out of style: classes ride on
217    /// the `class` attribute, extras ride on their own attribute slots.
218    pub fn to_inline_style(&self) -> Option<String> {
219        if self.fit.is_none() && self.position.is_none() {
220            return None;
221        }
222
223        let mut parts = Vec::new();
224        if let Some(ref fit) = self.fit {
225            parts.push(format!("object-fit:{}", fit.to_css_value()));
226        }
227        if let Some(ref pos) = self.position {
228            parts.push(format!("object-position:{}", pos.to_css_value()));
229        }
230        Some(parts.join(";"))
231    }
232
233    /// CSS class name for the moss-recognized vocabulary, or `None` if no
234    /// class-bearing attribute is set. Today only `align` produces a class;
235    /// future class-bearing attributes can extend this method.
236    ///
237    /// This is the moss-prefixed half — see [`Self::class_attr`] for the
238    /// merged value that includes author-provided `class_names`.
239    pub fn css_class(&self) -> Option<&'static str> {
240        self.align.map(AlignSide::css_class)
241    }
242
243    /// Build the full `class` attribute value, merging the moss-vocabulary
244    /// class (from [`Self::css_class`]) with author-provided `class_names`.
245    /// Returns `None` if both sources are empty.
246    ///
247    /// Order: moss-vocabulary class first (e.g. `moss-align-left`), then
248    /// `class_names` in author-provided order. Both halves are joined with a
249    /// single space.
250    pub fn class_attr(&self) -> Option<String> {
251        let moss_class = self.css_class();
252        if moss_class.is_none() && self.class_names.is_empty() {
253            return None;
254        }
255        let mut parts: Vec<&str> = Vec::new();
256        if let Some(c) = moss_class {
257            parts.push(c);
258        }
259        for c in &self.class_names {
260            parts.push(c.as_str());
261        }
262        Some(parts.join(" "))
263    }
264}
265
266// ---------------------------------------------------------------------------
267// ResolvedMedia
268// ---------------------------------------------------------------------------
269
270/// A fully resolved media reference: path + display attributes.
271/// Not yet consumed outside tests — kept `pub(crate)` until a real caller exists.
272#[derive(Debug, Clone, PartialEq, Eq)]
273pub(crate) struct ResolvedMedia {
274    /// Root-relative path (no leading `/`) or external URL.
275    pub path: String,
276    /// Parsed display attributes.
277    pub attrs: MediaAttrs,
278}
279
280// ---------------------------------------------------------------------------
281// Parsing functions
282// ---------------------------------------------------------------------------
283
284/// Strip `[[` and `]]` brackets from a wikilink reference, if present.
285///
286/// Returns the inner text. If brackets are not present, returns the input
287/// unchanged.
288pub fn strip_wikilink(raw: &str) -> &str {
289    let trimmed = raw.trim();
290    trimmed
291        .strip_prefix("[[")
292        .and_then(|s| s.strip_suffix("]]"))
293        .unwrap_or(trimmed)
294}
295
296/// Split a media reference on the first `|`, returning `(path, attrs_str)`.
297///
298/// If there is no `|`, `attrs_str` is an empty string.
299pub fn split_pipe(raw: &str) -> (&str, &str) {
300    raw.split_once('|').unwrap_or((raw, ""))
301}
302
303/// Parse space-separated display-attribute keywords (and `key=value` pairs)
304/// from the portion after `|`.
305///
306/// Recognized keywords map to [`Fit`], [`Position`], and [`AlignSide`]
307/// variants. Recognized `key=value` pairs: `color=<css-color>` (stored in
308/// [`MediaAttrs::color`]; consumed by the build's cover-color ladder).
309/// Empty-value tokens (`color=`) are silently ignored. Unknown tokens are
310/// silently ignored (callers may add diagnostic reporting).
311///
312/// Two-word position keywords like `"top left"` are handled: if a bare
313/// directional keyword (`top`, `bottom`) is followed by another (`left`,
314/// `right`), they are combined.
315pub fn parse_media_attrs(raw: &str) -> MediaAttrs {
316    let mut fit: Option<Fit> = None;
317    let mut position: Option<Position> = None;
318    let mut align: Option<AlignSide> = None;
319    let mut color: Option<String> = None;
320
321    let tokens: Vec<&str> = raw.split_whitespace().collect();
322    let mut i = 0;
323
324    while i < tokens.len() {
325        let token = tokens[i];
326
327        // Try combining with next token for two-word positions.
328        if i + 1 < tokens.len() {
329            let combined = format!("{} {}", token, tokens[i + 1]);
330            if let Some(pos) = Position::from_keyword(&combined) {
331                position = Some(pos);
332                i += 2;
333                continue;
334            }
335        }
336
337        // Single-token fit.
338        if let Some(f) = Fit::from_keyword(token) {
339            fit = Some(f);
340            i += 1;
341            continue;
342        }
343
344        // Single-token position.
345        if let Some(pos) = Position::from_keyword(token) {
346            position = Some(pos);
347            i += 1;
348            continue;
349        }
350
351        // Single-token align (editorial runaround: align-left / align-right).
352        if let Some(side) = AlignSide::from_keyword(token) {
353            align = Some(side);
354            i += 1;
355            continue;
356        }
357
358        // key=value: cover color override.
359        if let Some(value) = token.strip_prefix("color=") {
360            if !value.is_empty() {
361                color = Some(value.to_string());
362            }
363            i += 1;
364            continue;
365        }
366
367        // Unknown token — skip.
368        i += 1;
369    }
370
371    MediaAttrs {
372        fit,
373        position,
374        align,
375        color,
376        ..Default::default()
377    }
378}
379
380/// Recognize the spec § P9 width tokens (`body | wide | page | screen | full`).
381///
382/// `full` is the author-facing alias for `screen` — both at the fenced-div
383/// AttrBlock layer (see [`crate::ast::attrs::match_width_token`]) and here at
384/// the wikilink pipe-alias layer. The returned `&'static str` is the
385/// canonical value-space term emitted as `data-width="..."`.
386///
387/// The check is exact-match on the full input (case-sensitive ASCII): a string
388/// like `"wide screen"` returns `None` so that multi-word captions like
389/// `![[img|wide angle shot]]` are not silently classified as a width hint.
390/// Callers that handle multi-pipe wikilink aliases should split on `|` and
391/// call this on each trimmed segment individually.
392pub fn match_width_token(s: &str) -> Option<&'static str> {
393    match s {
394        "body" => Some("body"),
395        "wide" => Some("wide"),
396        "page" => Some("page"),
397        "screen" | "full" => Some("screen"),
398        _ => None,
399    }
400}
401
402/// Recognize a single image-figure width segment: a named token
403/// (`body|wide|page|screen|full`) OR a content-relative percent (`55%`).
404///
405/// Returns the canonical string to store in `Block::Figure.width`:
406/// - named token → its canonical form (`full` → `screen`)
407/// - percent → normalized `"NN%"` (clamped to `(0, 100]`)
408///
409/// Returns `None` for anything else (captions, `200x150` box sizing, px).
410/// Box/px are intentionally rejected: image figures only support
411/// content-relative widths in v1 (see design §"Out of scope").
412///
413/// MIRROR: the editor's read-side `parseImageWidth` in
414/// `frontend/app/editor/cm-image-extract.ts` agrees with this for every
415/// canonical/moss-emitted width (named tokens, `NN%`, `NN.N%`) — the only
416/// widths the write path (`set_image_width`) ever produces. The two may
417/// diverge on malformed hand-typed input (this `f64::parse` accepts `"55 %"`,
418/// `".5%"`, `"+5%"` which the TS regex rejects); harmless, read-side only.
419/// `f64` (not `f32`) is used so fractional percents format identically to the
420/// JS side, preserving the editor↔build string-equality the design relies on.
421pub fn parse_image_width(seg: &str) -> Option<String> {
422    let s = seg.trim();
423    if s.is_empty() {
424        return None;
425    }
426    if let Some(named) = match_width_token(s) {
427        return Some(named.to_string());
428    }
429    // Percent only — reject px / vh / box by requiring a '%' suffix here.
430    if let Some(rest) = s.strip_suffix('%') {
431        let v: f64 = rest.trim().parse().ok()?;
432        if v <= 0.0 {
433            return None;
434        }
435        let clamped = v.min(100.0);
436        // Integer-preserving format: "55%" not "55.0%"; "50.5%" stays.
437        let text = if clamped.fract() == 0.0 {
438            format!("{}%", clamped as i64)
439        } else {
440            format!("{}%", clamped)
441        };
442        return Some(text);
443    }
444    None
445}
446
447/// Split pipe-delimited alt/alias text into `(remaining, width)`.
448///
449/// Pulls out the FIRST segment that `parse_image_width` recognizes; all
450/// other segments are rejoined with `|` in order. If no segment is a
451/// width, returns the input unchanged with `None`. Mirrors the segment
452/// model of [`extract_width_from_alias`] but for the image width vocabulary
453/// (named + percent).
454pub fn split_alt_width(text: &str) -> (String, Option<String>) {
455    let mut width: Option<String> = None;
456    let mut remaining: Vec<&str> = Vec::new();
457    for seg in text.split('|') {
458        if width.is_none() {
459            if let Some(w) = parse_image_width(seg) {
460                width = Some(w);
461                continue;
462            }
463        }
464        remaining.push(seg);
465    }
466    (remaining.join("|"), width)
467}
468
469/// Rewrite the width token of a single image's markdown, preserving all
470/// other pipe segments (caption, alignment).
471///
472/// `width = Some("55%")` / `Some("wide")` sets (or replaces) the width;
473/// `width = None` (or an unrecognized string) removes it. Works on both
474/// standard `![alt|..](url)` and wikilink `![[file|..]]` syntaxes. Returns
475/// the input unchanged if it is not recognized as a single image.
476///
477/// This is the SINGLE SOURCE OF TRUTH for the editor's drag-resize and
478/// double-click-reset writes (via a Tauri command), so the produced text
479/// round-trips through the build's image-width parse.
480pub fn set_image_width(image_md: &str, width: Option<&str>) -> String {
481    // Normalize the requested width through the same validator the build
482    // uses. An unrecognized request becomes a removal.
483    let new_width: Option<String> = width.and_then(parse_image_width);
484
485    // ── Wikilink: ![[ inner ]] ───────────────────────────────────────
486    if let Some(inner) = image_md
487        .strip_prefix("![[")
488        .and_then(|s| s.strip_suffix("]]"))
489    {
490        let (path, pothole) = inner.split_once('|').unwrap_or((inner, ""));
491        // Strip any existing width from the pothole, keep other segments.
492        let (rest, _old) = split_alt_width(pothole);
493        let segments: Vec<&str> = rest.split('|').filter(|s| !s.is_empty()).collect();
494        let mut parts: Vec<String> = segments.iter().map(|s| s.to_string()).collect();
495        if let Some(w) = new_width {
496            parts.push(w);
497        }
498        return if parts.is_empty() {
499            format!("![[{}]]", path)
500        } else {
501            format!("![[{}|{}]]", path, parts.join("|"))
502        };
503    }
504
505    // ── Standard: ![alt](url) ────────────────────────────────────────
506    if image_md.starts_with("![") {
507        if let Some(close) = image_md.rfind("](") {
508            if image_md.ends_with(')') {
509                let alt_raw = &image_md[2..close];
510                let url = &image_md[close + 2..image_md.len() - 1];
511                let (rest_alt, _old) = split_alt_width(alt_raw);
512                // Setting a width always emits `![{alt}|{w}]` — even when the
513                // remaining alt is empty (`![|55%]`), so it round-trips with
514                // the standard-image parser's empty-alt-with-width form
515                // (`Block::Figure` carries the width, caption stays None).
516                let alt_out = match new_width {
517                    Some(w) => format!("{}|{}", rest_alt, w),
518                    None => rest_alt,
519                };
520                return format!("![{}]({})", alt_out, url);
521            }
522        }
523    }
524
525    image_md.to_string()
526}
527
528/// Parse a wikilink alias for an embedded width token plus the remaining
529/// alias content.
530///
531/// The wikilink parser (`parse_wikilink_inner`) splits on the first `|` only,
532/// so when an author writes `![[img|caption|full]]`, the resulting `alias`
533/// string is `"caption|full"`. This helper splits the alias on `|` and pulls
534/// out a bare width-token segment (per [`match_width_token`]) without
535/// reordering the others. The remaining segments are rejoined with `|`.
536///
537/// Returns `(width, remaining_alias)`:
538///
539/// - `width = Some("body|wide|page|screen")` if exactly one segment matched
540///   a width token (per the "entire alias-segment is exactly one of the
541///   tokens" rule). Width tokens never shadow longer captions.
542/// - `remaining_alias` is the trimmed concatenation of non-width segments,
543///   joined with `|`. Empty if the only segment was the width token.
544///
545/// If no width token is found, returns `(None, alias.to_string())` — the
546/// caller falls through to its existing alias handling.
547pub fn extract_width_from_alias(alias: &str) -> (Option<&'static str>, String) {
548    let segments: Vec<&str> = alias.split('|').collect();
549    let mut width: Option<&'static str> = None;
550    let mut remaining: Vec<&str> = Vec::with_capacity(segments.len());
551
552    for seg in &segments {
553        let trimmed = seg.trim();
554        if width.is_none() {
555            if let Some(canonical) = match_width_token(trimmed) {
556                width = Some(canonical);
557                continue;
558            }
559        }
560        remaining.push(seg);
561    }
562
563    (width, remaining.join("|"))
564}
565
566/// Return `true` if every token in `text` is a recognized display keyword.
567///
568/// Handles single-token keywords (`"left"`, `"contain"`) and two-word position
569/// keywords (`"top left"`).  An empty string returns `false`.
570pub fn is_all_display_keywords(text: &str) -> bool {
571    let tokens: Vec<&str> = text.split_whitespace().collect();
572    if tokens.is_empty() {
573        return false;
574    }
575
576    let mut i = 0;
577    while i < tokens.len() {
578        // Try combining current token with next for two-word positions.
579        if i + 1 < tokens.len() {
580            let combined = format!("{} {}", tokens[i], tokens[i + 1]);
581            if Position::from_keyword(&combined).is_some() {
582                i += 2;
583                continue;
584            }
585        }
586
587        if Fit::from_keyword(tokens[i]).is_some() {
588            i += 1;
589            continue;
590        }
591
592        if Position::from_keyword(tokens[i]).is_some() {
593            i += 1;
594            continue;
595        }
596
597        if AlignSide::from_keyword(tokens[i]).is_some() {
598            i += 1;
599            continue;
600        }
601
602        return false;
603    }
604
605    true
606}
607
608/// True when every whitespace-separated token in `alias` is either a
609/// recognized display keyword (fit / position / align) OR a canonical
610/// width token (body / wide / page / screen / full).
611///
612/// This is the structural-vs-caption classifier for image aliases: a
613/// fully-structural alias contributes only to display params; anything else
614/// becomes caption / alt text. The [`is_all_display_keywords`] half is
615/// unchanged (covers two-word position tokens like `top left`); the
616/// width-token half lets authors write `align-left wide` without breaking
617/// the pipe.
618///
619/// Lifted from `resolve::embed_renderer` (Phase 1 of the image-embed
620/// synth-collapse) so it survives `ImageRenderer`'s deletion — it is the
621/// load-bearing half of [`classify_image_alias`].
622pub(crate) fn is_structural_alias(alias: &str) -> bool {
623    // Fast path: any caption-like text fails `is_all_display_keywords`
624    // and would also fail the per-token loop below.
625    if is_all_display_keywords(alias) {
626        return true;
627    }
628    let tokens: Vec<&str> = alias.split_whitespace().collect();
629    if tokens.is_empty() {
630        return false;
631    }
632    // Walk tokens; admit width tokens, otherwise defer to display-keyword
633    // recognition (per-token, since position tokens may pair across two).
634    let mut i = 0;
635    while i < tokens.len() {
636        // Width token: single-token, simple admit.
637        if match_width_token(tokens[i]).is_some() {
638            i += 1;
639            continue;
640        }
641        // Two-word position (e.g. `top left`).
642        if i + 1 < tokens.len() {
643            let combined = format!("{} {}", tokens[i], tokens[i + 1]);
644            if Position::from_keyword(&combined).is_some() {
645                i += 2;
646                continue;
647            }
648        }
649        // Single-token display keyword.
650        if Fit::from_keyword(tokens[i]).is_some()
651            || Position::from_keyword(tokens[i]).is_some()
652            || AlignSide::from_keyword(tokens[i]).is_some()
653        {
654            i += 1;
655            continue;
656        }
657        return false;
658    }
659    true
660}
661
662/// Classification of an image-embed pipe alias into its display-vs-caption
663/// role.
664///
665/// The pipe alias of `![[photo.jpg|<alias>]]` is one of three things:
666/// a run of structural display keywords (`cover`, `wide cover`), human-
667/// readable caption prose (`My nice photo`), or absent/empty. This struct
668/// captures the disambiguation so every image-embed call site classifies
669/// identically.
670#[derive(Debug, Clone, PartialEq, Eq)]
671pub(crate) struct ImageAliasClass {
672    /// Structural display-keyword run (e.g. `"cover"`, `"wide cover"`) to be
673    /// fed to `parse_media_attrs`; `None` when the alias is a caption or
674    /// empty.
675    pub display_keywords: Option<String>,
676    /// Caption text (also used as `alt`) when the alias is human-readable
677    /// prose; `None` for structural/empty aliases.
678    ///
679    /// **Invariant:** never `Some("")`. An empty alias yields `None` so
680    /// callers never emit an empty `<figcaption>`.
681    pub caption: Option<String>,
682}
683
684/// Classify an image-embed pipe alias into [`ImageAliasClass`].
685///
686/// Mirrors the 3-way split previously inlined in
687/// `ImageRenderer::render_to_markdown` (now lifted so it survives that
688/// struct's deletion in the image-embed synth-collapse):
689///
690/// - `None`                       → both `None`
691/// - `Some("")` (empty)           → both `None` (no empty figcaption)
692/// - `Some(s)` and structural     → `display_keywords = Some(s)`, `caption = None`
693/// - `Some(other)`                → `display_keywords = None`, `caption = Some(other)`
694pub(crate) fn classify_image_alias(alias: Option<&str>) -> ImageAliasClass {
695    match alias {
696        // Empty alias (`![[file|]]`) is treated as no alias. Matches the
697        // historical `alias.is_empty()` guard exactly (no extra trimming).
698        Some(a) if a.is_empty() => ImageAliasClass {
699            display_keywords: None,
700            caption: None,
701        },
702        Some(a) if is_structural_alias(a) => ImageAliasClass {
703            display_keywords: Some(a.to_string()),
704            caption: None,
705        },
706        Some(other) => ImageAliasClass {
707            display_keywords: None,
708            caption: Some(other.to_string()),
709        },
710        None => ImageAliasClass {
711            display_keywords: None,
712            caption: None,
713        },
714    }
715}
716
717/// Escape a string for safe use in HTML text or attribute values.
718///
719/// Replaces `&`, `"`, `'`, `<`, and `>` with their HTML entities.
720pub fn html_escape(s: &str) -> String {
721    let mut out = String::with_capacity(s.len());
722    for ch in s.chars() {
723        match ch {
724            '&' => out.push_str("&amp;"),
725            '"' => out.push_str("&quot;"),
726            '\'' => out.push_str("&#39;"),
727            '<' => out.push_str("&lt;"),
728            '>' => out.push_str("&gt;"),
729            _ => out.push(ch),
730        }
731    }
732    out
733}
734
735// ---------------------------------------------------------------------------
736// Resolution
737// ---------------------------------------------------------------------------
738
739/// Returns `true` if the path looks like an external URL or data URI.
740fn is_external(path: &str) -> bool {
741    path.starts_with("http://")
742        || path.starts_with("https://")
743        || path.starts_with("//")
744        || path.starts_with("data:")
745}
746
747/// Full pipeline: strip wikilink → split pipe → resolve path → parse attrs.
748///
749/// - External URLs (`http://`, `https://`, `//`, `data:`) pass through unchanged.
750/// - Root-relative paths (leading `/`) have the slash stripped.
751/// - Everything else is resolved via [`ContentGraph::resolve_path`], falling
752///   back to the raw path if unresolved.
753pub(crate) fn resolve_media_ref(raw: &str, source_path: &str, graph: &ContentGraph) -> ResolvedMedia {
754    let inner = strip_wikilink(raw);
755    let (path_part, attrs_str) = split_pipe(inner);
756    let path_trimmed = path_part.trim();
757    let attrs = parse_media_attrs(attrs_str);
758
759    let resolved_path = if is_external(path_trimmed) {
760        // External URL — passthrough.
761        path_trimmed.to_string()
762    } else if let Some(stripped) = path_trimmed.strip_prefix('/') {
763        // Root-relative — strip leading slash.
764        stripped.to_string()
765    } else {
766        // Resolve via content graph, fall back to raw path.
767        graph
768            .resolve_path(path_trimmed, source_path)
769            .unwrap_or_else(|| path_trimmed.to_string())
770    };
771
772    ResolvedMedia {
773        path: resolved_path,
774        attrs,
775    }
776}
777
778// ---------------------------------------------------------------------------
779// Tests
780// ---------------------------------------------------------------------------
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785    use crate::content_graph::ContentGraphBuilder;
786
787    // -- Fit ----------------------------------------------------------------
788
789    #[test]
790    fn test_fit_to_css_value() {
791        assert_eq!(Fit::Cover.to_css_value(), "cover");
792        assert_eq!(Fit::Contain.to_css_value(), "contain");
793        assert_eq!(Fit::Fill.to_css_value(), "fill");
794        assert_eq!(Fit::None.to_css_value(), "none");
795        assert_eq!(Fit::ScaleDown.to_css_value(), "scale-down");
796    }
797
798    #[test]
799    fn test_fit_from_keyword() {
800        assert_eq!(Fit::from_keyword("cover"), Some(Fit::Cover));
801        assert_eq!(Fit::from_keyword("contain"), Some(Fit::Contain));
802        assert_eq!(Fit::from_keyword("fill"), Some(Fit::Fill));
803        assert_eq!(Fit::from_keyword("none"), Some(Fit::None));
804        assert_eq!(Fit::from_keyword("scale-down"), Some(Fit::ScaleDown));
805        assert_eq!(Fit::from_keyword("scaledown"), Some(Fit::ScaleDown));
806    }
807
808    #[test]
809    fn test_fit_from_keyword_case_insensitive() {
810        assert_eq!(Fit::from_keyword("COVER"), Some(Fit::Cover));
811        assert_eq!(Fit::from_keyword("Contain"), Some(Fit::Contain));
812        assert_eq!(Fit::from_keyword("Scale-Down"), Some(Fit::ScaleDown));
813        assert_eq!(Fit::from_keyword("SCALEDOWN"), Some(Fit::ScaleDown));
814    }
815
816    #[test]
817    fn test_fit_from_keyword_unknown() {
818        assert_eq!(Fit::from_keyword("zoom"), None);
819        assert_eq!(Fit::from_keyword(""), None);
820        assert_eq!(Fit::from_keyword("cover "), None); // trailing space — not trimmed
821    }
822
823    // -- AlignSide ----------------------------------------------------------
824
825    #[test]
826    fn test_align_side_from_keyword() {
827        assert_eq!(AlignSide::from_keyword("align-left"), Some(AlignSide::Left));
828        assert_eq!(AlignSide::from_keyword("align-right"), Some(AlignSide::Right));
829        // WordPress-style unhyphenated alias.
830        assert_eq!(AlignSide::from_keyword("alignleft"), Some(AlignSide::Left));
831        assert_eq!(AlignSide::from_keyword("alignright"), Some(AlignSide::Right));
832        // Case-insensitive.
833        assert_eq!(AlignSide::from_keyword("ALIGN-LEFT"), Some(AlignSide::Left));
834        assert_eq!(AlignSide::from_keyword("AlignRight"), Some(AlignSide::Right));
835        // Empty input never matches.
836        assert_eq!(AlignSide::from_keyword(""), None);
837    }
838
839    #[test]
840    fn test_align_side_from_keyword_bare_directional() {
841        // Bare `left` / `right` are accepted because Stage 1 emits them as
842        // the value of an explicit `align=` key (TitleParams), where the
843        // key disambiguates from Position context. The existing pipe-
844        // keyword space-separated parser (`parse_media_attrs`) still tries
845        // Position::from_keyword first and never reaches AlignSide for
846        // bare directionals — see test_parse_attrs_bare_left_is_position.
847        assert_eq!(AlignSide::from_keyword("left"), Some(AlignSide::Left));
848        assert_eq!(AlignSide::from_keyword("right"), Some(AlignSide::Right));
849        assert_eq!(AlignSide::from_keyword("LEFT"), Some(AlignSide::Left));
850        assert_eq!(AlignSide::from_keyword("Right"), Some(AlignSide::Right));
851    }
852
853    #[test]
854    fn test_parse_attrs_bare_left_is_position() {
855        // In the pipe-keyword (`![[img|cover left]]`) parser, bare `left`
856        // / `right` resolve as Position (object-position keyword), NOT as
857        // AlignSide. Position::from_keyword is tried first in
858        // `parse_media_attrs`; this test pins that ordering invariant so
859        // a future refactor that re-orders the matchers will fail loudly.
860        let attrs = parse_media_attrs("left");
861        assert_eq!(attrs.position, Some(Position::Left));
862        assert_eq!(attrs.align, None);
863
864        let attrs = parse_media_attrs("right");
865        assert_eq!(attrs.position, Some(Position::Right));
866        assert_eq!(attrs.align, None);
867    }
868
869    #[test]
870    fn test_align_side_css_class() {
871        assert_eq!(AlignSide::Left.css_class(), "moss-align-left");
872        assert_eq!(AlignSide::Right.css_class(), "moss-align-right");
873    }
874
875    // -- Position -----------------------------------------------------------
876
877    #[test]
878    fn test_position_to_css_value() {
879        assert_eq!(Position::Center.to_css_value(), "center");
880        assert_eq!(Position::Left.to_css_value(), "left");
881        assert_eq!(Position::Right.to_css_value(), "right");
882        assert_eq!(Position::Top.to_css_value(), "top");
883        assert_eq!(Position::Bottom.to_css_value(), "bottom");
884        assert_eq!(Position::TopLeft.to_css_value(), "top left");
885        assert_eq!(Position::TopRight.to_css_value(), "top right");
886        assert_eq!(Position::BottomLeft.to_css_value(), "bottom left");
887        assert_eq!(Position::BottomRight.to_css_value(), "bottom right");
888    }
889
890    #[test]
891    fn test_position_from_keyword_single() {
892        assert_eq!(Position::from_keyword("center"), Some(Position::Center));
893        assert_eq!(Position::from_keyword("left"), Some(Position::Left));
894        assert_eq!(Position::from_keyword("right"), Some(Position::Right));
895        assert_eq!(Position::from_keyword("top"), Some(Position::Top));
896        assert_eq!(Position::from_keyword("bottom"), Some(Position::Bottom));
897    }
898
899    #[test]
900    fn test_position_from_keyword_compound() {
901        // Hyphenated
902        assert_eq!(Position::from_keyword("top-left"), Some(Position::TopLeft));
903        assert_eq!(Position::from_keyword("top-right"), Some(Position::TopRight));
904        assert_eq!(Position::from_keyword("bottom-left"), Some(Position::BottomLeft));
905        assert_eq!(Position::from_keyword("bottom-right"), Some(Position::BottomRight));
906
907        // Concatenated
908        assert_eq!(Position::from_keyword("topleft"), Some(Position::TopLeft));
909        assert_eq!(Position::from_keyword("bottomright"), Some(Position::BottomRight));
910
911        // Space-separated (used when caller pre-joins tokens)
912        assert_eq!(Position::from_keyword("top left"), Some(Position::TopLeft));
913        assert_eq!(Position::from_keyword("bottom right"), Some(Position::BottomRight));
914    }
915
916    #[test]
917    fn test_position_from_keyword_case_insensitive() {
918        assert_eq!(Position::from_keyword("CENTER"), Some(Position::Center));
919        assert_eq!(Position::from_keyword("Top-Left"), Some(Position::TopLeft));
920        assert_eq!(Position::from_keyword("BOTTOMRIGHT"), Some(Position::BottomRight));
921    }
922
923    #[test]
924    fn test_position_from_keyword_unknown() {
925        assert_eq!(Position::from_keyword("middle"), None);
926        assert_eq!(Position::from_keyword(""), None);
927    }
928
929    // -- MediaAttrs ---------------------------------------------------------
930
931    #[test]
932    fn test_media_attrs_is_empty() {
933        let empty = MediaAttrs {
934            fit: None,
935            position: None,
936            align: None,
937            color: None,
938            class_names: Vec::new(),
939            extra_attrs: BTreeMap::new(),
940        };
941        assert!(empty.is_empty());
942
943        let with_fit = MediaAttrs {
944            fit: Some(Fit::Cover),
945            position: None,
946            align: None,
947            color: None,
948            class_names: Vec::new(),
949            extra_attrs: BTreeMap::new(),
950        };
951        assert!(!with_fit.is_empty());
952
953        let with_pos = MediaAttrs {
954            fit: None,
955            position: Some(Position::Center),
956            align: None,
957            color: None,
958            class_names: Vec::new(),
959            extra_attrs: BTreeMap::new(),
960        };
961        assert!(!with_pos.is_empty());
962    }
963
964    #[test]
965    fn test_to_inline_style_empty() {
966        let attrs = MediaAttrs {
967            fit: None,
968            position: None,
969            align: None,
970            color: None,
971            class_names: Vec::new(),
972            extra_attrs: BTreeMap::new(),
973        };
974        assert_eq!(attrs.to_inline_style(), None);
975    }
976
977    #[test]
978    fn test_to_inline_style_fit_only() {
979        let attrs = MediaAttrs {
980            fit: Some(Fit::Contain),
981            position: None,
982            align: None,
983            color: None,
984            class_names: Vec::new(),
985            extra_attrs: BTreeMap::new(),
986        };
987        assert_eq!(attrs.to_inline_style(), Some("object-fit:contain".into()));
988    }
989
990    #[test]
991    fn test_to_inline_style_position_only() {
992        let attrs = MediaAttrs {
993            fit: None,
994            position: Some(Position::Left),
995            align: None,
996            color: None,
997            class_names: Vec::new(),
998            extra_attrs: BTreeMap::new(),
999        };
1000        assert_eq!(
1001            attrs.to_inline_style(),
1002            Some("object-position:left".into())
1003        );
1004    }
1005
1006    #[test]
1007    fn test_to_inline_style_both() {
1008        let attrs = MediaAttrs {
1009            fit: Some(Fit::Cover),
1010            position: Some(Position::TopLeft),
1011            align: None,
1012            color: None,
1013            class_names: Vec::new(),
1014            extra_attrs: BTreeMap::new(),
1015        };
1016        assert_eq!(
1017            attrs.to_inline_style(),
1018            Some("object-fit:cover;object-position:top left".into())
1019        );
1020    }
1021
1022    // -- strip_wikilink -----------------------------------------------------
1023
1024    #[test]
1025    fn test_strip_wikilink_with_brackets() {
1026        assert_eq!(strip_wikilink("[[photo.jpg]]"), "photo.jpg");
1027        assert_eq!(strip_wikilink("[[path/to/image.png]]"), "path/to/image.png");
1028    }
1029
1030    #[test]
1031    fn test_strip_wikilink_without_brackets() {
1032        assert_eq!(strip_wikilink("photo.jpg"), "photo.jpg");
1033        assert_eq!(strip_wikilink("path/to/image.png"), "path/to/image.png");
1034    }
1035
1036    #[test]
1037    fn test_strip_wikilink_with_pipe() {
1038        assert_eq!(strip_wikilink("[[photo.jpg|cover]]"), "photo.jpg|cover");
1039    }
1040
1041    #[test]
1042    fn test_strip_wikilink_with_whitespace() {
1043        assert_eq!(strip_wikilink("  [[photo.jpg]]  "), "photo.jpg");
1044    }
1045
1046    #[test]
1047    fn test_strip_wikilink_partial_brackets() {
1048        // Only opening bracket — no stripping.
1049        assert_eq!(strip_wikilink("[[photo.jpg"), "[[photo.jpg");
1050        // Only closing bracket — no stripping.
1051        assert_eq!(strip_wikilink("photo.jpg]]"), "photo.jpg]]");
1052    }
1053
1054    #[test]
1055    fn test_strip_wikilink_empty() {
1056        assert_eq!(strip_wikilink("[[]]"), "");
1057        assert_eq!(strip_wikilink(""), "");
1058    }
1059
1060    // -- split_pipe ---------------------------------------------------------
1061
1062    #[test]
1063    fn test_split_pipe_with_pipe() {
1064        assert_eq!(split_pipe("photo.jpg|cover"), ("photo.jpg", "cover"));
1065        assert_eq!(
1066            split_pipe("path/to/img.png|contain center"),
1067            ("path/to/img.png", "contain center")
1068        );
1069    }
1070
1071    #[test]
1072    fn test_split_pipe_no_pipe() {
1073        assert_eq!(split_pipe("photo.jpg"), ("photo.jpg", ""));
1074        assert_eq!(split_pipe(""), ("", ""));
1075    }
1076
1077    #[test]
1078    fn test_split_pipe_multiple_pipes() {
1079        // Only split on the first pipe.
1080        assert_eq!(split_pipe("a|b|c"), ("a", "b|c"));
1081    }
1082
1083    #[test]
1084    fn test_split_pipe_pipe_at_edges() {
1085        assert_eq!(split_pipe("|cover"), ("", "cover"));
1086        assert_eq!(split_pipe("photo.jpg|"), ("photo.jpg", ""));
1087    }
1088
1089    // -- parse_media_attrs --------------------------------------------------
1090
1091    #[test]
1092    fn test_parse_attrs_fit_only() {
1093        let attrs = parse_media_attrs("cover");
1094        assert_eq!(attrs.fit, Some(Fit::Cover));
1095        assert_eq!(attrs.position, None);
1096    }
1097
1098    #[test]
1099    fn test_parse_attrs_position_only() {
1100        let attrs = parse_media_attrs("center");
1101        assert_eq!(attrs.fit, None);
1102        assert_eq!(attrs.position, Some(Position::Center));
1103    }
1104
1105    #[test]
1106    fn test_parse_attrs_fit_and_position() {
1107        let attrs = parse_media_attrs("contain left");
1108        assert_eq!(attrs.fit, Some(Fit::Contain));
1109        assert_eq!(attrs.position, Some(Position::Left));
1110    }
1111
1112    #[test]
1113    fn test_parse_attrs_two_word_position() {
1114        let attrs = parse_media_attrs("top left");
1115        assert_eq!(attrs.fit, None);
1116        assert_eq!(attrs.position, Some(Position::TopLeft));
1117
1118        let attrs2 = parse_media_attrs("cover bottom right");
1119        assert_eq!(attrs2.fit, Some(Fit::Cover));
1120        assert_eq!(attrs2.position, Some(Position::BottomRight));
1121    }
1122
1123    #[test]
1124    fn test_parse_attrs_hyphenated_compound_position() {
1125        let attrs = parse_media_attrs("top-right");
1126        assert_eq!(attrs.fit, None);
1127        assert_eq!(attrs.position, Some(Position::TopRight));
1128
1129        let attrs2 = parse_media_attrs("fill bottom-left");
1130        assert_eq!(attrs2.fit, Some(Fit::Fill));
1131        assert_eq!(attrs2.position, Some(Position::BottomLeft));
1132    }
1133
1134    #[test]
1135    fn test_parse_attrs_unknown_tokens_ignored() {
1136        let attrs = parse_media_attrs("cover unknown-token left");
1137        assert_eq!(attrs.fit, Some(Fit::Cover));
1138        assert_eq!(attrs.position, Some(Position::Left));
1139    }
1140
1141    #[test]
1142    fn test_parse_attrs_empty_string() {
1143        let attrs = parse_media_attrs("");
1144        assert!(attrs.is_empty());
1145    }
1146
1147    #[test]
1148    fn test_parse_attrs_only_whitespace() {
1149        let attrs = parse_media_attrs("   ");
1150        assert!(attrs.is_empty());
1151    }
1152
1153    #[test]
1154    fn test_parse_attrs_all_unknown() {
1155        let attrs = parse_media_attrs("foo bar baz");
1156        assert!(attrs.is_empty());
1157    }
1158
1159    #[test]
1160    fn test_parse_attrs_case_insensitive() {
1161        let attrs = parse_media_attrs("COVER CENTER");
1162        assert_eq!(attrs.fit, Some(Fit::Cover));
1163        assert_eq!(attrs.position, Some(Position::Center));
1164    }
1165
1166    #[test]
1167    fn test_parse_attrs_last_wins_for_duplicates() {
1168        // If multiple fit keywords appear, the last one wins.
1169        let attrs = parse_media_attrs("cover contain");
1170        assert_eq!(attrs.fit, Some(Fit::Contain));
1171    }
1172
1173    #[test]
1174    fn test_parse_attrs_scale_down() {
1175        let attrs = parse_media_attrs("scale-down");
1176        assert_eq!(attrs.fit, Some(Fit::ScaleDown));
1177    }
1178
1179    // -- resolve_media_ref --------------------------------------------------
1180
1181    fn sample_graph() -> ContentGraph {
1182        let mut b = ContentGraphBuilder::new();
1183        b.add_file("images/photo.jpg", "images/photo");
1184        b.add_file("assets/banner.png", "assets/banner");
1185        b.add_file("posts/hello.md", "posts/hello");
1186        b.build()
1187    }
1188
1189    #[test]
1190    fn test_resolve_simple_path() {
1191        let graph = sample_graph();
1192        let result = resolve_media_ref("photo.jpg", "posts/hello.md", &graph);
1193        assert_eq!(result.path, "images/photo.jpg");
1194        assert!(result.attrs.is_empty());
1195    }
1196
1197    #[test]
1198    fn test_resolve_with_attrs() {
1199        let graph = sample_graph();
1200        let result = resolve_media_ref("photo.jpg|cover center", "posts/hello.md", &graph);
1201        assert_eq!(result.path, "images/photo.jpg");
1202        assert_eq!(result.attrs.fit, Some(Fit::Cover));
1203        assert_eq!(result.attrs.position, Some(Position::Center));
1204    }
1205
1206    #[test]
1207    fn test_resolve_wikilink() {
1208        let graph = sample_graph();
1209        let result = resolve_media_ref("[[photo.jpg|contain]]", "posts/hello.md", &graph);
1210        assert_eq!(result.path, "images/photo.jpg");
1211        assert_eq!(result.attrs.fit, Some(Fit::Contain));
1212    }
1213
1214    #[test]
1215    fn test_resolve_wikilink_no_attrs() {
1216        let graph = sample_graph();
1217        let result = resolve_media_ref("[[photo.jpg]]", "posts/hello.md", &graph);
1218        assert_eq!(result.path, "images/photo.jpg");
1219        assert!(result.attrs.is_empty());
1220    }
1221
1222    #[test]
1223    fn test_resolve_external_http() {
1224        let graph = sample_graph();
1225        let result = resolve_media_ref(
1226            "https://example.com/img.jpg|cover",
1227            "posts/hello.md",
1228            &graph,
1229        );
1230        assert_eq!(result.path, "https://example.com/img.jpg");
1231        assert_eq!(result.attrs.fit, Some(Fit::Cover));
1232    }
1233
1234    #[test]
1235    fn test_resolve_external_protocol_relative() {
1236        let graph = sample_graph();
1237        let result = resolve_media_ref("//cdn.example.com/img.jpg", "posts/hello.md", &graph);
1238        assert_eq!(result.path, "//cdn.example.com/img.jpg");
1239    }
1240
1241    #[test]
1242    fn test_resolve_external_data_uri() {
1243        let graph = sample_graph();
1244        let result = resolve_media_ref("data:image/png;base64,abc", "posts/hello.md", &graph);
1245        assert_eq!(result.path, "data:image/png;base64,abc");
1246    }
1247
1248    #[test]
1249    fn test_resolve_root_relative() {
1250        let graph = sample_graph();
1251        let result = resolve_media_ref("/images/photo.jpg|fill", "posts/hello.md", &graph);
1252        assert_eq!(result.path, "images/photo.jpg");
1253        assert_eq!(result.attrs.fit, Some(Fit::Fill));
1254    }
1255
1256    #[test]
1257    fn test_resolve_unresolved_fallback() {
1258        let graph = sample_graph();
1259        let result = resolve_media_ref("missing.jpg", "posts/hello.md", &graph);
1260        // ContentGraph returns None → fallback to raw path.
1261        assert_eq!(result.path, "missing.jpg");
1262        assert!(result.attrs.is_empty());
1263    }
1264
1265    #[test]
1266    fn test_resolve_wikilink_with_two_word_position() {
1267        let graph = sample_graph();
1268        let result =
1269            resolve_media_ref("[[banner.png|cover top left]]", "posts/hello.md", &graph);
1270        assert_eq!(result.path, "assets/banner.png");
1271        assert_eq!(result.attrs.fit, Some(Fit::Cover));
1272        assert_eq!(result.attrs.position, Some(Position::TopLeft));
1273    }
1274
1275    #[test]
1276    fn test_resolve_external_in_wikilink() {
1277        let graph = sample_graph();
1278        let result = resolve_media_ref(
1279            "[[https://example.com/img.jpg|contain]]",
1280            "posts/hello.md",
1281            &graph,
1282        );
1283        assert_eq!(result.path, "https://example.com/img.jpg");
1284        assert_eq!(result.attrs.fit, Some(Fit::Contain));
1285    }
1286
1287    #[test]
1288    fn test_resolve_path_with_spaces_trimmed() {
1289        let graph = sample_graph();
1290        let result = resolve_media_ref("  photo.jpg  | cover ", "posts/hello.md", &graph);
1291        assert_eq!(result.path, "images/photo.jpg");
1292        assert_eq!(result.attrs.fit, Some(Fit::Cover));
1293    }
1294
1295    // -- is_all_display_keywords -------------------------------------------
1296
1297    #[test]
1298    fn test_is_all_display_keywords_positions() {
1299        assert!(is_all_display_keywords("left"));
1300        assert!(is_all_display_keywords("right"));
1301        assert!(is_all_display_keywords("center"));
1302        assert!(is_all_display_keywords("top"));
1303        assert!(is_all_display_keywords("bottom"));
1304        assert!(is_all_display_keywords("top left"));
1305        assert!(is_all_display_keywords("bottom right"));
1306    }
1307
1308    #[test]
1309    fn test_is_all_display_keywords_fits() {
1310        assert!(is_all_display_keywords("cover"));
1311        assert!(is_all_display_keywords("contain"));
1312        assert!(is_all_display_keywords("fill"));
1313        assert!(is_all_display_keywords("none"));
1314        assert!(is_all_display_keywords("scale-down"));
1315    }
1316
1317    #[test]
1318    fn test_is_all_display_keywords_combined() {
1319        assert!(is_all_display_keywords("contain left"));
1320        assert!(is_all_display_keywords("cover top left"));
1321        assert!(is_all_display_keywords("cover top-right"));
1322        assert!(is_all_display_keywords("scale-down bottom-left"));
1323    }
1324
1325    #[test]
1326    fn test_is_all_display_keywords_rejects_non_keywords() {
1327        assert!(!is_all_display_keywords("A beautiful sunset"));
1328        assert!(!is_all_display_keywords("left side"));
1329        assert!(!is_all_display_keywords(""));
1330        assert!(!is_all_display_keywords("   "));
1331    }
1332
1333    // -- html_escape --------------------------------------------------
1334
1335    #[test]
1336    fn test_html_escape_basic() {
1337        assert_eq!(html_escape("hello"), "hello");
1338        assert_eq!(html_escape("a&b"), "a&amp;b");
1339        assert_eq!(html_escape("a\"b"), "a&quot;b");
1340        assert_eq!(html_escape("a'b"), "a&#39;b");
1341        assert_eq!(html_escape("a<b>c"), "a&lt;b&gt;c");
1342        assert_eq!(
1343            html_escape("<div class=\"x\">&'</div>"),
1344            "&lt;div class=&quot;x&quot;&gt;&amp;&#39;&lt;/div&gt;"
1345        );
1346    }
1347
1348    #[test]
1349    fn test_parse_media_attrs_align_alone() {
1350        let attrs = parse_media_attrs("align-left");
1351        assert_eq!(attrs.align, Some(AlignSide::Left));
1352        assert_eq!(attrs.fit, None);
1353        assert_eq!(attrs.position, None);
1354    }
1355
1356    #[test]
1357    fn test_parse_media_attrs_align_with_cover() {
1358        // Order-free composition with Fit.
1359        let a = parse_media_attrs("cover align-right");
1360        assert_eq!(a.fit, Some(Fit::Cover));
1361        assert_eq!(a.align, Some(AlignSide::Right));
1362
1363        let b = parse_media_attrs("align-right cover");
1364        assert_eq!(b, a);
1365    }
1366
1367    #[test]
1368    fn test_parse_media_attrs_align_last_wins() {
1369        // Contradictory align keywords resolve last-wins (no error, no warning).
1370        // Locked here so a future refactor can't silently flip to first-wins or
1371        // None-on-conflict.
1372        let attrs = parse_media_attrs("align-left align-right");
1373        assert_eq!(attrs.align, Some(AlignSide::Right));
1374
1375        let attrs = parse_media_attrs("align-right align-left");
1376        assert_eq!(attrs.align, Some(AlignSide::Left));
1377    }
1378
1379    #[test]
1380    fn test_is_all_display_keywords_align() {
1381        assert!(is_all_display_keywords("align-left"));
1382        assert!(is_all_display_keywords("align-right"));
1383        assert!(is_all_display_keywords("cover align-left"));
1384        assert!(is_all_display_keywords("align-left cover"));
1385        // Composes with Position too.
1386        assert!(is_all_display_keywords("align-left top"));
1387    }
1388
1389    // -- match_width_token / extract_width_from_alias ---------------------
1390
1391    #[test]
1392    fn test_match_width_token_recognized() {
1393        assert_eq!(match_width_token("body"), Some("body"));
1394        assert_eq!(match_width_token("wide"), Some("wide"));
1395        assert_eq!(match_width_token("page"), Some("page"));
1396        assert_eq!(match_width_token("screen"), Some("screen"));
1397        // `full` is the author-facing alias for `screen` (canonical value).
1398        assert_eq!(match_width_token("full"), Some("screen"));
1399    }
1400
1401    #[test]
1402    fn test_match_width_token_rejects_non_width() {
1403        assert_eq!(match_width_token(""), None);
1404        assert_eq!(match_width_token("BODY"), None);
1405        assert_eq!(match_width_token("widely"), None);
1406        // Multi-token strings are exact-match only — no caption shadowing.
1407        assert_eq!(match_width_token("wide angle"), None);
1408        // Display keywords aren't width tokens.
1409        assert_eq!(match_width_token("contain"), None);
1410        assert_eq!(match_width_token("left"), None);
1411    }
1412
1413    #[test]
1414    fn test_extract_width_from_alias_single_segment_width() {
1415        let (w, rest) = extract_width_from_alias("full");
1416        assert_eq!(w, Some("screen"));
1417        assert_eq!(rest, "");
1418    }
1419
1420    #[test]
1421    fn test_extract_width_from_alias_caption_only() {
1422        // No width token — alias passes through unchanged.
1423        let (w, rest) = extract_width_from_alias("A beautiful sunset");
1424        assert_eq!(w, None);
1425        assert_eq!(rest, "A beautiful sunset");
1426    }
1427
1428    #[test]
1429    fn test_extract_width_from_alias_caption_then_width() {
1430        // Multi-pipe alias `caption|full` (the wikilink parser hands us
1431        // the post-first-`|` slice intact).
1432        let (w, rest) = extract_width_from_alias("A nice photo|full");
1433        assert_eq!(w, Some("screen"));
1434        assert_eq!(rest, "A nice photo");
1435    }
1436
1437    #[test]
1438    fn test_extract_width_from_alias_width_then_caption() {
1439        let (w, rest) = extract_width_from_alias("wide|A nice photo");
1440        assert_eq!(w, Some("wide"));
1441        assert_eq!(rest, "A nice photo");
1442    }
1443
1444    #[test]
1445    fn test_extract_width_from_alias_caption_with_width_word_not_shadowed() {
1446        // The phrase "caption that says wide" must NOT trigger width
1447        // recognition — a width token only fires when an entire alias
1448        // segment is exactly the token.
1449        let (w, rest) = extract_width_from_alias("caption that says wide");
1450        assert_eq!(w, None);
1451        assert_eq!(rest, "caption that says wide");
1452    }
1453
1454    #[test]
1455    fn test_extract_width_from_alias_only_first_width_extracted() {
1456        // If two width tokens appear, only the first one is canonical-ised;
1457        // the second stays in the caption text. Authors writing two width
1458        // tokens is malformed input, and rather than silently merging we
1459        // preserve the surplus for diagnostic visibility downstream.
1460        let (w, rest) = extract_width_from_alias("full|wide");
1461        assert_eq!(w, Some("screen"));
1462        assert_eq!(rest, "wide");
1463    }
1464
1465    #[test]
1466    fn test_extract_width_from_alias_segment_whitespace_trimmed() {
1467        // Authors who write `caption | full` should still get width
1468        // recognition — leading/trailing whitespace on a segment is
1469        // ignored for the token check but preserved in the rejoined rest.
1470        let (w, rest) = extract_width_from_alias("caption | full");
1471        assert_eq!(w, Some("screen"));
1472        assert_eq!(rest, "caption ");
1473    }
1474
1475    // -- MediaAttrs passthroughs: class_names + extra_attrs ----------------
1476
1477    #[test]
1478    fn test_media_attrs_class_names_preserved() {
1479        // Author-provided class names (not in moss vocabulary) survive on
1480        // MediaAttrs; the wikilink Stage 1 translator and downstream Stage 2
1481        // dispatcher consume `class_attr()` to compose the final class list.
1482        let attrs = MediaAttrs {
1483            fit: None,
1484            position: None,
1485            align: None,
1486            color: None,
1487            class_names: vec!["theme-rounded".to_string(), "shadow-lg".to_string()],
1488            extra_attrs: BTreeMap::new(),
1489        };
1490        assert!(!attrs.is_empty());
1491        assert_eq!(
1492            attrs.class_attr(),
1493            Some("theme-rounded shadow-lg".to_string())
1494        );
1495    }
1496
1497    #[test]
1498    fn test_media_attrs_class_names_compose_with_align() {
1499        // align (typed) and class_names (passthrough) compose into the same
1500        // class list. Stage 2 dispatcher recomposes them into the final
1501        // `class="moss-image moss-align-left theme-rounded"`.
1502        let attrs = MediaAttrs {
1503            fit: None,
1504            position: None,
1505            align: Some(AlignSide::Left),
1506            color: None,
1507            class_names: vec!["theme-rounded".to_string()],
1508            extra_attrs: BTreeMap::new(),
1509        };
1510        assert_eq!(
1511            attrs.class_attr(),
1512            Some("moss-align-left theme-rounded".to_string())
1513        );
1514    }
1515
1516    #[test]
1517    fn test_media_attrs_extra_attrs_non_empty() {
1518        // extra_attrs make MediaAttrs non-empty so callers know to round-trip
1519        // them through the wikilink title-params channel.
1520        let mut extras = BTreeMap::new();
1521        extras.insert("data-zoom".to_string(), "true".to_string());
1522        extras.insert("data-id".to_string(), "42".to_string());
1523        let attrs = MediaAttrs {
1524            fit: None,
1525            position: None,
1526            align: None,
1527            color: None,
1528            class_names: vec![],
1529            extra_attrs: extras,
1530        };
1531        assert!(!attrs.is_empty());
1532        // BTreeMap iteration is deterministic alphabetical (data-id < data-zoom).
1533        let keys: Vec<&str> = attrs.extra_attrs.keys().map(String::as_str).collect();
1534        assert_eq!(keys, vec!["data-id", "data-zoom"]);
1535    }
1536
1537    // -- classify_image_alias (Phase 1: lifted from ImageRenderer) ----------
1538
1539    #[test]
1540    fn test_classify_image_alias_none() {
1541        let c = classify_image_alias(None);
1542        assert_eq!(c.display_keywords, None);
1543        assert_eq!(c.caption, None);
1544    }
1545
1546    #[test]
1547    fn test_classify_image_alias_empty_is_none_never_some_empty() {
1548        // THE invariant: an empty alias yields caption=None, never Some(""),
1549        // so no caller emits an empty <figcaption>.
1550        let c = classify_image_alias(Some(""));
1551        assert_eq!(c.display_keywords, None);
1552        assert_eq!(c.caption, None);
1553    }
1554
1555    #[test]
1556    fn test_classify_image_alias_structural_single_keyword() {
1557        let c = classify_image_alias(Some("cover"));
1558        assert_eq!(c.display_keywords.as_deref(), Some("cover"));
1559        assert_eq!(c.caption, None);
1560    }
1561
1562    #[test]
1563    fn test_classify_image_alias_structural_compound() {
1564        // `wide cover` = width token + fit keyword — fully structural.
1565        let c = classify_image_alias(Some("wide cover"));
1566        assert_eq!(c.display_keywords.as_deref(), Some("wide cover"));
1567        assert_eq!(c.caption, None);
1568    }
1569
1570    #[test]
1571    fn test_classify_image_alias_pure_width_token_is_structural() {
1572        // A bare width token alone is structural, not a caption.
1573        let c = classify_image_alias(Some("wide"));
1574        assert_eq!(c.display_keywords.as_deref(), Some("wide"));
1575        assert_eq!(c.caption, None);
1576    }
1577
1578    #[test]
1579    fn test_classify_image_alias_caption_text() {
1580        let c = classify_image_alias(Some("My nice photo"));
1581        assert_eq!(c.display_keywords, None);
1582        assert_eq!(c.caption.as_deref(), Some("My nice photo"));
1583    }
1584
1585    #[test]
1586    fn test_is_structural_alias_matches_classifier() {
1587        // Sanity: the lifted helper agrees with the classifier's branch.
1588        assert!(is_structural_alias("cover"));
1589        assert!(is_structural_alias("wide cover"));
1590        assert!(is_structural_alias("top left"));
1591        assert!(!is_structural_alias("My nice photo"));
1592        assert!(!is_structural_alias(""));
1593    }
1594
1595    // -- parse_image_width -------------------------------------------------
1596
1597    #[test]
1598    fn parse_image_width_named_tokens() {
1599        assert_eq!(parse_image_width("wide").as_deref(), Some("wide"));
1600        assert_eq!(parse_image_width("full").as_deref(), Some("screen")); // alias
1601        assert_eq!(parse_image_width("body").as_deref(), Some("body"));
1602    }
1603
1604    #[test]
1605    fn parse_image_width_percent() {
1606        assert_eq!(parse_image_width("55%").as_deref(), Some("55%"));
1607        assert_eq!(parse_image_width("100%").as_deref(), Some("100%"));
1608        assert_eq!(parse_image_width(" 40% ").as_deref(), Some("40%")); // trimmed
1609    }
1610
1611    #[test]
1612    fn parse_image_width_clamps_and_rejects() {
1613        assert_eq!(parse_image_width("150%").as_deref(), Some("100%")); // clamp > 100
1614        assert_eq!(parse_image_width("0%"), None); // reject <= 0
1615        assert_eq!(parse_image_width("-5%"), None); // reject negative
1616        assert_eq!(parse_image_width("50.5%").as_deref(), Some("50.5%")); // f32 ok
1617    }
1618
1619    #[test]
1620    fn parse_image_width_rejects_non_width() {
1621        assert_eq!(parse_image_width("wide angle photo"), None); // multi-word caption
1622        assert_eq!(parse_image_width("200x150"), None); // box sizing not a figure width
1623        assert_eq!(parse_image_width("hello"), None);
1624        assert_eq!(parse_image_width(""), None);
1625    }
1626
1627    // -- split_alt_width ---------------------------------------------------
1628
1629    #[test]
1630    fn split_alt_width_extracts_and_preserves() {
1631        // (remaining_alt, width)
1632        assert_eq!(
1633            split_alt_width("My caption|55%"),
1634            ("My caption".to_string(), Some("55%".to_string()))
1635        );
1636        assert_eq!(
1637            split_alt_width("55%"),
1638            (String::new(), Some("55%".to_string()))
1639        );
1640        assert_eq!(
1641            split_alt_width("wide"),
1642            (String::new(), Some("wide".to_string()))
1643        );
1644        // No width → alt unchanged, no pipe collapse
1645        assert_eq!(
1646            split_alt_width("just a caption"),
1647            ("just a caption".to_string(), None)
1648        );
1649        // Width is any one segment; other segments preserved joined by '|'
1650        assert_eq!(
1651            split_alt_width("cap|55%|extra"),
1652            ("cap|extra".to_string(), Some("55%".to_string()))
1653        );
1654        // Only the FIRST width-looking segment is consumed
1655        assert_eq!(
1656            split_alt_width("40%|60%"),
1657            ("60%".to_string(), Some("40%".to_string()))
1658        );
1659    }
1660
1661    // -- set_image_width ---------------------------------------------------
1662
1663    #[test]
1664    fn set_image_width_standard_markdown() {
1665        // add to a bare image
1666        assert_eq!(
1667            set_image_width("![alt](pic.jpg)", Some("55%")),
1668            "![alt|55%](pic.jpg)"
1669        );
1670        // replace an existing percent
1671        assert_eq!(
1672            set_image_width("![alt|30%](pic.jpg)", Some("55%")),
1673            "![alt|55%](pic.jpg)"
1674        );
1675        // replace an existing named token
1676        assert_eq!(
1677            set_image_width("![alt|wide](pic.jpg)", Some("55%")),
1678            "![alt|55%](pic.jpg)"
1679        );
1680        // remove (double-click reset)
1681        assert_eq!(
1682            set_image_width("![alt|55%](pic.jpg)", None),
1683            "![alt](pic.jpg)"
1684        );
1685        // add to empty-alt image
1686        assert_eq!(
1687            set_image_width("![](pic.jpg)", Some("55%")),
1688            "![|55%](pic.jpg)"
1689        );
1690        // preserve a caption segment
1691        assert_eq!(
1692            set_image_width("![My cap|30%](pic.jpg)", Some("55%")),
1693            "![My cap|55%](pic.jpg)"
1694        );
1695    }
1696
1697    #[test]
1698    fn set_image_width_wikilink() {
1699        assert_eq!(
1700            set_image_width("![[pic.jpg]]", Some("55%")),
1701            "![[pic.jpg|55%]]"
1702        );
1703        assert_eq!(
1704            set_image_width("![[pic.jpg|30%]]", Some("55%")),
1705            "![[pic.jpg|55%]]"
1706        );
1707        assert_eq!(
1708            set_image_width("![[pic.jpg|wide]]", Some("55%")),
1709            "![[pic.jpg|55%]]"
1710        );
1711        assert_eq!(set_image_width("![[pic.jpg|55%]]", None), "![[pic.jpg]]");
1712        // preserve a caption pothole segment
1713        assert_eq!(
1714            set_image_width("![[pic.jpg|My cap|30%]]", Some("55%")),
1715            "![[pic.jpg|My cap|55%]]"
1716        );
1717        assert_eq!(
1718            set_image_width("![[pic.jpg|My cap]]", Some("55%")),
1719            "![[pic.jpg|My cap|55%]]"
1720        );
1721    }
1722
1723    #[test]
1724    fn set_image_width_validates() {
1725        // out-of-range width is clamped/rejected by parse_image_width
1726        assert_eq!(
1727            set_image_width("![a](p.jpg)", Some("150%")),
1728            "![a|100%](p.jpg)"
1729        );
1730        // an unrecognized width string is ignored → treated as removal-or-noop
1731        assert_eq!(
1732            set_image_width("![a|30%](p.jpg)", Some("garbage")),
1733            "![a](p.jpg)"
1734        );
1735    }
1736
1737    #[test]
1738    fn set_image_width_passthrough_non_image() {
1739        // Not an image syntax → returned unchanged (defensive).
1740        assert_eq!(set_image_width("plain text", Some("55%")), "plain text");
1741    }
1742
1743    // -- color= pipe attr ---------------------------------------------------
1744
1745    #[test]
1746    fn parse_color_attr() {
1747        let attrs = parse_media_attrs("color=black");
1748        assert_eq!(attrs.color.as_deref(), Some("black"));
1749
1750        let attrs = parse_media_attrs("color=#0a0a0a");
1751        assert_eq!(attrs.color.as_deref(), Some("#0a0a0a"));
1752    }
1753
1754    #[test]
1755    fn parse_color_attr_alongside_keywords() {
1756        let attrs = parse_media_attrs("contain color=rgb(10,10,10) top left");
1757        assert_eq!(attrs.color.as_deref(), Some("rgb(10,10,10)"));
1758        assert!(attrs.fit.is_some(), "fit keyword must still parse");
1759        assert!(attrs.position.is_some(), "position keywords must still parse");
1760    }
1761
1762    #[test]
1763    fn parse_empty_color_attr_is_none() {
1764        let attrs = parse_media_attrs("color=");
1765        assert_eq!(attrs.color, None);
1766        assert!(attrs.is_empty());
1767    }
1768
1769    #[test]
1770    fn color_attr_alone_is_not_empty() {
1771        let attrs = parse_media_attrs("color=black");
1772        assert!(!attrs.is_empty());
1773    }
1774
1775    #[test]
1776    fn color_attr_does_not_leak_into_style_or_class() {
1777        let attrs = parse_media_attrs("color=black");
1778        assert_eq!(attrs.to_inline_style(), None);
1779        assert_eq!(attrs.class_attr(), None);
1780    }
1781
1782    #[test]
1783    fn repeated_color_attr_last_wins() {
1784        // Consistent with fit/position: later tokens overwrite earlier ones.
1785        let attrs = parse_media_attrs("color=red color=blue");
1786        assert_eq!(attrs.color.as_deref(), Some("blue"));
1787    }
1788}