markdown_includes/fence/
toc.rs1use 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}