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
78fn markdown_html_open_tag(line: &str) -> Option<String> {
86 let line = line.trim_start();
87 let bytes = line.as_bytes();
88 if bytes.first() != Some(&b'<') || !bytes.get(1).is_some_and(u8::is_ascii_alphabetic) {
89 return None;
90 }
91
92 let mut i = 1;
93 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-') {
94 i += 1;
95 }
96 let tag = line[1..i].to_ascii_lowercase();
97 if !MARKDOWN_ATTRIBUTE_ELEMENTS.contains(&tag.as_str()) {
98 return None;
99 }
100
101 let mut declared: Option<&str> = None;
104 while i < bytes.len() {
105 match bytes[i] {
106 b'>' => {
107 return declared.filter(|value| *value != "0").map(|_| tag);
108 }
109 b' ' | b'\t' | b'/' => i += 1,
110 _ => {
111 let name_start = i;
112 while i < bytes.len() && !matches!(bytes[i], b' ' | b'\t' | b'=' | b'>' | b'/') {
113 i += 1;
114 }
115 let name = &line[name_start..i];
116
117 let mut value = "";
118 let mut j = i;
119 while j < bytes.len() && matches!(bytes[j], b' ' | b'\t') {
120 j += 1;
121 }
122 if bytes.get(j) == Some(&b'=') {
123 j += 1;
124 while j < bytes.len() && matches!(bytes[j], b' ' | b'\t') {
125 j += 1;
126 }
127 match bytes.get(j) {
128 Some("e @ (b'"' | b'\'')) => {
129 let start = j + 1;
130 let end = start + line[start..].find(quote as char)?;
133 value = &line[start..end];
134 j = end + 1;
135 }
136 _ => {
137 let start = j;
138 while j < bytes.len() && !matches!(bytes[j], b' ' | b'\t' | b'>') {
139 j += 1;
140 }
141 value = &line[start..j];
142 }
143 }
144 i = j;
145 }
146
147 if name.eq_ignore_ascii_case("markdown") {
148 declared = Some(value);
149 }
150 }
151 }
152 }
153 None
154}
155
156#[derive(Debug, Default)]
158pub struct MarkdownHtmlTracker {
159 tag_stack: Vec<(String, usize)>,
161 depth: usize,
163}
164
165impl MarkdownHtmlTracker {
166 pub fn new() -> Self {
167 Self::default()
168 }
169
170 pub fn is_open(&self) -> bool {
172 !self.tag_stack.is_empty()
173 }
174
175 pub fn process_line(&mut self, line: &str) -> bool {
180 let trimmed = line.trim();
181
182 if let Some(tag) = markdown_html_open_tag(line) {
184 let closes_here = Self::count_closes_lowered(&line.to_lowercase(), &tag) > 0;
186
187 self.depth += 1;
188 self.tag_stack.push((tag, self.depth));
189 if closes_here {
190 self.depth -= 1;
191 self.tag_stack.pop();
192 }
193 return true;
194 }
195
196 if !self.tag_stack.is_empty() {
198 let line_lower = trimmed.to_lowercase();
200
201 let tags: Vec<String> = self.tag_stack.iter().map(|(tag, _)| tag.clone()).collect();
203 for tag in &tags {
204 let opens = Self::count_opens_lowered(&line_lower, tag);
205 let closes = Self::count_closes_lowered(&line_lower, tag);
206
207 self.depth += opens;
208
209 for _ in 0..closes {
210 if self.depth > 0 {
211 self.depth -= 1;
212 }
213 }
214 }
215
216 while let Some((_, start_depth)) = self.tag_stack.last() {
218 if self.depth < *start_depth {
219 self.tag_stack.pop();
220 } else {
221 break;
222 }
223 }
224
225 return true;
228 }
229
230 false
231 }
232
233 fn count_opens_lowered(line_lower: &str, tag: &str) -> usize {
236 let open_pattern = format!("<{tag}");
237 let mut count = 0;
238 let mut search_start = 0;
239
240 while let Some(pos) = line_lower[search_start..].find(&open_pattern) {
241 let abs_pos = search_start + pos;
242 let after_tag = abs_pos + open_pattern.len();
243
244 if after_tag >= line_lower.len()
246 || line_lower[after_tag..].starts_with(|c: char| c.is_whitespace() || c == '>' || c == '/')
247 {
248 count += 1;
249 }
250 search_start = after_tag;
251 }
252 count
253 }
254
255 fn count_closes_lowered(line_lower: &str, tag: &str) -> usize {
258 let close_pattern = format!("</{tag}");
259 let mut count = 0;
260 let mut search_start = 0;
261
262 while let Some(pos) = line_lower[search_start..].find(&close_pattern) {
263 let abs_pos = search_start + pos;
264 let after_tag = abs_pos + close_pattern.len();
265
266 if let Some(rest) = line_lower.get(after_tag..)
268 && rest.trim_start().starts_with('>')
269 {
270 count += 1;
271 }
272 search_start = after_tag;
273 }
274 count
275 }
276
277 pub fn is_inside(&self) -> bool {
279 !self.tag_stack.is_empty()
280 }
281
282 pub fn reset(&mut self) {
284 self.tag_stack.clear();
285 self.depth = 0;
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 fn opens(line: &str) -> bool {
294 markdown_html_open_tag(line).is_some()
295 }
296
297 #[test]
298 fn test_markdown_html_detection() {
299 assert!(opens("<div markdown>"));
301 assert!(opens("<div class=\"grid cards\" markdown>"));
302 assert!(opens("<div markdown=\"1\">"));
303 assert!(opens("<div markdown=\"block\">"));
304
305 assert!(opens("<div markdown class=\"test\">"));
307 assert!(opens("<div id=\"foo\" markdown>"));
308
309 assert!(opens("<DIV markdown>"));
311 assert!(opens("<Div Markdown>"));
312
313 assert!(opens(" <div markdown>"));
315 assert!(opens(" <div class=\"grid\" markdown>"));
316
317 assert!(opens("<section markdown>"));
319 assert!(opens("<article markdown>"));
320 assert!(opens("<details markdown>"));
321
322 assert!(!opens("<div class=\"test\">"));
324 assert!(!opens("<span markdown>")); assert!(!opens("text with markdown word"));
326 assert!(!opens("<div>markdown</div>"));
327 }
328
329 #[test]
330 fn test_attribute_value_quoting() {
331 assert_eq!(markdown_html_open_tag("<div markdown=1>").as_deref(), Some("div"));
332 assert_eq!(markdown_html_open_tag("<div markdown='block'>").as_deref(), Some("div"));
333 assert_eq!(
334 markdown_html_open_tag("<div markdown = \"1\" >").as_deref(),
335 Some("div")
336 );
337 assert_eq!(
338 markdown_html_open_tag("<div markdown=\"default\"/>").as_deref(),
339 Some("div")
340 );
341
342 assert!(!opens("<div class=\"unclosed markdown>"));
344 assert!(!opens("<div markdown"));
346 }
347
348 #[test]
349 fn test_span_level_block_elements_take_the_attribute() {
350 for line in [
354 "<p markdown=\"1\">",
355 "<h1 markdown=\"1\">",
356 "<h5 markdown=\"1\">Diving in</h5>",
357 "<h6 markdown>",
358 "<li markdown=\"1\">",
359 "<td markdown=\"1\">",
360 "<th markdown=\"1\">",
361 "<dd markdown=\"1\">",
362 "<summary markdown=\"span\">",
363 "<blockquote markdown=\"1\">",
364 "<table markdown=\"block\">",
365 ] {
366 assert!(opens(line), "{line} should open a markdown block");
367 }
368 }
369
370 #[test]
371 fn test_the_word_markdown_in_another_attribute_is_not_an_opt_in() {
372 for line in [
375 "<div class=\"markdown-body\">",
376 "<div id=\"hello-markdown\">",
377 "<div class=\"marketplace-extensions-markdown-preview-curated\"></div>",
378 "<video title=\"Rendering markdown in the editor\" autoplay controls></video>",
379 "<section data-note=\"see markdown docs\">",
380 ] {
381 assert!(!opens(line), "{line} must not open a markdown block");
382 }
383
384 assert!(opens("<div class=\"markdown-body\" markdown=\"1\">"));
386 }
387
388 #[test]
389 fn test_markdown_zero_declares_raw_html() {
390 assert!(!opens("<div markdown=\"0\">"));
393 assert!(!opens("<div markdown='0'>"));
394 assert!(!opens("<div markdown=0>"));
395 assert!(!opens(
396 "<div markdown=\"0\"><a href=\"#\" class=\"btn\">Button</a></div>"
397 ));
398
399 let mut tracker = MarkdownHtmlTracker::new();
400 assert!(!tracker.process_line("<div markdown=\"0\">"));
401 assert!(!tracker.is_inside());
402
403 assert!(opens("<div markdown=\"10\">"));
405 }
406
407 #[test]
408 fn test_raw_text_and_void_elements_never_open_a_block() {
409 for line in [
412 "<pre markdown=\"1\">",
413 "<script markdown=\"1\">",
414 "<style markdown>",
415 "<textarea markdown=\"1\">",
416 "<canvas markdown=\"1\">",
417 "<option markdown=\"1\">",
418 ] {
419 assert!(!opens(line), "{line} must not open a markdown block");
420 }
421
422 assert!(!opens("<hr markdown>"));
425 assert!(!opens("<img src=\"x.png\" markdown=\"1\">"));
426 }
427
428 #[test]
429 fn test_tracker_paragraph_block_ends_at_its_closing_tag() {
430 let mut tracker = MarkdownHtmlTracker::new();
433
434 assert!(tracker.process_line("<p markdown=\"1\">"));
435 assert!(tracker.is_inside());
436 assert!(tracker.process_line("- a list marker with custom spacing"));
437 assert!(tracker.is_inside());
438 assert!(tracker.process_line("</p>"));
439 assert!(!tracker.is_inside());
440
441 assert!(!tracker.process_line("- back outside the block"));
442 }
443
444 #[test]
445 fn test_tracker_basic() {
446 let mut tracker = MarkdownHtmlTracker::new();
447
448 assert!(!tracker.is_inside());
449
450 assert!(tracker.process_line("<div class=\"grid cards\" markdown>"));
451 assert!(tracker.is_inside());
452
453 assert!(tracker.process_line("- Content here"));
454 assert!(tracker.is_inside());
455
456 assert!(tracker.process_line(" ---"));
457 assert!(tracker.is_inside());
458
459 tracker.process_line("</div>");
461 assert!(!tracker.is_inside());
462 }
463
464 #[test]
465 fn test_tracker_nested() {
466 let mut tracker = MarkdownHtmlTracker::new();
467
468 tracker.process_line("<div markdown>");
469 assert!(tracker.is_inside());
470
471 tracker.process_line("<div>nested</div>");
472 assert!(tracker.is_inside());
473
474 tracker.process_line("</div>");
475 assert!(!tracker.is_inside());
476 }
477
478 #[test]
479 fn test_grid_cards_pattern() {
480 let content = r#"<div class="grid cards" markdown>
481
482- :zap:{ .lg .middle } **Built for speed**
483
484 ---
485
486 Written in Rust.
487
488</div>"#;
489
490 let mut tracker = MarkdownHtmlTracker::new();
491 let mut inside_lines = Vec::new();
492
493 for (i, line) in content.lines().enumerate() {
494 let inside = tracker.process_line(line);
495 if inside {
496 inside_lines.push(i);
497 }
498 }
499
500 assert!(inside_lines.contains(&0)); assert!(inside_lines.contains(&2)); assert!(inside_lines.contains(&4)); assert!(inside_lines.contains(&6)); assert!(!tracker.is_inside()); }
507
508 #[test]
509 fn test_same_line_open_close() {
510 let mut tracker = MarkdownHtmlTracker::new();
511
512 let result = tracker.process_line("<div markdown>content</div>");
514 assert!(result); assert!(!tracker.is_inside()); }
517
518 #[test]
519 fn test_multiple_sequential_blocks() {
520 let mut tracker = MarkdownHtmlTracker::new();
521
522 assert!(tracker.process_line("<div markdown>"));
524 assert!(tracker.is_inside());
525 assert!(tracker.process_line("Content 1"));
526 tracker.process_line("</div>");
527 assert!(!tracker.is_inside());
528
529 assert!(tracker.process_line("<section markdown>"));
531 assert!(tracker.is_inside());
532 assert!(tracker.process_line("Content 2"));
533 tracker.process_line("</section>");
534 assert!(!tracker.is_inside());
535 }
536
537 #[test]
538 fn test_deeply_nested_same_tag() {
539 let mut tracker = MarkdownHtmlTracker::new();
540
541 assert!(tracker.process_line("<div markdown>"));
542 assert!(tracker.is_inside());
543
544 assert!(tracker.process_line("<div class=\"inner\">"));
546 assert!(tracker.is_inside());
547
548 assert!(tracker.process_line("</div>"));
550 assert!(tracker.is_inside()); tracker.process_line("</div>");
554 assert!(!tracker.is_inside());
555 }
556
557 #[test]
558 fn test_deeply_nested_different_tags() {
559 let mut tracker = MarkdownHtmlTracker::new();
560
561 assert!(tracker.process_line("<article markdown>"));
562 assert!(tracker.is_inside());
563
564 assert!(tracker.process_line("<section>"));
566 assert!(tracker.is_inside());
567
568 assert!(tracker.process_line("</section>"));
570 assert!(tracker.is_inside());
571
572 tracker.process_line("</article>");
574 assert!(!tracker.is_inside());
575 }
576
577 #[test]
578 fn test_multiple_closes_same_line() {
579 let mut tracker = MarkdownHtmlTracker::new();
580
581 assert!(tracker.process_line("<div markdown>"));
582 assert!(tracker.process_line("<div>inner</div></div>"));
583 assert!(!tracker.is_inside());
584 }
585
586 #[test]
587 fn test_count_opens_boundary_check() {
588 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<div>", "div"), 1);
590 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<div class='x'>", "div"), 1);
591 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<div>", "div"), 1);
592 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<div/><div>", "div"), 2);
593
594 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<divider>", "div"), 0);
596 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<dividend>", "div"), 0);
597
598 assert_eq!(
600 MarkdownHtmlTracker::count_opens_lowered(&"<DIV>".to_lowercase(), "div"),
601 1
602 );
603 }
604
605 #[test]
606 fn test_count_closes_variations() {
607 assert_eq!(MarkdownHtmlTracker::count_closes_lowered("</div>", "div"), 1);
609 assert_eq!(
610 MarkdownHtmlTracker::count_closes_lowered(&"</DIV>".to_lowercase(), "div"),
611 1
612 );
613 assert_eq!(MarkdownHtmlTracker::count_closes_lowered("</div >", "div"), 1);
614 assert_eq!(MarkdownHtmlTracker::count_closes_lowered("</div >", "div"), 1);
615 assert_eq!(MarkdownHtmlTracker::count_closes_lowered("</div></div>", "div"), 2);
616 assert_eq!(
617 MarkdownHtmlTracker::count_closes_lowered("text</div>more</div>end", "div"),
618 2
619 );
620 }
621
622 #[test]
623 fn test_reset() {
624 let mut tracker = MarkdownHtmlTracker::new();
625
626 tracker.process_line("<div markdown>");
627 assert!(tracker.is_inside());
628
629 tracker.reset();
630 assert!(!tracker.is_inside());
631
632 tracker.process_line("<section markdown>");
634 assert!(tracker.is_inside());
635 }
636}