Skip to main content

marco_core/render/
markdown.rs

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
13// Code block copy button icon (Tabler icon-tabler-copy).
14const 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
16// Slide-deck UI icons (Tabler).
17// These are embedded as inline SVG so they inherit `currentColor`.
18const 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    /// Tracks how many times each slug base has been used so duplicates get
41    /// a `-1`, `-2`, … suffix — matching the same logic in `intelligence::toc`.
42    heading_slug_counts: HashMap<String, usize>,
43}
44
45/// Render a parsed Markdown document into HTML.
46pub 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    // Estimate output size: typical HTML is ~1.5–2× the AST node count × avg node bytes.
53    // Seeding with a modest non-zero capacity avoids the first few reallocations for free.
54    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
111// Render individual node
112fn 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            // Use explicit {#id} when present; otherwise auto-derive from heading text.
124            // Duplicate slugs get a -1, -2, … suffix (same algorithm as `intelligence::toc`).
125            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            // level is 1–6; render digit as char to avoid a heap allocation.
140            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            // Wrap heading text in a self-anchor so the whole heading is clickable.
150            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            // Wrap code block in a container for copy button positioning
172            output.push_str("<div class=\"marco-code-block\">");
173
174            // Add copy button
175            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            // Add language class attribute if language specified
190            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            // Optional syntax highlighting. If syntect can't resolve the language,
199            // fall back to plain escaped code.
200            #[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            // HTML blocks are rendered as-is without escaping
221            // They already contain the complete HTML including tags
222            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            // Render with both GitHub-compatible classes (`markdown-alert`) and
245            // our theme-compatible classes (`admonition`).
246            //
247            // Quote-style admonitions intentionally omit the `*-<kind>` classes
248            // so themes keep neutral/blockquote-like colors.
249            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                // Icon is text (typically emoji). Use an inner span so themes can
272                // override line-height without affecting SVG icons.
273                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            // Tab items should be rendered via `render_tab_group` so we can
294            // coordinate radio inputs/labels/panels.
295            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            // Slides should be rendered via `render_slider_deck` so we can
305            // coordinate controls and timers.
306            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            // Tables should be rendered via `render_table` so we can decide
316            // whether a row belongs in <thead> or <tbody>.
317            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            // Cells should be rendered via `render_table_row`.
323            log::warn!("TableCell rendered outside of TableRow context");
324            render_table_cell(node, output, options, ctx)?;
325        }
326        NodeKind::FootnoteDefinition { .. } => {
327            // Footnote definitions are rendered in a dedicated section at the
328            // end of the document.
329        }
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            // Triple delimiter: bold + italic.
354            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            // Reference links should normally be resolved during parsing.
427            // If a reference is missing, or a caller bypasses the resolver,
428            // render the original source-ish form as literal text.
429            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                // Missing definition: keep the literal source form.
442                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            // Pass through inline HTML directly (no escaping)
480            output.push_str(html);
481        }
482        NodeKind::HardBreak => {
483            // Hard line break: <br />
484            output.push_str("<br />\n");
485        }
486        NodeKind::SoftBreak => {
487            // Soft line break: rendered as single space (or newline in some contexts)
488            output.push('\n');
489        }
490        NodeKind::List {
491            ordered,
492            start,
493            tight,
494        } => {
495            // Render list opening tag
496            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            // Render list items
509            for child in &node.children {
510                render_list_item(child, output, *tight, options, ctx)?;
511            }
512
513            // Render list closing tag
514            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            // This should only be called via render_list_item
543            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            // This should only be called via render_list_item (as a ListItem child).
552            log::warn!("TaskCheckbox rendered outside of ListItem context");
553        }
554        NodeKind::TaskCheckboxInline { checked } => {
555            // Inline checkbox marker (e.g. paragraph starting with `[ ]` / `[x]`).
556            render_task_checkbox_icon(output, *checked);
557        }
558        NodeKind::InlineMath { content } => {
559            // Render inline math using katex-rs
560            #[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                    // Fallback: show raw LaTeX in a code span
566                    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            // Render display math using katex-rs
580            #[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                    // Fallback: show raw LaTeX in a pre block
586                    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            // Render Mermaid diagram using mermaid-rs-renderer with per-render-pass caching.
600            #[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                        // Fallback: show raw Mermaid in a code block
623                        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    // Icons use `stroke="currentColor"` so theme CSS can color them by setting
653    // `color` on `.markdown-alert-title`.
654    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    // Assign a stable sequential id for this render pass.
715    let group_id = ctx.tab_group_counter;
716    ctx.tab_group_counter = ctx.tab_group_counter.saturating_add(1);
717
718    // Collect tab items in order.
719    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    // Radio inputs must come before the tablist/panels so generic nth-of-type CSS rules work.
735    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    // Assign a stable sequential id for this render pass.
795    let deck_id = ctx.slider_deck_counter;
796    ctx.slider_deck_counter = ctx.slider_deck_counter.saturating_add(1);
797
798    // Collect slides in order.
799    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        // The `--` separator sets `vertical: true` in the AST and the attribute is
832        // written here for future use, but the JS/CSS in preview_document.rs does not
833        // consume `data-vertical` — all slides behave as horizontal slides for now.
834        // When vertical navigation is added, the preview JS will need a 2D index model
835        // (column × row) and separate prev/next axis controls.
836        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    // Controls: prev / play-pause / next
848    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    // Dots navigation
876    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    // We keep the SVG strokes as `currentColor` and let CSS decide:
910    // - unchecked box: inherited text color
911    // - checked box: theme primary
912    // - checkmark: theme accent
913    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
1042// Render a list item with proper tight/loose handling
1043fn 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        // Tight list: paragraph content is inlined (no <p> wrapper), so we can
1067        // safely emit the checkbox icon at the start of the list item.
1068        if let Some(checked) = task_checked {
1069            render_task_checkbox_icon(output, checked);
1070        }
1071
1072        // Tight list: don't wrap paragraphs in <p> tags
1073        for child in &node.children {
1074            if matches!(child.kind, NodeKind::TaskCheckbox { .. }) {
1075                continue;
1076            }
1077            match &child.kind {
1078                NodeKind::Paragraph => {
1079                    // Render paragraph children directly without <p> wrapper
1080                    for grandchild in &child.children {
1081                        render_node(grandchild, output, options, ctx)?;
1082                    }
1083                }
1084                _ => {
1085                    // Other block elements render normally
1086                    render_node(child, output, options, ctx)?;
1087                }
1088            }
1089        }
1090    } else {
1091        // Loose list: keep paragraphs wrapped in <p>, but for task list items we
1092        // want the checkbox icon to sit inline with the first paragraph's text.
1093        let mut checkbox_emitted = false;
1094
1095        for child in &node.children {
1096            if matches!(child.kind, NodeKind::TaskCheckbox { .. }) {
1097                continue;
1098            }
1099
1100            // Emit the checkbox exactly once, either inside the first paragraph
1101            // or as a standalone prefix if the first block isn't a paragraph.
1102            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                            // fall through and render this child normally
1119                        }
1120                    }
1121                }
1122            }
1123
1124            render_node(child, output, options, ctx)?;
1125        }
1126    }
1127
1128    output.push_str("</li>\n");
1129    Ok(())
1130}
1131
1132// Escape HTML special characters to prevent XSS and ensure proper display
1133fn escape_html(text: &str) -> String {
1134    text.chars()
1135        .map(|c| match c {
1136            '&' => "&amp;".to_string(),
1137            '<' => "&lt;".to_string(),
1138            '>' => "&gt;".to_string(),
1139            '"' => "&quot;".to_string(),
1140            '\'' => "&#39;".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 &lt;world&gt; &amp; &quot;friends&quot;";
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 = "&lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;";
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        // All headings now get an auto-generated id for TOC anchor navigation.
1183        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        // Heading text is HTML-escaped; id is the slug of the unescaped text.
1206        assert!(result.contains("<h2 id=\"code-example-test\">"));
1207        assert!(result.contains("Code &lt;example&gt; &amp; 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        // Should contain wrapper div and copy button
1245        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!(&quot;Hello&quot;);\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        // Should contain wrapper div, copy button, and language attribute
1272        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        // Should contain wrapper, copy button, and properly escaped HTML
1299        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\">&lt;div&gt;Test &amp; verify&lt;/div&gt;</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        // Should contain heading (now with auto-slug id), paragraph, and code block with wrapper
1375        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(&#39;hello&#39;)</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        // `[![alt](img)](url)` must render as `<a href="url"><img .../></a>`, not broken.
1567        let input =
1568            "[![Marco Logo](https://example.com/logo.png)](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        // Backslash + newline → <br />
1582        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        // Two trailing spaces + newline → <br /> (used by Shift+Enter in editor)
1596        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        // Three trailing spaces must also produce a clean <br /> with no stray space before it.
1610        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        // Must not produce a stray space text node before <br />
1620        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        // Shift+Enter inserts "\u{00A0}\n\n" — a non-breaking space on its own line.
1630        // Rust's trim() does NOT strip \u{00A0} (only strips ASCII whitespace),
1631        // so the block parser accepts it as a real paragraph.
1632        // The rendered <p> has CSS line-height height → visible spacer in preview.
1633        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        // Must have a paragraph containing the nbsp character (as literal or escaped)
1638        let has_nbsp_para =
1639            html.contains("\u{00A0}") || html.contains("&#xa0;") || html.contains("&#160;");
1640        assert!(
1641            has_nbsp_para,
1642            "nbsp spacer paragraph must appear in HTML output, got: {}",
1643            html
1644        );
1645        // Must still have both surrounding paragraphs
1646        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}