1use super::code_languages::language_display_label;
2#[cfg(feature = "render-diagrams")]
3use super::diagram::render_mermaid_diagram;
4#[cfg(feature = "render-math")]
5use super::math::{render_display_math, render_inline_math};
6use super::plarform_mentions;
7#[cfg(feature = "render-syntax-highlighting")]
8use super::syntect_highlighter::highlight_code_to_classed_html;
9use super::RenderOptions;
10use crate::parser::{AdmonitionKind, AdmonitionStyle, Document, Node, NodeKind};
11use std::collections::HashMap;
12
13const CODE_BLOCK_COPY_SVG: &str = r#"<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1' stroke-linecap='round' stroke-linejoin='round' class='icon icon-tabler icons-tabler-outline icon-tabler-copy'><path stroke='none' d='M0 0h24v24H0z' fill='none'/><path d='M7 9.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667l0 -8.666' /><path d='M4.012 16.737a2.005 2.005 0 0 1 -1.012 -1.737v-10c0 -1.1 .9 -2 2 -2h10c.75 0 1.158 .385 1.5 1' /></svg>"#;
15
16const SLIDER_ARROW_LEFT_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-narrow-left" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l14 0" /><path d="M5 12l4 4" /><path d="M5 12l4 -4" /></svg>"#;
19
20const SLIDER_ARROW_RIGHT_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-narrow-right" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l14 0" /><path d="M15 16l4 -4" /><path d="M15 8l4 4" /></svg>"#;
21
22const SLIDER_PLAY_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-player-play" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 4v16l13 -8l-13 -8" /></svg>"#;
23
24const SLIDER_PAUSE_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-player-pause" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 6a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1l0 -12" /><path d="M14 6a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1l0 -12" /></svg>"#;
25
26const SLIDER_DOT_INACTIVE_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-point" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" /></svg>"#;
27
28const SLIDER_DOT_ACTIVE_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-point" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 7a5 5 0 1 1 -4.995 5.217l-.005 -.217l.005 -.217a5 5 0 0 1 4.995 -4.783z" /></svg>"#;
29
30#[derive(Default)]
31struct RenderContext<'a> {
32 footnote_defs: HashMap<String, &'a Node>,
33 footnote_numbers: HashMap<String, usize>,
34 footnote_order: Vec<String>,
35 footnote_ref_counts: HashMap<String, usize>,
36 tab_group_counter: usize,
37 slider_deck_counter: usize,
38 #[cfg(feature = "render-diagrams")]
39 mermaid_result_cache: HashMap<(String, String), Result<String, String>>,
40 heading_slug_counts: HashMap<String, usize>,
43 #[cfg(feature = "render-syntax-highlighting")]
53 precomputed_highlights: Option<HashMap<usize, String>>,
54}
55
56pub fn render_html(
58 document: &Document,
59 options: &RenderOptions,
60) -> Result<String, Box<dyn std::error::Error>> {
61 log::debug!("Rendering {} nodes to HTML", document.len());
62
63 let estimated = document.children.len() * 64;
66 let mut html = String::with_capacity(estimated.max(256));
67
68 let mut ctx = RenderContext::default();
69 for node in &document.children {
70 collect_footnote_definitions(node, &mut ctx.footnote_defs);
71 }
72
73 #[cfg(all(feature = "render-syntax-highlighting", feature = "parallel-render"))]
74 {
75 ctx.precomputed_highlights = Some(precompute_highlights(document, options));
76 }
77
78 for node in &document.children {
79 render_node(node, &mut html, options, &mut ctx)?;
80 }
81
82 if !ctx.footnote_order.is_empty() {
83 html.push_str("<section class=\"footnotes\">\n");
84 html.push_str("<ol>\n");
85
86 let mut i = 0usize;
87 while i < ctx.footnote_order.len() {
88 let label = ctx.footnote_order[i].clone();
89 let Some(n) = ctx.footnote_numbers.get(&label).copied() else {
90 i += 1;
91 continue;
92 };
93
94 let Some(def_node) = ctx.footnote_defs.get(&label).copied() else {
95 i += 1;
96 continue;
97 };
98
99 html.push_str("<li id=\"fn");
100 html.push_str(&n.to_string());
101 html.push_str("\">");
102 for child in &def_node.children {
103 render_node(child, &mut html, options, &mut ctx)?;
104 }
105 html.push_str("</li>\n");
106
107 i += 1;
108 }
109
110 html.push_str("</ol>\n");
111 html.push_str("</section>\n");
112 }
113
114 Ok(html)
115}
116
117fn collect_footnote_definitions<'a>(node: &'a Node, defs: &mut HashMap<String, &'a Node>) {
118 if let NodeKind::FootnoteDefinition { label } = &node.kind {
119 defs.entry(label.clone()).or_insert(node);
120 }
121
122 for child in &node.children {
123 collect_footnote_definitions(child, defs);
124 }
125}
126
127#[cfg(all(feature = "render-syntax-highlighting", feature = "parallel-render"))]
142fn precompute_highlights(document: &Document, options: &RenderOptions) -> HashMap<usize, String> {
143 use rayon::prelude::*;
144
145 fn collect<'a>(
146 node: &'a Node,
147 options: &RenderOptions,
148 targets: &mut Vec<(usize, &'a str, &'a str)>,
149 ) {
150 if let NodeKind::CodeBlock { language, code } = &node.kind {
151 if options.syntax_highlighting {
152 let language_raw = language.as_deref().map(str::trim).filter(|s| !s.is_empty());
153 if let Some(lang) = language_raw {
154 targets.push((std::ptr::from_ref(node) as usize, code.as_str(), lang));
155 }
156 }
157 }
158 for child in &node.children {
159 collect(child, options, targets);
160 }
161 }
162
163 let mut targets = Vec::new();
164 for node in &document.children {
165 collect(node, options, &mut targets);
166 }
167
168 targets
169 .par_iter()
170 .filter_map(|(key, code, lang)| {
171 highlight_code_to_classed_html(code, lang).map(|html| (*key, html))
172 })
173 .collect()
174}
175
176#[cfg(feature = "parallel-render")]
215pub fn warm_render_thread_pool(languages: &[&str]) {
216 let _ = rayon::ThreadPoolBuilder::new().build_global();
223
224 #[cfg(feature = "render-syntax-highlighting")]
225 for lang in languages {
226 let _ = highlight_code_to_classed_html(" ", lang);
227 }
228 #[cfg(not(feature = "render-syntax-highlighting"))]
229 let _ = languages;
230}
231
232#[cfg(not(feature = "parallel-render"))]
237pub fn warm_render_thread_pool(_languages: &[&str]) {}
238
239fn render_node(
241 node: &Node,
242 output: &mut String,
243 options: &RenderOptions,
244 ctx: &mut RenderContext<'_>,
245) -> Result<(), Box<dyn std::error::Error>> {
246 match &node.kind {
247 NodeKind::Heading { level, text, id } => {
248 log::trace!("Rendering heading level {}", level);
249 let escaped_text = escape_html(text);
250
251 let effective_id = if let Some(explicit_id) = id {
254 explicit_id.clone()
255 } else {
256 let base = crate::intelligence::toc::heading_slug(text);
257 let count = ctx.heading_slug_counts.entry(base.clone()).or_insert(0);
258 let slug = if *count == 0 {
259 base.clone()
260 } else {
261 format!("{}-{}", base, count)
262 };
263 *count += 1;
264 slug
265 };
266
267 let level_char = char::from_digit(*level as u32, 10).unwrap_or('1');
269 let escaped_id = escape_html(&effective_id);
270
271 output.push_str("<h");
272 output.push(level_char);
273 output.push_str(" id=\"");
274 output.push_str(&escaped_id);
275 output.push_str("\">");
276
277 output.push_str("<a class=\"marco-heading-anchor\" href=\"#");
279 output.push_str(&escaped_id);
280 output.push_str("\" aria-label=\"Link to this heading\">");
281 output.push_str(&escaped_text);
282 output.push_str("</a>");
283
284 output.push_str("</h");
285 output.push(level_char);
286 output.push_str(">\n");
287 }
288 NodeKind::Paragraph => {
289 output.push_str("<p>");
290 for child in &node.children {
291 render_node(child, output, options, ctx)?;
292 }
293 output.push_str("</p>\n");
294 }
295 NodeKind::CodeBlock { language, code } => {
296 log::trace!("Rendering code block: {:?}", language);
297 let language_raw = language.as_deref().map(str::trim).filter(|s| !s.is_empty());
298
299 output.push_str("<div class=\"marco-code-block\">");
301
302 output.push_str("<button class=\"marco-copy-btn\" data-action=\"copy\" aria-label=\"Copy code\" title=\"Copy code\">");
304 output.push_str(CODE_BLOCK_COPY_SVG);
305 output.push_str("</button>");
306
307 output.push_str("<pre");
308 if let Some(raw) = language_raw {
309 if let Some(label) = language_display_label(raw) {
310 output.push_str(" data-language=\"");
311 output.push_str(&escape_html(label.as_ref()));
312 output.push('"');
313 }
314 }
315 output.push_str("><code");
316
317 if let Some(lang) = language_raw {
319 output.push_str(" class=\"language-");
320 output.push_str(&escape_html(lang));
321 output.push('"');
322 }
323
324 output.push('>');
325
326 #[cfg(feature = "render-syntax-highlighting")]
333 if options.syntax_highlighting {
334 if let Some(lang) = language_raw {
335 let highlighted = match ctx.precomputed_highlights.as_ref() {
336 Some(cache) => cache.get(&(std::ptr::from_ref(node) as usize)).cloned(),
337 None => highlight_code_to_classed_html(code, lang),
338 };
339 if let Some(highlighted) = highlighted {
340 output.push_str(&highlighted);
341 output.push_str("</code></pre>");
342 output.push_str("</div>\n");
343 return Ok(());
344 }
345 }
346 }
347
348 output.push_str(&escape_html(code));
349 output.push_str("</code></pre>");
350 output.push_str("</div>\n");
351 }
352 NodeKind::ThematicBreak => {
353 output.push_str("<hr />\n");
354 }
355 NodeKind::HtmlBlock { html } => {
356 output.push_str(html);
359 if !html.ends_with('\n') {
360 output.push('\n');
361 }
362 }
363 NodeKind::Blockquote => {
364 output.push_str("<blockquote>\n");
365 for child in &node.children {
366 render_node(child, output, options, ctx)?;
367 }
368 output.push_str("</blockquote>\n");
369 }
370 NodeKind::Admonition {
371 kind,
372 title,
373 icon,
374 style,
375 } => {
376 let (slug, default_title, icon_svg) = admonition_presentation(kind);
377
378 let title_text = title.as_deref().unwrap_or(default_title);
379
380 output.push_str("<div class=\"");
386 output.push_str("markdown-alert");
387
388 if *style == AdmonitionStyle::Alert {
389 output.push_str(" markdown-alert-");
390 output.push_str(slug);
391 }
392
393 output.push_str(" admonition");
394
395 if *style == AdmonitionStyle::Alert {
396 output.push_str(" admonition-");
397 output.push_str(slug);
398 } else {
399 output.push_str(" admonition-quote");
400 }
401
402 output.push_str("\">\n");
403
404 output.push_str("<p class=\"markdown-alert-title\">");
405 output.push_str("<span class=\"markdown-alert-icon\" aria-hidden=\"true\">");
406 if let Some(icon_text) = icon {
407 output.push_str("<span class=\"markdown-alert-emoji\">");
410 output.push_str(&escape_html(icon_text));
411 output.push_str("</span>");
412 } else {
413 output.push_str(icon_svg);
414 }
415 output.push_str("</span>");
416 output.push_str(&escape_html(title_text));
417 output.push_str("</p>\n");
418
419 for child in &node.children {
420 render_node(child, output, options, ctx)?;
421 }
422
423 output.push_str("</div>\n");
424 }
425 NodeKind::TabGroup => {
426 render_tab_group(node, output, options, ctx)?;
427 }
428 NodeKind::TabItem { .. } => {
429 log::warn!("TabItem rendered outside of TabGroup context");
432 for child in &node.children {
433 render_node(child, output, options, ctx)?;
434 }
435 }
436 NodeKind::SliderDeck { .. } => {
437 render_slider_deck(node, output, options, ctx)?;
438 }
439 NodeKind::Slide { .. } => {
440 log::warn!("Slide rendered outside of SliderDeck context");
443 for child in &node.children {
444 render_node(child, output, options, ctx)?;
445 }
446 }
447 NodeKind::Table { .. } => {
448 render_table(node, output, options, ctx)?;
449 }
450 NodeKind::TableRow { .. } => {
451 log::warn!("TableRow rendered outside of Table context");
454 render_table_row(node, output, options, ctx)?;
455 output.push('\n');
456 }
457 NodeKind::TableCell { .. } => {
458 log::warn!("TableCell rendered outside of TableRow context");
460 render_table_cell(node, output, options, ctx)?;
461 }
462 NodeKind::FootnoteDefinition { .. } => {
463 }
466 NodeKind::Text(text) => {
467 output.push_str(&escape_html(text));
468 }
469 NodeKind::CodeSpan(code) => {
470 output.push_str("<code>");
471 output.push_str(&escape_html(code));
472 output.push_str("</code>");
473 }
474 NodeKind::Emphasis => {
475 output.push_str("<em>");
476 for child in &node.children {
477 render_node(child, output, options, ctx)?;
478 }
479 output.push_str("</em>");
480 }
481 NodeKind::Strong => {
482 output.push_str("<strong>");
483 for child in &node.children {
484 render_node(child, output, options, ctx)?;
485 }
486 output.push_str("</strong>");
487 }
488 NodeKind::StrongEmphasis => {
489 output.push_str("<em><strong>");
492 for child in &node.children {
493 render_node(child, output, options, ctx)?;
494 }
495 output.push_str("</strong></em>");
496 }
497 NodeKind::Strikethrough => {
498 output.push_str("<del>");
499 for child in &node.children {
500 render_node(child, output, options, ctx)?;
501 }
502 output.push_str("</del>");
503 }
504 NodeKind::Mark => {
505 output.push_str("<mark>");
506 for child in &node.children {
507 render_node(child, output, options, ctx)?;
508 }
509 output.push_str("</mark>");
510 }
511 NodeKind::Superscript => {
512 output.push_str("<sup>");
513 for child in &node.children {
514 render_node(child, output, options, ctx)?;
515 }
516 output.push_str("</sup>");
517 }
518 NodeKind::Subscript => {
519 output.push_str("<sub>");
520 for child in &node.children {
521 render_node(child, output, options, ctx)?;
522 }
523 output.push_str("</sub>");
524 }
525 NodeKind::Link { url, title } => {
526 output.push_str("<a href=\"");
527 output.push_str(&escape_html(url));
528 output.push('"');
529 if let Some(t) = title {
530 output.push_str(" title=\"");
531 output.push_str(&escape_html(t));
532 output.push('"');
533 }
534 output.push('>');
535 for child in &node.children {
536 render_node(child, output, options, ctx)?;
537 }
538 output.push_str("</a>");
539 }
540 NodeKind::PlatformMention {
541 username,
542 platform,
543 display,
544 } => {
545 let label = display.as_deref().unwrap_or(username);
546 let platform_key = platform.trim().to_ascii_lowercase();
547
548 if let Some(url) = plarform_mentions::profile_url(&platform_key, username) {
549 output.push_str("<a class=\"marco-mention marco-mention-");
550 output.push_str(&escape_html(&platform_key));
551 output.push_str("\" href=\"");
552 output.push_str(&escape_html(&url));
553 output.push_str("\">");
554 output.push_str(&escape_html(label));
555 output.push_str("</a>");
556 } else {
557 output.push_str("<span class=\"marco-mention marco-mention-unknown\">");
558 output.push_str(&escape_html(label));
559 output.push_str("</span>");
560 }
561 }
562 NodeKind::LinkReference { suffix, .. } => {
563 output.push('[');
567 for child in &node.children {
568 render_node(child, output, options, ctx)?;
569 }
570 output.push(']');
571 output.push_str(&escape_html(suffix));
572 }
573 NodeKind::FootnoteReference { label } => {
574 if !ctx.footnote_defs.contains_key(label) {
575 output.push_str("[^");
576 output.push_str(&escape_html(label));
577 output.push(']');
578 return Ok(());
580 }
581
582 let n = match ctx.footnote_numbers.get(label) {
583 Some(n) => *n,
584 None => {
585 let next = ctx.footnote_order.len() + 1;
586 ctx.footnote_order.push(label.clone());
587 ctx.footnote_numbers.insert(label.clone(), next);
588 next
589 }
590 };
591
592 let count = ctx.footnote_ref_counts.entry(label.clone()).or_insert(0);
593 *count += 1;
594 let ref_id = if *count == 1 {
595 format!("fnref{}", n)
596 } else {
597 format!("fnref{}-{}", n, *count)
598 };
599
600 output.push_str("<sup class=\"footnote-ref\"><a href=\"#fn");
601 output.push_str(&n.to_string());
602 output.push_str("\" id=\"");
603 output.push_str(&escape_html(&ref_id));
604 output.push_str("\">");
605 output.push_str(&n.to_string());
606 output.push_str("</a></sup>");
607 }
608 NodeKind::Image { url, alt } => {
609 output.push_str("<img src=\"");
610 output.push_str(&escape_html(url));
611 output.push_str("\" alt=\"");
612 output.push_str(&escape_html(alt));
613 output.push_str("\" />");
614 }
615 NodeKind::InlineHtml(html) => {
616 output.push_str(html);
618 }
619 NodeKind::HardBreak => {
620 output.push_str("<br />\n");
622 }
623 NodeKind::SoftBreak => {
624 output.push('\n');
626 }
627 NodeKind::List {
628 ordered,
629 start,
630 tight,
631 } => {
632 if *ordered {
634 output.push_str("<ol");
635 if let Some(num) = start {
636 if *num != 1 {
637 output.push_str(&format!(" start=\"{}\"", num));
638 }
639 }
640 output.push_str(">\n");
641 } else {
642 output.push_str("<ul>\n");
643 }
644
645 for child in &node.children {
647 render_list_item(child, output, *tight, options, ctx)?;
648 }
649
650 if *ordered {
652 output.push_str("</ol>\n");
653 } else {
654 output.push_str("</ul>\n");
655 }
656 }
657 NodeKind::DefinitionList => {
658 output.push_str("<dl>\n");
659 for child in &node.children {
660 render_node(child, output, options, ctx)?;
661 }
662 output.push_str("</dl>\n");
663 }
664 NodeKind::DefinitionTerm => {
665 output.push_str("<dt>");
666 for child in &node.children {
667 render_node(child, output, options, ctx)?;
668 }
669 output.push_str("</dt>\n");
670 }
671 NodeKind::DefinitionDescription => {
672 output.push_str("<dd>\n");
673 for child in &node.children {
674 render_node(child, output, options, ctx)?;
675 }
676 output.push_str("</dd>\n");
677 }
678 NodeKind::ListItem => {
679 log::warn!("ListItem rendered outside of List context");
681 output.push_str("<li>");
682 for child in &node.children {
683 render_node(child, output, options, ctx)?;
684 }
685 output.push_str("</li>\n");
686 }
687 NodeKind::TaskCheckbox { .. } => {
688 log::warn!("TaskCheckbox rendered outside of ListItem context");
690 }
691 NodeKind::TaskCheckboxInline { checked } => {
692 render_task_checkbox_icon(output, *checked);
694 }
695 NodeKind::InlineMath { content } => {
696 #[cfg(feature = "render-math")]
698 match render_inline_math(content) {
699 Ok(html) => output.push_str(&html),
700 Err(e) => {
701 log::warn!("Math render error (inline): {}", e);
702 output.push_str("<code class=\"math-error\" title=\"Failed to render math\">");
704 output.push_str(&escape_html(content));
705 output.push_str("</code>");
706 }
707 }
708 #[cfg(not(feature = "render-math"))]
709 {
710 output.push_str("<code class=\"math\">");
711 output.push_str(&escape_html(content));
712 output.push_str("</code>");
713 }
714 }
715 NodeKind::DisplayMath { content } => {
716 #[cfg(feature = "render-math")]
718 match render_display_math(content) {
719 Ok(html) => output.push_str(&html),
720 Err(e) => {
721 log::warn!("Math render error (display): {}", e);
722 output.push_str("<pre class=\"math-error\" title=\"Failed to render math\">");
724 output.push_str(&escape_html(content));
725 output.push_str("</pre>");
726 }
727 }
728 #[cfg(not(feature = "render-math"))]
729 {
730 output.push_str("<pre class=\"math\"><code>");
731 output.push_str(&escape_html(content));
732 output.push_str("</code></pre>\n");
733 }
734 }
735 NodeKind::MermaidDiagram { content } => {
736 #[cfg(feature = "render-diagrams")]
738 {
739 let cache_key = (options.theme.clone(), content.clone());
740 let rendered = if let Some(cached) = ctx.mermaid_result_cache.get(&cache_key) {
741 cached.clone()
742 } else {
743 let fresh = match render_mermaid_diagram(content, &options.theme) {
744 Ok(svg) => Ok(svg),
745 Err(e) => Err(e.to_string()),
746 };
747 ctx.mermaid_result_cache.insert(cache_key, fresh.clone());
748 fresh
749 };
750
751 match rendered {
752 Ok(svg) => {
753 output.push_str("<div class=\"marco-diagram\">");
754 output.push_str(&svg);
755 output.push_str("</div>\n");
756 }
757 Err(e) => {
758 log::warn!("Mermaid render error: {}", e);
759 let mut title = String::from("Failed to render diagram: ");
761 let max_len = 160usize;
762 if e.chars().count() > max_len {
763 title.push_str(&e.chars().take(max_len).collect::<String>());
764 title.push('…');
765 } else {
766 title.push_str(&e);
767 }
768 output.push_str("<pre class=\"mermaid-error\" title=\"");
769 output.push_str(&escape_html(&title));
770 output.push_str("\"><code>");
771 output.push_str(&escape_html(content));
772 output.push_str("</code></pre>\n");
773 }
774 }
775 }
776 #[cfg(not(feature = "render-diagrams"))]
777 {
778 output.push_str("<pre class=\"mermaid\"><code>");
779 output.push_str(&escape_html(content));
780 output.push_str("</code></pre>\n");
781 }
782 }
783 }
784
785 Ok(())
786}
787
788fn admonition_presentation(kind: &AdmonitionKind) -> (&'static str, &'static str, &'static str) {
789 match kind {
792 AdmonitionKind::Note => (
793 "note",
794 "Note",
795 concat!(
796 r#"<svg xmlns=""#,
797 "http",
798 r#"://www.w3.org/2000/svg"#,
799 r#"" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" focusable="false" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" /><path d="M12 9h.01" /><path d="M11 12h1v4h1" /></svg>"#,
800 ),
801 ),
802 AdmonitionKind::Tip => (
803 "tip",
804 "Tip",
805 concat!(
806 r#"<svg xmlns=""#,
807 "http",
808 r#"://www.w3.org/2000/svg"#,
809 r#"" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" focusable="false" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15.02 19.52c-2.341 .736 -5 .606 -7.32 -.52l-4.7 1l1.3 -3.9c-2.324 -3.437 -1.426 -7.872 2.1 -10.374c3.526 -2.501 8.59 -2.296 11.845 .48c1.649 1.407 2.575 3.253 2.742 5.152" /><path d="M19 22v.01" /><path d="M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483" /></svg>"#,
810 ),
811 ),
812 AdmonitionKind::Important => (
813 "important",
814 "Important",
815 concat!(
816 r#"<svg xmlns=""#,
817 "http",
818 r#"://www.w3.org/2000/svg"#,
819 r#"" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" focusable="false" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 9h8" /><path d="M8 13h6" /><path d="M15 18l-3 3l-3 -3h-3a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v5.5" /><path d="M19 16v3" /><path d="M19 22v.01" /></svg>"#,
820 ),
821 ),
822 AdmonitionKind::Warning => (
823 "warning",
824 "Warning",
825 concat!(
826 r#"<svg xmlns=""#,
827 "http",
828 r#"://www.w3.org/2000/svg"#,
829 r#"" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" focusable="false" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.363 3.591l-8.106 13.534a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636 -2.87l-8.106 -13.536a1.914 1.914 0 0 0 -3.274 0" /><path d="M12 9h.01" /><path d="M11 12h1v4h1" /></svg>"#,
830 ),
831 ),
832 AdmonitionKind::Caution => (
833 "caution",
834 "Caution",
835 concat!(
836 r#"<svg xmlns=""#,
837 "http",
838 r#"://www.w3.org/2000/svg"#,
839 r#"" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" focusable="false" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19.875 6.27c.7 .398 1.13 1.143 1.125 1.948v7.284c0 .809 -.443 1.555 -1.158 1.948l-6.75 4.27a2.269 2.269 0 0 1 -2.184 0l-6.75 -4.27a2.225 2.225 0 0 1 -1.158 -1.948v-7.285c0 -.809 .443 -1.554 1.158 -1.947l6.75 -3.98a2.33 2.33 0 0 1 2.25 0l6.75 3.98h-.033" /><path d="M12 8v4" /><path d="M12 16h.01" /></svg>"#,
840 ),
841 ),
842 }
843}
844
845fn render_tab_group(
846 node: &Node,
847 output: &mut String,
848 options: &RenderOptions,
849 ctx: &mut RenderContext<'_>,
850) -> Result<(), Box<dyn std::error::Error>> {
851 let group_id = ctx.tab_group_counter;
853 ctx.tab_group_counter = ctx.tab_group_counter.saturating_add(1);
854
855 let mut items: Vec<(&str, &Node)> = Vec::new();
857 for child in &node.children {
858 if let NodeKind::TabItem { title } = &child.kind {
859 items.push((title.as_str(), child));
860 } else {
861 log::warn!("Unexpected child inside TabGroup: {:?}", child.kind);
862 }
863 }
864
865 if items.is_empty() {
866 return Ok(());
867 }
868
869 output.push_str("<div class=\"marco-tabs\">\n");
870
871 for (i, (title, _item_node)) in items.iter().enumerate() {
873 output.push_str("<input class=\"marco-tabs__radio\" type=\"radio\" name=\"marco-tabs-");
874 output.push_str(&group_id.to_string());
875 output.push_str("\" id=\"marco-tabs-");
876 output.push_str(&group_id.to_string());
877 output.push('-');
878 output.push_str(&i.to_string());
879 output.push_str("\" aria-label=\"");
880 output.push_str(&escape_html(title));
881 output.push('"');
882 if i == 0 {
883 output.push_str(" checked");
884 }
885 output.push_str(" />\n");
886 }
887
888 output.push_str("<div class=\"marco-tabs__tablist\">\n");
889 for (i, (title, _item_node)) in items.iter().enumerate() {
890 output.push_str("<label class=\"marco-tabs__tab\" for=\"marco-tabs-");
891 output.push_str(&group_id.to_string());
892 output.push('-');
893 output.push_str(&i.to_string());
894 output.push_str("\">");
895 output.push_str(&escape_html(title));
896 output.push_str("</label>\n");
897 }
898 output.push_str("</div>\n");
899
900 output.push_str("<div class=\"marco-tabs__panels\">\n");
901 for &(_title, item_node) in items.iter() {
902 output.push_str("<div class=\"marco-tabs__panel\">\n");
903 for panel_child in &item_node.children {
904 render_node(panel_child, output, options, ctx)?;
905 }
906 output.push_str("</div>\n");
907 }
908 output.push_str("</div>\n");
909
910 output.push_str("</div>\n");
911 Ok(())
912}
913
914fn render_slider_deck(
915 node: &Node,
916 output: &mut String,
917 options: &RenderOptions,
918 ctx: &mut RenderContext<'_>,
919) -> Result<(), Box<dyn std::error::Error>> {
920 let timer_seconds = match &node.kind {
921 NodeKind::SliderDeck { timer_seconds } => *timer_seconds,
922 other => {
923 log::warn!(
924 "render_slider_deck called with non SliderDeck node: {:?}",
925 other
926 );
927 return Ok(());
928 }
929 };
930
931 let deck_id = ctx.slider_deck_counter;
933 ctx.slider_deck_counter = ctx.slider_deck_counter.saturating_add(1);
934
935 let mut slides: Vec<(bool, &Node)> = Vec::new();
937 for child in &node.children {
938 if let NodeKind::Slide { vertical } = &child.kind {
939 slides.push((*vertical, child));
940 } else {
941 log::warn!("Unexpected child inside SliderDeck: {:?}", child.kind);
942 }
943 }
944
945 if slides.is_empty() {
946 return Ok(());
947 }
948
949 output.push_str("<div class=\"marco-sliders\" id=\"marco-sliders-");
950 output.push_str(&deck_id.to_string());
951 output.push('"');
952 if let Some(seconds) = timer_seconds {
953 output.push_str(" data-timer-seconds=\"");
954 output.push_str(&seconds.to_string());
955 output.push('"');
956 }
957 output.push('>');
958
959 output.push_str("<div class=\"marco-sliders__viewport\">");
960 for (i, (vertical, slide_node)) in slides.iter().enumerate() {
961 output.push_str("<section class=\"marco-sliders__slide");
962 if i == 0 {
963 output.push_str(" is-active");
964 }
965 output.push_str("\" data-slide-index=\"");
966 output.push_str(&i.to_string());
967 output.push('"');
968 if *vertical {
974 output.push_str(" data-vertical=\"true\"");
975 }
976 output.push_str(">\n");
977 for child in &slide_node.children {
978 render_node(child, output, options, ctx)?;
979 }
980 output.push_str("</section>\n");
981 }
982 output.push_str("</div>");
983
984 output.push_str("<div class=\"marco-sliders__controls\" aria-label=\"Slideshow controls\">");
986
987 output.push_str(
988 "<button class=\"marco-sliders__btn marco-sliders__btn--prev\" type=\"button\" data-action=\"prev\" aria-label=\"Previous slide\">",
989 );
990 output.push_str(SLIDER_ARROW_LEFT_SVG);
991 output.push_str("</button>");
992
993 output.push_str(
994 "<button class=\"marco-sliders__btn marco-sliders__btn--toggle\" type=\"button\" data-action=\"toggle\" aria-label=\"Toggle autoplay\">",
995 );
996 output.push_str("<span class=\"marco-sliders__icon marco-sliders__icon--play\">");
997 output.push_str(SLIDER_PLAY_SVG);
998 output.push_str("</span>");
999 output.push_str("<span class=\"marco-sliders__icon marco-sliders__icon--pause\">");
1000 output.push_str(SLIDER_PAUSE_SVG);
1001 output.push_str("</span>");
1002 output.push_str("</button>");
1003
1004 output.push_str(
1005 "<button class=\"marco-sliders__btn marco-sliders__btn--next\" type=\"button\" data-action=\"next\" aria-label=\"Next slide\">",
1006 );
1007 output.push_str(SLIDER_ARROW_RIGHT_SVG);
1008 output.push_str("</button>");
1009
1010 output.push_str("</div>");
1011
1012 output.push_str(
1014 "<div class=\"marco-sliders__dots\" role=\"tablist\" aria-label=\"Slideshow navigation\">",
1015 );
1016 for i in 0..slides.len() {
1017 output.push_str("<button class=\"marco-sliders__dot");
1018 if i == 0 {
1019 output.push_str(" is-active");
1020 }
1021 output.push_str("\" type=\"button\" data-action=\"goto\" data-index=\"");
1022 output.push_str(&i.to_string());
1023 output.push_str("\" aria-label=\"Go to slide ");
1024 output.push_str(&(i + 1).to_string());
1025 output.push('"');
1026 if i == 0 {
1027 output.push_str(" aria-selected=\"true\"");
1028 }
1029 output.push_str(">\n");
1030 output
1031 .push_str("<span class=\"marco-sliders__dot-icon marco-sliders__dot-icon--inactive\">");
1032 output.push_str(SLIDER_DOT_INACTIVE_SVG);
1033 output.push_str("</span>");
1034 output.push_str("<span class=\"marco-sliders__dot-icon marco-sliders__dot-icon--active\">");
1035 output.push_str(SLIDER_DOT_ACTIVE_SVG);
1036 output.push_str("</span>");
1037 output.push_str("</button>");
1038 }
1039 output.push_str("</div>");
1040
1041 output.push_str("</div>\n");
1042 Ok(())
1043}
1044
1045fn render_task_checkbox_icon(output: &mut String, checked: bool) {
1046 if checked {
1051 output.push_str(
1052 r#"<span class="marco-marco-marco-task-checkbox checked" aria-hidden="true">"#,
1053 );
1054 output.push_str(
1055 concat!(
1056 r#"<svg xmlns=""#,
1057 "http",
1058 r#"://www.w3.org/2000/svg"#,
1059 r#"" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="marco-task-icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path class="marco-task-check" style="stroke: var(--mc-task-accent); stroke-width: 2.0;" d="M9 11l3 3l8 -8" /><path class="marco-task-box" style="stroke: var(--mc-task-primary);" d="M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14" /></svg>"#,
1060 ),
1061 );
1062 output.push_str("</span>");
1063 } else {
1064 output.push_str(
1065 r#"<span class="marco-marco-marco-task-checkbox unchecked" aria-hidden="true">"#,
1066 );
1067 output.push_str(
1068 concat!(
1069 r#"<svg xmlns=""#,
1070 "http",
1071 r#"://www.w3.org/2000/svg"#,
1072 r#"" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="marco-task-icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path class="marco-task-box" d="M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14" /></svg>"#,
1073 ),
1074 );
1075 output.push_str("</span>");
1076 }
1077}
1078
1079fn render_table(
1080 node: &Node,
1081 output: &mut String,
1082 options: &RenderOptions,
1083 ctx: &mut RenderContext<'_>,
1084) -> Result<(), Box<dyn std::error::Error>> {
1085 output.push_str("<table>\n");
1086
1087 let mut header_rows: Vec<&Node> = Vec::new();
1088 let mut body_rows: Vec<&Node> = Vec::new();
1089
1090 for row in &node.children {
1091 match row.kind {
1092 NodeKind::TableRow { header: true } => header_rows.push(row),
1093 NodeKind::TableRow { header: false } => body_rows.push(row),
1094 _ => {
1095 log::warn!("Unexpected child inside Table: {:?}", row.kind);
1096 }
1097 }
1098 }
1099
1100 if !header_rows.is_empty() {
1101 output.push_str("<thead>\n");
1102 for row in header_rows {
1103 render_table_row(row, output, options, ctx)?;
1104 output.push('\n');
1105 }
1106 output.push_str("</thead>\n");
1107 }
1108
1109 if !body_rows.is_empty() {
1110 output.push_str("<tbody>\n");
1111 for row in body_rows {
1112 render_table_row(row, output, options, ctx)?;
1113 output.push('\n');
1114 }
1115 output.push_str("</tbody>\n");
1116 }
1117
1118 output.push_str("</table>\n");
1119 Ok(())
1120}
1121
1122fn render_table_row(
1123 node: &Node,
1124 output: &mut String,
1125 options: &RenderOptions,
1126 ctx: &mut RenderContext<'_>,
1127) -> Result<(), Box<dyn std::error::Error>> {
1128 output.push_str("<tr>");
1129 for cell in &node.children {
1130 render_table_cell(cell, output, options, ctx)?;
1131 }
1132 output.push_str("</tr>");
1133 Ok(())
1134}
1135
1136fn render_table_cell(
1137 node: &Node,
1138 output: &mut String,
1139 options: &RenderOptions,
1140 ctx: &mut RenderContext<'_>,
1141) -> Result<(), Box<dyn std::error::Error>> {
1142 let (is_header, alignment) = match &node.kind {
1143 NodeKind::TableCell { header, alignment } => (*header, *alignment),
1144 _ => {
1145 log::warn!("Unexpected child inside TableRow: {:?}", node.kind);
1146 (false, crate::parser::ast::TableAlignment::None)
1147 }
1148 };
1149
1150 let tag = if is_header { "th" } else { "td" };
1151 output.push('<');
1152 output.push_str(tag);
1153
1154 if let Some(style_value) = alignment_to_css(alignment) {
1155 output.push_str(" style=\"");
1156 output.push_str(style_value);
1157 output.push('"');
1158 }
1159
1160 output.push('>');
1161 for child in &node.children {
1162 render_node(child, output, options, ctx)?;
1163 }
1164 output.push_str("</");
1165 output.push_str(tag);
1166 output.push('>');
1167 Ok(())
1168}
1169
1170fn alignment_to_css(alignment: crate::parser::ast::TableAlignment) -> Option<&'static str> {
1171 match alignment {
1172 crate::parser::ast::TableAlignment::None => None,
1173 crate::parser::ast::TableAlignment::Left => Some("text-align: left;"),
1174 crate::parser::ast::TableAlignment::Center => Some("text-align: center;"),
1175 crate::parser::ast::TableAlignment::Right => Some("text-align: right;"),
1176 }
1177}
1178
1179fn render_list_item(
1181 node: &Node,
1182 output: &mut String,
1183 tight: bool,
1184 options: &RenderOptions,
1185 ctx: &mut RenderContext<'_>,
1186) -> Result<(), Box<dyn std::error::Error>> {
1187 let task_checked = match node.children.first().map(|n| &n.kind) {
1188 Some(NodeKind::TaskCheckbox { checked }) => Some(*checked),
1189 _ => None,
1190 };
1191
1192 if let Some(checked) = task_checked {
1193 if checked {
1194 output.push_str("<li class=\"marco-task-list-item marco-task-list-item--checked\">");
1195 } else {
1196 output.push_str("<li class=\"marco-task-list-item\">");
1197 }
1198 } else {
1199 output.push_str("<li>");
1200 }
1201
1202 let needs_wrapper = task_checked.is_some()
1207 && node
1208 .children
1209 .iter()
1210 .any(|c| !matches!(c.kind, NodeKind::TaskCheckbox { .. } | NodeKind::Paragraph));
1211
1212 if tight {
1213 if let Some(checked) = task_checked {
1216 render_task_checkbox_icon(output, checked);
1217 }
1218 if needs_wrapper {
1219 output.push_str("<div class=\"marco-task-content\">");
1220 }
1221
1222 for child in &node.children {
1224 if matches!(child.kind, NodeKind::TaskCheckbox { .. }) {
1225 continue;
1226 }
1227 match &child.kind {
1228 NodeKind::Paragraph => {
1229 for grandchild in &child.children {
1231 render_node(grandchild, output, options, ctx)?;
1232 }
1233 }
1234 _ => {
1235 render_node(child, output, options, ctx)?;
1237 }
1238 }
1239 }
1240
1241 if needs_wrapper {
1242 output.push_str("</div>");
1243 }
1244 } else {
1245 let mut checkbox_emitted = false;
1248
1249 if needs_wrapper {
1250 if let Some(checked) = task_checked {
1252 render_task_checkbox_icon(output, checked);
1253 checkbox_emitted = true;
1254 }
1255 output.push_str("<div class=\"marco-task-content\">");
1256 }
1257
1258 for child in &node.children {
1259 if matches!(child.kind, NodeKind::TaskCheckbox { .. }) {
1260 continue;
1261 }
1262
1263 if let Some(checked) = task_checked {
1266 if !checkbox_emitted {
1267 match &child.kind {
1268 NodeKind::Paragraph => {
1269 output.push_str("<p>");
1270 render_task_checkbox_icon(output, checked);
1271 for grandchild in &child.children {
1272 render_node(grandchild, output, options, ctx)?;
1273 }
1274 output.push_str("</p>");
1275 checkbox_emitted = true;
1276 continue;
1277 }
1278 _ => {
1279 render_task_checkbox_icon(output, checked);
1280 checkbox_emitted = true;
1281 }
1283 }
1284 }
1285 }
1286
1287 render_node(child, output, options, ctx)?;
1288 }
1289
1290 if needs_wrapper {
1291 output.push_str("</div>");
1292 }
1293 }
1294
1295 output.push_str("</li>\n");
1296 Ok(())
1297}
1298
1299fn escape_html(text: &str) -> String {
1301 text.chars()
1302 .map(|c| match c {
1303 '&' => "&".to_string(),
1304 '<' => "<".to_string(),
1305 '>' => ">".to_string(),
1306 '"' => """.to_string(),
1307 '\'' => "'".to_string(),
1308 _ => c.to_string(),
1309 })
1310 .collect()
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315 use super::*;
1316 use crate::parser::ast::TableAlignment;
1317 use crate::parser::{Document, Node, NodeKind};
1318
1319 #[test]
1320 fn warm_render_thread_pool_is_callable_and_render_still_works() {
1321 warm_render_thread_pool(&["rust", "python"]);
1324 warm_render_thread_pool(&[]); let doc = Document {
1327 children: vec![Node {
1328 kind: NodeKind::Paragraph,
1329 span: None,
1330 children: vec![Node {
1331 kind: NodeKind::Text("still works after warmup".to_string()),
1332 span: None,
1333 children: vec![],
1334 }],
1335 }],
1336 ..Default::default()
1337 };
1338 let result = render_html(&doc, &RenderOptions::default()).unwrap();
1339 assert!(result.contains("still works after warmup"));
1340 }
1341
1342 #[test]
1343 fn smoke_test_escape_html_basic() {
1344 let input = "Hello <world> & \"friends\"";
1345 let expected = "Hello <world> & "friends"";
1346 assert_eq!(escape_html(input), expected);
1347 }
1348
1349 #[test]
1350 fn smoke_test_escape_html_script_tag() {
1351 let input = "<script>alert('XSS')</script>";
1352 let expected = "<script>alert('XSS')</script>";
1353 assert_eq!(escape_html(input), expected);
1354 }
1355
1356 #[test]
1357 fn smoke_test_render_heading_h1() {
1358 let doc = Document {
1359 children: vec![Node {
1360 kind: NodeKind::Heading {
1361 level: 1,
1362 text: "Hello World".to_string(),
1363 id: None,
1364 },
1365 span: None,
1366 children: vec![],
1367 }],
1368 ..Default::default()
1369 };
1370 let options = RenderOptions::default();
1371 let result = render_html(&doc, &options).unwrap();
1372 assert!(result.contains("<h1 id=\"hello-world\">"));
1374 assert!(result.contains("Hello World"));
1375 assert!(result.contains("class=\"marco-heading-anchor\""));
1376 assert!(result.contains("href=\"#hello-world\""));
1377 }
1378
1379 #[test]
1380 fn smoke_test_render_heading_with_html() {
1381 let doc = Document {
1382 children: vec![Node {
1383 kind: NodeKind::Heading {
1384 level: 2,
1385 text: "Code <example> & test".to_string(),
1386 id: None,
1387 },
1388 span: None,
1389 children: vec![],
1390 }],
1391 ..Default::default()
1392 };
1393 let options = RenderOptions::default();
1394 let result = render_html(&doc, &options).unwrap();
1395 assert!(result.contains("<h2 id=\"code-example-test\">"));
1397 assert!(result.contains("Code <example> & test"));
1398 }
1399
1400 #[test]
1401 fn smoke_test_render_paragraph_with_text() {
1402 let doc = Document {
1403 children: vec![Node {
1404 kind: NodeKind::Paragraph,
1405 span: None,
1406 children: vec![Node {
1407 kind: NodeKind::Text("This is a paragraph.".to_string()),
1408 span: None,
1409 children: vec![],
1410 }],
1411 }],
1412 ..Default::default()
1413 };
1414 let options = RenderOptions::default();
1415 let result = render_html(&doc, &options).unwrap();
1416 assert_eq!(result, "<p>This is a paragraph.</p>\n");
1417 }
1418
1419 #[test]
1420 fn smoke_test_render_code_block_without_language() {
1421 let doc = Document {
1422 children: vec![Node {
1423 kind: NodeKind::CodeBlock {
1424 language: None,
1425 code: "fn main() {\n println!(\"Hello\");\n}".to_string(),
1426 },
1427 span: None,
1428 children: vec![],
1429 }],
1430 ..Default::default()
1431 };
1432 let options = RenderOptions::default();
1433 let result = render_html(&doc, &options).unwrap();
1434 assert!(result.contains("<div class=\"marco-code-block\">"));
1436 assert!(result.contains("<button class=\"marco-copy-btn\""));
1437 assert!(result.contains("icon-tabler-copy"));
1438 assert!(result
1439 .contains("<pre><code>fn main() {\n println!("Hello");\n}</code></pre>"));
1440 assert!(result.contains("</div>\n"));
1441 }
1442
1443 #[test]
1444 fn smoke_test_render_code_block_with_language() {
1445 let doc = Document {
1446 children: vec![Node {
1447 kind: NodeKind::CodeBlock {
1448 language: Some("rust".to_string()),
1449 code: "let x = 42;".to_string(),
1450 },
1451 span: None,
1452 children: vec![],
1453 }],
1454 ..Default::default()
1455 };
1456 let options = RenderOptions {
1457 syntax_highlighting: false,
1458 ..RenderOptions::default()
1459 };
1460 let result = render_html(&doc, &options).unwrap();
1461 assert!(result.contains("<div class=\"marco-code-block\">"));
1463 assert!(result.contains("<button class=\"marco-copy-btn\""));
1464 assert!(result.contains(
1465 "<pre data-language=\"Rust\"><code class=\"language-rust\">let x = 42;</code></pre>"
1466 ));
1467 assert!(result.contains("</div>\n"));
1468 }
1469
1470 #[test]
1471 fn smoke_test_render_code_block_escapes_html() {
1472 let doc = Document {
1473 children: vec![Node {
1474 kind: NodeKind::CodeBlock {
1475 language: Some("html".to_string()),
1476 code: "<div>Test & verify</div>".to_string(),
1477 },
1478 span: None,
1479 children: vec![],
1480 }],
1481 ..Default::default()
1482 };
1483 let options = RenderOptions {
1484 syntax_highlighting: false,
1485 ..RenderOptions::default()
1486 };
1487 let result = render_html(&doc, &options).unwrap();
1488 assert!(result.contains("<div class=\"marco-code-block\">"));
1490 assert!(result.contains("<button class=\"marco-copy-btn\""));
1491 assert!(result.contains("<pre data-language=\"HTML\"><code class=\"language-html\"><div>Test & verify</div></code></pre>"));
1492 assert!(result.contains("</div>\n"));
1493 }
1494
1495 #[test]
1496 fn smoke_test_render_code_span() {
1497 let doc = Document {
1498 children: vec![Node {
1499 kind: NodeKind::Paragraph,
1500 span: None,
1501 children: vec![
1502 Node {
1503 kind: NodeKind::Text("Use ".to_string()),
1504 span: None,
1505 children: vec![],
1506 },
1507 Node {
1508 kind: NodeKind::CodeSpan("println!()".to_string()),
1509 span: None,
1510 children: vec![],
1511 },
1512 Node {
1513 kind: NodeKind::Text(" for output.".to_string()),
1514 span: None,
1515 children: vec![],
1516 },
1517 ],
1518 }],
1519 ..Default::default()
1520 };
1521 let options = RenderOptions::default();
1522 let result = render_html(&doc, &options).unwrap();
1523 assert_eq!(result, "<p>Use <code>println!()</code> for output.</p>\n");
1524 }
1525
1526 #[test]
1527 fn smoke_test_render_mixed_inlines() {
1528 let doc = Document {
1529 children: vec![
1530 Node {
1531 kind: NodeKind::Heading {
1532 level: 1,
1533 text: "Title".to_string(),
1534 id: None,
1535 },
1536 span: None,
1537 children: vec![],
1538 },
1539 Node {
1540 kind: NodeKind::Paragraph,
1541 span: None,
1542 children: vec![Node {
1543 kind: NodeKind::Text("Some text.".to_string()),
1544 span: None,
1545 children: vec![],
1546 }],
1547 },
1548 Node {
1549 kind: NodeKind::CodeBlock {
1550 language: Some("python".to_string()),
1551 code: "print('hello')".to_string(),
1552 },
1553 span: None,
1554 children: vec![],
1555 },
1556 ],
1557 ..Default::default()
1558 };
1559 let options = RenderOptions {
1560 syntax_highlighting: false,
1561 ..RenderOptions::default()
1562 };
1563 let result = render_html(&doc, &options).unwrap();
1564 assert!(result.contains("<h1 id=\"title\">"));
1566 assert!(result.contains("<p>Some text.</p>\n"));
1567 assert!(result.contains("<div class=\"marco-code-block\">"));
1568 assert!(result.contains("<button class=\"marco-copy-btn\""));
1569 assert!(result.contains("<pre data-language=\"Python\"><code class=\"language-python\">print('hello')</code></pre>"));
1570 assert!(result.contains("</div>\n"));
1571 }
1572
1573 #[test]
1574 fn smoke_test_render_strong_emphasis() {
1575 let doc = Document {
1576 children: vec![Node {
1577 kind: NodeKind::Paragraph,
1578 span: None,
1579 children: vec![Node {
1580 kind: NodeKind::StrongEmphasis,
1581 span: None,
1582 children: vec![Node {
1583 kind: NodeKind::Text("bold+italic".to_string()),
1584 span: None,
1585 children: vec![],
1586 }],
1587 }],
1588 }],
1589 ..Default::default()
1590 };
1591
1592 let options = RenderOptions::default();
1593 let result = render_html(&doc, &options).unwrap();
1594 assert_eq!(result, "<p><em><strong>bold+italic</strong></em></p>\n");
1595 }
1596
1597 #[test]
1598 fn smoke_test_render_strike_mark_sup_sub() {
1599 let doc = Document {
1600 children: vec![Node {
1601 kind: NodeKind::Paragraph,
1602 span: None,
1603 children: vec![
1604 Node {
1605 kind: NodeKind::Strikethrough,
1606 span: None,
1607 children: vec![Node {
1608 kind: NodeKind::Text("del".to_string()),
1609 span: None,
1610 children: vec![],
1611 }],
1612 },
1613 Node {
1614 kind: NodeKind::Text(" ".to_string()),
1615 span: None,
1616 children: vec![],
1617 },
1618 Node {
1619 kind: NodeKind::Mark,
1620 span: None,
1621 children: vec![Node {
1622 kind: NodeKind::Text("mark".to_string()),
1623 span: None,
1624 children: vec![],
1625 }],
1626 },
1627 Node {
1628 kind: NodeKind::Text(" ".to_string()),
1629 span: None,
1630 children: vec![],
1631 },
1632 Node {
1633 kind: NodeKind::Superscript,
1634 span: None,
1635 children: vec![Node {
1636 kind: NodeKind::Text("sup".to_string()),
1637 span: None,
1638 children: vec![],
1639 }],
1640 },
1641 Node {
1642 kind: NodeKind::Text(" ".to_string()),
1643 span: None,
1644 children: vec![],
1645 },
1646 Node {
1647 kind: NodeKind::Subscript,
1648 span: None,
1649 children: vec![Node {
1650 kind: NodeKind::Text("sub".to_string()),
1651 span: None,
1652 children: vec![],
1653 }],
1654 },
1655 ],
1656 }],
1657 ..Default::default()
1658 };
1659
1660 let options = RenderOptions::default();
1661 let result = render_html(&doc, &options).unwrap();
1662 assert_eq!(
1663 result,
1664 "<p><del>del</del> <mark>mark</mark> <sup>sup</sup> <sub>sub</sub></p>\n"
1665 );
1666 }
1667
1668 #[test]
1669 fn smoke_test_render_table_with_alignment() {
1670 let doc = Document {
1671 children: vec![Node {
1672 kind: NodeKind::Table {
1673 alignments: vec![TableAlignment::Left, TableAlignment::Center],
1674 },
1675 span: None,
1676 children: vec![
1677 Node {
1678 kind: NodeKind::TableRow { header: true },
1679 span: None,
1680 children: vec![
1681 Node {
1682 kind: NodeKind::TableCell {
1683 header: true,
1684 alignment: TableAlignment::Left,
1685 },
1686 span: None,
1687 children: vec![Node {
1688 kind: NodeKind::Text("h1".to_string()),
1689 span: None,
1690 children: vec![],
1691 }],
1692 },
1693 Node {
1694 kind: NodeKind::TableCell {
1695 header: true,
1696 alignment: TableAlignment::Center,
1697 },
1698 span: None,
1699 children: vec![Node {
1700 kind: NodeKind::Text("h2".to_string()),
1701 span: None,
1702 children: vec![],
1703 }],
1704 },
1705 ],
1706 },
1707 Node {
1708 kind: NodeKind::TableRow { header: false },
1709 span: None,
1710 children: vec![
1711 Node {
1712 kind: NodeKind::TableCell {
1713 header: false,
1714 alignment: TableAlignment::Left,
1715 },
1716 span: None,
1717 children: vec![Node {
1718 kind: NodeKind::Text("c1".to_string()),
1719 span: None,
1720 children: vec![],
1721 }],
1722 },
1723 Node {
1724 kind: NodeKind::TableCell {
1725 header: false,
1726 alignment: TableAlignment::Center,
1727 },
1728 span: None,
1729 children: vec![Node {
1730 kind: NodeKind::Text("c2".to_string()),
1731 span: None,
1732 children: vec![],
1733 }],
1734 },
1735 ],
1736 },
1737 ],
1738 }],
1739 ..Default::default()
1740 };
1741
1742 let options = RenderOptions::default();
1743 let result = render_html(&doc, &options).expect("render failed");
1744
1745 assert!(result.contains("<table>"));
1746 assert!(result.contains("<thead>"));
1747 assert!(result.contains("<tbody>"));
1748 assert!(result.contains("<th style=\"text-align: left;\">h1</th>"));
1749 assert!(result.contains("<th style=\"text-align: center;\">h2</th>"));
1750 assert!(result.contains("<td style=\"text-align: left;\">c1</td>"));
1751 assert!(result.contains("<td style=\"text-align: center;\">c2</td>"));
1752 }
1753
1754 #[test]
1755 fn smoke_image_as_link() {
1756 let input =
1758 "[](https://github.com/Ranrar/Marco)\n";
1759 let doc = crate::parser::parse(input).expect("parse failed");
1760 let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1761 .expect("render failed");
1762 assert!(
1763 html.contains("<a href=\"https://github.com/Ranrar/Marco\"><img"),
1764 "image-as-link must render as <a><img/></a>, got: {}",
1765 html
1766 );
1767 }
1768
1769 #[test]
1770 fn smoke_hard_break_backslash() {
1771 let input = "Hello\\\nworld\n";
1773 let doc = crate::parser::parse(input).expect("parse failed");
1774 let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1775 .expect("render failed");
1776 assert!(
1777 html.contains("<br"),
1778 "backslash hard break should render <br />, got: {}",
1779 html
1780 );
1781 }
1782
1783 #[test]
1784 fn smoke_hard_break_two_spaces() {
1785 let input = "Hello \nworld\n";
1787 let doc = crate::parser::parse(input).expect("parse failed");
1788 let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1789 .expect("render failed");
1790 assert!(
1791 html.contains("<br"),
1792 "two-space hard break should render <br />, got: {}",
1793 html
1794 );
1795 }
1796
1797 #[test]
1798 fn smoke_hard_break_three_spaces() {
1799 let input = "Hello \nworld\n";
1801 let doc = crate::parser::parse(input).expect("parse failed");
1802 let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1803 .expect("render failed");
1804 assert!(
1805 html.contains("<br"),
1806 "three-space hard break should render <br />, got: {}",
1807 html
1808 );
1809 assert!(
1811 !html.contains("Hello <br"),
1812 "three-space hard break should not leave a stray space before <br />, got: {}",
1813 html
1814 );
1815 }
1816
1817 #[test]
1818 fn smoke_nbsp_spacer_paragraph() {
1819 let input = "before\n\n\u{00A0}\n\nafter\n";
1824 let doc = crate::parser::parse(input).expect("parse failed");
1825 let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1826 .expect("render failed");
1827 let has_nbsp_para =
1829 html.contains("\u{00A0}") || html.contains(" ") || html.contains(" ");
1830 assert!(
1831 has_nbsp_para,
1832 "nbsp spacer paragraph must appear in HTML output, got: {}",
1833 html
1834 );
1835 assert!(
1837 html.contains(">before<"),
1838 "before paragraph must be present, got: {}",
1839 html
1840 );
1841 assert!(
1842 html.contains(">after<"),
1843 "after paragraph must be present, got: {}",
1844 html
1845 );
1846 }
1847
1848 #[cfg(all(feature = "render-syntax-highlighting", feature = "parallel-render"))]
1858 #[test]
1859 fn parallel_render_matches_each_code_block_to_its_own_node_across_footnote_reorder() {
1860 let top_code = "fn top() -> i32 { 1 }";
1861 let a_code = "def a():\n return 1\n";
1862 let b_code = "function b() { return 2; }";
1863
1864 let doc = Document {
1872 children: vec![
1873 Node {
1874 kind: NodeKind::Paragraph,
1875 span: None,
1876 children: vec![
1877 Node {
1878 kind: NodeKind::FootnoteReference {
1879 label: "b".to_string(),
1880 },
1881 span: None,
1882 children: vec![],
1883 },
1884 Node {
1885 kind: NodeKind::FootnoteReference {
1886 label: "a".to_string(),
1887 },
1888 span: None,
1889 children: vec![],
1890 },
1891 ],
1892 },
1893 Node {
1894 kind: NodeKind::CodeBlock {
1895 language: Some("rust".to_string()),
1896 code: top_code.to_string(),
1897 },
1898 span: None,
1899 children: vec![],
1900 },
1901 Node {
1902 kind: NodeKind::FootnoteDefinition {
1903 label: "a".to_string(),
1904 },
1905 span: None,
1906 children: vec![Node {
1907 kind: NodeKind::CodeBlock {
1908 language: Some("python".to_string()),
1909 code: a_code.to_string(),
1910 },
1911 span: None,
1912 children: vec![],
1913 }],
1914 },
1915 Node {
1916 kind: NodeKind::FootnoteDefinition {
1917 label: "b".to_string(),
1918 },
1919 span: None,
1920 children: vec![Node {
1921 kind: NodeKind::CodeBlock {
1922 language: Some("javascript".to_string()),
1923 code: b_code.to_string(),
1924 },
1925 span: None,
1926 children: vec![],
1927 }],
1928 },
1929 ],
1930 ..Default::default()
1931 };
1932
1933 let options = RenderOptions::default();
1934 let result = render_html(&doc, &options).unwrap();
1935
1936 let top_html = highlight_code_to_classed_html(top_code, "rust")
1940 .expect("rust snippet should highlight");
1941 let a_html = highlight_code_to_classed_html(a_code, "python")
1942 .expect("python snippet should highlight");
1943 let b_html = highlight_code_to_classed_html(b_code, "javascript")
1944 .expect("javascript snippet should highlight");
1945
1946 assert!(
1947 result.contains(&top_html),
1948 "top-level rust code block should carry its own highlighted HTML"
1949 );
1950 assert!(
1951 result.contains(&a_html),
1952 "footnote \"a\"'s python code block should carry its own \
1953 highlighted HTML, not another block's"
1954 );
1955 assert!(
1956 result.contains(&b_html),
1957 "footnote \"b\"'s javascript code block should carry its own \
1958 highlighted HTML, not another block's"
1959 );
1960
1961 let b_pos = result
1965 .find(&b_html)
1966 .expect("b highlighted fragment present");
1967 let a_pos = result
1968 .find(&a_html)
1969 .expect("a highlighted fragment present");
1970 assert!(
1971 b_pos < a_pos,
1972 "footnote \"b\" should render before footnote \"a\" \
1973 (first-reference order) while each keeps its own code block's \
1974 highlighting"
1975 );
1976 }
1977}