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 flush_line(
57        out: &mut Vec<String>,
58        buf: &mut String,
59        links: &mut Vec<(String, String)>,
60        stack: &[Ctx],
61        _last_blank: &mut bool,
62    ) {
63        let text = buf.trim();
64        if text.is_empty() && links.is_empty() {
65            return;
66        }
67
68        if let Some(Ctx::Heading(lvl)) = stack.last() {
69            let prefix = match lvl {
70                1 => "#",
71                2 => "##",
72                _ => "###",
73            };
74            if !text.is_empty() {
75                out.push(format!("{prefix} {text}"));
76            }
77            for (url, t) in links.drain(..) {
78                out.push(format!("=> {url} {t}"));
79            }
80            *_last_blank = false;
81        } else if let Some(Ctx::BlockQuote) = stack.last() {
82            if !text.is_empty() {
83                for line in text.lines() {
84                    out.push(format!("> {line}"));
85                }
86            }
87            for (url, t) in links.drain(..) {
88                out.push(format!("=> {url} {t}"));
89            }
90            *_last_blank = false;
91        } else if let Some(Ctx::ListItem) = stack.last() {
92            if !text.is_empty() {
93                out.push(format!("* {text}"));
94            }
95            for (url, t) in links.drain(..) {
96                out.push(format!("=> {url} {t}"));
97            }
98            *_last_blank = false;
99        } else {
100            if !text.is_empty() {
101                out.push(text.to_string());
102            }
103            for (url, t) in links.drain(..) {
104                out.push(format!("=> {url} {t}"));
105            }
106            *_last_blank = false;
107        }
108        buf.clear();
109    }
110
111    for event in parser {
112        match event {
113            Event::Start(tag) => match tag {
114                Tag::Heading { level, .. } => {
115                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
116                    let lvl = level as u8;
117                    stack.push(Ctx::Heading(if lvl > 3 { 3 } else { lvl }));
118                }
119                Tag::Paragraph => {
120                    stack.push(Ctx::Paragraph);
121                }
122                Tag::BlockQuote(_) => {
123                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
124                    stack.push(Ctx::BlockQuote);
125                }
126                Tag::CodeBlock(kind) => {
127                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
128                    in_code = true;
129                    code_kind = match kind {
130                        CodeBlockKind::Fenced(info) => info.to_string(),
131                        CodeBlockKind::Indented => String::new(),
132                    };
133                    code_body.clear();
134                }
135                Tag::List { .. } => {}
136                Tag::Item => {
137                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
138                    stack.push(Ctx::ListItem);
139                }
140                Tag::Link { dest_url, .. } => {
141                    in_link = true;
142                    link_url = dest_url.to_string();
143                    link_text.clear();
144                }
145                Tag::Image { dest_url, .. } => {
146                    in_link = true;
147                    link_url = dest_url.to_string();
148                    link_text.clear();
149                }
150                _ => {}
151            },
152            Event::End(tag) => match tag {
153                TagEnd::Heading(_) => {
154                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
155                    stack.pop();
156                }
157                TagEnd::Paragraph => {
158                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
159                    if !matches!(stack.last(), Some(Ctx::BlockQuote | Ctx::ListItem)) {
160                        out.push(String::new());
161                        last_blank = true;
162                    }
163                    stack.pop();
164                }
165                TagEnd::BlockQuote(_) => {
166                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
167                    out.push(String::new());
168                    last_blank = true;
169                    stack.pop();
170                }
171                TagEnd::CodeBlock => {
172                    if !code_body.is_empty() {
173                        out.push(format!("```{}", code_kind));
174                        for line in code_body.trim().lines() {
175                            out.push(line.to_string());
176                        }
177                        out.push("```".to_string());
178                        out.push(String::new());
179                        last_blank = true;
180                    }
181                    in_code = false;
182                    code_kind.clear();
183                    code_body.clear();
184                }
185                TagEnd::List(_) => {
186                    out.push(String::new());
187                    last_blank = true;
188                }
189                TagEnd::Item => {
190                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
191                    stack.pop();
192                }
193                TagEnd::Link => {
194                    let t = link_text.trim().to_string();
195                    if in_link && !link_url.is_empty() && !t.is_empty() {
196                        links.push((link_url.clone(), t));
197                    }
198                    in_link = false;
199                    link_url.clear();
200                    link_text.clear();
201                }
202                TagEnd::Image => {
203                    let t = if link_text.trim().is_empty() {
204                        "image".to_string()
205                    } else {
206                        link_text.trim().to_string()
207                    };
208                    if in_link {
209                        out.push(format!("=> {} {}", link_url, t));
210                    }
211                    in_link = false;
212                    link_url.clear();
213                    link_text.clear();
214                }
215                _ => {}
216            },
217            Event::Text(text) => {
218                if in_code {
219                    code_body.push_str(&text);
220                } else if in_link {
221                    link_text.push_str(&text);
222                } else {
223                    buf.push_str(&text);
224                }
225            }
226            Event::Code(text) => {
227                if in_link {
228                    link_text.push_str(&text);
229                } else {
230                    buf.push_str(&text);
231                }
232            }
233            Event::SoftBreak | Event::HardBreak => {
234                if in_code {
235                    code_body.push('\n');
236                } else {
237                    buf.push(' ');
238                }
239            }
240            _ => {}
241        }
242    }
243
244    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
245
246    while out.last().is_some_and(|l| l.is_empty()) {
247        out.pop();
248    }
249
250    out.join("\n") + "\n"
251}