Skip to main content

abbaye/
render.rs

1use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd, html};
2
3pub(crate) fn extract_local_refs(md: &str) -> Vec<String> {
4    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
5    let mut refs = Vec::new();
6    for event in Parser::new_ext(md, opts) {
7        let url: Option<pulldown_cmark::CowStr> = match event {
8            Event::Start(Tag::Image { dest_url, .. }) => Some(dest_url),
9            Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url),
10            Event::End(TagEnd::Image | TagEnd::Link) => None,
11            _ => None,
12        };
13        if let Some(url) = url {
14            let s = url.as_ref();
15            if !s.contains("://") && !s.starts_with('#') && !s.is_empty() {
16                refs.push(s.to_owned());
17            }
18        }
19    }
20    refs
21}
22
23pub(crate) fn render_markdown_html(md: &str) -> String {
24    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
25    let parser = Parser::new_ext(md, opts);
26    let mut buf = String::new();
27    html::push_html(&mut buf, parser);
28    buf
29}
30
31pub(crate) fn render_markdown_gemtext(md: &str) -> String {
32    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
33    let parser = Parser::new_ext(md, opts);
34
35    let mut out: Vec<String> = Vec::new();
36    let mut buf = String::new();
37    let mut links: Vec<(String, String)> = Vec::new();
38    let mut last_blank = false;
39
40    enum Ctx {
41        Paragraph,
42        BlockQuote,
43        ListItem,
44        Heading(u8),
45    }
46    let mut stack: Vec<Ctx> = Vec::new();
47
48    let mut in_link = false;
49    let mut link_url = String::new();
50    let mut link_text = String::new();
51
52    let mut in_code = false;
53    let mut code_kind = String::new();
54    let mut code_body = String::new();
55
56    fn in_ctx(stack: &[Ctx], target: Ctx) -> bool {
57        stack
58            .iter()
59            .any(|c| std::mem::discriminant(c) == std::mem::discriminant(&target))
60    }
61
62    fn flush_line(
63        out: &mut Vec<String>,
64        buf: &mut String,
65        links: &mut Vec<(String, String)>,
66        stack: &[Ctx],
67        _last_blank: &mut bool,
68    ) {
69        let text = buf.trim();
70        if text.is_empty() && links.is_empty() {
71            return;
72        }
73
74        let in_blockquote = in_ctx(stack, Ctx::BlockQuote);
75        let in_list = in_ctx(stack, Ctx::ListItem);
76
77        if let Some(Ctx::Heading(lvl)) = stack.last() {
78            let prefix = match lvl {
79                1 => "#",
80                2 => "##",
81                _ => "###",
82            };
83            if !text.is_empty() {
84                out.push(format!("{prefix} {text}"));
85            }
86            for (url, t) in links.drain(..) {
87                out.push(format!("=> {url} {t}"));
88            }
89            *_last_blank = false;
90        } else if in_blockquote {
91            if !text.is_empty() {
92                for line in text.lines() {
93                    out.push(format!("> {line}"));
94                }
95            }
96            for (url, t) in links.drain(..) {
97                out.push(format!("=> {url} {t}"));
98            }
99            *_last_blank = false;
100        } else if in_list {
101            if !text.is_empty() {
102                out.push(format!("* {text}"));
103            }
104            for (url, t) in links.drain(..) {
105                out.push(format!("=> {url} {t}"));
106            }
107            *_last_blank = false;
108        } else {
109            if !text.is_empty() {
110                out.push(text.to_string());
111            }
112            for (url, t) in links.drain(..) {
113                out.push(format!("=> {url} {t}"));
114            }
115            *_last_blank = false;
116        }
117        buf.clear();
118    }
119
120    for event in parser {
121        match event {
122            Event::Start(tag) => match tag {
123                Tag::Heading { level, .. } => {
124                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
125                    let lvl = level as u8;
126                    stack.push(Ctx::Heading(if lvl > 3 { 3 } else { lvl }));
127                }
128                Tag::Paragraph => {
129                    stack.push(Ctx::Paragraph);
130                }
131                Tag::BlockQuote(_) => {
132                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
133                    stack.push(Ctx::BlockQuote);
134                }
135                Tag::CodeBlock(kind) => {
136                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
137                    in_code = true;
138                    code_kind = match kind {
139                        CodeBlockKind::Fenced(info) => info.to_string(),
140                        CodeBlockKind::Indented => String::new(),
141                    };
142                    code_body.clear();
143                }
144                Tag::List { .. } => {}
145                Tag::Item => {
146                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
147                    stack.push(Ctx::ListItem);
148                }
149                Tag::Link { dest_url, .. } => {
150                    in_link = true;
151                    link_url = dest_url.to_string();
152                    link_text.clear();
153                }
154                Tag::Image { dest_url, .. } => {
155                    in_link = true;
156                    link_url = dest_url.to_string();
157                    link_text.clear();
158                }
159                _ => {}
160            },
161            Event::End(tag) => match tag {
162                TagEnd::Heading(_) => {
163                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
164                    stack.pop();
165                }
166                TagEnd::Paragraph => {
167                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
168                    if !in_ctx(&stack, Ctx::BlockQuote) && !in_ctx(&stack, Ctx::ListItem) {
169                        out.push(String::new());
170                        last_blank = true;
171                    }
172                    stack.pop();
173                }
174                TagEnd::BlockQuote(_) => {
175                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
176                    out.push(String::new());
177                    last_blank = true;
178                    stack.pop();
179                }
180                TagEnd::CodeBlock => {
181                    if !code_body.is_empty() {
182                        out.push(format!("```{}", code_kind));
183                        for line in code_body.trim().lines() {
184                            out.push(line.to_string());
185                        }
186                        out.push("```".to_string());
187                        out.push(String::new());
188                        last_blank = true;
189                    }
190                    in_code = false;
191                    code_kind.clear();
192                    code_body.clear();
193                }
194                TagEnd::List(_) => {
195                    out.push(String::new());
196                    last_blank = true;
197                }
198                TagEnd::Item => {
199                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
200                    stack.pop();
201                }
202                TagEnd::Link => {
203                    let t = link_text.trim().to_string();
204                    if in_link && !link_url.is_empty() && !t.is_empty() {
205                        links.push((link_url.clone(), t));
206                    }
207                    in_link = false;
208                    link_url.clear();
209                    link_text.clear();
210                }
211                TagEnd::Image => {
212                    let t = if link_text.trim().is_empty() {
213                        "image".to_string()
214                    } else {
215                        link_text.trim().to_string()
216                    };
217                    if in_link {
218                        out.push(format!("=> {} {}", link_url, t));
219                    }
220                    in_link = false;
221                    link_url.clear();
222                    link_text.clear();
223                }
224                _ => {}
225            },
226            Event::Text(text) => {
227                if in_code {
228                    code_body.push_str(&text);
229                } else if in_link {
230                    link_text.push_str(&text);
231                } else {
232                    buf.push_str(&text);
233                }
234            }
235            Event::Code(text) => {
236                if in_link {
237                    link_text.push_str(&text);
238                } else {
239                    buf.push_str(&text);
240                }
241            }
242            Event::SoftBreak | Event::HardBreak => {
243                if in_code {
244                    code_body.push('\n');
245                } else {
246                    buf.push(' ');
247                }
248            }
249            _ => {}
250        }
251    }
252
253    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
254
255    while out.last().is_some_and(|l| l.is_empty()) {
256        out.pop();
257    }
258
259    out.join("\n") + "\n"
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn test_extract_local_refs_none() {
268        let md = "# Hello\n\nJust text, no links.\n";
269        assert!(extract_local_refs(md).is_empty());
270    }
271
272    #[test]
273    fn test_extract_local_refs_absolute_url() {
274        let md = "[foo](https://example.com)";
275        assert!(extract_local_refs(md).is_empty());
276    }
277
278    #[test]
279    fn test_extract_local_refs_relative() {
280        let md = "[foo](/bar/baz)";
281        assert_eq!(extract_local_refs(md), vec!["/bar/baz"]);
282    }
283
284    #[test]
285    fn test_extract_local_refs_fragment() {
286        let md = "[foo](#section)";
287        assert!(extract_local_refs(md).is_empty());
288    }
289
290    #[test]
291    fn test_render_markdown_html_basic() {
292        let md = "# Hello\n\nThis is **bold**.";
293        let html = render_markdown_html(md);
294        assert!(html.contains("<h1>Hello</h1>"));
295        assert!(html.contains("<strong>bold</strong>"));
296    }
297
298    #[test]
299    fn test_render_markdown_html_table() {
300        let md = "| A | B |\n|---|---|\n| 1 | 2 |";
301        let html = render_markdown_html(md);
302        assert!(html.contains("<table>"));
303    }
304
305    #[test]
306    fn test_render_markdown_gemtext_heading() {
307        let md = "# Title\n\n## Subtitle\n\n### H3\n\n#### H4 (clamped)";
308        let gmi = render_markdown_gemtext(md);
309        assert_eq!(gmi.lines().next(), Some("# Title"));
310        assert!(gmi.contains("## Subtitle"));
311        assert!(gmi.contains("### H3"));
312        assert!(gmi.contains("### H4 (clamped)"));
313    }
314
315    #[test]
316    fn test_render_markdown_gemtext_paragraph() {
317        let md = "Hello, world!";
318        let gmi = render_markdown_gemtext(md);
319        assert!(gmi.starts_with("Hello, world!"));
320    }
321
322    #[test]
323    fn test_render_markdown_gemtext_link() {
324        let md = "[example](https://example.com)";
325        let gmi = render_markdown_gemtext(md);
326        assert!(gmi.contains("=> https://example.com example"));
327    }
328
329    #[test]
330    fn test_render_markdown_gemtext_image() {
331        let md = "![alt](img.png)";
332        let gmi = render_markdown_gemtext(md);
333        assert!(gmi.contains("=> img.png alt"));
334    }
335
336    #[test]
337    fn test_render_markdown_gemtext_image_no_alt() {
338        let md = "![](img.png)";
339        let gmi = render_markdown_gemtext(md);
340        assert!(gmi.contains("=> img.png image"));
341    }
342
343    #[test]
344    fn test_render_markdown_gemtext_blockquote() {
345        let md = "> A wise quote.";
346        let gmi = render_markdown_gemtext(md);
347        assert!(gmi.contains("> A wise quote."));
348    }
349
350    #[test]
351    fn test_render_markdown_gemtext_list() {
352        let md = "- one\n- two\n- three";
353        let gmi = render_markdown_gemtext(md);
354        assert!(gmi.contains("* one"));
355        assert!(gmi.contains("* two"));
356        assert!(gmi.contains("* three"));
357    }
358
359    #[test]
360    fn test_render_markdown_gemtext_code_block() {
361        let md = "```rust\nfn main() {}\n```";
362        let gmi = render_markdown_gemtext(md);
363        assert!(gmi.contains("```rust"));
364        assert!(gmi.contains("fn main() {}"));
365        assert!(gmi.contains("```"));
366    }
367
368    #[test]
369    fn test_render_markdown_gemtext_empty_code_block() {
370        let md = "```\n```";
371        let gmi = render_markdown_gemtext(md);
372        // Empty code blocks are silently dropped.
373        assert!(!gmi.contains("```"));
374    }
375
376    #[test]
377    fn test_render_markdown_gemtext_trailing_newline() {
378        let md = "# Hi";
379        let gmi = render_markdown_gemtext(md);
380        assert!(gmi.ends_with('\n'));
381    }
382
383    #[test]
384    fn test_render_markdown_gemtext_inline_code() {
385        let md = "use `foo` here";
386        let gmi = render_markdown_gemtext(md);
387        assert!(gmi.contains("foo"));
388    }
389
390    #[test]
391    fn test_render_markdown_gemtext_soft_break() {
392        let md = "line1\nline2";
393        let gmi = render_markdown_gemtext(md);
394        assert!(gmi.contains("line1 line2") || gmi.contains("line1\nline2"));
395    }
396
397    #[test]
398    fn test_render_markdown_gemtext_config_option() {
399        // Ensure ENABLE_TABLES and ENABLE_STRIKETHROUGH are set
400        let md = "~~strike~~";
401        let gmi = render_markdown_gemtext(md);
402        assert!(gmi.contains("strike"));
403    }
404}