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, meta.source_line);
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 if w.ends_with('%') {
378 out.push_str(r#" style="width:"#);
383 out.push_str(&escape_attr(w));
384 out.push('"');
385 } else {
386 out.push_str(r#" data-width=""#);
388 out.push_str(&escape_attr(w));
389 out.push('"');
390 }
391 }
392 push_source_line_attr(out, meta.source_line);
393 out.push('>');
394 match image {
398 Inline::Image {
399 src, alt, title, ..
400 } => match src {
401 Url::Resolved(r) => {
402 hooks.render_image_styled(out, r, alt, title.as_deref(), img_style.as_deref());
403 }
404 Url::Unresolved(s) => {
405 debug_assert!(
406 false,
407 "Url::Unresolved({s:?}) reached Block::Figure renderer — visit_urls_mut missing or buggy"
408 );
409 out.push_str(r#"<img src=""#);
410 out.push_str(&escape_attr(s));
411 out.push_str(r#"" alt=""#);
412 out.push_str(&escape_attr(alt));
413 out.push_str(r#"" />"#);
414 }
415 },
416 _ => {
417 render_inline(hooks, out, image);
421 }
422 }
423 if let Some(cap_inlines) = caption {
424 if !cap_inlines.is_empty() {
425 out.push_str("<figcaption>");
426 render_inlines(hooks, out, cap_inlines);
427 out.push_str("</figcaption>");
428 }
429 }
430 out.push_str("</figure>\n");
431 }
432 Block::LinkCard { url, children } => {
433 let resolved = match url {
443 Url::Resolved(r) => r,
444 Url::Unresolved(s) => {
445 debug_assert!(
446 false,
447 "Url::Unresolved({s:?}) reached Block::LinkCard renderer — visit_urls_mut missing or buggy"
448 );
449 out.push_str(r#"<a href=""#);
450 out.push_str(&escape_attr(s));
451 out.push_str(r#"" class="moss-grid-card" data-kind="link">"#);
452 render_blocks(hooks, out, children);
453 out.push_str("</a>");
454 return;
455 }
456 };
457 use super::url::UrlKind;
458 let is_external = matches!(resolved.kind, UrlKind::External | UrlKind::AssetNewtab);
459 if is_external {
460 out.push_str(r#"<a href=""#);
461 out.push_str(&escape_attr(&resolved.href));
462 out.push_str(
463 r#"" class="moss-grid-card link-preview" target="_blank" rel="noopener">"#,
464 );
465 } else {
466 out.push_str(r#"<a href=""#);
467 out.push_str(&escape_attr(&resolved.href));
468 out.push_str(r#"" class="moss-grid-card" data-kind="link">"#);
469 }
470 render_blocks(hooks, out, children);
471 out.push_str("</a>");
472 }
473 Block::Other(html) => {
474 out.push_str(html);
475 }
476 }
477}
478
479fn push_source_line_attr(out: &mut String, source_line: Option<usize>) {
491 if let Some(n) = source_line {
492 use std::fmt::Write as _;
493 let _ = write!(out, r#" data-source-line="{}""#, n);
496 }
497}
498
499pub(super) fn render_inlines<H: RenderHooks + ?Sized>(
500 hooks: &H,
501 out: &mut String,
502 inlines: &[Inline],
503) {
504 for inline in inlines {
505 render_inline(hooks, out, inline);
506 }
507}
508
509fn render_inline<H: RenderHooks + ?Sized>(hooks: &H, out: &mut String, inline: &Inline) {
510 match inline {
511 Inline::Text(t) => out.push_str(&escape_text(t)),
512 Inline::Link {
513 url,
514 title: _title,
515 children,
516 is_wikilink,
517 } => {
518 let resolved = match url {
519 Url::Resolved(r) => r,
520 Url::Unresolved(s) => {
521 debug_assert!(
522 false,
523 "Url::Unresolved({s:?}) reached renderer — visit_urls_mut missing or buggy"
524 );
525 out.push_str(r#"<a href=""#);
528 out.push_str(&escape_attr(s));
529 out.push_str(r#"">"#);
530 render_inlines(hooks, out, children);
531 out.push_str("</a>");
532 return;
533 }
534 };
535 let mut content = String::new();
536 render_inlines(hooks, &mut content, children);
537 hooks.render_link(out, resolved, *is_wikilink, &content);
545 }
546 Inline::Image {
547 src, alt, title, ..
548 } => {
549 let resolved = match src {
550 Url::Resolved(r) => r,
551 Url::Unresolved(s) => {
552 debug_assert!(
553 false,
554 "Url::Unresolved({s:?}) reached renderer — visit_urls_mut missing or buggy"
555 );
556 out.push_str(r#"<img src=""#);
557 out.push_str(&escape_attr(s));
558 out.push_str(r#"" alt=""#);
559 out.push_str(&escape_attr(alt));
560 out.push_str(r#"" />"#);
561 return;
562 }
563 };
564 hooks.render_image(out, resolved, alt, title.as_deref());
565 }
566 Inline::Emphasis(children) => {
567 out.push_str("<em>");
568 render_inlines(hooks, out, children);
569 out.push_str("</em>");
570 }
571 Inline::Strong(children) => {
572 out.push_str("<strong>");
573 render_inlines(hooks, out, children);
574 out.push_str("</strong>");
575 }
576 Inline::Code(c) => {
577 out.push_str("<code>");
578 out.push_str(&escape_text(c));
579 out.push_str("</code>");
580 }
581 Inline::LineBreak => out.push_str("<br />\n"),
582 Inline::Other(html) => {
583 match super::math_text::math_node_parts(html) {
591 Some((tex, display)) => hooks.render_math(out, &tex, display, html),
592 None => out.push_str(html),
593 }
594 }
595 }
596}
597
598#[cfg(test)]
599mod tests {
600 use super::super::hooks::DefaultHooks;
601 use super::super::node::Inline;
602 use super::super::url::{Url, UrlKind};
603 use super::*;
604
605 fn render(blocks: Vec<Block>) -> String {
606 let doc = Document::from_blocks(blocks);
607 render_document(&doc, &DefaultHooks::new())
608 }
609
610 #[test]
611 fn renders_empty_document_to_empty_string() {
612 assert_eq!(render(vec![]), "");
613 }
614
615 #[test]
616 fn renders_paragraph() {
617 let html = render(vec![Block::Paragraph(vec![Inline::Text("hi".into())])]);
618 assert_eq!(html, "<p>hi</p>\n");
619 }
620
621 #[test]
622 fn renders_heading_with_id() {
623 let html = render(vec![Block::Heading {
624 level: 2,
625 children: vec![Inline::Text("Setup".into())],
626 id: Some("setup".into()),
627 }]);
628 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");
629 }
630
631 #[test]
632 fn renders_resolved_link_internal() {
633 let html = render(vec![Block::Paragraph(vec![Inline::Link {
634 url: Url::resolved("docs/", UrlKind::Internal),
635 title: None,
636 children: vec![Inline::Text("Docs".into())],
637 is_wikilink: false,
638 }])]);
639 assert_eq!(html, "<p><a href=\"docs/\">Docs</a></p>\n");
640 }
641
642 #[test]
643 fn renders_resolved_link_wikilink_carries_class() {
644 let html = render(vec![Block::Paragraph(vec![Inline::Link {
647 url: Url::resolved("../docs/", UrlKind::Wikilink),
648 title: None,
649 children: vec![Inline::Text("Docs".into())],
650 is_wikilink: false,
651 }])]);
652 assert!(html.contains(r#"class="wikilink""#), "got: {html}");
653 }
654
655 #[test]
656 fn renders_link_with_is_wikilink_flag_emits_class() {
657 let html = render(vec![Block::Paragraph(vec![Inline::Link {
661 url: Url::resolved("../docs/", UrlKind::Internal),
662 title: None,
663 children: vec![Inline::Text("Docs".into())],
664 is_wikilink: true,
665 }])]);
666 assert!(
667 html.contains(r#"class="wikilink""#),
668 "is_wikilink: true should produce class=\"wikilink\"; got: {html}"
669 );
670 }
671
672 #[test]
673 fn renders_resolved_image() {
674 let html = render(vec![Block::Paragraph(vec![Inline::Image {
675 src: Url::resolved("cat.jpg", UrlKind::Asset),
676 alt: "Cat".into(),
677 title: None,
678 is_wikilink: false,
679 wikilink_pothole: None,
680 }])]);
681 assert_eq!(html, "<p><img src=\"cat.jpg\" alt=\"Cat\" /></p>\n");
682 }
683
684 #[test]
685 fn renders_emphasis_and_strong() {
686 let html = render(vec![Block::Paragraph(vec![
687 Inline::Emphasis(vec![Inline::Text("em".into())]),
688 Inline::Text(" ".into()),
689 Inline::Strong(vec![Inline::Text("strong".into())]),
690 ])]);
691 assert_eq!(html, "<p><em>em</em> <strong>strong</strong></p>\n");
692 }
693
694 #[test]
695 fn renders_inline_code_with_escaping() {
696 let html = render(vec![Block::Paragraph(vec![Inline::Code("a<b>c".into())])]);
697 assert_eq!(html, "<p><code>a<b>c</code></p>\n");
698 }
699
700 #[test]
701 fn renders_unordered_list_tight() {
702 let html = render(vec![Block::List {
703 ordered: false,
704 start: None,
705 items: vec![
706 vec![Block::Paragraph(vec![Inline::Text("one".into())])],
707 vec![Block::Paragraph(vec![Inline::Text("two".into())])],
708 ],
709 item_source_lines: vec![],
710 }]);
711 assert_eq!(html, "<ul>\n<li>one</li>\n<li>two</li>\n</ul>\n");
712 }
713
714 #[test]
715 fn renders_ordered_list() {
716 let html = render(vec![Block::List {
717 ordered: true,
718 start: None,
719 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
720 item_source_lines: vec![],
721 }]);
722 assert!(html.starts_with("<ol>"));
723 }
724
725 #[test]
726 fn render_ordered_list_emits_start_attribute_when_non_default() {
727 let html = render(vec![Block::List {
731 ordered: true,
732 start: Some(3),
733 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
734 item_source_lines: vec![],
735 }]);
736 assert!(
737 html.starts_with(r#"<ol start="3">"#),
738 "expected start attr immediately after <ol, got: {html}"
739 );
740 }
741
742 #[test]
743 fn render_ordered_list_omits_start_when_default_1() {
744 let html = render(vec![Block::List {
748 ordered: true,
749 start: None,
750 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
751 item_source_lines: vec![],
752 }]);
753 assert!(html.starts_with("<ol>"), "expected bare <ol>, got: {html}");
754 assert!(
755 !html.contains("start="),
756 "ordered list with default start should not emit start attr, got: {html}"
757 );
758 }
759
760 #[test]
761 fn render_unordered_list_emits_no_start() {
762 let html = render(vec![Block::List {
766 ordered: false,
767 start: Some(5),
768 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
769 item_source_lines: vec![],
770 }]);
771 assert!(html.starts_with("<ul>"), "expected bare <ul>, got: {html}");
772 assert!(
773 !html.contains("start="),
774 "unordered list must never carry start attr, got: {html}"
775 );
776 }
777
778 #[test]
779 fn renders_code_block_with_lang() {
780 let html = render(vec![Block::CodeBlock {
781 lang: Some("rust".into()),
782 value: "fn main() {}".into(),
783 }]);
784 assert_eq!(
785 html,
786 "<pre><code class=\"language-rust\">fn main() {}</code></pre>\n"
787 );
788 }
789
790 #[test]
791 fn renders_code_block_without_lang() {
792 let html = render(vec![Block::CodeBlock {
793 lang: None,
794 value: "bare".into(),
795 }]);
796 assert_eq!(html, "<pre><code>bare</code></pre>\n");
797 }
798
799 #[test]
800 fn renders_thematic_break() {
801 let html = render(vec![Block::ThematicBreak]);
802 assert_eq!(html, "<hr />\n");
803 }
804
805 use super::super::node::{CalloutKind, Fold};
810
811 #[test]
812 fn renders_basic_callout_with_title() {
813 let html = render(vec![Block::Callout {
814 kind: CalloutKind::Note,
815 fold: None,
816 title: Some("Heads up".into()),
817 children: vec![Block::Paragraph(vec![Inline::Text("Body.".into())])],
818 }]);
819 assert!(
820 html.contains(r#"<div class="callout" data-type="note">"#),
821 "expected callout div with data-type, got: {html}"
822 );
823 assert!(
824 html.contains(r#"<div class="callout-title">Heads up</div>"#),
825 "expected inline title slot, got: {html}"
826 );
827 assert!(
828 html.contains(r#"<div class="callout-content">"#),
829 "expected content slot, got: {html}"
830 );
831 assert!(html.contains("<p>Body.</p>"), "body must render: {html}");
832 }
833
834 #[test]
835 fn renders_callout_falls_back_to_default_title() {
836 let html = render(vec![Block::Callout {
837 kind: CalloutKind::Warning,
838 fold: None,
839 title: None,
840 children: vec![],
841 }]);
842 assert!(
843 html.contains(r#"<div class="callout-title">Warning</div>"#),
844 "expected capitalized fallback title, got: {html}"
845 );
846 }
847
848 #[test]
849 fn renders_foldable_callout_with_data_fold_attribute() {
850 let html_open = render(vec![Block::Callout {
851 kind: CalloutKind::Tip,
852 fold: Some(Fold::Open),
853 title: Some("Open".into()),
854 children: vec![],
855 }]);
856 assert!(
857 html_open.contains(r#"data-type="tip""#) && html_open.contains(r#"data-fold="open""#),
858 "expected data-fold='open' attribute, got: {html_open}"
859 );
860
861 let html_closed = render(vec![Block::Callout {
862 kind: CalloutKind::Tip,
863 fold: Some(Fold::Closed),
864 title: None,
865 children: vec![],
866 }]);
867 assert!(
868 html_closed.contains(r#"data-fold="closed""#),
869 "expected data-fold='closed' attribute, got: {html_closed}"
870 );
871 }
872
873 #[test]
874 fn callout_alias_renders_canonical_data_type_slug() {
875 let html = render(vec![Block::Callout {
878 kind: CalloutKind::Abstract,
879 fold: None,
880 title: Some("TL;DR".into()),
881 children: vec![],
882 }]);
883 assert!(
884 html.contains(r#"data-type="abstract""#),
885 "expected canonical slug 'abstract', got: {html}"
886 );
887 }
888
889 #[test]
890 fn callout_title_is_html_escaped() {
891 let html = render(vec![Block::Callout {
896 kind: CalloutKind::Warning,
897 fold: None,
898 title: Some(r#"Use <script> & "quotes""#.into()),
899 children: vec![],
900 }]);
901 assert!(
902 html.contains("Use <script> &"),
903 "title must escape lt/gt/amp, got: {html}"
904 );
905 assert!(
907 !html.contains("<div class=\"callout-title\">Use <script>"),
908 "unescaped angle brackets leaked, got: {html}"
909 );
910 }
911
912 #[test]
913 fn renders_blockquote_with_paragraph() {
914 let html = render(vec![Block::BlockQuote(vec![Block::Paragraph(vec![
915 Inline::Text("q".into()),
916 ])])]);
917 assert_eq!(html, "<blockquote>\n<p>q</p>\n</blockquote>\n");
918 }
919
920 #[test]
921 fn renders_table() {
922 let html = render(vec![Block::Table {
923 header: vec![vec![Inline::Text("A".into())]],
924 rows: vec![vec![vec![Inline::Text("1".into())]]],
925 header_source_line: None,
926 row_source_lines: vec![],
927 }]);
928 assert!(html.contains("<thead>"));
929 assert!(html.contains("<tbody>"));
930 assert!(html.contains("<th>A</th>"));
931 assert!(html.contains("<td>1</td>"));
932 }
933
934 #[test]
935 fn renders_other_block_passes_html_through() {
936 let html = render(vec![Block::Other("<custom></custom>".into())]);
937 assert_eq!(html, "<custom></custom>");
938 }
939
940 #[test]
941 fn text_escapes_lt_gt_amp() {
942 let html = render(vec![Block::Paragraph(vec![Inline::Text("a<b>c&d".into())])]);
943 assert_eq!(html, "<p>a<b>c&d</p>\n");
944 }
945
946 #[test]
947 fn round_trips_parse_to_render_for_canonical_doc() {
948 let md = "# Title\n\npara with [link](docs/) and *em*.\n";
954 let mut doc = super::super::parser::parse(md);
955 super::super::visit::visit_urls_mut(&mut doc, |u| match u {
956 Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Internal),
957 _ => {}
958 });
959 let html = render_document(&doc, &DefaultHooks::new());
960 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}");
961 assert!(html.contains(r#"<a href="docs/">link</a>"#));
962 assert!(html.contains("<em>em</em>"));
963 }
964
965 #[test]
970 fn figure_renders_with_caption() {
971 let html = render(vec![Block::Figure {
976 image: Inline::Image {
977 src: Url::resolved("logo.png", UrlKind::Asset),
978 alt: "A logo".into(),
979 title: None,
980 is_wikilink: false,
981 wikilink_pothole: None,
982 },
983 caption: Some(vec![Inline::Text("A logo".into())]),
984 width: None,
985 align: None,
986 class_names: Vec::new(),
987 img_style: None,
988 }]);
989 assert!(
990 html.starts_with(r#"<figure class="moss-image">"#),
991 "expected figure wrap, got: {html}"
992 );
993 assert!(html.contains(r#"src="logo.png""#), "got: {html}");
994 assert!(html.contains(r#"alt="A logo""#), "got: {html}");
995 assert!(
996 html.contains("<figcaption>A logo</figcaption>"),
997 "got: {html}"
998 );
999 assert!(html.ends_with("</figure>\n"), "got: {html}");
1000 }
1001
1002 #[test]
1003 fn figure_renders_without_caption_when_none() {
1004 let html = render(vec![Block::Figure {
1006 image: Inline::Image {
1007 src: Url::resolved("x.png", UrlKind::Asset),
1008 alt: String::new(),
1009 title: None,
1010 is_wikilink: false,
1011 wikilink_pothole: None,
1012 },
1013 caption: None,
1014 width: None,
1015 align: None,
1016 class_names: Vec::new(),
1017 img_style: None,
1018 }]);
1019 assert!(html.contains("<figure"), "got: {html}");
1020 assert!(
1021 !html.contains("<figcaption"),
1022 "expected no figcaption, got: {html}"
1023 );
1024 assert!(html.contains("</figure>"), "got: {html}");
1025 }
1026
1027 #[test]
1028 fn figure_renders_no_figcaption_for_empty_caption_vec() {
1029 let html = render(vec![Block::Figure {
1031 image: Inline::Image {
1032 src: Url::resolved("x.png", UrlKind::Asset),
1033 alt: "x".into(),
1034 title: None,
1035 is_wikilink: false,
1036 wikilink_pothole: None,
1037 },
1038 caption: Some(vec![]),
1039 width: None,
1040 align: None,
1041 class_names: Vec::new(),
1042 img_style: None,
1043 }]);
1044 assert!(!html.contains("<figcaption"), "got: {html}");
1045 }
1046
1047 #[test]
1052 fn figure_percent_width_emits_inline_style() {
1053 let html = render(vec![Block::Figure {
1054 image: Inline::Image {
1055 src: Url::resolved("pic.jpg", UrlKind::Asset),
1056 alt: "alt".into(),
1057 title: None,
1058 is_wikilink: false,
1059 wikilink_pothole: None,
1060 },
1061 caption: None,
1062 width: Some("55%".to_string()),
1063 align: None,
1064 class_names: vec![],
1065 img_style: None,
1066 }]);
1067 assert!(
1068 html.contains(r#"<figure class="moss-image" style="width:55%""#),
1069 "got: {html}"
1070 );
1071 assert!(
1072 !html.contains("data-width="),
1073 "percent must not emit data-width: {html}"
1074 );
1075 }
1076
1077 #[test]
1078 fn figure_named_width_still_emits_data_width() {
1079 let html = render(vec![Block::Figure {
1080 image: Inline::Image {
1081 src: Url::resolved("pic.jpg", UrlKind::Asset),
1082 alt: "alt".into(),
1083 title: None,
1084 is_wikilink: false,
1085 wikilink_pothole: None,
1086 },
1087 caption: None,
1088 width: Some("wide".to_string()),
1089 align: None,
1090 class_names: vec![],
1091 img_style: None,
1092 }]);
1093 assert!(html.contains(r#"data-width="wide""#), "got: {html}");
1094 assert!(
1095 !html.contains("style=\"width"),
1096 "named token must not emit style: {html}"
1097 );
1098 }
1099
1100 #[test]
1101 fn figure_caption_escapes_html_unsafe_chars() {
1102 let html = render(vec![Block::Figure {
1106 image: Inline::Image {
1107 src: Url::resolved("p.jpg", UrlKind::Asset),
1108 alt: "a<b>c".into(),
1109 title: None,
1110 is_wikilink: false,
1111 wikilink_pothole: None,
1112 },
1113 caption: Some(vec![Inline::Text("a<b>c".into())]),
1114 width: None,
1115 align: None,
1116 class_names: Vec::new(),
1117 img_style: None,
1118 }]);
1119 assert!(
1120 html.contains("<figcaption>a<b>c</figcaption>"),
1121 "got: {html}"
1122 );
1123 }
1124
1125 #[test]
1126 fn figure_end_to_end_from_parser_to_render() {
1127 let md = "\n";
1129 let mut doc = super::super::parser::parse(md);
1130 super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1131 Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Asset),
1132 _ => {}
1133 });
1134 let html = render_document(&doc, &DefaultHooks::new());
1135 assert!(
1136 html.contains(r#"<figure class="moss-image">"#),
1137 "expected figure, got: {html}"
1138 );
1139 assert!(html.contains(r#"src="photo.jpg""#), "got: {html}");
1140 assert!(
1141 html.contains("<figcaption>A photo</figcaption>"),
1142 "got: {html}"
1143 );
1144 }
1145
1146 #[test]
1147 fn paragraph_with_image_and_text_does_not_become_figure() {
1148 let md = " plain text\n";
1152 let mut doc = super::super::parser::parse(md);
1153 super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1154 Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Asset),
1155 _ => {}
1156 });
1157 let html = render_document(&doc, &DefaultHooks::new());
1158 assert!(
1159 !html.contains("<figure"),
1160 "image+text must not be wrapped in figure, got: {html}"
1161 );
1162 assert!(html.contains("plain text"), "got: {html}");
1163 }
1164
1165 #[test]
1166 #[cfg(debug_assertions)]
1167 #[should_panic(expected = "visit_urls_mut missing")]
1168 fn unresolved_url_in_link_panics_in_debug() {
1169 let _ = render(vec![Block::Paragraph(vec![Inline::Link {
1171 url: Url::unresolved("docs/"),
1172 title: None,
1173 children: vec![],
1174 is_wikilink: false,
1175 }])]);
1176 }
1177
1178 fn render_with_meta(blocks: Vec<Block>, meta: Vec<BlockMeta>) -> String {
1185 let doc = Document::from_blocks_with_meta(blocks, meta);
1186 render_document(&doc, &DefaultHooks::new())
1187 }
1188
1189 #[test]
1190 fn paragraph_emits_data_source_line_when_meta_set() {
1191 let html = render_with_meta(
1192 vec![Block::Paragraph(vec![Inline::Text("hi".into())])],
1193 vec![BlockMeta {
1194 source_line: Some(7),
1195 }],
1196 );
1197 assert_eq!(html, "<p data-source-line=\"7\">hi</p>\n");
1198 }
1199
1200 #[test]
1201 fn heading_emits_data_source_line_through_hook() {
1202 let html = render_with_meta(
1203 vec![Block::Heading {
1204 level: 2,
1205 children: vec![Inline::Text("Setup".into())],
1206 id: Some("setup".into()),
1207 }],
1208 vec![BlockMeta {
1209 source_line: Some(3),
1210 }],
1211 );
1212 assert!(
1213 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>"##),
1214 "got: {html}"
1215 );
1216 }
1217
1218 #[test]
1219 fn list_blockquote_codeblock_table_hr_emit_data_source_line() {
1220 let blocks = vec![
1224 Block::BlockQuote(vec![Block::Paragraph(vec![Inline::Text("q".into())])]),
1225 Block::List {
1226 ordered: false,
1227 start: None,
1228 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
1229 item_source_lines: vec![],
1230 },
1231 Block::List {
1232 ordered: true,
1233 start: None,
1234 items: vec![vec![Block::Paragraph(vec![Inline::Text("b".into())])]],
1235 item_source_lines: vec![],
1236 },
1237 Block::CodeBlock {
1238 lang: Some("rust".into()),
1239 value: "x".into(),
1240 },
1241 Block::Table {
1242 header: vec![vec![Inline::Text("H".into())]],
1243 rows: vec![vec![vec![Inline::Text("c".into())]]],
1244 header_source_line: None,
1245 row_source_lines: vec![],
1246 },
1247 Block::ThematicBreak,
1248 ];
1249 let meta = vec![
1250 BlockMeta {
1251 source_line: Some(1),
1252 },
1253 BlockMeta {
1254 source_line: Some(2),
1255 },
1256 BlockMeta {
1257 source_line: Some(3),
1258 },
1259 BlockMeta {
1260 source_line: Some(4),
1261 },
1262 BlockMeta {
1263 source_line: Some(5),
1264 },
1265 BlockMeta {
1266 source_line: Some(6),
1267 },
1268 ];
1269 let html = render_with_meta(blocks, meta);
1270 assert!(
1271 html.contains(r#"<blockquote data-source-line="1">"#),
1272 "blockquote missing: {html}"
1273 );
1274 assert!(
1275 html.contains(r#"<ul data-source-line="2">"#),
1276 "ul missing: {html}"
1277 );
1278 assert!(
1279 html.contains(r#"<ol data-source-line="3">"#),
1280 "ol missing: {html}"
1281 );
1282 assert!(
1283 html.contains(r#"<pre data-source-line="4">"#),
1284 "pre missing: {html}"
1285 );
1286 assert!(
1287 html.contains(r#"<table data-source-line="5">"#),
1288 "table missing: {html}"
1289 );
1290 assert!(
1291 html.contains(r#"<hr data-source-line="6" />"#),
1292 "hr missing: {html}"
1293 );
1294 }
1295
1296 #[test]
1297 fn list_emits_per_li_data_source_line_when_parser_tracks() {
1298 let blocks = vec![Block::List {
1305 ordered: false,
1306 start: None,
1307 items: vec![
1308 vec![Block::Paragraph(vec![Inline::Text("one".into())])],
1309 vec![Block::Paragraph(vec![Inline::Text("two".into())])],
1310 vec![Block::Paragraph(vec![Inline::Text("three".into())])],
1311 ],
1312 item_source_lines: vec![Some(10), Some(11), Some(12)],
1313 }];
1314 let meta = vec![BlockMeta {
1315 source_line: Some(10),
1316 }];
1317 let html = render_with_meta(blocks, meta);
1318 assert!(
1319 html.contains(r#"<ul data-source-line="10">"#),
1320 "ul opener missing: {html}"
1321 );
1322 assert!(
1323 html.contains(r#"<li data-source-line="10">one</li>"#),
1324 "li 10 missing: {html}"
1325 );
1326 assert!(
1327 html.contains(r#"<li data-source-line="11">two</li>"#),
1328 "li 11 missing: {html}"
1329 );
1330 assert!(
1331 html.contains(r#"<li data-source-line="12">three</li>"#),
1332 "li 12 missing: {html}"
1333 );
1334 }
1335
1336 #[test]
1337 fn list_omits_li_data_source_line_when_parser_did_not_track() {
1338 let blocks = vec![Block::List {
1343 ordered: false,
1344 start: None,
1345 items: vec![
1346 vec![Block::Paragraph(vec![Inline::Text("a".into())])],
1347 vec![Block::Paragraph(vec![Inline::Text("b".into())])],
1348 ],
1349 item_source_lines: vec![],
1350 }];
1351 let html = render_with_meta(blocks, vec![BlockMeta::default()]);
1352 assert_eq!(html, "<ul>\n<li>a</li>\n<li>b</li>\n</ul>\n");
1353 }
1354
1355 #[test]
1356 fn table_emits_per_tr_data_source_line_when_parser_tracks() {
1357 let blocks = vec![Block::Table {
1360 header: vec![vec![Inline::Text("H".into())]],
1361 rows: vec![
1362 vec![vec![Inline::Text("1".into())]],
1363 vec![vec![Inline::Text("2".into())]],
1364 vec![vec![Inline::Text("3".into())]],
1365 ],
1366 header_source_line: Some(5),
1367 row_source_lines: vec![Some(7), Some(8), Some(9)],
1368 }];
1369 let meta = vec![BlockMeta {
1370 source_line: Some(5),
1371 }];
1372 let html = render_with_meta(blocks, meta);
1373 assert!(
1374 html.contains(r#"<table data-source-line="5">"#),
1375 "table opener missing: {html}"
1376 );
1377 assert!(html.contains(r#"<thead>"#), "thead missing: {html}");
1378 assert!(
1382 html.contains(r#"<tr data-source-line="5"><th>H</th>"#),
1383 "head tr missing: {html}"
1384 );
1385 assert!(
1386 html.contains(r#"<tr data-source-line="7"><td>1</td>"#),
1387 "body tr 7 missing: {html}"
1388 );
1389 assert!(
1390 html.contains(r#"<tr data-source-line="8"><td>2</td>"#),
1391 "body tr 8 missing: {html}"
1392 );
1393 assert!(
1394 html.contains(r#"<tr data-source-line="9"><td>3</td>"#),
1395 "body tr 9 missing: {html}"
1396 );
1397 }
1398
1399 #[test]
1400 fn table_omits_tr_data_source_line_when_parser_did_not_track() {
1401 let blocks = vec![Block::Table {
1403 header: vec![vec![Inline::Text("A".into())]],
1404 rows: vec![vec![vec![Inline::Text("1".into())]]],
1405 header_source_line: None,
1406 row_source_lines: vec![],
1407 }];
1408 let html = render_with_meta(blocks, vec![BlockMeta::default()]);
1409 assert!(
1413 !html.contains("data-source-line"),
1414 "no annotation expected: {html}"
1415 );
1416 assert!(html.contains("<thead>"));
1417 assert!(html.contains("<tr><th>A</th></tr>"));
1418 assert!(html.contains("<tr><td>1</td></tr>"));
1419 }
1420
1421 #[test]
1422 fn figure_emits_data_source_line_on_outer_tag() {
1423 let blocks = vec![Block::Figure {
1424 image: Inline::Image {
1425 src: Url::resolved("p.jpg", UrlKind::Asset),
1426 alt: "A".into(),
1427 title: None,
1428 is_wikilink: false,
1429 wikilink_pothole: None,
1430 },
1431 caption: Some(vec![Inline::Text("A".into())]),
1432 width: None,
1433 align: None,
1434 class_names: Vec::new(),
1435 img_style: None,
1436 }];
1437 let meta = vec![BlockMeta {
1438 source_line: Some(9),
1439 }];
1440 let html = render_with_meta(blocks, meta);
1441 assert!(
1442 html.contains(r#"<figure class="moss-image" data-source-line="9">"#),
1443 "got: {html}"
1444 );
1445 }
1446
1447 #[test]
1448 fn no_data_source_line_when_meta_none() {
1449 let html = render(vec![
1452 Block::Paragraph(vec![Inline::Text("hi".into())]),
1453 Block::ThematicBreak,
1454 ]);
1455 assert!(
1456 !html.contains("data-source-line"),
1457 "default render must NOT emit data-source-line, got: {html}"
1458 );
1459 }
1460
1461 #[test]
1462 fn end_to_end_parse_with_config_emits_data_source_line() {
1463 let md = "# Title\n\nfirst paragraph\n\n## Sub\n\nsecond paragraph\n";
1465 let config = super::super::parser::ParseConfig {
1466 emit_source_lines: true,
1467 implicit_figure: true,
1468 source_line_offset: 0,
1469 math: false,
1470 };
1471 let mut doc = super::super::parser::parse_with_config(md, &config);
1472 super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1473 Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Internal),
1474 _ => {}
1475 });
1476 let html = render_document(&doc, &DefaultHooks::new());
1477 assert!(
1478 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>"##),
1479 "H1 should carry data-source-line=1: {html}"
1480 );
1481 assert!(
1482 html.contains(r#"<p data-source-line="3">first paragraph</p>"#),
1483 "first paragraph should carry data-source-line=3: {html}"
1484 );
1485 assert!(
1486 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>"##),
1487 "H2 should carry data-source-line=5: {html}"
1488 );
1489 assert!(
1490 html.contains(r#"<p data-source-line="7">second paragraph</p>"#),
1491 "second paragraph should carry data-source-line=7: {html}"
1492 );
1493 }
1494}