Skip to main content

rumdl_lib/utils/
mkdocs_html_markdown.rs

1//! HTML with a `markdown` attribute detection
2//!
3//! Both Python-Markdown's `md_in_html` extension (MkDocs) and kramdown (Jekyll)
4//! let an element opt its content into Markdown parsing with a `markdown`
5//! attribute:
6//! - `<div class="grid cards" markdown>` - Material grid cards
7//! - `<div markdown="1">`, `<details markdown="block">`, `<div markdown="span">`
8//! - `<p markdown="1">` and `<h5 markdown="1">` - common in Jekyll docs
9//!
10//! `<div markdown="0">` is kramdown's opposite and declares the content to be
11//! raw HTML, so it does not open a block here.
12
13/// Elements a `markdown` attribute can open a Markdown block on.
14///
15/// Derived from Python-Markdown's `markdown.util.BLOCK_LEVEL_ELEMENTS`, minus
16/// the two groups `md_in_html` excludes from `span_and_blocks_tags`:
17///
18/// - its raw tags (`canvas`, `math`, `option`, `pre`, `script`, `style`,
19///   `textarea`), whose content is never parsed - and four of which CommonMark
20///   itself treats as raw-text HTML blocks under every flavor;
21/// - its empty tag `hr`, which holds no content, so a tracked block opened on it
22///   would never find a closing tag and would swallow the rest of the document.
23///
24/// kramdown accepts the attribute on any element at all, so this is the narrower
25/// of the two rules.
26const 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/// How an element with a `markdown` attribute reads its content.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum MarkdownContent {
81    /// `markdown="1"`, `"block"` or a bare attribute: the content is Markdown blocks.
82    Block,
83    /// `markdown="span"`: the content is inline Markdown only, so it holds no
84    /// table, list or heading.
85    Span,
86}
87
88/// Whether the line holds nothing but the opening tag of an element that declares
89/// block Markdown content, so the content starts on the next line.
90pub 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
96/// Whether the line is the closing tag of an element that can declare its content
97/// to be Markdown.
98pub 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
110/// Name of the element a line opens, when that element declares its content to
111/// be Markdown.
112///
113/// Attributes are read with their quoting honoured, so the word `markdown`
114/// sitting inside another attribute's value is not an opt-in: neither
115/// `<div class="markdown-body">` nor `<video title="editing markdown files">`
116/// opens a block. The tag has to close on this line to open anything.
117fn markdown_html_open_tag(line: &str) -> Option<(String, MarkdownContent)> {
118    scan_markdown_open_tag(line).map(|(tag, content, _)| (tag, content))
119}
120
121/// Like [`markdown_html_open_tag`], also returning the byte offset just past the
122/// tag's closing `>` within the line with its leading whitespace removed.
123fn 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    // The last `markdown` attribute wins, matching how a browser resolves a
140    // repeated attribute.
141    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(&quote @ (b'"' | b'\'')) => {
174                            let start = j + 1;
175                            // An unterminated quote runs off the end of the line, so
176                            // there is no complete opening tag here.
177                            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/// Track state for markdown HTML block parsing
202#[derive(Debug, Default)]
203pub struct MarkdownHtmlTracker {
204    /// Stack of open tags (tag name, depth at that level)
205    tag_stack: Vec<(String, usize)>,
206    /// Content mode of each open element, parallel to `tag_stack`
207    content_stack: Vec<MarkdownContent>,
208    /// Current nesting depth
209    depth: usize,
210}
211
212impl MarkdownHtmlTracker {
213    pub fn new() -> Self {
214        Self::default()
215    }
216
217    /// Whether a markdown HTML block is open, i.e. the next line is inside one.
218    pub fn is_open(&self) -> bool {
219        !self.tag_stack.is_empty()
220    }
221
222    /// Process a line and return whether the line is inside a markdown HTML block.
223    /// Returns true if:
224    /// - This line opens a new markdown HTML block
225    /// - This line is part of an existing markdown HTML block (even if it closes it)
226    pub fn process_line(&mut self, line: &str) -> bool {
227        let trimmed = line.trim();
228
229        // Check for opening tag
230        if let Some((tag, content)) = markdown_html_open_tag(line) {
231            // Check if this line also closes the tag (self-contained)
232            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 we're inside a markdown HTML block at the start of this line
246        if !self.tag_stack.is_empty() {
247            // Lowercase the line once for all tag comparisons
248            let line_lower = trimmed.to_lowercase();
249
250            // Collect tag names by reference before mutating depth
251            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            // Clean up stack when depth reaches initial level
266            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 because this line was inside the block at the start
276            // (even if it also closes the block)
277            return true;
278        }
279
280        false
281    }
282
283    /// Count opening tags of a specific type in a pre-lowercased line.
284    /// `tag` is already lowercase (stored that way in `tag_stack`).
285    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            // Verify it's a tag boundary (followed by whitespace, >, or /)
295            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    /// Count closing tags of a specific type in a pre-lowercased line.
306    /// `tag` is already lowercase (stored that way in `tag_stack`).
307    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            // Find the closing > (may have whitespace before it)
317            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    /// Whether the innermost open element reads its content as inline Markdown
328    /// only. Asked right after [`Self::process_line`], it describes that line.
329    pub fn in_span(&self) -> bool {
330        self.content_stack.last() == Some(&MarkdownContent::Span)
331    }
332
333    /// How many `markdown` elements are open.
334    pub fn open_elements(&self) -> usize {
335        self.tag_stack.len()
336    }
337
338    /// Check if currently inside a markdown HTML block
339    pub fn is_inside(&self) -> bool {
340        !self.tag_stack.is_empty()
341    }
342
343    /// Reset the tracker state
344    pub fn reset(&mut self) {
345        self.tag_stack.clear();
346        self.content_stack.clear();
347        self.depth = 0;
348    }
349}
350
351/// Whether `text` contains the word "markdown" in any letter case, the spelling
352/// range `scan_markdown_open_tag` accepts for the attribute name.
353pub 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        // Basic patterns
368        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        // Attribute order variations
374        assert!(opens("<div markdown class=\"test\">"));
375        assert!(opens("<div id=\"foo\" markdown>"));
376
377        // Case insensitivity
378        assert!(opens("<DIV markdown>"));
379        assert!(opens("<Div Markdown>"));
380
381        // With indentation
382        assert!(opens("  <div markdown>"));
383        assert!(opens("    <div class=\"grid\" markdown>"));
384
385        // Other valid HTML5 elements
386        assert!(opens("<section markdown>"));
387        assert!(opens("<article markdown>"));
388        assert!(opens("<details markdown>"));
389
390        // Should NOT match
391        assert!(!opens("<div class=\"test\">"));
392        assert!(!opens("<span markdown>")); // span is not a block-level element
393        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        // An unterminated quote runs off the end of the line, so the tag never closes.
425        assert!(!opens("<div class=\"unclosed markdown>"));
426        // Nor does a tag that simply has no `>` on this line.
427        assert!(!opens("<div markdown"));
428    }
429
430    #[test]
431    fn test_span_level_block_elements_take_the_attribute() {
432        // Python-Markdown parses these with span-level rules and kramdown accepts
433        // them too; either way their content is Markdown, not raw HTML. Jekyll's
434        // own docs open notices with `<h5 markdown="1">` / `<p markdown="1">`.
435        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        // Each of these matched the tag-name-plus-`\bmarkdown\b` test that this
455        // scanner replaces, and none of them declares anything.
456        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        // The attribute itself still counts when it sits beside such a value.
467        assert!(opens("<div class=\"markdown-body\" markdown=\"1\">"));
468    }
469
470    #[test]
471    fn test_markdown_zero_declares_raw_html() {
472        // kramdown's `markdown="0"` is the opposite of `markdown="1"`: the content
473        // stays raw HTML.
474        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        // `0` only speaks for itself; a `10` or a `0.5` is not it.
486        assert!(opens("<div markdown=\"10\">"));
487    }
488
489    #[test]
490    fn test_raw_text_and_void_elements_never_open_a_block() {
491        // Python-Markdown never parses the content of these, and CommonMark reads
492        // four of them as raw-text HTML blocks under every flavor.
493        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        // `hr` holds no content, so a block opened on it would never be closed and
505        // would swallow the rest of the document.
506        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        // The shape Jekyll's upgrade notices use, and the one that used to leave
513        // list-marker and indent rules policing content they do not own.
514        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        // Close the div
542        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        // All lines except the last </div> should be marked as inside
583        assert!(inside_lines.contains(&0)); // <div ...>
584        assert!(inside_lines.contains(&2)); // -   :zap:...
585        assert!(inside_lines.contains(&4)); // ---
586        assert!(inside_lines.contains(&6)); // Written in Rust.
587        assert!(!tracker.is_inside()); // After </div>
588    }
589
590    #[test]
591    fn test_same_line_open_close() {
592        let mut tracker = MarkdownHtmlTracker::new();
593
594        // Single line with both open and close
595        let result = tracker.process_line("<div markdown>content</div>");
596        assert!(result); // The line itself is part of the block
597        assert!(!tracker.is_inside()); // But after processing, we're outside
598    }
599
600    #[test]
601    fn test_multiple_sequential_blocks() {
602        let mut tracker = MarkdownHtmlTracker::new();
603
604        // First block
605        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        // Second block (should work independently)
612        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        // Nested div (without markdown attr)
627        assert!(tracker.process_line("<div class=\"inner\">"));
628        assert!(tracker.is_inside());
629
630        // Close inner div
631        assert!(tracker.process_line("</div>"));
632        assert!(tracker.is_inside()); // Still inside outer div
633
634        // Close outer div
635        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        // Inner section (without markdown)
647        assert!(tracker.process_line("<section>"));
648        assert!(tracker.is_inside());
649
650        // Close section - tracker only tracks article
651        assert!(tracker.process_line("</section>"));
652        assert!(tracker.is_inside());
653
654        // Close article
655        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        // Should match (input is pre-lowercased)
671        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        // Should NOT match (divider is not div)
677        assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<divider>", "div"), 0);
678        assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<dividend>", "div"), 0);
679
680        // Case-insensitive via pre-lowercased input
681        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        // Input is pre-lowercased
690        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        // Should work fresh after reset
715        tracker.process_line("<section markdown>");
716        assert!(tracker.is_inside());
717    }
718}