yggdrasil/api/markdown/
mod.rs1#![allow(clippy::unused_unit, deprecated)]
10
11#[cfg(feature = "server")]
12mod code_block;
13#[cfg(feature = "server")]
14mod footnote;
15#[cfg(feature = "server")]
16mod heading;
17#[cfg(feature = "server")]
18pub(crate) mod mermaid;
19#[cfg(feature = "server")]
20mod post_process;
21#[cfg(all(test, feature = "server"))]
22mod tests;
23
24#[cfg(feature = "server")]
25pub(crate) fn clean_html(input: &str) -> String {
27 crate::api::sanitizer::clean_html(input)
28}
29
30#[derive(Debug, Clone)]
31#[cfg(feature = "server")]
32pub struct RenderedContent {
34 pub html: String,
36 pub toc_html: String,
38}
39
40#[cfg(feature = "server")]
41fn flush_events(html: &mut String, events: &mut Vec<pulldown_cmark::Event<'_>>) {
43 if !events.is_empty() {
44 pulldown_cmark::html::push_html(html, events.drain(..));
45 }
46}
47
48#[cfg(feature = "server")]
49pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
51 use pulldown_cmark::{Event, Options, Tag, TagEnd};
52 use std::fmt::Write as _;
53
54 let mut opts = Options::all();
65 opts.remove(Options::ENABLE_OLD_FOOTNOTES);
66 opts.insert(Options::ENABLE_FOOTNOTES);
67
68 let events: Vec<Event> = pulldown_cmark::Parser::new_ext(md, opts).collect();
72
73 let mut headings: Vec<(u8, String, String)> = Vec::new();
76 let mut current_heading: Option<(u8, String)> = None;
77
78 use std::collections::{HashMap, HashSet};
84 let mut fn_refs: HashMap<String, usize> = HashMap::new();
85 let mut fn_order: Vec<String> = Vec::new();
86 let mut heading_ids: HashSet<String> = HashSet::new();
88
89 for event in &events {
90 match event {
91 Event::Start(Tag::Heading { level, .. }) => {
92 current_heading = Some((heading::heading_level(*level), String::new()));
93 }
94 Event::Text(text) => {
95 if let Some((_, ref mut content)) = current_heading {
96 content.push_str(text);
97 }
98 }
99 Event::Code(code) => {
100 if let Some((_, ref mut content)) = current_heading {
101 content.push_str(code);
102 }
103 }
104 Event::End(TagEnd::Heading(_)) => {
105 if let Some((lvl, text)) = current_heading.take() {
106 let id = heading::unique_heading_slug(&text, &mut heading_ids);
107 headings.push((lvl, text, id));
108 }
109 }
110 Event::FootnoteReference(name) => {
114 let label = name.to_string();
115 let count = fn_refs.entry(label.clone()).or_insert(0);
116 *count += 1;
117 if *count == 1 {
118 fn_order.push(label);
119 }
120 }
121 _ => {}
122 }
123 }
124
125 let fn_num: HashMap<&String, usize> = fn_order
127 .iter()
128 .enumerate()
129 .map(|(i, label)| (label, i + 1))
130 .collect();
131
132 let toc_html = heading::generate_toc_html(&headings);
134
135 let mut html = String::with_capacity(md.len() + 256);
138 let mut diagrams = mermaid::Diagrams::new();
139 let mut heading_idx = 0;
140 let mut in_heading = false;
141 let mut in_codeblock = false;
142 let mut code_lang: Option<String> = None;
143 let mut code_runnable: Option<(String, String)> = None;
147 let mut code_buffer = String::new();
148 let mut non_heading_events: Vec<Event> = Vec::new();
149 let mut fn_ref_seen: HashMap<String, usize> = HashMap::new();
152 let mut fn_def_stack: Vec<String> = Vec::new();
155
156 for event in events {
157 match event {
158 Event::Start(Tag::Heading { level, .. }) => {
159 flush_events(&mut html, &mut non_heading_events);
161 in_heading = true;
162 if heading_idx < headings.len() {
163 let (_, _, ref id) = headings[heading_idx];
164 let tag = heading::heading_tag(level);
165 let _ = write!(html, "<{tag} id=\"{id}\">");
167 }
168 }
169 Event::End(TagEnd::Heading(level)) => {
170 if heading_idx < headings.len() {
171 let (_, _, ref id) = headings[heading_idx];
172 let tag = heading::heading_tag(level);
173 let _ = write!(
174 html,
175 "<a class=\"anchor\" aria-hidden=\"true\" href=\"#{id}\">#</a></{tag}>"
176 );
177 heading_idx += 1;
178 }
179 in_heading = false;
180 }
181 Event::Start(Tag::CodeBlock(kind)) => {
182 flush_events(&mut html, &mut non_heading_events);
184 in_codeblock = true;
185 code_lang = match kind {
186 pulldown_cmark::CodeBlockKind::Fenced(lang) => {
187 if lang.is_empty() {
188 None
189 } else {
190 Some(lang.to_string())
191 }
192 }
193 _ => None,
194 };
195 code_runnable = code_lang.as_deref().and_then(code_block::runnable_attrs);
197 code_buffer.clear();
198 }
199 Event::Text(text) if in_codeblock => {
200 code_buffer.push_str(&text);
201 }
202 Event::End(TagEnd::CodeBlock) => {
203 code_block::push_code_block(
204 &mut html,
205 code_lang.as_deref(),
206 code_runnable.take(),
207 &code_buffer,
208 &mut diagrams,
209 );
210 in_codeblock = false;
211 }
212 Event::InlineMath(tex) => {
213 let rendered = crate::api::katex::render_inline(&tex);
217 if in_heading {
218 html.push_str(&rendered);
220 } else {
221 non_heading_events.push(Event::InlineHtml(rendered.into()));
222 }
223 }
224 Event::DisplayMath(tex) => {
225 let rendered = format!(
229 "<p class=\"math-display\">{}</p>",
230 crate::api::katex::render_display(&tex)
231 );
232 if in_heading {
233 html.push_str(&rendered);
234 } else {
235 non_heading_events.push(Event::InlineHtml(rendered.into()));
236 }
237 }
238 Event::FootnoteReference(name) => {
239 let label = name.to_string();
240 let n = {
242 let entry = fn_ref_seen.entry(label.clone()).or_insert(0);
243 *entry += 1;
244 *entry
245 };
246 let id = footnote::footnote_id(&label);
247 let num = fn_num.get(&label).copied().unwrap_or(n);
249 let rendered = footnote::reference_html(&id, n, num);
251 if in_heading {
252 html.push_str(&rendered);
253 } else {
254 non_heading_events.push(Event::InlineHtml(rendered.into()));
255 }
256 }
257 Event::Start(Tag::FootnoteDefinition(name)) => {
258 flush_events(&mut html, &mut non_heading_events);
260 let label = name.to_string();
261 let id = footnote::footnote_id(&label);
262 let num = fn_num.get(&label).copied().unwrap_or_else(|| {
263 fn_order.len() + 1
265 });
266 footnote::push_definition_open(&mut html, &id, num);
267 fn_def_stack.push(label);
268 }
269 Event::End(TagEnd::FootnoteDefinition) => {
270 flush_events(&mut html, &mut non_heading_events);
273 if let Some(label) = fn_def_stack.pop() {
275 let id = footnote::footnote_id(&label);
276 let ref_count = fn_refs.get(&label).copied().unwrap_or(0);
277 footnote::push_backlinks(&mut html, &id, ref_count);
278 }
279 html.push_str("</aside>");
280 }
281 _ => {
282 if in_heading {
283 match event {
285 Event::Text(text) => html.push_str(&clean_html(&text)),
286 Event::Code(code) => {
287 html.push_str("<code>");
288 html.push_str(&clean_html(&code));
289 html.push_str("</code>");
290 }
291 _ => {}
292 }
293 } else if !in_codeblock {
294 non_heading_events.push(event);
295 }
296 }
297 }
298 }
299
300 flush_events(&mut html, &mut non_heading_events);
302
303 let html = post_process::wrap_images_with_blur(&html);
304 let html = post_process::wrap_tables(&html);
307 RenderedContent {
309 html: diagrams.substitute(clean_html(&html)),
310 toc_html,
311 }
312}