Skip to main content

marco_core/render/
markdown.rs

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
10// Code block copy button icon (Tabler icon-tabler-copy).
11const 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
13// Marco sliders UI icons (Tabler).
14// These are embedded as inline SVG so they inherit `currentColor`.
15const 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    /// Tracks how many times each slug base has been used so duplicates get
37    /// a `-1`, `-2`, … suffix — matching the same logic in `intelligence::toc`.
38    heading_slug_counts: HashMap<String, usize>,
39}
40
41// Render document to HTML
42pub 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
102// Render individual node
103fn 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            // Use explicit {#id} when present; otherwise auto-derive from heading text.
115            // Duplicate slugs get a -1, -2, … suffix (same algorithm as `intelligence::toc`).
116            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            // Wrap heading text in a self-anchor so the whole heading is clickable.
137            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            // Wrap code block in a container for copy button positioning
159            output.push_str("<div class=\"marco-code-block-wrapper\">");
160
161            // Add copy button
162            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            // Add language class attribute if language specified
177            if let Some(lang) = language_raw {
178                output.push_str(&format!(" class=\"language-{}\"", escape_html(lang)));
179            }
180
181            output.push('>');
182
183            // Optional syntax highlighting. If syntect can't resolve the language,
184            // fall back to plain escaped code.
185            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            // HTML blocks are rendered as-is without escaping
205            // They already contain the complete HTML including tags
206            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            // Render with both GitHub-compatible classes (`markdown-alert`) and
229            // our theme-compatible classes (`admonition`).
230            //
231            // Quote-style admonitions intentionally omit the `*-<kind>` classes
232            // so themes keep neutral/blockquote-like colors.
233            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                // Icon is text (typically emoji). Use an inner span so themes can
256                // override line-height without affecting SVG icons.
257                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            // Tab items should be rendered via `render_tab_group` so we can
278            // coordinate radio inputs/labels/panels.
279            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            // Slides should be rendered via `render_slider_deck` so we can
289            // coordinate controls and timers.
290            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            // Tables should be rendered via `render_table` so we can decide
300            // whether a row belongs in <thead> or <tbody>.
301            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            // Cells should be rendered via `render_table_row`.
307            log::warn!("TableCell rendered outside of TableRow context");
308            render_table_cell(node, output, options, ctx)?;
309        }
310        NodeKind::FootnoteDefinition { .. } => {
311            // Footnote definitions are rendered in a dedicated section at the
312            // end of the document.
313        }
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            // Triple delimiter: bold + italic.
338            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            // Reference links should normally be resolved during parsing.
411            // If a reference is missing, or a caller bypasses the resolver,
412            // render the original source-ish form as literal text.
413            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                // Missing definition: keep the literal source form.
426                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            // Pass through inline HTML directly (no escaping)
464            output.push_str(html);
465        }
466        NodeKind::HardBreak => {
467            // Hard line break: <br />
468            output.push_str("<br />\n");
469        }
470        NodeKind::SoftBreak => {
471            // Soft line break: rendered as single space (or newline in some contexts)
472            output.push('\n');
473        }
474        NodeKind::List {
475            ordered,
476            start,
477            tight,
478        } => {
479            // Render list opening tag
480            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            // Render list items
493            for child in &node.children {
494                render_list_item(child, output, *tight, options, ctx)?;
495            }
496
497            // Render list closing tag
498            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            // This should only be called via render_list_item
527            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            // This should only be called via render_list_item (as a ListItem child).
536            log::warn!("TaskCheckbox rendered outside of ListItem context");
537        }
538        NodeKind::TaskCheckboxInline { checked } => {
539            // Inline checkbox marker (e.g. paragraph starting with `[ ]` / `[x]`).
540            render_task_checkbox_icon(output, *checked);
541        }
542        NodeKind::InlineMath { content } => {
543            // Render inline math using katex-rs
544            match render_inline_math(content) {
545                Ok(html) => output.push_str(&html),
546                Err(e) => {
547                    log::warn!("Math render error (inline): {}", e);
548                    // Fallback: show raw LaTeX in a code span
549                    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            // Render display math using katex-rs
557            match render_display_math(content) {
558                Ok(html) => output.push_str(&html),
559                Err(e) => {
560                    log::warn!("Math render error (display): {}", e);
561                    // Fallback: show raw LaTeX in a pre block
562                    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            // Render Mermaid diagram using mermaid-rs-renderer with per-render-pass caching.
570            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                    // Fallback: show raw Mermaid in a code block
591                    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    // Icons use `stroke="currentColor"` so theme CSS can color them by setting
614    // `color` on `.markdown-alert-title`.
615    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    // Assign a stable sequential id for this render pass.
676    let group_id = ctx.tab_group_counter;
677    ctx.tab_group_counter = ctx.tab_group_counter.saturating_add(1);
678
679    // Collect tab items in order.
680    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    // Radio inputs must come before the tablist/panels so generic nth-of-type CSS rules work.
696    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    // Assign a stable sequential id for this render pass.
756    let deck_id = ctx.slider_deck_counter;
757    ctx.slider_deck_counter = ctx.slider_deck_counter.saturating_add(1);
758
759    // Collect slides in order.
760    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        // The `--` separator sets `vertical: true` in the AST and the attribute is
793        // written here for future use, but the JS/CSS in preview_document.rs does not
794        // consume `data-vertical` — all slides behave as horizontal slides for now.
795        // When vertical navigation is added, the preview JS will need a 2D index model
796        // (column × row) and separate prev/next axis controls.
797        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    // Controls: prev / play-pause / next
809    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    // Dots navigation
837    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    // We keep the SVG strokes as `currentColor` and let CSS decide:
871    // - unchecked box: inherited text color
872    // - checked box: theme primary
873    // - checkmark: theme accent
874    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
1003// Render a list item with proper tight/loose handling
1004fn 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        // Tight list: paragraph content is inlined (no <p> wrapper), so we can
1028        // safely emit the checkbox icon at the start of the list item.
1029        if let Some(checked) = task_checked {
1030            render_task_checkbox_icon(output, checked);
1031        }
1032
1033        // Tight list: don't wrap paragraphs in <p> tags
1034        for child in &node.children {
1035            if matches!(child.kind, NodeKind::TaskCheckbox { .. }) {
1036                continue;
1037            }
1038            match &child.kind {
1039                NodeKind::Paragraph => {
1040                    // Render paragraph children directly without <p> wrapper
1041                    for grandchild in &child.children {
1042                        render_node(grandchild, output, options, ctx)?;
1043                    }
1044                }
1045                _ => {
1046                    // Other block elements render normally
1047                    render_node(child, output, options, ctx)?;
1048                }
1049            }
1050        }
1051    } else {
1052        // Loose list: keep paragraphs wrapped in <p>, but for task list items we
1053        // want the checkbox icon to sit inline with the first paragraph's text.
1054        let mut checkbox_emitted = false;
1055
1056        for child in &node.children {
1057            if matches!(child.kind, NodeKind::TaskCheckbox { .. }) {
1058                continue;
1059            }
1060
1061            // Emit the checkbox exactly once, either inside the first paragraph
1062            // or as a standalone prefix if the first block isn't a paragraph.
1063            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                            // fall through and render this child normally
1080                        }
1081                    }
1082                }
1083            }
1084
1085            render_node(child, output, options, ctx)?;
1086        }
1087    }
1088
1089    output.push_str("</li>\n");
1090    Ok(())
1091}
1092
1093// Escape HTML special characters to prevent XSS and ensure proper display
1094fn escape_html(text: &str) -> String {
1095    text.chars()
1096        .map(|c| match c {
1097            '&' => "&amp;".to_string(),
1098            '<' => "&lt;".to_string(),
1099            '>' => "&gt;".to_string(),
1100            '"' => "&quot;".to_string(),
1101            '\'' => "&#39;".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 &lt;world&gt; &amp; &quot;friends&quot;";
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 = "&lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;";
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        // All headings now get an auto-generated id for TOC anchor navigation.
1144        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        // Heading text is HTML-escaped; id is the slug of the unescaped text.
1167        assert!(result.contains("<h2 id=\"code-example-test\">"));
1168        assert!(result.contains("Code &lt;example&gt; &amp; 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        // Should contain wrapper div and copy button
1206        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!(&quot;Hello&quot;);\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        // Should contain wrapper div, copy button, and language attribute
1233        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        // Should contain wrapper, copy button, and properly escaped HTML
1260        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\">&lt;div&gt;Test &amp; verify&lt;/div&gt;</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        // Should contain heading (now with auto-slug id), paragraph, and code block with wrapper
1336        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(&#39;hello&#39;)</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        // `[![alt](img)](url)` must render as `<a href="url"><img .../></a>`, not broken.
1528        let input =
1529            "[![Marco Logo](https://example.com/logo.png)](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        // Backslash + newline → <br />
1543        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        // Two trailing spaces + newline → <br /> (used by Shift+Enter in editor)
1557        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        // Three trailing spaces must also produce a clean <br /> with no stray space before it.
1571        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        // Must not produce a stray space text node before <br />
1581        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        // Shift+Enter inserts "\u{00A0}\n\n" — a non-breaking space on its own line.
1591        // Rust's trim() does NOT strip \u{00A0} (only strips ASCII whitespace),
1592        // so the block parser accepts it as a real paragraph.
1593        // The rendered <p> has CSS line-height height → visible spacer in preview.
1594        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        // Must have a paragraph containing the nbsp character (as literal or escaped)
1599        let has_nbsp_para =
1600            html.contains("\u{00A0}") || html.contains("&#xa0;") || html.contains("&#160;");
1601        assert!(
1602            has_nbsp_para,
1603            "nbsp spacer paragraph must appear in HTML output, got: {}",
1604            html
1605        );
1606        // Must still have both surrounding paragraphs
1607        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}