pulldown_cmark_toc/lib.rs
1//! Generate a table of contents from a Markdown document.
2//!
3//! By default the heading anchor calculation (aka the "slugification")
4//! is done in a way that attempts to mimic GitHub's (undocumented) behavior.
5//! (Though you can customize this with your own [`slug::Slugify`] implementation).
6//!
7//! # Examples
8//!
9//! ```
10//! use pulldown_cmark_toc::TableOfContents;
11//!
12//! let text = "# Heading\n\n## Subheading\n\n## Subheading with `code`\n";
13//!
14//! let toc = TableOfContents::new(text);
15//! assert_eq!(
16//! toc.to_cmark(),
17//! r#"- [Heading](#heading)
18//! - [Subheading](#subheading)
19//! - [Subheading with `code`](#subheading-with-code)
20//! "#
21//! );
22//! ```
23
24mod render;
25mod slug;
26
27use std::borrow::Borrow;
28use std::fmt::Write;
29use std::slice::Iter;
30
31pub use pulldown_cmark::HeadingLevel;
32use pulldown_cmark::{Event, Options as CmarkOptions, Parser, Tag, TagEnd};
33
34pub use render::{ItemSymbol, Options};
35pub use slug::{GitHubSlugifier, Slugify};
36
37/////////////////////////////////////////////////////////////////////////
38// Definitions
39/////////////////////////////////////////////////////////////////////////
40
41/// Represents a heading.
42#[derive(Debug, Clone)]
43pub struct Heading<'a> {
44 /// The Markdown events between the heading tags.
45 events: Vec<Event<'a>>,
46 /// The heading level.
47 level: HeadingLevel,
48}
49
50/// Represents a Table of Contents.
51#[derive(Debug)]
52pub struct TableOfContents<'a> {
53 headings: Vec<Heading<'a>>,
54}
55
56/////////////////////////////////////////////////////////////////////////
57// Implementations
58/////////////////////////////////////////////////////////////////////////
59
60impl Heading<'_> {
61 /// The raw events contained between the heading tags.
62 pub fn events(&self) -> Iter<Event> {
63 self.events.iter()
64 }
65
66 /// The heading level.
67 pub fn level(&self) -> HeadingLevel {
68 self.level
69 }
70
71 /// The heading text with all Markdown code stripped out.
72 ///
73 /// The output of this this function can be used to generate an anchor.
74 pub fn text(&self) -> String {
75 let mut buf = String::new();
76 for event in self.events() {
77 if let Event::Text(s) | Event::Code(s) = event {
78 buf.push_str(s);
79 }
80 }
81 buf
82 }
83}
84
85impl<'a> TableOfContents<'a> {
86 /// Construct a new table of contents from Markdown text.
87 ///
88 /// # Examples
89 ///
90 /// ```
91 /// # use pulldown_cmark_toc::TableOfContents;
92 /// let toc = TableOfContents::new("# Heading\n");
93 /// ```
94 pub fn new(text: &'a str) -> Self {
95 // We are not enabling all options since we want to mimic
96 // GitHub's behavior as closely as possible by default.
97 // And e.g. enabling heading attributes could result in wrong anchors
98 // or enabling smart punctuation would result in inconsistent rendering.
99 let mut options = CmarkOptions::empty();
100 options.insert(CmarkOptions::ENABLE_STRIKETHROUGH);
101 options.insert(CmarkOptions::ENABLE_FOOTNOTES);
102 // Not enabling tables and tasklists since they cannot have any
103 // effect on headings (which are the only events we care about).
104 let events = Parser::new_ext(text, options);
105 Self::new_with_events(events)
106 }
107
108 /// Construct a new table of contents from parsed Markdown events.
109 ///
110 /// # Examples
111 ///
112 /// ```
113 /// # use pulldown_cmark_toc::TableOfContents;
114 /// use pulldown_cmark::Parser;
115 ///
116 /// let parser = Parser::new("# Heading\n");
117 /// let toc = TableOfContents::new_with_events(parser);;
118 /// ```
119 pub fn new_with_events<I, E>(events: I) -> Self
120 where
121 I: Iterator<Item = E>,
122 E: Borrow<Event<'a>>,
123 {
124 let mut headings = Vec::new();
125 let mut current: Option<Heading> = None;
126
127 for event in events {
128 let event = event.borrow();
129 match event {
130 Event::Start(Tag::Heading { level, .. }) => {
131 current = Some(Heading {
132 events: Vec::new(),
133 level: *level,
134 });
135 }
136 Event::End(TagEnd::Heading(level)) => {
137 let heading = current.take().unwrap();
138 assert_eq!(heading.level, *level);
139 headings.push(heading);
140 }
141 event => {
142 if let Some(heading) = current.as_mut() {
143 heading.events.push(event.clone());
144 }
145 }
146 }
147 }
148 Self { headings }
149 }
150
151 /// Iterate over the headings in this table of contents.
152 ///
153 /// # Examples
154 ///
155 /// Simple iteration over each heading.
156 /// ```
157 /// # use pulldown_cmark_toc::TableOfContents;
158 /// let toc = TableOfContents::new("# Heading\n");
159 ///
160 /// for heading in toc.headings() {
161 /// // use heading
162 /// }
163 /// ```
164 ///
165 /// Filtering out certain heading levels.
166 /// ```
167 /// # use pulldown_cmark_toc::{HeadingLevel, TableOfContents};
168 /// let toc = TableOfContents::new("# Heading\n## Subheading\n");
169 ///
170 /// for heading in toc.headings().filter(|h| h.level() >= HeadingLevel::H2) {
171 /// // use heading
172 /// }
173 /// ```
174 pub fn headings(&self) -> Iter<Heading> {
175 self.headings.iter()
176 }
177
178 /// Render the table of contents as Markdown.
179 ///
180 /// # Examples
181 ///
182 /// ```
183 /// # use pulldown_cmark_toc::TableOfContents;
184 /// let toc = TableOfContents::new("# Heading\n## Subheading\n");
185 /// assert_eq!(
186 /// toc.to_cmark(),
187 /// "- [Heading](#heading)\n - [Subheading](#subheading)\n"
188 /// );
189 /// ```
190 #[must_use]
191 pub fn to_cmark(&self) -> String {
192 self.to_cmark_with_options(Options::default())
193 }
194
195 /// Render the table of contents as Markdown with extra options.
196 ///
197 /// # Examples
198 ///
199 /// ```
200 /// # use pulldown_cmark_toc::{HeadingLevel, ItemSymbol, Options, TableOfContents};
201 ///
202 /// let toc = TableOfContents::new("# Heading\n## Subheading\n");
203 /// let options = Options::default()
204 /// .item_symbol(ItemSymbol::Asterisk)
205 /// .levels(HeadingLevel::H2..=HeadingLevel::H6)
206 /// .indent(4);
207 /// assert_eq!(
208 /// toc.to_cmark_with_options(options),
209 /// "* [Subheading](#subheading)\n"
210 /// );
211 /// ```
212 #[must_use]
213 pub fn to_cmark_with_options(&self, options: Options) -> String {
214 let Options {
215 item_symbol,
216 levels,
217 indent,
218 slugifier: mut slugger,
219 } = options;
220
221 let mut buf = String::new();
222 for heading in self.headings().filter(|h| levels.contains(&h.level())) {
223 let title = crate::render::to_cmark(heading.events());
224 let indent = indent * (heading.level() as usize - *levels.start() as usize);
225
226 // make sure the anchor is unique
227
228 writeln!(
229 buf,
230 "{:indent$}{} [{}](#{})",
231 "",
232 item_symbol,
233 title,
234 slugger.slugify(&heading.text()),
235 indent = indent,
236 )
237 .unwrap();
238 }
239 buf
240 }
241}
242
243/////////////////////////////////////////////////////////////////////////
244// Unit tests
245/////////////////////////////////////////////////////////////////////////
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 use pulldown_cmark::CowStr::Borrowed;
252 use pulldown_cmark::Event::{Code, Text};
253
254 #[test]
255 fn heading_text_with_code() {
256 let heading = Heading {
257 events: vec![Code(Borrowed("Another")), Text(Borrowed(" heading"))],
258 level: HeadingLevel::H1,
259 };
260 assert_eq!(heading.text(), "Another heading");
261 }
262
263 #[test]
264 fn heading_text_with_links() {
265 let events = Parser::new("Here [TOML](https://toml.io)").collect();
266 let heading = Heading {
267 events,
268 level: HeadingLevel::H1,
269 };
270 assert_eq!(heading.text(), "Here TOML");
271 }
272
273 #[test]
274 fn toc_new() {
275 let toc = TableOfContents::new("# Heading\n\n## `Another` heading\n");
276 assert_eq!(toc.headings[0].events, [Text(Borrowed("Heading"))]);
277 assert_eq!(toc.headings[0].level, HeadingLevel::H1);
278 assert_eq!(
279 toc.headings[1].events,
280 [Code(Borrowed("Another")), Text(Borrowed(" heading"))]
281 );
282 assert_eq!(toc.headings[1].level, HeadingLevel::H2);
283 assert_eq!(toc.headings.len(), 2);
284 }
285
286 #[test]
287 fn toc_new_does_not_enable_smart_punctuation() {
288 let toc = TableOfContents::new("# What's the deal with ellipsis ...?\n");
289 assert_eq!(toc.headings[0].text(), "What's the deal with ellipsis ...?");
290 }
291
292 #[test]
293 fn toc_new_does_not_enable_heading_attributes() {
294 let toc = TableOfContents::new("# text { #id .class1 .class2 }\n");
295 assert_eq!(toc.headings[0].text(), "text { #id .class1 .class2 }");
296 }
297
298 #[test]
299 fn toc_to_cmark_unique_anchors() {
300 let toc = TableOfContents::new("# Heading\n\n# Heading\n\n# `Heading`");
301 assert_eq!(
302 toc.to_cmark(),
303 "- [Heading](#heading)\n- [Heading](#heading-1)\n- [`Heading`](#heading-2)\n"
304 )
305 }
306}