markdown_includes/fence/
toc.rs

1use anyhow::Result;
2use percent_encoding::{percent_encode, CONTROLS};
3use serde::Deserialize;
4use std::{ops::Range, path::Path, str::FromStr};
5use string_sections::SectionSpan;
6
7use super::Fence;
8
9pub struct TocFence {
10    conf: TocConfig,
11    outer: Range<usize>,
12}
13
14impl Fence for TocFence {
15    fn is_match(name: &str) -> bool
16    where
17        Self: Sized,
18    {
19        let name = name.to_lowercase();
20        name.ends_with("toc") || name.ends_with("table-of-contents")
21    }
22
23    fn priority(self: &Self) -> u8 {
24        10
25    }
26
27    fn create(document: &str, section: SectionSpan, _template_dir: &Path) -> Result<Box<Self>>
28    where
29        Self: Sized,
30    {
31        let conf = toml::de::from_str(&document[section.inner_range()])?;
32        let outer = section.outer_range();
33        Ok(Box::new(Self { conf, outer }))
34    }
35
36    fn run(self: &Self, document: &mut String) -> Result<()> {
37        let mut output = String::new();
38
39        if let Some(ref header) = self.conf.header {
40            output.push_str(&header);
41            output.push_str("\n\n");
42        }
43
44        let headings = find_headings(document);
45        let toc = headings
46            .iter()
47            .filter_map(|h| h.format(&self.conf))
48            .collect::<Vec<String>>()
49            .join("\n");
50
51        output.push_str(&toc);
52        document.replace_range(self.outer.clone(), &output);
53        Ok(())
54    }
55}
56
57#[derive(Deserialize)]
58pub struct TocConfig {
59    #[serde(default = "default_bullet")]
60    pub bullet: String,
61    #[serde(default = "default_inline")]
62    pub indent: usize,
63    pub max_depth: Option<usize>,
64    #[serde(default)]
65    pub min_depth: usize,
66    pub header: Option<String>,
67    #[serde(default = "default_link")]
68    pub link: bool,
69}
70
71fn default_link() -> bool {
72    true
73}
74fn default_inline() -> usize {
75    4
76}
77fn default_bullet() -> String {
78    "-".to_string()
79}
80fn slugify(text: &str) -> String {
81    percent_encode(text.replace(" ", "-").to_lowercase().as_bytes(), CONTROLS).to_string()
82}
83
84pub fn find_headings(content: &str) -> Vec<Heading> {
85    let mut in_fence = false;
86
87    content
88        .lines()
89        .filter(|line| {
90            let was_inside = in_fence;
91            line.starts_with("```").then(|| in_fence = !in_fence);
92            !(was_inside || in_fence)
93        })
94        .map(Heading::from_str)
95        .filter_map(Result::ok)
96        .collect::<Vec<Heading>>()
97}
98
99pub struct Heading {
100    pub depth: usize,
101    pub title: String,
102}
103
104impl FromStr for Heading {
105    type Err = ();
106
107    fn from_str(s: &str) -> Result<Self, Self::Err> {
108        let trimmed = s.trim_end();
109        if trimmed.starts_with("#") {
110            let mut depth = 0usize;
111            let title = trimmed
112                .chars()
113                .skip_while(|c| {
114                    if *c == '#' {
115                        depth += 1;
116                        true
117                    } else {
118                        false
119                    }
120                })
121                .collect::<String>()
122                .trim_start()
123                .to_owned();
124            Ok(Heading {
125                depth: depth - 1,
126                title,
127            })
128        } else {
129            Err(())
130        }
131    }
132}
133
134impl Heading {
135    pub fn format(&self, config: &TocConfig) -> Option<String> {
136        if self.depth >= config.min_depth
137            && config.max_depth.map(|d| self.depth <= d).unwrap_or(true)
138        {
139            let head = format!(
140                "{}{} {}",
141                " ".repeat(config.indent)
142                    .repeat(self.depth - config.min_depth),
143                &config.bullet,
144                if !config.link {
145                    self.title.clone()
146                } else {
147                    format!("[{}](#{})", &self.title, slugify(&self.title))
148                }
149            );
150            Some(head)
151        } else {
152            None
153        }
154    }
155}