use std::collections::{HashMap, HashSet};
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use super::document::{BlockMeta, Document};
use super::footnotes::FootnoteIndex;
use super::math_text::{math_inline, math_source};
use super::node::{Block, CalloutKind, ColumnAlignment, Fold, Inline};
use super::shortcode::Shortcode;
use super::shortcode_extract::{extract_shortcodes_with_config, parse_placeholder, ExtractedShortcode};
use super::url::Url;
use crate::heading::anchor::obsidian_heading_anchor;
#[derive(Debug, Clone, Copy)]
pub struct ParseConfig {
pub emit_source_lines: bool,
pub implicit_figure: bool,
pub source_line_offset: usize,
pub math: bool,
pub hard_line_breaks: bool,
}
impl Default for ParseConfig {
fn default() -> Self {
Self {
emit_source_lines: false,
implicit_figure: true,
source_line_offset: 0,
math: false,
hard_line_breaks: false,
}
}
}
pub fn parser_options(math: bool) -> Options {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_WIKILINKS);
#[cfg(feature = "cjk-friendly-emphasis")]
options.insert(Options::ENABLE_CJK_FRIENDLY_EMPHASIS);
if math {
options.insert(Options::ENABLE_MATH);
}
options
}
pub fn parse(markdown: &str) -> Document {
parse_with_config(markdown, &ParseConfig::default())
}
pub fn parse_with_config(markdown: &str, config: &ParseConfig) -> Document {
parse_document(markdown, config, HeadingIds::Number)
}
pub(super) fn parse_fragment_with_config(markdown: &str, config: &ParseConfig) -> Document {
parse_document(markdown, config, HeadingIds::LeaveBare)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum HeadingIds {
Number,
LeaveBare,
}
fn parse_document(markdown: &str, config: &ParseConfig, heading_ids: HeadingIds) -> Document {
let extraction = extract_shortcodes_with_config(markdown, config);
let (source, linked_embeds) =
super::linked_embed::substitute(&extraction.markdown_with_placeholders, &extraction.nonce);
let options = parser_options(config.math);
let (events, offsets): (Vec<Event<'_>>, Vec<Option<std::ops::Range<usize>>>) =
if config.emit_source_lines {
let mut evs = Vec::new();
let mut offs = Vec::new();
for (event, range) in Parser::new_ext(&source, options).into_offset_iter() {
evs.push(event);
offs.push(Some(range));
}
(evs, offs)
} else {
let evs: Vec<Event<'_>> = Parser::new_ext(&source, options).collect();
let len = evs.len();
(evs, vec![None; len])
};
let line_lookup = if config.emit_source_lines {
Some(LineLookup::build(&source, config.source_line_offset))
} else {
None
};
let line_ctx: Option<LineCtx<'_>> = line_lookup.as_ref().map(|lookup| LineCtx {
lookup,
offsets: &offsets,
});
let mut blocks = Vec::new();
let mut block_meta: Vec<BlockMeta> = Vec::new();
let mut i = 0;
while i < events.len() {
let event_start_idx = i;
let (block, advance) = parse_block(&events, i, line_ctx.as_ref());
if let Some(b) = block {
let source_line = match (line_lookup.as_ref(), offsets.get(event_start_idx)) {
(Some(lookup), Some(Some(range))) => Some(lookup.line_at(range.start)),
_ => None,
};
blocks.push(b);
block_meta.push(BlockMeta { source_line });
}
i += advance.max(1);
}
substitute_shortcode_placeholders(&mut blocks, &extraction.nonce, &extraction.extracted);
super::linked_embed::restore(&mut blocks, &linked_embeds, config);
if !config.implicit_figure {
for block in blocks.iter_mut() {
unwrap_implicit_figure(block);
}
}
if heading_ids == HeadingIds::Number {
assign_heading_id_suffixes(&mut blocks);
}
let mut doc = Document::from_blocks_with_meta(blocks, block_meta);
doc.warnings = extraction.warnings;
if config.hard_line_breaks {
super::line_breaks::apply(&mut doc);
}
doc
}
pub(crate) fn unwrap_implicit_figure(block: &mut Block) {
if is_implicit_figure(block) {
if let Block::Figure { image, .. } = block {
let img = std::mem::replace(
image,
Inline::Text(String::new()), );
*block = Block::Paragraph(vec![img]);
}
return;
}
match block {
Block::BlockQuote(children)
| Block::Callout { children, .. }
| Block::LinkCard { children, .. }
| Block::FootnoteDefinition { children, .. } => {
for child in children.iter_mut() {
unwrap_implicit_figure(child);
}
}
Block::List { items, .. } => {
for item in items.iter_mut() {
for child in item.iter_mut() {
unwrap_implicit_figure(child);
}
}
}
Block::Shortcode(sc) => match sc {
Shortcode::Grid(args) => {
for cell in args.cells.iter_mut() {
for child in cell.iter_mut() {
unwrap_implicit_figure(child);
}
}
}
Shortcode::Hero(args) => {
for child in args.overlay.iter_mut() {
unwrap_implicit_figure(child);
}
}
Shortcode::Subscribe(_)
| Shortcode::Buttons(_)
| Shortcode::Gallery(_)
| Shortcode::Recent(_)
| Shortcode::Apply(_) => {}
},
_ => {}
}
}
fn is_implicit_figure(block: &Block) -> bool {
matches!(
block,
Block::Figure {
width: None,
align: None,
class_names,
img_style: None,
..
} if class_names.is_empty()
)
}
pub fn unwrap_implicit_figures(doc: &mut Document) {
for block in doc.blocks.iter_mut() {
unwrap_implicit_figure(block);
}
}
struct LineCtx<'a> {
lookup: &'a LineLookup,
offsets: &'a [Option<std::ops::Range<usize>>],
}
impl<'a> LineCtx<'a> {
fn line_at_event(&self, event_index: usize) -> Option<usize> {
match self.offsets.get(event_index) {
Some(Some(range)) => Some(self.lookup.line_at(range.start)),
_ => None,
}
}
}
struct LineLookup {
newline_offsets: Vec<usize>,
line_offset: usize,
}
impl LineLookup {
fn build(source: &str, line_offset: usize) -> Self {
let mut newline_offsets = Vec::new();
for (i, b) in source.bytes().enumerate() {
if b == b'\n' {
newline_offsets.push(i);
}
}
Self {
newline_offsets,
line_offset,
}
}
fn line_at(&self, byte_offset: usize) -> usize {
let body_line = match self.newline_offsets.binary_search(&byte_offset) {
Ok(idx) => idx + 1,
Err(idx) => idx + 1,
};
body_line + self.line_offset
}
}
fn substitute_shortcode_placeholders(
blocks: &mut Vec<Block>,
nonce: &str,
extracted: &[ExtractedShortcode],
) {
for block in blocks.iter_mut() {
if let Block::Other(html) = block {
if let Some(index) = parse_placeholder(nonce, html) {
if let Some(entry) = extracted.iter().find(|e| e.index == index) {
*block = Block::Shortcode(entry.shortcode.clone());
}
}
}
}
}
fn parse_block(
events: &[Event<'_>],
start: usize,
line_ctx: Option<&LineCtx<'_>>,
) -> (Option<Block>, usize) {
match &events[start] {
Event::Start(tag) => parse_block_with_tag(events, start, tag, line_ctx),
Event::Text(_) | Event::Code(_) | Event::Html(_) | Event::SoftBreak | Event::HardBreak => {
(None, 1)
}
Event::End(_) => (None, 1),
Event::Rule => (Some(Block::ThematicBreak), 1),
_ => (None, 1),
}
}
fn parse_block_with_tag(
events: &[Event<'_>],
start: usize,
tag: &Tag<'_>,
line_ctx: Option<&LineCtx<'_>>,
) -> (Option<Block>, usize) {
match tag {
Tag::Heading { level, .. } => {
let (children, end) = collect_inlines_until(events, start + 1, |e| {
matches!(e, Event::End(TagEnd::Heading(_)))
});
let level_num = match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
};
let heading_text = crate::heading::text::events_to_text(events, start + 1, end);
let base_slug = obsidian_heading_anchor(&heading_text);
(
Some(Block::Heading {
level: level_num,
children,
id: Some(base_slug),
}),
end - start + 1,
)
}
Tag::Paragraph => {
let (children, end) = collect_inlines_until(events, start + 1, |e| {
matches!(e, Event::End(TagEnd::Paragraph))
});
let block = match try_promote_to_figure(children, events, start) {
Ok(figure) => figure,
Err(original_inlines) => Block::Paragraph(original_inlines),
};
(Some(block), end - start + 1)
}
Tag::CodeBlock(kind) => {
let lang = match kind {
pulldown_cmark::CodeBlockKind::Fenced(s) if !s.is_empty() => Some(s.to_string()),
_ => None,
};
let mut value = String::new();
let mut i = start + 1;
while i < events.len() {
match &events[i] {
Event::End(TagEnd::CodeBlock) => break,
Event::Text(t) => value.push_str(t),
_ => {}
}
i += 1;
}
(Some(Block::CodeBlock { lang, value }), i - start + 1)
}
Tag::BlockQuote(_) => {
match detect_and_assemble_callout(events, start + 1, line_ctx) {
Some((block, body_end)) => (Some(block), body_end - start + 1),
None => {
let (children, end) = collect_blocks_until(events, start + 1, line_ctx, |e| {
matches!(e, Event::End(TagEnd::BlockQuote(_)))
});
(Some(Block::BlockQuote(children)), end - start + 1)
}
}
}
Tag::List(start_num) => {
let ordered = start_num.is_some();
let list_start = match start_num {
Some(n) if *n != 1 => Some(*n),
_ => None,
};
let mut items: Vec<Vec<Block>> = Vec::new();
let mut item_source_lines: Vec<Option<usize>> = Vec::new();
let track_lines = line_ctx.is_some();
let mut i = start + 1;
while i < events.len() {
match &events[i] {
Event::End(TagEnd::List(_)) => break,
Event::Start(Tag::Item) => {
if track_lines {
item_source_lines.push(line_ctx.and_then(|ctx| ctx.line_at_event(i)));
}
let (item_blocks, end) = collect_item_blocks(events, i + 1, line_ctx);
items.push(item_blocks);
i = end + 1;
}
_ => i += 1,
}
}
(
Some(Block::List {
ordered,
start: list_start,
items,
item_source_lines,
}),
i - start + 1,
)
}
Tag::Table(column_alignments) => {
let alignments: Vec<ColumnAlignment> = if column_alignments
.iter()
.all(|a| matches!(a, pulldown_cmark::Alignment::None))
{
Vec::new()
} else {
column_alignments
.iter()
.map(|a| match a {
pulldown_cmark::Alignment::None => ColumnAlignment::None,
pulldown_cmark::Alignment::Left => ColumnAlignment::Left,
pulldown_cmark::Alignment::Center => ColumnAlignment::Center,
pulldown_cmark::Alignment::Right => ColumnAlignment::Right,
})
.collect()
};
let mut header: Vec<Vec<Inline>> = Vec::new();
let mut rows: Vec<Vec<Vec<Inline>>> = Vec::new();
let mut header_source_line: Option<usize> = None;
let mut row_source_lines: Vec<Option<usize>> = Vec::new();
let track_lines = line_ctx.is_some();
let mut current_row: Vec<Vec<Inline>> = Vec::new();
let mut in_head = false;
let mut in_body_row = false;
let mut i = start + 1;
while i < events.len() {
match &events[i] {
Event::End(TagEnd::Table) => break,
Event::Start(Tag::TableHead) => {
in_head = true;
if track_lines {
header_source_line = line_ctx.and_then(|ctx| ctx.line_at_event(i));
}
i += 1;
}
Event::End(TagEnd::TableHead) => {
in_head = false;
i += 1;
}
Event::Start(Tag::TableRow) => {
in_body_row = true;
current_row = Vec::new();
if track_lines {
row_source_lines.push(line_ctx.and_then(|ctx| ctx.line_at_event(i)));
}
i += 1;
}
Event::End(TagEnd::TableRow) => {
if in_body_row {
rows.push(std::mem::take(&mut current_row));
in_body_row = false;
}
i += 1;
}
Event::Start(Tag::TableCell) => {
let (cell_inlines, end) = collect_inlines_until(events, i + 1, |e| {
matches!(e, Event::End(TagEnd::TableCell))
});
if in_head {
header.push(cell_inlines);
} else {
current_row.push(cell_inlines);
}
i = end + 1;
}
_ => i += 1,
}
}
(
Some(Block::Table {
header,
rows,
alignments,
header_source_line,
row_source_lines,
}),
i - start + 1,
)
}
Tag::HtmlBlock => {
let mut html = String::new();
let mut i = start + 1;
while i < events.len() {
match &events[i] {
Event::End(TagEnd::HtmlBlock) => break,
Event::Html(s) | Event::Text(s) => html.push_str(s),
_ => {}
}
i += 1;
}
(Some(Block::Other(html)), i - start + 1)
}
Tag::FootnoteDefinition(label) => {
let (children, end) = collect_blocks_until(events, start + 1, line_ctx, |e| {
matches!(e, Event::End(TagEnd::FootnoteDefinition))
});
let label = label.to_string();
(
Some(Block::FootnoteDefinition { label, children }),
end - start + 1,
)
}
_ => (None, 1),
}
}
fn try_promote_to_figure(
mut inlines: Vec<Inline>,
events: &[Event<'_>],
para_start: usize,
) -> Result<Block, Vec<Inline>> {
let mut image_count = 0;
for inline in &inlines {
match inline {
Inline::Image { .. } => image_count += 1,
Inline::Text(s) if s.trim().is_empty() => {} Inline::LineBreak => {} _ => return Err(inlines),
}
}
if image_count != 1 {
return Err(inlines);
}
if let Some(Inline::Image {
src,
is_wikilink: true,
..
}) = inlines.iter().find(|i| matches!(i, Inline::Image { .. }))
{
let dest = match src {
Url::Unresolved(s) => s.as_str(),
Url::Resolved(r) => r.href.as_str(),
};
let ext = crate::path_ext::path_extension_lower(dest);
if !matches!(
crate::resolve::ext_kind::reference_kind_for_ext(&ext),
crate::resolve::ext_kind::ExtKind::Image
) {
return Err(inlines);
}
}
let mut figure_width: Option<String> = None;
let mut rewritten_alt: Option<String> = None;
match inlines.iter().find(|i| matches!(i, Inline::Image { .. })) {
Some(Inline::Image {
alt,
is_wikilink: false,
..
}) => {
let (rest_alt, w) = crate::media::split_alt_width(alt);
if w.is_some() {
figure_width = w;
rewritten_alt = Some(rest_alt);
}
}
Some(Inline::Image {
is_wikilink: true,
wikilink_pothole,
..
}) => {
if let Some(pothole) = wikilink_pothole {
let (remaining, w) = crate::media::split_alt_width(pothole);
if w.is_some() {
figure_width = w;
rewritten_alt = Some(remaining);
}
}
}
_ => {}
}
let raw_alt = inlines.iter().find_map(|i| match i {
Inline::Image { alt, .. } => Some(alt.as_str()),
_ => None,
});
let alt_text = rewritten_alt
.as_deref()
.or(raw_alt)
.map(|s| s.trim().to_string())
.unwrap_or_default();
if alt_text.is_empty() && figure_width.is_none() {
return Err(inlines);
}
let Some(image_pos) = inlines.iter().position(|i| matches!(i, Inline::Image { .. }))
else {
return Err(inlines);
};
let mut image = inlines.swap_remove(image_pos);
if let (Some(new_alt), Inline::Image { alt, .. }) = (rewritten_alt, &mut image) {
*alt = new_alt;
}
let caption = if alt_text.is_empty() {
None
} else {
Some(build_caption_inlines(
&image,
events,
para_start,
alt_text,
figure_width.is_some(),
))
};
Ok(Block::Figure {
image,
caption,
width: figure_width,
align: None,
class_names: Vec::new(),
img_style: None,
})
}
fn build_caption_inlines(
image: &Inline,
events: &[Event<'_>],
para_start: usize,
alt_text: String,
has_width: bool,
) -> Vec<Inline> {
let is_wikilink = matches!(
image,
Inline::Image {
is_wikilink: true,
..
}
);
if is_wikilink || has_width {
return vec![Inline::Text(alt_text)];
}
let mut img_children_start: Option<usize> = None;
let mut i = para_start + 1;
while i < events.len() {
match &events[i] {
Event::Start(Tag::Image { .. }) => {
img_children_start = Some(i + 1);
break;
}
Event::End(TagEnd::Paragraph) => break,
_ => {}
}
i += 1;
}
let Some(children_start) = img_children_start else {
return vec![Inline::Text(alt_text)];
};
let (mut caption, _end) = collect_inlines_until(events, children_start, |e| {
matches!(e, Event::End(TagEnd::Image))
});
if caption.iter().all(|c| matches!(c, Inline::Text(_))) {
return vec![Inline::Text(alt_text)];
}
if let Some(Inline::Text(first)) = caption.first_mut() {
*first = first.trim_start().to_string();
if first.is_empty() {
caption.remove(0);
}
}
if let Some(Inline::Text(last)) = caption.last_mut() {
*last = last.trim_end().to_string();
if last.is_empty() {
caption.pop();
}
}
if caption.is_empty() {
return vec![Inline::Text(alt_text)];
}
caption
}
fn collect_inlines_until<F>(events: &[Event<'_>], start: usize, is_end: F) -> (Vec<Inline>, usize)
where
F: Fn(&Event<'_>) -> bool,
{
let mut out: Vec<Inline> = Vec::new();
let mut i = start;
while i < events.len() {
if is_end(&events[i]) {
return (out, i);
}
let (inline, advance) = parse_inline(events, i);
if let Some(node) = inline {
out.push(node);
}
i += advance.max(1);
}
(out, i)
}
fn parse_inline(events: &[Event<'_>], start: usize) -> (Option<Inline>, usize) {
match &events[start] {
Event::Text(t) => (Some(Inline::Text(t.to_string())), 1),
Event::Code(c) => (Some(Inline::Code(c.to_string())), 1),
Event::SoftBreak => (Some(Inline::Text("\n".to_string())), 1),
Event::HardBreak => (Some(Inline::LineBreak), 1),
Event::Html(s) | Event::InlineHtml(s) => (Some(Inline::Other(s.to_string())), 1),
Event::InlineMath(tex) => (Some(math_inline(tex, false)), 1),
Event::DisplayMath(tex) => (Some(math_inline(tex, true)), 1),
Event::FootnoteReference(label) => (Some(Inline::FootnoteRef(label.to_string())), 1),
Event::TaskListMarker(checked) => (Some(Inline::TaskMarker(*checked)), 1),
Event::Start(tag) => match tag {
Tag::Emphasis => {
let (children, end) = collect_inlines_until(events, start + 1, |e| {
matches!(e, Event::End(TagEnd::Emphasis))
});
(Some(Inline::Emphasis(children)), end - start + 1)
}
Tag::Strong => {
let (children, end) = collect_inlines_until(events, start + 1, |e| {
matches!(e, Event::End(TagEnd::Strong))
});
(Some(Inline::Strong(children)), end - start + 1)
}
Tag::Strikethrough => {
let (children, end) = collect_inlines_until(events, start + 1, |e| {
matches!(e, Event::End(TagEnd::Strikethrough))
});
(Some(Inline::Strikethrough(children)), end - start + 1)
}
Tag::Link {
link_type,
dest_url,
title,
..
} => {
let (children, end) = collect_inlines_until(events, start + 1, |e| {
matches!(e, Event::End(TagEnd::Link))
});
let title_opt = if title.is_empty() {
None
} else {
Some(title.to_string())
};
let is_wikilink = matches!(*link_type, pulldown_cmark::LinkType::WikiLink { .. });
(
Some(Inline::Link {
url: Url::unresolved(dest_url.to_string()),
title: title_opt,
children,
is_wikilink,
}),
end - start + 1,
)
}
Tag::Image {
link_type,
dest_url,
title,
..
} => {
let mut alt = String::new();
let mut i = start + 1;
let mut depth: u32 = 1;
while i < events.len() {
match &events[i] {
Event::Start(Tag::Image { .. }) => depth += 1,
Event::End(TagEnd::Image) => {
depth -= 1;
if depth == 0 {
break;
}
}
Event::Text(t) => alt.push_str(t),
Event::Code(c) => alt.push_str(c),
Event::InlineMath(t) => alt.push_str(&math_source(t, false)),
Event::DisplayMath(t) => alt.push_str(&math_source(t, true)),
Event::SoftBreak | Event::HardBreak => {
if !alt.is_empty() && !alt.ends_with(' ') {
alt.push(' ');
}
}
_ => {}
}
i += 1;
}
let is_wikilink_image =
matches!(link_type, pulldown_cmark::LinkType::WikiLink { .. });
let wikilink_pothole: Option<String> = if is_wikilink_image {
let dest_str: &str = dest_url;
let trimmed = alt.trim();
if trimmed.is_empty() || trimmed == dest_str {
None
} else {
Some(trimmed.to_string())
}
} else {
None
};
if is_wikilink_image {
let dest_str: &str = dest_url;
let trimmed = alt.trim().to_string();
if trimmed.is_empty() || trimmed == dest_str {
alt.clear();
} else if crate::media::is_all_display_keywords(&trimmed) {
alt.clear();
} else {
use crate::resolve::wikilink_dispatch::{
parse_pothole_params, PotholeContent,
};
match parse_pothole_params(&trimmed) {
PotholeContent::Empty | PotholeContent::Params(_) => {
alt.clear();
}
PotholeContent::WidthToken { rest_alias, .. } => {
alt = rest_alias;
}
PotholeContent::Alias(text) => {
let (remaining, _w) = crate::media::split_alt_width(&text);
alt = remaining;
}
}
}
}
let title_opt = if title.is_empty() {
None
} else {
Some(title.to_string())
};
(
Some(Inline::Image {
src: Url::unresolved(dest_url.to_string()),
alt,
title: title_opt,
is_wikilink: is_wikilink_image,
wikilink_pothole,
}),
i - start + 1,
)
}
_ => (None, 1),
},
_ => (None, 1),
}
}
fn collect_blocks_until<F>(
events: &[Event<'_>],
start: usize,
line_ctx: Option<&LineCtx<'_>>,
is_end: F,
) -> (Vec<Block>, usize)
where
F: Fn(&Event<'_>) -> bool,
{
let mut out: Vec<Block> = Vec::new();
let mut i = start;
while i < events.len() {
if is_end(&events[i]) {
return (out, i);
}
let (block, advance) = parse_block(events, i, line_ctx);
if let Some(b) = block {
out.push(b);
}
i += advance.max(1);
}
(out, i)
}
fn collect_item_blocks(
events: &[Event<'_>],
start: usize,
line_ctx: Option<&LineCtx<'_>>,
) -> (Vec<Block>, usize) {
let mut out: Vec<Block> = Vec::new();
let mut pending_inlines: Vec<Inline> = Vec::new();
let mut i = start;
while i < events.len() {
if matches!(&events[i], Event::End(TagEnd::Item)) {
flush_pending_paragraph(&mut out, &mut pending_inlines);
return (out, i);
}
if let Some((inline, advance)) = parse_inline_event(events, i) {
if let Some(node) = inline {
pending_inlines.push(node);
}
i += advance.max(1);
continue;
}
flush_pending_paragraph(&mut out, &mut pending_inlines);
let (block, advance) = parse_block(events, i, line_ctx);
if let Some(b) = block {
out.push(b);
}
i += advance.max(1);
}
flush_pending_paragraph(&mut out, &mut pending_inlines);
(out, i)
}
fn detect_and_assemble_callout(
events: &[Event<'_>],
start: usize,
line_ctx: Option<&LineCtx<'_>>,
) -> Option<(Block, usize)> {
if !matches!(events.get(start), Some(Event::Start(Tag::Paragraph))) {
return None;
}
let mut leading = String::new();
let mut i = start + 1;
while let Some(event) = events.get(i) {
match event {
Event::Text(t) => {
leading.push_str(t);
i += 1;
}
Event::InlineMath(t) => {
leading.push_str(&math_source(t, false));
i += 1;
}
Event::DisplayMath(t) => {
leading.push_str(&math_source(t, true));
i += 1;
}
_ => break,
}
}
if leading.is_empty() {
return None;
}
let (raw_kind, fold, title, _marker_byte_len) = parse_callout_marker(&leading)?;
let kind = CalloutKind::from_raw(raw_kind).unwrap_or(CalloutKind::Note);
let title: Option<String> = title.map(|s| s.to_string()).filter(|s| !s.is_empty());
let mut body_blocks: Vec<Block> = Vec::new();
let body_paragraph_start: Option<usize> = match events.get(i) {
Some(Event::SoftBreak) | Some(Event::HardBreak) => {
Some(i + 1)
}
Some(Event::End(TagEnd::Paragraph)) => {
i += 1;
None
}
_ => {
Some(i)
}
};
if let Some(body_start) = body_paragraph_start {
let (body_inlines, after_para) = collect_inlines_until(events, body_start, |e| {
matches!(e, Event::End(TagEnd::Paragraph))
});
i = after_para + 1;
let trimmed_empty = body_inlines.iter().all(|x| match x {
Inline::Text(t) => t.trim().is_empty(),
_ => false,
});
if !trimmed_empty {
body_blocks.push(Block::Paragraph(body_inlines));
}
}
while let Some(event) = events.get(i) {
if matches!(event, Event::End(TagEnd::BlockQuote(_))) {
break;
}
let (block, advance) = parse_block(events, i, line_ctx);
if let Some(b) = block {
body_blocks.push(b);
}
i += advance.max(1);
}
let block = Block::Callout {
kind,
fold,
title,
children: body_blocks,
};
Some((block, i))
}
fn parse_callout_marker(text: &str) -> Option<(&str, Option<Fold>, Option<&str>, usize)> {
let after_open = text.strip_prefix("[!")?;
let close_offset = after_open.find(']')?;
let raw_kind = after_open.get(..close_offset)?;
if raw_kind.is_empty() || raw_kind.chars().any(|c| c.is_whitespace()) {
return None;
}
let after_bracket_offset = 2 + close_offset + 1;
let rest = text.get(after_bracket_offset..)?;
let (fold, after_fold_offset) = match rest.chars().next() {
Some('+') => (Some(Fold::Open), after_bracket_offset + 1),
Some('-') => (Some(Fold::Closed), after_bracket_offset + 1),
_ => (None, after_bracket_offset),
};
let rest_after_fold = text.get(after_fold_offset..)?;
let (title, marker_byte_len) = if rest_after_fold.is_empty() {
(None, after_fold_offset)
} else if let Some(remainder) = rest_after_fold.strip_prefix(' ') {
let title_str = remainder;
let consumed = after_fold_offset + 1 + remainder.len();
(Some(title_str), consumed)
} else {
(None, after_fold_offset)
};
Some((raw_kind, fold, title, marker_byte_len))
}
fn parse_inline_event(events: &[Event<'_>], i: usize) -> Option<(Option<Inline>, usize)> {
match &events[i] {
Event::Text(_)
| Event::Code(_)
| Event::Html(_)
| Event::InlineHtml(_)
| Event::SoftBreak
| Event::HardBreak
| Event::InlineMath(_)
| Event::DisplayMath(_)
| Event::FootnoteReference(_)
| Event::TaskListMarker(_) => Some(parse_inline(events, i)),
Event::Start(tag) => match tag {
Tag::Emphasis
| Tag::Strong
| Tag::Strikethrough
| Tag::Link { .. }
| Tag::Image { .. } => Some(parse_inline(events, i)),
_ => None,
},
_ => None,
}
}
fn flush_pending_paragraph(out: &mut Vec<Block>, pending_inlines: &mut Vec<Inline>) {
if !pending_inlines.is_empty() {
out.push(Block::Paragraph(std::mem::take(pending_inlines)));
}
}
fn assign_heading_id_suffixes(blocks: &mut [Block]) {
let note_order: Vec<String> = FootnoteIndex::build(blocks)
.entries()
.iter()
.map(|(_, label)| label.clone())
.collect();
let mut body: Vec<&mut Option<String>> = Vec::new();
let mut notes: Vec<(String, Vec<&mut Option<String>>)> = Vec::new();
let mut hoisted: HashSet<String> = HashSet::new();
let scope = HoistScope {
document_notes: ¬e_order,
in_shortcode: false,
};
collect_heading_id_slots(blocks, &mut body, &mut hoisted, &mut notes, scope);
notes.sort_by_key(|(label, _)| {
note_order
.iter()
.position(|l| l == label)
.unwrap_or(usize::MAX)
});
let mut id_counts: HashMap<String, usize> = HashMap::new();
for slot in body {
disambiguate_heading_id(slot, &mut id_counts);
}
for (_, slots) in notes {
for slot in slots {
disambiguate_heading_id(slot, &mut id_counts);
}
}
}
fn disambiguate_heading_id(id: &mut Option<String>, id_counts: &mut HashMap<String, usize>) {
let Some(slug) = id else { return };
let count = *id_counts.entry(slug.clone()).or_insert(0);
id_counts.insert(slug.clone(), count + 1);
if count > 0 {
let suffixed = format!("{slug}-{count}");
*id = Some(suffixed);
}
}
#[derive(Clone, Copy)]
struct HoistScope<'a> {
document_notes: &'a [String],
in_shortcode: bool,
}
impl HoistScope<'_> {
fn hoists(&self, label: &str, hoisted: &mut HashSet<String>) -> bool {
if self.in_shortcode {
return false;
}
if !self.document_notes.iter().any(|l| l == label) {
return false;
}
hoisted.insert(label.to_string())
}
fn inside_shortcode(self) -> Self {
Self {
in_shortcode: true,
..self
}
}
}
fn collect_heading_id_slots<'a>(
blocks: &'a mut [Block],
sink: &mut Vec<&'a mut Option<String>>,
hoisted: &mut HashSet<String>,
notes: &mut Vec<(String, Vec<&'a mut Option<String>>)>,
scope: HoistScope<'_>,
) {
for block in blocks.iter_mut() {
match block {
Block::Heading { id, .. } => sink.push(id),
Block::FootnoteDefinition { label, children } => {
let label = label.clone();
if scope.hoists(&label, hoisted) {
let mut note_sink: Vec<&'a mut Option<String>> = Vec::new();
collect_heading_id_slots(children, &mut note_sink, hoisted, notes, scope);
notes.push((label, note_sink));
} else {
collect_heading_id_slots(children, sink, hoisted, notes, scope);
}
}
Block::BlockQuote(children)
| Block::Callout { children, .. }
| Block::LinkCard { children, .. } => {
collect_heading_id_slots(children, sink, hoisted, notes, scope);
}
Block::List { items, .. } => {
for item in items.iter_mut() {
collect_heading_id_slots(item, sink, hoisted, notes, scope);
}
}
Block::Shortcode(sc) => match sc {
Shortcode::Grid(args) => {
for cell in args.cells.iter_mut() {
collect_heading_id_slots(cell, sink, hoisted, notes, scope.inside_shortcode());
}
}
Shortcode::Hero(args) => {
collect_heading_id_slots(&mut args.overlay, sink, hoisted, notes, scope.inside_shortcode());
}
Shortcode::Subscribe(_)
| Shortcode::Buttons(_)
| Shortcode::Gallery(_)
| Shortcode::Recent(_)
| Shortcode::Apply(_) => {}
},
Block::Paragraph(_)
| Block::CodeBlock { .. }
| Block::Table { .. }
| Block::ThematicBreak
| Block::Figure { .. }
| Block::Other(_) => {}
}
}
}
#[cfg(test)]
#[path = "parser_tests.rs"]
mod tests;