markdown_heading_id/
lib.rs

1//! Filter for the [`Parser`](https://docs.rs/pulldown-cmark/0.8.0/pulldown_cmark/struct.Parser.html) of crate [`pulldown-cmark`](https://crates.io/crates/pulldown-cmark)
2//!
3//! This crate provides a filter of [`Parser`](https://docs.rs/pulldown-cmark/0.8.0/pulldown_cmark/struct.Parser.html) which converts headings with custom ID into HTML.
4//! It uses the syntax of headings IDs defined in [Extended Syntax of Markdown](https://www.markdownguide.org/extended-syntax/#heading-ids).
5//!
6//! For example, if we have the following fragment of Markdown
7//! ```ignore
8//! ## Heading {#heading-id}
9//! ```
10//! then it is converted into a fragment of HTML below:
11//! ```ignore
12//! <h2 id="heading-id">Heading</h2>
13//! ```
14//!
15//! ## Usage
16//!
17//! It is easy to use a filter provided by this crate.
18//! `HeadingId` wraps an instance of [`Parser`](https://docs.rs/pulldown-cmark/0.8.0/pulldown_cmark/struct.Parser.html) and it can be passed to [`push_html`](https://docs.rs/pulldown-cmark/0.8.0/pulldown_cmark/html/fn.push_html.html) or [`write_html`](https://docs.rs/pulldown-cmark/0.8.0/pulldown_cmark/html/fn.write_html.html),
19//! because `HeadingId` implements the trait `Iterator<Item=Event<'a>>`.
20//! An example is given below:
21//! ```
22//! use pulldown_cmark::Parser;
23//! use pulldown_cmark::html::push_html;
24//! use markdown_heading_id::HeadingId;
25//!
26//! let parser = Parser::new("## Heading {#heading-id}");
27//! let parser = HeadingId::new(parser);
28//! let mut buf = String::new();
29//! push_html(&mut buf, parser);
30//! assert_eq!(buf.trim_end(), r#"<h2 id="heading-id">Heading</h2>"#);
31//! ```
32
33use std::marker::PhantomData;
34use pulldown_cmark::{Event, Tag};
35use pulldown_cmark::escape::{StrWrite, escape_html, escape_href};
36use pulldown_cmark::html::push_html;
37
38fn find_custom_id(s: &str) -> (&str, Option<&str>) {
39    let (before_brace, after_brace) = match s.find("{#") {
40        Some(pos) => (&s[..pos], &s[pos+2..]),
41        None => return (s, None),
42    };
43
44    let (inner_brace, _after_brace) = match after_brace.find('}') {
45        Some(pos) => (&after_brace[..pos], &after_brace[pos+1..]),
46        None => return (s, None),
47    };
48
49    (before_brace.trim_end(), Some(inner_brace))
50}
51
52/// Converts headings with ID into HTML
53///
54/// An iterator `HeadingId` converts a heading with ID into an HTML event.
55/// Heading IDs are written in an [extended syntax](https://www.markdownguide.org/extended-syntax/#heading-ids) of Markdown.
56/// This iterator acts as a filter of the `Parser` of `pulldown-cmark`.
57/// `Event`s between a start of `Tag::Heading` and end thereof are converted into one
58/// `Event::HTML`.
59/// It buffers those events because the heading id is positioned at the tail of heading line.
60pub struct HeadingId<'a, P> {
61    parser: P,
62    _marker: PhantomData<&'a P>,
63}
64
65impl<'a, P> HeadingId<'a, P>
66where
67    P: Iterator<Item=Event<'a>>,
68{
69    pub fn new(parser: P) -> Self {
70        Self {
71            parser: parser,
72            _marker: PhantomData,
73        }
74    }
75
76    fn convert_heading(&mut self, level: u32) -> Event<'a> {
77        // Read events until the end of heading comes.
78        let mut buffer = Vec::new();
79
80        while let Some(event) = self.parser.next() {
81            match event {
82                Event::End(Tag::Heading(n)) if n == level => break,
83                _ => {},
84            }
85            buffer.push(event.clone());
86        }
87
88        // Convert the events into an HTML
89        let mut html = String::new();
90        let mut start_tag = String::new();
91
92        if let Some((last, events)) = buffer.split_last() {
93            push_html(&mut html, events.iter().cloned());
94
95            match last {
96                Event::Text(text) => {
97                    let (text, id) = find_custom_id(text);
98                    escape_html(&mut html, text).unwrap();
99
100                    if let Some(id) = id {
101                        write!(&mut start_tag, "<h{} id=\"", level).unwrap();
102                        escape_href(&mut start_tag, id).unwrap();
103                        write!(&mut start_tag, "\">").unwrap();
104                    } else {
105                        write!(&mut start_tag, "<h{}>", level).unwrap();
106                    }
107                },
108                event => {
109                    push_html(&mut html, vec![event.clone()].into_iter());
110                },
111            }
112        } else {
113            write!(&mut start_tag, "<h{}>", level).unwrap();
114        }
115
116        writeln!(&mut html, "</h{}>", level).unwrap();
117
118        start_tag += &html;
119        let html = start_tag;
120        
121        Event::Html(html.into())
122    }
123}
124
125impl<'a, P> Iterator for HeadingId<'a, P>
126where
127    P: Iterator<Item=Event<'a>>,
128{
129    type Item = Event<'a>;
130
131    fn next(&mut self) -> Option<Self::Item> {
132        match self.parser.next() {
133            Some(Event::Start(Tag::Heading(level))) => Some(self.convert_heading(level)),
134            Some(event) => Some(event),
135            None => None,
136        }
137    }
138}
139
140#[cfg(test)]
141mod test {
142    use super::*;
143    use pulldown_cmark::Parser;
144
145    fn convert(s: &str) -> String {
146        let mut buf = String::new();
147        let parser = Parser::new(s);
148        let parser = HeadingId::new(parser);
149        pulldown_cmark::html::push_html(&mut buf, parser);
150        buf
151    }
152
153    #[test]
154    fn heading_id() {
155        let s = "## Heading {#heading-id}";
156        assert_eq!(convert(s).trim_end(), r#"<h2 id="heading-id">Heading</h2>"#);
157    }
158
159    #[test]
160    fn normal() {
161        let s = "## Heading";
162        assert_eq!(convert(s).trim_end(), r#"<h2>Heading</h2>"#);
163    }
164
165    #[test]
166    fn inline_code() {
167        let s = "# `source code` heading {#source}";
168        assert_eq!(convert(s).trim_end(),
169            r#"<h1 id="source"><code>source code</code> heading</h1>"#);
170    }
171
172    #[test]
173    fn em_strong() {
174        let s = "## *Italic* __BOLD__ heading {#italic-bold}";
175        assert_eq!(convert(s).trim_end(),
176            r#"<h2 id="italic-bold"><em>Italic</em> <strong>BOLD</strong> heading</h2>"#);
177    }
178
179    #[test]
180    fn whitespace() {
181        let s = "## ID with space {#id with space}";
182        assert_eq!(convert(s).trim_end(),
183            r#"<h2 id="id%20with%20space">ID with space</h2>"#);
184    }
185
186    #[test]
187    fn empty() {
188        assert_eq!(convert("##").trim_end(), "<h2></h2>");
189    }
190
191    #[test]
192    fn with_link() {
193        let s = "### [Link](https://example.com/) {#example}";
194        assert_eq!(convert(s).trim_end(),
195            r#"<h3 id="example"><a href="https://example.com/">Link</a></h3>"#);
196    }
197
198    #[test]
199    fn to_be_escaped() {
200        let s = "## ><";
201        assert_eq!(convert(s).trim_end(), "<h2>&gt;&lt;</h2>");
202    }
203}