Skip to main content

moss_core/ast/
extract_hero.rs

1//! Pre-render Hero extraction.
2//!
3//! Walks the top-level [`Document::blocks`] looking for the first
4//! `Block::Shortcode(Shortcode::Hero(_))`, removes it from the document,
5//! and returns the rendered hero HTML plus the OG-fallback fields the
6//! cover/description chains consume.
7//!
8//! # Why extract-at-caller (Phase 4 PR7a)
9//!
10//! Production has historically hoisted the first `:::hero` block to the
11//! article template's hero slot (the rendered HTML lands in the template
12//! header, separate from the body). `apply_typed_shortcodes` intercepted
13//! Hero variants in the AST before they reached the HTML renderer.
14//!
15//! When PR7a flips production to `render_document`, the body renderer
16//! walks the full block sequence. Letting it render a Hero shortcode
17//! inline would duplicate the slot rendering (hero appears in BOTH the
18//! template hero slot AND the body) OR force the hooks to emit nothing
19//! for Hero (which the renderer can't distinguish from a real empty
20//! emission).
21//!
22//! Extract-at-caller solves this cleanly:
23//! 1. The pipeline calls `extract_hero(&mut doc, &hooks)` BEFORE
24//!    `render_document(&doc, &hooks)`.
25//! 2. `extract_hero` walks `doc.blocks`, finds the first Hero, calls
26//!    the hooks to render it, removes the Hero block from `doc.blocks`,
27//!    and returns the rendered HTML + captured OG fields.
28//! 3. `render_document` then walks the hero-free block sequence; no
29//!    special Hero arm needed in the renderer or hooks.
30//!
31//! # Why top-level only
32//!
33//! Per the current SoCiviC + chps fixtures (the 4 client sites at Phase 4
34//! cutover), `:::hero` blocks only appear at the document top level
35//! (or as the only block in the document). The extractor doesn't descend
36//! into shortcode bodies. If a future fixture nests Hero inside Grid
37//! cells, this function will not extract it — the renderer's hooks
38//! implementation must decide what to do then (probably error or render
39//! inline). Keeping the extractor top-level matches today's interception
40//! semantics in `apply_typed_shortcodes`.
41
42use super::document::Document;
43use super::hooks::RenderHooks;
44use super::node::Block;
45use super::parser::{parse_fragment_with_config, ParseConfig};
46use crate::resolve::md_extract::{AssetPathSpan, MediaLineSpan, PathContainer};
47use super::shortcode::{HeroShortcode, Shortcode};
48use super::url::Url;
49
50/// Captured Hero data after extraction.
51///
52/// The pipeline threads these into `ParsedDocument`:
53/// - `html` — rendered `<section class="moss-hero">…</section>` lands in
54///   the template hero slot.
55/// - `image_url` — drives the homepage-hero rung of the cover chain.
56/// - `overlay_text` — drives the homepage-hero rung of the description
57///   chain (first-paragraph text extraction).
58#[derive(Debug, Default, Clone)]
59pub struct HeroExtraction {
60    pub html: String,
61    pub image_url: Option<String>,
62    pub overlay_text: Option<String>,
63}
64
65/// Find and extract the first top-level Hero shortcode from `doc`.
66///
67/// Returns `Some(HeroExtraction)` if a Hero was found (and removed from
68/// `doc.blocks`); returns `None` if the document has no Hero at the top
69/// level.
70///
71/// The Hero is rendered via `hooks.render_shortcode(&mut out, sc)` — the
72/// caller's `RenderHooks` impl decides the exact byte shape (production
73/// uses `PipelineHooks::render_shortcode` with the Hero arm calling
74/// `render_hero_html_typed`).
75pub fn extract_hero(doc: &mut Document, hooks: &dyn RenderHooks) -> Option<HeroExtraction> {
76    let hero_idx = doc.blocks.iter().position(|b| {
77        matches!(b, Block::Shortcode(Shortcode::Hero(_)))
78    })?;
79
80    // Pop the block from the document. Keep `block_meta` in sync — both
81    // vecs must remain the same length per the Document invariant
82    // asserted in `render_document`.
83    // Capture source_line before removing meta so the hero template slot
84    // can carry data-source-range for click-to-source in the preview.
85    let hero_source_line = doc.block_meta.get(hero_idx).and_then(|m| m.source_line);
86    let hero_block = doc.blocks.remove(hero_idx);
87    if hero_idx < doc.block_meta.len() {
88        doc.block_meta.remove(hero_idx);
89    }
90
91    // Pattern-match again to access the typed HeroShortcode for OG-fallback
92    // field capture.
93    let hero_shortcode = match &hero_block {
94        Block::Shortcode(sc) => sc,
95        _ => return None,
96    };
97    let hero_args = match hero_shortcode {
98        Shortcode::Hero(args) => args,
99        _ => return None,
100    };
101
102    // OG-fallback fields, read directly from the typed AST (post URL
103    // resolution by `resolve_urls`). The plan's Decision 1 calls this out:
104    //   "captures `image_url` from `args.image` (Url::Resolved → href);
105    //    captures `overlay_text` from the existing `args.overlay_text` field"
106    let image_url = match &hero_args.image {
107        Some(Url::Resolved(r)) => Some(r.href.clone()),
108        Some(Url::Unresolved(s)) => {
109            // Defensive: visit_urls_mut / resolve_urls should have
110            // classified this; if not, return raw so the cover chain
111            // still gets a value (silent None would erase the hero rung).
112            debug_assert!(
113                false,
114                "Url::Unresolved({s:?}) reached extract_hero — \
115                 resolve_urls missing for Hero (image)"
116            );
117            Some(s.clone())
118        }
119        None => None,
120    };
121
122    // overlay_text: walk the typed overlay first; fall back to the
123    // captured-at-parse-time markdown source if the typed walk yields
124    // empty.
125    //
126    // Plan Decision 1 notes the existing `overlay_text` field is the
127    // one PR4.5 flagged as the TODO(phase4-cleanup) consumed at
128    // extract-at-caller. Today we still also walk the typed Vec<Block>
129    // (production builds overlay_text alongside the typed overlay, so
130    // either source works); when the TODO is closed, only the typed
131    // walk remains.
132    let walked = first_paragraph_plain_text(&hero_args.overlay);
133    let overlay_text = if !walked.trim().is_empty() {
134        Some(walked)
135    } else if !hero_args.overlay_text.trim().is_empty() {
136        Some(hero_args.overlay_text.clone())
137    } else {
138        None
139    };
140
141    // Render via the hooks' Hero arm. Production's `PipelineHooks`
142    // dispatches to `render_hero_html_typed` which produces the full
143    // section+slot+overlay HTML.
144    let mut html = String::new();
145    // Hero is hoisted out of the body to the article template's hero slot.
146    // The slot IS in the preview DOM and clickable, so we pass source_line
147    // so the rendered section carries data-source-range for click-to-source.
148    hooks.render_shortcode(&mut html, hero_shortcode, hero_source_line);
149
150    Some(HeroExtraction {
151        html,
152        image_url,
153        overlay_text,
154    })
155}
156
157/// Walk a typed block sequence and return the first paragraph's plain
158/// text (no markdown formatting). Returns empty string if no paragraph
159/// is found.
160///
161/// Mirrors the intent of `crate::build::page::meta::extract_description`
162/// but operates on the typed AST instead of markdown source — the
163/// described follow-up at `HeroShortcode::overlay_text` (TODO
164/// `phase4-cleanup`).
165fn first_paragraph_plain_text(blocks: &[Block]) -> String {
166    for block in blocks {
167        match block {
168            Block::Paragraph(inlines) => {
169                return crate::ast::plain_text::inlines_to_plain_text(inlines)
170            }
171            // Skip headings and shortcodes; the description chain wants
172            // first body prose. Lists and other paragraphs follow if the
173            // first hit didn't qualify.
174            //
175            // This walk is deliberately SHALLOW — it never descends into a
176            // container, `Block::FootnoteDefinition` included. Endnote prose
177            // is not the overlay's opening line, and a footnote body reached
178            // by recursion would land in `<meta name="description">`.
179            _ => continue,
180        }
181    }
182    String::new()
183}
184
185// ── Source parsing: `:::hero` block → HeroShortcode ─────────────────────
186//
187// Moved here from `shortcode_extract` (2026-08-03): the hero concern gets
188// one owner. `shortcode_extract` still routes the `:::hero` name to
189// `parse_hero`; everything that decides what a hero's image IS lives here.
190
191/// Parse a `:::hero` block in any of three syntactic forms.
192///
193/// Image source priority:
194/// 1. `image=path` attribute in the `{...}` block (new grammar).
195/// 2. **Directive-line path**: `:::hero ./path.jpg` or
196///    `:::hero ./path.jpg|attrs` or `:::hero ./path.jpg {.classes}` —
197///    moss-releases / client-site backward-compat. The path appears as
198///    raw text before any `{...}` attribute block.
199/// 3. **Body-image fallback**: scan first non-empty body line for a
200///    media reference (`![[path|attrs]]`, `![alt](path|attrs)`, or
201///    bare media filename). Step 3 of the grammar migration rewrites
202///    these to use the `image=` attribute.
203/// 4. None — renderer emits a `<section>` with no `<img>`.
204///
205/// Returns `(HeroShortcode, bool, Vec<String>)` where the bool is `true`
206/// when the body-image fallback (Priority 3) fired, signaling the caller
207/// to emit a deprecation warning, and the `Vec<String>` carries warnings
208/// collected while re-parsing the overlay body as a fragment (e.g. a
209/// misspelled `:::name` shortcode nested inside the overlay) — see
210/// [`parse_overlay_to_blocks`].
211pub(super) fn parse_hero(args: &str, body: &str, config: &ParseConfig) -> (HeroShortcode, bool, Vec<String>) {
212    let trimmed_args = args.trim();
213
214    // Split args on the first `{` to separate the directive-line path
215    // (if any) from the attribute block (if any).
216    let (positional, attr_block): (&str, &str) = if let Some(pos) = trimmed_args.find('{') {
217        // char-aligned: pos points to ASCII '{' from str::find — safe to slice.
218        #[allow(clippy::string_slice)]
219        (trimmed_args[..pos].trim(), &trimmed_args[pos..])
220    } else {
221        (trimmed_args, "")
222    };
223
224    // Parse the attribute block, if present.
225    let parsed = if attr_block.is_empty() {
226        Default::default()
227    } else {
228        crate::ast::attrs::parse_attrs(attr_block).unwrap_or_default()
229    };
230    let classes = parsed.class_string();
231    let width = parsed.width.map(str::to_string);
232    let mobile = parsed.get("mobile").map(str::to_string);
233    // Read once, for all three image-source branches below — a caption belongs
234    // to the hero, not to whichever syntax named its image.
235    let caption = parsed.get("caption").unwrap_or_default().trim().to_string();
236
237    // Priority 1: `image=` attribute.
238    if let Some(image_value) = parsed.get("image") {
239        let (path, attrs_str) = crate::media::split_pipe(image_value);
240        let overlay_text = body.trim().to_string();
241        let (overlay, overlay_warnings) = parse_overlay_to_blocks(&overlay_text, config);
242        return (
243            HeroShortcode {
244                image: if path.trim().is_empty() {
245                    None
246                } else {
247                    Some(Url::unresolved(path.trim().to_string()))
248                },
249                extra_images: Vec::new(),
250                attrs: attrs_str.to_string(),
251                classes,
252                overlay,
253                overlay_text,
254                width,
255                mobile,
256                caption,
257            },
258            false,
259            overlay_warnings,
260        );
261    }
262
263    // Priority 2: directive-line path (legacy syntax). When the
264    // positional text is non-empty, treat it as the image path with
265    // optional `|attrs` pipe suffix. Body becomes pure overlay markdown.
266    if !positional.is_empty() {
267        let (path, attrs_str) = crate::media::split_pipe(positional);
268        let overlay_text = body.trim().to_string();
269        let (overlay, overlay_warnings) = parse_overlay_to_blocks(&overlay_text, config);
270        return (
271            HeroShortcode {
272                image: if path.trim().is_empty() {
273                    None
274                } else {
275                    Some(Url::unresolved(path.trim().to_string()))
276                },
277                extra_images: Vec::new(),
278                attrs: attrs_str.to_string(),
279                classes,
280                overlay,
281                overlay_text,
282                width,
283                mobile,
284                caption,
285            },
286            false,
287            overlay_warnings,
288        );
289    }
290
291    // Priority 3: body-image fallback. Every CONSECUTIVE leading media
292    // line is a background slide (2026-07-27 multi-image hero) — the
293    // first is the primary image, the rest `extra_images`; blank lines
294    // between media lines don't end the run. The first non-media,
295    // non-empty line starts the overlay.
296    // The media/overlay split is `hero_media_run`'s, so the span emitter in
297    // `shortcode_asset_spans` and this parser cannot disagree about which
298    // body lines are slides. Kept honest by `spans_agree_with_parsers`.
299    let lines: Vec<&str> = body.lines().collect();
300    let run = hero_media_run(&lines);
301
302    let mut image_path: Option<String> = None;
303    let mut image_attrs = String::new();
304    let mut extra_images: Vec<Url> = Vec::new();
305    for &k in &run.media {
306        let Some(m) = hero_media_line_span(lines[k]) else {
307            continue;
308        };
309        if image_path.is_none() {
310            image_path = Some(m.path);
311            // Frame-level media attrs (object-fit/position) come from the
312            // primary slide and apply to every slide.
313            image_attrs = m.attrs;
314        } else {
315            extra_images.push(Url::unresolved(m.path));
316        }
317    }
318    let used_priority_3 = image_path.is_some();
319    let overlay_text = run
320        .overlay
321        .iter()
322        .map(|&k| lines[k])
323        .collect::<Vec<_>>()
324        .join("\n")
325        .trim()
326        .to_string();
327    let (overlay, overlay_warnings) = parse_overlay_to_blocks(&overlay_text, config);
328    (
329        HeroShortcode {
330            image: image_path.map(Url::unresolved),
331            extra_images,
332            attrs: image_attrs,
333            classes,
334            overlay,
335            overlay_text,
336            width,
337            mobile,
338            caption,
339        },
340        used_priority_3,
341        overlay_warnings,
342    )
343}
344
345/// Parse a hero overlay's raw markdown source into `Vec<Block>`.
346///
347/// Phase 4 PR4.5 (2026-05-28): mirrors `parse_cell_to_blocks` for the
348/// grid-cell path but without compound-link detection (an overlay is not
349/// a compound-link surface; the SoCiviC pattern is grid-cell-specific).
350/// Returns an empty vec (and no warnings) when the overlay is empty.
351/// Otherwise returns `(blocks, warnings)` — the fragment `Document`'s
352/// warnings (e.g. a misspelled `:::name` shortcode nested inside the
353/// overlay body) are carried out here rather than dropped, so they reach
354/// [`parse_hero`] → [`super::shortcode_extract::parse_shortcode_block`],
355/// which already merges its `Vec<String>` into `doc.warnings`.
356fn parse_overlay_to_blocks(raw: &str, config: &ParseConfig) -> (Vec<Block>, Vec<String>) {
357    if raw.is_empty() {
358        return (Vec::new(), Vec::new());
359    }
360    let doc = parse_fragment_with_config(raw, config);
361    (doc.blocks, doc.warnings)
362}
363
364/// File extensions recognized as media for hero body-image fallback.
365pub(crate) const HERO_MEDIA_EXTENSIONS: &[&str] = &[
366    "jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "mp4", "webm", "mov",
367];
368
369pub(crate) fn is_bare_hero_media(s: &str) -> bool {
370    let (path_part, _) = crate::media::split_pipe(s);
371    let path = path_part.trim();
372    path.rfind('.')
373        .map(|dot| {
374            // char-aligned: dot points to ASCII '.' from str::rfind — `dot + 1`
375            // lands on the byte after '.', which is also a char boundary.
376            #[allow(clippy::string_slice)]
377            let ext = &path[dot + 1..];
378            HERO_MEDIA_EXTENSIONS
379                .iter()
380                .any(|e| e.eq_ignore_ascii_case(ext))
381        })
382        .unwrap_or(false)
383}
384
385/// Recognize one hero media line, with LINE-RELATIVE byte offsets.
386///
387/// The single grammar for a hero media line: [`parse_hero_media_line`] is a
388/// wrapper over it and [`crate::ast::shortcode_extract::shortcode_asset_spans`]
389/// lifts its offsets to absolute. Three arms, in the historical order —
390/// wikilink embed, markdown image, bare media filename.
391pub(crate) fn hero_media_line_span(line: &str) -> Option<MediaLineSpan> {
392    let lead = line.len() - line.trim_start().len();
393    let trimmed = line.trim();
394
395    // Wikilink embed: ![[path|attrs]]
396    if let Some(inner) = trimmed
397        .strip_prefix("![[")
398        .and_then(|s| s.strip_suffix("]]"))
399    {
400        let (path, attrs_str) = crate::media::split_pipe(inner);
401        // `![[` is 3 ASCII bytes.
402        let start = lead + 3;
403        return Some(MediaLineSpan {
404            path: path.trim().to_string(),
405            alt: String::new(),
406            attrs: attrs_str.to_string(),
407            value: start..start + inner.len(),
408            value_attrs: attrs_str.to_string(),
409            is_token: true,
410        });
411    }
412
413    // Standard markdown image: ![alt](path|attrs)
414    if trimmed.starts_with("![") {
415        if let Some(paren_open) = trimmed.find("](") {
416            if trimmed.ends_with(')') {
417                // char-aligned: paren_open points to ASCII "](" from str::find
418                // (paren_open + 2 lands on first byte after `](`, char boundary);
419                // `trimmed.len() - 1` is the byte before the trailing ASCII ')'.
420                #[allow(clippy::string_slice)]
421                let inner = &trimmed[paren_open + 2..trimmed.len() - 1];
422                #[allow(clippy::string_slice)]
423                let alt = trimmed[2..paren_open].to_string();
424                let (path, attrs_str) = crate::media::split_pipe(inner);
425                let start = lead + paren_open + 2;
426                return Some(MediaLineSpan {
427                    path: path.trim().to_string(),
428                    alt,
429                    attrs: attrs_str.to_string(),
430                    value: start..start + inner.len(),
431                    value_attrs: attrs_str.to_string(),
432                    is_token: true,
433                });
434            }
435        }
436    }
437
438    // Bare media filename: photo.jpg or photo.jpg|contain
439    if is_bare_hero_media(trimmed) {
440        let (path, attrs_str) = crate::media::split_pipe(trimmed);
441        return Some(MediaLineSpan {
442            path: path.trim().to_string(),
443            alt: String::new(),
444            attrs: attrs_str.to_string(),
445            value: lead..lead + trimmed.len(),
446            value_attrs: attrs_str.to_string(),
447            is_token: false,
448        });
449    }
450
451    None
452}
453
454/// Which body lines of a `:::hero` are background-media slides and which are
455/// overlay markdown.
456///
457/// The Priority-3 consecutive-run rule, lifted verbatim out of [`parse_hero`]
458/// so the span emitter and the parser cannot drift. `overlay` is literally
459/// the index list `parse_hero` pushes — blank lines INSIDE the run excluded,
460/// everything from the first non-media line onward included — rather than
461/// `lines[last_media + 1 ..]`, which is equivalent only by accident of the
462/// trailing `.trim()`.
463#[derive(Debug, Clone, PartialEq, Eq)]
464pub(crate) struct HeroRun {
465    pub media: Vec<usize>,
466    pub overlay: Vec<usize>,
467}
468
469pub(crate) fn hero_media_run(lines: &[&str]) -> HeroRun {
470    let mut media = Vec::new();
471    let mut overlay = Vec::new();
472    let mut in_media_run = true;
473    let mut have_primary = false;
474
475    for (k, line) in lines.iter().enumerate() {
476        if in_media_run {
477            if line.trim().is_empty() {
478                continue;
479            }
480            if let Some(m) = hero_media_line_span(line) {
481                // A bare filename containing whitespace on a CONTINUATION
482                // line is almost certainly prose that happens to end in a
483                // media extension ("Photo: alpine-meadow.jpg") — treat it
484                // as overlay rather than silently eating a caption. The
485                // first line keeps the historical bare-filename grammar.
486                let bare = !line.trim_start().starts_with("![");
487                if !(have_primary && bare && m.path.contains(char::is_whitespace)) {
488                    have_primary = true;
489                    media.push(k);
490                    continue;
491                }
492                // else fall through: this line ends the media run.
493            }
494            // First non-media, non-empty line — overlay starts here.
495            in_media_run = false;
496        }
497        overlay.push(k);
498    }
499
500    HeroRun { media, overlay }
501}
502/// Hero image spans, mirroring [`parse_hero`]'s priority
503/// ladder and short-circuiting at the same points.
504#[allow(clippy::too_many_arguments)]
505pub(crate) fn hero_asset_spans(
506    source: &str,
507    mask: &str,
508    table: &[(usize, usize, usize)],
509    opener: usize,
510    body_start: usize,
511    close: usize,
512    out: &mut Vec<AssetPathSpan>,
513) {
514    let (obase, ocontent, _) = table[opener];
515    #[allow(clippy::string_slice)]
516    let opener_masked = &mask[obase..obase + ocontent];
517
518    // Priority 1: an `image=` attribute anywhere in the (possibly multi-line)
519    // attribute block. `parse_attrs_spanned` is handed the rest of the
520    // document from the `{` — it returns at the first `}` at item position,
521    // so it terminates exactly where the gathered-args form would. See its
522    // doc comment for the constraint that makes this legal.
523    if let Some(brace_rel) = opener_masked.find('{') {
524        let brace_abs = obase + brace_rel;
525        #[allow(clippy::string_slice)]
526        // `find` on ASCII '{' → char boundary.
527        let rest = &source[brace_abs..];
528        if let Ok((_, kvs)) = crate::ast::attrs::parse_attrs_spanned(rest) {
529            if let Some(kv) = kvs.iter().find(|kv| kv.key == "image") {
530                let value = brace_abs + kv.value.start..brace_abs + kv.value.end;
531                let mut item = brace_abs + kv.item.start..brace_abs + kv.item.end;
532                // Absorb one leading space so removing the item doesn't
533                // leave a double space inside the braces.
534                if item.start > brace_abs && source.as_bytes()[item.start - 1] == b' ' {
535                    item.start -= 1;
536                }
537                #[allow(clippy::string_slice)]
538                // Both ends come from `char_indices()` over `rest`.
539                let raw = &source[value.clone()];
540                let inner = raw.strip_prefix('"').and_then(|r| r.strip_suffix('"')).unwrap_or(raw);
541                let (path, attrs) = crate::media::split_pipe(inner);
542                out.push(AssetPathSpan {
543                    path: crate::media::strip_wikilink(path).to_string(),
544                    attrs: attrs.to_string(),
545                    quote: if raw.starts_with('"') { Some('"') } else { None },
546                    value,
547                    outer: item,
548                    container: PathContainer::ShortcodeAttr { key: "image".to_string() },
549                });
550                // Body lines are pure overlay when `image=` is present.
551                return;
552            }
553        }
554    }
555
556    // Priority 2: a positional path on the directive line, before any `{`.
557    // `parse_shortcode_opener` already stripped the colons and the name.
558    let opener_trimmed = opener_masked.trim();
559    if let Some((_, _name, args)) = super::shortcode_extract::parse_shortcode_opener(opener_trimmed) {
560        let cut = args.find('{').unwrap_or(args.len());
561        #[allow(clippy::string_slice)]
562        // `find` on ASCII '{' → char boundary; `args` is a suffix of the line.
563        let positional = args[..cut].trim();
564        if !positional.is_empty() {
565            // Absolute offset of `positional` in the raw line. `args` is
566            // `opener_trimmed` with the colons + name stripped and both ends
567            // trimmed, and `opener_trimmed` has no trailing whitespace, so
568            // `args` ends where `opener_trimmed` does — its start offset is
569            // the length difference. `positional` starts at `args[0]`
570            // because `args` is already left-trimmed.
571            let lead = ocontent - opener_masked.trim_start().len();
572            let start = obase + lead + (opener_trimmed.len() - args.len());
573            let value = start..start + positional.len();
574            let (path, attrs) = crate::media::split_pipe(positional);
575            out.push(AssetPathSpan {
576                path: crate::media::strip_wikilink(path.trim()).to_string(),
577                attrs: attrs.to_string(),
578                quote: None,
579                value: value.clone(),
580                outer: value,
581                container: PathContainer::HeroDirective,
582            });
583            return;
584        }
585    }
586
587    // Priority 3: the leading run of body media lines.
588    let body: Vec<&str> = (body_start..close)
589        .map(|k| {
590            let (base, content, _) = table[k];
591            #[allow(clippy::string_slice)]
592            // Line boundaries from `line_table`; read verbatim, exactly as
593            // `parse_hero` reads them.
594            &source[base..base + content]
595        })
596        .collect();
597    let run = hero_media_run(&body);
598    for &k in &run.media {
599        let Some(m) = hero_media_line_span(body[k]) else {
600            continue;
601        };
602        let (base, content, term) = table[body_start + k];
603        out.push(AssetPathSpan {
604            path: crate::media::strip_wikilink(&m.path).to_string(),
605            attrs: m.value_attrs,
606            quote: None,
607            value: base + m.value.start..base + m.value.end,
608            outer: base..base + content + term,
609            container: PathContainer::HeroBodyMedia,
610        });
611    }
612}
613
614#[cfg(test)]
615#[path = "extract_hero_tests.rs"]
616mod tests;