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 /// (``) 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 `` 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:  ────────────────────────────────────────
506 if image_md.starts_with("
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("&"),
728 '"' => out.push_str("""),
729 '\'' => out.push_str("'"),
730 '<' => out.push_str("<"),
731 '>' => out.push_str(">"),
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;