use crate::ast::{parse, Block, Inline};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadingInfo {
pub text: String,
pub slug: String,
pub level: u8,
}
fn inlines_to_text(inlines: &[Inline], out: &mut String) {
for inline in inlines {
match inline {
Inline::Text(t) => out.push_str(t),
Inline::Code(c) => out.push_str(c),
Inline::Emphasis(children) | Inline::Strong(children) => {
inlines_to_text(children, out)
}
Inline::Link { children, .. } => inlines_to_text(children, out),
Inline::Image { alt, .. } => out.push_str(alt),
Inline::LineBreak => out.push(' '),
Inline::Other(_) => {}
}
}
}
pub fn extract_headings(markdown: &str) -> Vec<HeadingInfo> {
let doc = parse(markdown);
let mut out = Vec::new();
for block in &doc.blocks {
if let Block::Heading {
level,
children,
id,
} = block
{
let mut text = String::new();
inlines_to_text(children, &mut text);
out.push(HeadingInfo {
text: text.trim().to_string(),
slug: id.clone().unwrap_or_default(),
level: *level,
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_text_slug_level() {
let md = "# Title\n\n## Getting Started\n\ntext\n\n### Sub *em*\n";
let hs = extract_headings(md);
assert_eq!(hs.len(), 3);
assert_eq!(hs[0], HeadingInfo { text: "Title".into(), slug: "title".into(), level: 1 });
assert_eq!(hs[1], HeadingInfo { text: "Getting Started".into(), slug: "getting-started".into(), level: 2 });
assert_eq!(hs[2], HeadingInfo { text: "Sub em".into(), slug: "sub-em".into(), level: 3 });
}
#[test]
fn dedups_duplicate_slugs() {
let md = "## Setup\n\n## Setup\n";
let hs = extract_headings(md);
assert_eq!(hs[0].slug, "setup");
assert_eq!(hs[1].slug, "setup-1");
}
#[test]
fn preserves_cjk() {
let md = "## 中文标题\n";
let hs = extract_headings(md);
assert_eq!(hs[0].slug, "中文标题");
assert_eq!(hs[0].text, "中文标题");
}
#[test]
fn empty_doc_no_headings() {
assert!(extract_headings("just a paragraph\n").is_empty());
}
#[test]
fn slug_matches_obsidian_anchor_for_punctuation() {
let md = "## Step 1: Install\n";
let hs = extract_headings(md);
assert_eq!(hs[0].slug, crate::heading_anchor::obsidian_heading_anchor("Step 1: Install"));
}
}