Skip to main content

see_cat/
html.rs

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('&', "&amp;")
352        .replace('<', "&lt;")
353        .replace('>', "&gt;")
354}
355
356fn escape_html_attr(input: &str) -> String {
357    escape_html(input).replace('"', "&quot;")
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}