use super::document::Document;
use super::hooks::RenderHooks;
use super::node::{Block, Inline};
use super::shortcode::Shortcode;
use super::url::Url;
#[derive(Debug, Default, Clone)]
pub struct HeroExtraction {
pub html: String,
pub image_url: Option<String>,
pub overlay_text: Option<String>,
}
pub fn extract_hero(doc: &mut Document, hooks: &dyn RenderHooks) -> Option<HeroExtraction> {
let hero_idx = doc.blocks.iter().position(|b| {
matches!(b, Block::Shortcode(Shortcode::Hero(_)))
})?;
let hero_source_line = doc.block_meta.get(hero_idx).and_then(|m| m.source_line);
let hero_block = doc.blocks.remove(hero_idx);
if hero_idx < doc.block_meta.len() {
doc.block_meta.remove(hero_idx);
}
let hero_shortcode = match &hero_block {
Block::Shortcode(sc) => sc,
_ => return None,
};
let hero_args = match hero_shortcode {
Shortcode::Hero(args) => args,
_ => return None,
};
let image_url = match &hero_args.image {
Some(Url::Resolved(r)) => Some(r.href.clone()),
Some(Url::Unresolved(s)) => {
debug_assert!(
false,
"Url::Unresolved({s:?}) reached extract_hero — \
resolve_urls missing for Hero (image)"
);
Some(s.clone())
}
None => None,
};
let walked = first_paragraph_plain_text(&hero_args.overlay);
let overlay_text = if !walked.trim().is_empty() {
Some(walked)
} else if !hero_args.overlay_text.trim().is_empty() {
Some(hero_args.overlay_text.clone())
} else {
None
};
let mut html = String::new();
hooks.render_shortcode(&mut html, hero_shortcode, hero_source_line);
Some(HeroExtraction {
html,
image_url,
overlay_text,
})
}
fn first_paragraph_plain_text(blocks: &[Block]) -> String {
for block in blocks {
match block {
Block::Paragraph(inlines) => return inlines_plain_text(inlines),
_ => continue,
}
}
String::new()
}
fn inlines_plain_text(inlines: &[Inline]) -> String {
let mut out = String::new();
crate::heading::text::inlines_to_text(inlines, &mut out);
out
}
#[cfg(test)]
mod tests {
use super::super::document::BlockMeta;
use super::super::hooks::DefaultHooks;
use super::super::node::Inline;
use super::super::shortcode::HeroShortcode;
use super::super::url::{Url, UrlKind};
use super::*;
fn hero_block(image_url: Option<&str>, overlay: Vec<Block>) -> Block {
Block::Shortcode(Shortcode::Hero(HeroShortcode {
image: image_url.map(|u| Url::resolved(u, UrlKind::Asset)),
attrs: String::new(),
classes: String::new(),
overlay,
overlay_text: String::new(),
width: None,
mobile: None,
}))
}
#[test]
fn extract_hero_returns_none_when_no_hero_present() {
let mut doc = Document::from_blocks(vec![
Block::Paragraph(vec![Inline::Text("plain".into())]),
]);
let hooks = DefaultHooks::new();
let extraction = extract_hero(&mut doc, &hooks);
assert!(extraction.is_none());
assert_eq!(doc.blocks.len(), 1, "doc unchanged when no hero");
}
#[test]
fn extract_hero_removes_top_level_hero_from_doc() {
let mut doc = Document::from_blocks(vec![
hero_block(Some("hero.jpg"), vec![]),
Block::Paragraph(vec![Inline::Text("body".into())]),
]);
let hooks = DefaultHooks::new();
let extraction = extract_hero(&mut doc, &hooks).expect("hero found");
assert!(!extraction.html.is_empty(), "hero rendered");
assert_eq!(extraction.image_url.as_deref(), Some("hero.jpg"));
assert_eq!(doc.blocks.len(), 1, "hero removed from doc");
assert!(matches!(&doc.blocks[0], Block::Paragraph(_)));
}
#[test]
fn extract_hero_captures_image_url_from_resolved_url() {
let mut doc = Document::from_blocks(vec![hero_block(Some("path/to/hero.webp"), vec![])]);
let hooks = DefaultHooks::new();
let extraction = extract_hero(&mut doc, &hooks).expect("hero found");
assert_eq!(extraction.image_url.as_deref(), Some("path/to/hero.webp"));
}
#[test]
fn extract_hero_image_url_none_when_no_hero_image() {
let mut doc = Document::from_blocks(vec![hero_block(None, vec![])]);
let hooks = DefaultHooks::new();
let extraction = extract_hero(&mut doc, &hooks).expect("hero found");
assert!(extraction.image_url.is_none());
}
#[test]
fn extract_hero_overlay_text_walks_typed_blocks() {
let overlay = vec![Block::Paragraph(vec![
Inline::Text("Hello, ".into()),
Inline::Strong(vec![Inline::Text("world".into())]),
])];
let mut doc = Document::from_blocks(vec![hero_block(None, overlay)]);
let hooks = DefaultHooks::new();
let extraction = extract_hero(&mut doc, &hooks).expect("hero found");
assert_eq!(extraction.overlay_text.as_deref(), Some("Hello, world"));
}
#[test]
fn extract_hero_overlay_text_skips_leading_heading() {
let overlay = vec![
Block::Heading {
level: 2,
children: vec![Inline::Text("Title".into())],
id: None,
},
Block::Paragraph(vec![Inline::Text("Description".into())]),
];
let mut doc = Document::from_blocks(vec![hero_block(None, overlay)]);
let hooks = DefaultHooks::new();
let extraction = extract_hero(&mut doc, &hooks).expect("hero found");
assert_eq!(extraction.overlay_text.as_deref(), Some("Description"));
}
#[test]
fn extract_hero_finds_only_first_hero_when_multiple_present() {
let mut doc = Document::from_blocks(vec![
hero_block(Some("a.jpg"), vec![]),
Block::Paragraph(vec![Inline::Text("middle".into())]),
hero_block(Some("b.jpg"), vec![]),
]);
let hooks = DefaultHooks::new();
let extraction = extract_hero(&mut doc, &hooks).expect("hero found");
assert_eq!(extraction.image_url.as_deref(), Some("a.jpg"));
assert_eq!(doc.blocks.len(), 2);
}
#[test]
fn extract_hero_passes_source_line_to_render_shortcode() {
let hero = hero_block(None, vec![]);
let meta_with_line = BlockMeta { source_line: Some(3), ..BlockMeta::default() };
let mut doc = Document::from_blocks_with_meta(vec![hero], vec![meta_with_line]);
let hooks = DefaultHooks::new();
let extraction = extract_hero(&mut doc, &hooks).expect("hero found");
assert!(
extraction.html.contains(r#"data-source-range="3-3""#),
"hoisted hero should carry data-source-range from block_meta, got: {}",
&extraction.html[..extraction.html.len().min(300)]
);
}
#[test]
fn extract_hero_source_line_none_when_meta_absent() {
let hero = hero_block(None, vec![]);
let mut doc = Document::from_blocks(vec![hero]); let hooks = DefaultHooks::new();
let extraction = extract_hero(&mut doc, &hooks).expect("hero found");
assert!(
!extraction.html.contains("data-source-range"),
"no source_line in meta → no data-source-range, got: {}",
&extraction.html[..extraction.html.len().min(300)]
);
}
#[test]
fn extract_hero_with_no_top_level_hero_in_nested_block() {
let mut doc = Document::from_blocks(vec![Block::BlockQuote(vec![hero_block(
Some("nested.jpg"),
vec![],
)])]);
let hooks = DefaultHooks::new();
let extraction = extract_hero(&mut doc, &hooks);
assert!(extraction.is_none(), "nested hero NOT extracted");
assert_eq!(doc.blocks.len(), 1);
}
}