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    /// Syntax-highlight results computed up front (see `precompute_highlights`),
44    /// keyed by the `CodeBlock` node's own address — stable for the
45    /// lifetime of one `render_html` call since `document` is never mutated
46    /// in between, and immune to traversal-order concerns (footnote
47    /// definitions render in a different order than they appear in the
48    /// tree). `None` means no precomputed cache is installed — the
49    /// `parallel-render` feature is disabled — and `render_node` falls back
50    /// to computing highlights synchronously, unchanged from before this
51    /// cache existed.
52    #[cfg(feature = "render-syntax-highlighting")]
53    precomputed_highlights: Option<HashMap<usize, String>>,
54}
55
56/// Render a parsed Markdown document into HTML.
57pub fn render_html(
58    document: &Document,
59    options: &RenderOptions,
60) -> Result<String, Box<dyn std::error::Error>> {
61    log::debug!("Rendering {} nodes to HTML", document.len());
62
63    // Estimate output size: typical HTML is ~1.5–2× the AST node count × avg node bytes.
64    // Seeding with a modest non-zero capacity avoids the first few reallocations for free.
65    let estimated = document.children.len() * 64;
66    let mut html = String::with_capacity(estimated.max(256));
67
68    let mut ctx = RenderContext::default();
69    for node in &document.children {
70        collect_footnote_definitions(node, &mut ctx.footnote_defs);
71    }
72
73    #[cfg(all(feature = "render-syntax-highlighting", feature = "parallel-render"))]
74    {
75        ctx.precomputed_highlights = Some(precompute_highlights(document, options));
76    }
77
78    for node in &document.children {
79        render_node(node, &mut html, options, &mut ctx)?;
80    }
81
82    if !ctx.footnote_order.is_empty() {
83        html.push_str("<section class=\"footnotes\">\n");
84        html.push_str("<ol>\n");
85
86        let mut i = 0usize;
87        while i < ctx.footnote_order.len() {
88            let label = ctx.footnote_order[i].clone();
89            let Some(n) = ctx.footnote_numbers.get(&label).copied() else {
90                i += 1;
91                continue;
92            };
93
94            let Some(def_node) = ctx.footnote_defs.get(&label).copied() else {
95                i += 1;
96                continue;
97            };
98
99            html.push_str("<li id=\"fn");
100            html.push_str(&n.to_string());
101            html.push_str("\">");
102            for child in &def_node.children {
103                render_node(child, &mut html, options, &mut ctx)?;
104            }
105            html.push_str("</li>\n");
106
107            i += 1;
108        }
109
110        html.push_str("</ol>\n");
111        html.push_str("</section>\n");
112    }
113
114    Ok(html)
115}
116
117fn collect_footnote_definitions<'a>(node: &'a Node, defs: &mut HashMap<String, &'a Node>) {
118    if let NodeKind::FootnoteDefinition { label } = &node.kind {
119        defs.entry(label.clone()).or_insert(node);
120    }
121
122    for child in &node.children {
123        collect_footnote_definitions(child, defs);
124    }
125}
126
127/// Fan out per-`CodeBlock` syntax highlighting across cores with rayon,
128/// computing every eligible block's result up front. Highlighting is a
129/// pure function of `(code, lang)` with no shared mutable state between
130/// blocks, so this is safe.
131///
132/// Results are keyed by each `CodeBlock` node's own address rather than by
133/// traversal position: footnote definitions render in a different order
134/// (deferred to the end, in first-reference order — see `render_html`)
135/// than they appear in the tree, so a position/index-based cache would
136/// require this collection pass to exactly replicate that reordering to
137/// stay in sync. Node addresses sidestep that entirely — every node here
138/// and in `render_node` refers to the same never-mutated `document`, so an
139/// address collected here is guaranteed to match the same node seen later,
140/// regardless of which order either pass visits things in.
141#[cfg(all(feature = "render-syntax-highlighting", feature = "parallel-render"))]
142fn precompute_highlights(document: &Document, options: &RenderOptions) -> HashMap<usize, String> {
143    use rayon::prelude::*;
144
145    fn collect<'a>(
146        node: &'a Node,
147        options: &RenderOptions,
148        targets: &mut Vec<(usize, &'a str, &'a str)>,
149    ) {
150        if let NodeKind::CodeBlock { language, code } = &node.kind {
151            if options.syntax_highlighting {
152                let language_raw = language.as_deref().map(str::trim).filter(|s| !s.is_empty());
153                if let Some(lang) = language_raw {
154                    targets.push((std::ptr::from_ref(node) as usize, code.as_str(), lang));
155                }
156            }
157        }
158        for child in &node.children {
159            collect(child, options, targets);
160        }
161    }
162
163    let mut targets = Vec::new();
164    for node in &document.children {
165        collect(node, options, &mut targets);
166    }
167
168    targets
169        .par_iter()
170        .filter_map(|(key, code, lang)| {
171            highlight_code_to_classed_html(code, lang).map(|html| (*key, html))
172        })
173        .collect()
174}
175
176/// Eagerly initializes state that `parallel-render` otherwise sets up
177/// lazily on first use: rayon's global thread pool, syntect's default
178/// syntax/theme sets, and — for each language in `languages` — syntect's
179/// internal per-language setup.
180///
181/// The `languages` list matters more than it might look: measured directly
182/// (16-core machine, the `fixture:large/code-heavy.md` perf-lab fixture,
183/// mixed rust/python/javascript), warming *only* the thread pool made no
184/// measurable difference to the first render (~20ms either way — thread
185/// spawn itself is not the bottleneck here, contrary to an earlier draft of
186/// this function that assumed it was). What did measurably help was also
187/// pre-highlighting the specific languages the fixture actually uses:
188/// first-render time dropped from ~20.4ms to ~14.1ms (~31%). A
189/// language-blind warm-up can't buy that on its own, because it doesn't
190/// know which languages your documents contain — pass the ones you expect
191/// (e.g. your editor's supported-language list, or languages seen in the
192/// user's recently opened files) to get the real benefit. An empty slice
193/// still warms the thread pool and syntect's defaults, which is a small,
194/// generically-safe win, just not the dominant one.
195///
196/// Note there is a residual first-render cost (~6-7ms in the same
197/// measurement) that persists even after warming the pool, syntect
198/// defaults, *and* every language actually used — this looks like
199/// first-touch allocation/OS-level warm-up rather than anything this crate
200/// controls, and no combination of arguments to this function removes it.
201///
202/// Call this during application startup, before the first document is
203/// opened (e.g. off a splash screen or init routine), to move whichever
204/// part of that cost *can* be moved off a user-visible render. This is
205/// purely a matter of *when* the cost is paid: if you never call this,
206/// rendering still works correctly and everything still initializes
207/// lazily and correctly on first use (the default "warm on first request"
208/// behavior) — this function does not change behavior or output, only
209/// timing.
210///
211/// A no-op when `parallel-render` is not compiled in, so it's always safe
212/// to call unconditionally regardless of which features a consumer builds
213/// with.
214#[cfg(feature = "parallel-render")]
215pub fn warm_render_thread_pool(languages: &[&str]) {
216    // `rayon::join`/`par_iter` on trivial work can return before every
217    // worker OS thread has actually been spawned, so it doesn't reliably
218    // move that part of the cost earlier. `build_global` spawns the
219    // configured worker threads synchronously as part of the call. `Err`
220    // just means some other code already installed a global pool (e.g. a
221    // previous call to this function) — harmless.
222    let _ = rayon::ThreadPoolBuilder::new().build_global();
223
224    #[cfg(feature = "render-syntax-highlighting")]
225    for lang in languages {
226        let _ = highlight_code_to_classed_html(" ", lang);
227    }
228    #[cfg(not(feature = "render-syntax-highlighting"))]
229    let _ = languages;
230}
231
232/// See the `parallel-render`-enabled version of this function for the full
233/// explanation. Without that feature there is nothing to warm, so this is
234/// a no-op — kept so callers don't need their own `#[cfg]` around a call to
235/// this at startup.
236#[cfg(not(feature = "parallel-render"))]
237pub fn warm_render_thread_pool(_languages: &[&str]) {}
238
239// Render individual node
240fn render_node(
241    node: &Node,
242    output: &mut String,
243    options: &RenderOptions,
244    ctx: &mut RenderContext<'_>,
245) -> Result<(), Box<dyn std::error::Error>> {
246    match &node.kind {
247        NodeKind::Heading { level, text, id } => {
248            log::trace!("Rendering heading level {}", level);
249            let escaped_text = escape_html(text);
250
251            // Use explicit {#id} when present; otherwise auto-derive from heading text.
252            // Duplicate slugs get a -1, -2, … suffix (same algorithm as `intelligence::toc`).
253            let effective_id = if let Some(explicit_id) = id {
254                explicit_id.clone()
255            } else {
256                let base = crate::intelligence::toc::heading_slug(text);
257                let count = ctx.heading_slug_counts.entry(base.clone()).or_insert(0);
258                let slug = if *count == 0 {
259                    base.clone()
260                } else {
261                    format!("{}-{}", base, count)
262                };
263                *count += 1;
264                slug
265            };
266
267            // level is 1–6; render digit as char to avoid a heap allocation.
268            let level_char = char::from_digit(*level as u32, 10).unwrap_or('1');
269            let escaped_id = escape_html(&effective_id);
270
271            output.push_str("<h");
272            output.push(level_char);
273            output.push_str(" id=\"");
274            output.push_str(&escaped_id);
275            output.push_str("\">");
276
277            // Wrap heading text in a self-anchor so the whole heading is clickable.
278            output.push_str("<a class=\"marco-heading-anchor\" href=\"#");
279            output.push_str(&escaped_id);
280            output.push_str("\" aria-label=\"Link to this heading\">");
281            output.push_str(&escaped_text);
282            output.push_str("</a>");
283
284            output.push_str("</h");
285            output.push(level_char);
286            output.push_str(">\n");
287        }
288        NodeKind::Paragraph => {
289            output.push_str("<p>");
290            for child in &node.children {
291                render_node(child, output, options, ctx)?;
292            }
293            output.push_str("</p>\n");
294        }
295        NodeKind::CodeBlock { language, code } => {
296            log::trace!("Rendering code block: {:?}", language);
297            let language_raw = language.as_deref().map(str::trim).filter(|s| !s.is_empty());
298
299            // Wrap code block in a container for copy button positioning
300            output.push_str("<div class=\"marco-code-block\">");
301
302            // Add copy button
303            output.push_str("<button class=\"marco-copy-btn\" data-action=\"copy\" aria-label=\"Copy code\" title=\"Copy code\">");
304            output.push_str(CODE_BLOCK_COPY_SVG);
305            output.push_str("</button>");
306
307            output.push_str("<pre");
308            if let Some(raw) = language_raw {
309                if let Some(label) = language_display_label(raw) {
310                    output.push_str(" data-language=\"");
311                    output.push_str(&escape_html(label.as_ref()));
312                    output.push('"');
313                }
314            }
315            output.push_str("><code");
316
317            // Add language class attribute if language specified
318            if let Some(lang) = language_raw {
319                output.push_str(" class=\"language-");
320                output.push_str(&escape_html(lang));
321                output.push('"');
322            }
323
324            output.push('>');
325
326            // Optional syntax highlighting. If syntect can't resolve the language,
327            // fall back to plain escaped code. When the `parallel-render`
328            // cache is installed (see `precompute_highlights`), use its
329            // precomputed result instead of highlighting synchronously here
330            // — falls back to the original direct call when no cache is
331            // installed (feature disabled), unchanged from before.
332            #[cfg(feature = "render-syntax-highlighting")]
333            if options.syntax_highlighting {
334                if let Some(lang) = language_raw {
335                    let highlighted = match ctx.precomputed_highlights.as_ref() {
336                        Some(cache) => cache.get(&(std::ptr::from_ref(node) as usize)).cloned(),
337                        None => highlight_code_to_classed_html(code, lang),
338                    };
339                    if let Some(highlighted) = highlighted {
340                        output.push_str(&highlighted);
341                        output.push_str("</code></pre>");
342                        output.push_str("</div>\n");
343                        return Ok(());
344                    }
345                }
346            }
347
348            output.push_str(&escape_html(code));
349            output.push_str("</code></pre>");
350            output.push_str("</div>\n");
351        }
352        NodeKind::ThematicBreak => {
353            output.push_str("<hr />\n");
354        }
355        NodeKind::HtmlBlock { html } => {
356            // HTML blocks are rendered as-is without escaping
357            // They already contain the complete HTML including tags
358            output.push_str(html);
359            if !html.ends_with('\n') {
360                output.push('\n');
361            }
362        }
363        NodeKind::Blockquote => {
364            output.push_str("<blockquote>\n");
365            for child in &node.children {
366                render_node(child, output, options, ctx)?;
367            }
368            output.push_str("</blockquote>\n");
369        }
370        NodeKind::Admonition {
371            kind,
372            title,
373            icon,
374            style,
375        } => {
376            let (slug, default_title, icon_svg) = admonition_presentation(kind);
377
378            let title_text = title.as_deref().unwrap_or(default_title);
379
380            // Render with both GitHub-compatible classes (`markdown-alert`) and
381            // our theme-compatible classes (`admonition`).
382            //
383            // Quote-style admonitions intentionally omit the `*-<kind>` classes
384            // so themes keep neutral/blockquote-like colors.
385            output.push_str("<div class=\"");
386            output.push_str("markdown-alert");
387
388            if *style == AdmonitionStyle::Alert {
389                output.push_str(" markdown-alert-");
390                output.push_str(slug);
391            }
392
393            output.push_str(" admonition");
394
395            if *style == AdmonitionStyle::Alert {
396                output.push_str(" admonition-");
397                output.push_str(slug);
398            } else {
399                output.push_str(" admonition-quote");
400            }
401
402            output.push_str("\">\n");
403
404            output.push_str("<p class=\"markdown-alert-title\">");
405            output.push_str("<span class=\"markdown-alert-icon\" aria-hidden=\"true\">");
406            if let Some(icon_text) = icon {
407                // Icon is text (typically emoji). Use an inner span so themes can
408                // override line-height without affecting SVG icons.
409                output.push_str("<span class=\"markdown-alert-emoji\">");
410                output.push_str(&escape_html(icon_text));
411                output.push_str("</span>");
412            } else {
413                output.push_str(icon_svg);
414            }
415            output.push_str("</span>");
416            output.push_str(&escape_html(title_text));
417            output.push_str("</p>\n");
418
419            for child in &node.children {
420                render_node(child, output, options, ctx)?;
421            }
422
423            output.push_str("</div>\n");
424        }
425        NodeKind::TabGroup => {
426            render_tab_group(node, output, options, ctx)?;
427        }
428        NodeKind::TabItem { .. } => {
429            // Tab items should be rendered via `render_tab_group` so we can
430            // coordinate radio inputs/labels/panels.
431            log::warn!("TabItem rendered outside of TabGroup context");
432            for child in &node.children {
433                render_node(child, output, options, ctx)?;
434            }
435        }
436        NodeKind::SliderDeck { .. } => {
437            render_slider_deck(node, output, options, ctx)?;
438        }
439        NodeKind::Slide { .. } => {
440            // Slides should be rendered via `render_slider_deck` so we can
441            // coordinate controls and timers.
442            log::warn!("Slide rendered outside of SliderDeck context");
443            for child in &node.children {
444                render_node(child, output, options, ctx)?;
445            }
446        }
447        NodeKind::Table { .. } => {
448            render_table(node, output, options, ctx)?;
449        }
450        NodeKind::TableRow { .. } => {
451            // Tables should be rendered via `render_table` so we can decide
452            // whether a row belongs in <thead> or <tbody>.
453            log::warn!("TableRow rendered outside of Table context");
454            render_table_row(node, output, options, ctx)?;
455            output.push('\n');
456        }
457        NodeKind::TableCell { .. } => {
458            // Cells should be rendered via `render_table_row`.
459            log::warn!("TableCell rendered outside of TableRow context");
460            render_table_cell(node, output, options, ctx)?;
461        }
462        NodeKind::FootnoteDefinition { .. } => {
463            // Footnote definitions are rendered in a dedicated section at the
464            // end of the document.
465        }
466        NodeKind::Text(text) => {
467            output.push_str(&escape_html(text));
468        }
469        NodeKind::CodeSpan(code) => {
470            output.push_str("<code>");
471            output.push_str(&escape_html(code));
472            output.push_str("</code>");
473        }
474        NodeKind::Emphasis => {
475            output.push_str("<em>");
476            for child in &node.children {
477                render_node(child, output, options, ctx)?;
478            }
479            output.push_str("</em>");
480        }
481        NodeKind::Strong => {
482            output.push_str("<strong>");
483            for child in &node.children {
484                render_node(child, output, options, ctx)?;
485            }
486            output.push_str("</strong>");
487        }
488        NodeKind::StrongEmphasis => {
489            // Triple delimiter: emphasis wrapping strong, e.g. `***foo***` ->
490            // `<em><strong>foo</strong></em>` (CommonMark spec example 467).
491            output.push_str("<em><strong>");
492            for child in &node.children {
493                render_node(child, output, options, ctx)?;
494            }
495            output.push_str("</strong></em>");
496        }
497        NodeKind::Strikethrough => {
498            output.push_str("<del>");
499            for child in &node.children {
500                render_node(child, output, options, ctx)?;
501            }
502            output.push_str("</del>");
503        }
504        NodeKind::Mark => {
505            output.push_str("<mark>");
506            for child in &node.children {
507                render_node(child, output, options, ctx)?;
508            }
509            output.push_str("</mark>");
510        }
511        NodeKind::Superscript => {
512            output.push_str("<sup>");
513            for child in &node.children {
514                render_node(child, output, options, ctx)?;
515            }
516            output.push_str("</sup>");
517        }
518        NodeKind::Subscript => {
519            output.push_str("<sub>");
520            for child in &node.children {
521                render_node(child, output, options, ctx)?;
522            }
523            output.push_str("</sub>");
524        }
525        NodeKind::Link { url, title } => {
526            output.push_str("<a href=\"");
527            output.push_str(&escape_html(url));
528            output.push('"');
529            if let Some(t) = title {
530                output.push_str(" title=\"");
531                output.push_str(&escape_html(t));
532                output.push('"');
533            }
534            output.push('>');
535            for child in &node.children {
536                render_node(child, output, options, ctx)?;
537            }
538            output.push_str("</a>");
539        }
540        NodeKind::PlatformMention {
541            username,
542            platform,
543            display,
544        } => {
545            let label = display.as_deref().unwrap_or(username);
546            let platform_key = platform.trim().to_ascii_lowercase();
547
548            if let Some(url) = plarform_mentions::profile_url(&platform_key, username) {
549                output.push_str("<a class=\"marco-mention marco-mention-");
550                output.push_str(&escape_html(&platform_key));
551                output.push_str("\" href=\"");
552                output.push_str(&escape_html(&url));
553                output.push_str("\">");
554                output.push_str(&escape_html(label));
555                output.push_str("</a>");
556            } else {
557                output.push_str("<span class=\"marco-mention marco-mention-unknown\">");
558                output.push_str(&escape_html(label));
559                output.push_str("</span>");
560            }
561        }
562        NodeKind::LinkReference { suffix, .. } => {
563            // Reference links should normally be resolved during parsing.
564            // If a reference is missing, or a caller bypasses the resolver,
565            // render the original source-ish form as literal text.
566            output.push('[');
567            for child in &node.children {
568                render_node(child, output, options, ctx)?;
569            }
570            output.push(']');
571            output.push_str(&escape_html(suffix));
572        }
573        NodeKind::FootnoteReference { label } => {
574            if !ctx.footnote_defs.contains_key(label) {
575                output.push_str("[^");
576                output.push_str(&escape_html(label));
577                output.push(']');
578                // Missing definition: keep the literal source form.
579                return Ok(());
580            }
581
582            let n = match ctx.footnote_numbers.get(label) {
583                Some(n) => *n,
584                None => {
585                    let next = ctx.footnote_order.len() + 1;
586                    ctx.footnote_order.push(label.clone());
587                    ctx.footnote_numbers.insert(label.clone(), next);
588                    next
589                }
590            };
591
592            let count = ctx.footnote_ref_counts.entry(label.clone()).or_insert(0);
593            *count += 1;
594            let ref_id = if *count == 1 {
595                format!("fnref{}", n)
596            } else {
597                format!("fnref{}-{}", n, *count)
598            };
599
600            output.push_str("<sup class=\"footnote-ref\"><a href=\"#fn");
601            output.push_str(&n.to_string());
602            output.push_str("\" id=\"");
603            output.push_str(&escape_html(&ref_id));
604            output.push_str("\">");
605            output.push_str(&n.to_string());
606            output.push_str("</a></sup>");
607        }
608        NodeKind::Image { url, alt } => {
609            output.push_str("<img src=\"");
610            output.push_str(&escape_html(url));
611            output.push_str("\" alt=\"");
612            output.push_str(&escape_html(alt));
613            output.push_str("\" />");
614        }
615        NodeKind::InlineHtml(html) => {
616            // Pass through inline HTML directly (no escaping)
617            output.push_str(html);
618        }
619        NodeKind::HardBreak => {
620            // Hard line break: <br />
621            output.push_str("<br />\n");
622        }
623        NodeKind::SoftBreak => {
624            // Soft line break: rendered as single space (or newline in some contexts)
625            output.push('\n');
626        }
627        NodeKind::List {
628            ordered,
629            start,
630            tight,
631        } => {
632            // Render list opening tag
633            if *ordered {
634                output.push_str("<ol");
635                if let Some(num) = start {
636                    if *num != 1 {
637                        output.push_str(&format!(" start=\"{}\"", num));
638                    }
639                }
640                output.push_str(">\n");
641            } else {
642                output.push_str("<ul>\n");
643            }
644
645            // Render list items
646            for child in &node.children {
647                render_list_item(child, output, *tight, options, ctx)?;
648            }
649
650            // Render list closing tag
651            if *ordered {
652                output.push_str("</ol>\n");
653            } else {
654                output.push_str("</ul>\n");
655            }
656        }
657        NodeKind::DefinitionList => {
658            output.push_str("<dl>\n");
659            for child in &node.children {
660                render_node(child, output, options, ctx)?;
661            }
662            output.push_str("</dl>\n");
663        }
664        NodeKind::DefinitionTerm => {
665            output.push_str("<dt>");
666            for child in &node.children {
667                render_node(child, output, options, ctx)?;
668            }
669            output.push_str("</dt>\n");
670        }
671        NodeKind::DefinitionDescription => {
672            output.push_str("<dd>\n");
673            for child in &node.children {
674                render_node(child, output, options, ctx)?;
675            }
676            output.push_str("</dd>\n");
677        }
678        NodeKind::ListItem => {
679            // This should only be called via render_list_item
680            log::warn!("ListItem rendered outside of List context");
681            output.push_str("<li>");
682            for child in &node.children {
683                render_node(child, output, options, ctx)?;
684            }
685            output.push_str("</li>\n");
686        }
687        NodeKind::TaskCheckbox { .. } => {
688            // This should only be called via render_list_item (as a ListItem child).
689            log::warn!("TaskCheckbox rendered outside of ListItem context");
690        }
691        NodeKind::TaskCheckboxInline { checked } => {
692            // Inline checkbox marker (e.g. paragraph starting with `[ ]` / `[x]`).
693            render_task_checkbox_icon(output, *checked);
694        }
695        NodeKind::InlineMath { content } => {
696            // Render inline math using katex-rs
697            #[cfg(feature = "render-math")]
698            match render_inline_math(content) {
699                Ok(html) => output.push_str(&html),
700                Err(e) => {
701                    log::warn!("Math render error (inline): {}", e);
702                    // Fallback: show raw LaTeX in a code span
703                    output.push_str("<code class=\"math-error\" title=\"Failed to render math\">");
704                    output.push_str(&escape_html(content));
705                    output.push_str("</code>");
706                }
707            }
708            #[cfg(not(feature = "render-math"))]
709            {
710                output.push_str("<code class=\"math\">");
711                output.push_str(&escape_html(content));
712                output.push_str("</code>");
713            }
714        }
715        NodeKind::DisplayMath { content } => {
716            // Render display math using katex-rs
717            #[cfg(feature = "render-math")]
718            match render_display_math(content) {
719                Ok(html) => output.push_str(&html),
720                Err(e) => {
721                    log::warn!("Math render error (display): {}", e);
722                    // Fallback: show raw LaTeX in a pre block
723                    output.push_str("<pre class=\"math-error\" title=\"Failed to render math\">");
724                    output.push_str(&escape_html(content));
725                    output.push_str("</pre>");
726                }
727            }
728            #[cfg(not(feature = "render-math"))]
729            {
730                output.push_str("<pre class=\"math\"><code>");
731                output.push_str(&escape_html(content));
732                output.push_str("</code></pre>\n");
733            }
734        }
735        NodeKind::MermaidDiagram { content } => {
736            // Render Mermaid diagram using mermaid-rs-renderer with per-render-pass caching.
737            #[cfg(feature = "render-diagrams")]
738            {
739                let cache_key = (options.theme.clone(), content.clone());
740                let rendered = if let Some(cached) = ctx.mermaid_result_cache.get(&cache_key) {
741                    cached.clone()
742                } else {
743                    let fresh = match render_mermaid_diagram(content, &options.theme) {
744                        Ok(svg) => Ok(svg),
745                        Err(e) => Err(e.to_string()),
746                    };
747                    ctx.mermaid_result_cache.insert(cache_key, fresh.clone());
748                    fresh
749                };
750
751                match rendered {
752                    Ok(svg) => {
753                        output.push_str("<div class=\"marco-diagram\">");
754                        output.push_str(&svg);
755                        output.push_str("</div>\n");
756                    }
757                    Err(e) => {
758                        log::warn!("Mermaid render error: {}", e);
759                        // Fallback: show raw Mermaid in a code block
760                        let mut title = String::from("Failed to render diagram: ");
761                        let max_len = 160usize;
762                        if e.chars().count() > max_len {
763                            title.push_str(&e.chars().take(max_len).collect::<String>());
764                            title.push('…');
765                        } else {
766                            title.push_str(&e);
767                        }
768                        output.push_str("<pre class=\"mermaid-error\" title=\"");
769                        output.push_str(&escape_html(&title));
770                        output.push_str("\"><code>");
771                        output.push_str(&escape_html(content));
772                        output.push_str("</code></pre>\n");
773                    }
774                }
775            }
776            #[cfg(not(feature = "render-diagrams"))]
777            {
778                output.push_str("<pre class=\"mermaid\"><code>");
779                output.push_str(&escape_html(content));
780                output.push_str("</code></pre>\n");
781            }
782        }
783    }
784
785    Ok(())
786}
787
788fn admonition_presentation(kind: &AdmonitionKind) -> (&'static str, &'static str, &'static str) {
789    // Icons use `stroke="currentColor"` so theme CSS can color them by setting
790    // `color` on `.markdown-alert-title`.
791    match kind {
792        AdmonitionKind::Note => (
793            "note",
794            "Note",
795            concat!(
796                r#"<svg xmlns=""#,
797                "http",
798                r#"://www.w3.org/2000/svg"#,
799                r#"" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" focusable="false" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" /><path d="M12 9h.01" /><path d="M11 12h1v4h1" /></svg>"#,
800            ),
801        ),
802        AdmonitionKind::Tip => (
803            "tip",
804            "Tip",
805            concat!(
806                r#"<svg xmlns=""#,
807                "http",
808                r#"://www.w3.org/2000/svg"#,
809                r#"" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" focusable="false" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15.02 19.52c-2.341 .736 -5 .606 -7.32 -.52l-4.7 1l1.3 -3.9c-2.324 -3.437 -1.426 -7.872 2.1 -10.374c3.526 -2.501 8.59 -2.296 11.845 .48c1.649 1.407 2.575 3.253 2.742 5.152" /><path d="M19 22v.01" /><path d="M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483" /></svg>"#,
810            ),
811        ),
812        AdmonitionKind::Important => (
813            "important",
814            "Important",
815            concat!(
816                r#"<svg xmlns=""#,
817                "http",
818                r#"://www.w3.org/2000/svg"#,
819                r#"" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" focusable="false" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 9h8" /><path d="M8 13h6" /><path d="M15 18l-3 3l-3 -3h-3a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v5.5" /><path d="M19 16v3" /><path d="M19 22v.01" /></svg>"#,
820            ),
821        ),
822        AdmonitionKind::Warning => (
823            "warning",
824            "Warning",
825            concat!(
826                r#"<svg xmlns=""#,
827                "http",
828                r#"://www.w3.org/2000/svg"#,
829                r#"" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" focusable="false" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.363 3.591l-8.106 13.534a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636 -2.87l-8.106 -13.536a1.914 1.914 0 0 0 -3.274 0" /><path d="M12 9h.01" /><path d="M11 12h1v4h1" /></svg>"#,
830            ),
831        ),
832        AdmonitionKind::Caution => (
833            "caution",
834            "Caution",
835            concat!(
836                r#"<svg xmlns=""#,
837                "http",
838                r#"://www.w3.org/2000/svg"#,
839                r#"" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" focusable="false" aria-hidden="true"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19.875 6.27c.7 .398 1.13 1.143 1.125 1.948v7.284c0 .809 -.443 1.555 -1.158 1.948l-6.75 4.27a2.269 2.269 0 0 1 -2.184 0l-6.75 -4.27a2.225 2.225 0 0 1 -1.158 -1.948v-7.285c0 -.809 .443 -1.554 1.158 -1.947l6.75 -3.98a2.33 2.33 0 0 1 2.25 0l6.75 3.98h-.033" /><path d="M12 8v4" /><path d="M12 16h.01" /></svg>"#,
840            ),
841        ),
842    }
843}
844
845fn render_tab_group(
846    node: &Node,
847    output: &mut String,
848    options: &RenderOptions,
849    ctx: &mut RenderContext<'_>,
850) -> Result<(), Box<dyn std::error::Error>> {
851    // Assign a stable sequential id for this render pass.
852    let group_id = ctx.tab_group_counter;
853    ctx.tab_group_counter = ctx.tab_group_counter.saturating_add(1);
854
855    // Collect tab items in order.
856    let mut items: Vec<(&str, &Node)> = Vec::new();
857    for child in &node.children {
858        if let NodeKind::TabItem { title } = &child.kind {
859            items.push((title.as_str(), child));
860        } else {
861            log::warn!("Unexpected child inside TabGroup: {:?}", child.kind);
862        }
863    }
864
865    if items.is_empty() {
866        return Ok(());
867    }
868
869    output.push_str("<div class=\"marco-tabs\">\n");
870
871    // Radio inputs must come before the tablist/panels so generic nth-of-type CSS rules work.
872    for (i, (title, _item_node)) in items.iter().enumerate() {
873        output.push_str("<input class=\"marco-tabs__radio\" type=\"radio\" name=\"marco-tabs-");
874        output.push_str(&group_id.to_string());
875        output.push_str("\" id=\"marco-tabs-");
876        output.push_str(&group_id.to_string());
877        output.push('-');
878        output.push_str(&i.to_string());
879        output.push_str("\" aria-label=\"");
880        output.push_str(&escape_html(title));
881        output.push('"');
882        if i == 0 {
883            output.push_str(" checked");
884        }
885        output.push_str(" />\n");
886    }
887
888    output.push_str("<div class=\"marco-tabs__tablist\">\n");
889    for (i, (title, _item_node)) in items.iter().enumerate() {
890        output.push_str("<label class=\"marco-tabs__tab\" for=\"marco-tabs-");
891        output.push_str(&group_id.to_string());
892        output.push('-');
893        output.push_str(&i.to_string());
894        output.push_str("\">");
895        output.push_str(&escape_html(title));
896        output.push_str("</label>\n");
897    }
898    output.push_str("</div>\n");
899
900    output.push_str("<div class=\"marco-tabs__panels\">\n");
901    for &(_title, item_node) in items.iter() {
902        output.push_str("<div class=\"marco-tabs__panel\">\n");
903        for panel_child in &item_node.children {
904            render_node(panel_child, output, options, ctx)?;
905        }
906        output.push_str("</div>\n");
907    }
908    output.push_str("</div>\n");
909
910    output.push_str("</div>\n");
911    Ok(())
912}
913
914fn render_slider_deck(
915    node: &Node,
916    output: &mut String,
917    options: &RenderOptions,
918    ctx: &mut RenderContext<'_>,
919) -> Result<(), Box<dyn std::error::Error>> {
920    let timer_seconds = match &node.kind {
921        NodeKind::SliderDeck { timer_seconds } => *timer_seconds,
922        other => {
923            log::warn!(
924                "render_slider_deck called with non SliderDeck node: {:?}",
925                other
926            );
927            return Ok(());
928        }
929    };
930
931    // Assign a stable sequential id for this render pass.
932    let deck_id = ctx.slider_deck_counter;
933    ctx.slider_deck_counter = ctx.slider_deck_counter.saturating_add(1);
934
935    // Collect slides in order.
936    let mut slides: Vec<(bool, &Node)> = Vec::new();
937    for child in &node.children {
938        if let NodeKind::Slide { vertical } = &child.kind {
939            slides.push((*vertical, child));
940        } else {
941            log::warn!("Unexpected child inside SliderDeck: {:?}", child.kind);
942        }
943    }
944
945    if slides.is_empty() {
946        return Ok(());
947    }
948
949    output.push_str("<div class=\"marco-sliders\" id=\"marco-sliders-");
950    output.push_str(&deck_id.to_string());
951    output.push('"');
952    if let Some(seconds) = timer_seconds {
953        output.push_str(" data-timer-seconds=\"");
954        output.push_str(&seconds.to_string());
955        output.push('"');
956    }
957    output.push('>');
958
959    output.push_str("<div class=\"marco-sliders__viewport\">");
960    for (i, (vertical, slide_node)) in slides.iter().enumerate() {
961        output.push_str("<section class=\"marco-sliders__slide");
962        if i == 0 {
963            output.push_str(" is-active");
964        }
965        output.push_str("\" data-slide-index=\"");
966        output.push_str(&i.to_string());
967        output.push('"');
968        // The `--` separator sets `vertical: true` in the AST and the attribute is
969        // written here for future use, but the JS/CSS in preview_document.rs does not
970        // consume `data-vertical` — all slides behave as horizontal slides for now.
971        // When vertical navigation is added, the preview JS will need a 2D index model
972        // (column × row) and separate prev/next axis controls.
973        if *vertical {
974            output.push_str(" data-vertical=\"true\"");
975        }
976        output.push_str(">\n");
977        for child in &slide_node.children {
978            render_node(child, output, options, ctx)?;
979        }
980        output.push_str("</section>\n");
981    }
982    output.push_str("</div>");
983
984    // Controls: prev / play-pause / next
985    output.push_str("<div class=\"marco-sliders__controls\" aria-label=\"Slideshow controls\">");
986
987    output.push_str(
988        "<button class=\"marco-sliders__btn marco-sliders__btn--prev\" type=\"button\" data-action=\"prev\" aria-label=\"Previous slide\">",
989    );
990    output.push_str(SLIDER_ARROW_LEFT_SVG);
991    output.push_str("</button>");
992
993    output.push_str(
994        "<button class=\"marco-sliders__btn marco-sliders__btn--toggle\" type=\"button\" data-action=\"toggle\" aria-label=\"Toggle autoplay\">",
995    );
996    output.push_str("<span class=\"marco-sliders__icon marco-sliders__icon--play\">");
997    output.push_str(SLIDER_PLAY_SVG);
998    output.push_str("</span>");
999    output.push_str("<span class=\"marco-sliders__icon marco-sliders__icon--pause\">");
1000    output.push_str(SLIDER_PAUSE_SVG);
1001    output.push_str("</span>");
1002    output.push_str("</button>");
1003
1004    output.push_str(
1005        "<button class=\"marco-sliders__btn marco-sliders__btn--next\" type=\"button\" data-action=\"next\" aria-label=\"Next slide\">",
1006    );
1007    output.push_str(SLIDER_ARROW_RIGHT_SVG);
1008    output.push_str("</button>");
1009
1010    output.push_str("</div>");
1011
1012    // Dots navigation
1013    output.push_str(
1014        "<div class=\"marco-sliders__dots\" role=\"tablist\" aria-label=\"Slideshow navigation\">",
1015    );
1016    for i in 0..slides.len() {
1017        output.push_str("<button class=\"marco-sliders__dot");
1018        if i == 0 {
1019            output.push_str(" is-active");
1020        }
1021        output.push_str("\" type=\"button\" data-action=\"goto\" data-index=\"");
1022        output.push_str(&i.to_string());
1023        output.push_str("\" aria-label=\"Go to slide ");
1024        output.push_str(&(i + 1).to_string());
1025        output.push('"');
1026        if i == 0 {
1027            output.push_str(" aria-selected=\"true\"");
1028        }
1029        output.push_str(">\n");
1030        output
1031            .push_str("<span class=\"marco-sliders__dot-icon marco-sliders__dot-icon--inactive\">");
1032        output.push_str(SLIDER_DOT_INACTIVE_SVG);
1033        output.push_str("</span>");
1034        output.push_str("<span class=\"marco-sliders__dot-icon marco-sliders__dot-icon--active\">");
1035        output.push_str(SLIDER_DOT_ACTIVE_SVG);
1036        output.push_str("</span>");
1037        output.push_str("</button>");
1038    }
1039    output.push_str("</div>");
1040
1041    output.push_str("</div>\n");
1042    Ok(())
1043}
1044
1045fn render_task_checkbox_icon(output: &mut String, checked: bool) {
1046    // We keep the SVG strokes as `currentColor` and let CSS decide:
1047    // - unchecked box: inherited text color
1048    // - checked box: theme primary
1049    // - checkmark: theme accent
1050    if checked {
1051        output.push_str(
1052            r#"<span class="marco-marco-marco-task-checkbox checked" aria-hidden="true">"#,
1053        );
1054        output.push_str(
1055            concat!(
1056                r#"<svg xmlns=""#,
1057                "http",
1058                r#"://www.w3.org/2000/svg"#,
1059                r#"" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="marco-task-icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path class="marco-task-check" style="stroke: var(--mc-task-accent); stroke-width: 2.0;" d="M9 11l3 3l8 -8" /><path class="marco-task-box" style="stroke: var(--mc-task-primary);" d="M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14" /></svg>"#,
1060            ),
1061        );
1062        output.push_str("</span>");
1063    } else {
1064        output.push_str(
1065            r#"<span class="marco-marco-marco-task-checkbox unchecked" aria-hidden="true">"#,
1066        );
1067        output.push_str(
1068            concat!(
1069                r#"<svg xmlns=""#,
1070                "http",
1071                r#"://www.w3.org/2000/svg"#,
1072                r#"" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="marco-task-icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path class="marco-task-box" d="M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14" /></svg>"#,
1073            ),
1074        );
1075        output.push_str("</span>");
1076    }
1077}
1078
1079fn render_table(
1080    node: &Node,
1081    output: &mut String,
1082    options: &RenderOptions,
1083    ctx: &mut RenderContext<'_>,
1084) -> Result<(), Box<dyn std::error::Error>> {
1085    output.push_str("<table>\n");
1086
1087    let mut header_rows: Vec<&Node> = Vec::new();
1088    let mut body_rows: Vec<&Node> = Vec::new();
1089
1090    for row in &node.children {
1091        match row.kind {
1092            NodeKind::TableRow { header: true } => header_rows.push(row),
1093            NodeKind::TableRow { header: false } => body_rows.push(row),
1094            _ => {
1095                log::warn!("Unexpected child inside Table: {:?}", row.kind);
1096            }
1097        }
1098    }
1099
1100    if !header_rows.is_empty() {
1101        output.push_str("<thead>\n");
1102        for row in header_rows {
1103            render_table_row(row, output, options, ctx)?;
1104            output.push('\n');
1105        }
1106        output.push_str("</thead>\n");
1107    }
1108
1109    if !body_rows.is_empty() {
1110        output.push_str("<tbody>\n");
1111        for row in body_rows {
1112            render_table_row(row, output, options, ctx)?;
1113            output.push('\n');
1114        }
1115        output.push_str("</tbody>\n");
1116    }
1117
1118    output.push_str("</table>\n");
1119    Ok(())
1120}
1121
1122fn render_table_row(
1123    node: &Node,
1124    output: &mut String,
1125    options: &RenderOptions,
1126    ctx: &mut RenderContext<'_>,
1127) -> Result<(), Box<dyn std::error::Error>> {
1128    output.push_str("<tr>");
1129    for cell in &node.children {
1130        render_table_cell(cell, output, options, ctx)?;
1131    }
1132    output.push_str("</tr>");
1133    Ok(())
1134}
1135
1136fn render_table_cell(
1137    node: &Node,
1138    output: &mut String,
1139    options: &RenderOptions,
1140    ctx: &mut RenderContext<'_>,
1141) -> Result<(), Box<dyn std::error::Error>> {
1142    let (is_header, alignment) = match &node.kind {
1143        NodeKind::TableCell { header, alignment } => (*header, *alignment),
1144        _ => {
1145            log::warn!("Unexpected child inside TableRow: {:?}", node.kind);
1146            (false, crate::parser::ast::TableAlignment::None)
1147        }
1148    };
1149
1150    let tag = if is_header { "th" } else { "td" };
1151    output.push('<');
1152    output.push_str(tag);
1153
1154    if let Some(style_value) = alignment_to_css(alignment) {
1155        output.push_str(" style=\"");
1156        output.push_str(style_value);
1157        output.push('"');
1158    }
1159
1160    output.push('>');
1161    for child in &node.children {
1162        render_node(child, output, options, ctx)?;
1163    }
1164    output.push_str("</");
1165    output.push_str(tag);
1166    output.push('>');
1167    Ok(())
1168}
1169
1170fn alignment_to_css(alignment: crate::parser::ast::TableAlignment) -> Option<&'static str> {
1171    match alignment {
1172        crate::parser::ast::TableAlignment::None => None,
1173        crate::parser::ast::TableAlignment::Left => Some("text-align: left;"),
1174        crate::parser::ast::TableAlignment::Center => Some("text-align: center;"),
1175        crate::parser::ast::TableAlignment::Right => Some("text-align: right;"),
1176    }
1177}
1178
1179// Render a list item with proper tight/loose handling
1180fn render_list_item(
1181    node: &Node,
1182    output: &mut String,
1183    tight: bool,
1184    options: &RenderOptions,
1185    ctx: &mut RenderContext<'_>,
1186) -> Result<(), Box<dyn std::error::Error>> {
1187    let task_checked = match node.children.first().map(|n| &n.kind) {
1188        Some(NodeKind::TaskCheckbox { checked }) => Some(*checked),
1189        _ => None,
1190    };
1191
1192    if let Some(checked) = task_checked {
1193        if checked {
1194            output.push_str("<li class=\"marco-task-list-item marco-task-list-item--checked\">");
1195        } else {
1196            output.push_str("<li class=\"marco-task-list-item\">");
1197        }
1198    } else {
1199        output.push_str("<li>");
1200    }
1201
1202    // A task-list item with nested block elements (e.g. a sub-list) needs a
1203    // content wrapper <div> so those blocks flow in a single flex column
1204    // beside the checkbox icon, not as a second row-sibling flex item.
1205    // Simple items (text only) skip the wrapper to keep the output minimal.
1206    let needs_wrapper = task_checked.is_some()
1207        && node
1208            .children
1209            .iter()
1210            .any(|c| !matches!(c.kind, NodeKind::TaskCheckbox { .. } | NodeKind::Paragraph));
1211
1212    if tight {
1213        // Tight list: paragraph content is inlined (no <p> wrapper), so we can
1214        // safely emit the checkbox icon at the start of the list item.
1215        if let Some(checked) = task_checked {
1216            render_task_checkbox_icon(output, checked);
1217        }
1218        if needs_wrapper {
1219            output.push_str("<div class=\"marco-task-content\">");
1220        }
1221
1222        // Tight list: don't wrap paragraphs in <p> tags
1223        for child in &node.children {
1224            if matches!(child.kind, NodeKind::TaskCheckbox { .. }) {
1225                continue;
1226            }
1227            match &child.kind {
1228                NodeKind::Paragraph => {
1229                    // Render paragraph children directly without <p> wrapper
1230                    for grandchild in &child.children {
1231                        render_node(grandchild, output, options, ctx)?;
1232                    }
1233                }
1234                _ => {
1235                    // Other block elements render normally
1236                    render_node(child, output, options, ctx)?;
1237                }
1238            }
1239        }
1240
1241        if needs_wrapper {
1242            output.push_str("</div>");
1243        }
1244    } else {
1245        // Loose list: keep paragraphs wrapped in <p>, but for task list items we
1246        // want the checkbox icon to sit inline with the first paragraph's text.
1247        let mut checkbox_emitted = false;
1248
1249        if needs_wrapper {
1250            // Emit checkbox before the wrapper so it stays as its own flex item.
1251            if let Some(checked) = task_checked {
1252                render_task_checkbox_icon(output, checked);
1253                checkbox_emitted = true;
1254            }
1255            output.push_str("<div class=\"marco-task-content\">");
1256        }
1257
1258        for child in &node.children {
1259            if matches!(child.kind, NodeKind::TaskCheckbox { .. }) {
1260                continue;
1261            }
1262
1263            // Emit the checkbox exactly once (when not already done above),
1264            // either inside the first paragraph or as a standalone prefix.
1265            if let Some(checked) = task_checked {
1266                if !checkbox_emitted {
1267                    match &child.kind {
1268                        NodeKind::Paragraph => {
1269                            output.push_str("<p>");
1270                            render_task_checkbox_icon(output, checked);
1271                            for grandchild in &child.children {
1272                                render_node(grandchild, output, options, ctx)?;
1273                            }
1274                            output.push_str("</p>");
1275                            checkbox_emitted = true;
1276                            continue;
1277                        }
1278                        _ => {
1279                            render_task_checkbox_icon(output, checked);
1280                            checkbox_emitted = true;
1281                            // fall through and render this child normally
1282                        }
1283                    }
1284                }
1285            }
1286
1287            render_node(child, output, options, ctx)?;
1288        }
1289
1290        if needs_wrapper {
1291            output.push_str("</div>");
1292        }
1293    }
1294
1295    output.push_str("</li>\n");
1296    Ok(())
1297}
1298
1299// Escape HTML special characters to prevent XSS and ensure proper display
1300fn escape_html(text: &str) -> String {
1301    text.chars()
1302        .map(|c| match c {
1303            '&' => "&amp;".to_string(),
1304            '<' => "&lt;".to_string(),
1305            '>' => "&gt;".to_string(),
1306            '"' => "&quot;".to_string(),
1307            '\'' => "&#39;".to_string(),
1308            _ => c.to_string(),
1309        })
1310        .collect()
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315    use super::*;
1316    use crate::parser::ast::TableAlignment;
1317    use crate::parser::{Document, Node, NodeKind};
1318
1319    #[test]
1320    fn warm_render_thread_pool_is_callable_and_render_still_works() {
1321        // Present regardless of features (no-op without `parallel-render`);
1322        // callers shouldn't need their own #[cfg] just to call this.
1323        warm_render_thread_pool(&["rust", "python"]);
1324        warm_render_thread_pool(&[]); // repeat calls, empty list, must stay harmless
1325
1326        let doc = Document {
1327            children: vec![Node {
1328                kind: NodeKind::Paragraph,
1329                span: None,
1330                children: vec![Node {
1331                    kind: NodeKind::Text("still works after warmup".to_string()),
1332                    span: None,
1333                    children: vec![],
1334                }],
1335            }],
1336            ..Default::default()
1337        };
1338        let result = render_html(&doc, &RenderOptions::default()).unwrap();
1339        assert!(result.contains("still works after warmup"));
1340    }
1341
1342    #[test]
1343    fn smoke_test_escape_html_basic() {
1344        let input = "Hello <world> & \"friends\"";
1345        let expected = "Hello &lt;world&gt; &amp; &quot;friends&quot;";
1346        assert_eq!(escape_html(input), expected);
1347    }
1348
1349    #[test]
1350    fn smoke_test_escape_html_script_tag() {
1351        let input = "<script>alert('XSS')</script>";
1352        let expected = "&lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;";
1353        assert_eq!(escape_html(input), expected);
1354    }
1355
1356    #[test]
1357    fn smoke_test_render_heading_h1() {
1358        let doc = Document {
1359            children: vec![Node {
1360                kind: NodeKind::Heading {
1361                    level: 1,
1362                    text: "Hello World".to_string(),
1363                    id: None,
1364                },
1365                span: None,
1366                children: vec![],
1367            }],
1368            ..Default::default()
1369        };
1370        let options = RenderOptions::default();
1371        let result = render_html(&doc, &options).unwrap();
1372        // All headings now get an auto-generated id for TOC anchor navigation.
1373        assert!(result.contains("<h1 id=\"hello-world\">"));
1374        assert!(result.contains("Hello World"));
1375        assert!(result.contains("class=\"marco-heading-anchor\""));
1376        assert!(result.contains("href=\"#hello-world\""));
1377    }
1378
1379    #[test]
1380    fn smoke_test_render_heading_with_html() {
1381        let doc = Document {
1382            children: vec![Node {
1383                kind: NodeKind::Heading {
1384                    level: 2,
1385                    text: "Code <example> & test".to_string(),
1386                    id: None,
1387                },
1388                span: None,
1389                children: vec![],
1390            }],
1391            ..Default::default()
1392        };
1393        let options = RenderOptions::default();
1394        let result = render_html(&doc, &options).unwrap();
1395        // Heading text is HTML-escaped; id is the slug of the unescaped text.
1396        assert!(result.contains("<h2 id=\"code-example-test\">"));
1397        assert!(result.contains("Code &lt;example&gt; &amp; test"));
1398    }
1399
1400    #[test]
1401    fn smoke_test_render_paragraph_with_text() {
1402        let doc = Document {
1403            children: vec![Node {
1404                kind: NodeKind::Paragraph,
1405                span: None,
1406                children: vec![Node {
1407                    kind: NodeKind::Text("This is a paragraph.".to_string()),
1408                    span: None,
1409                    children: vec![],
1410                }],
1411            }],
1412            ..Default::default()
1413        };
1414        let options = RenderOptions::default();
1415        let result = render_html(&doc, &options).unwrap();
1416        assert_eq!(result, "<p>This is a paragraph.</p>\n");
1417    }
1418
1419    #[test]
1420    fn smoke_test_render_code_block_without_language() {
1421        let doc = Document {
1422            children: vec![Node {
1423                kind: NodeKind::CodeBlock {
1424                    language: None,
1425                    code: "fn main() {\n    println!(\"Hello\");\n}".to_string(),
1426                },
1427                span: None,
1428                children: vec![],
1429            }],
1430            ..Default::default()
1431        };
1432        let options = RenderOptions::default();
1433        let result = render_html(&doc, &options).unwrap();
1434        // Should contain wrapper div and copy button
1435        assert!(result.contains("<div class=\"marco-code-block\">"));
1436        assert!(result.contains("<button class=\"marco-copy-btn\""));
1437        assert!(result.contains("icon-tabler-copy"));
1438        assert!(result
1439            .contains("<pre><code>fn main() {\n    println!(&quot;Hello&quot;);\n}</code></pre>"));
1440        assert!(result.contains("</div>\n"));
1441    }
1442
1443    #[test]
1444    fn smoke_test_render_code_block_with_language() {
1445        let doc = Document {
1446            children: vec![Node {
1447                kind: NodeKind::CodeBlock {
1448                    language: Some("rust".to_string()),
1449                    code: "let x = 42;".to_string(),
1450                },
1451                span: None,
1452                children: vec![],
1453            }],
1454            ..Default::default()
1455        };
1456        let options = RenderOptions {
1457            syntax_highlighting: false,
1458            ..RenderOptions::default()
1459        };
1460        let result = render_html(&doc, &options).unwrap();
1461        // Should contain wrapper div, copy button, and language attribute
1462        assert!(result.contains("<div class=\"marco-code-block\">"));
1463        assert!(result.contains("<button class=\"marco-copy-btn\""));
1464        assert!(result.contains(
1465            "<pre data-language=\"Rust\"><code class=\"language-rust\">let x = 42;</code></pre>"
1466        ));
1467        assert!(result.contains("</div>\n"));
1468    }
1469
1470    #[test]
1471    fn smoke_test_render_code_block_escapes_html() {
1472        let doc = Document {
1473            children: vec![Node {
1474                kind: NodeKind::CodeBlock {
1475                    language: Some("html".to_string()),
1476                    code: "<div>Test & verify</div>".to_string(),
1477                },
1478                span: None,
1479                children: vec![],
1480            }],
1481            ..Default::default()
1482        };
1483        let options = RenderOptions {
1484            syntax_highlighting: false,
1485            ..RenderOptions::default()
1486        };
1487        let result = render_html(&doc, &options).unwrap();
1488        // Should contain wrapper, copy button, and properly escaped HTML
1489        assert!(result.contains("<div class=\"marco-code-block\">"));
1490        assert!(result.contains("<button class=\"marco-copy-btn\""));
1491        assert!(result.contains("<pre data-language=\"HTML\"><code class=\"language-html\">&lt;div&gt;Test &amp; verify&lt;/div&gt;</code></pre>"));
1492        assert!(result.contains("</div>\n"));
1493    }
1494
1495    #[test]
1496    fn smoke_test_render_code_span() {
1497        let doc = Document {
1498            children: vec![Node {
1499                kind: NodeKind::Paragraph,
1500                span: None,
1501                children: vec![
1502                    Node {
1503                        kind: NodeKind::Text("Use ".to_string()),
1504                        span: None,
1505                        children: vec![],
1506                    },
1507                    Node {
1508                        kind: NodeKind::CodeSpan("println!()".to_string()),
1509                        span: None,
1510                        children: vec![],
1511                    },
1512                    Node {
1513                        kind: NodeKind::Text(" for output.".to_string()),
1514                        span: None,
1515                        children: vec![],
1516                    },
1517                ],
1518            }],
1519            ..Default::default()
1520        };
1521        let options = RenderOptions::default();
1522        let result = render_html(&doc, &options).unwrap();
1523        assert_eq!(result, "<p>Use <code>println!()</code> for output.</p>\n");
1524    }
1525
1526    #[test]
1527    fn smoke_test_render_mixed_inlines() {
1528        let doc = Document {
1529            children: vec![
1530                Node {
1531                    kind: NodeKind::Heading {
1532                        level: 1,
1533                        text: "Title".to_string(),
1534                        id: None,
1535                    },
1536                    span: None,
1537                    children: vec![],
1538                },
1539                Node {
1540                    kind: NodeKind::Paragraph,
1541                    span: None,
1542                    children: vec![Node {
1543                        kind: NodeKind::Text("Some text.".to_string()),
1544                        span: None,
1545                        children: vec![],
1546                    }],
1547                },
1548                Node {
1549                    kind: NodeKind::CodeBlock {
1550                        language: Some("python".to_string()),
1551                        code: "print('hello')".to_string(),
1552                    },
1553                    span: None,
1554                    children: vec![],
1555                },
1556            ],
1557            ..Default::default()
1558        };
1559        let options = RenderOptions {
1560            syntax_highlighting: false,
1561            ..RenderOptions::default()
1562        };
1563        let result = render_html(&doc, &options).unwrap();
1564        // Should contain heading (now with auto-slug id), paragraph, and code block with wrapper
1565        assert!(result.contains("<h1 id=\"title\">"));
1566        assert!(result.contains("<p>Some text.</p>\n"));
1567        assert!(result.contains("<div class=\"marco-code-block\">"));
1568        assert!(result.contains("<button class=\"marco-copy-btn\""));
1569        assert!(result.contains("<pre data-language=\"Python\"><code class=\"language-python\">print(&#39;hello&#39;)</code></pre>"));
1570        assert!(result.contains("</div>\n"));
1571    }
1572
1573    #[test]
1574    fn smoke_test_render_strong_emphasis() {
1575        let doc = Document {
1576            children: vec![Node {
1577                kind: NodeKind::Paragraph,
1578                span: None,
1579                children: vec![Node {
1580                    kind: NodeKind::StrongEmphasis,
1581                    span: None,
1582                    children: vec![Node {
1583                        kind: NodeKind::Text("bold+italic".to_string()),
1584                        span: None,
1585                        children: vec![],
1586                    }],
1587                }],
1588            }],
1589            ..Default::default()
1590        };
1591
1592        let options = RenderOptions::default();
1593        let result = render_html(&doc, &options).unwrap();
1594        assert_eq!(result, "<p><em><strong>bold+italic</strong></em></p>\n");
1595    }
1596
1597    #[test]
1598    fn smoke_test_render_strike_mark_sup_sub() {
1599        let doc = Document {
1600            children: vec![Node {
1601                kind: NodeKind::Paragraph,
1602                span: None,
1603                children: vec![
1604                    Node {
1605                        kind: NodeKind::Strikethrough,
1606                        span: None,
1607                        children: vec![Node {
1608                            kind: NodeKind::Text("del".to_string()),
1609                            span: None,
1610                            children: vec![],
1611                        }],
1612                    },
1613                    Node {
1614                        kind: NodeKind::Text(" ".to_string()),
1615                        span: None,
1616                        children: vec![],
1617                    },
1618                    Node {
1619                        kind: NodeKind::Mark,
1620                        span: None,
1621                        children: vec![Node {
1622                            kind: NodeKind::Text("mark".to_string()),
1623                            span: None,
1624                            children: vec![],
1625                        }],
1626                    },
1627                    Node {
1628                        kind: NodeKind::Text(" ".to_string()),
1629                        span: None,
1630                        children: vec![],
1631                    },
1632                    Node {
1633                        kind: NodeKind::Superscript,
1634                        span: None,
1635                        children: vec![Node {
1636                            kind: NodeKind::Text("sup".to_string()),
1637                            span: None,
1638                            children: vec![],
1639                        }],
1640                    },
1641                    Node {
1642                        kind: NodeKind::Text(" ".to_string()),
1643                        span: None,
1644                        children: vec![],
1645                    },
1646                    Node {
1647                        kind: NodeKind::Subscript,
1648                        span: None,
1649                        children: vec![Node {
1650                            kind: NodeKind::Text("sub".to_string()),
1651                            span: None,
1652                            children: vec![],
1653                        }],
1654                    },
1655                ],
1656            }],
1657            ..Default::default()
1658        };
1659
1660        let options = RenderOptions::default();
1661        let result = render_html(&doc, &options).unwrap();
1662        assert_eq!(
1663            result,
1664            "<p><del>del</del> <mark>mark</mark> <sup>sup</sup> <sub>sub</sub></p>\n"
1665        );
1666    }
1667
1668    #[test]
1669    fn smoke_test_render_table_with_alignment() {
1670        let doc = Document {
1671            children: vec![Node {
1672                kind: NodeKind::Table {
1673                    alignments: vec![TableAlignment::Left, TableAlignment::Center],
1674                },
1675                span: None,
1676                children: vec![
1677                    Node {
1678                        kind: NodeKind::TableRow { header: true },
1679                        span: None,
1680                        children: vec![
1681                            Node {
1682                                kind: NodeKind::TableCell {
1683                                    header: true,
1684                                    alignment: TableAlignment::Left,
1685                                },
1686                                span: None,
1687                                children: vec![Node {
1688                                    kind: NodeKind::Text("h1".to_string()),
1689                                    span: None,
1690                                    children: vec![],
1691                                }],
1692                            },
1693                            Node {
1694                                kind: NodeKind::TableCell {
1695                                    header: true,
1696                                    alignment: TableAlignment::Center,
1697                                },
1698                                span: None,
1699                                children: vec![Node {
1700                                    kind: NodeKind::Text("h2".to_string()),
1701                                    span: None,
1702                                    children: vec![],
1703                                }],
1704                            },
1705                        ],
1706                    },
1707                    Node {
1708                        kind: NodeKind::TableRow { header: false },
1709                        span: None,
1710                        children: vec![
1711                            Node {
1712                                kind: NodeKind::TableCell {
1713                                    header: false,
1714                                    alignment: TableAlignment::Left,
1715                                },
1716                                span: None,
1717                                children: vec![Node {
1718                                    kind: NodeKind::Text("c1".to_string()),
1719                                    span: None,
1720                                    children: vec![],
1721                                }],
1722                            },
1723                            Node {
1724                                kind: NodeKind::TableCell {
1725                                    header: false,
1726                                    alignment: TableAlignment::Center,
1727                                },
1728                                span: None,
1729                                children: vec![Node {
1730                                    kind: NodeKind::Text("c2".to_string()),
1731                                    span: None,
1732                                    children: vec![],
1733                                }],
1734                            },
1735                        ],
1736                    },
1737                ],
1738            }],
1739            ..Default::default()
1740        };
1741
1742        let options = RenderOptions::default();
1743        let result = render_html(&doc, &options).expect("render failed");
1744
1745        assert!(result.contains("<table>"));
1746        assert!(result.contains("<thead>"));
1747        assert!(result.contains("<tbody>"));
1748        assert!(result.contains("<th style=\"text-align: left;\">h1</th>"));
1749        assert!(result.contains("<th style=\"text-align: center;\">h2</th>"));
1750        assert!(result.contains("<td style=\"text-align: left;\">c1</td>"));
1751        assert!(result.contains("<td style=\"text-align: center;\">c2</td>"));
1752    }
1753
1754    #[test]
1755    fn smoke_image_as_link() {
1756        // `[![alt](img)](url)` must render as `<a href="url"><img .../></a>`, not broken.
1757        let input =
1758            "[![Marco Logo](https://example.com/logo.png)](https://github.com/Ranrar/Marco)\n";
1759        let doc = crate::parser::parse(input).expect("parse failed");
1760        let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1761            .expect("render failed");
1762        assert!(
1763            html.contains("<a href=\"https://github.com/Ranrar/Marco\"><img"),
1764            "image-as-link must render as <a><img/></a>, got: {}",
1765            html
1766        );
1767    }
1768
1769    #[test]
1770    fn smoke_hard_break_backslash() {
1771        // Backslash + newline → <br />
1772        let input = "Hello\\\nworld\n";
1773        let doc = crate::parser::parse(input).expect("parse failed");
1774        let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1775            .expect("render failed");
1776        assert!(
1777            html.contains("<br"),
1778            "backslash hard break should render <br />, got: {}",
1779            html
1780        );
1781    }
1782
1783    #[test]
1784    fn smoke_hard_break_two_spaces() {
1785        // Two trailing spaces + newline → <br /> (used by Shift+Enter in editor)
1786        let input = "Hello  \nworld\n";
1787        let doc = crate::parser::parse(input).expect("parse failed");
1788        let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1789            .expect("render failed");
1790        assert!(
1791            html.contains("<br"),
1792            "two-space hard break should render <br />, got: {}",
1793            html
1794        );
1795    }
1796
1797    #[test]
1798    fn smoke_hard_break_three_spaces() {
1799        // Three trailing spaces must also produce a clean <br /> with no stray space before it.
1800        let input = "Hello   \nworld\n";
1801        let doc = crate::parser::parse(input).expect("parse failed");
1802        let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1803            .expect("render failed");
1804        assert!(
1805            html.contains("<br"),
1806            "three-space hard break should render <br />, got: {}",
1807            html
1808        );
1809        // Must not produce a stray space text node before <br />
1810        assert!(
1811            !html.contains("Hello <br"),
1812            "three-space hard break should not leave a stray space before <br />, got: {}",
1813            html
1814        );
1815    }
1816
1817    #[test]
1818    fn smoke_nbsp_spacer_paragraph() {
1819        // Shift+Enter inserts "\u{00A0}\n\n" — a non-breaking space on its own line.
1820        // Rust's trim() does NOT strip \u{00A0} (only strips ASCII whitespace),
1821        // so the block parser accepts it as a real paragraph.
1822        // The rendered <p> has CSS line-height height → visible spacer in preview.
1823        let input = "before\n\n\u{00A0}\n\nafter\n";
1824        let doc = crate::parser::parse(input).expect("parse failed");
1825        let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
1826            .expect("render failed");
1827        // Must have a paragraph containing the nbsp character (as literal or escaped)
1828        let has_nbsp_para =
1829            html.contains("\u{00A0}") || html.contains("&#xa0;") || html.contains("&#160;");
1830        assert!(
1831            has_nbsp_para,
1832            "nbsp spacer paragraph must appear in HTML output, got: {}",
1833            html
1834        );
1835        // Must still have both surrounding paragraphs
1836        assert!(
1837            html.contains(">before<"),
1838            "before paragraph must be present, got: {}",
1839            html
1840        );
1841        assert!(
1842            html.contains(">after<"),
1843            "after paragraph must be present, got: {}",
1844            html
1845        );
1846    }
1847
1848    /// Regression test for the Phase 3 (`parallel-render`) node-address-keyed
1849    /// highlight cache in `precompute_highlights`. Footnote definitions
1850    /// render in first-reference order at the end of the document, which can
1851    /// differ from their order in the tree — this is the exact mismatch that
1852    /// motivated keying the cache by node address instead of traversal
1853    /// position/index (see the "parser-render-optimization-plan.md" Phase 3
1854    /// notes). Only compiled when `parallel-render` is enabled, since that's
1855    /// the only configuration where `precomputed_highlights` is ever `Some`
1856    /// and this cache is actually exercised.
1857    #[cfg(all(feature = "render-syntax-highlighting", feature = "parallel-render"))]
1858    #[test]
1859    fn parallel_render_matches_each_code_block_to_its_own_node_across_footnote_reorder() {
1860        let top_code = "fn top() -> i32 { 1 }";
1861        let a_code = "def a():\n    return 1\n";
1862        let b_code = "function b() { return 2; }";
1863
1864        // Tree order defines footnote "a" before "b", but the paragraph
1865        // references "b" before "a" — so the footnotes section (rendered in
1866        // first-reference order) emits "b" before "a", the reverse of tree
1867        // order. `precompute_highlights` walks the tree once (a-then-b
1868        // order); `render_node` walks it again but emits the footnotes
1869        // section in b-then-a order. Node-address keying must still resolve
1870        // each code block to its own highlighted result despite that.
1871        let doc = Document {
1872            children: vec![
1873                Node {
1874                    kind: NodeKind::Paragraph,
1875                    span: None,
1876                    children: vec![
1877                        Node {
1878                            kind: NodeKind::FootnoteReference {
1879                                label: "b".to_string(),
1880                            },
1881                            span: None,
1882                            children: vec![],
1883                        },
1884                        Node {
1885                            kind: NodeKind::FootnoteReference {
1886                                label: "a".to_string(),
1887                            },
1888                            span: None,
1889                            children: vec![],
1890                        },
1891                    ],
1892                },
1893                Node {
1894                    kind: NodeKind::CodeBlock {
1895                        language: Some("rust".to_string()),
1896                        code: top_code.to_string(),
1897                    },
1898                    span: None,
1899                    children: vec![],
1900                },
1901                Node {
1902                    kind: NodeKind::FootnoteDefinition {
1903                        label: "a".to_string(),
1904                    },
1905                    span: None,
1906                    children: vec![Node {
1907                        kind: NodeKind::CodeBlock {
1908                            language: Some("python".to_string()),
1909                            code: a_code.to_string(),
1910                        },
1911                        span: None,
1912                        children: vec![],
1913                    }],
1914                },
1915                Node {
1916                    kind: NodeKind::FootnoteDefinition {
1917                        label: "b".to_string(),
1918                    },
1919                    span: None,
1920                    children: vec![Node {
1921                        kind: NodeKind::CodeBlock {
1922                            language: Some("javascript".to_string()),
1923                            code: b_code.to_string(),
1924                        },
1925                        span: None,
1926                        children: vec![],
1927                    }],
1928                },
1929            ],
1930            ..Default::default()
1931        };
1932
1933        let options = RenderOptions::default();
1934        let result = render_html(&doc, &options).unwrap();
1935
1936        // Independently highlight each snippet via the same underlying
1937        // function the parallel path calls, to build the expected fragment
1938        // for each node without hardcoding syntect's exact class output.
1939        let top_html = highlight_code_to_classed_html(top_code, "rust")
1940            .expect("rust snippet should highlight");
1941        let a_html = highlight_code_to_classed_html(a_code, "python")
1942            .expect("python snippet should highlight");
1943        let b_html = highlight_code_to_classed_html(b_code, "javascript")
1944            .expect("javascript snippet should highlight");
1945
1946        assert!(
1947            result.contains(&top_html),
1948            "top-level rust code block should carry its own highlighted HTML"
1949        );
1950        assert!(
1951            result.contains(&a_html),
1952            "footnote \"a\"'s python code block should carry its own \
1953             highlighted HTML, not another block's"
1954        );
1955        assert!(
1956            result.contains(&b_html),
1957            "footnote \"b\"'s javascript code block should carry its own \
1958             highlighted HTML, not another block's"
1959        );
1960
1961        // And the footnote reordering itself must still hold: "b" (first
1962        // referenced) before "a", even though "a" was collected first by
1963        // `precompute_highlights`'s tree walk.
1964        let b_pos = result
1965            .find(&b_html)
1966            .expect("b highlighted fragment present");
1967        let a_pos = result
1968            .find(&a_html)
1969            .expect("a highlighted fragment present");
1970        assert!(
1971            b_pos < a_pos,
1972            "footnote \"b\" should render before footnote \"a\" \
1973             (first-reference order) while each keeps its own code block's \
1974             highlighting"
1975        );
1976    }
1977}