1const MARKDOWN_ATTRIBUTE_ELEMENTS: &[&str] = &[
27 "address",
28 "article",
29 "aside",
30 "blockquote",
31 "body",
32 "colgroup",
33 "dd",
34 "details",
35 "div",
36 "dl",
37 "dt",
38 "fieldset",
39 "figcaption",
40 "figure",
41 "footer",
42 "form",
43 "group",
44 "h1",
45 "h2",
46 "h3",
47 "h4",
48 "h5",
49 "h6",
50 "header",
51 "hgroup",
52 "iframe",
53 "legend",
54 "li",
55 "main",
56 "map",
57 "menu",
58 "nav",
59 "noscript",
60 "object",
61 "ol",
62 "output",
63 "p",
64 "progress",
65 "section",
66 "summary",
67 "table",
68 "tbody",
69 "td",
70 "tfoot",
71 "th",
72 "thead",
73 "tr",
74 "ul",
75 "video",
76];
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum MarkdownContent {
81 Block,
83 Span,
86}
87
88pub fn opens_markdown_block_alone(line: &str) -> bool {
91 scan_markdown_open_tag(line).is_some_and(|(_, content, end)| {
92 content == MarkdownContent::Block && line.trim_start()[end..].trim().is_empty()
93 })
94}
95
96pub fn closes_markdown_element(line: &str) -> bool {
99 let Some(rest) = line.trim().strip_prefix("</") else {
100 return false;
101 };
102 let name: String = rest
103 .chars()
104 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
105 .collect::<String>()
106 .to_ascii_lowercase();
107 MARKDOWN_ATTRIBUTE_ELEMENTS.contains(&name.as_str()) && rest[name.len()..].trim_start().starts_with('>')
108}
109
110fn markdown_html_open_tag(line: &str) -> Option<(String, MarkdownContent)> {
118 scan_markdown_open_tag(line).map(|(tag, content, _)| (tag, content))
119}
120
121fn scan_markdown_open_tag(line: &str) -> Option<(String, MarkdownContent, usize)> {
124 let line = line.trim_start();
125 let bytes = line.as_bytes();
126 if bytes.first() != Some(&b'<') || !bytes.get(1).is_some_and(u8::is_ascii_alphabetic) {
127 return None;
128 }
129
130 let mut i = 1;
131 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-') {
132 i += 1;
133 }
134 let tag = line[1..i].to_ascii_lowercase();
135 if !MARKDOWN_ATTRIBUTE_ELEMENTS.contains(&tag.as_str()) {
136 return None;
137 }
138
139 let mut declared: Option<&str> = None;
142 while i < bytes.len() {
143 match bytes[i] {
144 b'>' => {
145 return declared.filter(|value| *value != "0").map(|value| {
146 let content = if value.eq_ignore_ascii_case("span") {
147 MarkdownContent::Span
148 } else {
149 MarkdownContent::Block
150 };
151 (tag, content, i + 1)
152 });
153 }
154 b' ' | b'\t' | b'/' => i += 1,
155 _ => {
156 let name_start = i;
157 while i < bytes.len() && !matches!(bytes[i], b' ' | b'\t' | b'=' | b'>' | b'/') {
158 i += 1;
159 }
160 let name = &line[name_start..i];
161
162 let mut value = "";
163 let mut j = i;
164 while j < bytes.len() && matches!(bytes[j], b' ' | b'\t') {
165 j += 1;
166 }
167 if bytes.get(j) == Some(&b'=') {
168 j += 1;
169 while j < bytes.len() && matches!(bytes[j], b' ' | b'\t') {
170 j += 1;
171 }
172 match bytes.get(j) {
173 Some("e @ (b'"' | b'\'')) => {
174 let start = j + 1;
175 let end = start + line[start..].find(quote as char)?;
178 value = &line[start..end];
179 j = end + 1;
180 }
181 _ => {
182 let start = j;
183 while j < bytes.len() && !matches!(bytes[j], b' ' | b'\t' | b'>') {
184 j += 1;
185 }
186 value = &line[start..j];
187 }
188 }
189 i = j;
190 }
191
192 if name.eq_ignore_ascii_case("markdown") {
193 declared = Some(value);
194 }
195 }
196 }
197 }
198 None
199}
200
201#[derive(Debug, Default)]
203pub struct MarkdownHtmlTracker {
204 tag_stack: Vec<(String, usize)>,
206 content_stack: Vec<MarkdownContent>,
208 depth: usize,
210}
211
212impl MarkdownHtmlTracker {
213 pub fn new() -> Self {
214 Self::default()
215 }
216
217 pub fn is_open(&self) -> bool {
219 !self.tag_stack.is_empty()
220 }
221
222 pub fn process_line(&mut self, line: &str) -> bool {
227 let trimmed = line.trim();
228
229 if let Some((tag, content)) = markdown_html_open_tag(line) {
231 let closes_here = Self::count_closes_lowered(&line.to_lowercase(), &tag) > 0;
233
234 self.depth += 1;
235 self.tag_stack.push((tag, self.depth));
236 self.content_stack.push(content);
237 if closes_here {
238 self.depth -= 1;
239 self.tag_stack.pop();
240 self.content_stack.pop();
241 }
242 return true;
243 }
244
245 if !self.tag_stack.is_empty() {
247 let line_lower = trimmed.to_lowercase();
249
250 let tags: Vec<String> = self.tag_stack.iter().map(|(tag, _)| tag.clone()).collect();
252 for tag in &tags {
253 let opens = Self::count_opens_lowered(&line_lower, tag);
254 let closes = Self::count_closes_lowered(&line_lower, tag);
255
256 self.depth += opens;
257
258 for _ in 0..closes {
259 if self.depth > 0 {
260 self.depth -= 1;
261 }
262 }
263 }
264
265 while let Some((_, start_depth)) = self.tag_stack.last() {
267 if self.depth < *start_depth {
268 self.tag_stack.pop();
269 self.content_stack.pop();
270 } else {
271 break;
272 }
273 }
274
275 return true;
278 }
279
280 false
281 }
282
283 fn count_opens_lowered(line_lower: &str, tag: &str) -> usize {
286 let open_pattern = format!("<{tag}");
287 let mut count = 0;
288 let mut search_start = 0;
289
290 while let Some(pos) = line_lower[search_start..].find(&open_pattern) {
291 let abs_pos = search_start + pos;
292 let after_tag = abs_pos + open_pattern.len();
293
294 if after_tag >= line_lower.len()
296 || line_lower[after_tag..].starts_with(|c: char| c.is_whitespace() || c == '>' || c == '/')
297 {
298 count += 1;
299 }
300 search_start = after_tag;
301 }
302 count
303 }
304
305 fn count_closes_lowered(line_lower: &str, tag: &str) -> usize {
308 let close_pattern = format!("</{tag}");
309 let mut count = 0;
310 let mut search_start = 0;
311
312 while let Some(pos) = line_lower[search_start..].find(&close_pattern) {
313 let abs_pos = search_start + pos;
314 let after_tag = abs_pos + close_pattern.len();
315
316 if let Some(rest) = line_lower.get(after_tag..)
318 && rest.trim_start().starts_with('>')
319 {
320 count += 1;
321 }
322 search_start = after_tag;
323 }
324 count
325 }
326
327 pub fn in_span(&self) -> bool {
330 self.content_stack.last() == Some(&MarkdownContent::Span)
331 }
332
333 pub fn open_elements(&self) -> usize {
335 self.tag_stack.len()
336 }
337
338 pub fn is_inside(&self) -> bool {
340 !self.tag_stack.is_empty()
341 }
342
343 pub fn reset(&mut self) {
345 self.tag_stack.clear();
346 self.content_stack.clear();
347 self.depth = 0;
348 }
349}
350
351pub fn mentions_markdown(text: &str) -> bool {
354 text.as_bytes().windows(8).any(|w| w.eq_ignore_ascii_case(b"markdown"))
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 fn opens(line: &str) -> bool {
362 markdown_html_open_tag(line).is_some()
363 }
364
365 #[test]
366 fn test_markdown_html_detection() {
367 assert!(opens("<div markdown>"));
369 assert!(opens("<div class=\"grid cards\" markdown>"));
370 assert!(opens("<div markdown=\"1\">"));
371 assert!(opens("<div markdown=\"block\">"));
372
373 assert!(opens("<div markdown class=\"test\">"));
375 assert!(opens("<div id=\"foo\" markdown>"));
376
377 assert!(opens("<DIV markdown>"));
379 assert!(opens("<Div Markdown>"));
380
381 assert!(opens(" <div markdown>"));
383 assert!(opens(" <div class=\"grid\" markdown>"));
384
385 assert!(opens("<section markdown>"));
387 assert!(opens("<article markdown>"));
388 assert!(opens("<details markdown>"));
389
390 assert!(!opens("<div class=\"test\">"));
392 assert!(!opens("<span markdown>")); assert!(!opens("text with markdown word"));
394 assert!(!opens("<div>markdown</div>"));
395 }
396
397 #[test]
398 fn test_attribute_value_quoting() {
399 assert_eq!(
400 markdown_html_open_tag("<div markdown=1>")
401 .map(|(tag, _)| tag)
402 .as_deref(),
403 Some("div")
404 );
405 assert_eq!(
406 markdown_html_open_tag("<div markdown='block'>")
407 .map(|(tag, _)| tag)
408 .as_deref(),
409 Some("div")
410 );
411 assert_eq!(
412 markdown_html_open_tag("<div markdown = \"1\" >")
413 .map(|(tag, _)| tag)
414 .as_deref(),
415 Some("div")
416 );
417 assert_eq!(
418 markdown_html_open_tag("<div markdown=\"default\"/>")
419 .map(|(tag, _)| tag)
420 .as_deref(),
421 Some("div")
422 );
423
424 assert!(!opens("<div class=\"unclosed markdown>"));
426 assert!(!opens("<div markdown"));
428 }
429
430 #[test]
431 fn test_span_level_block_elements_take_the_attribute() {
432 for line in [
436 "<p markdown=\"1\">",
437 "<h1 markdown=\"1\">",
438 "<h5 markdown=\"1\">Diving in</h5>",
439 "<h6 markdown>",
440 "<li markdown=\"1\">",
441 "<td markdown=\"1\">",
442 "<th markdown=\"1\">",
443 "<dd markdown=\"1\">",
444 "<summary markdown=\"span\">",
445 "<blockquote markdown=\"1\">",
446 "<table markdown=\"block\">",
447 ] {
448 assert!(opens(line), "{line} should open a markdown block");
449 }
450 }
451
452 #[test]
453 fn test_the_word_markdown_in_another_attribute_is_not_an_opt_in() {
454 for line in [
457 "<div class=\"markdown-body\">",
458 "<div id=\"hello-markdown\">",
459 "<div class=\"marketplace-extensions-markdown-preview-curated\"></div>",
460 "<video title=\"Rendering markdown in the editor\" autoplay controls></video>",
461 "<section data-note=\"see markdown docs\">",
462 ] {
463 assert!(!opens(line), "{line} must not open a markdown block");
464 }
465
466 assert!(opens("<div class=\"markdown-body\" markdown=\"1\">"));
468 }
469
470 #[test]
471 fn test_markdown_zero_declares_raw_html() {
472 assert!(!opens("<div markdown=\"0\">"));
475 assert!(!opens("<div markdown='0'>"));
476 assert!(!opens("<div markdown=0>"));
477 assert!(!opens(
478 "<div markdown=\"0\"><a href=\"#\" class=\"btn\">Button</a></div>"
479 ));
480
481 let mut tracker = MarkdownHtmlTracker::new();
482 assert!(!tracker.process_line("<div markdown=\"0\">"));
483 assert!(!tracker.is_inside());
484
485 assert!(opens("<div markdown=\"10\">"));
487 }
488
489 #[test]
490 fn test_raw_text_and_void_elements_never_open_a_block() {
491 for line in [
494 "<pre markdown=\"1\">",
495 "<script markdown=\"1\">",
496 "<style markdown>",
497 "<textarea markdown=\"1\">",
498 "<canvas markdown=\"1\">",
499 "<option markdown=\"1\">",
500 ] {
501 assert!(!opens(line), "{line} must not open a markdown block");
502 }
503
504 assert!(!opens("<hr markdown>"));
507 assert!(!opens("<img src=\"x.png\" markdown=\"1\">"));
508 }
509
510 #[test]
511 fn test_tracker_paragraph_block_ends_at_its_closing_tag() {
512 let mut tracker = MarkdownHtmlTracker::new();
515
516 assert!(tracker.process_line("<p markdown=\"1\">"));
517 assert!(tracker.is_inside());
518 assert!(tracker.process_line("- a list marker with custom spacing"));
519 assert!(tracker.is_inside());
520 assert!(tracker.process_line("</p>"));
521 assert!(!tracker.is_inside());
522
523 assert!(!tracker.process_line("- back outside the block"));
524 }
525
526 #[test]
527 fn test_tracker_basic() {
528 let mut tracker = MarkdownHtmlTracker::new();
529
530 assert!(!tracker.is_inside());
531
532 assert!(tracker.process_line("<div class=\"grid cards\" markdown>"));
533 assert!(tracker.is_inside());
534
535 assert!(tracker.process_line("- Content here"));
536 assert!(tracker.is_inside());
537
538 assert!(tracker.process_line(" ---"));
539 assert!(tracker.is_inside());
540
541 tracker.process_line("</div>");
543 assert!(!tracker.is_inside());
544 }
545
546 #[test]
547 fn test_tracker_nested() {
548 let mut tracker = MarkdownHtmlTracker::new();
549
550 tracker.process_line("<div markdown>");
551 assert!(tracker.is_inside());
552
553 tracker.process_line("<div>nested</div>");
554 assert!(tracker.is_inside());
555
556 tracker.process_line("</div>");
557 assert!(!tracker.is_inside());
558 }
559
560 #[test]
561 fn test_grid_cards_pattern() {
562 let content = r#"<div class="grid cards" markdown>
563
564- :zap:{ .lg .middle } **Built for speed**
565
566 ---
567
568 Written in Rust.
569
570</div>"#;
571
572 let mut tracker = MarkdownHtmlTracker::new();
573 let mut inside_lines = Vec::new();
574
575 for (i, line) in content.lines().enumerate() {
576 let inside = tracker.process_line(line);
577 if inside {
578 inside_lines.push(i);
579 }
580 }
581
582 assert!(inside_lines.contains(&0)); assert!(inside_lines.contains(&2)); assert!(inside_lines.contains(&4)); assert!(inside_lines.contains(&6)); assert!(!tracker.is_inside()); }
589
590 #[test]
591 fn test_same_line_open_close() {
592 let mut tracker = MarkdownHtmlTracker::new();
593
594 let result = tracker.process_line("<div markdown>content</div>");
596 assert!(result); assert!(!tracker.is_inside()); }
599
600 #[test]
601 fn test_multiple_sequential_blocks() {
602 let mut tracker = MarkdownHtmlTracker::new();
603
604 assert!(tracker.process_line("<div markdown>"));
606 assert!(tracker.is_inside());
607 assert!(tracker.process_line("Content 1"));
608 tracker.process_line("</div>");
609 assert!(!tracker.is_inside());
610
611 assert!(tracker.process_line("<section markdown>"));
613 assert!(tracker.is_inside());
614 assert!(tracker.process_line("Content 2"));
615 tracker.process_line("</section>");
616 assert!(!tracker.is_inside());
617 }
618
619 #[test]
620 fn test_deeply_nested_same_tag() {
621 let mut tracker = MarkdownHtmlTracker::new();
622
623 assert!(tracker.process_line("<div markdown>"));
624 assert!(tracker.is_inside());
625
626 assert!(tracker.process_line("<div class=\"inner\">"));
628 assert!(tracker.is_inside());
629
630 assert!(tracker.process_line("</div>"));
632 assert!(tracker.is_inside()); tracker.process_line("</div>");
636 assert!(!tracker.is_inside());
637 }
638
639 #[test]
640 fn test_deeply_nested_different_tags() {
641 let mut tracker = MarkdownHtmlTracker::new();
642
643 assert!(tracker.process_line("<article markdown>"));
644 assert!(tracker.is_inside());
645
646 assert!(tracker.process_line("<section>"));
648 assert!(tracker.is_inside());
649
650 assert!(tracker.process_line("</section>"));
652 assert!(tracker.is_inside());
653
654 tracker.process_line("</article>");
656 assert!(!tracker.is_inside());
657 }
658
659 #[test]
660 fn test_multiple_closes_same_line() {
661 let mut tracker = MarkdownHtmlTracker::new();
662
663 assert!(tracker.process_line("<div markdown>"));
664 assert!(tracker.process_line("<div>inner</div></div>"));
665 assert!(!tracker.is_inside());
666 }
667
668 #[test]
669 fn test_count_opens_boundary_check() {
670 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<div>", "div"), 1);
672 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<div class='x'>", "div"), 1);
673 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<div>", "div"), 1);
674 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<div/><div>", "div"), 2);
675
676 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<divider>", "div"), 0);
678 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<dividend>", "div"), 0);
679
680 assert_eq!(
682 MarkdownHtmlTracker::count_opens_lowered(&"<DIV>".to_lowercase(), "div"),
683 1
684 );
685 }
686
687 #[test]
688 fn test_count_closes_variations() {
689 assert_eq!(MarkdownHtmlTracker::count_closes_lowered("</div>", "div"), 1);
691 assert_eq!(
692 MarkdownHtmlTracker::count_closes_lowered(&"</DIV>".to_lowercase(), "div"),
693 1
694 );
695 assert_eq!(MarkdownHtmlTracker::count_closes_lowered("</div >", "div"), 1);
696 assert_eq!(MarkdownHtmlTracker::count_closes_lowered("</div >", "div"), 1);
697 assert_eq!(MarkdownHtmlTracker::count_closes_lowered("</div></div>", "div"), 2);
698 assert_eq!(
699 MarkdownHtmlTracker::count_closes_lowered("text</div>more</div>end", "div"),
700 2
701 );
702 }
703
704 #[test]
705 fn test_reset() {
706 let mut tracker = MarkdownHtmlTracker::new();
707
708 tracker.process_line("<div markdown>");
709 assert!(tracker.is_inside());
710
711 tracker.reset();
712 assert!(!tracker.is_inside());
713
714 tracker.process_line("<section markdown>");
716 assert!(tracker.is_inside());
717 }
718}