Skip to main content

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