| 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 | |
| 6 | use markdown_it::{Node, NodeValue, Renderer}; |
| 7 | use oxc_allocator::Allocator; |
| 8 | use oxc_diagnostics::OxcDiagnostic; |
| 9 | use oxc_span::Span; |
| 10 | use std::collections::HashSet; |
| 11 | |
| 12 | use crate::marko_ast::AttributeValue; |
| 13 | use crate::plugin::tags::{MarkoBlockComplete, MarkoOpen, MarkoOpenWithText}; |
| 14 | use crate::typescript::parse_expr_extra; |
| 15 | |
| 16 | /// Decode a static id attribute value, stripping quotes and unescaping if needed. |
| 17 | fn 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)] |
| 33 | pub 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 |
| 43 | pub 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)] |
| 54 | struct HeadingContentRef { |
| 55 | component_name: String, |
| 56 | } |
| 57 | |
| 58 | impl 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. |
| 65 | pub 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. |
| 106 | pub 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. |
| 131 | fn 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 | |
| 140 | fn 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 | |
| 471 | fn 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. |
| 485 | pub 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)] |
| 505 | mod 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 | } |