1use std::collections::HashMap;
63
64use crate::attrs::ParsedAttrs;
65use crate::tasks::{Annotations, TaskPriority, TaskStatus};
66use chrono::NaiveDateTime;
67use pulldown_cmark_escape::IoWriter;
68use pulldown_cmark_escape::{FmtWriter, StrWrite, escape_href, escape_html, escape_html_body_text};
69
70use pulldown_cmark::{
71 Alignment, BlockQuoteKind, CodeBlockKind, CowStr,
72 Event::{self, *},
73 LinkType, Tag, TagEnd,
74};
75
76#[derive(Debug, Clone, Copy)]
87pub struct BlockLine {
88 pub at: u32,
90 pub line: u32,
92}
93
94#[derive(Debug, Default, Clone)]
100pub struct HtmlConfig {
101 pub enable_sections: bool,
112
113 pub enable_mermaid: bool,
116
117 pub section_attrs: HashMap<usize, ParsedAttrs>,
124
125 pub block_lines: Vec<BlockLine>,
138}
139
140impl HtmlConfig {
141 pub fn mbr_defaults() -> Self {
143 Self {
144 enable_sections: true,
145 enable_mermaid: true,
146 section_attrs: HashMap::new(),
147 block_lines: Vec::new(),
148 }
149 }
150
151 pub fn mbr_with_section_attrs(section_attrs: HashMap<usize, ParsedAttrs>) -> Self {
153 Self {
154 enable_sections: true,
155 enable_mermaid: true,
156 section_attrs,
157 block_lines: Vec::new(),
158 }
159 }
160
161 pub fn mbr_with_attrs_and_block_lines(
167 section_attrs: HashMap<usize, ParsedAttrs>,
168 block_lines: Vec<BlockLine>,
169 ) -> Self {
170 Self {
171 enable_sections: true,
172 enable_mermaid: true,
173 section_attrs,
174 block_lines,
175 }
176 }
177}
178
179enum TableState {
184 Head,
185 Body,
186}
187
188struct HtmlWriter<'a, I, W> {
189 iter: I,
191
192 writer: W,
194
195 end_newline: bool,
197
198 in_non_writing_block: bool,
200
201 codeblock_state: Option<CowStr<'a>>,
203
204 table_state: TableState,
205 table_alignments: Vec<Alignment>,
206 table_cell_index: usize,
207 numbers: HashMap<CowStr<'a>, usize>,
208
209 config: HtmlConfig,
211
212 section_started: bool,
214
215 current_section: usize,
217
218 container_depth: usize,
222
223 event_index: usize,
227
228 block_line_next: usize,
233
234 emit_block_lines: bool,
237}
238
239fn is_block_container(tag: &Tag<'_>) -> bool {
245 matches!(
246 tag,
247 Tag::BlockQuote(_)
248 | Tag::List(_)
249 | Tag::Item
250 | Tag::TableCell
251 | Tag::FootnoteDefinition(_)
252 | Tag::DefinitionList
253 | Tag::DefinitionListDefinition
254 )
255}
256
257fn is_block_container_end(tag: &TagEnd) -> bool {
259 matches!(
260 tag,
261 TagEnd::BlockQuote(_)
262 | TagEnd::List(_)
263 | TagEnd::Item
264 | TagEnd::TableCell
265 | TagEnd::FootnoteDefinition
266 | TagEnd::DefinitionList
267 | TagEnd::DefinitionListDefinition
268 )
269}
270
271pub(crate) const TASK_TEXT_CLOSE: &str = "</span>";
277
278pub(crate) fn task_checkbox_html(status: TaskStatus, line: Option<u32>) -> String {
291 let mut html = String::with_capacity(128);
294 html.push_str("<input type=\"checkbox\" class=\"mbr-task-check\"");
295 if let Some(line) = line {
296 let _ = write!(
299 html,
300 " id=\"mbr-task-{line}\" data-mbr-task-line=\"{line}\""
301 );
302 }
303 let _ = write!(
306 html,
307 " data-mbr-task-status=\"{}\"",
308 task_status_slug(status)
309 );
310 if status == TaskStatus::Done {
311 html.push_str(" checked");
312 }
313 html.push_str(" disabled>");
314 html
315}
316
317pub(crate) const fn task_text_open(status: TaskStatus) -> &'static str {
325 match status {
326 TaskStatus::Canceled => "<span class=\"mbr-task-text mbr-task-canceled\">",
327 TaskStatus::Open | TaskStatus::Done => "<span class=\"mbr-task-text\">",
328 }
329}
330
331pub(crate) fn task_annotations_html(annotations: &Annotations) -> String {
336 let mut html = String::new();
337
338 if let Some((class, label)) = task_priority_chip(annotations.priority) {
339 let _ = write!(
342 html,
343 " <span class=\"mbr-task-pri {class}\" role=\"img\" aria-label=\"{label}\" title=\"{label}\"></span>"
344 );
345 }
346 for tag in &annotations.tags {
347 html.push_str(" <span class=\"mbr-task-tag\">#");
348 let _ = escape_html(&mut html, tag);
352 html.push_str("</span>");
353 }
354 if let Some(due) = annotations.due {
355 push_task_time(&mut html, "mbr-task-due", due, annotations.due_has_time);
356 }
357 if let Some(done) = annotations.done {
358 push_task_time(
359 &mut html,
360 "mbr-task-completed",
361 done,
362 annotations.done_has_time,
363 );
364 }
365 if let Some(moved_to) = annotations.moved_to {
366 push_task_time(
367 &mut html,
368 "mbr-task-moved",
369 moved_to.and_time(chrono::NaiveTime::MIN),
370 false,
371 );
372 }
373
374 html
375}
376
377const fn task_status_slug(status: TaskStatus) -> &'static str {
379 match status {
380 TaskStatus::Open => "open",
381 TaskStatus::Done => "done",
382 TaskStatus::Canceled => "canceled",
383 }
384}
385
386const fn task_priority_chip(priority: TaskPriority) -> Option<(&'static str, &'static str)> {
389 match priority {
390 TaskPriority::Normal => None,
391 TaskPriority::High => Some(("mbr-task-pri-high", "High priority")),
392 TaskPriority::Urgent => Some(("mbr-task-pri-urgent", "Urgent priority")),
393 }
394}
395
396fn push_task_time(html: &mut String, class: &str, value: NaiveDateTime, has_time: bool) {
405 let (attr, label) = if has_time {
406 (
407 value.format("%Y-%m-%dT%H:%M").to_string(),
408 value.format("%b %-d, %-I:%M %p").to_string(),
409 )
410 } else {
411 (
412 value.format("%Y-%m-%d").to_string(),
413 value.format("%b %-d").to_string(),
414 )
415 };
416 let _ = write!(
417 html,
418 " <time class=\"{class}\" datetime=\"{attr}\">{label}</time>"
419 );
420}
421
422const TABLE_SCROLL_OPEN: &str = "<div class=\"mbr-table-scroll\">";
426
427const BLOCKED_DESTINATION: &str = "about:blank#blocked";
434
435const MAX_BLOCKED_SCHEME_LEN: usize = 10;
437
438fn is_blocked_destination(dest: &str) -> bool {
451 let mut scheme = [0u8; MAX_BLOCKED_SCHEME_LEN];
452 let mut len = 0usize;
453 let mut found_colon = false;
454
455 for c in dest.trim_start_matches(|c: char| c <= ' ').chars() {
456 match c {
457 '\t' | '\n' | '\r' => continue,
458 ':' => {
459 found_colon = true;
460 break;
461 }
462 _ if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') => {
463 if len == MAX_BLOCKED_SCHEME_LEN {
464 return false;
466 }
467 scheme[len] = c.to_ascii_lowercase() as u8;
468 len += 1;
469 }
470 _ => return false,
472 }
473 }
474
475 if !found_colon {
476 return false;
477 }
478
479 match &scheme[..len] {
480 b"javascript" | b"vbscript" => true,
481 b"data" => !is_inline_image_data_url(dest),
482 _ => false,
483 }
484}
485
486fn is_inline_image_data_url(dest: &str) -> bool {
488 let Some((_, payload)) = dest.split_once(':') else {
489 return false;
490 };
491 let mut actual = payload
492 .chars()
493 .filter(|c| !matches!(c, '\t' | '\n' | '\r'))
494 .map(|c| c.to_ascii_lowercase());
495 "image/"
496 .chars()
497 .all(|expected| actual.next() == Some(expected))
498}
499
500impl<'a, I, W> HtmlWriter<'a, I, W>
501where
502 I: Iterator<Item = Event<'a>>,
503 W: StrWrite,
504{
505 fn new(iter: I, writer: W) -> Self {
506 Self::new_with_config(iter, writer, HtmlConfig::default())
507 }
508
509 fn new_with_config(iter: I, writer: W, config: HtmlConfig) -> Self {
510 let emit_block_lines = !config.block_lines.is_empty();
511 Self {
512 iter,
513 writer,
514 end_newline: true,
515 in_non_writing_block: false,
516 codeblock_state: None,
517 table_state: TableState::Head,
518 table_alignments: vec![],
519 table_cell_index: 0,
520 numbers: HashMap::new(),
521 config,
522 section_started: false,
523 current_section: 0,
524 container_depth: 0,
525 event_index: 0,
526 block_line_next: 0,
527 emit_block_lines,
528 }
529 }
530
531 #[inline]
545 fn next_event(&mut self) -> Option<Event<'a>> {
546 let event = self.iter.next();
547 if event.is_some() {
548 self.event_index += 1;
549 }
550 event
551 }
552
553 fn write_block_line(&mut self) -> Result<(), W::Error> {
560 let index = match self.event_index.checked_sub(1) {
561 Some(index) => index,
562 None => return Ok(()),
564 };
565 while self
567 .config
568 .block_lines
569 .get(self.block_line_next)
570 .is_some_and(|entry| (entry.at as usize) < index)
571 {
572 self.block_line_next += 1;
573 }
574 let line = self
576 .config
577 .block_lines
578 .get(self.block_line_next)
579 .filter(|entry| entry.at as usize == index)
580 .map(|entry| entry.line);
581 match line {
582 Some(line) => {
583 self.write(" data-mbr-line=\"")?;
584 write!(&mut self.writer, "{}", line)?;
585 self.write("\"")
586 }
587 None => Ok(()),
588 }
589 }
590
591 #[inline]
593 fn write_newline(&mut self) -> Result<(), W::Error> {
594 self.end_newline = true;
595 self.writer.write_str("\n")
596 }
597
598 #[inline]
600 fn write(&mut self, s: &str) -> Result<(), W::Error> {
601 self.writer.write_str(s)?;
602
603 if !s.is_empty() {
604 self.end_newline = s.ends_with('\n');
605 }
606 Ok(())
607 }
608
609 #[inline]
612 fn write_destination(&mut self, dest: &str) -> Result<(), W::Error> {
613 if is_blocked_destination(dest) {
614 self.write(BLOCKED_DESTINATION)
615 } else {
616 escape_href(&mut self.writer, dest)
617 }
618 }
619
620 fn write_pre_open(&mut self) -> Result<(), W::Error> {
624 if self.emit_block_lines {
625 self.write("<pre")?;
626 self.write_block_line()?;
627 self.write(">")
628 } else {
629 self.write("<pre>")
630 }
631 }
632
633 fn run(mut self) -> Result<(), W::Error> {
634 if self.config.enable_sections {
636 let attrs_str = self
637 .config
638 .section_attrs
639 .get(&self.current_section)
640 .map(|a| a.to_html_attr_string())
641 .unwrap_or_default();
642 self.write(&format!("<section{}>\n", attrs_str))?;
643 self.section_started = true;
644 }
645
646 while let Some(event) = self.next_event() {
649 match event {
650 Start(tag) => {
651 self.start_tag(tag)?;
652 }
653 End(tag) => {
654 self.end_tag(tag)?;
655 }
656 Text(text) => {
657 if !self.in_non_writing_block {
658 escape_html_body_text(&mut self.writer, &text)?;
659 self.end_newline = text.ends_with('\n');
660 }
661 }
662 Code(text) => {
663 self.write("<code>")?;
664 escape_html_body_text(&mut self.writer, &text)?;
665 self.write("</code>")?;
666 }
667 InlineMath(text) => {
668 self.write(r#"<span class="math math-inline">"#)?;
669 escape_html(&mut self.writer, &text)?;
670 self.write("</span>")?;
671 }
672 DisplayMath(text) => {
673 self.write(r#"<span class="math math-display">"#)?;
674 escape_html(&mut self.writer, &text)?;
675 self.write("</span>")?;
676 }
677 Html(html) | InlineHtml(html) => {
678 self.write(&html)?;
679 }
680 SoftBreak => {
681 self.write_newline()?;
682 }
683 HardBreak => {
684 self.write("<br />\n")?;
685 }
686 Rule => {
688 if self.config.enable_sections {
692 self.current_section += 1;
693 }
694
695 if self.config.enable_sections && self.container_depth == 0 {
696 let attrs_str = self
697 .config
698 .section_attrs
699 .get(&self.current_section)
700 .map(|a| a.to_html_attr_string())
701 .unwrap_or_default();
702 self.write(&format!("</section>\n<hr />\n<section{}>\n", attrs_str))?;
703 } else {
704 if self.end_newline {
709 self.write("<hr />\n")?;
710 } else {
711 self.write("\n<hr />\n")?;
712 }
713 }
714 }
715 FootnoteReference(name) => {
716 let len = self.numbers.len() + 1;
717 self.write("<sup class=\"footnote-reference\"><a href=\"#")?;
718 escape_html(&mut self.writer, &name)?;
719 self.write("\">")?;
720 let number = *self.numbers.entry(name).or_insert(len);
721 write!(&mut self.writer, "{}", number)?;
722 self.write("</a></sup>")?;
723 }
724 TaskListMarker(checked) => {
733 let status = if checked {
734 TaskStatus::Done
735 } else {
736 TaskStatus::Open
737 };
738 self.write(&task_checkbox_html(status, None))?;
739 self.write("\n")?;
740 }
741 }
742 }
743
744 if self.config.enable_sections && self.section_started {
746 self.write("</section>\n")?;
747 }
748
749 Ok(())
750 }
751
752 fn start_tag(&mut self, tag: Tag<'a>) -> Result<(), W::Error> {
754 if is_block_container(&tag) {
756 self.container_depth += 1;
757 }
758 match tag {
759 Tag::HtmlBlock => Ok(()),
760 Tag::Paragraph => {
763 if self.emit_block_lines {
764 self.write(if self.end_newline { "<p" } else { "\n<p" })?;
765 self.write_block_line()?;
766 self.write(">")
767 } else if self.end_newline {
768 self.write("<p>")
769 } else {
770 self.write("\n<p>")
771 }
772 }
773 Tag::Heading {
774 level,
775 id,
776 classes,
777 attrs,
778 } => {
779 if self.end_newline {
780 self.write("<")?;
781 } else {
782 self.write("\n<")?;
783 }
784 write!(&mut self.writer, "{}", level)?;
785 if self.emit_block_lines {
791 self.write_block_line()?;
792 }
793 if let Some(id) = id {
794 self.write(" id=\"")?;
795 escape_html(&mut self.writer, &id)?;
796 self.write("\"")?;
797 }
798 let mut classes = classes.iter();
799 if let Some(class) = classes.next() {
800 self.write(" class=\"")?;
801 escape_html(&mut self.writer, class)?;
802 for class in classes {
803 self.write(" ")?;
804 escape_html(&mut self.writer, class)?;
805 }
806 self.write("\"")?;
807 }
808 for (attr, value) in attrs {
809 self.write(" ")?;
810 escape_html(&mut self.writer, &attr)?;
811 if let Some(val) = value {
812 self.write("=\"")?;
813 escape_html(&mut self.writer, &val)?;
814 self.write("\"")?;
815 } else {
816 self.write("=\"\"")?;
817 }
818 }
819 self.write(">")
820 }
821 Tag::Table(alignments) => {
833 self.table_alignments = alignments;
834 self.write(TABLE_SCROLL_OPEN)?;
835 if self.emit_block_lines {
836 self.write("<table")?;
837 self.write_block_line()?;
838 self.write(">")
839 } else {
840 self.write("<table>")
841 }
842 }
843 Tag::TableHead => {
844 self.table_state = TableState::Head;
845 self.table_cell_index = 0;
846 self.write("<thead><tr>")
847 }
848 Tag::TableRow => {
849 self.table_cell_index = 0;
850 self.write("<tr>")
851 }
852 Tag::TableCell => {
853 match self.table_state {
854 TableState::Head => {
855 self.write("<th")?;
856 }
857 TableState::Body => {
858 self.write("<td")?;
859 }
860 }
861 match self.table_alignments.get(self.table_cell_index) {
862 Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"),
863 Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"),
864 Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"),
865 _ => self.write(">"),
866 }
867 }
868 Tag::BlockQuote(kind) => {
869 let class_str = match kind {
870 None => "",
871 Some(kind) => match kind {
872 BlockQuoteKind::Note => " class=\"markdown-alert-note\"",
873 BlockQuoteKind::Tip => " class=\"markdown-alert-tip\"",
874 BlockQuoteKind::Important => " class=\"markdown-alert-important\"",
875 BlockQuoteKind::Warning => " class=\"markdown-alert-warning\"",
876 BlockQuoteKind::Caution => " class=\"markdown-alert-caution\"",
877 },
878 };
879 if self.emit_block_lines {
881 self.write(if self.end_newline {
882 "<blockquote"
883 } else {
884 "\n<blockquote"
885 })?;
886 self.write(class_str)?;
887 self.write_block_line()?;
888 self.write(">\n")
889 } else if self.end_newline {
890 self.write(&format!("<blockquote{}>\n", class_str))
891 } else {
892 self.write(&format!("\n<blockquote{}>\n", class_str))
893 }
894 }
895 Tag::CodeBlock(info) => {
896 if !self.end_newline {
897 self.write_newline()?;
898 }
899 self.codeblock_state = Some("</code></pre>".into());
900 match info {
903 CodeBlockKind::Fenced(info) => {
904 let lang = info.split(' ').next().unwrap_or_default();
905 if lang.is_empty() {
906 self.write_pre_open()?;
907 self.write("<code>")
908 } else if self.config.enable_mermaid && lang == "mermaid" {
910 self.codeblock_state = Some("</pre>".into());
911 self.write("<pre class=\"mermaid\"")?;
912 if self.emit_block_lines {
913 self.write_block_line()?;
914 }
915 self.write(">")
916 } else {
917 self.write_pre_open()?;
918 self.write("<code class=\"language-")?;
919 escape_html(&mut self.writer, lang)?;
920 self.write("\">")
921 }
922 }
923 CodeBlockKind::Indented => {
924 self.write_pre_open()?;
925 self.write("<code>")
926 }
927 }
928 }
929 Tag::List(Some(1)) => {
930 if self.end_newline {
931 self.write("<ol>\n")
932 } else {
933 self.write("\n<ol>\n")
934 }
935 }
936 Tag::List(Some(start)) => {
937 if self.end_newline {
938 self.write("<ol start=\"")?;
939 } else {
940 self.write("\n<ol start=\"")?;
941 }
942 write!(&mut self.writer, "{}", start)?;
943 self.write("\">\n")
944 }
945 Tag::List(None) => {
946 if self.end_newline {
947 self.write("<ul>\n")
948 } else {
949 self.write("\n<ul>\n")
950 }
951 }
952 Tag::Item => {
956 if self.emit_block_lines {
957 self.write(if self.end_newline { "<li" } else { "\n<li" })?;
958 self.write_block_line()?;
959 self.write(">")
960 } else if self.end_newline {
961 self.write("<li>")
962 } else {
963 self.write("\n<li>")
964 }
965 }
966 Tag::DefinitionList => {
967 if self.end_newline {
968 self.write("<dl>\n")
969 } else {
970 self.write("\n<dl>\n")
971 }
972 }
973 Tag::DefinitionListTitle => {
974 self.write(if self.end_newline { "<dt" } else { "\n<dt" })?;
988 if self.emit_block_lines {
989 self.write_block_line()?;
990 }
991 self.write(" tabindex=\"0\">")
992 }
993 Tag::DefinitionListDefinition => {
995 self.write(if self.end_newline { "<dd" } else { "\n<dd" })?;
996 if self.emit_block_lines {
997 self.write_block_line()?;
998 }
999 self.write(">")
1000 }
1001 Tag::Subscript => self.write("<sub>"),
1002 Tag::Superscript => self.write("<sup>"),
1003 Tag::Emphasis => self.write("<em>"),
1004 Tag::Strong => self.write("<strong>"),
1005 Tag::Strikethrough => self.write("<del>"),
1006 Tag::Link {
1007 link_type: LinkType::Email,
1008 dest_url,
1009 title,
1010 id: _,
1011 } => {
1012 self.write("<a href=\"mailto:")?;
1013 escape_href(&mut self.writer, &dest_url)?;
1014 if !title.is_empty() {
1015 self.write("\" title=\"")?;
1016 escape_html(&mut self.writer, &title)?;
1017 }
1018 self.write("\">")
1019 }
1020 Tag::Link {
1021 link_type: _,
1022 dest_url,
1023 title,
1024 id: _,
1025 } => {
1026 self.write("<a href=\"")?;
1027 self.write_destination(&dest_url)?;
1028 if !title.is_empty() {
1029 self.write("\" title=\"")?;
1030 escape_html(&mut self.writer, &title)?;
1031 }
1032 self.write("\">")
1033 }
1034 Tag::Image {
1035 link_type: _,
1036 dest_url,
1037 title,
1038 id: _,
1039 } => {
1040 self.write("<img src=\"")?;
1041 self.write_destination(&dest_url)?;
1042 self.write("\" alt=\"")?;
1043 self.raw_text()?;
1044 if !title.is_empty() {
1045 self.write("\" title=\"")?;
1046 escape_html(&mut self.writer, &title)?;
1047 }
1048 self.write("\" loading=\"lazy\" decoding=\"async\" />")
1051 }
1052 Tag::FootnoteDefinition(name) => {
1053 if self.end_newline {
1054 self.write("<div class=\"footnote-definition\" id=\"")?;
1055 } else {
1056 self.write("\n<div class=\"footnote-definition\" id=\"")?;
1057 }
1058 escape_html(&mut self.writer, &name)?;
1059 self.write("\"><sup class=\"footnote-definition-label\">")?;
1060 let len = self.numbers.len() + 1;
1061 let number = *self.numbers.entry(name).or_insert(len);
1062 write!(&mut self.writer, "{}", number)?;
1063 self.write("</sup>")
1064 }
1065 Tag::MetadataBlock(_) => {
1066 self.in_non_writing_block = true;
1067 Ok(())
1068 }
1069 }
1070 }
1071
1072 fn end_tag(&mut self, tag: TagEnd) -> Result<(), W::Error> {
1073 if is_block_container_end(&tag) {
1076 self.container_depth = self.container_depth.saturating_sub(1);
1077 }
1078 match tag {
1079 TagEnd::HtmlBlock => {}
1080 TagEnd::Paragraph => {
1081 self.write("</p>\n")?;
1082 }
1083 TagEnd::Heading(level) => {
1084 self.write("</")?;
1085 write!(&mut self.writer, "{}", level)?;
1086 self.write(">\n")?;
1087 }
1088 TagEnd::Table => {
1089 self.write("</tbody></table></div>\n")?;
1090 }
1091 TagEnd::TableHead => {
1092 self.write("</tr></thead><tbody>\n")?;
1093 self.table_state = TableState::Body;
1094 }
1095 TagEnd::TableRow => {
1096 self.write("</tr>\n")?;
1097 }
1098 TagEnd::TableCell => {
1099 match self.table_state {
1100 TableState::Head => {
1101 self.write("</th>")?;
1102 }
1103 TableState::Body => {
1104 self.write("</td>")?;
1105 }
1106 }
1107 self.table_cell_index += 1;
1108 }
1109 TagEnd::BlockQuote(_) => {
1110 self.write("</blockquote>\n")?;
1111 }
1112 TagEnd::CodeBlock => {
1113 match self.codeblock_state.take() {
1114 Some(closing) => self.write(closing.as_ref())?,
1115 None => self.write("</code></pre>")?,
1116 }
1117 self.write("\n")?;
1118 }
1119 TagEnd::List(true) => {
1120 self.write("</ol>\n")?;
1121 }
1122 TagEnd::List(false) => {
1123 self.write("</ul>\n")?;
1124 }
1125 TagEnd::Item => {
1126 self.write("</li>\n")?;
1127 }
1128 TagEnd::DefinitionList => {
1129 self.write("</dl>\n")?;
1130 }
1131 TagEnd::DefinitionListTitle => {
1132 self.write("</dt>\n")?;
1133 }
1134 TagEnd::DefinitionListDefinition => {
1135 self.write("</dd>\n")?;
1136 }
1137 TagEnd::Emphasis => {
1138 self.write("</em>")?;
1139 }
1140 TagEnd::Superscript => {
1141 self.write("</sup>")?;
1142 }
1143 TagEnd::Subscript => {
1144 self.write("</sub>")?;
1145 }
1146 TagEnd::Strong => {
1147 self.write("</strong>")?;
1148 }
1149 TagEnd::Strikethrough => {
1150 self.write("</del>")?;
1151 }
1152 TagEnd::Link => {
1153 self.write("</a>")?;
1154 }
1155 TagEnd::Image => (), TagEnd::FootnoteDefinition => {
1157 self.write("</div>\n")?;
1158 }
1159 TagEnd::MetadataBlock(_) => {
1160 self.in_non_writing_block = false;
1161 }
1162 }
1163 Ok(())
1164 }
1165
1166 fn raw_text(&mut self) -> Result<(), W::Error> {
1168 let mut nest = 0;
1169 while let Some(event) = self.next_event() {
1174 match event {
1175 Start(_) => nest += 1,
1176 End(_) => {
1177 if nest == 0 {
1178 break;
1179 }
1180 nest -= 1;
1181 }
1182 Html(_) => {}
1183 InlineHtml(text) | Code(text) | Text(text) => {
1184 escape_html(&mut self.writer, &text)?;
1187 self.end_newline = text.ends_with('\n');
1188 }
1189 InlineMath(text) => {
1190 self.write("$")?;
1191 escape_html(&mut self.writer, &text)?;
1192 self.write("$")?;
1193 }
1194 DisplayMath(text) => {
1195 self.write("$$")?;
1196 escape_html(&mut self.writer, &text)?;
1197 self.write("$$")?;
1198 }
1199 SoftBreak | HardBreak | Rule => {
1200 self.write(" ")?;
1201 }
1202 FootnoteReference(name) => {
1203 let len = self.numbers.len() + 1;
1204 let number = *self.numbers.entry(name).or_insert(len);
1205 write!(&mut self.writer, "[{}]", number)?;
1206 }
1207 TaskListMarker(true) => self.write("[x]")?,
1208 TaskListMarker(false) => self.write("[ ]")?,
1209 }
1210 }
1211 Ok(())
1212 }
1213}
1214
1215pub fn push_html<'a, I>(s: &mut String, iter: I)
1243where
1244 I: Iterator<Item = Event<'a>>,
1245{
1246 write_html_fmt(s, iter).expect("writing to a String cannot fail")
1247}
1248
1249pub fn write_html_io<'a, I, W>(writer: W, iter: I) -> std::io::Result<()>
1283where
1284 I: Iterator<Item = Event<'a>>,
1285 W: std::io::Write,
1286{
1287 HtmlWriter::new(iter, IoWriter(writer)).run()
1288}
1289
1290pub fn write_html_fmt<'a, I, W>(writer: W, iter: I) -> core::fmt::Result
1318where
1319 I: Iterator<Item = Event<'a>>,
1320 W: core::fmt::Write,
1321{
1322 HtmlWriter::new(iter, FmtWriter(writer)).run()
1323}
1324
1325pub fn push_html_mbr<'a, I>(s: &mut String, iter: I)
1356where
1357 I: Iterator<Item = Event<'a>>,
1358{
1359 write_html_fmt_with_config(s, iter, HtmlConfig::mbr_defaults())
1360 .expect("writing to a String cannot fail")
1361}
1362
1363pub fn push_html_mbr_with_attrs<'a, I>(
1388 s: &mut String,
1389 iter: I,
1390 section_attrs: HashMap<usize, ParsedAttrs>,
1391) where
1392 I: Iterator<Item = Event<'a>>,
1393{
1394 write_html_fmt_with_config(s, iter, HtmlConfig::mbr_with_section_attrs(section_attrs))
1395 .expect("writing to a String cannot fail")
1396}
1397
1398pub fn push_html_with_config<'a, I>(s: &mut String, iter: I, config: HtmlConfig)
1419where
1420 I: Iterator<Item = Event<'a>>,
1421{
1422 write_html_fmt_with_config(s, iter, config).expect("writing to a String cannot fail")
1423}
1424
1425fn write_html_fmt_with_config<'a, I, W>(writer: W, iter: I, config: HtmlConfig) -> core::fmt::Result
1427where
1428 I: Iterator<Item = Event<'a>>,
1429 W: core::fmt::Write,
1430{
1431 HtmlWriter::new_with_config(iter, FmtWriter(writer), config).run()
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436 use super::*;
1437 use pulldown_cmark::{Options, Parser};
1438
1439 fn render_with_config(markdown: &str, config: HtmlConfig) -> String {
1441 let parser = Parser::new(markdown);
1442 let mut html = String::new();
1443 push_html_with_config(&mut html, parser, config);
1444 html
1445 }
1446
1447 #[test]
1450 fn task_checkbox_carries_its_line_when_one_is_known() {
1451 assert_eq!(
1452 task_checkbox_html(TaskStatus::Open, Some(42)),
1453 concat!(
1454 r#"<input type="checkbox" class="mbr-task-check" id="mbr-task-42" "#,
1455 r#"data-mbr-task-line="42" data-mbr-task-status="open" disabled>"#
1456 )
1457 );
1458 assert_eq!(
1459 task_checkbox_html(TaskStatus::Done, Some(1)),
1460 concat!(
1461 r#"<input type="checkbox" class="mbr-task-check" id="mbr-task-1" "#,
1462 r#"data-mbr-task-line="1" data-mbr-task-status="done" checked disabled>"#
1463 )
1464 );
1465 }
1466
1467 #[test]
1468 fn task_checkbox_without_a_line_omits_the_anchor() {
1469 let html = task_checkbox_html(TaskStatus::Canceled, None);
1470 assert_eq!(
1471 html,
1472 r#"<input type="checkbox" class="mbr-task-check" data-mbr-task-status="canceled" disabled>"#
1473 );
1474 assert!(!html.contains("id="));
1475 }
1476
1477 #[test]
1478 fn every_task_checkbox_is_inert() {
1479 for status in [TaskStatus::Open, TaskStatus::Done, TaskStatus::Canceled] {
1480 for line in [None, Some(7)] {
1481 assert!(
1482 task_checkbox_html(status, line).ends_with(" disabled>"),
1483 "{status:?} {line:?}"
1484 );
1485 }
1486 }
1487 }
1488
1489 #[test]
1490 fn a_task_with_no_annotations_renders_no_chips() {
1491 assert_eq!(task_annotations_html(&Annotations::default()), "");
1492 }
1493
1494 #[test]
1495 fn annotation_chips_render_in_a_fixed_order() {
1496 let annotations = Annotations {
1497 priority: TaskPriority::Urgent,
1498 tags: vec!["work".to_string(), "ops".to_string()],
1499 due: chrono::NaiveDate::from_ymd_opt(2026, 8, 5).and_then(|d| d.and_hms_opt(15, 0, 0)),
1500 due_has_time: true,
1501 done: chrono::NaiveDate::from_ymd_opt(2026, 8, 4).and_then(|d| d.and_hms_opt(0, 0, 0)),
1502 done_has_time: false,
1503 moved_to: chrono::NaiveDate::from_ymd_opt(2026, 9, 1),
1504 };
1505 assert_eq!(
1506 task_annotations_html(&annotations),
1507 concat!(
1508 r#" <span class="mbr-task-pri mbr-task-pri-urgent" role="img" "#,
1509 r#"aria-label="Urgent priority" title="Urgent priority"></span>"#,
1510 r#" <span class="mbr-task-tag">#work</span>"#,
1511 r#" <span class="mbr-task-tag">#ops</span>"#,
1512 r#" <time class="mbr-task-due" datetime="2026-08-05T15:00">Aug 5, 3:00 PM</time>"#,
1513 r#" <time class="mbr-task-completed" datetime="2026-08-04">Aug 4</time>"#,
1514 r#" <time class="mbr-task-moved" datetime="2026-09-01">Sep 1</time>"#
1515 )
1516 );
1517 }
1518
1519 #[test]
1520 fn a_high_priority_dot_is_labelled_differently_from_an_urgent_one() {
1521 let high = task_annotations_html(&Annotations {
1522 priority: TaskPriority::High,
1523 ..Annotations::default()
1524 });
1525 assert!(high.contains("mbr-task-pri-high"), "{high}");
1526 assert!(high.contains(r#"aria-label="High priority""#), "{high}");
1527 assert!(!high.contains("mbr-task-pri-urgent"), "{high}");
1528 }
1529
1530 #[test]
1533 fn tag_chips_are_html_escaped() {
1534 let html = task_annotations_html(&Annotations {
1535 tags: vec!["<script>".to_string()],
1536 ..Annotations::default()
1537 });
1538 assert!(html.contains("<script>"), "{html}");
1539 assert!(!html.contains("<script>"), "{html}");
1540 }
1541
1542 #[test]
1543 fn only_a_canceled_task_gets_the_canceled_text_class() {
1544 assert!(task_text_open(TaskStatus::Canceled).contains("mbr-task-canceled"));
1545 for status in [TaskStatus::Open, TaskStatus::Done] {
1546 assert_eq!(task_text_open(status), r#"<span class="mbr-task-text">"#);
1547 }
1548 }
1549
1550 fn render_mbr(markdown: &str) -> String {
1553 let parser = Parser::new_ext(markdown, Options::all());
1554 let mut html = String::new();
1555 push_html_mbr(&mut html, parser);
1556 html
1557 }
1558
1559 fn between<'a>(html: &'a str, open: &str, close: &str) -> &'a str {
1565 let start = html
1566 .find(open)
1567 .unwrap_or_else(|| panic!("expected {open} in output:\n{html}"))
1568 + open.len();
1569 let end = html[start..]
1570 .find(close)
1571 .unwrap_or_else(|| panic!("expected {close} after {open} in output:\n{html}"))
1572 + start;
1573 &html[start..end]
1574 }
1575
1576 #[test]
1577 fn test_sections_disabled() {
1578 let config = HtmlConfig {
1580 enable_sections: false,
1581 enable_mermaid: false,
1582 section_attrs: HashMap::new(),
1583 block_lines: Vec::new(),
1584 };
1585 let html = render_with_config("Hello\n\n---\n\nWorld", config);
1586
1587 assert!(
1589 !html.contains("<section"),
1590 "Sections disabled should not produce section tags. Got: {}",
1591 html
1592 );
1593 assert!(
1595 html.contains("<hr />"),
1596 "Should still have hr divider. Got: {}",
1597 html
1598 );
1599 }
1600
1601 #[test]
1602 fn test_sections_enabled_default() {
1603 let config = HtmlConfig::mbr_defaults();
1605 let html = render_with_config("Hello\n\n---\n\nWorld", config);
1606
1607 assert!(
1609 html.contains("<section>"),
1610 "Sections enabled should produce section tags. Got: {}",
1611 html
1612 );
1613 }
1614
1615 #[test]
1616 fn test_sections_with_attrs() {
1617 let mut section_attrs = HashMap::new();
1619 section_attrs.insert(
1620 1,
1621 ParsedAttrs {
1622 id: Some("second".to_string()),
1623 classes: vec!["highlight".to_string()],
1624 attrs: vec![],
1625 },
1626 );
1627 let config = HtmlConfig::mbr_with_section_attrs(section_attrs);
1628 let html = render_with_config("First\n\n---\n\nSecond", config);
1629
1630 assert!(
1632 html.contains(r#"id="second""#),
1633 "Section should have id. Got: {}",
1634 html
1635 );
1636 assert!(
1637 html.contains(r#"class="highlight""#),
1638 "Section should have class. Got: {}",
1639 html
1640 );
1641 }
1642
1643 #[test]
1644 fn test_mermaid_disabled() {
1645 let config = HtmlConfig {
1647 enable_sections: false,
1648 enable_mermaid: false,
1649 section_attrs: HashMap::new(),
1650 block_lines: Vec::new(),
1651 };
1652 let html = render_with_config("```mermaid\ngraph TD\n```", config);
1653
1654 assert!(
1656 html.contains("<pre><code"),
1657 "Mermaid disabled should use standard code. Got: {}",
1658 html
1659 );
1660 }
1661
1662 #[test]
1663 fn test_mermaid_enabled() {
1664 let config = HtmlConfig {
1666 enable_sections: false,
1667 enable_mermaid: true,
1668 section_attrs: HashMap::new(),
1669 block_lines: Vec::new(),
1670 };
1671 let html = render_with_config("```mermaid\ngraph TD\n```", config);
1672
1673 assert!(
1675 html.contains(r#"<pre class="mermaid">"#),
1676 "Mermaid enabled should use mermaid class. Got: {}",
1677 html
1678 );
1679 assert!(
1681 !html.contains("<code"),
1682 "Mermaid should not have code wrapper. Got: {}",
1683 html
1684 );
1685 }
1686
1687 #[test]
1692 fn test_rule_inside_blockquote_does_not_close_section() {
1693 let html = render_mbr("> before\n>\n> ---\n>\n> after");
1694 let inside = between(&html, "<blockquote>", "</blockquote>");
1695
1696 assert!(
1697 !inside.contains("</section>"),
1698 "A rule inside a blockquote must not close the surrounding section \
1699 (the browser would hoist everything after it out of the quote). Got: {html}"
1700 );
1701 assert!(
1702 !inside.contains("<section"),
1703 "A rule inside a blockquote must not open a section. Got: {html}"
1704 );
1705 assert!(
1706 inside.contains("<hr />"),
1707 "The rule should still render as a plain <hr />. Got: {html}"
1708 );
1709 assert!(
1710 inside.contains("after"),
1711 "Content after the rule must stay inside the blockquote. Got: {html}"
1712 );
1713 }
1714
1715 #[test]
1716 fn test_rule_inside_list_item_does_not_close_section() {
1717 let html = render_mbr("- a\n\n ---\n\n- b");
1718 let inside = between(&html, "<li>", "</li>");
1719
1720 assert!(
1721 !inside.contains("</section>"),
1722 "A rule inside a list item must not close the surrounding section. Got: {html}"
1723 );
1724 assert!(
1725 !inside.contains("<section"),
1726 "A rule inside a list item must not open a section. Got: {html}"
1727 );
1728 assert!(
1729 inside.contains("<hr />"),
1730 "The rule should still render as a plain <hr />. Got: {html}"
1731 );
1732 let list = between(&html, "<ul>", "</ul>");
1734 assert!(
1735 !list.contains("section"),
1736 "The list must not be split by a section boundary. Got: {html}"
1737 );
1738 }
1739
1740 #[test]
1741 fn test_rule_inside_definition_list_does_not_close_section() {
1742 let html = render_mbr("Term\n\n: def\n\n ---\n\n more\n");
1743 let inside = between(&html, "<dd>", "</dd>");
1744
1745 assert!(
1746 !inside.contains("</section>"),
1747 "A rule inside a definition must not close the surrounding section. Got: {html}"
1748 );
1749 assert!(
1750 inside.contains("more"),
1751 "Content after the rule must stay inside the definition. Got: {html}"
1752 );
1753 }
1754
1755 #[test]
1760 fn test_definition_list_titles_are_focusable() {
1761 let html = render_mbr("Tight\n: answer\n\nLoose\n\n: first para\n\n second para\n");
1762
1763 assert!(
1764 html.contains("<dl>"),
1765 "definition lists must still open a <dl>. Got: {html}"
1766 );
1767 assert!(
1768 html.contains("<dd>"),
1769 "definitions must still render as <dd>. Got: {html}"
1770 );
1771 assert_eq!(
1772 html.matches("<dt tabindex=\"0\">").count(),
1773 2,
1774 "every <dt> must be focusable so the CSS disclosure can open its \
1775 <dd>. Got: {html}"
1776 );
1777 assert!(
1778 !html.contains("<dt>"),
1779 "no <dt> may be emitted without tabindex -- its answer would be \
1780 unreachable. Got: {html}"
1781 );
1782 }
1783
1784 #[test]
1791 fn test_definition_bodies_are_tight_or_paragraph_wrapped() {
1792 let tight = render_mbr("Term\n: answer\n");
1793 assert!(
1794 tight.contains("<dd>answer</dd>"),
1795 "a tight definition should hold bare inline text. Got: {tight}"
1796 );
1797
1798 let loose = render_mbr("Term\n\n: answer\n");
1799 assert!(
1800 loose.contains("<dd>\n<p>answer</p>\n</dd>"),
1801 "a loose definition should wrap its body in <p>. Got: {loose}"
1802 );
1803 }
1804
1805 #[test]
1810 fn test_one_title_can_own_several_definitions() {
1811 let html = render_mbr("Term\n: first\n: second\n");
1812
1813 assert_eq!(
1814 html.matches("<dt tabindex=\"0\">").count(),
1815 1,
1816 "expected a single term. Got: {html}"
1817 );
1818 assert_eq!(
1819 html.matches("<dd>").count(),
1820 2,
1821 "expected the term to own two sibling <dd>s. Got: {html}"
1822 );
1823 }
1824
1825 #[test]
1826 fn test_top_level_rule_still_splits_sections() {
1827 let html = render_mbr("Hello\n\n---\n\nWorld");
1828
1829 assert!(
1830 html.contains("</section>\n<hr />\n<section>"),
1831 "A top-level rule must still split sections. Got: {html}"
1832 );
1833 }
1834
1835 #[test]
1836 fn test_nested_rule_does_not_shift_section_attr_indices() {
1837 let mut section_attrs = HashMap::new();
1841 section_attrs.insert(
1842 2,
1843 ParsedAttrs {
1844 id: Some("third".to_string()),
1845 classes: vec![],
1846 attrs: vec![],
1847 },
1848 );
1849 let config = HtmlConfig::mbr_with_section_attrs(section_attrs);
1850 let parser = Parser::new_ext("> ---\n\n---\n\ntail", Options::all());
1851 let mut html = String::new();
1852 push_html_with_config(&mut html, parser, config);
1853
1854 assert!(
1855 html.contains(r#"<section id="third">"#),
1856 "Section index must count the nested rule. Got: {html}"
1857 );
1858 }
1859
1860 #[test]
1865 fn test_javascript_link_destination_is_neutralized() {
1866 let html = render_mbr("[click me](javascript:alert(1))");
1867
1868 assert!(
1869 !html.contains("javascript:"),
1870 "javascript: href must not survive. Got: {html}"
1871 );
1872 assert!(
1873 html.contains(r#"<a href="about:blank#blocked">"#),
1874 "Blocked link should get an inert href. Got: {html}"
1875 );
1876 assert!(
1877 html.contains("click me"),
1878 "Link text must be preserved. Got: {html}"
1879 );
1880 }
1881
1882 #[test]
1883 fn test_javascript_scheme_obfuscation_is_neutralized() {
1884 for dest in [
1887 "JaVaScRiPt:alert(1)",
1888 " javascript:alert(1)",
1889 "java\tscript:alert(1)",
1890 "\u{1}javascript:alert(1)",
1891 ] {
1892 let html = render_mbr(&format!("[x](<{dest}>)"));
1893 assert!(
1894 html.contains(r#"href="about:blank#blocked""#),
1895 "{dest:?} should have been neutralized. Got: {html}"
1896 );
1897 }
1898 }
1899
1900 #[test]
1901 fn test_vbscript_link_destination_is_neutralized() {
1902 let html = render_mbr("[x](vbscript:msgbox(1))");
1903 assert!(
1904 html.contains(r#"href="about:blank#blocked""#),
1905 "vbscript: href must be neutralized. Got: {html}"
1906 );
1907 }
1908
1909 #[test]
1910 fn test_non_image_data_url_is_neutralized() {
1911 let html = render_mbr("[x](data:text/html;base64,PHNjcmlwdD4=)");
1912 assert!(
1913 html.contains(r#"href="about:blank#blocked""#),
1914 "data:text/html href must be neutralized. Got: {html}"
1915 );
1916 }
1917
1918 #[test]
1919 fn test_inline_image_data_url_is_preserved() {
1920 let html = render_mbr("");
1923 assert!(
1924 html.contains(r#"src="data:image/png;base64,iVBORw0KGgo=""#),
1925 "data:image/* src must be preserved. Got: {html}"
1926 );
1927
1928 let html = render_mbr("[x](data:image/png;base64,iVBORw0KGgo=)");
1929 assert!(
1930 html.contains(r#"href="data:image/png;base64,iVBORw0KGgo=""#),
1931 "data:image/* href must be preserved. Got: {html}"
1932 );
1933 }
1934
1935 #[test]
1936 fn test_javascript_image_destination_is_neutralized() {
1937 let html = render_mbr(")");
1938 assert!(
1939 !html.contains("javascript:"),
1940 "javascript: src must not survive. Got: {html}"
1941 );
1942 assert!(
1943 html.contains(r#"<img src="about:blank#blocked""#),
1944 "Blocked image should get an inert src. Got: {html}"
1945 );
1946 }
1947
1948 #[test]
1949 fn test_ordinary_destinations_are_untouched() {
1950 let cases = [
1951 (
1952 "[a](https://example.com/p?q=1)",
1953 "https://example.com/p?q=1",
1954 ),
1955 ("[a](http://example.com/)", "http://example.com/"),
1956 ("[a](../notes/other/)", "../notes/other/"),
1957 ("[a](/images/pic.png)", "/images/pic.png"),
1958 ("[a](#anchor)", "#anchor"),
1959 ("[a](mailto:me@example.com)", "mailto:me@example.com"),
1960 ("[a](tel:+15551234)", "tel:+15551234"),
1961 ("[a](javascript)", "javascript"),
1963 ];
1964 for (markdown, expected_href) in cases {
1965 let html = render_mbr(markdown);
1966 assert!(
1967 html.contains(&format!(r#"href="{expected_href}""#)),
1968 "{markdown} should render href={expected_href:?}. Got: {html}"
1969 );
1970 }
1971 }
1972
1973 #[test]
1974 fn test_autolink_email_is_untouched() {
1975 let html = render_mbr("<me@example.com>");
1976 assert!(
1977 html.contains(r#"href="mailto:me@example.com""#),
1978 "Email autolinks must keep working. Got: {html}"
1979 );
1980 }
1981
1982 #[test]
1987 fn test_images_get_lazy_loading_and_async_decoding() {
1988 let html = render_mbr("");
1989
1990 assert!(
1991 html.contains(r#"loading="lazy""#),
1992 "Images should be lazily loaded. Got: {html}"
1993 );
1994 assert!(
1995 html.contains(r#"decoding="async""#),
1996 "Images should decode asynchronously. Got: {html}"
1997 );
1998 }
1999
2000 #[test]
2001 fn test_image_with_title_keeps_loading_hints() {
2002 let html = render_mbr(r#""#);
2003
2004 assert!(
2005 html.contains(r#"title="A title" loading="lazy" decoding="async" />"#),
2006 "Title and loading hints should both be emitted. Got: {html}"
2007 );
2008 }
2009
2010 #[test]
2015 fn test_is_blocked_destination() {
2016 for dest in [
2017 "javascript:alert(1)",
2018 "JAVASCRIPT:alert(1)",
2019 " \u{c}javascript:alert(1)",
2020 "v\rbscript:x", "vbscript:x",
2022 "data:text/html,<script>",
2023 "data:,plain",
2024 "data:image", "data:imag", "javascript:", "jAvAsCrIpT\n:x", ] {
2029 assert!(is_blocked_destination(dest), "{dest:?} should be blocked");
2030 }
2031
2032 for dest in [
2033 "",
2034 ":",
2035 "https://example.com",
2036 "HTTPS://example.com",
2037 "mailto:me@example.com",
2038 "../a/b",
2039 "/a/b",
2040 "#frag",
2041 "?q=1",
2042 "javascript", "a javascript:alert(1)", "not-javascript:x",
2045 "data:image/png;base64,AAAA",
2046 "DATA:IMAGE/PNG;base64,AAAA",
2047 "data:image/svg+xml,%3Csvg%3E",
2048 "blob:https://example.com/uuid",
2049 "verylongschemename:x",
2050 ] {
2051 assert!(
2052 !is_blocked_destination(dest),
2053 "{dest:?} should not be blocked"
2054 );
2055 }
2056 }
2057
2058 fn render_with_block_lines(events: Vec<Event<'static>>, pairs: &[(u32, u32)]) -> String {
2062 let block_lines = pairs
2063 .iter()
2064 .map(|&(at, line)| BlockLine { at, line })
2065 .collect();
2066 let config = HtmlConfig {
2067 enable_sections: false,
2068 enable_mermaid: true,
2069 section_attrs: HashMap::new(),
2070 block_lines,
2071 };
2072 let mut html = String::new();
2073 push_html_with_config(&mut html, events.into_iter(), config);
2074 html
2075 }
2076
2077 fn render_markdown_with_block_lines(
2080 markdown: &str,
2081 pick: impl Fn(&Event<'_>) -> bool,
2082 ) -> String {
2083 let events: Vec<Event<'static>> = Parser::new_ext(markdown, Options::all())
2084 .map(|e| owned_event(&e))
2085 .collect();
2086 let pairs: Vec<(u32, u32)> = events
2087 .iter()
2088 .enumerate()
2089 .filter(|(_, e)| pick(e))
2090 .map(|(i, _)| (i as u32, (i as u32) + 1))
2091 .collect();
2092 render_with_block_lines(events, &pairs)
2093 }
2094
2095 fn owned_event(event: &Event<'_>) -> Event<'static> {
2097 match event {
2100 Start(tag) => Start(owned_tag(tag)),
2101 End(tag) => End(*tag),
2102 Text(t) => Text(CowStr::from(t.to_string())),
2103 Code(t) => Code(CowStr::from(t.to_string())),
2104 Html(t) => Html(CowStr::from(t.to_string())),
2105 InlineHtml(t) => InlineHtml(CowStr::from(t.to_string())),
2106 InlineMath(t) => InlineMath(CowStr::from(t.to_string())),
2107 DisplayMath(t) => DisplayMath(CowStr::from(t.to_string())),
2108 FootnoteReference(t) => FootnoteReference(CowStr::from(t.to_string())),
2109 SoftBreak => SoftBreak,
2110 HardBreak => HardBreak,
2111 Rule => Rule,
2112 TaskListMarker(b) => TaskListMarker(*b),
2113 }
2114 }
2115
2116 fn owned_tag(tag: &Tag<'_>) -> Tag<'static> {
2117 match tag {
2118 Tag::Paragraph => Tag::Paragraph,
2119 Tag::Heading {
2120 level,
2121 id,
2122 classes,
2123 attrs,
2124 } => Tag::Heading {
2125 level: *level,
2126 id: id.as_ref().map(|i| CowStr::from(i.to_string())),
2127 classes: classes
2128 .iter()
2129 .map(|c| CowStr::from(c.to_string()))
2130 .collect(),
2131 attrs: attrs
2132 .iter()
2133 .map(|(k, v)| {
2134 (
2135 CowStr::from(k.to_string()),
2136 v.as_ref().map(|v| CowStr::from(v.to_string())),
2137 )
2138 })
2139 .collect(),
2140 },
2141 Tag::BlockQuote(kind) => Tag::BlockQuote(*kind),
2142 Tag::CodeBlock(CodeBlockKind::Fenced(info)) => {
2143 Tag::CodeBlock(CodeBlockKind::Fenced(CowStr::from(info.to_string())))
2144 }
2145 Tag::CodeBlock(CodeBlockKind::Indented) => Tag::CodeBlock(CodeBlockKind::Indented),
2146 Tag::List(start) => Tag::List(*start),
2147 Tag::Item => Tag::Item,
2148 Tag::Table(a) => Tag::Table(a.clone()),
2149 Tag::TableHead => Tag::TableHead,
2150 Tag::TableRow => Tag::TableRow,
2151 Tag::TableCell => Tag::TableCell,
2152 Tag::Emphasis => Tag::Emphasis,
2153 Tag::Strong => Tag::Strong,
2154 Tag::Strikethrough => Tag::Strikethrough,
2155 Tag::Image {
2156 link_type,
2157 dest_url,
2158 title,
2159 id,
2160 } => Tag::Image {
2161 link_type: *link_type,
2162 dest_url: CowStr::from(dest_url.to_string()),
2163 title: CowStr::from(title.to_string()),
2164 id: CowStr::from(id.to_string()),
2165 },
2166 Tag::Link {
2167 link_type,
2168 dest_url,
2169 title,
2170 id,
2171 } => Tag::Link {
2172 link_type: *link_type,
2173 dest_url: CowStr::from(dest_url.to_string()),
2174 title: CowStr::from(title.to_string()),
2175 id: CowStr::from(id.to_string()),
2176 },
2177 Tag::HtmlBlock => Tag::HtmlBlock,
2178 Tag::DefinitionList => Tag::DefinitionList,
2179 Tag::DefinitionListTitle => Tag::DefinitionListTitle,
2180 Tag::DefinitionListDefinition => Tag::DefinitionListDefinition,
2181 other => panic!("unhandled tag in test helper: {other:?}"),
2182 }
2183 }
2184
2185 #[test]
2187 fn block_line_attribute_on_each_tag() {
2188 let events: Vec<Event<'static>> = vec![
2189 Start(Tag::Paragraph), Text(CowStr::Borrowed("p")), End(TagEnd::Paragraph), Start(Tag::Heading {
2193 level: pulldown_cmark::HeadingLevel::H2,
2195 id: None,
2196 classes: vec![],
2197 attrs: vec![],
2198 }),
2199 Text(CowStr::Borrowed("h")), End(TagEnd::Heading(pulldown_cmark::HeadingLevel::H2)), Start(Tag::List(None)), Start(Tag::Item), Text(CowStr::Borrowed("li")), End(TagEnd::Item), End(TagEnd::List(false)), Start(Tag::BlockQuote(None)), End(TagEnd::BlockQuote(None)), Start(Tag::CodeBlock(CodeBlockKind::Fenced(CowStr::Borrowed("")))), End(TagEnd::CodeBlock), Start(Tag::Table(vec![Alignment::None])), Start(Tag::TableHead), Start(Tag::TableCell), End(TagEnd::TableCell), End(TagEnd::TableHead), End(TagEnd::Table), ];
2217 let html = render_with_block_lines(
2219 events,
2220 &[
2221 (0, 11), (3, 22), (6, 33), (7, 44), (11, 55), (13, 66), (15, 77), (17, 88), ],
2230 );
2231
2232 assert!(html.contains(r#"<p data-mbr-line="11">"#), "{html}");
2233 assert!(html.contains(r#"<h2 data-mbr-line="22">"#), "{html}");
2234 assert!(html.contains(r#"<li data-mbr-line="44">"#), "{html}");
2235 assert!(
2236 html.contains(r#"<blockquote data-mbr-line="55">"#),
2237 "{html}"
2238 );
2239 assert!(html.contains(r#"<pre data-mbr-line="66"><code>"#), "{html}");
2240 assert!(
2241 html.contains(r#"<div class="mbr-table-scroll"><table data-mbr-line="77">"#),
2242 "the line stays on the <table>, inside its scroll wrapper: {html}"
2243 );
2244
2245 assert!(html.contains("<ul>"), "no attribute on <ul>: {html}");
2249 assert!(html.contains("<th>"), "no attribute on <th>: {html}");
2250 assert!(!html.contains(r#""33""#), "{html}");
2251 assert!(!html.contains(r#""88""#), "{html}");
2252 }
2253
2254 #[test]
2259 fn block_line_index_survives_image_alt_text() {
2260 let markdown =
2264 "\n\nSecond paragraph.\n\nThird one.\n";
2265 let events: Vec<Event<'static>> = Parser::new_ext(markdown, Options::all())
2266 .map(|e| owned_event(&e))
2267 .collect();
2268
2269 let paragraph_starts: Vec<usize> = events
2271 .iter()
2272 .enumerate()
2273 .filter(|(_, e)| matches!(e, Start(Tag::Paragraph)))
2274 .map(|(i, _)| i)
2275 .collect();
2276 assert_eq!(
2277 paragraph_starts.len(),
2278 3,
2279 "the image paragraph plus two more: {events:?}"
2280 );
2281 let pairs = [
2282 (paragraph_starts[0] as u32, 1),
2283 (paragraph_starts[1] as u32, 3),
2284 (paragraph_starts[2] as u32, 5),
2285 ];
2286
2287 let html = render_with_block_lines(events, &pairs);
2288 assert!(
2289 html.contains(r#"<p data-mbr-line="3">Second paragraph.</p>"#),
2290 "the paragraph after the image must keep its own line: {html}"
2291 );
2292 assert!(
2293 html.contains(r#"<p data-mbr-line="5">Third one.</p>"#),
2294 "and so must the one after that: {html}"
2295 );
2296 }
2297
2298 #[test]
2302 fn block_line_attribute_on_definition_list_tags() {
2303 let markdown = "Term\n: answer\n";
2304 let html = render_markdown_with_block_lines(markdown, |e| {
2305 matches!(
2306 e,
2307 Start(Tag::DefinitionListTitle | Tag::DefinitionListDefinition)
2308 )
2309 });
2310 assert!(
2311 html.contains(r#"<dt data-mbr-line="2" tabindex="0">Term</dt>"#),
2312 "{html}"
2313 );
2314 assert!(
2315 html.contains(r#"<dd data-mbr-line="5">answer</dd>"#),
2316 "{html}"
2317 );
2318
2319 let plain = render_markdown_with_block_lines(markdown, |_| false);
2320 assert!(
2321 plain.contains("<dt tabindex=\"0\">Term</dt>\n<dd>answer</dd>"),
2322 "{plain}"
2323 );
2324 }
2325
2326 #[test]
2332 fn block_lines_holding_only_terms_number_only_terms() {
2333 let markdown = "Para\n\n- item\n\nTerm\n: answer\n";
2334 let html = render_markdown_with_block_lines(markdown, |e| {
2335 matches!(e, Start(Tag::DefinitionListTitle))
2336 });
2337 assert!(html.contains("<p>Para</p>"), "{html}");
2338 assert!(html.contains("<li>item</li>"), "{html}");
2339 assert!(html.contains("<dd>answer</dd>"), "{html}");
2340 assert_eq!(html.matches("data-mbr-line=\"").count(), 1, "{html}");
2341 assert!(html.contains("<dt data-mbr-line="), "{html}");
2342 }
2343
2344 #[test]
2345 fn empty_block_lines_emits_no_attribute() {
2346 let html = render_with_block_lines(
2347 vec![
2348 Start(Tag::Paragraph),
2349 Text(CowStr::Borrowed("p")),
2350 End(TagEnd::Paragraph),
2351 ],
2352 &[],
2353 );
2354 assert_eq!(html, "<p>p</p>\n");
2355 assert!(!html.contains("data-mbr-line"));
2356 }
2357
2358 #[test]
2361 fn our_heading_line_precedes_author_attrs() {
2362 let html =
2363 render_markdown_with_block_lines("## Title {#custom .cls data-mbr-line=99}\n", |e| {
2364 matches!(e, Start(Tag::Heading { .. }))
2365 });
2366 let ours = html.find(r#"data-mbr-line="1""#).expect(&html);
2367 let theirs = html.find(r#"data-mbr-line="99""#).expect(&html);
2368 assert!(
2369 ours < theirs,
2370 "ours must come first so a parser prefers it: {html}"
2371 );
2372 assert!(html.contains(r#"id="custom""#), "{html}");
2374 assert!(html.contains(r#"class="cls""#), "{html}");
2375 }
2376
2377 #[test]
2378 fn mermaid_pre_carries_block_line() {
2379 let html = render_markdown_with_block_lines("```mermaid\ngraph TD\n```\n", |e| {
2380 matches!(e, Start(Tag::CodeBlock(_)))
2381 });
2382 assert!(
2383 html.contains(r#"<pre class="mermaid" data-mbr-line="1">"#),
2384 "{html}"
2385 );
2386 assert!(
2387 !html.contains("<code"),
2388 "mermaid keeps its bare <pre>: {html}"
2389 );
2390 }
2391
2392 #[test]
2393 fn fenced_lang_pre_carries_block_line() {
2394 let html = render_markdown_with_block_lines("```rust\nfn main() {}\n```\n", |e| {
2395 matches!(e, Start(Tag::CodeBlock(_)))
2396 });
2397 assert!(
2398 html.contains(r#"<pre data-mbr-line="1"><code class="language-rust">"#),
2399 "{html}"
2400 );
2401 }
2402
2403 #[test]
2404 fn table_is_wrapped_in_scroll_container() {
2405 let html = render_mbr("| a | b |\n|---|---|\n| 1 | 2 |\n\nafter\n");
2406 let open = r#"<div class="mbr-table-scroll"><table>"#;
2407 assert!(html.contains(open), "{html}");
2408 assert!(html.contains("</tbody></table></div>\n"), "{html}");
2409 assert_eq!(html.matches("mbr-table-scroll").count(), 1, "{html}");
2411 let wrapper_end = html.find("</table></div>").expect("closed wrapper");
2412 let next_block = html.find("<p>after</p>").expect("following paragraph");
2413 assert!(wrapper_end < next_block, "{html}");
2414 }
2415
2416 #[test]
2417 fn every_table_gets_its_own_scroll_container() {
2418 let html = render_mbr("| a |\n|---|\n| 1 |\n\n> | b |\n> |---|\n> | 2 |\n");
2419 assert_eq!(
2420 html.matches(r#"<div class="mbr-table-scroll">"#).count(),
2421 2,
2422 "{html}"
2423 );
2424 assert_eq!(html.matches("</table></div>").count(), 2, "{html}");
2425 assert!(
2426 html.contains("<blockquote>\n<div class=\"mbr-table-scroll\"><table>"),
2427 "a nested table is wrapped where it stands: {html}"
2428 );
2429 }
2430}