feat(render): add anchor extraction during markdown rendering
- Add Anchor struct with id, label, level fields - markdown_to_html() now returns (String, Vec<Anchor>) tuple - Headings h2-h6 are extracted with slugified IDs - Add toc: bool frontmatter field for per-page TOC opt-in - All heading tags now include id attributes for anchor links
This commit is contained in:
@@ -50,6 +50,8 @@ pub struct Frontmatter {
|
||||
pub section_type: Option<String>,
|
||||
/// Override template for this content item
|
||||
pub template: Option<String>,
|
||||
/// Enable table of contents (anchor nav in sidebar)
|
||||
pub toc: Option<bool>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Anchor>) {
|
||||
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<String> = 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<String> = None;
|
||||
let mut image_attrs: Option<(String, String)> = None; // (src, title)
|
||||
|
||||
// Heading accumulation state
|
||||
let mut heading_level: Option<HeadingLevel> = 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!("<h{}", level_num));
|
||||
// ID will be added at End event after we have the text
|
||||
}
|
||||
Event::Start(tag) => {
|
||||
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 "<hN" - find it and complete with id and >
|
||||
if let Some(pos) = html_output.rfind(&format!("<h{}", level_num)) {
|
||||
let insert_pos = pos + format!("<h{}", level_num).len();
|
||||
html_output.insert_str(insert_pos, &format!(" id=\"{}\">", id));
|
||||
}
|
||||
html_output.push_str(&format!("</h{}>\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::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
fn start_tag_to_html(tag: &Tag) -> String {
|
||||
match tag {
|
||||
Tag::Paragraph => "<p>".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("<h1>Hello</h1>"));
|
||||
let (html, _) = markdown_to_html(md);
|
||||
assert!(html.contains("<h1 id=\"hello\">Hello</h1>"));
|
||||
assert!(html.contains("<em>test</em>"));
|
||||
}
|
||||
|
||||
#[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("<pre><code"));
|
||||
@@ -289,7 +357,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_code_block_unknown_language() {
|
||||
let md = "```unknown\nsome code\n```";
|
||||
let html = markdown_to_html(md);
|
||||
let (html, _) = markdown_to_html(md);
|
||||
|
||||
// Should contain escaped code without highlighting spans
|
||||
assert!(html.contains("<pre><code"));
|
||||
@@ -301,7 +369,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_inline_code() {
|
||||
let md = "Use `cargo run` to start.";
|
||||
let html = markdown_to_html(md);
|
||||
let (html, _) = markdown_to_html(md);
|
||||
|
||||
assert!(html.contains("<code>cargo run</code>"));
|
||||
}
|
||||
@@ -309,7 +377,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_image_alt_text() {
|
||||
let md = "";
|
||||
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 = "";
|
||||
let html = markdown_to_html(md);
|
||||
let (html, _) = markdown_to_html(md);
|
||||
|
||||
assert!(html.contains("alt=\"Logo image\""));
|
||||
assert!(html.contains("src=\"logo.png\""));
|
||||
|
||||
Reference in New Issue
Block a user