1use anyhow::Result;
2use async_trait::async_trait;
3use comrak::html::format_document_with_formatter;
4use comrak::nodes::Ast;
5use comrak::plugins::syntect::{SyntectAdapter, SyntectAdapterBuilder};
6use comrak::{Options, Plugins};
7use std::cell::RefCell;
8use std::io::BufWriter;
9use std::sync::Arc;
10
11
12/// Trait for rendering markdown content to HTML.
13#[async_trait]
14pub trait RenderManager: Send + Sync {
15 /// Render markdown content to HTML.
16 async fn render_markdown(&self, markdown: &str) -> Result<String>;
17}
18
19/// A markdown renderer using the Comrak library with syntax highlighting.
20pub struct ComrakRenderManager<'a> {
21 syntect_adapter: Arc<SyntectAdapter>,
22 options: Options<'a>,
23 external_base: String,
24}
25
26impl ComrakRenderManager<'static> {
27
28 #[cfg(test)]
29 /// Create a new Comrak render manager with the given external base URL.
30 pub fn new(external_base: &str) -> Self {
31 Self::with_theme("base16-ocean.dark", external_base)
32 }
33
34 /// Create a new Comrak render manager with the given theme and external base URL.
35 pub fn with_theme(theme: &str, external_base: &str) -> Self {
36 let syntect_adapter = Arc::new(
37 SyntectAdapterBuilder::new()
38 .theme(theme)
39 .build(),
40 );
41
42 let mut options = Options::default();
43 options.extension.strikethrough = true;
44 options.extension.table = true;
45 options.extension.autolink = true;
46 options.extension.tasklist = true;
47 options.extension.superscript = true;
48 options.extension.footnotes = true;
49 options.extension.description_lists = true;
50 options.render.unsafe_ = false;
51
52 Self {
53 syntect_adapter,
54 options,
55 external_base: external_base.to_string(),
56 }
57 }
58}
59
60fn prepend_image_base<'a>(
61 nl: &mut comrak::nodes::NodeLink,
62 context: &mut comrak::html::Context<String>,
63 _node: &'a comrak::arena_tree::Node<'a, RefCell<Ast>>,
64 entering: bool,
65) {
66 if !entering {
67 return;
68 }
69
70 if !nl.url.starts_with("https://") && !nl.url.starts_with("/") {
71 nl.url = format!("{}/content/{}", &context.user, nl.url)
72 }
73}
74
75fn formatter<'a>(
76 context: &mut comrak::html::Context<String>,
77 node: &'a comrak::nodes::AstNode<'a>,
78 entering: bool,
79) -> std::io::Result<comrak::html::ChildRendering> {
80 let mut borrow = node.data.borrow_mut();
81 if let comrak::nodes::NodeValue::Image(ref mut nl) = borrow.value {
82 prepend_image_base(nl, context, node, entering);
83 }
84 drop(borrow);
85 comrak::html::format_node_default(context, node, entering)
86}
87
88#[async_trait]
89impl RenderManager for ComrakRenderManager<'static> {
90 async fn render_markdown(&self, markdown: &str) -> Result<String> {
91 let adapter = self.syntect_adapter.as_ref();
92 let mut plugins = Plugins::default();
93 plugins.render.codefence_syntax_highlighter = Some(adapter);
94
95 let arena = comrak::Arena::new();
96 let root = comrak::parse_document(&arena, markdown, &self.options);
97 let mut bw = BufWriter::new(Vec::new());
98 format_document_with_formatter(
99 root,
100 &self.options,
101 &mut bw,
102 &plugins,
103 formatter,
104 self.external_base.clone(),
105 )
106 .unwrap();
107
108 Ok(String::from_utf8(bw.into_inner().unwrap()).unwrap())
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 #[tokio::test]
117 async fn test_render_markdown() {
118 let renderer = ComrakRenderManager::new("http://localhost:8080");
119
120 let markdown = "# Hello World\n\nThis is a **test**.\n\n```rust\nfn main() {\n println!(\"Hello, world!\");\n}\n```";
121
122 let html = renderer.render_markdown(markdown).await.unwrap();
123
124 assert!(html.contains("<h1>"));
125 assert!(html.contains("Hello World"));
126 assert!(html.contains("<strong>test</strong>"));
127 assert!(html.contains("main"));
128 }
129
130 #[tokio::test]
131 async fn test_render_with_extensions() {
132 let renderer = ComrakRenderManager::new("http://localhost:8080");
133
134 let markdown = "~~strikethrough~~ and https://example.com autolink";
135
136 let html = renderer.render_markdown(markdown).await.unwrap();
137
138 assert!(html.contains("<del>"));
139 assert!(html.contains("<a href=\"https://example.com\""));
140 }
141
142 #[tokio::test]
143 async fn test_syntax_highlighting() {
144 let renderer = ComrakRenderManager::new("http://localhost:8080");
145
146 let markdown = "```rust\nlet x = 42;\n```";
147
148 let html = renderer.render_markdown(markdown).await.unwrap();
149
150 assert!(html.contains("<pre"));
151 assert!(html.contains("style="));
152 }
153
154 #[tokio::test]
155 async fn test_image_url_prefixing() {
156 let renderer = ComrakRenderManager::new("https://example.com");
157
158 let markdown = "  ";
159
160 let html = renderer.render_markdown(markdown).await.unwrap();
161
162 assert!(html.contains("https://example.com/image.jpg"));
163 assert!(html.contains("https://other.com/image.jpg"));
164 assert!(html.contains("\"/root.jpg\""));
165 }
166}