1use 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#[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 #[must_use]
58 pub fn new() -> Self {
59 Self::default()
60 }
61
62 #[must_use]
64 pub fn with_options(options: CompileOptions) -> Self {
65 Self {
66 options,
67 ..Self::default()
68 }
69 }
70
71 #[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(¶graph.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("e.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 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}