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) => out.push_str(html),
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::super::hooks::DefaultHooks;
589 use super::super::node::Inline;
590 use super::super::url::{Url, UrlKind};
591 use super::*;
592
593 fn render(blocks: Vec<Block>) -> String {
594 let doc = Document::from_blocks(blocks);
595 render_document(&doc, &DefaultHooks::new())
596 }
597
598 #[test]
599 fn renders_empty_document_to_empty_string() {
600 assert_eq!(render(vec![]), "");
601 }
602
603 #[test]
604 fn renders_paragraph() {
605 let html = render(vec![Block::Paragraph(vec![Inline::Text("hi".into())])]);
606 assert_eq!(html, "<p>hi</p>\n");
607 }
608
609 #[test]
610 fn renders_heading_with_id() {
611 let html = render(vec![Block::Heading {
612 level: 2,
613 children: vec![Inline::Text("Setup".into())],
614 id: Some("setup".into()),
615 }]);
616 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");
617 }
618
619 #[test]
620 fn renders_resolved_link_internal() {
621 let html = render(vec![Block::Paragraph(vec![Inline::Link {
622 url: Url::resolved("docs/", UrlKind::Internal),
623 title: None,
624 children: vec![Inline::Text("Docs".into())],
625 is_wikilink: false,
626 }])]);
627 assert_eq!(html, "<p><a href=\"docs/\">Docs</a></p>\n");
628 }
629
630 #[test]
631 fn renders_resolved_link_wikilink_carries_class() {
632 let html = render(vec![Block::Paragraph(vec![Inline::Link {
635 url: Url::resolved("../docs/", UrlKind::Wikilink),
636 title: None,
637 children: vec![Inline::Text("Docs".into())],
638 is_wikilink: false,
639 }])]);
640 assert!(html.contains(r#"class="wikilink""#), "got: {html}");
641 }
642
643 #[test]
644 fn renders_link_with_is_wikilink_flag_emits_class() {
645 let html = render(vec![Block::Paragraph(vec![Inline::Link {
649 url: Url::resolved("../docs/", UrlKind::Internal),
650 title: None,
651 children: vec![Inline::Text("Docs".into())],
652 is_wikilink: true,
653 }])]);
654 assert!(
655 html.contains(r#"class="wikilink""#),
656 "is_wikilink: true should produce class=\"wikilink\"; got: {html}"
657 );
658 }
659
660 #[test]
661 fn renders_resolved_image() {
662 let html = render(vec![Block::Paragraph(vec![Inline::Image {
663 src: Url::resolved("cat.jpg", UrlKind::Asset),
664 alt: "Cat".into(),
665 title: None,
666 is_wikilink: false,
667 wikilink_pothole: None,
668 }])]);
669 assert_eq!(html, "<p><img src=\"cat.jpg\" alt=\"Cat\" /></p>\n");
670 }
671
672 #[test]
673 fn renders_emphasis_and_strong() {
674 let html = render(vec![Block::Paragraph(vec![
675 Inline::Emphasis(vec![Inline::Text("em".into())]),
676 Inline::Text(" ".into()),
677 Inline::Strong(vec![Inline::Text("strong".into())]),
678 ])]);
679 assert_eq!(html, "<p><em>em</em> <strong>strong</strong></p>\n");
680 }
681
682 #[test]
683 fn renders_inline_code_with_escaping() {
684 let html = render(vec![Block::Paragraph(vec![Inline::Code("a<b>c".into())])]);
685 assert_eq!(html, "<p><code>a<b>c</code></p>\n");
686 }
687
688 #[test]
689 fn renders_unordered_list_tight() {
690 let html = render(vec![Block::List {
691 ordered: false,
692 start: None,
693 items: vec![
694 vec![Block::Paragraph(vec![Inline::Text("one".into())])],
695 vec![Block::Paragraph(vec![Inline::Text("two".into())])],
696 ],
697 item_source_lines: vec![],
698 }]);
699 assert_eq!(html, "<ul>\n<li>one</li>\n<li>two</li>\n</ul>\n");
700 }
701
702 #[test]
703 fn renders_ordered_list() {
704 let html = render(vec![Block::List {
705 ordered: true,
706 start: None,
707 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
708 item_source_lines: vec![],
709 }]);
710 assert!(html.starts_with("<ol>"));
711 }
712
713 #[test]
714 fn render_ordered_list_emits_start_attribute_when_non_default() {
715 let html = render(vec![Block::List {
719 ordered: true,
720 start: Some(3),
721 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
722 item_source_lines: vec![],
723 }]);
724 assert!(
725 html.starts_with(r#"<ol start="3">"#),
726 "expected start attr immediately after <ol, got: {html}"
727 );
728 }
729
730 #[test]
731 fn render_ordered_list_omits_start_when_default_1() {
732 let html = render(vec![Block::List {
736 ordered: true,
737 start: None,
738 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
739 item_source_lines: vec![],
740 }]);
741 assert!(html.starts_with("<ol>"), "expected bare <ol>, got: {html}");
742 assert!(
743 !html.contains("start="),
744 "ordered list with default start should not emit start attr, got: {html}"
745 );
746 }
747
748 #[test]
749 fn render_unordered_list_emits_no_start() {
750 let html = render(vec![Block::List {
754 ordered: false,
755 start: Some(5),
756 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
757 item_source_lines: vec![],
758 }]);
759 assert!(html.starts_with("<ul>"), "expected bare <ul>, got: {html}");
760 assert!(
761 !html.contains("start="),
762 "unordered list must never carry start attr, got: {html}"
763 );
764 }
765
766 #[test]
767 fn renders_code_block_with_lang() {
768 let html = render(vec![Block::CodeBlock {
769 lang: Some("rust".into()),
770 value: "fn main() {}".into(),
771 }]);
772 assert_eq!(
773 html,
774 "<pre><code class=\"language-rust\">fn main() {}</code></pre>\n"
775 );
776 }
777
778 #[test]
779 fn renders_code_block_without_lang() {
780 let html = render(vec![Block::CodeBlock {
781 lang: None,
782 value: "bare".into(),
783 }]);
784 assert_eq!(html, "<pre><code>bare</code></pre>\n");
785 }
786
787 #[test]
788 fn renders_thematic_break() {
789 let html = render(vec![Block::ThematicBreak]);
790 assert_eq!(html, "<hr />\n");
791 }
792
793 use super::super::node::{CalloutKind, Fold};
798
799 #[test]
800 fn renders_basic_callout_with_title() {
801 let html = render(vec![Block::Callout {
802 kind: CalloutKind::Note,
803 fold: None,
804 title: Some("Heads up".into()),
805 children: vec![Block::Paragraph(vec![Inline::Text("Body.".into())])],
806 }]);
807 assert!(
808 html.contains(r#"<div class="callout" data-type="note">"#),
809 "expected callout div with data-type, got: {html}"
810 );
811 assert!(
812 html.contains(r#"<div class="callout-title">Heads up</div>"#),
813 "expected inline title slot, got: {html}"
814 );
815 assert!(
816 html.contains(r#"<div class="callout-content">"#),
817 "expected content slot, got: {html}"
818 );
819 assert!(html.contains("<p>Body.</p>"), "body must render: {html}");
820 }
821
822 #[test]
823 fn renders_callout_falls_back_to_default_title() {
824 let html = render(vec![Block::Callout {
825 kind: CalloutKind::Warning,
826 fold: None,
827 title: None,
828 children: vec![],
829 }]);
830 assert!(
831 html.contains(r#"<div class="callout-title">Warning</div>"#),
832 "expected capitalized fallback title, got: {html}"
833 );
834 }
835
836 #[test]
837 fn renders_foldable_callout_with_data_fold_attribute() {
838 let html_open = render(vec![Block::Callout {
839 kind: CalloutKind::Tip,
840 fold: Some(Fold::Open),
841 title: Some("Open".into()),
842 children: vec![],
843 }]);
844 assert!(
845 html_open.contains(r#"data-type="tip""#) && html_open.contains(r#"data-fold="open""#),
846 "expected data-fold='open' attribute, got: {html_open}"
847 );
848
849 let html_closed = render(vec![Block::Callout {
850 kind: CalloutKind::Tip,
851 fold: Some(Fold::Closed),
852 title: None,
853 children: vec![],
854 }]);
855 assert!(
856 html_closed.contains(r#"data-fold="closed""#),
857 "expected data-fold='closed' attribute, got: {html_closed}"
858 );
859 }
860
861 #[test]
862 fn callout_alias_renders_canonical_data_type_slug() {
863 let html = render(vec![Block::Callout {
866 kind: CalloutKind::Abstract,
867 fold: None,
868 title: Some("TL;DR".into()),
869 children: vec![],
870 }]);
871 assert!(
872 html.contains(r#"data-type="abstract""#),
873 "expected canonical slug 'abstract', got: {html}"
874 );
875 }
876
877 #[test]
878 fn callout_title_is_html_escaped() {
879 let html = render(vec![Block::Callout {
884 kind: CalloutKind::Warning,
885 fold: None,
886 title: Some(r#"Use <script> & "quotes""#.into()),
887 children: vec![],
888 }]);
889 assert!(
890 html.contains("Use <script> &"),
891 "title must escape lt/gt/amp, got: {html}"
892 );
893 assert!(
895 !html.contains("<div class=\"callout-title\">Use <script>"),
896 "unescaped angle brackets leaked, got: {html}"
897 );
898 }
899
900 #[test]
901 fn renders_blockquote_with_paragraph() {
902 let html = render(vec![Block::BlockQuote(vec![Block::Paragraph(vec![
903 Inline::Text("q".into()),
904 ])])]);
905 assert_eq!(html, "<blockquote>\n<p>q</p>\n</blockquote>\n");
906 }
907
908 #[test]
909 fn renders_table() {
910 let html = render(vec![Block::Table {
911 header: vec![vec![Inline::Text("A".into())]],
912 rows: vec![vec![vec![Inline::Text("1".into())]]],
913 header_source_line: None,
914 row_source_lines: vec![],
915 }]);
916 assert!(html.contains("<thead>"));
917 assert!(html.contains("<tbody>"));
918 assert!(html.contains("<th>A</th>"));
919 assert!(html.contains("<td>1</td>"));
920 }
921
922 #[test]
923 fn renders_other_block_passes_html_through() {
924 let html = render(vec![Block::Other("<custom></custom>".into())]);
925 assert_eq!(html, "<custom></custom>");
926 }
927
928 #[test]
929 fn text_escapes_lt_gt_amp() {
930 let html = render(vec![Block::Paragraph(vec![Inline::Text("a<b>c&d".into())])]);
931 assert_eq!(html, "<p>a<b>c&d</p>\n");
932 }
933
934 #[test]
935 fn round_trips_parse_to_render_for_canonical_doc() {
936 let md = "# Title\n\npara with [link](docs/) and *em*.\n";
942 let mut doc = super::super::parser::parse(md);
943 super::super::visit::visit_urls_mut(&mut doc, |u| match u {
944 Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Internal),
945 _ => {}
946 });
947 let html = render_document(&doc, &DefaultHooks::new());
948 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}");
949 assert!(html.contains(r#"<a href="docs/">link</a>"#));
950 assert!(html.contains("<em>em</em>"));
951 }
952
953 #[test]
958 fn figure_renders_with_caption() {
959 let html = render(vec![Block::Figure {
964 image: Inline::Image {
965 src: Url::resolved("logo.png", UrlKind::Asset),
966 alt: "A logo".into(),
967 title: None,
968 is_wikilink: false,
969 wikilink_pothole: None,
970 },
971 caption: Some(vec![Inline::Text("A logo".into())]),
972 width: None,
973 align: None,
974 class_names: Vec::new(),
975 img_style: None,
976 }]);
977 assert!(
978 html.starts_with(r#"<figure class="moss-image">"#),
979 "expected figure wrap, got: {html}"
980 );
981 assert!(html.contains(r#"src="logo.png""#), "got: {html}");
982 assert!(html.contains(r#"alt="A logo""#), "got: {html}");
983 assert!(
984 html.contains("<figcaption>A logo</figcaption>"),
985 "got: {html}"
986 );
987 assert!(html.ends_with("</figure>\n"), "got: {html}");
988 }
989
990 #[test]
991 fn figure_renders_without_caption_when_none() {
992 let html = render(vec![Block::Figure {
994 image: Inline::Image {
995 src: Url::resolved("x.png", UrlKind::Asset),
996 alt: String::new(),
997 title: None,
998 is_wikilink: false,
999 wikilink_pothole: None,
1000 },
1001 caption: None,
1002 width: None,
1003 align: None,
1004 class_names: Vec::new(),
1005 img_style: None,
1006 }]);
1007 assert!(html.contains("<figure"), "got: {html}");
1008 assert!(
1009 !html.contains("<figcaption"),
1010 "expected no figcaption, got: {html}"
1011 );
1012 assert!(html.contains("</figure>"), "got: {html}");
1013 }
1014
1015 #[test]
1016 fn figure_renders_no_figcaption_for_empty_caption_vec() {
1017 let html = render(vec![Block::Figure {
1019 image: Inline::Image {
1020 src: Url::resolved("x.png", UrlKind::Asset),
1021 alt: "x".into(),
1022 title: None,
1023 is_wikilink: false,
1024 wikilink_pothole: None,
1025 },
1026 caption: Some(vec![]),
1027 width: None,
1028 align: None,
1029 class_names: Vec::new(),
1030 img_style: None,
1031 }]);
1032 assert!(!html.contains("<figcaption"), "got: {html}");
1033 }
1034
1035 #[test]
1040 fn figure_percent_width_emits_inline_style() {
1041 let html = render(vec![Block::Figure {
1042 image: Inline::Image {
1043 src: Url::resolved("pic.jpg", UrlKind::Asset),
1044 alt: "alt".into(),
1045 title: None,
1046 is_wikilink: false,
1047 wikilink_pothole: None,
1048 },
1049 caption: None,
1050 width: Some("55%".to_string()),
1051 align: None,
1052 class_names: vec![],
1053 img_style: None,
1054 }]);
1055 assert!(
1056 html.contains(r#"<figure class="moss-image" style="width:55%""#),
1057 "got: {html}"
1058 );
1059 assert!(
1060 !html.contains("data-width="),
1061 "percent must not emit data-width: {html}"
1062 );
1063 }
1064
1065 #[test]
1066 fn figure_named_width_still_emits_data_width() {
1067 let html = render(vec![Block::Figure {
1068 image: Inline::Image {
1069 src: Url::resolved("pic.jpg", UrlKind::Asset),
1070 alt: "alt".into(),
1071 title: None,
1072 is_wikilink: false,
1073 wikilink_pothole: None,
1074 },
1075 caption: None,
1076 width: Some("wide".to_string()),
1077 align: None,
1078 class_names: vec![],
1079 img_style: None,
1080 }]);
1081 assert!(html.contains(r#"data-width="wide""#), "got: {html}");
1082 assert!(
1083 !html.contains("style=\"width"),
1084 "named token must not emit style: {html}"
1085 );
1086 }
1087
1088 #[test]
1089 fn figure_caption_escapes_html_unsafe_chars() {
1090 let html = render(vec![Block::Figure {
1094 image: Inline::Image {
1095 src: Url::resolved("p.jpg", UrlKind::Asset),
1096 alt: "a<b>c".into(),
1097 title: None,
1098 is_wikilink: false,
1099 wikilink_pothole: None,
1100 },
1101 caption: Some(vec![Inline::Text("a<b>c".into())]),
1102 width: None,
1103 align: None,
1104 class_names: Vec::new(),
1105 img_style: None,
1106 }]);
1107 assert!(
1108 html.contains("<figcaption>a<b>c</figcaption>"),
1109 "got: {html}"
1110 );
1111 }
1112
1113 #[test]
1114 fn figure_end_to_end_from_parser_to_render() {
1115 let md = "\n";
1117 let mut doc = super::super::parser::parse(md);
1118 super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1119 Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Asset),
1120 _ => {}
1121 });
1122 let html = render_document(&doc, &DefaultHooks::new());
1123 assert!(
1124 html.contains(r#"<figure class="moss-image">"#),
1125 "expected figure, got: {html}"
1126 );
1127 assert!(html.contains(r#"src="photo.jpg""#), "got: {html}");
1128 assert!(
1129 html.contains("<figcaption>A photo</figcaption>"),
1130 "got: {html}"
1131 );
1132 }
1133
1134 #[test]
1135 fn paragraph_with_image_and_text_does_not_become_figure() {
1136 let md = " plain text\n";
1140 let mut doc = super::super::parser::parse(md);
1141 super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1142 Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Asset),
1143 _ => {}
1144 });
1145 let html = render_document(&doc, &DefaultHooks::new());
1146 assert!(
1147 !html.contains("<figure"),
1148 "image+text must not be wrapped in figure, got: {html}"
1149 );
1150 assert!(html.contains("plain text"), "got: {html}");
1151 }
1152
1153 #[test]
1154 #[cfg(debug_assertions)]
1155 #[should_panic(expected = "visit_urls_mut missing")]
1156 fn unresolved_url_in_link_panics_in_debug() {
1157 let _ = render(vec![Block::Paragraph(vec![Inline::Link {
1159 url: Url::unresolved("docs/"),
1160 title: None,
1161 children: vec![],
1162 is_wikilink: false,
1163 }])]);
1164 }
1165
1166 fn render_with_meta(blocks: Vec<Block>, meta: Vec<BlockMeta>) -> String {
1173 let doc = Document::from_blocks_with_meta(blocks, meta);
1174 render_document(&doc, &DefaultHooks::new())
1175 }
1176
1177 #[test]
1178 fn paragraph_emits_data_source_line_when_meta_set() {
1179 let html = render_with_meta(
1180 vec![Block::Paragraph(vec![Inline::Text("hi".into())])],
1181 vec![BlockMeta {
1182 source_line: Some(7),
1183 }],
1184 );
1185 assert_eq!(html, "<p data-source-line=\"7\">hi</p>\n");
1186 }
1187
1188 #[test]
1189 fn heading_emits_data_source_line_through_hook() {
1190 let html = render_with_meta(
1191 vec![Block::Heading {
1192 level: 2,
1193 children: vec![Inline::Text("Setup".into())],
1194 id: Some("setup".into()),
1195 }],
1196 vec![BlockMeta {
1197 source_line: Some(3),
1198 }],
1199 );
1200 assert!(
1201 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>"##),
1202 "got: {html}"
1203 );
1204 }
1205
1206 #[test]
1207 fn list_blockquote_codeblock_table_hr_emit_data_source_line() {
1208 let blocks = vec![
1212 Block::BlockQuote(vec![Block::Paragraph(vec![Inline::Text("q".into())])]),
1213 Block::List {
1214 ordered: false,
1215 start: None,
1216 items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
1217 item_source_lines: vec![],
1218 },
1219 Block::List {
1220 ordered: true,
1221 start: None,
1222 items: vec![vec![Block::Paragraph(vec![Inline::Text("b".into())])]],
1223 item_source_lines: vec![],
1224 },
1225 Block::CodeBlock {
1226 lang: Some("rust".into()),
1227 value: "x".into(),
1228 },
1229 Block::Table {
1230 header: vec![vec![Inline::Text("H".into())]],
1231 rows: vec![vec![vec![Inline::Text("c".into())]]],
1232 header_source_line: None,
1233 row_source_lines: vec![],
1234 },
1235 Block::ThematicBreak,
1236 ];
1237 let meta = vec![
1238 BlockMeta {
1239 source_line: Some(1),
1240 },
1241 BlockMeta {
1242 source_line: Some(2),
1243 },
1244 BlockMeta {
1245 source_line: Some(3),
1246 },
1247 BlockMeta {
1248 source_line: Some(4),
1249 },
1250 BlockMeta {
1251 source_line: Some(5),
1252 },
1253 BlockMeta {
1254 source_line: Some(6),
1255 },
1256 ];
1257 let html = render_with_meta(blocks, meta);
1258 assert!(
1259 html.contains(r#"<blockquote data-source-line="1">"#),
1260 "blockquote missing: {html}"
1261 );
1262 assert!(
1263 html.contains(r#"<ul data-source-line="2">"#),
1264 "ul missing: {html}"
1265 );
1266 assert!(
1267 html.contains(r#"<ol data-source-line="3">"#),
1268 "ol missing: {html}"
1269 );
1270 assert!(
1271 html.contains(r#"<pre data-source-line="4">"#),
1272 "pre missing: {html}"
1273 );
1274 assert!(
1275 html.contains(r#"<table data-source-line="5">"#),
1276 "table missing: {html}"
1277 );
1278 assert!(
1279 html.contains(r#"<hr data-source-line="6" />"#),
1280 "hr missing: {html}"
1281 );
1282 }
1283
1284 #[test]
1285 fn list_emits_per_li_data_source_line_when_parser_tracks() {
1286 let blocks = vec![Block::List {
1293 ordered: false,
1294 start: None,
1295 items: vec![
1296 vec![Block::Paragraph(vec![Inline::Text("one".into())])],
1297 vec![Block::Paragraph(vec![Inline::Text("two".into())])],
1298 vec![Block::Paragraph(vec![Inline::Text("three".into())])],
1299 ],
1300 item_source_lines: vec![Some(10), Some(11), Some(12)],
1301 }];
1302 let meta = vec![BlockMeta {
1303 source_line: Some(10),
1304 }];
1305 let html = render_with_meta(blocks, meta);
1306 assert!(
1307 html.contains(r#"<ul data-source-line="10">"#),
1308 "ul opener missing: {html}"
1309 );
1310 assert!(
1311 html.contains(r#"<li data-source-line="10">one</li>"#),
1312 "li 10 missing: {html}"
1313 );
1314 assert!(
1315 html.contains(r#"<li data-source-line="11">two</li>"#),
1316 "li 11 missing: {html}"
1317 );
1318 assert!(
1319 html.contains(r#"<li data-source-line="12">three</li>"#),
1320 "li 12 missing: {html}"
1321 );
1322 }
1323
1324 #[test]
1325 fn list_omits_li_data_source_line_when_parser_did_not_track() {
1326 let blocks = vec![Block::List {
1331 ordered: false,
1332 start: None,
1333 items: vec![
1334 vec![Block::Paragraph(vec![Inline::Text("a".into())])],
1335 vec![Block::Paragraph(vec![Inline::Text("b".into())])],
1336 ],
1337 item_source_lines: vec![],
1338 }];
1339 let html = render_with_meta(blocks, vec![BlockMeta::default()]);
1340 assert_eq!(html, "<ul>\n<li>a</li>\n<li>b</li>\n</ul>\n");
1341 }
1342
1343 #[test]
1344 fn table_emits_per_tr_data_source_line_when_parser_tracks() {
1345 let blocks = vec![Block::Table {
1348 header: vec![vec![Inline::Text("H".into())]],
1349 rows: vec![
1350 vec![vec![Inline::Text("1".into())]],
1351 vec![vec![Inline::Text("2".into())]],
1352 vec![vec![Inline::Text("3".into())]],
1353 ],
1354 header_source_line: Some(5),
1355 row_source_lines: vec![Some(7), Some(8), Some(9)],
1356 }];
1357 let meta = vec![BlockMeta {
1358 source_line: Some(5),
1359 }];
1360 let html = render_with_meta(blocks, meta);
1361 assert!(
1362 html.contains(r#"<table data-source-line="5">"#),
1363 "table opener missing: {html}"
1364 );
1365 assert!(html.contains(r#"<thead>"#), "thead missing: {html}");
1366 assert!(
1370 html.contains(r#"<tr data-source-line="5"><th>H</th>"#),
1371 "head tr missing: {html}"
1372 );
1373 assert!(
1374 html.contains(r#"<tr data-source-line="7"><td>1</td>"#),
1375 "body tr 7 missing: {html}"
1376 );
1377 assert!(
1378 html.contains(r#"<tr data-source-line="8"><td>2</td>"#),
1379 "body tr 8 missing: {html}"
1380 );
1381 assert!(
1382 html.contains(r#"<tr data-source-line="9"><td>3</td>"#),
1383 "body tr 9 missing: {html}"
1384 );
1385 }
1386
1387 #[test]
1388 fn table_omits_tr_data_source_line_when_parser_did_not_track() {
1389 let blocks = vec![Block::Table {
1391 header: vec![vec![Inline::Text("A".into())]],
1392 rows: vec![vec![vec![Inline::Text("1".into())]]],
1393 header_source_line: None,
1394 row_source_lines: vec![],
1395 }];
1396 let html = render_with_meta(blocks, vec![BlockMeta::default()]);
1397 assert!(
1401 !html.contains("data-source-line"),
1402 "no annotation expected: {html}"
1403 );
1404 assert!(html.contains("<thead>"));
1405 assert!(html.contains("<tr><th>A</th></tr>"));
1406 assert!(html.contains("<tr><td>1</td></tr>"));
1407 }
1408
1409 #[test]
1410 fn figure_emits_data_source_line_on_outer_tag() {
1411 let blocks = vec![Block::Figure {
1412 image: Inline::Image {
1413 src: Url::resolved("p.jpg", UrlKind::Asset),
1414 alt: "A".into(),
1415 title: None,
1416 is_wikilink: false,
1417 wikilink_pothole: None,
1418 },
1419 caption: Some(vec![Inline::Text("A".into())]),
1420 width: None,
1421 align: None,
1422 class_names: Vec::new(),
1423 img_style: None,
1424 }];
1425 let meta = vec![BlockMeta {
1426 source_line: Some(9),
1427 }];
1428 let html = render_with_meta(blocks, meta);
1429 assert!(
1430 html.contains(r#"<figure class="moss-image" data-source-line="9">"#),
1431 "got: {html}"
1432 );
1433 }
1434
1435 #[test]
1436 fn no_data_source_line_when_meta_none() {
1437 let html = render(vec![
1440 Block::Paragraph(vec![Inline::Text("hi".into())]),
1441 Block::ThematicBreak,
1442 ]);
1443 assert!(
1444 !html.contains("data-source-line"),
1445 "default render must NOT emit data-source-line, got: {html}"
1446 );
1447 }
1448
1449 #[test]
1450 fn end_to_end_parse_with_config_emits_data_source_line() {
1451 let md = "# Title\n\nfirst paragraph\n\n## Sub\n\nsecond paragraph\n";
1453 let config = super::super::parser::ParseConfig {
1454 emit_source_lines: true,
1455 implicit_figure: true,
1456 source_line_offset: 0,
1457 };
1458 let mut doc = super::super::parser::parse_with_config(md, &config);
1459 super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1460 Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Internal),
1461 _ => {}
1462 });
1463 let html = render_document(&doc, &DefaultHooks::new());
1464 assert!(
1465 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>"##),
1466 "H1 should carry data-source-line=1: {html}"
1467 );
1468 assert!(
1469 html.contains(r#"<p data-source-line="3">first paragraph</p>"#),
1470 "first paragraph should carry data-source-line=3: {html}"
1471 );
1472 assert!(
1473 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>"##),
1474 "H2 should carry data-source-line=5: {html}"
1475 );
1476 assert!(
1477 html.contains(r#"<p data-source-line="7">second paragraph</p>"#),
1478 "second paragraph should carry data-source-line=7: {html}"
1479 );
1480 }
1481}