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}
44
45pub fn render_html(
47 document: &Document,
48 options: &RenderOptions,
49) -> Result<String, Box<dyn std::error::Error>> {
50 log::debug!("Rendering {} nodes to HTML", document.len());
51
52 let estimated = document.children.len() * 64;
55 let mut html = String::with_capacity(estimated.max(256));
56
57 let mut ctx = RenderContext::default();
58 for node in &document.children {
59 collect_footnote_definitions(node, &mut ctx.footnote_defs);
60 }
61
62 for node in &document.children {
63 render_node(node, &mut html, options, &mut ctx)?;
64 }
65
66 if !ctx.footnote_order.is_empty() {
67 html.push_str("<section class=\"footnotes\">\n");
68 html.push_str("<ol>\n");
69
70 let mut i = 0usize;
71 while i < ctx.footnote_order.len() {
72 let label = ctx.footnote_order[i].clone();
73 let Some(n) = ctx.footnote_numbers.get(&label).copied() else {
74 i += 1;
75 continue;
76 };
77
78 let Some(def_node) = ctx.footnote_defs.get(&label).copied() else {
79 i += 1;
80 continue;
81 };
82
83 html.push_str("<li id=\"fn");
84 html.push_str(&n.to_string());
85 html.push_str("\">");
86 for child in &def_node.children {
87 render_node(child, &mut html, options, &mut ctx)?;
88 }
89 html.push_str("</li>\n");
90
91 i += 1;
92 }
93
94 html.push_str("</ol>\n");
95 html.push_str("</section>\n");
96 }
97
98 Ok(html)
99}
100
101fn collect_footnote_definitions<'a>(node: &'a Node, defs: &mut HashMap<String, &'a Node>) {
102 if let NodeKind::FootnoteDefinition { label } = &node.kind {
103 defs.entry(label.clone()).or_insert(node);
104 }
105
106 for child in &node.children {
107 collect_footnote_definitions(child, defs);
108 }
109}
110
111fn render_node(
113 node: &Node,
114 output: &mut String,
115 options: &RenderOptions,
116 ctx: &mut RenderContext<'_>,
117) -> Result<(), Box<dyn std::error::Error>> {
118 match &node.kind {
119 NodeKind::Heading { level, text, id } => {
120 log::trace!("Rendering heading level {}", level);
121 let escaped_text = escape_html(text);
122
123 let effective_id = if let Some(explicit_id) = id {
126 explicit_id.clone()
127 } else {
128 let base = crate::intelligence::toc::heading_slug(text);
129 let count = ctx.heading_slug_counts.entry(base.clone()).or_insert(0);
130 let slug = if *count == 0 {
131 base.clone()
132 } else {
133 format!("{}-{}", base, count)
134 };
135 *count += 1;
136 slug
137 };
138
139 let level_char = char::from_digit(*level as u32, 10).unwrap_or('1');
141 let escaped_id = escape_html(&effective_id);
142
143 output.push_str("<h");
144 output.push(level_char);
145 output.push_str(" id=\"");
146 output.push_str(&escaped_id);
147 output.push_str("\">");
148
149 output.push_str("<a class=\"marco-heading-anchor\" href=\"#");
151 output.push_str(&escaped_id);
152 output.push_str("\" aria-label=\"Link to this heading\">");
153 output.push_str(&escaped_text);
154 output.push_str("</a>");
155
156 output.push_str("</h");
157 output.push(level_char);
158 output.push_str(">\n");
159 }
160 NodeKind::Paragraph => {
161 output.push_str("<p>");
162 for child in &node.children {
163 render_node(child, output, options, ctx)?;
164 }
165 output.push_str("</p>\n");
166 }
167 NodeKind::CodeBlock { language, code } => {
168 log::trace!("Rendering code block: {:?}", language);
169 let language_raw = language.as_deref().map(str::trim).filter(|s| !s.is_empty());
170
171 output.push_str("<div class=\"marco-code-block\">");
173
174 output.push_str("<button class=\"marco-copy-btn\" data-action=\"copy\" aria-label=\"Copy code\" title=\"Copy code\">");
176 output.push_str(CODE_BLOCK_COPY_SVG);
177 output.push_str("</button>");
178
179 output.push_str("<pre");
180 if let Some(raw) = language_raw {
181 if let Some(label) = language_display_label(raw) {
182 output.push_str(" data-language=\"");
183 output.push_str(&escape_html(label.as_ref()));
184 output.push('"');
185 }
186 }
187 output.push_str("><code");
188
189 if let Some(lang) = language_raw {
191 output.push_str(" class=\"language-");
192 output.push_str(&escape_html(lang));
193 output.push('"');
194 }
195
196 output.push('>');
197
198 #[cfg(feature = "render-syntax-highlighting")]
201 if options.syntax_highlighting {
202 if let Some(lang) = language_raw {
203 if let Some(highlighted) = highlight_code_to_classed_html(code, lang) {
204 output.push_str(&highlighted);
205 output.push_str("</code></pre>");
206 output.push_str("</div>\n");
207 return Ok(());
208 }
209 }
210 }
211
212 output.push_str(&escape_html(code));
213 output.push_str("</code></pre>");
214 output.push_str("</div>\n");
215 }
216 NodeKind::ThematicBreak => {
217 output.push_str("<hr />\n");
218 }
219 NodeKind::HtmlBlock { html } => {
220 output.push_str(html);
223 if !html.ends_with('\n') {
224 output.push('\n');
225 }
226 }
227 NodeKind::Blockquote => {
228 output.push_str("<blockquote>\n");
229 for child in &node.children {
230 render_node(child, output, options, ctx)?;
231 }
232 output.push_str("</blockquote>\n");
233 }
234 NodeKind::Admonition {
235 kind,
236 title,
237 icon,
238 style,
239 } => {
240 let (slug, default_title, icon_svg) = admonition_presentation(kind);
241
242 let title_text = title.as_deref().unwrap_or(default_title);
243
244 output.push_str("<div class=\"");
250 output.push_str("markdown-alert");
251
252 if *style == AdmonitionStyle::Alert {
253 output.push_str(" markdown-alert-");
254 output.push_str(slug);
255 }
256
257 output.push_str(" admonition");
258
259 if *style == AdmonitionStyle::Alert {
260 output.push_str(" admonition-");
261 output.push_str(slug);
262 } else {
263 output.push_str(" admonition-quote");
264 }
265
266 output.push_str("\">\n");
267
268 output.push_str("<p class=\"markdown-alert-title\">");
269 output.push_str("<span class=\"markdown-alert-icon\" aria-hidden=\"true\">");
270 if let Some(icon_text) = icon {
271 output.push_str("<span class=\"markdown-alert-emoji\">");
274 output.push_str(&escape_html(icon_text));
275 output.push_str("</span>");
276 } else {
277 output.push_str(icon_svg);
278 }
279 output.push_str("</span>");
280 output.push_str(&escape_html(title_text));
281 output.push_str("</p>\n");
282
283 for child in &node.children {
284 render_node(child, output, options, ctx)?;
285 }
286
287 output.push_str("</div>\n");
288 }
289 NodeKind::TabGroup => {
290 render_tab_group(node, output, options, ctx)?;
291 }
292 NodeKind::TabItem { .. } => {
293 log::warn!("TabItem rendered outside of TabGroup context");
296 for child in &node.children {
297 render_node(child, output, options, ctx)?;
298 }
299 }
300 NodeKind::SliderDeck { .. } => {
301 render_slider_deck(node, output, options, ctx)?;
302 }
303 NodeKind::Slide { .. } => {
304 log::warn!("Slide rendered outside of SliderDeck context");
307 for child in &node.children {
308 render_node(child, output, options, ctx)?;
309 }
310 }
311 NodeKind::Table { .. } => {
312 render_table(node, output, options, ctx)?;
313 }
314 NodeKind::TableRow { .. } => {
315 log::warn!("TableRow rendered outside of Table context");
318 render_table_row(node, output, options, ctx)?;
319 output.push('\n');
320 }
321 NodeKind::TableCell { .. } => {
322 log::warn!("TableCell rendered outside of TableRow context");
324 render_table_cell(node, output, options, ctx)?;
325 }
326 NodeKind::FootnoteDefinition { .. } => {
327 }
330 NodeKind::Text(text) => {
331 output.push_str(&escape_html(text));
332 }
333 NodeKind::CodeSpan(code) => {
334 output.push_str("<code>");
335 output.push_str(&escape_html(code));
336 output.push_str("</code>");
337 }
338 NodeKind::Emphasis => {
339 output.push_str("<em>");
340 for child in &node.children {
341 render_node(child, output, options, ctx)?;
342 }
343 output.push_str("</em>");
344 }
345 NodeKind::Strong => {
346 output.push_str("<strong>");
347 for child in &node.children {
348 render_node(child, output, options, ctx)?;
349 }
350 output.push_str("</strong>");
351 }
352 NodeKind::StrongEmphasis => {
353 output.push_str("<strong><em>");
355 for child in &node.children {
356 render_node(child, output, options, ctx)?;
357 }
358 output.push_str("</em></strong>");
359 }
360 NodeKind::Strikethrough => {
361 output.push_str("<del>");
362 for child in &node.children {
363 render_node(child, output, options, ctx)?;
364 }
365 output.push_str("</del>");
366 }
367 NodeKind::Mark => {
368 output.push_str("<mark>");
369 for child in &node.children {
370 render_node(child, output, options, ctx)?;
371 }
372 output.push_str("</mark>");
373 }
374 NodeKind::Superscript => {
375 output.push_str("<sup>");
376 for child in &node.children {
377 render_node(child, output, options, ctx)?;
378 }
379 output.push_str("</sup>");
380 }
381 NodeKind::Subscript => {
382 output.push_str("<sub>");
383 for child in &node.children {
384 render_node(child, output, options, ctx)?;
385 }
386 output.push_str("</sub>");
387 }
388 NodeKind::Link { url, title } => {
389 output.push_str("<a href=\"");
390 output.push_str(&escape_html(url));
391 output.push('"');
392 if let Some(t) = title {
393 output.push_str(" title=\"");
394 output.push_str(&escape_html(t));
395 output.push('"');
396 }
397 output.push('>');
398 for child in &node.children {
399 render_node(child, output, options, ctx)?;
400 }
401 output.push_str("</a>");
402 }
403 NodeKind::PlatformMention {
404 username,
405 platform,
406 display,
407 } => {
408 let label = display.as_deref().unwrap_or(username);
409 let platform_key = platform.trim().to_ascii_lowercase();
410
411 if let Some(url) = plarform_mentions::profile_url(&platform_key, username) {
412 output.push_str("<a class=\"marco-mention marco-mention-");
413 output.push_str(&escape_html(&platform_key));
414 output.push_str("\" href=\"");
415 output.push_str(&escape_html(&url));
416 output.push_str("\">");
417 output.push_str(&escape_html(label));
418 output.push_str("</a>");
419 } else {
420 output.push_str("<span class=\"marco-mention marco-mention-unknown\">");
421 output.push_str(&escape_html(label));
422 output.push_str("</span>");
423 }
424 }
425 NodeKind::LinkReference { suffix, .. } => {
426 output.push('[');
430 for child in &node.children {
431 render_node(child, output, options, ctx)?;
432 }
433 output.push(']');
434 output.push_str(&escape_html(suffix));
435 }
436 NodeKind::FootnoteReference { label } => {
437 if !ctx.footnote_defs.contains_key(label) {
438 output.push_str("[^");
439 output.push_str(&escape_html(label));
440 output.push(']');
441 return Ok(());
443 }
444
445 let n = match ctx.footnote_numbers.get(label) {
446 Some(n) => *n,
447 None => {
448 let next = ctx.footnote_order.len() + 1;
449 ctx.footnote_order.push(label.clone());
450 ctx.footnote_numbers.insert(label.clone(), next);
451 next
452 }
453 };
454
455 let count = ctx.footnote_ref_counts.entry(label.clone()).or_insert(0);
456 *count += 1;
457 let ref_id = if *count == 1 {
458 format!("fnref{}", n)
459 } else {
460 format!("fnref{}-{}", n, *count)
461 };
462
463 output.push_str("<sup class=\"footnote-ref\"><a href=\"#fn");
464 output.push_str(&n.to_string());
465 output.push_str("\" id=\"");
466 output.push_str(&escape_html(&ref_id));
467 output.push_str("\">");
468 output.push_str(&n.to_string());
469 output.push_str("</a></sup>");
470 }
471 NodeKind::Image { url, alt } => {
472 output.push_str("<img src=\"");
473 output.push_str(&escape_html(url));
474 output.push_str("\" alt=\"");
475 output.push_str(&escape_html(alt));
476 output.push_str("\" />");
477 }
478 NodeKind::InlineHtml(html) => {
479 output.push_str(html);
481 }
482 NodeKind::HardBreak => {
483 output.push_str("<br />\n");
485 }
486 NodeKind::SoftBreak => {
487 output.push('\n');
489 }
490 NodeKind::List {
491 ordered,
492 start,
493 tight,
494 } => {
495 if *ordered {
497 output.push_str("<ol");
498 if let Some(num) = start {
499 if *num != 1 {
500 output.push_str(&format!(" start=\"{}\"", num));
501 }
502 }
503 output.push_str(">\n");
504 } else {
505 output.push_str("<ul>\n");
506 }
507
508 for child in &node.children {
510 render_list_item(child, output, *tight, options, ctx)?;
511 }
512
513 if *ordered {
515 output.push_str("</ol>\n");
516 } else {
517 output.push_str("</ul>\n");
518 }
519 }
520 NodeKind::DefinitionList => {
521 output.push_str("<dl>\n");
522 for child in &node.children {
523 render_node(child, output, options, ctx)?;
524 }
525 output.push_str("</dl>\n");
526 }
527 NodeKind::DefinitionTerm => {
528 output.push_str("<dt>");
529 for child in &node.children {
530 render_node(child, output, options, ctx)?;
531 }
532 output.push_str("</dt>\n");
533 }
534 NodeKind::DefinitionDescription => {
535 output.push_str("<dd>\n");
536 for child in &node.children {
537 render_node(child, output, options, ctx)?;
538 }
539 output.push_str("</dd>\n");
540 }
541 NodeKind::ListItem => {
542 log::warn!("ListItem rendered outside of List context");
544 output.push_str("<li>");
545 for child in &node.children {
546 render_node(child, output, options, ctx)?;
547 }
548 output.push_str("</li>\n");
549 }
550 NodeKind::TaskCheckbox { .. } => {
551 log::warn!("TaskCheckbox rendered outside of ListItem context");
553 }
554 NodeKind::TaskCheckboxInline { checked } => {
555 render_task_checkbox_icon(output, *checked);
557 }
558 NodeKind::InlineMath { content } => {
559 #[cfg(feature = "render-math")]
561 match render_inline_math(content) {
562 Ok(html) => output.push_str(&html),
563 Err(e) => {
564 log::warn!("Math render error (inline): {}", e);
565 output.push_str("<code class=\"math-error\" title=\"Failed to render math\">");
567 output.push_str(&escape_html(content));
568 output.push_str("</code>");
569 }
570 }
571 #[cfg(not(feature = "render-math"))]
572 {
573 output.push_str("<code class=\"math\">");
574 output.push_str(&escape_html(content));
575 output.push_str("</code>");
576 }
577 }
578 NodeKind::DisplayMath { content } => {
579 #[cfg(feature = "render-math")]
581 match render_display_math(content) {
582 Ok(html) => output.push_str(&html),
583 Err(e) => {
584 log::warn!("Math render error (display): {}", e);
585 output.push_str("<pre class=\"math-error\" title=\"Failed to render math\">");
587 output.push_str(&escape_html(content));
588 output.push_str("</pre>");
589 }
590 }
591 #[cfg(not(feature = "render-math"))]
592 {
593 output.push_str("<pre class=\"math\"><code>");
594 output.push_str(&escape_html(content));
595 output.push_str("</code></pre>\n");
596 }
597 }
598 NodeKind::MermaidDiagram { content } => {
599 #[cfg(feature = "render-diagrams")]
601 {
602 let cache_key = (options.theme.clone(), content.clone());
603 let rendered = if let Some(cached) = ctx.mermaid_result_cache.get(&cache_key) {
604 cached.clone()
605 } else {
606 let fresh = match render_mermaid_diagram(content, &options.theme) {
607 Ok(svg) => Ok(svg),
608 Err(e) => Err(e.to_string()),
609 };
610 ctx.mermaid_result_cache.insert(cache_key, fresh.clone());
611 fresh
612 };
613
614 match rendered {
615 Ok(svg) => {
616 output.push_str("<div class=\"marco-diagram\">");
617 output.push_str(&svg);
618 output.push_str("</div>\n");
619 }
620 Err(e) => {
621 log::warn!("Mermaid render error: {}", e);
622 let mut title = String::from("Failed to render diagram: ");
624 let max_len = 160usize;
625 if e.chars().count() > max_len {
626 title.push_str(&e.chars().take(max_len).collect::<String>());
627 title.push('…');
628 } else {
629 title.push_str(&e);
630 }
631 output.push_str("<pre class=\"mermaid-error\" title=\"");
632 output.push_str(&escape_html(&title));
633 output.push_str("\"><code>");
634 output.push_str(&escape_html(content));
635 output.push_str("</code></pre>\n");
636 }
637 }
638 }
639 #[cfg(not(feature = "render-diagrams"))]
640 {
641 output.push_str("<pre class=\"mermaid\"><code>");
642 output.push_str(&escape_html(content));
643 output.push_str("</code></pre>\n");
644 }
645 }
646 }
647
648 Ok(())
649}
650
651fn admonition_presentation(kind: &AdmonitionKind) -> (&'static str, &'static str, &'static str) {
652 match kind {
655 AdmonitionKind::Note => (
656 "note",
657 "Note",
658 concat!(
659 r#"<svg xmlns=""#,
660 "http",
661 r#"://www.w3.org/2000/svg"#,
662 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>"#,
663 ),
664 ),
665 AdmonitionKind::Tip => (
666 "tip",
667 "Tip",
668 concat!(
669 r#"<svg xmlns=""#,
670 "http",
671 r#"://www.w3.org/2000/svg"#,
672 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>"#,
673 ),
674 ),
675 AdmonitionKind::Important => (
676 "important",
677 "Important",
678 concat!(
679 r#"<svg xmlns=""#,
680 "http",
681 r#"://www.w3.org/2000/svg"#,
682 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>"#,
683 ),
684 ),
685 AdmonitionKind::Warning => (
686 "warning",
687 "Warning",
688 concat!(
689 r#"<svg xmlns=""#,
690 "http",
691 r#"://www.w3.org/2000/svg"#,
692 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>"#,
693 ),
694 ),
695 AdmonitionKind::Caution => (
696 "caution",
697 "Caution",
698 concat!(
699 r#"<svg xmlns=""#,
700 "http",
701 r#"://www.w3.org/2000/svg"#,
702 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>"#,
703 ),
704 ),
705 }
706}
707
708fn render_tab_group(
709 node: &Node,
710 output: &mut String,
711 options: &RenderOptions,
712 ctx: &mut RenderContext<'_>,
713) -> Result<(), Box<dyn std::error::Error>> {
714 let group_id = ctx.tab_group_counter;
716 ctx.tab_group_counter = ctx.tab_group_counter.saturating_add(1);
717
718 let mut items: Vec<(&str, &Node)> = Vec::new();
720 for child in &node.children {
721 if let NodeKind::TabItem { title } = &child.kind {
722 items.push((title.as_str(), child));
723 } else {
724 log::warn!("Unexpected child inside TabGroup: {:?}", child.kind);
725 }
726 }
727
728 if items.is_empty() {
729 return Ok(());
730 }
731
732 output.push_str("<div class=\"marco-tabs\">\n");
733
734 for (i, (title, _item_node)) in items.iter().enumerate() {
736 output.push_str("<input class=\"marco-tabs__radio\" type=\"radio\" name=\"marco-tabs-");
737 output.push_str(&group_id.to_string());
738 output.push_str("\" id=\"marco-tabs-");
739 output.push_str(&group_id.to_string());
740 output.push('-');
741 output.push_str(&i.to_string());
742 output.push_str("\" aria-label=\"");
743 output.push_str(&escape_html(title));
744 output.push('"');
745 if i == 0 {
746 output.push_str(" checked");
747 }
748 output.push_str(" />\n");
749 }
750
751 output.push_str("<div class=\"marco-tabs__tablist\">\n");
752 for (i, (title, _item_node)) in items.iter().enumerate() {
753 output.push_str("<label class=\"marco-tabs__tab\" for=\"marco-tabs-");
754 output.push_str(&group_id.to_string());
755 output.push('-');
756 output.push_str(&i.to_string());
757 output.push_str("\">");
758 output.push_str(&escape_html(title));
759 output.push_str("</label>\n");
760 }
761 output.push_str("</div>\n");
762
763 output.push_str("<div class=\"marco-tabs__panels\">\n");
764 for &(_title, item_node) in items.iter() {
765 output.push_str("<div class=\"marco-tabs__panel\">\n");
766 for panel_child in &item_node.children {
767 render_node(panel_child, output, options, ctx)?;
768 }
769 output.push_str("</div>\n");
770 }
771 output.push_str("</div>\n");
772
773 output.push_str("</div>\n");
774 Ok(())
775}
776
777fn render_slider_deck(
778 node: &Node,
779 output: &mut String,
780 options: &RenderOptions,
781 ctx: &mut RenderContext<'_>,
782) -> Result<(), Box<dyn std::error::Error>> {
783 let timer_seconds = match &node.kind {
784 NodeKind::SliderDeck { timer_seconds } => *timer_seconds,
785 other => {
786 log::warn!(
787 "render_slider_deck called with non SliderDeck node: {:?}",
788 other
789 );
790 return Ok(());
791 }
792 };
793
794 let deck_id = ctx.slider_deck_counter;
796 ctx.slider_deck_counter = ctx.slider_deck_counter.saturating_add(1);
797
798 let mut slides: Vec<(bool, &Node)> = Vec::new();
800 for child in &node.children {
801 if let NodeKind::Slide { vertical } = &child.kind {
802 slides.push((*vertical, child));
803 } else {
804 log::warn!("Unexpected child inside SliderDeck: {:?}", child.kind);
805 }
806 }
807
808 if slides.is_empty() {
809 return Ok(());
810 }
811
812 output.push_str("<div class=\"marco-sliders\" id=\"marco-sliders-");
813 output.push_str(&deck_id.to_string());
814 output.push('"');
815 if let Some(seconds) = timer_seconds {
816 output.push_str(" data-timer-seconds=\"");
817 output.push_str(&seconds.to_string());
818 output.push('"');
819 }
820 output.push('>');
821
822 output.push_str("<div class=\"marco-sliders__viewport\">");
823 for (i, (vertical, slide_node)) in slides.iter().enumerate() {
824 output.push_str("<section class=\"marco-sliders__slide");
825 if i == 0 {
826 output.push_str(" is-active");
827 }
828 output.push_str("\" data-slide-index=\"");
829 output.push_str(&i.to_string());
830 output.push('"');
831 if *vertical {
837 output.push_str(" data-vertical=\"true\"");
838 }
839 output.push_str(">\n");
840 for child in &slide_node.children {
841 render_node(child, output, options, ctx)?;
842 }
843 output.push_str("</section>\n");
844 }
845 output.push_str("</div>");
846
847 output.push_str("<div class=\"marco-sliders__controls\" aria-label=\"Slideshow controls\">");
849
850 output.push_str(
851 "<button class=\"marco-sliders__btn marco-sliders__btn--prev\" type=\"button\" data-action=\"prev\" aria-label=\"Previous slide\">",
852 );
853 output.push_str(SLIDER_ARROW_LEFT_SVG);
854 output.push_str("</button>");
855
856 output.push_str(
857 "<button class=\"marco-sliders__btn marco-sliders__btn--toggle\" type=\"button\" data-action=\"toggle\" aria-label=\"Toggle autoplay\">",
858 );
859 output.push_str("<span class=\"marco-sliders__icon marco-sliders__icon--play\">");
860 output.push_str(SLIDER_PLAY_SVG);
861 output.push_str("</span>");
862 output.push_str("<span class=\"marco-sliders__icon marco-sliders__icon--pause\">");
863 output.push_str(SLIDER_PAUSE_SVG);
864 output.push_str("</span>");
865 output.push_str("</button>");
866
867 output.push_str(
868 "<button class=\"marco-sliders__btn marco-sliders__btn--next\" type=\"button\" data-action=\"next\" aria-label=\"Next slide\">",
869 );
870 output.push_str(SLIDER_ARROW_RIGHT_SVG);
871 output.push_str("</button>");
872
873 output.push_str("</div>");
874
875 output.push_str(
877 "<div class=\"marco-sliders__dots\" role=\"tablist\" aria-label=\"Slideshow navigation\">",
878 );
879 for i in 0..slides.len() {
880 output.push_str("<button class=\"marco-sliders__dot");
881 if i == 0 {
882 output.push_str(" is-active");
883 }
884 output.push_str("\" type=\"button\" data-action=\"goto\" data-index=\"");
885 output.push_str(&i.to_string());
886 output.push_str("\" aria-label=\"Go to slide ");
887 output.push_str(&(i + 1).to_string());
888 output.push('"');
889 if i == 0 {
890 output.push_str(" aria-selected=\"true\"");
891 }
892 output.push_str(">\n");
893 output
894 .push_str("<span class=\"marco-sliders__dot-icon marco-sliders__dot-icon--inactive\">");
895 output.push_str(SLIDER_DOT_INACTIVE_SVG);
896 output.push_str("</span>");
897 output.push_str("<span class=\"marco-sliders__dot-icon marco-sliders__dot-icon--active\">");
898 output.push_str(SLIDER_DOT_ACTIVE_SVG);
899 output.push_str("</span>");
900 output.push_str("</button>");
901 }
902 output.push_str("</div>");
903
904 output.push_str("</div>\n");
905 Ok(())
906}
907
908fn render_task_checkbox_icon(output: &mut String, checked: bool) {
909 if checked {
914 output.push_str(
915 r#"<span class="marco-marco-marco-task-checkbox checked" aria-hidden="true">"#,
916 );
917 output.push_str(
918 concat!(
919 r#"<svg xmlns=""#,
920 "http",
921 r#"://www.w3.org/2000/svg"#,
922 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>"#,
923 ),
924 );
925 output.push_str("</span>");
926 } else {
927 output.push_str(
928 r#"<span class="marco-marco-marco-task-checkbox unchecked" aria-hidden="true">"#,
929 );
930 output.push_str(
931 concat!(
932 r#"<svg xmlns=""#,
933 "http",
934 r#"://www.w3.org/2000/svg"#,
935 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>"#,
936 ),
937 );
938 output.push_str("</span>");
939 }
940}
941
942fn render_table(
943 node: &Node,
944 output: &mut String,
945 options: &RenderOptions,
946 ctx: &mut RenderContext<'_>,
947) -> Result<(), Box<dyn std::error::Error>> {
948 output.push_str("<table>\n");
949
950 let mut header_rows: Vec<&Node> = Vec::new();
951 let mut body_rows: Vec<&Node> = Vec::new();
952
953 for row in &node.children {
954 match row.kind {
955 NodeKind::TableRow { header: true } => header_rows.push(row),
956 NodeKind::TableRow { header: false } => body_rows.push(row),
957 _ => {
958 log::warn!("Unexpected child inside Table: {:?}", row.kind);
959 }
960 }
961 }
962
963 if !header_rows.is_empty() {
964 output.push_str("<thead>\n");
965 for row in header_rows {
966 render_table_row(row, output, options, ctx)?;
967 output.push('\n');
968 }
969 output.push_str("</thead>\n");
970 }
971
972 if !body_rows.is_empty() {
973 output.push_str("<tbody>\n");
974 for row in body_rows {
975 render_table_row(row, output, options, ctx)?;
976 output.push('\n');
977 }
978 output.push_str("</tbody>\n");
979 }
980
981 output.push_str("</table>\n");
982 Ok(())
983}
984
985fn render_table_row(
986 node: &Node,
987 output: &mut String,
988 options: &RenderOptions,
989 ctx: &mut RenderContext<'_>,
990) -> Result<(), Box<dyn std::error::Error>> {
991 output.push_str("<tr>");
992 for cell in &node.children {
993 render_table_cell(cell, output, options, ctx)?;
994 }
995 output.push_str("</tr>");
996 Ok(())
997}
998
999fn render_table_cell(
1000 node: &Node,
1001 output: &mut String,
1002 options: &RenderOptions,
1003 ctx: &mut RenderContext<'_>,
1004) -> Result<(), Box<dyn std::error::Error>> {
1005 let (is_header, alignment) = match &node.kind {
1006 NodeKind::TableCell { header, alignment } => (*header, *alignment),
1007 _ => {
1008 log::warn!("Unexpected child inside TableRow: {:?}", node.kind);
1009 (false, crate::parser::ast::TableAlignment::None)
1010 }
1011 };
1012
1013 let tag = if is_header { "th" } else { "td" };
1014 output.push('<');
1015 output.push_str(tag);
1016
1017 if let Some(style_value) = alignment_to_css(alignment) {
1018 output.push_str(" style=\"");
1019 output.push_str(style_value);
1020 output.push('"');
1021 }
1022
1023 output.push('>');
1024 for child in &node.children {
1025 render_node(child, output, options, ctx)?;
1026 }
1027 output.push_str("</");
1028 output.push_str(tag);
1029 output.push('>');
1030 Ok(())
1031}
1032
1033fn alignment_to_css(alignment: crate::parser::ast::TableAlignment) -> Option<&'static str> {
1034 match alignment {
1035 crate::parser::ast::TableAlignment::None => None,
1036 crate::parser::ast::TableAlignment::Left => Some("text-align: left;"),
1037 crate::parser::ast::TableAlignment::Center => Some("text-align: center;"),
1038 crate::parser::ast::TableAlignment::Right => Some("text-align: right;"),
1039 }
1040}
1041
1042fn render_list_item(
1044 node: &Node,
1045 output: &mut String,
1046 tight: bool,
1047 options: &RenderOptions,
1048 ctx: &mut RenderContext<'_>,
1049) -> Result<(), Box<dyn std::error::Error>> {
1050 let task_checked = match node.children.first().map(|n| &n.kind) {
1051 Some(NodeKind::TaskCheckbox { checked }) => Some(*checked),
1052 _ => None,
1053 };
1054
1055 if let Some(checked) = task_checked {
1056 if checked {
1057 output.push_str("<li class=\"marco-task-list-item marco-task-list-item--checked\">");
1058 } else {
1059 output.push_str("<li class=\"marco-task-list-item\">");
1060 }
1061 } else {
1062 output.push_str("<li>");
1063 }
1064
1065 if tight {
1066 if let Some(checked) = task_checked {
1069 render_task_checkbox_icon(output, checked);
1070 }
1071
1072 for child in &node.children {
1074 if matches!(child.kind, NodeKind::TaskCheckbox { .. }) {
1075 continue;
1076 }
1077 match &child.kind {
1078 NodeKind::Paragraph => {
1079 for grandchild in &child.children {
1081 render_node(grandchild, output, options, ctx)?;
1082 }
1083 }
1084 _ => {
1085 render_node(child, output, options, ctx)?;
1087 }
1088 }
1089 }
1090 } else {
1091 let mut checkbox_emitted = false;
1094
1095 for child in &node.children {
1096 if matches!(child.kind, NodeKind::TaskCheckbox { .. }) {
1097 continue;
1098 }
1099
1100 if let Some(checked) = task_checked {
1103 if !checkbox_emitted {
1104 match &child.kind {
1105 NodeKind::Paragraph => {
1106 output.push_str("<p>");
1107 render_task_checkbox_icon(output, checked);
1108 for grandchild in &child.children {
1109 render_node(grandchild, output, options, ctx)?;
1110 }
1111 output.push_str("</p>");
1112 checkbox_emitted = true;
1113 continue;
1114 }
1115 _ => {
1116 render_task_checkbox_icon(output, checked);
1117 checkbox_emitted = true;
1118 }
1120 }
1121 }
1122 }
1123
1124 render_node(child, output, options, ctx)?;
1125 }
1126 }
1127
1128 output.push_str("</li>\n");
1129 Ok(())
1130}
1131
1132fn escape_html(text: &str) -> String {
1134 text.chars()
1135 .map(|c| match c {
1136 '&' => "&".to_string(),
1137 '<' => "<".to_string(),
1138 '>' => ">".to_string(),
1139 '"' => """.to_string(),
1140 '\'' => "'".to_string(),
1141 _ => c.to_string(),
1142 })
1143 .collect()
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148 use super::*;
1149 use crate::parser::ast::TableAlignment;
1150 use crate::parser::{Document, Node, NodeKind};
1151
1152 #[test]
1153 fn smoke_test_escape_html_basic() {
1154 let input = "Hello <world> & \"friends\"";
1155 let expected = "Hello <world> & "friends"";
1156 assert_eq!(escape_html(input), expected);
1157 }
1158
1159 #[test]
1160 fn smoke_test_escape_html_script_tag() {
1161 let input = "<script>alert('XSS')</script>";
1162 let expected = "<script>alert('XSS')</script>";
1163 assert_eq!(escape_html(input), expected);
1164 }
1165
1166 #[test]
1167 fn smoke_test_render_heading_h1() {
1168 let doc = Document {
1169 children: vec![Node {
1170 kind: NodeKind::Heading {
1171 level: 1,
1172 text: "Hello World".to_string(),
1173 id: None,
1174 },
1175 span: None,
1176 children: vec![],
1177 }],
1178 ..Default::default()
1179 };
1180 let options = RenderOptions::default();
1181 let result = render_html(&doc, &options).unwrap();
1182 assert!(result.contains("<h1 id=\"hello-world\">"));
1184 assert!(result.contains("Hello World"));
1185 assert!(result.contains("class=\"marco-heading-anchor\""));
1186 assert!(result.contains("href=\"#hello-world\""));
1187 }
1188
1189 #[test]
1190 fn smoke_test_render_heading_with_html() {
1191 let doc = Document {
1192 children: vec![Node {
1193 kind: NodeKind::Heading {
1194 level: 2,
1195 text: "Code <example> & test".to_string(),
1196 id: None,
1197 },
1198 span: None,
1199 children: vec![],
1200 }],
1201 ..Default::default()
1202 };
1203 let options = RenderOptions::default();
1204 let result = render_html(&doc, &options).unwrap();
1205 assert!(result.contains("<h2 id=\"code-example-test\">"));
1207 assert!(result.contains("Code <example> & test"));
1208 }
1209
1210 #[test]
1211 fn smoke_test_render_paragraph_with_text() {
1212 let doc = Document {
1213 children: vec![Node {
1214 kind: NodeKind::Paragraph,
1215 span: None,
1216 children: vec![Node {
1217 kind: NodeKind::Text("This is a paragraph.".to_string()),
1218 span: None,
1219 children: vec![],
1220 }],
1221 }],
1222 ..Default::default()
1223 };
1224 let options = RenderOptions::default();
1225 let result = render_html(&doc, &options).unwrap();
1226 assert_eq!(result, "<p>This is a paragraph.</p>\n");
1227 }
1228
1229 #[test]
1230 fn smoke_test_render_code_block_without_language() {
1231 let doc = Document {
1232 children: vec![Node {
1233 kind: NodeKind::CodeBlock {
1234 language: None,
1235 code: "fn main() {\n println!(\"Hello\");\n}".to_string(),
1236 },
1237 span: None,
1238 children: vec![],
1239 }],
1240 ..Default::default()
1241 };
1242 let options = RenderOptions::default();
1243 let result = render_html(&doc, &options).unwrap();
1244 assert!(result.contains("<div class=\"marco-code-block\">"));
1246 assert!(result.contains("<button class=\"marco-copy-btn\""));
1247 assert!(result.contains("icon-tabler-copy"));
1248 assert!(result
1249 .contains("<pre><code>fn main() {\n println!("Hello");\n}</code></pre>"));
1250 assert!(result.contains("</div>\n"));
1251 }
1252
1253 #[test]
1254 fn smoke_test_render_code_block_with_language() {
1255 let doc = Document {
1256 children: vec![Node {
1257 kind: NodeKind::CodeBlock {
1258 language: Some("rust".to_string()),
1259 code: "let x = 42;".to_string(),
1260 },
1261 span: None,
1262 children: vec![],
1263 }],
1264 ..Default::default()
1265 };
1266 let options = RenderOptions {
1267 syntax_highlighting: false,
1268 ..RenderOptions::default()
1269 };
1270 let result = render_html(&doc, &options).unwrap();
1271 assert!(result.contains("<div class=\"marco-code-block\">"));
1273 assert!(result.contains("<button class=\"marco-copy-btn\""));
1274 assert!(result.contains(
1275 "<pre data-language=\"Rust\"><code class=\"language-rust\">let x = 42;</code></pre>"
1276 ));
1277 assert!(result.contains("</div>\n"));
1278 }
1279
1280 #[test]
1281 fn smoke_test_render_code_block_escapes_html() {
1282 let doc = Document {
1283 children: vec![Node {
1284 kind: NodeKind::CodeBlock {
1285 language: Some("html".to_string()),
1286 code: "<div>Test & verify</div>".to_string(),
1287 },
1288 span: None,
1289 children: vec![],
1290 }],
1291 ..Default::default()
1292 };
1293 let options = RenderOptions {
1294 syntax_highlighting: false,
1295 ..RenderOptions::default()
1296 };
1297 let result = render_html(&doc, &options).unwrap();
1298 assert!(result.contains("<div class=\"marco-code-block\">"));
1300 assert!(result.contains("<button class=\"marco-copy-btn\""));
1301 assert!(result.contains("<pre data-language=\"HTML\"><code class=\"language-html\"><div>Test & verify</div></code></pre>"));
1302 assert!(result.contains("</div>\n"));
1303 }
1304
1305 #[test]
1306 fn smoke_test_render_code_span() {
1307 let doc = Document {
1308 children: vec![Node {
1309 kind: NodeKind::Paragraph,
1310 span: None,
1311 children: vec![
1312 Node {
1313 kind: NodeKind::Text("Use ".to_string()),
1314 span: None,
1315 children: vec![],
1316 },
1317 Node {
1318 kind: NodeKind::CodeSpan("println!()".to_string()),
1319 span: None,
1320 children: vec![],
1321 },
1322 Node {
1323 kind: NodeKind::Text(" for output.".to_string()),
1324 span: None,
1325 children: vec![],
1326 },
1327 ],
1328 }],
1329 ..Default::default()
1330 };
1331 let options = RenderOptions::default();
1332 let result = render_html(&doc, &options).unwrap();
1333 assert_eq!(result, "<p>Use <code>println!()</code> for output.</p>\n");
1334 }
1335
1336 #[test]
1337 fn smoke_test_render_mixed_inlines() {
1338 let doc = Document {
1339 children: vec![
1340 Node {
1341 kind: NodeKind::Heading {
1342 level: 1,
1343 text: "Title".to_string(),
1344 id: None,
1345 },
1346 span: None,
1347 children: vec![],
1348 },
1349 Node {
1350 kind: NodeKind::Paragraph,
1351 span: None,
1352 children: vec![Node {
1353 kind: NodeKind::Text("Some text.".to_string()),
1354 span: None,
1355 children: vec![],
1356 }],
1357 },
1358 Node {
1359 kind: NodeKind::CodeBlock {
1360 language: Some("python".to_string()),
1361 code: "print('hello')".to_string(),
1362 },
1363 span: None,
1364 children: vec![],
1365 },
1366 ],
1367 ..Default::default()
1368 };
1369 let options = RenderOptions {
1370 syntax_highlighting: false,
1371 ..RenderOptions::default()
1372 };
1373 let result = render_html(&doc, &options).unwrap();
1374 assert!(result.contains("<h1 id=\"title\">"));
1376 assert!(result.contains("<p>Some text.</p>\n"));
1377 assert!(result.contains("<div class=\"marco-code-block\">"));
1378 assert!(result.contains("<button class=\"marco-copy-btn\""));
1379 assert!(result.contains("<pre data-language=\"Python\"><code class=\"language-python\">print('hello')</code></pre>"));
1380 assert!(result.contains("</div>\n"));
1381 }
1382
1383 #[test]
1384 fn smoke_test_render_strong_emphasis() {
1385 let doc = Document {
1386 children: vec![Node {
1387 kind: NodeKind::Paragraph,
1388 span: None,
1389 children: vec![Node {
1390 kind: NodeKind::StrongEmphasis,
1391 span: None,
1392 children: vec![Node {
1393 kind: NodeKind::Text("bold+italic".to_string()),
1394 span: None,
1395 children: vec![],
1396 }],
1397 }],
1398 }],
1399 ..Default::default()
1400 };
1401
1402 let options = RenderOptions::default();
1403 let result = render_html(&doc, &options).unwrap();
1404 assert_eq!(result, "<p><strong><em>bold+italic</em></strong></p>\n");
1405 }
1406
1407 #[test]
1408 fn smoke_test_render_strike_mark_sup_sub() {
1409 let doc = Document {
1410 children: vec![Node {
1411 kind: NodeKind::Paragraph,
1412 span: None,
1413 children: vec![
1414 Node {
1415 kind: NodeKind::Strikethrough,
1416 span: None,
1417 children: vec![Node {
1418 kind: NodeKind::Text("del".to_string()),
1419 span: None,
1420 children: vec![],
1421 }],
1422 },
1423 Node {
1424 kind: NodeKind::Text(" ".to_string()),
1425 span: None,
1426 children: vec![],
1427 },
1428 Node {
1429 kind: NodeKind::Mark,
1430 span: None,
1431 children: vec![Node {
1432 kind: NodeKind::Text("mark".to_string()),
1433 span: None,
1434 children: vec![],
1435 }],
1436 },
1437 Node {
1438 kind: NodeKind::Text(" ".to_string()),
1439 span: None,
1440 children: vec![],
1441 },
1442 Node {
1443 kind: NodeKind::Superscript,
1444 span: None,
1445 children: vec![Node {
1446 kind: NodeKind::Text("sup".to_string()),
1447 span: None,
1448 children: vec![],
1449 }],
1450 },
1451 Node {
1452 kind: NodeKind::Text(" ".to_string()),
1453 span: None,
1454 children: vec![],
1455 },
1456 Node {
1457 kind: NodeKind::Subscript,
1458 span: None,
1459 children: vec![Node {
1460 kind: NodeKind::Text("sub".to_string()),
1461 span: None,
1462 children: vec![],
1463 }],
1464 },
1465 ],
1466 }],
1467 ..Default::default()
1468 };
1469
1470 let options = RenderOptions::default();
1471 let result = render_html(&doc, &options).unwrap();
1472 assert_eq!(
1473 result,
1474 "<p><del>del</del> <mark>mark</mark> <sup>sup</sup> <sub>sub</sub></p>\n"
1475 );
1476 }
1477
1478 #[test]
1479 fn smoke_test_render_table_with_alignment() {
1480 let doc = Document {
1481 children: vec![Node {
1482 kind: NodeKind::Table {
1483 alignments: vec![TableAlignment::Left, TableAlignment::Center],
1484 },
1485 span: None,
1486 children: vec![
1487 Node {
1488 kind: NodeKind::TableRow { header: true },
1489 span: None,
1490 children: vec![
1491 Node {
1492 kind: NodeKind::TableCell {
1493 header: true,
1494 alignment: TableAlignment::Left,
1495 },
1496 span: None,
1497 children: vec![Node {
1498 kind: NodeKind::Text("h1".to_string()),
1499 span: None,
1500 children: vec![],
1501 }],
1502 },
1503 Node {
1504 kind: NodeKind::TableCell {
1505 header: true,
1506 alignment: TableAlignment::Center,
1507 },
1508 span: None,
1509 children: vec![Node {
1510 kind: NodeKind::Text("h2".to_string()),
1511 span: None,
1512 children: vec![],
1513 }],
1514 },
1515 ],
1516 },
1517 Node {
1518 kind: NodeKind::TableRow { header: false },
1519 span: None,
1520 children: vec![
1521 Node {
1522 kind: NodeKind::TableCell {
1523 header: false,
1524 alignment: TableAlignment::Left,
1525 },
1526 span: None,
1527 children: vec![Node {
1528 kind: NodeKind::Text("c1".to_string()),
1529 span: None,
1530 children: vec![],
1531 }],
1532 },
1533 Node {
1534 kind: NodeKind::TableCell {
1535 header: false,
1536 alignment: TableAlignment::Center,
1537 },
1538 span: None,
1539 children: vec![Node {
1540 kind: NodeKind::Text("c2".to_string()),
1541 span: None,
1542 children: vec![],
1543 }],
1544 },
1545 ],
1546 },
1547 ],
1548 }],
1549 ..Default::default()
1550 };
1551
1552 let options = RenderOptions::default();
1553 let result = render_html(&doc, &options).expect("render failed");
1554
1555 assert!(result.contains("<table>"));
1556 assert!(result.contains("<thead>"));
1557 assert!(result.contains("<tbody>"));
1558 assert!(result.contains("<th style=\"text-align: left;\">h1</th>"));
1559 assert!(result.contains("<th style=\"text-align: center;\">h2</th>"));
1560 assert!(result.contains("<td style=\"text-align: left;\">c1</td>"));
1561 assert!(result.contains("<td style=\"text-align: center;\">c2</td>"));
1562 }
1563
1564 #[test]
1565 fn smoke_image_as_link() {
1566 let input =
1568 "[](https://github.com/Ranrar/Marco)\n";
1569 let doc = crate::parser::parse(input).expect("parse failed");
1570 let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1571 .expect("render failed");
1572 assert!(
1573 html.contains("<a href=\"https://github.com/Ranrar/Marco\"><img"),
1574 "image-as-link must render as <a><img/></a>, got: {}",
1575 html
1576 );
1577 }
1578
1579 #[test]
1580 fn smoke_hard_break_backslash() {
1581 let input = "Hello\\\nworld\n";
1583 let doc = crate::parser::parse(input).expect("parse failed");
1584 let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1585 .expect("render failed");
1586 assert!(
1587 html.contains("<br"),
1588 "backslash hard break should render <br />, got: {}",
1589 html
1590 );
1591 }
1592
1593 #[test]
1594 fn smoke_hard_break_two_spaces() {
1595 let input = "Hello \nworld\n";
1597 let doc = crate::parser::parse(input).expect("parse failed");
1598 let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1599 .expect("render failed");
1600 assert!(
1601 html.contains("<br"),
1602 "two-space hard break should render <br />, got: {}",
1603 html
1604 );
1605 }
1606
1607 #[test]
1608 fn smoke_hard_break_three_spaces() {
1609 let input = "Hello \nworld\n";
1611 let doc = crate::parser::parse(input).expect("parse failed");
1612 let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1613 .expect("render failed");
1614 assert!(
1615 html.contains("<br"),
1616 "three-space hard break should render <br />, got: {}",
1617 html
1618 );
1619 assert!(
1621 !html.contains("Hello <br"),
1622 "three-space hard break should not leave a stray space before <br />, got: {}",
1623 html
1624 );
1625 }
1626
1627 #[test]
1628 fn smoke_nbsp_spacer_paragraph() {
1629 let input = "before\n\n\u{00A0}\n\nafter\n";
1634 let doc = crate::parser::parse(input).expect("parse failed");
1635 let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1636 .expect("render failed");
1637 let has_nbsp_para =
1639 html.contains("\u{00A0}") || html.contains(" ") || html.contains(" ");
1640 assert!(
1641 has_nbsp_para,
1642 "nbsp spacer paragraph must appear in HTML output, got: {}",
1643 html
1644 );
1645 assert!(
1647 html.contains(">before<"),
1648 "before paragraph must be present, got: {}",
1649 html
1650 );
1651 assert!(
1652 html.contains(">after<"),
1653 "after paragraph must be present, got: {}",
1654 html
1655 );
1656 }
1657}