diff --git a/src/marko.rs b/src/marko.rs index da927e4723acccbb2ec05ad572380be83102fb78..02e0dc3bc92ab37bd2cee78f657faf938898e7e7 100644 --- a/src/marko.rs +++ b/src/marko.rs @@ -1,3 +1,5 @@ +use std::{borrow::Cow, iter::Map}; + use oxc_diagnostics::OxcDiagnostic; use oxc_span::Span; @@ -9,14 +11,14 @@ use crate::{ }, }; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct Open<'a> { pub tag_name: &'a str, pub content: &'a str, pub self_closing: bool, } -impl Open<'_> { +impl<'a> Open<'a> { /// Returns the span of the tag name relative to the start of the tag. pub fn tag_name_span(&self) -> Span { // fast case, when a literal tag name is passed @@ -32,6 +34,14 @@ impl Open<'_> { Span::new(3, l.offset() as u32) } } + + pub fn id_attr(&self) -> Option<&'a str> { + todo!(); + } + + pub fn set_id_attr(&mut self, str: &'a str) { + todo!(); + } } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 2276f329ac713e5a45e947e6f0c4a69677fc05a9..f6ffa59ad16b789aa51c7c367375bb6d2b7037d9 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -5,6 +5,7 @@ pub mod inline_tags; pub mod statement; pub mod tags; pub mod template; +pub mod toc; pub use frontmatter::{extract_preamble_and_frontmatter, PreambleResult}; diff --git a/src/plugin/toc.rs b/src/plugin/toc.rs new file mode 100644 index 0000000000000000000000000000000000000000..dd5de9391749d9bf74eb9ed0bb7d531c0bf4f308 --- /dev/null +++ b/src/plugin/toc.rs @@ -0,0 +1,229 @@ +use markdown_it::Node; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct HeadingEntry { + pub level: u8, + pub id: String, + pub text: String, +} + +pub fn generate_slug(text: &str, existing_ids: &mut std::collections::HashSet) -> String { + let mut slug = text + .chars() + .map(|c| match c { + 'a'..='z' | '0'..='9' => c.to_ascii_lowercase(), + 'A'..='Z' => c.to_ascii_lowercase(), + ' ' | '\t' => '-', + _ if c.is_alphanumeric() => c.to_ascii_lowercase(), + _ => '-', + }) + .collect::(); + + slug = slug + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + + if slug.is_empty() { + slug = "heading".to_string(); + } + + if !existing_ids.contains(&slug) { + existing_ids.insert(slug.clone()); + return slug; + } + + let mut counter = 1; + loop { + let new_slug = format!("{}-{}", slug, counter); + if !existing_ids.contains(&new_slug) { + existing_ids.insert(new_slug.clone()); + return new_slug; + } + counter += 1; + } +} + +pub fn collect_headings(node: &Node) -> Vec { + let mut headings = Vec::new(); + let mut existing_ids = std::collections::HashSet::new(); + + collect_headings_recursive(node, &mut headings, &mut existing_ids); + + headings +} + +fn collect_headings_recursive( + node: &Node, + headings: &mut Vec, + existing_ids: &mut std::collections::HashSet, +) { + // Check for markdown-it ATX headings (# ## ### etc) + if let Some(heading) = node.cast::() { + let text = node.collect_text(); + let id = generate_slug(&text, existing_ids); + headings.push(HeadingEntry { + level: heading.level, + id, + text, + }); + } + // Check for setext headings (underline style) + else if let Some(heading) = + node.cast::() + { + let text = node.collect_text(); + let id = generate_slug(&text, existing_ids); + headings.push(HeadingEntry { + level: heading.level, + id, + text, + }); + } + // Check for MarkoOpen tags that are h1-h6 + else if let Some(open) = node.cast::() { + if let Some(level) = parse_heading_level(&open.tag_name) { + let text = node + .children + .iter() + .map(|c| c.collect_text()) + .collect::>() + .join(""); + let id = if let Some(existing) = open.content.strip_prefix('<').and_then(|s| { + s.find("id=\"") + .map(|pos| { + let start = pos + 4; + s[start..].split('"').next().map(|s| s.to_string()) + }) + .flatten() + }) { + if !existing_ids.contains(&existing) { + existing_ids.insert(existing.clone()); + existing + } else { + generate_slug(&text, existing_ids) + } + } else { + generate_slug(&text, existing_ids) + }; + headings.push(HeadingEntry { level, id, text }); + } + } + // Check for MarkoOpenWithText tags that are h1-h6 + else if let Some(open) = node.cast::() { + if let Some(level) = parse_heading_level(open.tag_name.as_str()) { + let text = node + .children + .iter() + .map(|c| c.collect_text()) + .collect::>() + .join(""); + let id = generate_slug(&text, existing_ids); + headings.push(HeadingEntry { level, id, text }); + } + } + // Check for MarkoBlockComplete tags that are h1-h6 + else if let Some(block) = node.cast::() { + if let Some(level) = parse_heading_level(block.tag_name.as_str()) { + let text = node + .children + .iter() + .map(|c| c.collect_text()) + .collect::>() + .join(""); + let id = generate_slug(&text, existing_ids); + headings.push(HeadingEntry { level, id, text }); + } + } + + for child in &node.children { + collect_headings_recursive(child, headings, existing_ids); + } +} + +fn parse_heading_level(tag_name: &str) -> Option { + match tag_name { + "h1" => Some(1), + "h2" => Some(2), + "h3" => Some(3), + "h4" => Some(4), + "h5" => Some(5), + "h6" => Some(6), + _ => None, + } +} + +pub fn inject_heading_ids(text: &str, headings: &[HeadingEntry]) -> String { + if headings.is_empty() { + return text.to_string(); + } + + let mut result = String::new(); + let mut pos = 0; + let mut heading_idx = 0; + let bytes = text.as_bytes(); + + while pos < bytes.len() { + if bytes[pos] == b'<' { + let remaining = &bytes[pos..]; + if remaining.starts_with(b" 1u8, + b'2' => 2, + b'3' => 3, + b'4' => 4, + b'5' => 5, + b'6' => 6, + _ => { + result.push('<'); + pos += 1; + continue; + } + }; + + let close_pos = match remaining[2..].iter().position(|&b| b == b'>') { + Some(p) => p + 2, + None => { + result.push('<'); + pos += 1; + continue; + } + }; + + let tag_end = pos + close_pos; + let tag = std::str::from_utf8(&bytes[pos..=tag_end]).unwrap_or(""); + + if tag.ends_with("/>") || tag.contains(" ") { + result.push_str(tag); + pos = tag_end + 1; + continue; + } + + if heading_idx < headings.len() && headings[heading_idx].level == level { + let id = &headings[heading_idx].id; + result.push_str(&format!("h{level} id=\"{}\">", id)); + heading_idx += 1; + pos = tag_end + 1; + continue; + } else { + result.push_str(tag); + pos = tag_end + 1; + continue; + } + } + } + + result.push(bytes[pos] as char); + pos += 1; + } + + result +}