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    /// Process a line and return whether the line is inside a markdown HTML block.
171    /// Returns true if:
172    /// - This line opens a new markdown HTML block
173    /// - This line is part of an existing markdown HTML block (even if it closes it)
174    pub fn process_line(&mut self, line: &str) -> bool {
175        let trimmed = line.trim();
176
177        // Check for opening tag
178        if let Some(tag) = markdown_html_open_tag(line) {
179            // Check if this line also closes the tag (self-contained)
180            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 we're inside a markdown HTML block at the start of this line
192        if !self.tag_stack.is_empty() {
193            // Lowercase the line once for all tag comparisons
194            let line_lower = trimmed.to_lowercase();
195
196            // Collect tag names by reference before mutating depth
197            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            // Clean up stack when depth reaches initial level
212            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 because this line was inside the block at the start
221            // (even if it also closes the block)
222            return true;
223        }
224
225        false
226    }
227
228    /// Count opening tags of a specific type in a pre-lowercased line.
229    /// `tag` is already lowercase (stored that way in `tag_stack`).
230    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            // Verify it's a tag boundary (followed by whitespace, >, or /)
240            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    /// Count closing tags of a specific type in a pre-lowercased line.
251    /// `tag` is already lowercase (stored that way in `tag_stack`).
252    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            // Find the closing > (may have whitespace before it)
262            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    /// Check if currently inside a markdown HTML block
273    pub fn is_inside(&self) -> bool {
274        !self.tag_stack.is_empty()
275    }
276
277    /// Reset the tracker state
278    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        // Basic patterns
295        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        // Attribute order variations
301        assert!(opens("<div markdown class=\"test\">"));
302        assert!(opens("<div id=\"foo\" markdown>"));
303
304        // Case insensitivity
305        assert!(opens("<DIV markdown>"));
306        assert!(opens("<Div Markdown>"));
307
308        // With indentation
309        assert!(opens("  <div markdown>"));
310        assert!(opens("    <div class=\"grid\" markdown>"));
311
312        // Other valid HTML5 elements
313        assert!(opens("<section markdown>"));
314        assert!(opens("<article markdown>"));
315        assert!(opens("<details markdown>"));
316
317        // Should NOT match
318        assert!(!opens("<div class=\"test\">"));
319        assert!(!opens("<span markdown>")); // span is not a block-level element
320        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        // An unterminated quote runs off the end of the line, so the tag never closes.
338        assert!(!opens("<div class=\"unclosed markdown>"));
339        // Nor does a tag that simply has no `>` on this line.
340        assert!(!opens("<div markdown"));
341    }
342
343    #[test]
344    fn test_span_level_block_elements_take_the_attribute() {
345        // Python-Markdown parses these with span-level rules and kramdown accepts
346        // them too; either way their content is Markdown, not raw HTML. Jekyll's
347        // own docs open notices with `<h5 markdown="1">` / `<p markdown="1">`.
348        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        // Each of these matched the tag-name-plus-`\bmarkdown\b` test that this
368        // scanner replaces, and none of them declares anything.
369        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        // The attribute itself still counts when it sits beside such a value.
380        assert!(opens("<div class=\"markdown-body\" markdown=\"1\">"));
381    }
382
383    #[test]
384    fn test_markdown_zero_declares_raw_html() {
385        // kramdown's `markdown="0"` is the opposite of `markdown="1"`: the content
386        // stays raw HTML.
387        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        // `0` only speaks for itself; a `10` or a `0.5` is not it.
399        assert!(opens("<div markdown=\"10\">"));
400    }
401
402    #[test]
403    fn test_raw_text_and_void_elements_never_open_a_block() {
404        // Python-Markdown never parses the content of these, and CommonMark reads
405        // four of them as raw-text HTML blocks under every flavor.
406        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        // `hr` holds no content, so a block opened on it would never be closed and
418        // would swallow the rest of the document.
419        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        // The shape Jekyll's upgrade notices use, and the one that used to leave
426        // list-marker and indent rules policing content they do not own.
427        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        // Close the div
455        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        // All lines except the last </div> should be marked as inside
496        assert!(inside_lines.contains(&0)); // <div ...>
497        assert!(inside_lines.contains(&2)); // -   :zap:...
498        assert!(inside_lines.contains(&4)); // ---
499        assert!(inside_lines.contains(&6)); // Written in Rust.
500        assert!(!tracker.is_inside()); // After </div>
501    }
502
503    #[test]
504    fn test_same_line_open_close() {
505        let mut tracker = MarkdownHtmlTracker::new();
506
507        // Single line with both open and close
508        let result = tracker.process_line("<div markdown>content</div>");
509        assert!(result); // The line itself is part of the block
510        assert!(!tracker.is_inside()); // But after processing, we're outside
511    }
512
513    #[test]
514    fn test_multiple_sequential_blocks() {
515        let mut tracker = MarkdownHtmlTracker::new();
516
517        // First block
518        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        // Second block (should work independently)
525        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        // Nested div (without markdown attr)
540        assert!(tracker.process_line("<div class=\"inner\">"));
541        assert!(tracker.is_inside());
542
543        // Close inner div
544        assert!(tracker.process_line("</div>"));
545        assert!(tracker.is_inside()); // Still inside outer div
546
547        // Close outer div
548        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        // Inner section (without markdown)
560        assert!(tracker.process_line("<section>"));
561        assert!(tracker.is_inside());
562
563        // Close section - tracker only tracks article
564        assert!(tracker.process_line("</section>"));
565        assert!(tracker.is_inside());
566
567        // Close article
568        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        // Should match (input is pre-lowercased)
584        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        // Should NOT match (divider is not div)
590        assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<divider>", "div"), 0);
591        assert_eq!(MarkdownHtmlTracker::count_opens_lowered("<dividend>", "div"), 0);
592
593        // Case-insensitive via pre-lowercased input
594        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        // Input is pre-lowercased
603        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        // Should work fresh after reset
628        tracker.process_line("<section markdown>");
629        assert!(tracker.is_inside());
630    }
631}