use super::document::Document;
use super::hooks::RenderHooks;
use super::node::Block;
use super::parser::{parse_fragment_with_config, ParseConfig};
use crate::resolve::md_extract::{AssetPathSpan, MediaLineSpan, PathContainer};
use super::shortcode::{HeroShortcode, 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 crate::ast::plain_text::inlines_to_plain_text(inlines)
}
_ => continue,
}
}
String::new()
}
pub(super) fn parse_hero(args: &str, body: &str, config: &ParseConfig) -> (HeroShortcode, bool, Vec<String>) {
let trimmed_args = args.trim();
let (positional, attr_block): (&str, &str) = if let Some(pos) = trimmed_args.find('{') {
#[allow(clippy::string_slice)]
(trimmed_args[..pos].trim(), &trimmed_args[pos..])
} else {
(trimmed_args, "")
};
let parsed = if attr_block.is_empty() {
Default::default()
} else {
crate::ast::attrs::parse_attrs(attr_block).unwrap_or_default()
};
let classes = parsed.class_string();
let width = parsed.width.map(str::to_string);
let mobile = parsed.get("mobile").map(str::to_string);
let caption = parsed.get("caption").unwrap_or_default().trim().to_string();
if let Some(image_value) = parsed.get("image") {
let (path, attrs_str) = crate::media::split_pipe(image_value);
let overlay_text = body.trim().to_string();
let (overlay, overlay_warnings) = parse_overlay_to_blocks(&overlay_text, config);
return (
HeroShortcode {
image: if path.trim().is_empty() {
None
} else {
Some(Url::unresolved(path.trim().to_string()))
},
extra_images: Vec::new(),
attrs: attrs_str.to_string(),
classes,
overlay,
overlay_text,
width,
mobile,
caption,
},
false,
overlay_warnings,
);
}
if !positional.is_empty() {
let (path, attrs_str) = crate::media::split_pipe(positional);
let overlay_text = body.trim().to_string();
let (overlay, overlay_warnings) = parse_overlay_to_blocks(&overlay_text, config);
return (
HeroShortcode {
image: if path.trim().is_empty() {
None
} else {
Some(Url::unresolved(path.trim().to_string()))
},
extra_images: Vec::new(),
attrs: attrs_str.to_string(),
classes,
overlay,
overlay_text,
width,
mobile,
caption,
},
false,
overlay_warnings,
);
}
let lines: Vec<&str> = body.lines().collect();
let run = hero_media_run(&lines);
let mut image_path: Option<String> = None;
let mut image_attrs = String::new();
let mut extra_images: Vec<Url> = Vec::new();
for &k in &run.media {
let Some(m) = hero_media_line_span(lines[k]) else {
continue;
};
if image_path.is_none() {
image_path = Some(m.path);
image_attrs = m.attrs;
} else {
extra_images.push(Url::unresolved(m.path));
}
}
let used_priority_3 = image_path.is_some();
let overlay_text = run
.overlay
.iter()
.map(|&k| lines[k])
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string();
let (overlay, overlay_warnings) = parse_overlay_to_blocks(&overlay_text, config);
(
HeroShortcode {
image: image_path.map(Url::unresolved),
extra_images,
attrs: image_attrs,
classes,
overlay,
overlay_text,
width,
mobile,
caption,
},
used_priority_3,
overlay_warnings,
)
}
fn parse_overlay_to_blocks(raw: &str, config: &ParseConfig) -> (Vec<Block>, Vec<String>) {
if raw.is_empty() {
return (Vec::new(), Vec::new());
}
let doc = parse_fragment_with_config(raw, config);
(doc.blocks, doc.warnings)
}
pub(crate) const HERO_MEDIA_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "mp4", "webm", "mov",
];
pub(crate) fn is_bare_hero_media(s: &str) -> bool {
let (path_part, _) = crate::media::split_pipe(s);
let path = path_part.trim();
path.rfind('.')
.map(|dot| {
#[allow(clippy::string_slice)]
let ext = &path[dot + 1..];
HERO_MEDIA_EXTENSIONS
.iter()
.any(|e| e.eq_ignore_ascii_case(ext))
})
.unwrap_or(false)
}
pub(crate) fn hero_media_line_span(line: &str) -> Option<MediaLineSpan> {
let lead = line.len() - line.trim_start().len();
let trimmed = line.trim();
if let Some(inner) = trimmed
.strip_prefix("![[")
.and_then(|s| s.strip_suffix("]]"))
{
let (path, attrs_str) = crate::media::split_pipe(inner);
let start = lead + 3;
return Some(MediaLineSpan {
path: path.trim().to_string(),
alt: String::new(),
attrs: attrs_str.to_string(),
value: start..start + inner.len(),
value_attrs: attrs_str.to_string(),
is_token: true,
});
}
if trimmed.starts_with(" {
if trimmed.ends_with(')') {
#[allow(clippy::string_slice)]
let inner = &trimmed[paren_open + 2..trimmed.len() - 1];
#[allow(clippy::string_slice)]
let alt = trimmed[2..paren_open].to_string();
let (path, attrs_str) = crate::media::split_pipe(inner);
let start = lead + paren_open + 2;
return Some(MediaLineSpan {
path: path.trim().to_string(),
alt,
attrs: attrs_str.to_string(),
value: start..start + inner.len(),
value_attrs: attrs_str.to_string(),
is_token: true,
});
}
}
}
if is_bare_hero_media(trimmed) {
let (path, attrs_str) = crate::media::split_pipe(trimmed);
return Some(MediaLineSpan {
path: path.trim().to_string(),
alt: String::new(),
attrs: attrs_str.to_string(),
value: lead..lead + trimmed.len(),
value_attrs: attrs_str.to_string(),
is_token: false,
});
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HeroRun {
pub media: Vec<usize>,
pub overlay: Vec<usize>,
}
pub(crate) fn hero_media_run(lines: &[&str]) -> HeroRun {
let mut media = Vec::new();
let mut overlay = Vec::new();
let mut in_media_run = true;
let mut have_primary = false;
for (k, line) in lines.iter().enumerate() {
if in_media_run {
if line.trim().is_empty() {
continue;
}
if let Some(m) = hero_media_line_span(line) {
let bare = !line.trim_start().starts_with("![");
if !(have_primary && bare && m.path.contains(char::is_whitespace)) {
have_primary = true;
media.push(k);
continue;
}
}
in_media_run = false;
}
overlay.push(k);
}
HeroRun { media, overlay }
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn hero_asset_spans(
source: &str,
mask: &str,
table: &[(usize, usize, usize)],
opener: usize,
body_start: usize,
close: usize,
out: &mut Vec<AssetPathSpan>,
) {
let (obase, ocontent, _) = table[opener];
#[allow(clippy::string_slice)]
let opener_masked = &mask[obase..obase + ocontent];
if let Some(brace_rel) = opener_masked.find('{') {
let brace_abs = obase + brace_rel;
#[allow(clippy::string_slice)]
let rest = &source[brace_abs..];
if let Ok((_, kvs)) = crate::ast::attrs::parse_attrs_spanned(rest) {
if let Some(kv) = kvs.iter().find(|kv| kv.key == "image") {
let value = brace_abs + kv.value.start..brace_abs + kv.value.end;
let mut item = brace_abs + kv.item.start..brace_abs + kv.item.end;
if item.start > brace_abs && source.as_bytes()[item.start - 1] == b' ' {
item.start -= 1;
}
#[allow(clippy::string_slice)]
let raw = &source[value.clone()];
let inner = raw.strip_prefix('"').and_then(|r| r.strip_suffix('"')).unwrap_or(raw);
let (path, attrs) = crate::media::split_pipe(inner);
out.push(AssetPathSpan {
path: crate::media::strip_wikilink(path).to_string(),
attrs: attrs.to_string(),
quote: if raw.starts_with('"') { Some('"') } else { None },
value,
outer: item,
container: PathContainer::ShortcodeAttr { key: "image".to_string() },
});
return;
}
}
}
let opener_trimmed = opener_masked.trim();
if let Some((_, _name, args)) = super::shortcode_extract::parse_shortcode_opener(opener_trimmed) {
let cut = args.find('{').unwrap_or(args.len());
#[allow(clippy::string_slice)]
let positional = args[..cut].trim();
if !positional.is_empty() {
let lead = ocontent - opener_masked.trim_start().len();
let start = obase + lead + (opener_trimmed.len() - args.len());
let value = start..start + positional.len();
let (path, attrs) = crate::media::split_pipe(positional);
out.push(AssetPathSpan {
path: crate::media::strip_wikilink(path.trim()).to_string(),
attrs: attrs.to_string(),
quote: None,
value: value.clone(),
outer: value,
container: PathContainer::HeroDirective,
});
return;
}
}
let body: Vec<&str> = (body_start..close)
.map(|k| {
let (base, content, _) = table[k];
#[allow(clippy::string_slice)]
&source[base..base + content]
})
.collect();
let run = hero_media_run(&body);
for &k in &run.media {
let Some(m) = hero_media_line_span(body[k]) else {
continue;
};
let (base, content, term) = table[body_start + k];
out.push(AssetPathSpan {
path: crate::media::strip_wikilink(&m.path).to_string(),
attrs: m.value_attrs,
quote: None,
value: base + m.value.start..base + m.value.end,
outer: base..base + content + term,
container: PathContainer::HeroBodyMedia,
});
}
}
#[cfg(test)]
#[path = "extract_hero_tests.rs"]
mod tests;