1use crate::app::parse_and_process_markdown_with_config;
2use crate::config::AppConfig;
3use crate::utils::{detect_language_with_extensions, highlight_code_html};
4use serde_json::Value;
5use std::collections::HashMap;
6use std::io;
7use std::path::Path;
8
9#[derive(Debug, Clone)]
10pub struct HtmlRenderOptions {
11 pub render_links: bool,
12 pub convert_html: bool,
13 pub syntax_theme: String,
14 pub syntax_extensions: HashMap<String, String>,
15}
16
17impl Default for HtmlRenderOptions {
18 fn default() -> Self {
19 Self {
20 render_links: true,
21 convert_html: true,
22 syntax_theme: "github_light".to_string(),
23 syntax_extensions: HashMap::new(),
24 }
25 }
26}
27
28#[derive(Default)]
29struct HtmlContext {
30 link_definitions: HashMap<String, (String, Option<String>)>,
31 footnotes: HashMap<String, String>,
32}
33
34impl HtmlContext {
35 fn collect(&mut self, node: &Value) {
36 match node["type"].as_str() {
37 Some("definition") => {
38 let identifier = node["identifier"].as_str().unwrap_or("").to_string();
39 let url = node["url"].as_str().unwrap_or("").to_string();
40 let title = node["title"].as_str().map(str::to_string);
41 self.link_definitions.insert(identifier, (url, title));
42 }
43 Some("footnoteDefinition") => {
44 let identifier = node["identifier"].as_str().unwrap_or("").to_string();
45 self.footnotes.insert(identifier, node_text(node));
46 }
47 _ => {}
48 }
49
50 if let Some(children) = node["children"].as_array() {
51 for child in children {
52 self.collect(child);
53 }
54 }
55 }
56}
57
58pub fn render_markdown_to_html(content: &str, options: &HtmlRenderOptions) -> io::Result<String> {
59 let config = AppConfig {
60 render_links: options.render_links,
61 convert_html: options.convert_html,
62 syntax_theme: options.syntax_theme.clone(),
63 syntax_extensions: options.syntax_extensions.clone(),
64 ..AppConfig::default()
65 };
66 let ast = parse_and_process_markdown_with_config(content, &config, false)?;
67
68 let mut context = HtmlContext::default();
69 context.collect(&ast);
70
71 let mut html = String::new();
72 render_node(&ast, options, &context, &mut html)?;
73
74 if !context.footnotes.is_empty() {
75 html.push_str("<section class=\"see-footnotes\"><h2>Footnotes</h2><ol>");
76 let mut entries: Vec<_> = context.footnotes.iter().collect();
77 entries.sort_by(|a, b| a.0.cmp(b.0));
78 for (identifier, content) in entries {
79 html.push_str("<li id=\"fn-");
80 html.push_str(&escape_html_attr(identifier));
81 html.push_str("\">");
82 html.push_str(&escape_html(content));
83 html.push_str("</li>");
84 }
85 html.push_str("</ol></section>");
86 }
87
88 Ok(html)
89}
90
91pub fn render_code_to_html(
92 content: &str,
93 language: Option<&str>,
94 options: &HtmlRenderOptions,
95) -> io::Result<String> {
96 let language = language.unwrap_or("txt");
97 let highlighted = highlight_code_html(content, language, &options.syntax_theme)
98 .unwrap_or_else(|_| escape_html(content));
99
100 Ok(format!(
101 "<pre class=\"see-code-block\"><code class=\"language-{}\">{}</code></pre>",
102 escape_html_attr(language),
103 highlighted
104 ))
105}
106
107pub fn render_file_to_html(path: impl AsRef<Path>, options: &HtmlRenderOptions) -> io::Result<String> {
108 let path = path.as_ref();
109
110 match path
111 .extension()
112 .and_then(|ext| ext.to_str())
113 .unwrap_or("")
114 .to_ascii_lowercase()
115 .as_str()
116 {
117 "md" => render_markdown_to_html(&std::fs::read_to_string(path)?, options),
118 "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" => Ok(format!(
119 "<img src=\"{}\" alt=\"{}\" />",
120 escape_html_attr(&path.to_string_lossy()),
121 escape_html_attr(
122 &path.file_name()
123 .and_then(|name| name.to_str())
124 .unwrap_or_default()
125 )
126 )),
127 _ => {
128 let content = std::fs::read_to_string(path)?;
129 let language = detect_language_with_extensions(
130 &path.to_string_lossy(),
131 &options.syntax_extensions,
132 );
133 render_code_to_html(&content, Some(&language), options)
134 }
135 }
136}
137
138fn render_node(
139 node: &Value,
140 options: &HtmlRenderOptions,
141 context: &HtmlContext,
142 out: &mut String,
143) -> io::Result<()> {
144 match node["type"].as_str() {
145 Some("root") => render_children(node, options, context, out)?,
146 Some("heading") => {
147 let level = node["depth"].as_u64().unwrap_or(1).clamp(1, 6);
148 out.push_str(&format!("<h{level}>"));
149 render_children(node, options, context, out)?;
150 out.push_str(&format!("</h{level}>"));
151 }
152 Some("paragraph") => {
153 out.push_str("<p>");
154 render_children(node, options, context, out)?;
155 out.push_str("</p>");
156 }
157 Some("text") => out.push_str(&escape_html(node["value"].as_str().unwrap_or(""))),
158 Some("emphasis") => wrap_tag("em", node, options, context, out)?,
159 Some("strong") => wrap_tag("strong", node, options, context, out)?,
160 Some("delete") => wrap_tag("del", node, options, context, out)?,
161 Some("inlineCode") => {
162 out.push_str("<code>");
163 out.push_str(&escape_html(node["value"].as_str().unwrap_or("")));
164 out.push_str("</code>");
165 }
166 Some("code") => {
167 let language = node["lang"].as_str().unwrap_or("txt");
168 out.push_str(&render_code_to_html(
169 node["value"].as_str().unwrap_or(""),
170 Some(language),
171 options,
172 )?);
173 }
174 Some("blockquote") => {
175 out.push_str("<blockquote>");
176 render_children(node, options, context, out)?;
177 out.push_str("</blockquote>");
178 }
179 Some("list") => {
180 let tag = if node["ordered"].as_bool().unwrap_or(false) {
181 "ol"
182 } else {
183 "ul"
184 };
185 out.push('<');
186 out.push_str(tag);
187 out.push('>');
188 render_children(node, options, context, out)?;
189 out.push_str("</");
190 out.push_str(tag);
191 out.push('>');
192 }
193 Some("listItem") => wrap_tag("li", node, options, context, out)?,
194 Some("table") => render_table(node, options, context, out)?,
195 Some("link") => render_link(node, options, context, out)?,
196 Some("linkReference") => render_link_reference(node, options, context, out)?,
197 Some("image") => {
198 out.push_str("<img src=\"");
199 out.push_str(&escape_html_attr(node["url"].as_str().unwrap_or("")));
200 out.push_str("\" alt=\"");
201 out.push_str(&escape_html_attr(node["alt"].as_str().unwrap_or("")));
202 out.push_str("\" />");
203 }
204 Some("imageReference") => {
205 out.push_str("<span class=\"see-image-reference\">![");
206 out.push_str(&escape_html(node["alt"].as_str().unwrap_or("")));
207 out.push_str("]</span>");
208 }
209 Some("footnoteReference") => {
210 let identifier = node["identifier"].as_str().unwrap_or("");
211 out.push_str("<sup><a href=\"#fn-");
212 out.push_str(&escape_html_attr(identifier));
213 out.push_str("\">");
214 out.push_str(&escape_html(identifier));
215 out.push_str("</a></sup>");
216 }
217 Some("html") => {
218 let raw = node["value"].as_str().unwrap_or("");
219 if options.convert_html {
220 out.push_str(raw);
221 } else {
222 out.push_str(&escape_html(raw));
223 }
224 }
225 Some("thematicBreak") => out.push_str("<hr />"),
226 Some("definition") | Some("footnoteDefinition") => {}
227 _ => render_children(node, options, context, out)?,
228 }
229
230 Ok(())
231}
232
233fn render_children(
234 node: &Value,
235 options: &HtmlRenderOptions,
236 context: &HtmlContext,
237 out: &mut String,
238) -> io::Result<()> {
239 if let Some(children) = node["children"].as_array() {
240 for child in children {
241 render_node(child, options, context, out)?;
242 }
243 }
244 Ok(())
245}
246
247fn wrap_tag(
248 tag: &str,
249 node: &Value,
250 options: &HtmlRenderOptions,
251 context: &HtmlContext,
252 out: &mut String,
253) -> io::Result<()> {
254 out.push('<');
255 out.push_str(tag);
256 out.push('>');
257 render_children(node, options, context, out)?;
258 out.push_str("</");
259 out.push_str(tag);
260 out.push('>');
261 Ok(())
262}
263
264fn render_link(
265 node: &Value,
266 options: &HtmlRenderOptions,
267 context: &HtmlContext,
268 out: &mut String,
269) -> io::Result<()> {
270 if !options.render_links {
271 return render_children(node, options, context, out);
272 }
273
274 out.push_str("<a href=\"");
275 out.push_str(&escape_html_attr(node["url"].as_str().unwrap_or("")));
276 out.push('"');
277 if let Some(title) = node["title"].as_str() {
278 out.push_str(" title=\"");
279 out.push_str(&escape_html_attr(title));
280 out.push('"');
281 }
282 out.push('>');
283 render_children(node, options, context, out)?;
284 out.push_str("</a>");
285 Ok(())
286}
287
288fn render_link_reference(
289 node: &Value,
290 options: &HtmlRenderOptions,
291 context: &HtmlContext,
292 out: &mut String,
293) -> io::Result<()> {
294 let identifier = node["identifier"].as_str().unwrap_or("");
295 if let Some((url, title)) = context.link_definitions.get(identifier) {
296 let mut link = serde_json::json!({
297 "type": "link",
298 "url": url,
299 "children": node["children"].clone(),
300 });
301 if let Some(title) = title {
302 link["title"] = Value::String(title.clone());
303 }
304 render_link(&link, options, context, out)
305 } else {
306 render_children(node, options, context, out)
307 }
308}
309
310fn render_table(
311 node: &Value,
312 options: &HtmlRenderOptions,
313 context: &HtmlContext,
314 out: &mut String,
315) -> io::Result<()> {
316 out.push_str("<table>");
317 if let Some(rows) = node["children"].as_array() {
318 for (row_index, row) in rows.iter().enumerate() {
319 out.push_str("<tr>");
320 if let Some(cells) = row["children"].as_array() {
321 for cell in cells {
322 let tag = if row_index == 0 { "th" } else { "td" };
323 out.push('<');
324 out.push_str(tag);
325 out.push('>');
326 render_children(cell, options, context, out)?;
327 out.push_str("</");
328 out.push_str(tag);
329 out.push('>');
330 }
331 }
332 out.push_str("</tr>");
333 }
334 }
335 out.push_str("</table>");
336 Ok(())
337}
338
339fn node_text(node: &Value) -> String {
340 match node["type"].as_str() {
341 Some("text") => node["value"].as_str().unwrap_or("").to_string(),
342 _ => node["children"]
343 .as_array()
344 .map(|children| children.iter().map(node_text).collect::<Vec<_>>().join(""))
345 .unwrap_or_default(),
346 }
347}
348
349fn escape_html(input: &str) -> String {
350 input
351 .replace('&', "&")
352 .replace('<', "<")
353 .replace('>', ">")
354}
355
356fn escape_html_attr(input: &str) -> String {
357 escape_html(input).replace('"', """)
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn renders_markdown_heading_without_terminal_suffix() {
366 let html = render_markdown_to_html("# Title", &HtmlRenderOptions::default()).unwrap();
367 assert_eq!(html, "<h1>Title</h1>");
368 }
369}