use crate::ast::parser::ParseConfig;
use crate::ast::plain_text::inlines_to_plain_text;
use crate::ast::{parse_with_config, Block};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadingInfo {
pub text: String,
pub slug: String,
pub level: u8,
}
pub fn extract_headings(markdown: &str) -> Vec<HeadingInfo> {
extract_headings_with_config(markdown, &ParseConfig::default())
}
pub fn extract_headings_with_config(markdown: &str, config: &ParseConfig) -> Vec<HeadingInfo> {
let doc = parse_with_config(markdown, config);
let mut out = Vec::new();
for block in &doc.blocks {
if let Block::Heading {
level,
children,
id,
} = block
{
let text = inlines_to_plain_text(children);
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"));
}
#[test]
fn math_heading_slug_is_identical_across_every_surface() {
let md = "# Euler $e^{i\\pi}=-1$ identity\n";
let math_on = ParseConfig { math: true, ..Default::default() };
let extracted = extract_headings_with_config(md, &math_on);
assert_eq!(extracted.len(), 1);
let doc = crate::ast::parse_with_config(md, &math_on);
let Block::Heading { id, .. } = &doc.blocks[0] else {
panic!("expected a heading, got {:?}", doc.blocks[0]);
};
assert_eq!(extracted[0].slug, *id.as_ref().expect("heading must have an id"));
assert_eq!(
extracted[0].slug,
crate::heading::anchor::obsidian_heading_anchor("Euler $e^{i\\pi}=-1$ identity")
);
let off = extract_headings_with_config(md, &ParseConfig::default());
assert_eq!(
extracted[0].slug, off[0].slug,
"TeX with no markdown-active characters must slug identically either way"
);
assert_eq!(extracted[0].text, "Euler $e^{i\\pi}=-1$ identity");
assert_eq!(extracted[0].text, off[0].text);
}
#[test]
fn markdown_active_chars_in_tex_move_the_anchor_but_keep_graph_agreement() {
let math_on = ParseConfig { math: true, ..Default::default() };
for (md, raw, expect_off) in [
(
"# Convolution $f*g$ and $h*k$ end\n",
"Convolution $f*g$ and $h*k$ end",
"convolution-$fg$-and-$hk$-end",
),
("# Dual $V^*$ and $W^*$ end\n", "Dual $V^*$ and $W^*$ end", "dual-$v$-and-$w$-end"),
] {
let on = extract_headings_with_config(md, &math_on);
let off = extract_headings_with_config(md, &ParseConfig::default());
assert_eq!(
on[0].slug,
crate::heading::anchor::obsidian_heading_anchor(raw),
"math-ON slug diverged from the raw-line slug the wikilink graph computes"
);
assert_eq!(off[0].slug, expect_off, "math-OFF slug drifted from what ADR-030 records");
assert_ne!(
on[0].slug, off[0].slug,
"expected this heading's anchor to MOVE when math is enabled"
);
}
}
#[test]
fn display_math_in_a_heading_keeps_both_delimiters() {
let math_on = ParseConfig { math: true, ..Default::default() };
let hs = extract_headings_with_config("# Case $$a+b$$ tail\n", &math_on);
assert_eq!(hs[0].text, "Case $$a+b$$ tail");
assert_eq!(
hs[0].slug,
crate::heading::anchor::obsidian_heading_anchor("Case $$a+b$$ tail"),
"graph agreement — the invariant that always holds"
);
assert_eq!(
hs[0].slug,
extract_headings_with_config("# Case $$a+b$$ tail\n", &ParseConfig::default())[0].slug
);
}
#[test]
fn headings_differing_only_inside_math_do_not_collide() {
let math_on = ParseConfig { math: true, ..Default::default() };
let hs = extract_headings_with_config("## Case $a$\n\n## Case $b$\n", &math_on);
assert_ne!(hs[0].slug, hs[1].slug);
assert!(!hs[1].slug.ends_with("-1"), "slug {:?} collided", hs[1].slug);
}
#[test]
fn grid_cell_heading_shares_the_page_id_counter() {
let md = ":::grid\n### Notes\n:::\n\n## Notes\n";
let hs = extract_headings(md);
assert_eq!(hs.len(), 1, "only top-level headings are offered: {hs:?}");
assert_eq!(
hs[0].slug, "notes-1",
"the body section must report the id it actually renders with"
);
let mut doc = crate::ast::parse(md);
crate::ast::classify_remaining_urls(&mut doc);
let html = crate::ast::render_document(&doc, &crate::ast::DefaultHooks::new());
assert_eq!(
html.matches(r#"id="notes""#).count(),
1,
"duplicate DOM id: {html}"
);
assert!(
html.contains(r#"id="notes-1""#),
"the body heading lost its disambiguated id: {html}"
);
}
fn rendered_ids(md: &str) -> Vec<String> {
let mut doc = crate::ast::parse(md);
crate::ast::classify_remaining_urls(&mut doc);
let html = crate::ast::render_document(&doc, &crate::ast::DefaultHooks::new());
let mut ids = Vec::new();
let mut rest = html.as_str();
while let Some(at) = rest.find("id=\"") {
let Some(after) = rest.get(at + 4..) else { break };
let Some(end) = after.find('"') else { break };
let Some(id) = after.get(..end) else { break };
ids.push(id.to_string());
rest = after.get(end + 1..).unwrap_or("");
}
ids
}
#[test]
fn a_cell_holding_two_same_titled_headings_still_yields_unique_ids() {
for (name, md) in [
("grid", "## Notes\n\n:::grid\n### Notes\n\n### Notes\n:::\n"),
(
"hero overlay",
":::hero\ncover.jpg\n---\n### Notes\n\n### Notes\n:::\n\n## Notes\n",
),
(
"compound-link card",
":::grid\n[### Notes\n\n### Notes](https://example.com/a)\n:::\n\n## Notes\n",
),
] {
let ids = rendered_ids(md);
let unique: std::collections::HashSet<&String> = ids.iter().collect();
assert_eq!(
unique.len(),
ids.len(),
"{name}: duplicate DOM id among {ids:?}"
);
}
}
#[test]
fn no_id_carries_a_doubled_suffix() {
let ids = rendered_ids(":::grid 2\n### Notes\n\n### Notes\n+++\n### Notes\n\n### Notes\n:::\n\n## Notes\n");
for id in &ids {
assert!(
!id.contains("-1-"),
"id {id:?} was suffixed twice — the nested parse numbered it \
and the page walk numbered it again: {ids:?}"
);
}
let unique: std::collections::HashSet<&String> = ids.iter().collect();
assert_eq!(unique.len(), ids.len(), "duplicate DOM id among {ids:?}");
}
}