Skip to main content

socketry_markdown/renderer/
html.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4//! HTML rendering for mdast trees.
5use super::Renderer;
6use crate::{
7    mdast::{AlignKind, AttributeContent, AttributeValue, Heading, Node},
8    util::{encode::encode, sanitize_uri::sanitize_with_protocols},
9    CompileOptions,
10};
11use alloc::{
12    collections::BTreeMap,
13    fmt::Write as _,
14    format,
15    string::{String, ToString},
16    vec::Vec,
17};
18
19/// Render an mdast tree or fragment as HTML.
20///
21/// This renders AST nodes directly. It is separate from [`crate::to_html`],
22/// which renders Markdown source through the parser's event compiler. Raw HTML
23/// is escaped and JSX is omitted by default; URLs use the same safe protocol
24/// allowlists as the built-in HTML compiler. Pass `allow_dangerous_html` to
25/// render raw HTML and static JSX. MDX expressions and ESM are omitted because
26/// evaluating JavaScript is outside the renderer's scope.
27///
28/// # Example
29///
30/// ```ignore
31/// use socketry_markdown::{mdast::Headings, renderer::HTMLRenderer, to_mdast, ParseOptions};
32///
33/// let tree = to_mdast("# Hello *world*!", &ParseOptions::default())?;
34/// let mut renderer = HTMLRenderer::default();
35/// let html = tree.render_with(&mut renderer);
36/// # Ok::<(), socketry_markdown::message::Message>(())
37/// ```
38#[derive(Clone, Debug, Default)]
39#[allow(clippy::upper_case_acronyms)]
40pub struct HTMLRenderer {
41    options: CompileOptions,
42    heading_anchors: Vec<String>,
43    heading_index: usize,
44    references: BTreeMap<String, (String, Option<String>)>,
45    footnote_definitions: BTreeMap<String, Vec<Node>>,
46    footnote_numbers: BTreeMap<String, usize>,
47    footnote_calls: BTreeMap<String, usize>,
48    footnote_order: Vec<String>,
49    tight_list: bool,
50}
51
52impl HTMLRenderer {
53    /// Create a safe HTML renderer with default options.
54    #[must_use]
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Create an HTML renderer with compilation options.
60    #[must_use]
61    pub fn with_options(options: CompileOptions) -> Self {
62        Self {
63            options,
64            ..Self::default()
65        }
66    }
67
68    /// Return the options used by this renderer.
69    #[must_use]
70    pub fn options(&self) -> &CompileOptions {
71        &self.options
72    }
73
74    fn prepare(&mut self, root: &Node) {
75        self.heading_anchors.clear();
76        self.heading_index = 0;
77        self.references.clear();
78        self.footnote_definitions.clear();
79        self.footnote_numbers.clear();
80        self.footnote_calls.clear();
81        self.footnote_order.clear();
82        self.tight_list = false;
83
84        if self.options.heading_ids {
85            self.heading_anchors = crate::mdast::Headings::extract(root)
86                .iter()
87                .map(|heading| heading.anchor.clone())
88                .collect();
89        }
90
91        let mut references = BTreeMap::new();
92        let mut footnote_definitions = BTreeMap::new();
93        root.walk(|node| match node {
94            Node::Definition(definition) => {
95                references.insert(
96                    definition.identifier.clone(),
97                    (definition.url.clone(), definition.title.clone()),
98                );
99            }
100            Node::FootnoteDefinition(definition) => {
101                footnote_definitions
102                    .insert(definition.identifier.clone(), definition.children.clone());
103            }
104            _ => {}
105        });
106        self.references = references;
107        self.footnote_definitions = footnote_definitions;
108    }
109
110    fn render_node_inner(&mut self, node: &Node) -> String {
111        match node {
112            Node::Root(root) => self.render_root(&root.children),
113            Node::Fragment(fragment) => self.render_root(&fragment.children),
114            Node::Paragraph(paragraph) => {
115                let content = self.render_inline_children(&paragraph.children);
116                if content.is_empty() {
117                    String::new()
118                } else if self.tight_list {
119                    content
120                } else {
121                    format!("<p>{content}</p>")
122                }
123            }
124            Node::Heading(heading) => self.render_heading(heading),
125            Node::Blockquote(quote) => {
126                let children = self.render_flow_children(&quote.children);
127                let line_ending = self.line_ending();
128                format!(
129                    "<blockquote>{}</blockquote>",
130                    with_surrounding_line_endings(&children, &line_ending)
131                )
132            }
133            Node::List(list) => self.render_list(list),
134            Node::ListItem(item) => self.render_list_item(item),
135            Node::Code(code) => {
136                let class = code
137                    .lang
138                    .as_ref()
139                    .map(|lang| format!(" class=\"language-{}\"", encode(lang, true)))
140                    .unwrap_or_default();
141                format!(
142                    "<pre><code{}>{}</code></pre>",
143                    class,
144                    self.render_code_value(&code.value)
145                )
146            }
147            Node::Math(math) => format!(
148                "<pre><code class=\"language-math math-display\">{}</code></pre>",
149                self.render_code_value(&math.value)
150            ),
151            Node::ThematicBreak(_) => "<hr />".into(),
152            Node::Html(html) => self.render_html(&html.value),
153            Node::Break(_) => format!("<br />{}", self.line_ending()),
154            Node::InlineCode(code) => {
155                let class = code
156                    .lang
157                    .as_ref()
158                    .map(|lang| format!(" class=\"language-{}\"", encode(lang, true)))
159                    .unwrap_or_default();
160                format!("<code{}>{}</code>", class, encode(&code.value, true))
161            }
162            Node::InlineMath(math) => format!(
163                "<code class=\"language-math math-inline\">{}</code>",
164                encode(&math.value, true)
165            ),
166            Node::Emphasis(emphasis) => format!(
167                "<em>{}</em>",
168                self.render_inline_children(&emphasis.children)
169            ),
170            Node::Strong(strong) => format!(
171                "<strong>{}</strong>",
172                self.render_inline_children(&strong.children)
173            ),
174            Node::Delete(delete) => format!(
175                "<del>{}</del>",
176                self.render_inline_children(&delete.children)
177            ),
178            Node::Text(text) => encode(&text.value, true),
179            Node::Link(link) => {
180                let href = self.link_url(&link.url);
181                let title = render_title(link.title.as_deref());
182                format!(
183                    "<a href=\"{}\"{}>{}</a>",
184                    href,
185                    title,
186                    self.render_inline_children(&link.children)
187                )
188            }
189            Node::Image(image) => {
190                let src = self.image_url(&image.url);
191                let title = render_title(image.title.as_deref());
192                format!(
193                    "<img src=\"{}\" alt=\"{}\"{} />",
194                    src,
195                    encode(&image.alt, true),
196                    title
197                )
198            }
199            Node::LinkReference(reference) => self.render_link_reference(reference),
200            Node::ImageReference(reference) => self.render_image_reference(reference),
201            Node::Table(table) => self.render_table(table),
202            Node::TableRow(row) => self.render_table_row(&row.children, false, &[]),
203            Node::TableCell(cell) => {
204                format!("<td>{}</td>", self.render_inline_children(&cell.children))
205            }
206            Node::FootnoteReference(reference) => self.render_footnote_reference(reference),
207            Node::FootnoteDefinition(_)
208            | Node::Definition(_)
209            | Node::MdxTextExpression(_)
210            | Node::MdxFlowExpression(_)
211            | Node::MdxjsEsm(_)
212            | Node::Yaml(_)
213            | Node::Toml(_) => String::new(),
214            Node::MdxJsxFlowElement(element) => self.render_jsx(
215                element.name.as_deref(),
216                &element.attributes,
217                &element.children,
218                true,
219            ),
220            Node::MdxJsxTextElement(element) => self.render_jsx(
221                element.name.as_deref(),
222                &element.attributes,
223                &element.children,
224                false,
225            ),
226        }
227    }
228
229    fn render_root(&mut self, children: &[Node]) -> String {
230        let mut output = self.render_flow_children(children);
231        let footnotes = self.render_footnote_section();
232        let line_ending = self.line_ending();
233
234        if !footnotes.is_empty() {
235            if !output.is_empty() {
236                output.push_str(&line_ending);
237            }
238            output.push_str(&footnotes);
239        }
240
241        output
242    }
243
244    fn render_heading(&mut self, heading: &Heading) -> String {
245        let anchor = self.heading_anchors.get(self.heading_index).cloned();
246        self.heading_index += 1;
247        let id = anchor
248            .map(|value| format!(" id=\"{}\"", encode(&value, true)))
249            .unwrap_or_default();
250        let content = self.render_inline_children(&heading.children);
251        format!("<h{0}{1}>{2}</h{0}>", heading.depth, id, content)
252    }
253
254    fn render_list(&mut self, list: &crate::mdast::List) -> String {
255        let previous_tight = self.tight_list;
256        self.tight_list = !list.spread;
257
258        let mut items = Vec::new();
259        for child in &list.children {
260            let rendered = self.render_node_inner(child);
261            if !rendered.is_empty() {
262                items.push(rendered);
263            }
264        }
265
266        self.tight_list = previous_tight;
267        let line_ending = self.line_ending();
268        let content = items.join(&line_ending);
269
270        if list.ordered {
271            let start = list.start.unwrap_or(1);
272            let start_attribute = if start == 1 {
273                String::new()
274            } else {
275                format!(" start=\"{start}\"")
276            };
277            format!("<ol{start_attribute}>{line_ending}{content}{line_ending}</ol>")
278        } else {
279            format!("<ul>{line_ending}{content}{line_ending}</ul>")
280        }
281    }
282
283    fn render_list_item(&mut self, item: &crate::mdast::ListItem) -> String {
284        let mut checkbox = String::new();
285        if let Some(checked) = item.checked {
286            checkbox.push_str("<input type=\"checkbox\" ");
287            if checked {
288                checkbox.push_str("checked=\"\" ");
289            }
290            if !self.options.gfm_task_list_item_checkable {
291                checkbox.push_str("disabled=\"\" ");
292            }
293            checkbox.push_str("/> ");
294        }
295
296        let content = self.render_flow_children(&item.children);
297        format!("<li>{checkbox}{content}</li>")
298    }
299
300    fn render_table(&mut self, table: &crate::mdast::Table) -> String {
301        let mut rows = table.children.iter();
302        let mut output = String::from("<table>");
303        let line_ending = self.line_ending();
304
305        if let Some(Node::TableRow(row)) = rows.next() {
306            output.push_str(&line_ending);
307            output.push_str("<thead>");
308            output.push_str(&line_ending);
309            output.push_str(&self.render_table_row(&row.children, true, &table.align));
310            output.push_str(&line_ending);
311            output.push_str("</thead>");
312        }
313
314        let body = rows
315            .filter_map(|node| match node {
316                Node::TableRow(row) => Some(row),
317                _ => None,
318            })
319            .collect::<Vec<_>>();
320
321        if !body.is_empty() {
322            output.push_str(&line_ending);
323            output.push_str("<tbody>");
324            output.push_str(&line_ending);
325            for (index, row) in body.iter().enumerate() {
326                if index > 0 {
327                    output.push_str(&line_ending);
328                }
329                output.push_str(&self.render_table_row(&row.children, false, &table.align));
330            }
331            output.push_str(&line_ending);
332            output.push_str("</tbody>");
333        }
334
335        output.push_str(&line_ending);
336        output.push_str("</table>");
337        output
338    }
339
340    fn render_table_row(
341        &mut self,
342        cells: &[Node],
343        header: bool,
344        alignments: &[AlignKind],
345    ) -> String {
346        let mut output = String::from("<tr>");
347        for (index, cell) in cells.iter().enumerate() {
348            let tag = if header { "th" } else { "td" };
349            let alignment = match alignments.get(index) {
350                Some(AlignKind::Left) => " align=\"left\"",
351                Some(AlignKind::Right) => " align=\"right\"",
352                Some(AlignKind::Center) => " align=\"center\"",
353                Some(AlignKind::None) | None => "",
354            };
355            output.push('<');
356            output.push_str(tag);
357            output.push_str(alignment);
358            output.push('>');
359            match cell {
360                Node::TableCell(cell) => {
361                    output.push_str(&self.render_inline_children(&cell.children));
362                }
363                _ => output.push_str(&self.render_node_inner(cell)),
364            }
365            output.push_str("</");
366            output.push_str(tag);
367            output.push('>');
368        }
369        output.push_str("</tr>");
370        output
371    }
372
373    fn render_link_reference(&mut self, reference: &crate::mdast::LinkReference) -> String {
374        if let Some((url, title)) = self.references.get(&reference.identifier).cloned() {
375            let href = self.link_url(&url);
376            format!(
377                "<a href=\"{}\"{}>{}</a>",
378                href,
379                render_title(title.as_deref()),
380                self.render_inline_children(&reference.children)
381            )
382        } else {
383            self.render_inline_children(&reference.children)
384        }
385    }
386
387    fn render_image_reference(&mut self, reference: &crate::mdast::ImageReference) -> String {
388        if let Some((url, title)) = self.references.get(&reference.identifier).cloned() {
389            format!(
390                "<img src=\"{}\" alt=\"{}\"{} />",
391                self.image_url(&url),
392                encode(&reference.alt, true),
393                render_title(title.as_deref())
394            )
395        } else {
396            encode(&reference.alt, true)
397        }
398    }
399
400    fn render_footnote_reference(&mut self, reference: &crate::mdast::FootnoteReference) -> String {
401        if !self
402            .footnote_definitions
403            .contains_key(&reference.identifier)
404        {
405            return String::new();
406        }
407
408        let number = if let Some(number) = self.footnote_numbers.get(&reference.identifier) {
409            *number
410        } else {
411            let number = self.footnote_order.len() + 1;
412            self.footnote_order.push(reference.identifier.clone());
413            self.footnote_numbers
414                .insert(reference.identifier.clone(), number);
415            number
416        };
417
418        let call = self
419            .footnote_calls
420            .entry(reference.identifier.clone())
421            .or_insert(0);
422        *call += 1;
423
424        let prefix = self
425            .options
426            .gfm_footnote_clobber_prefix
427            .as_deref()
428            .unwrap_or("user-content-");
429        let id = crate::util::sanitize_uri::sanitize(&reference.identifier.to_lowercase());
430        let suffix = if *call == 1 {
431            String::new()
432        } else {
433            format!("-{call}")
434        };
435
436        format!(
437            "<sup><a href=\"#{}fn-{}\" id=\"{}fnref-{}{}\" data-footnote-ref=\"\" aria-describedby=\"footnote-label\">{}</a></sup>",
438            encode(prefix, true),
439            id,
440            encode(prefix, true),
441            id,
442            suffix,
443            number
444        )
445    }
446
447    fn render_footnote_section(&mut self) -> String {
448        if self.footnote_order.is_empty() {
449            return String::new();
450        }
451
452        let label_tag = self
453            .options
454            .gfm_footnote_label_tag_name
455            .as_deref()
456            .unwrap_or("h2");
457        let label_attributes = self
458            .options
459            .gfm_footnote_label_attributes
460            .as_deref()
461            .unwrap_or("class=\"sr-only\"");
462        let label = self
463            .options
464            .gfm_footnote_label
465            .as_deref()
466            .unwrap_or("Footnotes");
467        let line_ending = self.line_ending();
468        let mut output = format!(
469            "<section data-footnotes=\"\" class=\"footnotes\"><{0} id=\"footnote-label\" {1}>{2}</{0}>",
470            encode(label_tag, true),
471            label_attributes,
472            encode(label, true),
473        );
474        output.push_str(&line_ending);
475        output.push_str("<ol>");
476        output.push_str(&line_ending);
477        let mut has_item = false;
478
479        let mut index = 0;
480        while index < self.footnote_order.len() {
481            let identifier = self.footnote_order[index].clone();
482            index += 1;
483            let Some(children) = self.footnote_definitions.get(&identifier).cloned() else {
484                continue;
485            };
486
487            let prefix = self
488                .options
489                .gfm_footnote_clobber_prefix
490                .clone()
491                .unwrap_or_else(|| "user-content-".into());
492            let id = crate::util::sanitize_uri::sanitize(&identifier.to_lowercase());
493            let mut content = self.render_flow_children(&children);
494            let count = self.footnote_calls.get(&identifier).copied().unwrap_or(1);
495            let back_label = self
496                .options
497                .gfm_footnote_back_label
498                .as_deref()
499                .unwrap_or("Back to content");
500            let mut backrefs = String::new();
501
502            for call in 1..=count {
503                if call > 1 {
504                    backrefs.push(' ');
505                }
506                let suffix = if call == 1 {
507                    String::new()
508                } else {
509                    format!("-{call}")
510                };
511                let _ = write!(
512                    backrefs,
513                    "<a href=\"#{}fnref-{}{}\" data-footnote-backref=\"\" aria-label=\"{}\" class=\"data-footnote-backref\">↩",
514                    encode(&prefix, true),
515                    id,
516                    suffix,
517                    encode(back_label, true)
518                );
519                if call > 1 {
520                    let _ = write!(backrefs, "<sup>{call}</sup>");
521                }
522                backrefs.push_str("</a>");
523            }
524
525            if let Some(index) = content.rfind("</p>") {
526                content.insert_str(index, &format!(" {backrefs}"));
527            } else {
528                content.push(' ');
529                content.push_str(&backrefs);
530            }
531
532            if has_item {
533                output.push_str(&line_ending);
534            }
535            let _ = write!(output, "<li id=\"{}fn-{}\">", encode(&prefix, true), id);
536            output.push_str(&line_ending);
537            output.push_str(&content);
538            output.push_str("</li>");
539            has_item = true;
540        }
541
542        output.push_str(&line_ending);
543        output.push_str("</ol>");
544        output.push_str(&line_ending);
545        output.push_str("</section>");
546        output
547    }
548
549    fn render_jsx(
550        &mut self,
551        name: Option<&str>,
552        attributes: &[AttributeContent],
553        children: &[Node],
554        flow: bool,
555    ) -> String {
556        // JSX is an HTML-like construct. Keep it disabled under the same
557        // trust option as raw HTML.
558        if !self.options.allow_dangerous_html {
559            return String::new();
560        }
561
562        let content = if flow {
563            self.render_flow_children(children)
564        } else {
565            self.render_inline_children(children)
566        };
567
568        let Some(name) = name else {
569            return content;
570        };
571
572        let mut opening = format!("<{}", encode(name, true));
573        for attribute in attributes {
574            let attribute = match attribute {
575                AttributeContent::Property(attribute) => attribute,
576                AttributeContent::Expression(_) => continue,
577            };
578            match &attribute.value {
579                Some(AttributeValue::Literal(value)) => {
580                    opening.push(' ');
581                    opening.push_str(&encode(&attribute.name, true));
582                    opening.push_str("=\"");
583                    let value = if attribute.name.eq_ignore_ascii_case("href") {
584                        self.link_url(value)
585                    } else if attribute.name.eq_ignore_ascii_case("src") {
586                        self.image_url(value)
587                    } else {
588                        encode(value, true)
589                    };
590                    opening.push_str(&value);
591                    opening.push('"');
592                }
593                Some(AttributeValue::Expression(_)) => {}
594                None => {
595                    opening.push(' ');
596                    opening.push_str(&encode(&attribute.name, true));
597                }
598            }
599        }
600
601        let output = if children.is_empty() {
602            format!("{opening}/>")
603        } else {
604            format!("{}>{}</{}>", opening, content, encode(name, true))
605        };
606
607        self.render_html(&output)
608    }
609
610    fn render_html(&self, value: &str) -> String {
611        if !self.options.allow_dangerous_html {
612            return encode(value, true);
613        }
614        if self.options.gfm_tagfilter {
615            crate::util::gfm_tagfilter::gfm_tagfilter(value)
616        } else {
617            value.to_string()
618        }
619    }
620
621    fn render_code_value(&self, value: &str) -> String {
622        let mut output = encode(value, true);
623        if !value.is_empty() && !value.ends_with('\n') && !value.ends_with('\r') {
624            output.push_str(&self.line_ending());
625        }
626        output
627    }
628
629    fn line_ending(&self) -> String {
630        self.options.default_line_ending.as_str().to_string()
631    }
632
633    fn link_url(&self, value: &str) -> String {
634        if self.options.allow_dangerous_protocol {
635            crate::util::sanitize_uri::sanitize(value)
636        } else {
637            sanitize_with_protocols(value, &["http", "https", "irc", "ircs", "mailto", "xmpp"])
638        }
639    }
640
641    fn image_url(&self, value: &str) -> String {
642        if self.options.allow_any_img_src || self.options.allow_dangerous_protocol {
643            crate::util::sanitize_uri::sanitize(value)
644        } else {
645            sanitize_with_protocols(value, &["http", "https"])
646        }
647    }
648
649    fn render_inline_children(&mut self, children: &[Node]) -> String {
650        let mut output = String::new();
651        for child in children {
652            output.push_str(&self.render_node_inner(child));
653        }
654        output
655    }
656
657    fn render_flow_children(&mut self, children: &[Node]) -> String {
658        let mut output = String::new();
659        let mut count = 0;
660        let line_ending = self.line_ending();
661        for child in children {
662            let rendered = self.render_node_inner(child);
663            if rendered.is_empty() {
664                continue;
665            }
666            if count > 0 {
667                output.push_str(&line_ending);
668            }
669            output.push_str(&rendered);
670            count += 1;
671        }
672        output
673    }
674}
675
676impl Renderer for HTMLRenderer {
677    fn render(&mut self, node: &Node) -> String {
678        self.prepare(node);
679        self.render_node_inner(node)
680    }
681
682    fn render_node(&mut self, node: &Node) -> String {
683        self.render_node_inner(node)
684    }
685
686    fn render_children(&mut self, node: &Node) -> String {
687        match node {
688            Node::Root(_)
689            | Node::Fragment(_)
690            | Node::Blockquote(_)
691            | Node::List(_)
692            | Node::ListItem(_)
693            | Node::Table(_)
694            | Node::FootnoteDefinition(_)
695            | Node::MdxJsxFlowElement(_) => node
696                .children()
697                .map(|children| self.render_flow_children(children))
698                .unwrap_or_default(),
699            _ => node
700                .children()
701                .map(|children| self.render_inline_children(children))
702                .unwrap_or_default(),
703        }
704    }
705}
706
707fn render_title(title: Option<&str>) -> String {
708    match title {
709        Some(title) => format!(" title=\"{}\"", encode(title, true)),
710        None => String::new(),
711    }
712}
713
714fn with_surrounding_line_endings(value: &str, line_ending: &str) -> String {
715    if value.is_empty() {
716        String::new()
717    } else {
718        format!("{line_ending}{value}{line_ending}")
719    }
720}