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/// Name of the element a line opens, when that element declares its content to
79/// be Markdown.
80///
81/// Attributes are read with their quoting honoured, so the word `markdown`
82/// sitting inside another attribute's value is not an opt-in: neither
83/// `<div class="markdown-body">` nor `<video title="editing markdown files">`
84/// opens a block. The tag has to close on this line to open anything.
85fn 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    // The last `markdown` attribute wins, matching how a browser resolves a
102    // repeated attribute.
103    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(&quote @ (b'"' | b'\'')) => {
129                            let start = j + 1;
130                            // An unterminated quote runs off the end of the line, so
131                            // there is no complete opening tag here.
132                            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/// Track state for markdown HTML block parsing
157#[derive(Debug, Default)]
158pub struct MarkdownHtmlTracker {
159    /// Stack of open tags (tag name, depth at that level)
160    tag_stack: Vec<(String, usize)>,
161    /// Current nesting depth
162    depth: usize,
163}
164
165impl MarkdownHtmlTracker {
166    pub fn new() -> Self {
167        Self::default()
168    }
169
170    /// Whether a markdown HTML block is open, i.e. the next line is inside one.
171    pub fn is_open(&self) -> bool {
172        !self.tag_stack.is_empty()
173    }
174
175    /// Process a line and return whether the line is inside a markdown HTML block.
176    /// Returns true if:
177    /// - This line opens a new markdown HTML block
178    /// - This line is part of an existing markdown HTML block (even if it closes it)
179    pub fn process_line(&mut self, line: &str) -> bool {
180        let trimmed = line.trim();
181
182        // Check for opening tag
183        if let Some(tag) = markdown_html_open_tag(line) {
184            // Check if this line also closes the tag (self-contained)
185            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 we're inside a markdown HTML block at the start of this line
197        if !self.tag_stack.is_empty() {
198            // Lowercase the line once for all tag comparisons
199            let line_lower = trimmed.to_lowercase();
200
201            // Collect tag names by reference before mutating depth
202            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            // Clean up stack when depth reaches initial level
217            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 because this line was inside the block at the start
226            // (even if it also closes the block)
227            return true;
228        }
229
230        false
231    }
232
233    /// Count opening tags of a specific type in a pre-lowercased line.
234    /// `tag` is already lowercase (stored that way in `tag_stack`).
235    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            // Verify it's a tag boundary (followed by whitespace, >, or /)
245            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    /// Count closing tags of a specific type in a pre-lowercased line.
256    /// `tag` is already lowercase (stored that way in `tag_stack`).
257    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            // Find the closing > (may have whitespace before it)
267            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    /// Check if currently inside a markdown HTML block
278    pub fn is_inside(&self) -> bool {
279        !self.tag_stack.is_empty()
280    }
281
282    /// Reset the tracker state
283    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        // Basic patterns
300        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        // Attribute order variations
306        assert!(opens("<div markdown class=\"test\">"));
307        assert!(opens("<div id=\"foo\" markdown>"));
308
309        // Case insensitivity
310        assert!(opens("<DIV markdown>"));
311        assert!(opens("<Div Markdown>"));
312
313        // With indentation
314        assert!(opens("  <div markdown>"));
315        assert!(opens("    <div class=\"grid\" markdown>"));
316
317        // Other valid HTML5 elements
318        assert!(opens("<section markdown>"));
319        assert!(opens("<article markdown>"));
320        assert!(opens("<details markdown>"));
321
322        // Should NOT match
323        assert!(!opens("<div class=\"test\">"));
324        assert!(!opens("<span markdown>")); // span is not a block-level element
325        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        // An unterminated quote runs off the end of the line, so the tag never closes.
343        assert!(!opens("<div class=\"unclosed markdown>"));
344        // Nor does a tag that simply has no `>` on this line.
345        assert!(!opens("<div markdown"));
346    }
347
348    #[test]
349    fn test_span_level_block_elements_take_the_attribute() {
350        // Python-Markdown parses these with span-level rules and kramdown accepts
351        // them too; either way their content is Markdown, not raw HTML. Jekyll's
352        // own docs open notices with `<h5 markdown="1">` / `<p markdown="1">`.
353        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        // Each of these matched the tag-name-plus-`\bmarkdown\b` test that this
373        // scanner replaces, and none of them declares anything.
374        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        // The attribute itself still counts when it sits beside such a value.
385        assert!(opens("<div class=\"markdown-body\" markdown=\"1\">"));
386    }
387
388    #[test]
389    fn test_markdown_zero_declares_raw_html() {
390        // kramdown's `markdown="0"` is the opposite of `markdown="1"`: the content
391        // stays raw HTML.
392        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        // `0` only speaks for itself; a `10` or a `0.5` is not it.
404        assert!(opens("<div markdown=\"10\">"));
405    }
406
407    #[test]
408    fn test_raw_text_and_void_elements_never_open_a_block() {
409        // Python-Markdown never parses the content of these, and CommonMark reads
410        // four of them as raw-text HTML blocks under every flavor.
411        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        // `hr` holds no content, so a block opened on it would never be closed and
423        // would swallow the rest of the document.
424        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        // The shape Jekyll's upgrade notices use, and the one that used to leave
431        // list-marker and indent rules policing content they do not own.
432        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        // Close the div
460        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        // All lines except the last </div> should be marked as inside
501        assert!(inside_lines.contains(&0)); // <div ...>
502        assert!(inside_lines.contains(&2)); // -   :zap:...
503        assert!(inside_lines.contains(&4)); // ---
504        assert!(inside_lines.contains(&6)); // Written in Rust.
505        assert!(!tracker.is_inside()); // After </div>
506    }
507
508    #[test]
509    fn test_same_line_open_close() {
510        let mut tracker = MarkdownHtmlTracker::new();
511
512        // Single line with both open and close
513        let result = tracker.process_line("<div markdown>content</div>");
514        assert!(result); // The line itself is part of the block
515        assert!(!tracker.is_inside()); // But after processing, we're outside
516    }
517
518    #[test]
519    fn test_multiple_sequential_blocks() {
520        let mut tracker = MarkdownHtmlTracker::new();
521
522        // First block
523        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        // Second block (should work independently)
530        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        // Nested div (without markdown attr)
545        assert!(tracker.process_line("<div class=\"inner\">"));
546        assert!(tracker.is_inside());
547
548        // Close inner div
549        assert!(tracker.process_line("</div>"));
550        assert!(tracker.is_inside()); // Still inside outer div
551
552        // Close outer div
553        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        // Inner section (without markdown)
565        assert!(tracker.process_line("<section>"));
566        assert!(tracker.is_inside());
567
568        // Close section - tracker only tracks article
569        assert!(tracker.process_line("</section>"));
570        assert!(tracker.is_inside());
571
572        // Close article
573        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        // Should match (input is pre-lowercased)
589        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        // Should NOT match (divider is not div)
595        assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<divider>", "div"), 0);
596        assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<dividend>", "div"), 0);
597
598        // Case-insensitive via pre-lowercased input
599        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        // Input is pre-lowercased
608        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        // Should work fresh after reset
633        tracker.process_line("<section markdown>");
634        assert!(tracker.is_inside());
635    }
636}