markdown_heading_id/
lib.rs1use 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
52pub 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 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 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>><</h2>");
202 }
203}