Skip to main content

socketry_markdown/renderer/
html_renderer.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::Frontmatter(_)
214            | Node::Toml(_) => String::new(),
215            Node::MdxJsxFlowElement(element) => self.render_jsx(
216                element.name.as_deref(),
217                &element.attributes,
218                &element.children,
219                true,
220            ),
221            Node::MdxJsxTextElement(element) => self.render_jsx(
222                element.name.as_deref(),
223                &element.attributes,
224                &element.children,
225                false,
226            ),
227        }
228    }
229
230    fn render_root(&mut self, children: &[Node]) -> String {
231        let mut output = self.render_flow_children(children);
232        let footnotes = self.render_footnote_section();
233        let line_ending = self.line_ending();
234
235        if !footnotes.is_empty() {
236            if !output.is_empty() {
237                output.push_str(&line_ending);
238            }
239            output.push_str(&footnotes);
240        }
241
242        output
243    }
244
245    fn render_heading(&mut self, heading: &Heading) -> String {
246        let anchor = self.heading_anchors.get(self.heading_index).cloned();
247        self.heading_index += 1;
248        let id = anchor
249            .map(|value| format!(" id=\"{}\"", encode(&value, true)))
250            .unwrap_or_default();
251        let content = self.render_inline_children(&heading.children);
252        format!("<h{0}{1}>{2}</h{0}>", heading.depth, id, content)
253    }
254
255    fn render_list(&mut self, list: &crate::mdast::List) -> String {
256        let previous_tight = self.tight_list;
257        self.tight_list = !list.spread;
258
259        let mut items = Vec::new();
260        for child in &list.children {
261            let rendered = self.render_node_inner(child);
262            if !rendered.is_empty() {
263                items.push(rendered);
264            }
265        }
266
267        self.tight_list = previous_tight;
268        let line_ending = self.line_ending();
269        let content = items.join(&line_ending);
270
271        if list.ordered {
272            let start = list.start.unwrap_or(1);
273            let start_attribute = if start == 1 {
274                String::new()
275            } else {
276                format!(" start=\"{start}\"")
277            };
278            format!("<ol{start_attribute}>{line_ending}{content}{line_ending}</ol>")
279        } else {
280            format!("<ul>{line_ending}{content}{line_ending}</ul>")
281        }
282    }
283
284    fn render_list_item(&mut self, item: &crate::mdast::ListItem) -> String {
285        let mut checkbox = String::new();
286        if let Some(checked) = item.checked {
287            checkbox.push_str("<input type=\"checkbox\" ");
288            if checked {
289                checkbox.push_str("checked=\"\" ");
290            }
291            if !self.options.gfm_task_list_item_checkable {
292                checkbox.push_str("disabled=\"\" ");
293            }
294            checkbox.push_str("/> ");
295        }
296
297        let content = self.render_flow_children(&item.children);
298        format!("<li>{checkbox}{content}</li>")
299    }
300
301    fn render_table(&mut self, table: &crate::mdast::Table) -> String {
302        let mut rows = table.children.iter();
303        let mut output = String::from("<table>");
304        let line_ending = self.line_ending();
305
306        if let Some(Node::TableRow(row)) = rows.next() {
307            output.push_str(&line_ending);
308            output.push_str("<thead>");
309            output.push_str(&line_ending);
310            output.push_str(&self.render_table_row(&row.children, true, &table.align));
311            output.push_str(&line_ending);
312            output.push_str("</thead>");
313        }
314
315        let body = rows
316            .filter_map(|node| match node {
317                Node::TableRow(row) => Some(row),
318                _ => None,
319            })
320            .collect::<Vec<_>>();
321
322        if !body.is_empty() {
323            output.push_str(&line_ending);
324            output.push_str("<tbody>");
325            output.push_str(&line_ending);
326            for (index, row) in body.iter().enumerate() {
327                if index > 0 {
328                    output.push_str(&line_ending);
329                }
330                output.push_str(&self.render_table_row(&row.children, false, &table.align));
331            }
332            output.push_str(&line_ending);
333            output.push_str("</tbody>");
334        }
335
336        output.push_str(&line_ending);
337        output.push_str("</table>");
338        output
339    }
340
341    fn render_table_row(
342        &mut self,
343        cells: &[Node],
344        header: bool,
345        alignments: &[AlignKind],
346    ) -> String {
347        let mut output = String::from("<tr>");
348        for (index, cell) in cells.iter().enumerate() {
349            let tag = if header { "th" } else { "td" };
350            let alignment = match alignments.get(index) {
351                Some(AlignKind::Left) => " align=\"left\"",
352                Some(AlignKind::Right) => " align=\"right\"",
353                Some(AlignKind::Center) => " align=\"center\"",
354                Some(AlignKind::None) | None => "",
355            };
356            output.push('<');
357            output.push_str(tag);
358            output.push_str(alignment);
359            output.push('>');
360            match cell {
361                Node::TableCell(cell) => {
362                    output.push_str(&self.render_inline_children(&cell.children));
363                }
364                _ => output.push_str(&self.render_node_inner(cell)),
365            }
366            output.push_str("</");
367            output.push_str(tag);
368            output.push('>');
369        }
370        output.push_str("</tr>");
371        output
372    }
373
374    fn render_link_reference(&mut self, reference: &crate::mdast::LinkReference) -> String {
375        if let Some((url, title)) = self.references.get(&reference.identifier).cloned() {
376            let href = self.link_url(&url);
377            format!(
378                "<a href=\"{}\"{}>{}</a>",
379                href,
380                render_title(title.as_deref()),
381                self.render_inline_children(&reference.children)
382            )
383        } else {
384            self.render_inline_children(&reference.children)
385        }
386    }
387
388    fn render_image_reference(&mut self, reference: &crate::mdast::ImageReference) -> String {
389        if let Some((url, title)) = self.references.get(&reference.identifier).cloned() {
390            format!(
391                "<img src=\"{}\" alt=\"{}\"{} />",
392                self.image_url(&url),
393                encode(&reference.alt, true),
394                render_title(title.as_deref())
395            )
396        } else {
397            encode(&reference.alt, true)
398        }
399    }
400
401    fn render_footnote_reference(&mut self, reference: &crate::mdast::FootnoteReference) -> String {
402        if !self
403            .footnote_definitions
404            .contains_key(&reference.identifier)
405        {
406            return String::new();
407        }
408
409        let number = if let Some(number) = self.footnote_numbers.get(&reference.identifier) {
410            *number
411        } else {
412            let number = self.footnote_order.len() + 1;
413            self.footnote_order.push(reference.identifier.clone());
414            self.footnote_numbers
415                .insert(reference.identifier.clone(), number);
416            number
417        };
418
419        let call = self
420            .footnote_calls
421            .entry(reference.identifier.clone())
422            .or_insert(0);
423        *call += 1;
424
425        let prefix = self
426            .options
427            .gfm_footnote_clobber_prefix
428            .as_deref()
429            .unwrap_or("user-content-");
430        let id = crate::util::sanitize_uri::sanitize(&reference.identifier.to_lowercase());
431        let suffix = if *call == 1 {
432            String::new()
433        } else {
434            format!("-{call}")
435        };
436
437        format!(
438            "<sup><a href=\"#{}fn-{}\" id=\"{}fnref-{}{}\" data-footnote-ref=\"\" aria-describedby=\"footnote-label\">{}</a></sup>",
439            encode(prefix, true),
440            id,
441            encode(prefix, true),
442            id,
443            suffix,
444            number
445        )
446    }
447
448    fn render_footnote_section(&mut self) -> String {
449        if self.footnote_order.is_empty() {
450            return String::new();
451        }
452
453        let label_tag = self
454            .options
455            .gfm_footnote_label_tag_name
456            .as_deref()
457            .unwrap_or("h2");
458        let label_attributes = self
459            .options
460            .gfm_footnote_label_attributes
461            .as_deref()
462            .unwrap_or("class=\"sr-only\"");
463        let label = self
464            .options
465            .gfm_footnote_label
466            .as_deref()
467            .unwrap_or("Footnotes");
468        let line_ending = self.line_ending();
469        let mut output = format!(
470            "<section data-footnotes=\"\" class=\"footnotes\"><{0} id=\"footnote-label\" {1}>{2}</{0}>",
471            encode(label_tag, true),
472            label_attributes,
473            encode(label, true),
474        );
475        output.push_str(&line_ending);
476        output.push_str("<ol>");
477        output.push_str(&line_ending);
478        let mut has_item = false;
479
480        let mut index = 0;
481        while index < self.footnote_order.len() {
482            let identifier = self.footnote_order[index].clone();
483            index += 1;
484            let Some(children) = self.footnote_definitions.get(&identifier).cloned() else {
485                continue;
486            };
487
488            let prefix = self
489                .options
490                .gfm_footnote_clobber_prefix
491                .clone()
492                .unwrap_or_else(|| "user-content-".into());
493            let id = crate::util::sanitize_uri::sanitize(&identifier.to_lowercase());
494            let mut content = self.render_flow_children(&children);
495            let count = self.footnote_calls.get(&identifier).copied().unwrap_or(1);
496            let back_label = self
497                .options
498                .gfm_footnote_back_label
499                .as_deref()
500                .unwrap_or("Back to content");
501            let mut backrefs = String::new();
502
503            for call in 1..=count {
504                if call > 1 {
505                    backrefs.push(' ');
506                }
507                let suffix = if call == 1 {
508                    String::new()
509                } else {
510                    format!("-{call}")
511                };
512                let _ = write!(
513                    backrefs,
514                    "<a href=\"#{}fnref-{}{}\" data-footnote-backref=\"\" aria-label=\"{}\" class=\"data-footnote-backref\">↩",
515                    encode(&prefix, true),
516                    id,
517                    suffix,
518                    encode(back_label, true)
519                );
520                if call > 1 {
521                    let _ = write!(backrefs, "<sup>{call}</sup>");
522                }
523                backrefs.push_str("</a>");
524            }
525
526            if let Some(index) = content.rfind("</p>") {
527                content.insert_str(index, &format!(" {backrefs}"));
528            } else {
529                content.push(' ');
530                content.push_str(&backrefs);
531            }
532
533            if has_item {
534                output.push_str(&line_ending);
535            }
536            let _ = write!(output, "<li id=\"{}fn-{}\">", encode(&prefix, true), id);
537            output.push_str(&line_ending);
538            output.push_str(&content);
539            output.push_str("</li>");
540            has_item = true;
541        }
542
543        output.push_str(&line_ending);
544        output.push_str("</ol>");
545        output.push_str(&line_ending);
546        output.push_str("</section>");
547        output
548    }
549
550    fn render_jsx(
551        &mut self,
552        name: Option<&str>,
553        attributes: &[AttributeContent],
554        children: &[Node],
555        flow: bool,
556    ) -> String {
557        // JSX is an HTML-like construct. Keep it disabled under the same
558        // trust option as raw HTML.
559        if !self.options.allow_dangerous_html {
560            return String::new();
561        }
562
563        let content = if flow {
564            self.render_flow_children(children)
565        } else {
566            self.render_inline_children(children)
567        };
568
569        let Some(name) = name else {
570            return content;
571        };
572
573        let mut opening = format!("<{}", encode(name, true));
574        for attribute in attributes {
575            let attribute = match attribute {
576                AttributeContent::Property(attribute) => attribute,
577                AttributeContent::Expression(_) => continue,
578            };
579            match &attribute.value {
580                Some(AttributeValue::Literal(value)) => {
581                    opening.push(' ');
582                    opening.push_str(&encode(&attribute.name, true));
583                    opening.push_str("=\"");
584                    let value = if attribute.name.eq_ignore_ascii_case("href") {
585                        self.link_url(value)
586                    } else if attribute.name.eq_ignore_ascii_case("src") {
587                        self.image_url(value)
588                    } else {
589                        encode(value, true)
590                    };
591                    opening.push_str(&value);
592                    opening.push('"');
593                }
594                Some(AttributeValue::Expression(_)) => {}
595                None => {
596                    opening.push(' ');
597                    opening.push_str(&encode(&attribute.name, true));
598                }
599            }
600        }
601
602        let output = if children.is_empty() {
603            format!("{opening}/>")
604        } else {
605            format!("{}>{}</{}>", opening, content, encode(name, true))
606        };
607
608        self.render_html(&output)
609    }
610
611    fn render_html(&self, value: &str) -> String {
612        if !self.options.allow_dangerous_html {
613            return encode(value, true);
614        }
615        if self.options.gfm_tagfilter {
616            crate::util::gfm_tagfilter::gfm_tagfilter(value)
617        } else {
618            value.to_string()
619        }
620    }
621
622    fn render_code_value(&self, value: &str) -> String {
623        let mut output = encode(value, true);
624        if !value.is_empty() && !value.ends_with('\n') && !value.ends_with('\r') {
625            output.push_str(&self.line_ending());
626        }
627        output
628    }
629
630    fn line_ending(&self) -> String {
631        self.options.default_line_ending.as_str().to_string()
632    }
633
634    fn link_url(&self, value: &str) -> String {
635        if self.options.allow_dangerous_protocol {
636            crate::util::sanitize_uri::sanitize(value)
637        } else {
638            sanitize_with_protocols(value, &["http", "https", "irc", "ircs", "mailto", "xmpp"])
639        }
640    }
641
642    fn image_url(&self, value: &str) -> String {
643        if self.options.allow_any_img_src || self.options.allow_dangerous_protocol {
644            crate::util::sanitize_uri::sanitize(value)
645        } else {
646            sanitize_with_protocols(value, &["http", "https"])
647        }
648    }
649
650    fn render_inline_children(&mut self, children: &[Node]) -> String {
651        let mut output = String::new();
652        for child in children {
653            output.push_str(&self.render_node_inner(child));
654        }
655        output
656    }
657
658    fn render_flow_children(&mut self, children: &[Node]) -> String {
659        let mut output = String::new();
660        let mut count = 0;
661        let line_ending = self.line_ending();
662        for child in children {
663            let rendered = self.render_node_inner(child);
664            if rendered.is_empty() {
665                continue;
666            }
667            if count > 0 {
668                output.push_str(&line_ending);
669            }
670            output.push_str(&rendered);
671            count += 1;
672        }
673        output
674    }
675}
676
677impl Renderer for HTMLRenderer {
678    fn render(&mut self, node: &Node) -> String {
679        self.prepare(node);
680        self.render_node_inner(node)
681    }
682
683    fn render_node(&mut self, node: &Node) -> String {
684        self.render_node_inner(node)
685    }
686
687    fn render_children(&mut self, node: &Node) -> String {
688        match node {
689            Node::Root(_)
690            | Node::Fragment(_)
691            | Node::Blockquote(_)
692            | Node::List(_)
693            | Node::ListItem(_)
694            | Node::Table(_)
695            | Node::FootnoteDefinition(_)
696            | Node::MdxJsxFlowElement(_) => node
697                .children()
698                .map(|children| self.render_flow_children(children))
699                .unwrap_or_default(),
700            _ => node
701                .children()
702                .map(|children| self.render_inline_children(children))
703                .unwrap_or_default(),
704        }
705    }
706}
707
708fn render_title(title: Option<&str>) -> String {
709    match title {
710        Some(title) => format!(" title=\"{}\"", encode(title, true)),
711        None => String::new(),
712    }
713}
714
715fn with_surrounding_line_endings(value: &str, line_ending: &str) -> String {
716    if value.is_empty() {
717        String::new()
718    } else {
719        format!("{line_ending}{value}{line_ending}")
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use super::HTMLRenderer;
726    use crate::mdast::{Node, Paragraph, Text};
727    use alloc::vec;
728
729    #[test]
730    fn skips_footnote_entries_without_definitions() {
731        let mut renderer = HTMLRenderer::new();
732        renderer.footnote_order.push("missing".into());
733
734        let output = renderer.render_footnote_section();
735
736        assert!(output.contains("<ol>"));
737        assert!(!output.contains("<li>"));
738    }
739
740    #[test]
741    fn renders_footnotes_without_preceding_root_content() {
742        let mut renderer = HTMLRenderer::new();
743        renderer.footnote_order.push("note".into());
744        renderer.footnote_definitions.insert(
745            "note".into(),
746            vec![Node::Paragraph(Paragraph {
747                children: vec![Node::Text(Text {
748                    value: "footnote".into(),
749                    position: None,
750                })],
751                position: None,
752            })],
753        );
754
755        let output = renderer.render_root(&[]);
756
757        assert!(output.starts_with("<section data-footnotes"));
758        assert!(!output.starts_with('\n'));
759    }
760
761    #[test]
762    fn omits_empty_nodes_from_lists_and_only_terminates_code_when_needed() {
763        let mut renderer = HTMLRenderer::new();
764        let list = crate::mdast::List {
765            children: vec![Node::MdxFlowExpression(crate::mdast::MdxFlowExpression {
766                value: "value".into(),
767                position: None,
768                stops: vec![],
769            })],
770            position: None,
771            ordered: false,
772            start: None,
773            spread: false,
774        };
775
776        assert_eq!(renderer.render_list(&list), "<ul>\n\n</ul>");
777        assert_eq!(renderer.render_code_value(""), "");
778        assert_eq!(renderer.render_code_value("code\n"), "code\n");
779    }
780}