diff --git a/src/content.rs b/src/content.rs index 04cb818..10fd381 100644 --- a/src/content.rs +++ b/src/content.rs @@ -50,6 +50,8 @@ pub struct Frontmatter { pub section_type: Option, /// Override template for this content item pub template: Option, + /// Enable table of contents (anchor nav in sidebar) + pub toc: Option, } /// A content item ready for rendering. @@ -144,6 +146,7 @@ fn parse_frontmatter(path: &Path, parsed: &gray_matter::ParsedEntity) -> Result< let nav_label = pod.get("nav_label").and_then(|v| v.as_string().ok()); let section_type = pod.get("section_type").and_then(|v| v.as_string().ok()); let template = pod.get("template").and_then(|v| v.as_string().ok()); + let toc = pod.get("toc").and_then(|v| v.as_bool().ok()); // Handle nested taxonomies.tags structure let tags = if let Some(taxonomies) = pod.get("taxonomies") { @@ -174,6 +177,7 @@ fn parse_frontmatter(path: &Path, parsed: &gray_matter::ParsedEntity) -> Result< nav_label, section_type, template, + toc, }) } diff --git a/src/main.rs b/src/main.rs index 00b5140..4056a01 100644 --- a/src/main.rs +++ b/src/main.rs @@ -142,7 +142,7 @@ fn run(config_path: &Path) -> Result<()> { // Render individual content pages for all sections for item in &items { eprintln!(" processing: {}", item.slug); - let html_body = render::markdown_to_html(&item.body); + let (html_body, _anchors) = render::markdown_to_html(&item.body); let page_path = format!("/{}", item.output_path(&content_dir).display()); let html = engine.render_content(item, &html_body, &page_path, &config, &manifest.nav)?; @@ -259,7 +259,7 @@ fn process_pages( eprintln!("processing: {}", path.display()); let content = Content::from_path(&path, ContentKind::Page)?; - let html_body = render::markdown_to_html(&content.body); + let (html_body, _anchors) = render::markdown_to_html(&content.body); let page_path = format!("/{}", content.output_path(content_dir).display()); let html = engine.render_page(&content, &html_body, &page_path, config, nav)?; @@ -278,7 +278,7 @@ fn generate_homepage( ) -> Result<()> { eprintln!("generating: homepage"); - let html_body = render::markdown_to_html(&manifest.homepage.body); + let (html_body, _anchors) = render::markdown_to_html(&manifest.homepage.body); let html = engine.render_page( &manifest.homepage, &html_body, diff --git a/src/render.rs b/src/render.rs index 8d25c6c..3b92a0f 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,10 +1,23 @@ //! Markdown to HTML rendering via pulldown-cmark with syntax highlighting. use crate::highlight::{highlight_code, Language}; -use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use serde::Serialize; + +/// A heading anchor extracted from markdown content. +#[derive(Debug, Clone, Serialize)] +pub struct Anchor { + /// Heading ID attribute (slug) + pub id: String, + /// Heading text content + pub label: String, + /// Heading level (2-6, h1 excluded) + pub level: u8, +} /// Render markdown content to HTML with syntax highlighting. -pub fn markdown_to_html(markdown: &str) -> String { +/// Returns the HTML output and a list of extracted heading anchors. +pub fn markdown_to_html(markdown: &str) -> (String, Vec) { let options = Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES | Options::ENABLE_STRIKETHROUGH @@ -13,6 +26,7 @@ pub fn markdown_to_html(markdown: &str) -> String { let parser = Parser::new_ext(markdown, options); let mut html_output = String::new(); + let mut anchors = Vec::new(); let mut code_block_lang: Option = None; let mut code_block_content = String::new(); @@ -20,6 +34,10 @@ pub fn markdown_to_html(markdown: &str) -> String { let mut image_alt_content: Option = None; let mut image_attrs: Option<(String, String)> = None; // (src, title) + // Heading accumulation state + let mut heading_level: Option = None; + let mut heading_text = String::new(); + for event in parser { match event { Event::Start(Tag::CodeBlock(kind)) => { @@ -90,6 +108,11 @@ pub fn markdown_to_html(markdown: &str) -> String { code_block_lang = None; code_block_content.clear(); } + Event::Text(text) if heading_level.is_some() => { + // Accumulate heading text + heading_text.push_str(&text); + html_output.push_str(&html_escape(&text)); + } Event::Text(text) => { // Regular text outside code blocks html_output.push_str(&html_escape(&text)); @@ -107,6 +130,14 @@ pub fn markdown_to_html(markdown: &str) -> String { image_alt_content = Some(String::new()); image_attrs = Some((dest_url.to_string(), title.to_string())); } + Event::Start(Tag::Heading { level, .. }) => { + // Begin accumulating heading text + heading_level = Some(level); + heading_text.clear(); + let level_num = level as u8; + html_output.push_str(&format!(" { html_output.push_str(&start_tag_to_html(&tag)); } @@ -130,6 +161,31 @@ pub fn markdown_to_html(markdown: &str) -> String { } } } + Event::End(TagEnd::Heading(level)) => { + // Generate slug ID from heading text + let id = slugify(&heading_text); + let level_num = level as u8; + + // We need to go back and insert the id attribute and close the tag + // The heading was opened as " + if let Some(pos) = html_output.rfind(&format!("", id)); + } + html_output.push_str(&format!("\n", level_num)); + + // Extract anchor for h2-h6 (skip h1) + if level_num >= 2 { + anchors.push(Anchor { + id, + label: heading_text.clone(), + level: level_num, + }); + } + + heading_level = None; + heading_text.clear(); + } Event::End(tag) => { html_output.push_str(&end_tag_to_html(&tag)); } @@ -184,7 +240,7 @@ pub fn markdown_to_html(markdown: &str) -> String { } } - html_output + (html_output, anchors) } fn html_escape(s: &str) -> String { @@ -194,6 +250,18 @@ fn html_escape(s: &str) -> String { .replace('"', """) } +/// Convert heading text to a URL-friendly slug ID. +fn slugify(text: &str) -> String { + text.to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + fn start_tag_to_html(tag: &Tag) -> String { match tag { Tag::Paragraph => "

".to_string(), @@ -270,15 +338,15 @@ mod tests { #[test] fn test_basic_markdown() { let md = "# Hello\n\nThis is a *test*."; - let html = markdown_to_html(md); - assert!(html.contains("

Hello

")); + let (html, _) = markdown_to_html(md); + assert!(html.contains("

Hello

")); assert!(html.contains("test")); } #[test] fn test_code_block_highlighting() { let md = "```rust\nfn main() {}\n```"; - let html = markdown_to_html(md); + let (html, _) = markdown_to_html(md); // Should contain highlighted code assert!(html.contains("
cargo run"));
     }
@@ -309,7 +377,7 @@ mod tests {
     #[test]
     fn test_image_alt_text() {
         let md = "![Beautiful sunset](sunset.jpg \"Evening sky\")";
-        let html = markdown_to_html(md);
+        let (html, _) = markdown_to_html(md);
 
         assert!(html.contains("alt=\"Beautiful sunset\""));
         assert!(html.contains("title=\"Evening sky\""));
@@ -319,7 +387,7 @@ mod tests {
     #[test]
     fn test_image_alt_text_no_title() {
         let md = "![Logo image](logo.png)";
-        let html = markdown_to_html(md);
+        let (html, _) = markdown_to_html(md);
 
         assert!(html.contains("alt=\"Logo image\""));
         assert!(html.contains("src=\"logo.png\""));