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