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