use crate::core::slug::SlugGenerator;
use comrak::nodes::NodeValue;
use comrak::{Arena, Options, parse_document};
#[derive(Debug, Clone)]
pub struct TocEntry {
pub level: u8,
pub text: String,
#[cfg_attr(not(feature = "webview-backend"), allow(dead_code))]
pub anchor: String,
}
pub fn extract_toc(content: &str) -> Vec<TocEntry> {
let arena = Arena::new();
let mut options = Options::default();
options.extension.strikethrough = true;
options.extension.table = true;
options.extension.autolink = true;
options.extension.tasklist = true;
options.extension.footnotes = true;
options.extension.front_matter_delimiter = Some("---".to_string());
let root = parse_document(&arena, content, &options);
let mut entries = Vec::new();
let mut slugs = SlugGenerator::new();
for node in root.descendants() {
if let NodeValue::Heading(heading) = &node.data.borrow().value {
let level = heading.level;
let text = collect_text(node);
let anchor = slugs.generate(&text);
entries.push(TocEntry {
level,
text,
anchor,
});
}
}
entries
}
fn collect_text<'a>(
node: &'a comrak::arena_tree::Node<'a, std::cell::RefCell<comrak::nodes::Ast>>,
) -> String {
let mut text = String::new();
for child in node.descendants() {
if let NodeValue::Text(ref t) = child.data.borrow().value {
text.push_str(t);
}
if let NodeValue::Code(ref c) = child.data.borrow().value {
text.push_str(&c.literal);
}
}
text
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::slug::slugify;
#[test]
fn slugify_simple_text() {
assert_eq!(slugify("Hello World"), "hello-world");
}
#[test]
fn slugify_preserves_hyphens_and_underscores() {
assert_eq!(slugify("my-heading_here"), "my-heading_here");
}
#[test]
fn slugify_strips_special_characters() {
assert_eq!(slugify("Hello, World! (2024)"), "hello-world-2024");
}
#[test]
fn slugify_multiple_spaces_become_multiple_hyphens() {
assert_eq!(slugify("hello world"), "hello---world");
}
#[test]
fn slugify_empty_string() {
assert_eq!(slugify(""), "");
}
#[test]
fn slugify_only_special_chars() {
assert_eq!(slugify("!@#$%"), "");
}
#[test]
fn slugify_unicode_alphanumeric() {
let result = slugify("Café Résumé");
assert!(result.contains("café"));
assert!(result.contains("résumé"));
}
#[test]
fn slugify_numbers() {
assert_eq!(slugify("Chapter 1"), "chapter-1");
}
#[test]
fn extract_toc_empty_input() {
let entries = extract_toc("");
assert!(entries.is_empty());
}
#[test]
fn extract_toc_no_headings() {
let entries = extract_toc("Just some paragraph text.\n\nAnother paragraph.");
assert!(entries.is_empty());
}
#[test]
fn extract_toc_single_h1() {
let entries = extract_toc("# Hello World");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].level, 1);
assert_eq!(entries[0].text, "Hello World");
assert_eq!(entries[0].anchor, "hello-world");
}
#[test]
fn extract_toc_multiple_levels() {
let md = "# Title\n## Section\n### Subsection\n#### Deep";
let entries = extract_toc(md);
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].level, 1);
assert_eq!(entries[1].level, 2);
assert_eq!(entries[2].level, 3);
assert_eq!(entries[3].level, 4);
}
#[test]
fn extract_toc_heading_with_inline_code() {
let md = "# The `main` function";
let entries = extract_toc(md);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].text, "The main function");
}
#[test]
fn extract_toc_heading_with_special_chars() {
let md = "## Hello, World! (2024)";
let entries = extract_toc(md);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].anchor, "hello-world-2024");
}
#[test]
fn extract_toc_mixed_content_and_headings() {
let md = "Some intro text.\n\n# First\n\nParagraph here.\n\n## Second\n\nMore text.";
let entries = extract_toc(md);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].text, "First");
assert_eq!(entries[1].text, "Second");
}
#[test]
fn extract_toc_h5_and_h6() {
let md = "##### Level 5\n###### Level 6";
let entries = extract_toc(md);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].level, 5);
assert_eq!(entries[1].level, 6);
}
#[test]
fn extract_toc_preserves_order() {
let md = "## B\n# A\n### C";
let entries = extract_toc(md);
assert_eq!(entries[0].text, "B");
assert_eq!(entries[1].text, "A");
assert_eq!(entries[2].text, "C");
}
}
#[cfg(test)]
mod anchor_tests {
use super::*;
#[test]
fn duplicate_headings_get_distinct_anchors() {
let toc = extract_toc("## Setup\n\ntext\n\n## Setup\n\nmore");
assert_eq!(toc.len(), 2);
assert_eq!(toc[0].anchor, "setup");
assert_eq!(toc[1].anchor, "setup-1");
}
#[cfg(feature = "webview-backend")]
#[test]
fn toc_anchors_match_the_rendered_html_ids() {
let md = "# Intro\n\n## Setup\n\n## Setup\n\n## Usage\n";
let html = crate::core::markdown::parse_markdown(md);
for entry in extract_toc(md) {
assert!(
html.contains(&format!("id=\"{}\"", entry.anchor)),
"anchor {} missing from {}",
entry.anchor,
html
);
}
}
#[test]
fn front_matter_does_not_produce_a_toc_entry() {
let md = "---\ntitle: front matter\ntags: [a, b]\n---\n\n# Title\n";
let toc = extract_toc(md);
assert_eq!(toc.len(), 1, "{toc:?}");
assert_eq!(toc[0].text, "Title");
}
}