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