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 = "";
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 = "";
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 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 let md = "~~strike~~";
401 let gmi = render_markdown_gemtext(md);
402 assert!(gmi.contains("strike"));
403 }
404}