1use super::document::{BlockMeta, Document};
48use super::hooks::{escape_attr, escape_text, RenderHooks};
49use super::node::{Block, Fold, Inline};
50use super::url::Url;
51
52pub fn render_document<H: RenderHooks>(doc: &Document, hooks: &H) -> String {
60 let mut out = String::new();
61 debug_assert_eq!(
64 doc.blocks.len(),
65 doc.block_meta.len(),
66 "Document invariant: blocks.len() == block_meta.len()"
67 );
68 for (i, block) in doc.blocks.iter().enumerate() {
69 let meta = doc.block_meta.get(i).copied().unwrap_or_default();
70 render_block(hooks, &mut out, block, &meta);
71 }
72 out
73}
74
75pub fn render_blocks<H: RenderHooks + ?Sized>(hooks: &H, out: &mut String, blocks: &[Block]) {
93 for block in blocks {
94 render_block(hooks, out, block, &BlockMeta::default());
101 }
102}
103
104fn render_block<H: RenderHooks + ?Sized>(
105 hooks: &H,
106 out: &mut String,
107 block: &Block,
108 meta: &BlockMeta,
109) {
110 match block {
111 Block::Heading {
112 level,
113 children,
114 id,
115 } => {
116 let mut content = String::new();
117 render_inlines(hooks, &mut content, children);
118 hooks.render_heading(out, *level, id.as_deref(), meta.source_line, &content);
119 out.push('\n');
120 }
121 Block::Paragraph(children) => {
122 out.push_str("<p");
123 push_source_line_attr(out, meta.source_line);
124 out.push('>');
125 render_inlines(hooks, out, children);
126 out.push_str("</p>\n");
127 }
128 Block::Callout {
129 kind,
130 fold,
131 title,
132 children,
133 } => {
134 out.push_str(r#"<div class="callout" data-type=""#);
150 out.push_str(kind.as_slug());
151 out.push_str(r#"""#);
152 push_source_line_attr(out, meta.source_line);
153 if let Some(fold_state) = fold {
154 let fold_attr = match fold_state {
155 Fold::Open => "open",
156 Fold::Closed => "closed",
157 };
158 out.push_str(r#" data-fold=""#);
159 out.push_str(fold_attr);
160 out.push_str(r#"""#);
161 }
162 out.push_str(">\n");
163 let display_title = title
166 .as_deref()
167 .map(|t| t.trim())
168 .filter(|t| !t.is_empty())
169 .map(|t| escape_text(t))
170 .unwrap_or_else(|| kind.default_title().to_string());
171 out.push_str(r#" <div class="callout-title">"#);
172 out.push_str(&display_title);
173 out.push_str("</div>\n");
174 out.push_str(r#" <div class="callout-content">"#);
175 out.push('\n');
176 render_blocks(hooks, out, children);
177 out.push_str("</div>\n");
178 out.push_str("</div>\n");
179 }
180 Block::List {
181 ordered,
182 start,
183 items,
184 item_source_lines,
185 } => {
186 debug_assert!(
194 item_source_lines.is_empty() || item_source_lines.len() == items.len(),
195 "Block::List invariant: item_source_lines.len() ({}) must equal items.len() ({}) when populated",
196 item_source_lines.len(),
197 items.len()
198 );
199 if *ordered {
200 out.push_str("<ol");
201 if let Some(n) = start {
208 out.push_str(" start=\"");
209 out.push_str(&n.to_string());
210 out.push('"');
211 }
212 push_source_line_attr(out, meta.source_line);
213 out.push_str(">\n");
214 } else {
215 out.push_str("<ul");
216 push_source_line_attr(out, meta.source_line);
217 out.push_str(">\n");
218 }
219 for (idx, item_blocks) in items.iter().enumerate() {
220 out.push_str("<li");
227 let item_line = item_source_lines.get(idx).copied().flatten();
228 push_source_line_attr(out, item_line);
229 out.push('>');
230 if let [Block::Paragraph(inlines)] = item_blocks.as_slice() {
233 render_inlines(hooks, out, inlines);
234 } else {
235 out.push('\n');
236 render_blocks(hooks, out, item_blocks);
237 }
238 out.push_str("</li>\n");
239 }
240 if *ordered {
241 out.push_str("</ol>\n");
242 } else {
243 out.push_str("</ul>\n");
244 }
245 }
246 Block::CodeBlock { lang, value } => {
247 out.push_str("<pre");
248 push_source_line_attr(out, meta.source_line);
249 out.push('>');
250 match lang {
251 Some(l) => {
252 out.push_str(r#"<code class="language-"#);
253 out.push_str(&escape_attr(l));
254 out.push_str(r#"">"#);
255 }
256 None => out.push_str("<code>"),
257 }
258 out.push_str(&escape_text(value));
259 out.push_str("</code></pre>\n");
260 }
261 Block::Table {
262 header,
263 rows,
264 header_source_line,
265 row_source_lines,
266 } => {
267 debug_assert!(
273 row_source_lines.is_empty() || row_source_lines.len() == rows.len(),
274 "Block::Table invariant: row_source_lines.len() ({}) must equal rows.len() ({}) when populated",
275 row_source_lines.len(),
276 rows.len()
277 );
278 out.push_str("<table");
279 push_source_line_attr(out, meta.source_line);
280 out.push_str(">\n<thead>\n<tr");
281 push_source_line_attr(out, *header_source_line);
284 out.push('>');
285 for cell in header {
286 out.push_str("<th>");
287 render_inlines(hooks, out, cell);
288 out.push_str("</th>");
289 }
290 out.push_str("</tr>\n</thead>\n");
291 if !rows.is_empty() {
292 out.push_str("<tbody>\n");
293 for (idx, row) in rows.iter().enumerate() {
294 out.push_str("<tr");
295 let row_line = row_source_lines.get(idx).copied().flatten();
296 push_source_line_attr(out, row_line);
297 out.push('>');
298 for cell in row {
299 out.push_str("<td>");
300 render_inlines(hooks, out, cell);
301 out.push_str("</td>");
302 }
303 out.push_str("</tr>\n");
304 }
305 out.push_str("</tbody>\n");
306 }
307 out.push_str("</table>\n");
308 }
309 Block::BlockQuote(children) => {
310 out.push_str("<blockquote");
311 push_source_line_attr(out, meta.source_line);
312 out.push_str(">\n");
313 render_blocks(hooks, out, children);
314 out.push_str("</blockquote>\n");
315 }
316 Block::Shortcode(sc) => {
317 hooks.render_shortcode(out, sc);
318 out.push('\n');
319 }
320 Block::ThematicBreak => {
321 out.push_str("<hr");
322 push_source_line_attr(out, meta.source_line);
323 out.push_str(" />\n");
324 }
325 Block::Figure {
326 image,
327 caption,
328 width,
329 align,
330 class_names,
331 img_style,
332 } => {
333 let mut class_value = String::from("moss-image");
362 if let Some(a) = align {
363 class_value.push(' ');
364 class_value.push_str(a);
365 }
366 for cn in class_names {
367 if cn.is_empty() {
368 continue;
369 }
370 class_value.push(' ');
371 class_value.push_str(cn);
372 }
373 out.push_str(r#"<figure class=""#);
374 out.push_str(&escape_attr(&class_value));
375 out.push('"');
376 if let Some(w) = width {
377 out.push_str(r#" data-width=""#);
378 out.push_str(&escape_attr(w));
379 out.push('"');
380 }
381 push_source_line_attr(out, meta.source_line);
382 out.push('>');
383 match image {
387 Inline::Image {
388 src, alt, title, ..
389 } => match src {
390 Url::Resolved(r) => {
391 hooks.render_image_styled(out, r, alt, title.as_deref(), img_style.as_deref());
392 }
393 Url::Unresolved(s) => {
394 debug_assert!(
395 false,
396 "Url::Unresolved({s:?}) reached Block::Figure renderer — visit_urls_mut missing or buggy"
397 );
398 out.push_str(r#"<img src=""#);
399 out.push_str(&escape_attr(s));
400 out.push_str(r#"" alt=""#);
401 out.push_str(&escape_attr(alt));
402 out.push_str(r#"" />"#);
403 }
404 },
405 _ => {
406 render_inline(hooks, out, image);
410 }
411 }
412 if let Some(cap_inlines) = caption {
413 if !cap_inlines.is_empty() {
414 out.push_str("<figcaption>");
415 render_inlines(hooks, out, cap_inlines);
416 out.push_str("</figcaption>");
417 }
418 }
419 out.push_str("</figure>\n");
420 }
421 Block::LinkCard { url, children } => {
422 let resolved = match url {
432 Url::Resolved(r) => r,
433 Url::Unresolved(s) => {
434 debug_assert!(
435 false,
436 "Url::Unresolved({s:?}) reached Block::LinkCard renderer — visit_urls_mut missing or buggy"
437 );
438 out.push_str(r#"<a href=""#);
439 out.push_str(&escape_attr(s));
440 out.push_str(r#"" class="moss-grid-card" data-kind="link">"#);
441 render_blocks(hooks, out, children);
442 out.push_str("</a>");
443 return;
444 }
445 };
446 use super::url::UrlKind;
447 let is_external = matches!(resolved.kind, UrlKind::External | UrlKind::AssetNewtab);
448 if is_external {
449 out.push_str(r#"<a href=""#);
450 out.push_str(&escape_attr(&resolved.href));
451 out.push_str(
452 r#"" class="moss-grid-card link-preview" target="_blank" rel="noopener">"#,
453 );
454 } else {
455 out.push_str(r#"<a href=""#);
456 out.push_str(&escape_attr(&resolved.href));
457 out.push_str(r#"" class="moss-grid-card" data-kind="link">"#);
458 }
459 render_blocks(hooks, out, children);
460 out.push_str("</a>");
461 }
462 Block::Other(html) => {
463 out.push_str(html);
464 }
465 }
466}
467
468fn push_source_line_attr(out: &mut String, source_line: Option<usize>) {
480 if let Some(n) = source_line {
481 use std::fmt::Write as _;
482 let _ = write!(out, r#" data-source-line="{}""#, n);
485 }
486}
487
488pub(super) fn render_inlines<H: RenderHooks + ?Sized>(
489 hooks: &H,
490 out: &mut String,
491 inlines: &[Inline],
492) {
493 for inline in inlines {
494 render_inline(hooks, out, inline);
495 }
496}
497
498fn render_inline<H: RenderHooks + ?Sized>(hooks: &H, out: &mut String, inline: &Inline) {
499 match inline {
500 Inline::Text(t) => out.push_str(&escape_text(t)),
501 Inline::Link {
502 url,
503 title: _title,
504 children,
505 is_wikilink,
506 } => {
507 let resolved = match url {
508 Url::Resolved(r) => r,
509 Url::Unresolved(s) => {
510 debug_assert!(
511 false,
512 "Url::Unresolved({s:?}) reached renderer — visit_urls_mut missing or buggy"
513 );
514 out.push_str(r#"<a href=""#);
517 out.push_str(&escape_attr(s));
518 out.push_str(r#"">"#);
519 render_inlines(hooks, out, children);
520 out.push_str("</a>");
521 return;
522 }
523 };
524 let mut content = String::new();
525 render_inlines(hooks, &mut content, children);
526 hooks.render_link(out, resolved, *is_wikilink, &content);
534 }
535 Inline::Image {
536 src, alt, title, ..
537 } => {
538 let resolved = match src {
539 Url::Resolved(r) => r,
540 Url::Unresolved(s) => {
541 debug_assert!(
542 false,
543 "Url::Unresolved({s:?}) reached renderer — visit_urls_mut missing or buggy"
544 );
545 out.push_str(r#"<img src=""#);
546 out.push_str(&escape_attr(s));
547 out.push_str(r#"" alt=""#);
548 out.push_str(&escape_attr(alt));
549 out.push_str(r#"" />"#);
550 return;
551 }
552 };
553 hooks.render_image(out, resolved, alt, title.as_deref());
554 }
555 Inline::Emphasis(children) => {
556 out.push_str("<em>");
557 render_inlines(hooks, out, children);
558 out.push_str("</em>");
559 }
560 Inline::Strong(children) => {
561 out.push_str("<strong>");
562 render_inlines(hooks, out, children);
563 out.push_str("</strong>");
564 }
565 Inline::Code(c) => {
566 out.push_str("<code>");
567 out.push_str(&escape_text(c));
568 out.push_str("</code>");
569 }
570 Inline::LineBreak => out.push_str("<br />\n"),
571 Inline::Other(html) => out.push_str(html),
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::super::hooks::DefaultHooks;
578 use super::super::node::Inline;
579 use super::super::url::{Url, UrlKind};
580 use super::*;
581
582 fn render(blocks: Vec<Block>) -> String {
583 let doc = Document::from_blocks(blocks);
584 render_document(&doc, &DefaultHooks::new())
585 }
586
587 #[test]
588 fn renders_empty_document_to_empty_string() {
589 assert_eq!(render(vec![]), "");
590 }
591
592 #[test]
593 fn renders_paragraph() {
594 let html = render(vec![Block::Paragraph(vec![Inline::Text("hi".into())])]);
595 assert_eq!(html, "<p>hi</p>\n");
596 }
597
598 #[test]
599 fn renders_heading_with_id() {
600 let html = render(vec![Block::Heading {
601 level: 2,
602 children: vec![Inline::Text("Setup".into())],
603 id: Some("setup".into()),
604 }]);
605 assert_eq!(html, "<h2 id=\"setup\">Setup<a class=\"moss-heading-anchor\" href=\"#setup\" aria-label=\"Permalink to this section\"><span aria-hidden=\"true\">#</span></a></h2>\n");
606 }
607
608 #[test]
609 fn renders_resolved_link_internal() {
610 let html = render(vec![Block::Paragraph(vec![Inline::Link {
611 url: Url::resolved("docs/", UrlKind::Internal),
612 title: None,
613 children: vec![Inline::Text("Docs".into())],
614 is_wikilink: false,
615 }])]);
616 assert_eq!(html, "<p><a href=\"docs/\">Docs</a></p>\n");
617 }
618
619 #[test]
620 fn renders_resolved_link_wikilink_carries_class() {
621 let html = render(vec![Block::Paragraph(vec![Inline::Link {
624 url: Url::resolved("../docs/", UrlKind::Wikilink),
625 title: None,
626 children: vec![Inline::Text("Docs".into())],
627 is_wikilink: false,
628 }])]);
629 assert!(html.contains(r#"class="wikilink""#), "got: {html}");
630 }
631
632 #[test]
633 fn renders_link_with_is_wikilink_flag_emits_class() {
634 let html = render(vec![Block::Paragraph(vec![Inline::Link {
638 url: Url::resolved("../docs/", UrlKind::Internal),
639 title: None,
640 children: vec![Inline::Text("Docs".into())],
641 is_wikilink: true,
642 }])]);
643 assert!(
644 html.contains(r#"class="wikilink""#),
645 "is_wikilink: true should produce class=\"wikilink\"; got: {html}"
646 );
647 }
648
649 #[test]
650 fn renders_resolved_image() {
651 let html = render(vec![Block::Paragraph(vec![Inline::Image {
652 src: Url::resolved("cat.jpg", UrlKind::Asset),
653 alt: "Cat".into(),
654 title: None,
655 is_wikilink: false,
656 wikilink_pothole: None,
657 }])]);
658 assert_eq!(html, "<p><img src=\"cat.jpg\" alt=\"Cat\" /></p>\n");
659 }
660
661 #[test]
662 fn renders_emphasis_and_strong() {
663 let html = render(vec![Block::Paragraph(vec![
664 Inline::Emphasis(vec![Inline::Text("em".into())]),
665 Inline::Text(" ".into()),
666 Inline::Strong(vec![Inline::Text("strong".into())]),
667 ])]);
668 assert_eq!(html, "<p><em>em</em> <strong>strong</strong></p>\n");
669 }
670
671 #[test]
672 fn renders_inline_code_with_escaping() {
673 let html = render(vec![Block::Paragraph(vec![Inline::Code("a<b>c".into())])]);
674 assert_eq!(html, "<p><code>a<b>c</code></p>\n");
675 }
676
677 #[test]
678 fn renders_unordered_list_tight() {
679 let html = render(vec![Block::List {
680 ordered: false,
681 start: None,
682 items: vec![
683 vec![Block::Paragraph(vec![Inline::Text("one".into())])],
684 vec![Block::Paragraph(vec![Inline::Text("two".into())])],
685 ],
686 item_source_lines: vec![],
687 }]);
688 assert_eq!(html, "<ul>\n<li>one</li>\n<li>two</li>\n</ul>\n");
689 }
690
691 #[test]
692 fn renders_ordered_list() {
693 let html = render(vec![Block::List {
694 ordered: true,
695 start: None,
696 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
697 item_source_lines: vec![],
698 }]);
699 assert!(html.starts_with("<ol>"));
700 }
701
702 #[test]
703 fn render_ordered_list_emits_start_attribute_when_non_default() {
704 let html = render(vec![Block::List {
708 ordered: true,
709 start: Some(3),
710 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
711 item_source_lines: vec![],
712 }]);
713 assert!(
714 html.starts_with(r#"<ol start="3">"#),
715 "expected start attr immediately after <ol, got: {html}"
716 );
717 }
718
719 #[test]
720 fn render_ordered_list_omits_start_when_default_1() {
721 let html = render(vec![Block::List {
725 ordered: true,
726 start: None,
727 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
728 item_source_lines: vec![],
729 }]);
730 assert!(html.starts_with("<ol>"), "expected bare <ol>, got: {html}");
731 assert!(
732 !html.contains("start="),
733 "ordered list with default start should not emit start attr, got: {html}"
734 );
735 }
736
737 #[test]
738 fn render_unordered_list_emits_no_start() {
739 let html = render(vec![Block::List {
743 ordered: false,
744 start: Some(5),
745 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
746 item_source_lines: vec![],
747 }]);
748 assert!(html.starts_with("<ul>"), "expected bare <ul>, got: {html}");
749 assert!(
750 !html.contains("start="),
751 "unordered list must never carry start attr, got: {html}"
752 );
753 }
754
755 #[test]
756 fn renders_code_block_with_lang() {
757 let html = render(vec![Block::CodeBlock {
758 lang: Some("rust".into()),
759 value: "fn main() {}".into(),
760 }]);
761 assert_eq!(
762 html,
763 "<pre><code class=\"language-rust\">fn main() {}</code></pre>\n"
764 );
765 }
766
767 #[test]
768 fn renders_code_block_without_lang() {
769 let html = render(vec![Block::CodeBlock {
770 lang: None,
771 value: "bare".into(),
772 }]);
773 assert_eq!(html, "<pre><code>bare</code></pre>\n");
774 }
775
776 #[test]
777 fn renders_thematic_break() {
778 let html = render(vec![Block::ThematicBreak]);
779 assert_eq!(html, "<hr />\n");
780 }
781
782 use super::super::node::{CalloutKind, Fold};
787
788 #[test]
789 fn renders_basic_callout_with_title() {
790 let html = render(vec![Block::Callout {
791 kind: CalloutKind::Note,
792 fold: None,
793 title: Some("Heads up".into()),
794 children: vec![Block::Paragraph(vec![Inline::Text("Body.".into())])],
795 }]);
796 assert!(
797 html.contains(r#"<div class="callout" data-type="note">"#),
798 "expected callout div with data-type, got: {html}"
799 );
800 assert!(
801 html.contains(r#"<div class="callout-title">Heads up</div>"#),
802 "expected inline title slot, got: {html}"
803 );
804 assert!(
805 html.contains(r#"<div class="callout-content">"#),
806 "expected content slot, got: {html}"
807 );
808 assert!(html.contains("<p>Body.</p>"), "body must render: {html}");
809 }
810
811 #[test]
812 fn renders_callout_falls_back_to_default_title() {
813 let html = render(vec![Block::Callout {
814 kind: CalloutKind::Warning,
815 fold: None,
816 title: None,
817 children: vec![],
818 }]);
819 assert!(
820 html.contains(r#"<div class="callout-title">Warning</div>"#),
821 "expected capitalized fallback title, got: {html}"
822 );
823 }
824
825 #[test]
826 fn renders_foldable_callout_with_data_fold_attribute() {
827 let html_open = render(vec![Block::Callout {
828 kind: CalloutKind::Tip,
829 fold: Some(Fold::Open),
830 title: Some("Open".into()),
831 children: vec![],
832 }]);
833 assert!(
834 html_open.contains(r#"data-type="tip""#) && html_open.contains(r#"data-fold="open""#),
835 "expected data-fold='open' attribute, got: {html_open}"
836 );
837
838 let html_closed = render(vec![Block::Callout {
839 kind: CalloutKind::Tip,
840 fold: Some(Fold::Closed),
841 title: None,
842 children: vec![],
843 }]);
844 assert!(
845 html_closed.contains(r#"data-fold="closed""#),
846 "expected data-fold='closed' attribute, got: {html_closed}"
847 );
848 }
849
850 #[test]
851 fn callout_alias_renders_canonical_data_type_slug() {
852 let html = render(vec![Block::Callout {
855 kind: CalloutKind::Abstract,
856 fold: None,
857 title: Some("TL;DR".into()),
858 children: vec![],
859 }]);
860 assert!(
861 html.contains(r#"data-type="abstract""#),
862 "expected canonical slug 'abstract', got: {html}"
863 );
864 }
865
866 #[test]
867 fn callout_title_is_html_escaped() {
868 let html = render(vec![Block::Callout {
873 kind: CalloutKind::Warning,
874 fold: None,
875 title: Some(r#"Use <script> & "quotes""#.into()),
876 children: vec![],
877 }]);
878 assert!(
879 html.contains("Use <script> &"),
880 "title must escape lt/gt/amp, got: {html}"
881 );
882 assert!(
884 !html.contains("<div class=\"callout-title\">Use <script>"),
885 "unescaped angle brackets leaked, got: {html}"
886 );
887 }
888
889 #[test]
890 fn renders_blockquote_with_paragraph() {
891 let html = render(vec![Block::BlockQuote(vec![Block::Paragraph(vec![
892 Inline::Text("q".into()),
893 ])])]);
894 assert_eq!(html, "<blockquote>\n<p>q</p>\n</blockquote>\n");
895 }
896
897 #[test]
898 fn renders_table() {
899 let html = render(vec![Block::Table {
900 header: vec![vec![Inline::Text("A".into())]],
901 rows: vec![vec![vec![Inline::Text("1".into())]]],
902 header_source_line: None,
903 row_source_lines: vec![],
904 }]);
905 assert!(html.contains("<thead>"));
906 assert!(html.contains("<tbody>"));
907 assert!(html.contains("<th>A</th>"));
908 assert!(html.contains("<td>1</td>"));
909 }
910
911 #[test]
912 fn renders_other_block_passes_html_through() {
913 let html = render(vec![Block::Other("<custom></custom>".into())]);
914 assert_eq!(html, "<custom></custom>");
915 }
916
917 #[test]
918 fn text_escapes_lt_gt_amp() {
919 let html = render(vec![Block::Paragraph(vec![Inline::Text("a<b>c&d".into())])]);
920 assert_eq!(html, "<p>a<b>c&d</p>\n");
921 }
922
923 #[test]
924 fn round_trips_parse_to_render_for_canonical_doc() {
925 let md = "# Title\n\npara with [link](docs/) and *em*.\n";
931 let mut doc = super::super::parser::parse(md);
932 super::super::visit::visit_urls_mut(&mut doc, |u| match u {
933 Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Internal),
934 _ => {}
935 });
936 let html = render_document(&doc, &DefaultHooks::new());
937 assert!(html.contains(r##"<h1 id="title">Title<a class="moss-heading-anchor" href="#title" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h1>"##), "got: {html}");
938 assert!(html.contains(r#"<a href="docs/">link</a>"#));
939 assert!(html.contains("<em>em</em>"));
940 }
941
942 #[test]
947 fn figure_renders_with_caption() {
948 let html = render(vec![Block::Figure {
953 image: Inline::Image {
954 src: Url::resolved("logo.png", UrlKind::Asset),
955 alt: "A logo".into(),
956 title: None,
957 is_wikilink: false,
958 wikilink_pothole: None,
959 },
960 caption: Some(vec![Inline::Text("A logo".into())]),
961 width: None,
962 align: None,
963 class_names: Vec::new(),
964 img_style: None,
965 }]);
966 assert!(
967 html.starts_with(r#"<figure class="moss-image">"#),
968 "expected figure wrap, got: {html}"
969 );
970 assert!(html.contains(r#"src="logo.png""#), "got: {html}");
971 assert!(html.contains(r#"alt="A logo""#), "got: {html}");
972 assert!(
973 html.contains("<figcaption>A logo</figcaption>"),
974 "got: {html}"
975 );
976 assert!(html.ends_with("</figure>\n"), "got: {html}");
977 }
978
979 #[test]
980 fn figure_renders_without_caption_when_none() {
981 let html = render(vec![Block::Figure {
983 image: Inline::Image {
984 src: Url::resolved("x.png", UrlKind::Asset),
985 alt: String::new(),
986 title: None,
987 is_wikilink: false,
988 wikilink_pothole: None,
989 },
990 caption: None,
991 width: None,
992 align: None,
993 class_names: Vec::new(),
994 img_style: None,
995 }]);
996 assert!(html.contains("<figure"), "got: {html}");
997 assert!(
998 !html.contains("<figcaption"),
999 "expected no figcaption, got: {html}"
1000 );
1001 assert!(html.contains("</figure>"), "got: {html}");
1002 }
1003
1004 #[test]
1005 fn figure_renders_no_figcaption_for_empty_caption_vec() {
1006 let html = render(vec![Block::Figure {
1008 image: Inline::Image {
1009 src: Url::resolved("x.png", UrlKind::Asset),
1010 alt: "x".into(),
1011 title: None,
1012 is_wikilink: false,
1013 wikilink_pothole: None,
1014 },
1015 caption: Some(vec![]),
1016 width: None,
1017 align: None,
1018 class_names: Vec::new(),
1019 img_style: None,
1020 }]);
1021 assert!(!html.contains("<figcaption"), "got: {html}");
1022 }
1023
1024 #[test]
1025 fn figure_caption_escapes_html_unsafe_chars() {
1026 let html = render(vec![Block::Figure {
1030 image: Inline::Image {
1031 src: Url::resolved("p.jpg", UrlKind::Asset),
1032 alt: "a<b>c".into(),
1033 title: None,
1034 is_wikilink: false,
1035 wikilink_pothole: None,
1036 },
1037 caption: Some(vec![Inline::Text("a<b>c".into())]),
1038 width: None,
1039 align: None,
1040 class_names: Vec::new(),
1041 img_style: None,
1042 }]);
1043 assert!(
1044 html.contains("<figcaption>a<b>c</figcaption>"),
1045 "got: {html}"
1046 );
1047 }
1048
1049 #[test]
1050 fn figure_end_to_end_from_parser_to_render() {
1051 let md = "\n";
1053 let mut doc = super::super::parser::parse(md);
1054 super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1055 Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Asset),
1056 _ => {}
1057 });
1058 let html = render_document(&doc, &DefaultHooks::new());
1059 assert!(
1060 html.contains(r#"<figure class="moss-image">"#),
1061 "expected figure, got: {html}"
1062 );
1063 assert!(html.contains(r#"src="photo.jpg""#), "got: {html}");
1064 assert!(
1065 html.contains("<figcaption>A photo</figcaption>"),
1066 "got: {html}"
1067 );
1068 }
1069
1070 #[test]
1071 fn paragraph_with_image_and_text_does_not_become_figure() {
1072 let md = " plain text\n";
1076 let mut doc = super::super::parser::parse(md);
1077 super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1078 Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Asset),
1079 _ => {}
1080 });
1081 let html = render_document(&doc, &DefaultHooks::new());
1082 assert!(
1083 !html.contains("<figure"),
1084 "image+text must not be wrapped in figure, got: {html}"
1085 );
1086 assert!(html.contains("plain text"), "got: {html}");
1087 }
1088
1089 #[test]
1090 #[cfg(debug_assertions)]
1091 #[should_panic(expected = "visit_urls_mut missing")]
1092 fn unresolved_url_in_link_panics_in_debug() {
1093 let _ = render(vec![Block::Paragraph(vec![Inline::Link {
1095 url: Url::unresolved("docs/"),
1096 title: None,
1097 children: vec![],
1098 is_wikilink: false,
1099 }])]);
1100 }
1101
1102 fn render_with_meta(blocks: Vec<Block>, meta: Vec<BlockMeta>) -> String {
1109 let doc = Document::from_blocks_with_meta(blocks, meta);
1110 render_document(&doc, &DefaultHooks::new())
1111 }
1112
1113 #[test]
1114 fn paragraph_emits_data_source_line_when_meta_set() {
1115 let html = render_with_meta(
1116 vec![Block::Paragraph(vec![Inline::Text("hi".into())])],
1117 vec![BlockMeta {
1118 source_line: Some(7),
1119 }],
1120 );
1121 assert_eq!(html, "<p data-source-line=\"7\">hi</p>\n");
1122 }
1123
1124 #[test]
1125 fn heading_emits_data_source_line_through_hook() {
1126 let html = render_with_meta(
1127 vec![Block::Heading {
1128 level: 2,
1129 children: vec![Inline::Text("Setup".into())],
1130 id: Some("setup".into()),
1131 }],
1132 vec![BlockMeta {
1133 source_line: Some(3),
1134 }],
1135 );
1136 assert!(
1137 html.contains(r##"<h2 id="setup" data-source-line="3">Setup<a class="moss-heading-anchor" href="#setup" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h2>"##),
1138 "got: {html}"
1139 );
1140 }
1141
1142 #[test]
1143 fn list_blockquote_codeblock_table_hr_emit_data_source_line() {
1144 let blocks = vec![
1148 Block::BlockQuote(vec![Block::Paragraph(vec![Inline::Text("q".into())])]),
1149 Block::List {
1150 ordered: false,
1151 start: None,
1152 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
1153 item_source_lines: vec![],
1154 },
1155 Block::List {
1156 ordered: true,
1157 start: None,
1158 items: vec![vec![Block::Paragraph(vec![Inline::Text("b".into())])]],
1159 item_source_lines: vec![],
1160 },
1161 Block::CodeBlock {
1162 lang: Some("rust".into()),
1163 value: "x".into(),
1164 },
1165 Block::Table {
1166 header: vec![vec![Inline::Text("H".into())]],
1167 rows: vec![vec![vec![Inline::Text("c".into())]]],
1168 header_source_line: None,
1169 row_source_lines: vec![],
1170 },
1171 Block::ThematicBreak,
1172 ];
1173 let meta = vec![
1174 BlockMeta {
1175 source_line: Some(1),
1176 },
1177 BlockMeta {
1178 source_line: Some(2),
1179 },
1180 BlockMeta {
1181 source_line: Some(3),
1182 },
1183 BlockMeta {
1184 source_line: Some(4),
1185 },
1186 BlockMeta {
1187 source_line: Some(5),
1188 },
1189 BlockMeta {
1190 source_line: Some(6),
1191 },
1192 ];
1193 let html = render_with_meta(blocks, meta);
1194 assert!(
1195 html.contains(r#"<blockquote data-source-line="1">"#),
1196 "blockquote missing: {html}"
1197 );
1198 assert!(
1199 html.contains(r#"<ul data-source-line="2">"#),
1200 "ul missing: {html}"
1201 );
1202 assert!(
1203 html.contains(r#"<ol data-source-line="3">"#),
1204 "ol missing: {html}"
1205 );
1206 assert!(
1207 html.contains(r#"<pre data-source-line="4">"#),
1208 "pre missing: {html}"
1209 );
1210 assert!(
1211 html.contains(r#"<table data-source-line="5">"#),
1212 "table missing: {html}"
1213 );
1214 assert!(
1215 html.contains(r#"<hr data-source-line="6" />"#),
1216 "hr missing: {html}"
1217 );
1218 }
1219
1220 #[test]
1221 fn list_emits_per_li_data_source_line_when_parser_tracks() {
1222 let blocks = vec![Block::List {
1229 ordered: false,
1230 start: None,
1231 items: vec![
1232 vec![Block::Paragraph(vec![Inline::Text("one".into())])],
1233 vec![Block::Paragraph(vec![Inline::Text("two".into())])],
1234 vec![Block::Paragraph(vec![Inline::Text("three".into())])],
1235 ],
1236 item_source_lines: vec![Some(10), Some(11), Some(12)],
1237 }];
1238 let meta = vec![BlockMeta {
1239 source_line: Some(10),
1240 }];
1241 let html = render_with_meta(blocks, meta);
1242 assert!(
1243 html.contains(r#"<ul data-source-line="10">"#),
1244 "ul opener missing: {html}"
1245 );
1246 assert!(
1247 html.contains(r#"<li data-source-line="10">one</li>"#),
1248 "li 10 missing: {html}"
1249 );
1250 assert!(
1251 html.contains(r#"<li data-source-line="11">two</li>"#),
1252 "li 11 missing: {html}"
1253 );
1254 assert!(
1255 html.contains(r#"<li data-source-line="12">three</li>"#),
1256 "li 12 missing: {html}"
1257 );
1258 }
1259
1260 #[test]
1261 fn list_omits_li_data_source_line_when_parser_did_not_track() {
1262 let blocks = vec![Block::List {
1267 ordered: false,
1268 start: None,
1269 items: vec![
1270 vec![Block::Paragraph(vec![Inline::Text("a".into())])],
1271 vec![Block::Paragraph(vec![Inline::Text("b".into())])],
1272 ],
1273 item_source_lines: vec![],
1274 }];
1275 let html = render_with_meta(blocks, vec![BlockMeta::default()]);
1276 assert_eq!(html, "<ul>\n<li>a</li>\n<li>b</li>\n</ul>\n");
1277 }
1278
1279 #[test]
1280 fn table_emits_per_tr_data_source_line_when_parser_tracks() {
1281 let blocks = vec![Block::Table {
1284 header: vec![vec![Inline::Text("H".into())]],
1285 rows: vec![
1286 vec![vec![Inline::Text("1".into())]],
1287 vec![vec![Inline::Text("2".into())]],
1288 vec![vec![Inline::Text("3".into())]],
1289 ],
1290 header_source_line: Some(5),
1291 row_source_lines: vec![Some(7), Some(8), Some(9)],
1292 }];
1293 let meta = vec![BlockMeta {
1294 source_line: Some(5),
1295 }];
1296 let html = render_with_meta(blocks, meta);
1297 assert!(
1298 html.contains(r#"<table data-source-line="5">"#),
1299 "table opener missing: {html}"
1300 );
1301 assert!(html.contains(r#"<thead>"#), "thead missing: {html}");
1302 assert!(
1306 html.contains(r#"<tr data-source-line="5"><th>H</th>"#),
1307 "head tr missing: {html}"
1308 );
1309 assert!(
1310 html.contains(r#"<tr data-source-line="7"><td>1</td>"#),
1311 "body tr 7 missing: {html}"
1312 );
1313 assert!(
1314 html.contains(r#"<tr data-source-line="8"><td>2</td>"#),
1315 "body tr 8 missing: {html}"
1316 );
1317 assert!(
1318 html.contains(r#"<tr data-source-line="9"><td>3</td>"#),
1319 "body tr 9 missing: {html}"
1320 );
1321 }
1322
1323 #[test]
1324 fn table_omits_tr_data_source_line_when_parser_did_not_track() {
1325 let blocks = vec![Block::Table {
1327 header: vec![vec![Inline::Text("A".into())]],
1328 rows: vec![vec![vec![Inline::Text("1".into())]]],
1329 header_source_line: None,
1330 row_source_lines: vec![],
1331 }];
1332 let html = render_with_meta(blocks, vec![BlockMeta::default()]);
1333 assert!(
1337 !html.contains("data-source-line"),
1338 "no annotation expected: {html}"
1339 );
1340 assert!(html.contains("<thead>"));
1341 assert!(html.contains("<tr><th>A</th></tr>"));
1342 assert!(html.contains("<tr><td>1</td></tr>"));
1343 }
1344
1345 #[test]
1346 fn figure_emits_data_source_line_on_outer_tag() {
1347 let blocks = vec![Block::Figure {
1348 image: Inline::Image {
1349 src: Url::resolved("p.jpg", UrlKind::Asset),
1350 alt: "A".into(),
1351 title: None,
1352 is_wikilink: false,
1353 wikilink_pothole: None,
1354 },
1355 caption: Some(vec![Inline::Text("A".into())]),
1356 width: None,
1357 align: None,
1358 class_names: Vec::new(),
1359 img_style: None,
1360 }];
1361 let meta = vec![BlockMeta {
1362 source_line: Some(9),
1363 }];
1364 let html = render_with_meta(blocks, meta);
1365 assert!(
1366 html.contains(r#"<figure class="moss-image" data-source-line="9">"#),
1367 "got: {html}"
1368 );
1369 }
1370
1371 #[test]
1372 fn no_data_source_line_when_meta_none() {
1373 let html = render(vec![
1376 Block::Paragraph(vec![Inline::Text("hi".into())]),
1377 Block::ThematicBreak,
1378 ]);
1379 assert!(
1380 !html.contains("data-source-line"),
1381 "default render must NOT emit data-source-line, got: {html}"
1382 );
1383 }
1384
1385 #[test]
1386 fn end_to_end_parse_with_config_emits_data_source_line() {
1387 let md = "# Title\n\nfirst paragraph\n\n## Sub\n\nsecond paragraph\n";
1389 let config = super::super::parser::ParseConfig {
1390 emit_source_lines: true,
1391 implicit_figure: true,
1392 };
1393 let mut doc = super::super::parser::parse_with_config(md, &config);
1394 super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1395 Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Internal),
1396 _ => {}
1397 });
1398 let html = render_document(&doc, &DefaultHooks::new());
1399 assert!(
1400 html.contains(r##"<h1 id="title" data-source-line="1">Title<a class="moss-heading-anchor" href="#title" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h1>"##),
1401 "H1 should carry data-source-line=1: {html}"
1402 );
1403 assert!(
1404 html.contains(r#"<p data-source-line="3">first paragraph</p>"#),
1405 "first paragraph should carry data-source-line=3: {html}"
1406 );
1407 assert!(
1408 html.contains(r##"<h2 id="sub" data-source-line="5">Sub<a class="moss-heading-anchor" href="#sub" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h2>"##),
1409 "H2 should carry data-source-line=5: {html}"
1410 );
1411 assert!(
1412 html.contains(r#"<p data-source-line="7">second paragraph</p>"#),
1413 "second paragraph should carry data-source-line=7: {html}"
1414 );
1415 }
1416}