1//! Outline extraction for headings.
2//!
3//! Walks the AST to collect heading information, inject IDs, and extract content
4//! into hoisted `<define/>` blocks for use in the outline.
5
6use markdown_it::{Node, NodeValue, Renderer};
7use oxc_allocator::Allocator;
8use oxc_diagnostics::OxcDiagnostic;
9use oxc_span::Span;
10use std::collections::HashSet;
11
12use crate::marko_ast::AttributeValue;
13use crate::plugin::tags::{MarkoBlockComplete, MarkoOpen, MarkoOpenWithText};
14use crate::typescript::parse_expr_extra;
15
16/// Decode a static id attribute value, stripping quotes and unescaping if needed.
17fn decode_static_id(src: &str, span: Span, is_quoted: bool) -> String {
18 let string = &src[span.start as usize..span.end as usize];
19 if is_quoted {
20 let mut allocator = Allocator::new();
21 let expr = parse_expr_extra(string, 0, &mut allocator);
22 match &expr {
23 Ok(oxc_ast::ast::Expression::StringLiteral(literal)) => literal.value.to_string(),
24 _ => panic!("verified beforehand as a string literal"),
25 }
26 } else {
27 string.to_owned()
28 }
29}
30
31/// A heading entry for the outline, with content reference for hoisting.
32#[derive(Debug, Clone)]
33pub struct HeadingEntry {
34 pub level: u8,
35 pub id: String,
36 /// Plain text content (for display/search)
37 pub text: String,
38 /// Component name for the hoisted content (e.g., "Heading_1__markodown__")
39 pub component_name: String,
40}
41
42/// Result of outline extraction
43pub struct OutlineResult {
44 /// The heading entries for the outline
45 pub headings: Vec<HeadingEntry>,
46 /// The `<define/>` blocks to hoist (as rendered strings)
47 pub defines: String,
48 /// Any errors encountered (e.g., dynamic IDs)
49 pub errors: Vec<OxcDiagnostic>,
50}
51
52/// A node that renders as a component reference: `<Heading_N__markodown__/>`
53#[derive(Debug)]
54struct HeadingContentRef {
55 component_name: String,
56}
57
58impl NodeValue for HeadingContentRef {
59 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
60 fmt.text_raw(&format!("<{}/>\n", self.component_name));
61 }
62}
63
64/// Generate a URL-friendly slug from text, ensuring uniqueness.
65pub fn generate_slug(text: &str, existing_ids: &mut HashSet<String>) -> String {
66 let mut slug: String = text
67 .chars()
68 .map(|c| match c {
69 'a'..='z' | '0'..='9' => c,
70 'A'..='Z' => c.to_ascii_lowercase(),
71 ' ' | '\t' => '-',
72 _ if c.is_alphanumeric() => c.to_ascii_lowercase(),
73 _ => '-',
74 })
75 .collect();
76
77 // Collapse multiple dashes and trim
78 slug = slug
79 .split('-')
80 .filter(|s| !s.is_empty())
81 .collect::<Vec<_>>()
82 .join("-");
83
84 if slug.is_empty() {
85 slug = "heading".to_string();
86 }
87
88 // Ensure uniqueness
89 if !existing_ids.contains(&slug) {
90 existing_ids.insert(slug.clone());
91 return slug;
92 }
93
94 let mut counter = 1;
95 loop {
96 let new_slug = format!("{slug}-{counter}");
97 if !existing_ids.contains(&new_slug) {
98 existing_ids.insert(new_slug.clone());
99 return new_slug;
100 }
101 counter += 1;
102 }
103}
104
105/// Collect headings from the AST, inject IDs, and extract content into defines.
106pub fn collect_and_extract(node: &mut Node, preamble_offset: u32) -> OutlineResult {
107 let mut headings = Vec::new();
108 let mut errors = Vec::new();
109 let mut defines = String::new();
110 let mut existing_ids = HashSet::new();
111 let mut heading_counter = 0usize;
112
113 collect_recursive(
114 node,
115 &mut headings,
116 &mut errors,
117 &mut defines,
118 &mut existing_ids,
119 &mut heading_counter,
120 preamble_offset,
121 );
122
123 OutlineResult {
124 headings,
125 defines,
126 errors,
127 }
128}
129
130/// Render a node's children to a string.
131fn render_children(node: &Node) -> String {
132 // Create a temporary wrapper to render just the children
133 let mut output = String::new();
134 for child in &node.children {
135 output.push_str(&child.render());
136 }
137 output
138}
139
140fn collect_recursive(
141 node: &mut Node,
142 headings: &mut Vec<HeadingEntry>,
143 errors: &mut Vec<OxcDiagnostic>,
144 defines: &mut String,
145 existing_ids: &mut HashSet<String>,
146 heading_counter: &mut usize,
147 preamble_offset: u32,
148) {
149 let (node_start, _) = node.srcmap.map(|s| s.get_byte_offsets()).unwrap_or((0, 0));
150 let node_start = node_start as u32 + preamble_offset;
151
152 // Check for markdown-it ATX headings (# ## ### etc)
153 if let Some(heading) = node.cast::<markdown_it::plugins::cmark::block::heading::ATXHeading>() {
154 let level = heading.level;
155 let text = node.collect_text();
156 let id = generate_slug(&text, existing_ids);
157
158 // Generate component name
159 *heading_counter += 1;
160 let component_name = format!("Heading_{heading_counter}__markodown__");
161
162 // Render children before replacing them
163 let rendered_content = render_children(node);
164
165 // Create the define block
166 let trimmed_content = rendered_content.trim();
167 defines.push_str(&format!(
168 "<define/{component_name}>\n{trimmed_content}\n</>\n"
169 ));
170
171 // Replace children with reference node
172 node.children.clear();
173 node.children.push(Node::new(HeadingContentRef {
174 component_name: component_name.clone(),
175 }));
176
177 // Inject id attribute
178 node.attrs.push(("id", id.clone()));
179
180 headings.push(HeadingEntry {
181 level,
182 id,
183 text,
184 component_name,
185 });
186 }
187 // Check for setext headings (underline style)
188 else if let Some(heading) =
189 node.cast::<markdown_it::plugins::cmark::block::lheading::SetextHeader>()
190 {
191 let level = heading.level;
192 let text = node.collect_text();
193 let id = generate_slug(&text, existing_ids);
194
195 *heading_counter += 1;
196 let component_name = format!("Heading_{heading_counter}__markodown__");
197
198 let rendered_content = render_children(node);
199
200 let trimmed_content = rendered_content.trim();
201 defines.push_str(&format!(
202 "<define/{component_name}>\n{trimmed_content}\n</>\n"
203 ));
204
205 node.children.clear();
206 node.children.push(Node::new(HeadingContentRef {
207 component_name: component_name.clone(),
208 }));
209
210 node.attrs.push(("id", id.clone()));
211
212 headings.push(HeadingEntry {
213 level,
214 id,
215 text,
216 component_name,
217 });
218 }
219 // Check for MarkoOpen tags that are h1-h6
220 else if node.cast::<MarkoOpen>().is_some() {
221 let heading_info = {
222 let open = node.cast::<MarkoOpen>().unwrap();
223 if let Some(level) = parse_heading_level(open.open.as_ref().tag_name()) {
224 let text = node
225 .children
226 .iter()
227 .map(|c| c.collect_text())
228 .collect::<Vec<_>>()
229 .join("");
230 let id_info = open.open.id;
231 let tag_span = open.open.as_ref().tag_name_span();
232 let existing_id = match id_info {
233 AttributeValue::Static { span, is_quoted } => {
234 Some(decode_static_id(&open.open.src, span, is_quoted))
235 }
236 _ => None,
237 };
238 Some((level, text, id_info, tag_span, existing_id))
239 } else {
240 None
241 }
242 };
243
244 if let Some((level, text, id_info, tag_span, existing_id)) = heading_info {
245 let id = match id_info {
246 AttributeValue::Static { .. } => {
247 let id_str = existing_id.unwrap();
248 if !existing_ids.contains(&id_str) {
249 existing_ids.insert(id_str.clone());
250 id_str
251 } else {
252 generate_slug(&text, existing_ids)
253 }
254 }
255 AttributeValue::Dynamic => {
256 errors.push(
257 OxcDiagnostic::error(
258 "Dynamic id attribute not supported when outline is enabled",
259 )
260 .with_label(Span::new(
261 node_start + tag_span.start,
262 node_start + tag_span.end,
263 )),
264 );
265 generate_slug(&text, existing_ids)
266 }
267 AttributeValue::None => {
268 let id = generate_slug(&text, existing_ids);
269 let open = node.cast_mut::<MarkoOpen>().unwrap();
270 open.open.insert_id_attr(&id);
271 id
272 }
273 };
274
275 *heading_counter += 1;
276 let component_name = format!("Heading_{heading_counter}__markodown__");
277
278 let rendered_content = render_children(node);
279
280 let trimmed_content = rendered_content.trim();
281 defines.push_str(&format!(
282 "<define/{component_name}>\n{trimmed_content}\n</>\n"
283 ));
284
285 node.children.clear();
286 node.children.push(Node::new(HeadingContentRef {
287 component_name: component_name.clone(),
288 }));
289
290 headings.push(HeadingEntry {
291 level,
292 id,
293 text,
294 component_name,
295 });
296 }
297 }
298 // Check for MarkoOpenWithText tags that are h1-h6
299 else if node.cast::<MarkoOpenWithText>().is_some() {
300 let heading_info = {
301 let open = node.cast::<MarkoOpenWithText>().unwrap();
302 if let Some(level) = parse_heading_level(open.open.as_ref().tag_name()) {
303 let text = node
304 .children
305 .iter()
306 .map(|c| c.collect_text())
307 .collect::<Vec<_>>()
308 .join("");
309 let id_info = open.open.id;
310 let tag_span = open.open.as_ref().tag_name_span();
311 let existing_id = match id_info {
312 AttributeValue::Static { span, is_quoted } => {
313 Some(decode_static_id(&open.open.src, span, is_quoted))
314 }
315 _ => None,
316 };
317 Some((level, text, id_info, tag_span, existing_id))
318 } else {
319 None
320 }
321 };
322
323 if let Some((level, text, id_info, tag_span, existing_id)) = heading_info {
324 let id = match id_info {
325 AttributeValue::Static { .. } => {
326 let id_str = existing_id.unwrap();
327 if !existing_ids.contains(&id_str) {
328 existing_ids.insert(id_str.clone());
329 id_str
330 } else {
331 generate_slug(&text, existing_ids)
332 }
333 }
334 AttributeValue::Dynamic => {
335 errors.push(
336 OxcDiagnostic::error(
337 "Dynamic id attribute not supported when outline is enabled",
338 )
339 .with_label(Span::new(
340 node_start + tag_span.start,
341 node_start + tag_span.end,
342 )),
343 );
344 generate_slug(&text, existing_ids)
345 }
346 AttributeValue::None => {
347 let id = generate_slug(&text, existing_ids);
348 let open = node.cast_mut::<MarkoOpenWithText>().unwrap();
349 open.open.insert_id_attr(&id);
350 id
351 }
352 };
353
354 *heading_counter += 1;
355 let component_name = format!("Heading_{heading_counter}__markodown__");
356
357 let rendered_content = render_children(node);
358
359 let trimmed_content = rendered_content.trim();
360 defines.push_str(&format!(
361 "<define/{component_name}>\n{trimmed_content}\n</>\n"
362 ));
363
364 node.children.clear();
365 node.children.push(Node::new(HeadingContentRef {
366 component_name: component_name.clone(),
367 }));
368
369 headings.push(HeadingEntry {
370 level,
371 id,
372 text,
373 component_name,
374 });
375 }
376 }
377 // Check for MarkoBlockComplete tags that are h1-h6
378 else if node.cast::<MarkoBlockComplete>().is_some() {
379 let heading_info = {
380 let block = node.cast::<MarkoBlockComplete>().unwrap();
381 if let Some(level) = parse_heading_level(block.open.as_ref().tag_name()) {
382 let text = node
383 .children
384 .iter()
385 .map(|c| c.collect_text())
386 .collect::<Vec<_>>()
387 .join("");
388 let id_info = block.open.id;
389 let tag_span = block.open.as_ref().tag_name_span();
390 let existing_id = match id_info {
391 AttributeValue::Static { span, is_quoted } => {
392 Some(decode_static_id(&block.open.src, span, is_quoted))
393 }
394 _ => None,
395 };
396 Some((level, text, id_info, tag_span, existing_id))
397 } else {
398 None
399 }
400 };
401
402 if let Some((level, text, id_info, tag_span, existing_id)) = heading_info {
403 let id = match id_info {
404 AttributeValue::Static { .. } => {
405 let id_str = existing_id.unwrap();
406 if !existing_ids.contains(&id_str) {
407 existing_ids.insert(id_str.clone());
408 id_str
409 } else {
410 generate_slug(&text, existing_ids)
411 }
412 }
413 AttributeValue::Dynamic => {
414 errors.push(
415 OxcDiagnostic::error(
416 "Dynamic id attribute not supported when outline is enabled",
417 )
418 .with_label(Span::new(
419 node_start + tag_span.start,
420 node_start + tag_span.end,
421 )),
422 );
423 generate_slug(&text, existing_ids)
424 }
425 AttributeValue::None => {
426 let id = generate_slug(&text, existing_ids);
427 let block = node.cast_mut::<MarkoBlockComplete>().unwrap();
428 block.open.insert_id_attr(&id);
429 id
430 }
431 };
432
433 *heading_counter += 1;
434 let component_name = format!("Heading_{heading_counter}__markodown__");
435
436 let rendered_content = render_children(node);
437
438 let trimmed_content = rendered_content.trim();
439 defines.push_str(&format!(
440 "<define/{component_name}>\n{trimmed_content}\n</>\n"
441 ));
442
443 node.children.clear();
444 node.children.push(Node::new(HeadingContentRef {
445 component_name: component_name.clone(),
446 }));
447
448 headings.push(HeadingEntry {
449 level,
450 id,
451 text,
452 component_name,
453 });
454 }
455 }
456
457 // Recurse into children
458 for child in &mut node.children {
459 collect_recursive(
460 child,
461 headings,
462 errors,
463 defines,
464 existing_ids,
465 heading_counter,
466 preamble_offset,
467 );
468 }
469}
470
471fn parse_heading_level(tag_name: &str) -> Option<u8> {
472 match tag_name {
473 "h1" => Some(1),
474 "h2" => Some(2),
475 "h3" => Some(3),
476 "h4" => Some(4),
477 "h5" => Some(5),
478 "h6" => Some(6),
479 _ => None,
480 }
481}
482
483/// Format the outline array for Marko output.
484/// Uses JS object syntax with unquoted component references.
485pub fn format_outline_array(headings: &[HeadingEntry]) -> String {
486 if headings.is_empty() {
487 return "[]".to_string();
488 }
489
490 let entries: Vec<String> = headings
491 .iter()
492 .map(|h| {
493 let escaped_id = h.id.replace('\\', "\\\\").replace('\'', "\\'");
494 format!(
495 "{{ level: {}, id: '{}', content: {} }}",
496 h.level, escaped_id, h.component_name
497 )
498 })
499 .collect();
500
501 format!("[\n {},\n]", entries.join(",\n "))
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 #[test]
509 fn test_generate_slug_basic() {
510 let mut existing = HashSet::new();
511 assert_eq!(generate_slug("Hello World", &mut existing), "hello-world");
512 assert!(existing.contains("hello-world"));
513 }
514
515 #[test]
516 fn test_generate_slug_special_chars() {
517 let mut existing = HashSet::new();
518 assert_eq!(
519 generate_slug("What's up? (2024)", &mut existing),
520 "what-s-up-2024"
521 );
522 }
523
524 #[test]
525 fn test_generate_slug_duplicates() {
526 let mut existing = HashSet::new();
527 assert_eq!(generate_slug("test", &mut existing), "test");
528 assert_eq!(generate_slug("test", &mut existing), "test-1");
529 assert_eq!(generate_slug("test", &mut existing), "test-2");
530 }
531
532 #[test]
533 fn test_generate_slug_empty() {
534 let mut existing = HashSet::new();
535 assert_eq!(generate_slug("!!!", &mut existing), "heading");
536 }
537
538 #[test]
539 fn test_format_outline_array() {
540 let headings = vec![
541 HeadingEntry {
542 level: 1,
543 id: "hello".to_string(),
544 text: "Hello".to_string(),
545 component_name: "Heading_1__markodown__".to_string(),
546 },
547 HeadingEntry {
548 level: 2,
549 id: "world".to_string(),
550 text: "World".to_string(),
551 component_name: "Heading_2__markodown__".to_string(),
552 },
553 ];
554
555 let result = format_outline_array(&headings);
556 assert!(result.contains("level: 1"));
557 assert!(result.contains("id: 'hello'"));
558 assert!(result.contains("content: Heading_1__markodown__"));
559 }
560
561 #[test]
562 fn test_format_outline_array_escapes_single_quotes() {
563 let headings = vec![HeadingEntry {
564 level: 2,
565 id: "it's-here".to_string(),
566 text: "It's here".to_string(),
567 component_name: "Heading_1__markodown__".to_string(),
568 }];
569 let result = format_outline_array(&headings);
570 assert!(
571 result.contains(r"id: 'it\'s-here'"),
572 "single quote in id must be escaped: {result}"
573 );
574 }
575}