authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-15 21:37:52-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-16 00:51:53-08:00
log8b526795802755bdd74f81a3b127695a1fbe3f6b
treeb5e123b35677baf17f4b15110399b9577b09a267
parent2cc147a320759892fbaef7f6c9430007e3db8888
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: stupid table of contents


3 files changed, 242 insertions(+), 2 deletions(-)

src/marko.rs+12-2
......@@ -1,3 +1,5 @@
1use std::{borrow::Cow, iter::Map};
2
13use oxc_diagnostics::OxcDiagnostic;
24use oxc_span::Span;
35
......@@ -9,14 +11,14 @@ use crate::{
911 },
1012};
1113
12#[derive(Debug, Clone, PartialEq, Eq)]
14#[derive(Debug, Clone, PartialEq)]
1315pub struct Open<'a> {
1416 pub tag_name: &'a str,
1517 pub content: &'a str,
1618 pub self_closing: bool,
1719}
1820
19impl Open<'_> {
21impl<'a> Open<'a> {
2022 /// Returns the span of the tag name relative to the start of the tag.
2123 pub fn tag_name_span(&self) -> Span {
2224 // fast case, when a literal tag name is passed
......@@ -32,6 +34,14 @@ impl Open<'_> {
3234 Span::new(3, l.offset() as u32)
3335 }
3436 }
37
38 pub fn id_attr(&self) -> Option<&'a str> {
39 todo!();
40 }
41
42 pub fn set_id_attr(&mut self, str: &'a str) {
43 todo!();
44 }
3545}
3646
3747#[derive(Debug, Clone, PartialEq, Eq)]
src/plugin/mod.rs+1
......@@ -5,6 +5,7 @@ pub mod inline_tags;
55pub mod statement;
66pub mod tags;
77pub mod template;
8pub mod toc;
89
910pub use frontmatter::{extract_preamble_and_frontmatter, PreambleResult};
1011
src/plugin/toc.rs created+229
......@@ -0,0 +1,229 @@
1use markdown_it::Node;
2use serde::Serialize;
3
4#[derive(Debug, Clone, Serialize)]
5pub struct HeadingEntry {
6 pub level: u8,
7 pub id: String,
8 pub text: String,
9}
10
11pub fn generate_slug(text: &str, existing_ids: &mut std::collections::HashSet<String>) -> String {
12 let mut slug = text
13 .chars()
14 .map(|c| match c {
15 'a'..='z' | '0'..='9' => c.to_ascii_lowercase(),
16 'A'..='Z' => c.to_ascii_lowercase(),
17 ' ' | '\t' => '-',
18 _ if c.is_alphanumeric() => c.to_ascii_lowercase(),
19 _ => '-',
20 })
21 .collect::<String>();
22
23 slug = slug
24 .split('-')
25 .filter(|s| !s.is_empty())
26 .collect::<Vec<_>>()
27 .join("-");
28
29 if slug.is_empty() {
30 slug = "heading".to_string();
31 }
32
33 if !existing_ids.contains(&slug) {
34 existing_ids.insert(slug.clone());
35 return slug;
36 }
37
38 let mut counter = 1;
39 loop {
40 let new_slug = format!("{}-{}", slug, counter);
41 if !existing_ids.contains(&new_slug) {
42 existing_ids.insert(new_slug.clone());
43 return new_slug;
44 }
45 counter += 1;
46 }
47}
48
49pub fn collect_headings(node: &Node) -> Vec<HeadingEntry> {
50 let mut headings = Vec::new();
51 let mut existing_ids = std::collections::HashSet::new();
52
53 collect_headings_recursive(node, &mut headings, &mut existing_ids);
54
55 headings
56}
57
58fn collect_headings_recursive(
59 node: &Node,
60 headings: &mut Vec<HeadingEntry>,
61 existing_ids: &mut std::collections::HashSet<String>,
62) {
63 // Check for markdown-it ATX headings (# ## ### etc)
64 if let Some(heading) = node.cast::<markdown_it::plugins::cmark::block::heading::ATXHeading>() {
65 let text = node.collect_text();
66 let id = generate_slug(&text, existing_ids);
67 headings.push(HeadingEntry {
68 level: heading.level,
69 id,
70 text,
71 });
72 }
73 // Check for setext headings (underline style)
74 else if let Some(heading) =
75 node.cast::<markdown_it::plugins::cmark::block::lheading::SetextHeader>()
76 {
77 let text = node.collect_text();
78 let id = generate_slug(&text, existing_ids);
79 headings.push(HeadingEntry {
80 level: heading.level,
81 id,
82 text,
83 });
84 }
85 // Check for MarkoOpen tags that are h1-h6
86 else if let Some(open) = node.cast::<super::tags::MarkoOpen>() {
87 if let Some(level) = parse_heading_level(&open.tag_name) {
88 let text = node
89 .children
90 .iter()
91 .map(|c| c.collect_text())
92 .collect::<Vec<_>>()
93 .join("");
94 let id = if let Some(existing) = open.content.strip_prefix('<').and_then(|s| {
95 s.find("id=\"")
96 .map(|pos| {
97 let start = pos + 4;
98 s[start..].split('"').next().map(|s| s.to_string())
99 })
100 .flatten()
101 }) {
102 if !existing_ids.contains(&existing) {
103 existing_ids.insert(existing.clone());
104 existing
105 } else {
106 generate_slug(&text, existing_ids)
107 }
108 } else {
109 generate_slug(&text, existing_ids)
110 };
111 headings.push(HeadingEntry { level, id, text });
112 }
113 }
114 // Check for MarkoOpenWithText tags that are h1-h6
115 else if let Some(open) = node.cast::<super::tags::MarkoOpenWithText>() {
116 if let Some(level) = parse_heading_level(open.tag_name.as_str()) {
117 let text = node
118 .children
119 .iter()
120 .map(|c| c.collect_text())
121 .collect::<Vec<_>>()
122 .join("");
123 let id = generate_slug(&text, existing_ids);
124 headings.push(HeadingEntry { level, id, text });
125 }
126 }
127 // Check for MarkoBlockComplete tags that are h1-h6
128 else if let Some(block) = node.cast::<super::tags::MarkoBlockComplete>() {
129 if let Some(level) = parse_heading_level(block.tag_name.as_str()) {
130 let text = node
131 .children
132 .iter()
133 .map(|c| c.collect_text())
134 .collect::<Vec<_>>()
135 .join("");
136 let id = generate_slug(&text, existing_ids);
137 headings.push(HeadingEntry { level, id, text });
138 }
139 }
140
141 for child in &node.children {
142 collect_headings_recursive(child, headings, existing_ids);
143 }
144}
145
146fn parse_heading_level(tag_name: &str) -> Option<u8> {
147 match tag_name {
148 "h1" => Some(1),
149 "h2" => Some(2),
150 "h3" => Some(3),
151 "h4" => Some(4),
152 "h5" => Some(5),
153 "h6" => Some(6),
154 _ => None,
155 }
156}
157
158pub fn inject_heading_ids(text: &str, headings: &[HeadingEntry]) -> String {
159 if headings.is_empty() {
160 return text.to_string();
161 }
162
163 let mut result = String::new();
164 let mut pos = 0;
165 let mut heading_idx = 0;
166 let bytes = text.as_bytes();
167
168 while pos < bytes.len() {
169 if bytes[pos] == b'<' {
170 let remaining = &bytes[pos..];
171 if remaining.starts_with(b"<h1")
172 || remaining.starts_with(b"<h2")
173 || remaining.starts_with(b"<h3")
174 || remaining.starts_with(b"<h4")
175 || remaining.starts_with(b"<h5")
176 || remaining.starts_with(b"<h6")
177 {
178 let level = match remaining[1] {
179 b'1' => 1u8,
180 b'2' => 2,
181 b'3' => 3,
182 b'4' => 4,
183 b'5' => 5,
184 b'6' => 6,
185 _ => {
186 result.push('<');
187 pos += 1;
188 continue;
189 }
190 };
191
192 let close_pos = match remaining[2..].iter().position(|&b| b == b'>') {
193 Some(p) => p + 2,
194 None => {
195 result.push('<');
196 pos += 1;
197 continue;
198 }
199 };
200
201 let tag_end = pos + close_pos;
202 let tag = std::str::from_utf8(&bytes[pos..=tag_end]).unwrap_or("");
203
204 if tag.ends_with("/>") || tag.contains(" ") {
205 result.push_str(tag);
206 pos = tag_end + 1;
207 continue;
208 }
209
210 if heading_idx < headings.len() && headings[heading_idx].level == level {
211 let id = &headings[heading_idx].id;
212 result.push_str(&format!("h{level} id=\"{}\">", id));
213 heading_idx += 1;
214 pos = tag_end + 1;
215 continue;
216 } else {
217 result.push_str(tag);
218 pos = tag_end + 1;
219 continue;
220 }
221 }
222 }
223
224 result.push(bytes[pos] as char);
225 pos += 1;
226 }
227
228 result
229}