markdown_it_table_of_contents/
lib.rs

1use markdown_it::{
2    MarkdownIt, Node, NodeValue, Renderer,
3    plugins::cmark::block::{
4        heading::ATXHeading,
5        lheading::SetextHeader,
6    },
7    parser::{
8        core::CoreRule,
9        extset::MarkdownItExt
10    }
11};
12use std::vec::Vec;
13use unbox_box::BoxExt;
14
15#[derive(Debug)]
16pub struct TableOfContentsItem {
17    pub slug: String,
18    pub title: String,
19    pub level: u8,
20    pub children: Box<Vec<TableOfContentsItem>>
21}
22
23#[derive(Debug)]
24pub struct TOCOptions {
25    pub allow_titles_in_toc: bool, // parse in title (h1)
26    pub treat_title_as_h2: bool, // only matters if allow_titles_in_toc is true
27    pub toc_class: String,
28    pub wrap_in_nav: bool, // whether to wrap the table of contents in a <nav> element.
29                           // If true, toc_class is applied to <nav> instead of <ol>.
30    pub toc_heading: Option<( u8, String )> // level of heading to use, recommended is 2, followed
31                                            // by the text in the title.
32}
33
34impl MarkdownItExt for TOCOptions {}
35impl Default for TOCOptions {
36    fn default() -> Self {
37        TOCOptions {
38            allow_titles_in_toc: false,
39            treat_title_as_h2: false,
40            toc_class: "table_of_contents".to_string(),
41            wrap_in_nav: true,
42            toc_heading: Some((2, "Contents".to_string()))
43        }
44    }
45}
46
47impl TableOfContentsItem {
48    fn push(&mut self, item: TableOfContentsItem, depth_limit: u8) {
49        let child_count = self.children.unbox_ref().len();
50        if depth_limit > self.level+1 && child_count != 0 {
51            self.children.unbox_mut()[child_count - 1].push(item, depth_limit-1);
52            return;
53        }
54        self.children.unbox_mut().push(item);
55    }
56}
57
58#[derive(Debug)]
59pub struct TOC {
60    contents: Vec<TableOfContentsItem>,
61    min_level: u8,
62    wrap_in_nav: bool,
63    toc_heading: Option<( u8, String )>
64}
65
66impl NodeValue for TOC {
67    fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
68
69        fn render_item(item: &TableOfContentsItem, fmt: &mut dyn Renderer) {
70            fmt.cr();
71            fmt.open("li", &[]);
72
73            let mut link_href = String::from("#");
74            link_href.push_str(&item.slug);
75            fmt.cr();
76            fmt.open("a", &[("href", link_href)]);
77            fmt.text(&item.title);
78            fmt.close("a");
79            if item.children.len() != 0 {
80                fmt.cr();
81                fmt.open("ol", &[]);
82                item.children.unbox_ref().iter().for_each(|child| {
83                    render_item(&(child), fmt);
84                });
85                fmt.cr();
86                fmt.close("ol");
87            }
88            fmt.cr();
89            fmt.close("li");
90        }
91        let attrs = node.attrs.clone();
92
93        fmt.cr();
94        if self.wrap_in_nav {
95            fmt.open("nav", &attrs);
96            fmt.cr();
97        }
98        if let Some(heading) = &self.toc_heading {
99            let heading_tag = String::from("h") + &heading.0.to_string();
100            fmt.open(&heading_tag, &[]);
101            fmt.text(&heading.1);
102            fmt.close(&heading_tag);
103            fmt.cr();
104        }
105        fmt.open(
106            "ol",
107            if !self.wrap_in_nav { &attrs } else { &[] }
108        );
109        self.contents.iter().for_each(|item| {
110            render_item(item, fmt);
111        });
112        fmt.cr();
113        fmt.close("ol");
114        if self.wrap_in_nav {
115            fmt.cr();
116            fmt.close("nav");
117        }
118        fmt.cr();
119    }
120}
121
122impl TOC {
123    fn push(&mut self, item: TableOfContentsItem, depth_limit: u8) {
124        println!("{}", depth_limit);
125        let child_count = self.contents.len();
126        if depth_limit > self.min_level && child_count != 0 {
127            self.contents[child_count - 1].push(item, depth_limit - 1);
128            return;
129        }
130        println!("---");
131        self.min_level = item.level;
132        self.contents.push(item);
133    }
134}
135
136
137fn sluggify(name: &str) -> String {
138    name.to_string().replace(|c| !char::is_alphanumeric(c) && c != ' ', "").replace(" ", "-").to_lowercase()
139}
140
141struct TableOfContentsDetect;
142
143impl CoreRule for TableOfContentsDetect {
144    fn run(root: &mut Node, md: &MarkdownIt) {
145        struct Heading {
146            level: u8,
147            title: String,
148            slug: String
149        }
150
151        let mut disorganized_headings: Vec<Heading> = Vec::new();
152        let mut index = 0;
153        let mut head_count = 0;
154        let mut first_heading: Option<u16> = None;
155        root.walk_post_mut(|node, _| {
156            fn get_level(node: &Node) -> Option<u8> {
157                match node.cast::<ATXHeading>() {
158                    Some(item) => return Some(item.level),
159                    None => ()
160                };
161                match node.cast::<SetextHeader>() {
162                    Some(item) => return Some(item.level),
163                    None => ()
164                };
165                return None
166            }
167
168            index += 1;
169
170            let level = match get_level(node) {
171                None => return (),
172                Some(l) => l
173            };
174
175            if first_heading == None {
176                first_heading = Some((index - 1) >> 1);
177                // MD-It has been adding a skip text between each element for some reason.
178            }
179            head_count = head_count + 1;
180
181            let title = node.collect_text();
182            let slug = match node.attrs.as_slice() {
183                [("id", id)] => String::from(id),
184                // If another plugin sets the id, use that instead.
185                _ => {
186                    let slug = sluggify(&title);
187                    node.attrs.push(("id", slug.clone()));
188                    slug
189                }
190            };
191            
192            let header_tag = Heading {
193                title: title,
194                slug: String::from(slug),
195                level: level
196            };
197            disorganized_headings.push(header_tag);
198        });
199
200        let default_opts = TOCOptions::default();
201        let opts = md.ext.get::<TOCOptions>().unwrap_or(&default_opts);
202
203        let mut organized_headings = TOC {
204            contents: Vec::new(),
205            min_level: 6,
206            wrap_in_nav: opts.wrap_in_nav,
207            toc_heading: opts.toc_heading.clone()
208        };
209        for heading in disorganized_headings {
210            let head = TableOfContentsItem {
211                title: heading.title,
212                slug: heading.slug,
213                level: heading.level,
214                children: Box::new(Vec::new())
215            };
216            if heading.level == 1 && !opts.allow_titles_in_toc { continue; }
217            organized_headings.push(
218                head,
219                if heading.level == 1 && opts.treat_title_as_h2 { 2 } else { heading.level }
220            );
221        }
222
223        if head_count < 2 {
224            return ();
225        }
226
227        let mut table_of_contents = Node::new(organized_headings);
228        table_of_contents.attrs.push(("class", opts.toc_class.clone()));
229        match first_heading {
230            Some(i) => root.children.insert(i.into(), table_of_contents),
231            None => ()
232        };
233    }
234}
235
236pub fn add(md: &mut MarkdownIt) {
237    // insert this rule into parser
238    md.add_rule::<TableOfContentsDetect>();
239}