markdown_it_tasklist/
lib.rs

1//! A [markdown_it] plugin for parsing tasklists
2//!
3//! ```rust
4//! let parser = &mut markdown_it::MarkdownIt::new();
5//! markdown_it::plugins::cmark::add(parser);
6//! markdown_it_tasklist::add(parser);
7//! let root = parser.parse("- [x] task");
8//! let mut names = vec![];
9//! root.walk(|node,_| { names.push(node.name()); });
10//! assert_eq!(names, vec![
11//! "markdown_it::parser::core::root::Root",
12//! "markdown_it::plugins::cmark::block::list::BulletList",
13//! "markdown_it::plugins::cmark::block::list::ListItem",
14//! "markdown_it_tasklist::TodoCheckbox",
15//! "markdown_it::parser::inline::builtin::skip_text::Text",
16//! ]);
17//! ```
18
19use markdown_it::{
20    parser::{
21        core::CoreRule,
22        inline::{builtin::InlineParserRule, Text},
23    },
24    plugins::cmark::block::{
25        list::{BulletList, ListItem, OrderedList},
26        paragraph::Paragraph,
27    },
28    MarkdownIt, Node, NodeValue, Renderer,
29};
30use once_cell::sync::Lazy;
31use regex::Regex;
32
33/// Add the tasklist plugin to the parser
34pub fn add(md: &mut MarkdownIt) {
35    md.add_rule::<TasklistRule<false>>()
36        .after::<InlineParserRule>();
37}
38
39/// Add the tasklist plugin to the parser
40pub fn add_disabled(md: &mut MarkdownIt) {
41    md.add_rule::<TasklistRule<true>>()
42        .after::<InlineParserRule>();
43}
44
45#[derive(Debug)]
46pub struct TodoCheckbox {
47    pub checked: bool,
48    pub disabled: bool,
49}
50
51impl NodeValue for TodoCheckbox {
52    fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
53        let mut attrs = node.attrs.clone();
54        attrs.push(("class", "task-list-item-checkbox".into()));
55        attrs.push(("type", "checkbox".into()));
56        if self.disabled {
57            attrs.push(("disabled", "".into()));
58        }
59        if self.checked {
60            attrs.push(("checked", "".into()));
61        }
62        fmt.self_close("input", &attrs);
63    }
64}
65
66struct TasklistRule<const DISABLED: bool>;
67
68static CHECKBOX_CHECKED_RE: Lazy<Regex> =
69    Lazy::new(|| Regex::new(r"^\[[xX]\][\s\t\n\v\f\r]").unwrap());
70static CHECKBOX_UNCHECKED_RE: Lazy<Regex> =
71    Lazy::new(|| Regex::new(r"^\[\s\][\s\t\n\v\f\r]").unwrap());
72
73impl<const DISABLED: bool> CoreRule for TasklistRule<DISABLED> {
74    fn run(root: &mut Node, _: &MarkdownIt) {
75        fn walk_recursive(node: &mut Node, disabled: bool) {
76            if node.is::<Paragraph>() {
77                // Paragraphs cannot contain lists, so we can stop here,
78                // without walking children
79                return;
80            }
81            if node.is::<BulletList>() || node.is::<OrderedList>() {
82                let mut contains_task = false;
83                for item in node.children.iter_mut() {
84                    if !item.is::<ListItem>() {
85                        continue;
86                    }
87                    if let Some(child) = item.children.first_mut() {
88                        // can be a paragraph->text or text, depending on if the list is tight
89                        let mut text_value = None;
90                        if child.cast_mut::<Paragraph>().is_some() {
91                            if let Some(child) = child.children.first_mut() {
92                                if let Some(value) = child.cast_mut::<Text>() {
93                                    text_value = Some(value);
94                                }
95                            }
96                        } else if let Some(value) = child.cast_mut::<Text>() {
97                            text_value = Some(value);
98                        }
99                        if let Some(text) = text_value {
100                            // TODO fix source mappings
101                            if CHECKBOX_UNCHECKED_RE.is_match(&text.content) {
102                                contains_task = true;
103                                text.content.replace_range(0..3, "");
104                                item.attrs.push(("class", "task-list-item".into()));
105                                item.children.insert(
106                                    0,
107                                    Node::new(TodoCheckbox {
108                                        checked: false,
109                                        disabled,
110                                    }),
111                                );
112                            } else if CHECKBOX_CHECKED_RE.is_match(&text.content) {
113                                contains_task = true;
114                                text.content.replace_range(0..3, "");
115                                item.attrs.push(("class", "task-list-item".into()));
116                                item.children.insert(
117                                    0,
118                                    Node::new(TodoCheckbox {
119                                        checked: true,
120                                        disabled,
121                                    }),
122                                );
123                            }
124                        }
125                    }
126                }
127                if contains_task {
128                    node.attrs.push(("class", "contains-task-list".into()));
129                }
130            }
131            for n in node.children.iter_mut() {
132                stacker::maybe_grow(64 * 1024, 1024 * 1024, || {
133                    walk_recursive(n, disabled);
134                });
135            }
136        }
137
138        walk_recursive(root, DISABLED);
139    }
140}