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/reference/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        // `![` off the front and `)` off the back, then split on the last `](`:
508        // the same three anchors the byte offsets used, without the arithmetic.
509        let body = image_md
510            .strip_prefix("![")
511            .and_then(|rest| rest.strip_suffix(')'));
512        if let Some((alt_raw, url)) = body.and_then(|body| body.rsplit_once("](")) {
513            {
514                let (rest_alt, _old) = split_alt_width(alt_raw);
515                // Setting a width always emits `![{alt}|{w}]` — even when the
516                // remaining alt is empty (`![|55%]`), so it round-trips with
517                // the standard-image parser's empty-alt-with-width form
518                // (`Block::Figure` carries the width, caption stays None).
519                let alt_out = match new_width {
520                    Some(w) => format!("{}|{}", rest_alt, w),
521                    None => rest_alt,
522                };
523                return format!("![{}]({})", alt_out, url);
524            }
525        }
526    }
527
528    image_md.to_string()
529}
530
531/// Parse a wikilink alias for an embedded width token plus the remaining
532/// alias content.
533///
534/// The wikilink parser (`parse_wikilink_inner`) splits on the first `|` only,
535/// so when an author writes `![[img|caption|full]]`, the resulting `alias`
536/// string is `"caption|full"`. This helper splits the alias on `|` and pulls
537/// out a bare width-token segment (per [`match_width_token`]) without
538/// reordering the others. The remaining segments are rejoined with `|`.
539///
540/// Returns `(width, remaining_alias)`:
541///
542/// - `width = Some("body|wide|page|screen")` if exactly one segment matched
543///   a width token (per the "entire alias-segment is exactly one of the
544///   tokens" rule). Width tokens never shadow longer captions.
545/// - `remaining_alias` is the trimmed concatenation of non-width segments,
546///   joined with `|`. Empty if the only segment was the width token.
547///
548/// If no width token is found, returns `(None, alias.to_string())` — the
549/// caller falls through to its existing alias handling.
550pub fn extract_width_from_alias(alias: &str) -> (Option<&'static str>, String) {
551    let segments: Vec<&str> = alias.split('|').collect();
552    let mut width: Option<&'static str> = None;
553    let mut remaining: Vec<&str> = Vec::with_capacity(segments.len());
554
555    for seg in &segments {
556        let trimmed = seg.trim();
557        if width.is_none() {
558            if let Some(canonical) = match_width_token(trimmed) {
559                width = Some(canonical);
560                continue;
561            }
562        }
563        remaining.push(seg);
564    }
565
566    (width, remaining.join("|"))
567}
568
569/// Return `true` if every token in `text` is a recognized display keyword.
570///
571/// Handles single-token keywords (`"left"`, `"contain"`) and two-word position
572/// keywords (`"top left"`).  An empty string returns `false`.
573pub fn is_all_display_keywords(text: &str) -> bool {
574    let tokens: Vec<&str> = text.split_whitespace().collect();
575    if tokens.is_empty() {
576        return false;
577    }
578
579    let mut i = 0;
580    while i < tokens.len() {
581        // Try combining current token with next for two-word positions.
582        if i + 1 < tokens.len() {
583            let combined = format!("{} {}", tokens[i], tokens[i + 1]);
584            if Position::from_keyword(&combined).is_some() {
585                i += 2;
586                continue;
587            }
588        }
589
590        if Fit::from_keyword(tokens[i]).is_some() {
591            i += 1;
592            continue;
593        }
594
595        if Position::from_keyword(tokens[i]).is_some() {
596            i += 1;
597            continue;
598        }
599
600        if AlignSide::from_keyword(tokens[i]).is_some() {
601            i += 1;
602            continue;
603        }
604
605        return false;
606    }
607
608    true
609}
610
611/// True when every whitespace-separated token in `alias` is either a
612/// recognized display keyword (fit / position / align) OR a canonical
613/// width token (body / wide / page / screen / full).
614///
615/// This is the structural-vs-caption classifier for image aliases: a
616/// fully-structural alias contributes only to display params; anything else
617/// becomes caption / alt text. The [`is_all_display_keywords`] half is
618/// unchanged (covers two-word position tokens like `top left`); the
619/// width-token half lets authors write `align-left wide` without breaking
620/// the pipe.
621///
622/// Lifted from `resolve::embed_renderer` (Phase 1 of the image-embed
623/// synth-collapse) so it survives `ImageRenderer`'s deletion — it is the
624/// load-bearing half of [`classify_image_alias`].
625pub(crate) fn is_structural_alias(alias: &str) -> bool {
626    // Fast path: any caption-like text fails `is_all_display_keywords`
627    // and would also fail the per-token loop below.
628    if is_all_display_keywords(alias) {
629        return true;
630    }
631    let tokens: Vec<&str> = alias.split_whitespace().collect();
632    if tokens.is_empty() {
633        return false;
634    }
635    // Walk tokens; admit width tokens, otherwise defer to display-keyword
636    // recognition (per-token, since position tokens may pair across two).
637    let mut i = 0;
638    while i < tokens.len() {
639        // Width token: single-token, simple admit.
640        if match_width_token(tokens[i]).is_some() {
641            i += 1;
642            continue;
643        }
644        // Two-word position (e.g. `top left`).
645        if i + 1 < tokens.len() {
646            let combined = format!("{} {}", tokens[i], tokens[i + 1]);
647            if Position::from_keyword(&combined).is_some() {
648                i += 2;
649                continue;
650            }
651        }
652        // Single-token display keyword.
653        if Fit::from_keyword(tokens[i]).is_some()
654            || Position::from_keyword(tokens[i]).is_some()
655            || AlignSide::from_keyword(tokens[i]).is_some()
656        {
657            i += 1;
658            continue;
659        }
660        return false;
661    }
662    true
663}
664
665/// Classification of an image-embed pipe alias into its display-vs-caption
666/// role.
667///
668/// The pipe alias of `![[photo.jpg|<alias>]]` is one of three things:
669/// a run of structural display keywords (`cover`, `wide cover`), human-
670/// readable caption prose (`My nice photo`), or absent/empty. This struct
671/// captures the disambiguation so every image-embed call site classifies
672/// identically.
673#[derive(Debug, Clone, PartialEq, Eq)]
674pub(crate) struct ImageAliasClass {
675    /// Structural display-keyword run (e.g. `"cover"`, `"wide cover"`) to be
676    /// fed to `parse_media_attrs`; `None` when the alias is a caption or
677    /// empty.
678    pub display_keywords: Option<String>,
679    /// Caption text (also used as `alt`) when the alias is human-readable
680    /// prose; `None` for structural/empty aliases.
681    ///
682    /// **Invariant:** never `Some("")`. An empty alias yields `None` so
683    /// callers never emit an empty `<figcaption>`.
684    pub caption: Option<String>,
685}
686
687/// Classify an image-embed pipe alias into [`ImageAliasClass`].
688///
689/// Mirrors the 3-way split previously inlined in
690/// `ImageRenderer::render_to_markdown` (now lifted so it survives that
691/// struct's deletion in the image-embed synth-collapse):
692///
693/// - `None`                       → both `None`
694/// - `Some("")` (empty)           → both `None` (no empty figcaption)
695/// - `Some(s)` and structural     → `display_keywords = Some(s)`, `caption = None`
696/// - `Some(other)`                → `display_keywords = None`, `caption = Some(other)`
697pub(crate) fn classify_image_alias(alias: Option<&str>) -> ImageAliasClass {
698    match alias {
699        // Empty alias (`![[file|]]`) is treated as no alias. Matches the
700        // historical `alias.is_empty()` guard exactly (no extra trimming).
701        Some(a) if a.is_empty() => ImageAliasClass {
702            display_keywords: None,
703            caption: None,
704        },
705        Some(a) if is_structural_alias(a) => ImageAliasClass {
706            display_keywords: Some(a.to_string()),
707            caption: None,
708        },
709        Some(other) => ImageAliasClass {
710            display_keywords: None,
711            caption: Some(other.to_string()),
712        },
713        None => ImageAliasClass {
714            display_keywords: None,
715            caption: None,
716        },
717    }
718}
719
720/// Escape a string for safe use in HTML text or attribute values.
721///
722/// Replaces `&`, `"`, `'`, `<`, and `>` with their HTML entities.
723pub fn html_escape(s: &str) -> String {
724    let mut out = String::with_capacity(s.len());
725    for ch in s.chars() {
726        match ch {
727            '&' => out.push_str("&amp;"),
728            '"' => out.push_str("&quot;"),
729            '\'' => out.push_str("&#39;"),
730            '<' => out.push_str("&lt;"),
731            '>' => out.push_str("&gt;"),
732            _ => out.push(ch),
733        }
734    }
735    out
736}
737
738// ---------------------------------------------------------------------------
739// Resolution
740// ---------------------------------------------------------------------------
741
742/// Returns `true` if the path looks like an external URL or data URI.
743fn is_external(path: &str) -> bool {
744    path.starts_with("http://")
745        || path.starts_with("https://")
746        || path.starts_with("//")
747        || path.starts_with("data:")
748}
749
750/// Full pipeline: strip wikilink → split pipe → resolve path → parse attrs.
751///
752/// - External URLs (`http://`, `https://`, `//`, `data:`) pass through unchanged.
753/// - Root-relative paths (leading `/`) have the slash stripped.
754/// - Everything else is resolved via [`ContentGraph::resolve_path`], falling
755///   back to the raw path if unresolved.
756pub(crate) fn resolve_media_ref(raw: &str, source_path: &str, graph: &ContentGraph) -> ResolvedMedia {
757    let inner = strip_wikilink(raw);
758    let (path_part, attrs_str) = split_pipe(inner);
759    let path_trimmed = path_part.trim();
760    let attrs = parse_media_attrs(attrs_str);
761
762    let resolved_path = if is_external(path_trimmed) {
763        // External URL — passthrough.
764        path_trimmed.to_string()
765    } else if let Some(stripped) = path_trimmed.strip_prefix('/') {
766        // Root-relative — strip leading slash.
767        stripped.to_string()
768    } else {
769        // Resolve via content graph, fall back to raw path.
770        graph
771            .resolve_path(path_trimmed, source_path)
772            .unwrap_or_else(|| path_trimmed.to_string())
773    };
774
775    ResolvedMedia {
776        path: resolved_path,
777        attrs,
778    }
779}
780
781// ---------------------------------------------------------------------------
782// Tests
783// ---------------------------------------------------------------------------
784
785#[cfg(test)]
786#[path = "media_tests.rs"]
787mod tests;