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 process_line(&mut self, line: &str) -> bool {
175 let trimmed = line.trim();
176
177 if let Some(tag) = markdown_html_open_tag(line) {
179 let closes_here = Self::count_closes_lowered(&line.to_lowercase(), &tag) > 0;
181
182 self.depth += 1;
183 self.tag_stack.push((tag, self.depth));
184 if closes_here {
185 self.depth -= 1;
186 self.tag_stack.pop();
187 }
188 return true;
189 }
190
191 if !self.tag_stack.is_empty() {
193 let line_lower = trimmed.to_lowercase();
195
196 let tags: Vec<String> = self.tag_stack.iter().map(|(tag, _)| tag.clone()).collect();
198 for tag in &tags {
199 let opens = Self::count_opens_lowered(&line_lower, tag);
200 let closes = Self::count_closes_lowered(&line_lower, tag);
201
202 self.depth += opens;
203
204 for _ in 0..closes {
205 if self.depth > 0 {
206 self.depth -= 1;
207 }
208 }
209 }
210
211 while let Some((_, start_depth)) = self.tag_stack.last() {
213 if self.depth < *start_depth {
214 self.tag_stack.pop();
215 } else {
216 break;
217 }
218 }
219
220 return true;
223 }
224
225 false
226 }
227
228 fn count_opens_lowered(line_lower: &str, tag: &str) -> usize {
231 let open_pattern = format!("<{tag}");
232 let mut count = 0;
233 let mut search_start = 0;
234
235 while let Some(pos) = line_lower[search_start..].find(&open_pattern) {
236 let abs_pos = search_start + pos;
237 let after_tag = abs_pos + open_pattern.len();
238
239 if after_tag >= line_lower.len()
241 || line_lower[after_tag..].starts_with(|c: char| c.is_whitespace() || c == '>' || c == '/')
242 {
243 count += 1;
244 }
245 search_start = after_tag;
246 }
247 count
248 }
249
250 fn count_closes_lowered(line_lower: &str, tag: &str) -> usize {
253 let close_pattern = format!("</{tag}");
254 let mut count = 0;
255 let mut search_start = 0;
256
257 while let Some(pos) = line_lower[search_start..].find(&close_pattern) {
258 let abs_pos = search_start + pos;
259 let after_tag = abs_pos + close_pattern.len();
260
261 if let Some(rest) = line_lower.get(after_tag..)
263 && rest.trim_start().starts_with('>')
264 {
265 count += 1;
266 }
267 search_start = after_tag;
268 }
269 count
270 }
271
272 pub fn is_inside(&self) -> bool {
274 !self.tag_stack.is_empty()
275 }
276
277 pub fn reset(&mut self) {
279 self.tag_stack.clear();
280 self.depth = 0;
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 fn opens(line: &str) -> bool {
289 markdown_html_open_tag(line).is_some()
290 }
291
292 #[test]
293 fn test_markdown_html_detection() {
294 assert!(opens("<div markdown>"));
296 assert!(opens("<div class=\"grid cards\" markdown>"));
297 assert!(opens("<div markdown=\"1\">"));
298 assert!(opens("<div markdown=\"block\">"));
299
300 assert!(opens("<div markdown class=\"test\">"));
302 assert!(opens("<div id=\"foo\" markdown>"));
303
304 assert!(opens("<DIV markdown>"));
306 assert!(opens("<Div Markdown>"));
307
308 assert!(opens(" <div markdown>"));
310 assert!(opens(" <div class=\"grid\" markdown>"));
311
312 assert!(opens("<section markdown>"));
314 assert!(opens("<article markdown>"));
315 assert!(opens("<details markdown>"));
316
317 assert!(!opens("<div class=\"test\">"));
319 assert!(!opens("<span markdown>")); assert!(!opens("text with markdown word"));
321 assert!(!opens("<div>markdown</div>"));
322 }
323
324 #[test]
325 fn test_attribute_value_quoting() {
326 assert_eq!(markdown_html_open_tag("<div markdown=1>").as_deref(), Some("div"));
327 assert_eq!(markdown_html_open_tag("<div markdown='block'>").as_deref(), Some("div"));
328 assert_eq!(
329 markdown_html_open_tag("<div markdown = \"1\" >").as_deref(),
330 Some("div")
331 );
332 assert_eq!(
333 markdown_html_open_tag("<div markdown=\"default\"/>").as_deref(),
334 Some("div")
335 );
336
337 assert!(!opens("<div class=\"unclosed markdown>"));
339 assert!(!opens("<div markdown"));
341 }
342
343 #[test]
344 fn test_span_level_block_elements_take_the_attribute() {
345 for line in [
349 "<p markdown=\"1\">",
350 "<h1 markdown=\"1\">",
351 "<h5 markdown=\"1\">Diving in</h5>",
352 "<h6 markdown>",
353 "<li markdown=\"1\">",
354 "<td markdown=\"1\">",
355 "<th markdown=\"1\">",
356 "<dd markdown=\"1\">",
357 "<summary markdown=\"span\">",
358 "<blockquote markdown=\"1\">",
359 "<table markdown=\"block\">",
360 ] {
361 assert!(opens(line), "{line} should open a markdown block");
362 }
363 }
364
365 #[test]
366 fn test_the_word_markdown_in_another_attribute_is_not_an_opt_in() {
367 for line in [
370 "<div class=\"markdown-body\">",
371 "<div id=\"hello-markdown\">",
372 "<div class=\"marketplace-extensions-markdown-preview-curated\"></div>",
373 "<video title=\"Rendering markdown in the editor\" autoplay controls></video>",
374 "<section data-note=\"see markdown docs\">",
375 ] {
376 assert!(!opens(line), "{line} must not open a markdown block");
377 }
378
379 assert!(opens("<div class=\"markdown-body\" markdown=\"1\">"));
381 }
382
383 #[test]
384 fn test_markdown_zero_declares_raw_html() {
385 assert!(!opens("<div markdown=\"0\">"));
388 assert!(!opens("<div markdown='0'>"));
389 assert!(!opens("<div markdown=0>"));
390 assert!(!opens(
391 "<div markdown=\"0\"><a href=\"#\" class=\"btn\">Button</a></div>"
392 ));
393
394 let mut tracker = MarkdownHtmlTracker::new();
395 assert!(!tracker.process_line("<div markdown=\"0\">"));
396 assert!(!tracker.is_inside());
397
398 assert!(opens("<div markdown=\"10\">"));
400 }
401
402 #[test]
403 fn test_raw_text_and_void_elements_never_open_a_block() {
404 for line in [
407 "<pre markdown=\"1\">",
408 "<script markdown=\"1\">",
409 "<style markdown>",
410 "<textarea markdown=\"1\">",
411 "<canvas markdown=\"1\">",
412 "<option markdown=\"1\">",
413 ] {
414 assert!(!opens(line), "{line} must not open a markdown block");
415 }
416
417 assert!(!opens("<hr markdown>"));
420 assert!(!opens("<img src=\"x.png\" markdown=\"1\">"));
421 }
422
423 #[test]
424 fn test_tracker_paragraph_block_ends_at_its_closing_tag() {
425 let mut tracker = MarkdownHtmlTracker::new();
428
429 assert!(tracker.process_line("<p markdown=\"1\">"));
430 assert!(tracker.is_inside());
431 assert!(tracker.process_line("- a list marker with custom spacing"));
432 assert!(tracker.is_inside());
433 assert!(tracker.process_line("</p>"));
434 assert!(!tracker.is_inside());
435
436 assert!(!tracker.process_line("- back outside the block"));
437 }
438
439 #[test]
440 fn test_tracker_basic() {
441 let mut tracker = MarkdownHtmlTracker::new();
442
443 assert!(!tracker.is_inside());
444
445 assert!(tracker.process_line("<div class=\"grid cards\" markdown>"));
446 assert!(tracker.is_inside());
447
448 assert!(tracker.process_line("- Content here"));
449 assert!(tracker.is_inside());
450
451 assert!(tracker.process_line(" ---"));
452 assert!(tracker.is_inside());
453
454 tracker.process_line("</div>");
456 assert!(!tracker.is_inside());
457 }
458
459 #[test]
460 fn test_tracker_nested() {
461 let mut tracker = MarkdownHtmlTracker::new();
462
463 tracker.process_line("<div markdown>");
464 assert!(tracker.is_inside());
465
466 tracker.process_line("<div>nested</div>");
467 assert!(tracker.is_inside());
468
469 tracker.process_line("</div>");
470 assert!(!tracker.is_inside());
471 }
472
473 #[test]
474 fn test_grid_cards_pattern() {
475 let content = r#"<div class="grid cards" markdown>
476
477- :zap:{ .lg .middle } **Built for speed**
478
479 ---
480
481 Written in Rust.
482
483</div>"#;
484
485 let mut tracker = MarkdownHtmlTracker::new();
486 let mut inside_lines = Vec::new();
487
488 for (i, line) in content.lines().enumerate() {
489 let inside = tracker.process_line(line);
490 if inside {
491 inside_lines.push(i);
492 }
493 }
494
495 assert!(inside_lines.contains(&0)); assert!(inside_lines.contains(&2)); assert!(inside_lines.contains(&4)); assert!(inside_lines.contains(&6)); assert!(!tracker.is_inside()); }
502
503 #[test]
504 fn test_same_line_open_close() {
505 let mut tracker = MarkdownHtmlTracker::new();
506
507 let result = tracker.process_line("<div markdown>content</div>");
509 assert!(result); assert!(!tracker.is_inside()); }
512
513 #[test]
514 fn test_multiple_sequential_blocks() {
515 let mut tracker = MarkdownHtmlTracker::new();
516
517 assert!(tracker.process_line("<div markdown>"));
519 assert!(tracker.is_inside());
520 assert!(tracker.process_line("Content 1"));
521 tracker.process_line("</div>");
522 assert!(!tracker.is_inside());
523
524 assert!(tracker.process_line("<section markdown>"));
526 assert!(tracker.is_inside());
527 assert!(tracker.process_line("Content 2"));
528 tracker.process_line("</section>");
529 assert!(!tracker.is_inside());
530 }
531
532 #[test]
533 fn test_deeply_nested_same_tag() {
534 let mut tracker = MarkdownHtmlTracker::new();
535
536 assert!(tracker.process_line("<div markdown>"));
537 assert!(tracker.is_inside());
538
539 assert!(tracker.process_line("<div class=\"inner\">"));
541 assert!(tracker.is_inside());
542
543 assert!(tracker.process_line("</div>"));
545 assert!(tracker.is_inside()); tracker.process_line("</div>");
549 assert!(!tracker.is_inside());
550 }
551
552 #[test]
553 fn test_deeply_nested_different_tags() {
554 let mut tracker = MarkdownHtmlTracker::new();
555
556 assert!(tracker.process_line("<article markdown>"));
557 assert!(tracker.is_inside());
558
559 assert!(tracker.process_line("<section>"));
561 assert!(tracker.is_inside());
562
563 assert!(tracker.process_line("</section>"));
565 assert!(tracker.is_inside());
566
567 tracker.process_line("</article>");
569 assert!(!tracker.is_inside());
570 }
571
572 #[test]
573 fn test_multiple_closes_same_line() {
574 let mut tracker = MarkdownHtmlTracker::new();
575
576 assert!(tracker.process_line("<div markdown>"));
577 assert!(tracker.process_line("<div>inner</div></div>"));
578 assert!(!tracker.is_inside());
579 }
580
581 #[test]
582 fn test_count_opens_boundary_check() {
583 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<div>", "div"), 1);
585 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<div class='x'>", "div"), 1);
586 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<div>", "div"), 1);
587 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<div/><div>", "div"), 2);
588
589 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<divider>", "div"), 0);
591 assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<dividend>", "div"), 0);
592
593 assert_eq!(
595 MarkdownHtmlTracker::count_opens_lowered(&"<DIV>".to_lowercase(), "div"),
596 1
597 );
598 }
599
600 #[test]
601 fn test_count_closes_variations() {
602 assert_eq!(MarkdownHtmlTracker::count_closes_lowered("</div>", "div"), 1);
604 assert_eq!(
605 MarkdownHtmlTracker::count_closes_lowered(&"</DIV>".to_lowercase(), "div"),
606 1
607 );
608 assert_eq!(MarkdownHtmlTracker::count_closes_lowered("</div >", "div"), 1);
609 assert_eq!(MarkdownHtmlTracker::count_closes_lowered("</div >", "div"), 1);
610 assert_eq!(MarkdownHtmlTracker::count_closes_lowered("</div></div>", "div"), 2);
611 assert_eq!(
612 MarkdownHtmlTracker::count_closes_lowered("text</div>more</div>end", "div"),
613 2
614 );
615 }
616
617 #[test]
618 fn test_reset() {
619 let mut tracker = MarkdownHtmlTracker::new();
620
621 tracker.process_line("<div markdown>");
622 assert!(tracker.is_inside());
623
624 tracker.reset();
625 assert!(!tracker.is_inside());
626
627 tracker.process_line("<section markdown>");
629 assert!(tracker.is_inside());
630 }
631}