use super::code_languages::language_display_label;
#[cfg(feature = "render-diagrams")]
use super::diagram::render_mermaid_diagram;
#[cfg(feature = "render-math")]
use super::math::{render_display_math, render_inline_math};
use super::plarform_mentions;
#[cfg(feature = "render-syntax-highlighting")]
use super::syntect_highlighter::highlight_code_to_classed_html;
use super::RenderOptions;
use crate::parser::{AdmonitionKind, AdmonitionStyle, Document, Node, NodeKind};
use std::collections::HashMap;
// Code block copy button icon (Tabler icon-tabler-copy).
const 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>"#;
// Slide-deck UI icons (Tabler).
// These are embedded as inline SVG so they inherit `currentColor`.
const 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>"#;
const 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>"#;
const 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>"#;
const 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>"#;
const 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>"#;
const 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>"#;
#[derive(Default)]
struct RenderContext<'a> {
footnote_defs: HashMap<String, &'a Node>,
footnote_numbers: HashMap<String, usize>,
footnote_order: Vec<String>,
footnote_ref_counts: HashMap<String, usize>,
tab_group_counter: usize,
slider_deck_counter: usize,
#[cfg(feature = "render-diagrams")]
mermaid_result_cache: HashMap<(String, String), Result<String, String>>,
/// Tracks how many times each slug base has been used so duplicates get
/// a `-1`, `-2`, … suffix — matching the same logic in `intelligence::toc`.
heading_slug_counts: HashMap<String, usize>,
/// Syntax-highlight results computed up front (see `precompute_highlights`),
/// keyed by the `CodeBlock` node's own address — stable for the
/// lifetime of one `render_html` call since `document` is never mutated
/// in between, and immune to traversal-order concerns (footnote
/// definitions render in a different order than they appear in the
/// tree). `None` means no precomputed cache is installed — the
/// `parallel-render` feature is disabled — and `render_node` falls back
/// to computing highlights synchronously, unchanged from before this
/// cache existed.
#[cfg(feature = "render-syntax-highlighting")]
precomputed_highlights: Option<HashMap<usize, String>>,
}
/// Render a parsed Markdown document into HTML.
pub fn render_html(
document: &Document,
options: &RenderOptions,
) -> Result<String, Box<dyn std::error::Error>> {
log::debug!("Rendering {} nodes to HTML", document.len());
// Estimate output size: typical HTML is ~1.5–2× the AST node count × avg node bytes.
// Seeding with a modest non-zero capacity avoids the first few reallocations for free.
let estimated = document.children.len() * 64;
let mut html = String::with_capacity(estimated.max(256));
let mut ctx = RenderContext::default();
for node in &document.children {
collect_footnote_definitions(node, &mut ctx.footnote_defs);
}
#[cfg(all(feature = "render-syntax-highlighting", feature = "parallel-render"))]
{
ctx.precomputed_highlights = Some(precompute_highlights(document, options));
}
for node in &document.children {
render_node(node, &mut html, options, &mut ctx)?;
}
if !ctx.footnote_order.is_empty() {
html.push_str("<section class=\"footnotes\">\n");
html.push_str("<ol>\n");
let mut i = 0usize;
while i < ctx.footnote_order.len() {
let label = ctx.footnote_order[i].clone();
let Some(n) = ctx.footnote_numbers.get(&label).copied() else {
i += 1;
continue;
};
let Some(def_node) = ctx.footnote_defs.get(&label).copied() else {
i += 1;
continue;
};
html.push_str("<li id=\"fn");
html.push_str(&n.to_string());
html.push_str("\">");
for child in &def_node.children {
render_node(child, &mut html, options, &mut ctx)?;
}
html.push_str("</li>\n");
i += 1;
}
html.push_str("</ol>\n");
html.push_str("</section>\n");
}
Ok(html)
}
fn collect_footnote_definitions<'a>(node: &'a Node, defs: &mut HashMap<String, &'a Node>) {
if let NodeKind::FootnoteDefinition { label } = &node.kind {
defs.entry(label.clone()).or_insert(node);
}
for child in &node.children {
collect_footnote_definitions(child, defs);
}
}
/// Fan out per-`CodeBlock` syntax highlighting across cores with rayon,
/// computing every eligible block's result up front. Highlighting is a
/// pure function of `(code, lang)` with no shared mutable state between
/// blocks, so this is safe.
///
/// Results are keyed by each `CodeBlock` node's own address rather than by
/// traversal position: footnote definitions render in a different order
/// (deferred to the end, in first-reference order — see `render_html`)
/// than they appear in the tree, so a position/index-based cache would
/// require this collection pass to exactly replicate that reordering to
/// stay in sync. Node addresses sidestep that entirely — every node here
/// and in `render_node` refers to the same never-mutated `document`, so an
/// address collected here is guaranteed to match the same node seen later,
/// regardless of which order either pass visits things in.
#[cfg(all(feature = "render-syntax-highlighting", feature = "parallel-render"))]
fn precompute_highlights(document: &Document, options: &RenderOptions) -> HashMap<usize, String> {
use rayon::prelude::*;
fn collect<'a>(
node: &'a Node,
options: &RenderOptions,
targets: &mut Vec<(usize, &'a str, &'a str)>,
) {
if let NodeKind::CodeBlock { language, code } = &node.kind {
if options.syntax_highlighting {
let language_raw = language.as_deref().map(str::trim).filter(|s| !s.is_empty());
if let Some(lang) = language_raw {
targets.push((std::ptr::from_ref(node) as usize, code.as_str(), lang));
}
}
}
for child in &node.children {
collect(child, options, targets);
}
}
let mut targets = Vec::new();
for node in &document.children {
collect(node, options, &mut targets);
}
targets
.par_iter()
.filter_map(|(key, code, lang)| {
highlight_code_to_classed_html(code, lang).map(|html| (*key, html))
})
.collect()
}
/// Eagerly initializes state that `parallel-render` otherwise sets up
/// lazily on first use: rayon's global thread pool, syntect's default
/// syntax/theme sets, and — for each language in `languages` — syntect's
/// internal per-language setup.
///
/// The `languages` list matters more than it might look: measured directly
/// (16-core machine, the `fixture:large/code-heavy.md` perf-lab fixture,
/// mixed rust/python/javascript), warming *only* the thread pool made no
/// measurable difference to the first render (~20ms either way — thread
/// spawn itself is not the bottleneck here, contrary to an earlier draft of
/// this function that assumed it was). What did measurably help was also
/// pre-highlighting the specific languages the fixture actually uses:
/// first-render time dropped from ~20.4ms to ~14.1ms (~31%). A
/// language-blind warm-up can't buy that on its own, because it doesn't
/// know which languages your documents contain — pass the ones you expect
/// (e.g. your editor's supported-language list, or languages seen in the
/// user's recently opened files) to get the real benefit. An empty slice
/// still warms the thread pool and syntect's defaults, which is a small,
/// generically-safe win, just not the dominant one.
///
/// Note there is a residual first-render cost (~6-7ms in the same
/// measurement) that persists even after warming the pool, syntect
/// defaults, *and* every language actually used — this looks like
/// first-touch allocation/OS-level warm-up rather than anything this crate
/// controls, and no combination of arguments to this function removes it.
///
/// Call this during application startup, before the first document is
/// opened (e.g. off a splash screen or init routine), to move whichever
/// part of that cost *can* be moved off a user-visible render. This is
/// purely a matter of *when* the cost is paid: if you never call this,
/// rendering still works correctly and everything still initializes
/// lazily and correctly on first use (the default "warm on first request"
/// behavior) — this function does not change behavior or output, only
/// timing.
///
/// A no-op when `parallel-render` is not compiled in, so it's always safe
/// to call unconditionally regardless of which features a consumer builds
/// with.
#[cfg(feature = "parallel-render")]
pub fn warm_render_thread_pool(languages: &[&str]) {
// `rayon::join`/`par_iter` on trivial work can return before every
// worker OS thread has actually been spawned, so it doesn't reliably
// move that part of the cost earlier. `build_global` spawns the
// configured worker threads synchronously as part of the call. `Err`
// just means some other code already installed a global pool (e.g. a
// previous call to this function) — harmless.
let _ = rayon::ThreadPoolBuilder::new().build_global();
#[cfg(feature = "render-syntax-highlighting")]
for lang in languages {
let _ = highlight_code_to_classed_html(" ", lang);
}
#[cfg(not(feature = "render-syntax-highlighting"))]
let _ = languages;
}
/// See the `parallel-render`-enabled version of this function for the full
/// explanation. Without that feature there is nothing to warm, so this is
/// a no-op — kept so callers don't need their own `#[cfg]` around a call to
/// this at startup.
#[cfg(not(feature = "parallel-render"))]
pub fn warm_render_thread_pool(_languages: &[&str]) {}
// Render individual node
fn render_node(
node: &Node,
output: &mut String,
options: &RenderOptions,
ctx: &mut RenderContext<'_>,
) -> Result<(), Box<dyn std::error::Error>> {
match &node.kind {
NodeKind::Heading { level, text, id } => {
log::trace!("Rendering heading level {}", level);
let escaped_text = escape_html(text);
// Use explicit {#id} when present; otherwise auto-derive from heading text.
// Duplicate slugs get a -1, -2, … suffix (same algorithm as `intelligence::toc`).
let effective_id = if let Some(explicit_id) = id {
explicit_id.clone()
} else {
let base = crate::intelligence::toc::heading_slug(text);
let count = ctx.heading_slug_counts.entry(base.clone()).or_insert(0);
let slug = if *count == 0 {
base.clone()
} else {
format!("{}-{}", base, count)
};
*count += 1;
slug
};
// level is 1–6; render digit as char to avoid a heap allocation.
let level_char = char::from_digit(*level as u32, 10).unwrap_or('1');
let escaped_id = escape_html(&effective_id);
output.push_str("<h");
output.push(level_char);
output.push_str(" id=\"");
output.push_str(&escaped_id);
output.push_str("\">");
// Wrap heading text in a self-anchor so the whole heading is clickable.
output.push_str("<a class=\"marco-heading-anchor\" href=\"#");
output.push_str(&escaped_id);
output.push_str("\" aria-label=\"Link to this heading\">");
output.push_str(&escaped_text);
output.push_str("</a>");
output.push_str("</h");
output.push(level_char);
output.push_str(">\n");
}
NodeKind::Paragraph => {
output.push_str("<p>");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</p>\n");
}
NodeKind::CodeBlock { language, code } => {
log::trace!("Rendering code block: {:?}", language);
let language_raw = language.as_deref().map(str::trim).filter(|s| !s.is_empty());
// Wrap code block in a container for copy button positioning
output.push_str("<div class=\"marco-code-block\">");
// Add copy button
output.push_str("<button class=\"marco-copy-btn\" data-action=\"copy\" aria-label=\"Copy code\" title=\"Copy code\">");
output.push_str(CODE_BLOCK_COPY_SVG);
output.push_str("</button>");
output.push_str("<pre");
if let Some(raw) = language_raw {
if let Some(label) = language_display_label(raw) {
output.push_str(" data-language=\"");
output.push_str(&escape_html(label.as_ref()));
output.push('"');
}
}
output.push_str("><code");
// Add language class attribute if language specified
if let Some(lang) = language_raw {
output.push_str(" class=\"language-");
output.push_str(&escape_html(lang));
output.push('"');
}
output.push('>');
// Optional syntax highlighting. If syntect can't resolve the language,
// fall back to plain escaped code. When the `parallel-render`
// cache is installed (see `precompute_highlights`), use its
// precomputed result instead of highlighting synchronously here
// — falls back to the original direct call when no cache is
// installed (feature disabled), unchanged from before.
#[cfg(feature = "render-syntax-highlighting")]
if options.syntax_highlighting {
if let Some(lang) = language_raw {
let highlighted = match ctx.precomputed_highlights.as_ref() {
Some(cache) => cache.get(&(std::ptr::from_ref(node) as usize)).cloned(),
None => highlight_code_to_classed_html(code, lang),
};
if let Some(highlighted) = highlighted {
output.push_str(&highlighted);
output.push_str("</code></pre>");
output.push_str("</div>\n");
return Ok(());
}
}
}
output.push_str(&escape_html(code));
output.push_str("</code></pre>");
output.push_str("</div>\n");
}
NodeKind::ThematicBreak => {
output.push_str("<hr />\n");
}
NodeKind::HtmlBlock { html } => {
// HTML blocks are rendered as-is without escaping
// They already contain the complete HTML including tags
output.push_str(html);
if !html.ends_with('\n') {
output.push('\n');
}
}
NodeKind::Blockquote => {
output.push_str("<blockquote>\n");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</blockquote>\n");
}
NodeKind::Admonition {
kind,
title,
icon,
style,
} => {
let (slug, default_title, icon_svg) = admonition_presentation(kind);
let title_text = title.as_deref().unwrap_or(default_title);
// Render with both GitHub-compatible classes (`markdown-alert`) and
// our theme-compatible classes (`admonition`).
//
// Quote-style admonitions intentionally omit the `*-<kind>` classes
// so themes keep neutral/blockquote-like colors.
output.push_str("<div class=\"");
output.push_str("markdown-alert");
if *style == AdmonitionStyle::Alert {
output.push_str(" markdown-alert-");
output.push_str(slug);
}
output.push_str(" admonition");
if *style == AdmonitionStyle::Alert {
output.push_str(" admonition-");
output.push_str(slug);
} else {
output.push_str(" admonition-quote");
}
output.push_str("\">\n");
output.push_str("<p class=\"markdown-alert-title\">");
output.push_str("<span class=\"markdown-alert-icon\" aria-hidden=\"true\">");
if let Some(icon_text) = icon {
// Icon is text (typically emoji). Use an inner span so themes can
// override line-height without affecting SVG icons.
output.push_str("<span class=\"markdown-alert-emoji\">");
output.push_str(&escape_html(icon_text));
output.push_str("</span>");
} else {
output.push_str(icon_svg);
}
output.push_str("</span>");
output.push_str(&escape_html(title_text));
output.push_str("</p>\n");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</div>\n");
}
NodeKind::TabGroup => {
render_tab_group(node, output, options, ctx)?;
}
NodeKind::TabItem { .. } => {
// Tab items should be rendered via `render_tab_group` so we can
// coordinate radio inputs/labels/panels.
log::warn!("TabItem rendered outside of TabGroup context");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
}
NodeKind::SliderDeck { .. } => {
render_slider_deck(node, output, options, ctx)?;
}
NodeKind::Slide { .. } => {
// Slides should be rendered via `render_slider_deck` so we can
// coordinate controls and timers.
log::warn!("Slide rendered outside of SliderDeck context");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
}
NodeKind::Table { .. } => {
render_table(node, output, options, ctx)?;
}
NodeKind::TableRow { .. } => {
// Tables should be rendered via `render_table` so we can decide
// whether a row belongs in <thead> or <tbody>.
log::warn!("TableRow rendered outside of Table context");
render_table_row(node, output, options, ctx)?;
output.push('\n');
}
NodeKind::TableCell { .. } => {
// Cells should be rendered via `render_table_row`.
log::warn!("TableCell rendered outside of TableRow context");
render_table_cell(node, output, options, ctx)?;
}
NodeKind::FootnoteDefinition { .. } => {
// Footnote definitions are rendered in a dedicated section at the
// end of the document.
}
NodeKind::Text(text) => {
output.push_str(&escape_html(text));
}
NodeKind::CodeSpan(code) => {
output.push_str("<code>");
output.push_str(&escape_html(code));
output.push_str("</code>");
}
NodeKind::Emphasis => {
output.push_str("<em>");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</em>");
}
NodeKind::Strong => {
output.push_str("<strong>");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</strong>");
}
NodeKind::StrongEmphasis => {
// Triple delimiter: emphasis wrapping strong, e.g. `***foo***` ->
// `<em><strong>foo</strong></em>` (CommonMark spec example 467).
output.push_str("<em><strong>");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</strong></em>");
}
NodeKind::Strikethrough => {
output.push_str("<del>");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</del>");
}
NodeKind::Mark => {
output.push_str("<mark>");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</mark>");
}
NodeKind::Superscript => {
output.push_str("<sup>");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</sup>");
}
NodeKind::Subscript => {
output.push_str("<sub>");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</sub>");
}
NodeKind::Link { url, title } => {
output.push_str("<a href=\"");
output.push_str(&escape_html(url));
output.push('"');
if let Some(t) = title {
output.push_str(" title=\"");
output.push_str(&escape_html(t));
output.push('"');
}
output.push('>');
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</a>");
}
NodeKind::PlatformMention {
username,
platform,
display,
} => {
let label = display.as_deref().unwrap_or(username);
let platform_key = platform.trim().to_ascii_lowercase();
if let Some(url) = plarform_mentions::profile_url(&platform_key, username) {
output.push_str("<a class=\"marco-mention marco-mention-");
output.push_str(&escape_html(&platform_key));
output.push_str("\" href=\"");
output.push_str(&escape_html(&url));
output.push_str("\">");
output.push_str(&escape_html(label));
output.push_str("</a>");
} else {
output.push_str("<span class=\"marco-mention marco-mention-unknown\">");
output.push_str(&escape_html(label));
output.push_str("</span>");
}
}
NodeKind::LinkReference { suffix, .. } => {
// Reference links should normally be resolved during parsing.
// If a reference is missing, or a caller bypasses the resolver,
// render the original source-ish form as literal text.
output.push('[');
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push(']');
output.push_str(&escape_html(suffix));
}
NodeKind::FootnoteReference { label } => {
if !ctx.footnote_defs.contains_key(label) {
output.push_str("[^");
output.push_str(&escape_html(label));
output.push(']');
// Missing definition: keep the literal source form.
return Ok(());
}
let n = match ctx.footnote_numbers.get(label) {
Some(n) => *n,
None => {
let next = ctx.footnote_order.len() + 1;
ctx.footnote_order.push(label.clone());
ctx.footnote_numbers.insert(label.clone(), next);
next
}
};
let count = ctx.footnote_ref_counts.entry(label.clone()).or_insert(0);
*count += 1;
let ref_id = if *count == 1 {
format!("fnref{}", n)
} else {
format!("fnref{}-{}", n, *count)
};
output.push_str("<sup class=\"footnote-ref\"><a href=\"#fn");
output.push_str(&n.to_string());
output.push_str("\" id=\"");
output.push_str(&escape_html(&ref_id));
output.push_str("\">");
output.push_str(&n.to_string());
output.push_str("</a></sup>");
}
NodeKind::Image { url, alt } => {
output.push_str("<img src=\"");
output.push_str(&escape_html(url));
output.push_str("\" alt=\"");
output.push_str(&escape_html(alt));
output.push_str("\" />");
}
NodeKind::InlineHtml(html) => {
// Pass through inline HTML directly (no escaping)
output.push_str(html);
}
NodeKind::HardBreak => {
// Hard line break: <br />
output.push_str("<br />\n");
}
NodeKind::SoftBreak => {
// Soft line break: rendered as single space (or newline in some contexts)
output.push('\n');
}
NodeKind::List {
ordered,
start,
tight,
} => {
// Render list opening tag
if *ordered {
output.push_str("<ol");
if let Some(num) = start {
if *num != 1 {
output.push_str(&format!(" start=\"{}\"", num));
}
}
output.push_str(">\n");
} else {
output.push_str("<ul>\n");
}
// Render list items
for child in &node.children {
render_list_item(child, output, *tight, options, ctx)?;
}
// Render list closing tag
if *ordered {
output.push_str("</ol>\n");
} else {
output.push_str("</ul>\n");
}
}
NodeKind::DefinitionList => {
output.push_str("<dl>\n");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</dl>\n");
}
NodeKind::DefinitionTerm => {
output.push_str("<dt>");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</dt>\n");
}
NodeKind::DefinitionDescription => {
output.push_str("<dd>\n");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</dd>\n");
}
NodeKind::ListItem => {
// This should only be called via render_list_item
log::warn!("ListItem rendered outside of List context");
output.push_str("<li>");
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</li>\n");
}
NodeKind::TaskCheckbox { .. } => {
// This should only be called via render_list_item (as a ListItem child).
log::warn!("TaskCheckbox rendered outside of ListItem context");
}
NodeKind::TaskCheckboxInline { checked } => {
// Inline checkbox marker (e.g. paragraph starting with `[ ]` / `[x]`).
render_task_checkbox_icon(output, *checked);
}
NodeKind::InlineMath { content } => {
// Render inline math using katex-rs
#[cfg(feature = "render-math")]
match render_inline_math(content) {
Ok(html) => output.push_str(&html),
Err(e) => {
log::warn!("Math render error (inline): {}", e);
// Fallback: show raw LaTeX in a code span
output.push_str("<code class=\"math-error\" title=\"Failed to render math\">");
output.push_str(&escape_html(content));
output.push_str("</code>");
}
}
#[cfg(not(feature = "render-math"))]
{
output.push_str("<code class=\"math\">");
output.push_str(&escape_html(content));
output.push_str("</code>");
}
}
NodeKind::DisplayMath { content } => {
// Render display math using katex-rs
#[cfg(feature = "render-math")]
match render_display_math(content) {
Ok(html) => output.push_str(&html),
Err(e) => {
log::warn!("Math render error (display): {}", e);
// Fallback: show raw LaTeX in a pre block
output.push_str("<pre class=\"math-error\" title=\"Failed to render math\">");
output.push_str(&escape_html(content));
output.push_str("</pre>");
}
}
#[cfg(not(feature = "render-math"))]
{
output.push_str("<pre class=\"math\"><code>");
output.push_str(&escape_html(content));
output.push_str("</code></pre>\n");
}
}
NodeKind::MermaidDiagram { content } => {
// Render Mermaid diagram using mermaid-rs-renderer with per-render-pass caching.
#[cfg(feature = "render-diagrams")]
{
let cache_key = (options.theme.clone(), content.clone());
let rendered = if let Some(cached) = ctx.mermaid_result_cache.get(&cache_key) {
cached.clone()
} else {
let fresh = match render_mermaid_diagram(content, &options.theme) {
Ok(svg) => Ok(svg),
Err(e) => Err(e.to_string()),
};
ctx.mermaid_result_cache.insert(cache_key, fresh.clone());
fresh
};
match rendered {
Ok(svg) => {
output.push_str("<div class=\"marco-diagram\">");
output.push_str(&svg);
output.push_str("</div>\n");
}
Err(e) => {
log::warn!("Mermaid render error: {}", e);
// Fallback: show raw Mermaid in a code block
let mut title = String::from("Failed to render diagram: ");
let max_len = 160usize;
if e.chars().count() > max_len {
title.push_str(&e.chars().take(max_len).collect::<String>());
title.push('…');
} else {
title.push_str(&e);
}
output.push_str("<pre class=\"mermaid-error\" title=\"");
output.push_str(&escape_html(&title));
output.push_str("\"><code>");
output.push_str(&escape_html(content));
output.push_str("</code></pre>\n");
}
}
}
#[cfg(not(feature = "render-diagrams"))]
{
output.push_str("<pre class=\"mermaid\"><code>");
output.push_str(&escape_html(content));
output.push_str("</code></pre>\n");
}
}
}
Ok(())
}
fn admonition_presentation(kind: &AdmonitionKind) -> (&'static str, &'static str, &'static str) {
// Icons use `stroke="currentColor"` so theme CSS can color them by setting
// `color` on `.markdown-alert-title`.
match kind {
AdmonitionKind::Note => (
"note",
"Note",
concat!(
r#"<svg xmlns=""#,
"http",
r#"://www.w3.org/2000/svg"#,
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>"#,
),
),
AdmonitionKind::Tip => (
"tip",
"Tip",
concat!(
r#"<svg xmlns=""#,
"http",
r#"://www.w3.org/2000/svg"#,
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>"#,
),
),
AdmonitionKind::Important => (
"important",
"Important",
concat!(
r#"<svg xmlns=""#,
"http",
r#"://www.w3.org/2000/svg"#,
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>"#,
),
),
AdmonitionKind::Warning => (
"warning",
"Warning",
concat!(
r#"<svg xmlns=""#,
"http",
r#"://www.w3.org/2000/svg"#,
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>"#,
),
),
AdmonitionKind::Caution => (
"caution",
"Caution",
concat!(
r#"<svg xmlns=""#,
"http",
r#"://www.w3.org/2000/svg"#,
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>"#,
),
),
}
}
fn render_tab_group(
node: &Node,
output: &mut String,
options: &RenderOptions,
ctx: &mut RenderContext<'_>,
) -> Result<(), Box<dyn std::error::Error>> {
// Assign a stable sequential id for this render pass.
let group_id = ctx.tab_group_counter;
ctx.tab_group_counter = ctx.tab_group_counter.saturating_add(1);
// Collect tab items in order.
let mut items: Vec<(&str, &Node)> = Vec::new();
for child in &node.children {
if let NodeKind::TabItem { title } = &child.kind {
items.push((title.as_str(), child));
} else {
log::warn!("Unexpected child inside TabGroup: {:?}", child.kind);
}
}
if items.is_empty() {
return Ok(());
}
output.push_str("<div class=\"marco-tabs\">\n");
// Radio inputs must come before the tablist/panels so generic nth-of-type CSS rules work.
for (i, (title, _item_node)) in items.iter().enumerate() {
output.push_str("<input class=\"marco-tabs__radio\" type=\"radio\" name=\"marco-tabs-");
output.push_str(&group_id.to_string());
output.push_str("\" id=\"marco-tabs-");
output.push_str(&group_id.to_string());
output.push('-');
output.push_str(&i.to_string());
output.push_str("\" aria-label=\"");
output.push_str(&escape_html(title));
output.push('"');
if i == 0 {
output.push_str(" checked");
}
output.push_str(" />\n");
}
output.push_str("<div class=\"marco-tabs__tablist\">\n");
for (i, (title, _item_node)) in items.iter().enumerate() {
output.push_str("<label class=\"marco-tabs__tab\" for=\"marco-tabs-");
output.push_str(&group_id.to_string());
output.push('-');
output.push_str(&i.to_string());
output.push_str("\">");
output.push_str(&escape_html(title));
output.push_str("</label>\n");
}
output.push_str("</div>\n");
output.push_str("<div class=\"marco-tabs__panels\">\n");
for &(_title, item_node) in items.iter() {
output.push_str("<div class=\"marco-tabs__panel\">\n");
for panel_child in &item_node.children {
render_node(panel_child, output, options, ctx)?;
}
output.push_str("</div>\n");
}
output.push_str("</div>\n");
output.push_str("</div>\n");
Ok(())
}
fn render_slider_deck(
node: &Node,
output: &mut String,
options: &RenderOptions,
ctx: &mut RenderContext<'_>,
) -> Result<(), Box<dyn std::error::Error>> {
let timer_seconds = match &node.kind {
NodeKind::SliderDeck { timer_seconds } => *timer_seconds,
other => {
log::warn!(
"render_slider_deck called with non SliderDeck node: {:?}",
other
);
return Ok(());
}
};
// Assign a stable sequential id for this render pass.
let deck_id = ctx.slider_deck_counter;
ctx.slider_deck_counter = ctx.slider_deck_counter.saturating_add(1);
// Collect slides in order.
let mut slides: Vec<(bool, &Node)> = Vec::new();
for child in &node.children {
if let NodeKind::Slide { vertical } = &child.kind {
slides.push((*vertical, child));
} else {
log::warn!("Unexpected child inside SliderDeck: {:?}", child.kind);
}
}
if slides.is_empty() {
return Ok(());
}
output.push_str("<div class=\"marco-sliders\" id=\"marco-sliders-");
output.push_str(&deck_id.to_string());
output.push('"');
if let Some(seconds) = timer_seconds {
output.push_str(" data-timer-seconds=\"");
output.push_str(&seconds.to_string());
output.push('"');
}
output.push('>');
output.push_str("<div class=\"marco-sliders__viewport\">");
for (i, (vertical, slide_node)) in slides.iter().enumerate() {
output.push_str("<section class=\"marco-sliders__slide");
if i == 0 {
output.push_str(" is-active");
}
output.push_str("\" data-slide-index=\"");
output.push_str(&i.to_string());
output.push('"');
// The `--` separator sets `vertical: true` in the AST and the attribute is
// written here for future use, but the JS/CSS in preview_document.rs does not
// consume `data-vertical` — all slides behave as horizontal slides for now.
// When vertical navigation is added, the preview JS will need a 2D index model
// (column × row) and separate prev/next axis controls.
if *vertical {
output.push_str(" data-vertical=\"true\"");
}
output.push_str(">\n");
for child in &slide_node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</section>\n");
}
output.push_str("</div>");
// Controls: prev / play-pause / next
output.push_str("<div class=\"marco-sliders__controls\" aria-label=\"Slideshow controls\">");
output.push_str(
"<button class=\"marco-sliders__btn marco-sliders__btn--prev\" type=\"button\" data-action=\"prev\" aria-label=\"Previous slide\">",
);
output.push_str(SLIDER_ARROW_LEFT_SVG);
output.push_str("</button>");
output.push_str(
"<button class=\"marco-sliders__btn marco-sliders__btn--toggle\" type=\"button\" data-action=\"toggle\" aria-label=\"Toggle autoplay\">",
);
output.push_str("<span class=\"marco-sliders__icon marco-sliders__icon--play\">");
output.push_str(SLIDER_PLAY_SVG);
output.push_str("</span>");
output.push_str("<span class=\"marco-sliders__icon marco-sliders__icon--pause\">");
output.push_str(SLIDER_PAUSE_SVG);
output.push_str("</span>");
output.push_str("</button>");
output.push_str(
"<button class=\"marco-sliders__btn marco-sliders__btn--next\" type=\"button\" data-action=\"next\" aria-label=\"Next slide\">",
);
output.push_str(SLIDER_ARROW_RIGHT_SVG);
output.push_str("</button>");
output.push_str("</div>");
// Dots navigation
output.push_str(
"<div class=\"marco-sliders__dots\" role=\"tablist\" aria-label=\"Slideshow navigation\">",
);
for i in 0..slides.len() {
output.push_str("<button class=\"marco-sliders__dot");
if i == 0 {
output.push_str(" is-active");
}
output.push_str("\" type=\"button\" data-action=\"goto\" data-index=\"");
output.push_str(&i.to_string());
output.push_str("\" aria-label=\"Go to slide ");
output.push_str(&(i + 1).to_string());
output.push('"');
if i == 0 {
output.push_str(" aria-selected=\"true\"");
}
output.push_str(">\n");
output
.push_str("<span class=\"marco-sliders__dot-icon marco-sliders__dot-icon--inactive\">");
output.push_str(SLIDER_DOT_INACTIVE_SVG);
output.push_str("</span>");
output.push_str("<span class=\"marco-sliders__dot-icon marco-sliders__dot-icon--active\">");
output.push_str(SLIDER_DOT_ACTIVE_SVG);
output.push_str("</span>");
output.push_str("</button>");
}
output.push_str("</div>");
output.push_str("</div>\n");
Ok(())
}
fn render_task_checkbox_icon(output: &mut String, checked: bool) {
// We keep the SVG strokes as `currentColor` and let CSS decide:
// - unchecked box: inherited text color
// - checked box: theme primary
// - checkmark: theme accent
if checked {
output.push_str(
r#"<span class="marco-marco-marco-task-checkbox checked" aria-hidden="true">"#,
);
output.push_str(
concat!(
r#"<svg xmlns=""#,
"http",
r#"://www.w3.org/2000/svg"#,
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>"#,
),
);
output.push_str("</span>");
} else {
output.push_str(
r#"<span class="marco-marco-marco-task-checkbox unchecked" aria-hidden="true">"#,
);
output.push_str(
concat!(
r#"<svg xmlns=""#,
"http",
r#"://www.w3.org/2000/svg"#,
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>"#,
),
);
output.push_str("</span>");
}
}
fn render_table(
node: &Node,
output: &mut String,
options: &RenderOptions,
ctx: &mut RenderContext<'_>,
) -> Result<(), Box<dyn std::error::Error>> {
output.push_str("<table>\n");
let mut header_rows: Vec<&Node> = Vec::new();
let mut body_rows: Vec<&Node> = Vec::new();
for row in &node.children {
match row.kind {
NodeKind::TableRow { header: true } => header_rows.push(row),
NodeKind::TableRow { header: false } => body_rows.push(row),
_ => {
log::warn!("Unexpected child inside Table: {:?}", row.kind);
}
}
}
if !header_rows.is_empty() {
output.push_str("<thead>\n");
for row in header_rows {
render_table_row(row, output, options, ctx)?;
output.push('\n');
}
output.push_str("</thead>\n");
}
if !body_rows.is_empty() {
output.push_str("<tbody>\n");
for row in body_rows {
render_table_row(row, output, options, ctx)?;
output.push('\n');
}
output.push_str("</tbody>\n");
}
output.push_str("</table>\n");
Ok(())
}
fn render_table_row(
node: &Node,
output: &mut String,
options: &RenderOptions,
ctx: &mut RenderContext<'_>,
) -> Result<(), Box<dyn std::error::Error>> {
output.push_str("<tr>");
for cell in &node.children {
render_table_cell(cell, output, options, ctx)?;
}
output.push_str("</tr>");
Ok(())
}
fn render_table_cell(
node: &Node,
output: &mut String,
options: &RenderOptions,
ctx: &mut RenderContext<'_>,
) -> Result<(), Box<dyn std::error::Error>> {
let (is_header, alignment) = match &node.kind {
NodeKind::TableCell { header, alignment } => (*header, *alignment),
_ => {
log::warn!("Unexpected child inside TableRow: {:?}", node.kind);
(false, crate::parser::ast::TableAlignment::None)
}
};
let tag = if is_header { "th" } else { "td" };
output.push('<');
output.push_str(tag);
if let Some(style_value) = alignment_to_css(alignment) {
output.push_str(" style=\"");
output.push_str(style_value);
output.push('"');
}
output.push('>');
for child in &node.children {
render_node(child, output, options, ctx)?;
}
output.push_str("</");
output.push_str(tag);
output.push('>');
Ok(())
}
fn alignment_to_css(alignment: crate::parser::ast::TableAlignment) -> Option<&'static str> {
match alignment {
crate::parser::ast::TableAlignment::None => None,
crate::parser::ast::TableAlignment::Left => Some("text-align: left;"),
crate::parser::ast::TableAlignment::Center => Some("text-align: center;"),
crate::parser::ast::TableAlignment::Right => Some("text-align: right;"),
}
}
// Render a list item with proper tight/loose handling
fn render_list_item(
node: &Node,
output: &mut String,
tight: bool,
options: &RenderOptions,
ctx: &mut RenderContext<'_>,
) -> Result<(), Box<dyn std::error::Error>> {
let task_checked = match node.children.first().map(|n| &n.kind) {
Some(NodeKind::TaskCheckbox { checked }) => Some(*checked),
_ => None,
};
if let Some(checked) = task_checked {
if checked {
output.push_str("<li class=\"marco-task-list-item marco-task-list-item--checked\">");
} else {
output.push_str("<li class=\"marco-task-list-item\">");
}
} else {
output.push_str("<li>");
}
// A task-list item with nested block elements (e.g. a sub-list) needs a
// content wrapper <div> so those blocks flow in a single flex column
// beside the checkbox icon, not as a second row-sibling flex item.
// Simple items (text only) skip the wrapper to keep the output minimal.
let needs_wrapper = task_checked.is_some()
&& node
.children
.iter()
.any(|c| !matches!(c.kind, NodeKind::TaskCheckbox { .. } | NodeKind::Paragraph));
if tight {
// Tight list: paragraph content is inlined (no <p> wrapper), so we can
// safely emit the checkbox icon at the start of the list item.
if let Some(checked) = task_checked {
render_task_checkbox_icon(output, checked);
}
if needs_wrapper {
output.push_str("<div class=\"marco-task-content\">");
}
// Tight list: don't wrap paragraphs in <p> tags
for child in &node.children {
if matches!(child.kind, NodeKind::TaskCheckbox { .. }) {
continue;
}
match &child.kind {
NodeKind::Paragraph => {
// Render paragraph children directly without <p> wrapper
for grandchild in &child.children {
render_node(grandchild, output, options, ctx)?;
}
}
_ => {
// Other block elements render normally
render_node(child, output, options, ctx)?;
}
}
}
if needs_wrapper {
output.push_str("</div>");
}
} else {
// Loose list: keep paragraphs wrapped in <p>, but for task list items we
// want the checkbox icon to sit inline with the first paragraph's text.
let mut checkbox_emitted = false;
if needs_wrapper {
// Emit checkbox before the wrapper so it stays as its own flex item.
if let Some(checked) = task_checked {
render_task_checkbox_icon(output, checked);
checkbox_emitted = true;
}
output.push_str("<div class=\"marco-task-content\">");
}
for child in &node.children {
if matches!(child.kind, NodeKind::TaskCheckbox { .. }) {
continue;
}
// Emit the checkbox exactly once (when not already done above),
// either inside the first paragraph or as a standalone prefix.
if let Some(checked) = task_checked {
if !checkbox_emitted {
match &child.kind {
NodeKind::Paragraph => {
output.push_str("<p>");
render_task_checkbox_icon(output, checked);
for grandchild in &child.children {
render_node(grandchild, output, options, ctx)?;
}
output.push_str("</p>");
checkbox_emitted = true;
continue;
}
_ => {
render_task_checkbox_icon(output, checked);
checkbox_emitted = true;
// fall through and render this child normally
}
}
}
}
render_node(child, output, options, ctx)?;
}
if needs_wrapper {
output.push_str("</div>");
}
}
output.push_str("</li>\n");
Ok(())
}
// Escape HTML special characters to prevent XSS and ensure proper display
fn escape_html(text: &str) -> String {
text.chars()
.map(|c| match c {
'&' => "&".to_string(),
'<' => "<".to_string(),
'>' => ">".to_string(),
'"' => """.to_string(),
'\'' => "'".to_string(),
_ => c.to_string(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::ast::TableAlignment;
use crate::parser::{Document, Node, NodeKind};
#[test]
fn warm_render_thread_pool_is_callable_and_render_still_works() {
// Present regardless of features (no-op without `parallel-render`);
// callers shouldn't need their own #[cfg] just to call this.
warm_render_thread_pool(&["rust", "python"]);
warm_render_thread_pool(&[]); // repeat calls, empty list, must stay harmless
let doc = Document {
children: vec![Node {
kind: NodeKind::Paragraph,
span: None,
children: vec![Node {
kind: NodeKind::Text("still works after warmup".to_string()),
span: None,
children: vec![],
}],
}],
..Default::default()
};
let result = render_html(&doc, &RenderOptions::default()).unwrap();
assert!(result.contains("still works after warmup"));
}
#[test]
fn smoke_test_escape_html_basic() {
let input = "Hello <world> & \"friends\"";
let expected = "Hello <world> & "friends"";
assert_eq!(escape_html(input), expected);
}
#[test]
fn smoke_test_escape_html_script_tag() {
let input = "<script>alert('XSS')</script>";
let expected = "<script>alert('XSS')</script>";
assert_eq!(escape_html(input), expected);
}
#[test]
fn smoke_test_render_heading_h1() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Heading {
level: 1,
text: "Hello World".to_string(),
id: None,
},
span: None,
children: vec![],
}],
..Default::default()
};
let options = RenderOptions::default();
let result = render_html(&doc, &options).unwrap();
// All headings now get an auto-generated id for TOC anchor navigation.
assert!(result.contains("<h1 id=\"hello-world\">"));
assert!(result.contains("Hello World"));
assert!(result.contains("class=\"marco-heading-anchor\""));
assert!(result.contains("href=\"#hello-world\""));
}
#[test]
fn smoke_test_render_heading_with_html() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Heading {
level: 2,
text: "Code <example> & test".to_string(),
id: None,
},
span: None,
children: vec![],
}],
..Default::default()
};
let options = RenderOptions::default();
let result = render_html(&doc, &options).unwrap();
// Heading text is HTML-escaped; id is the slug of the unescaped text.
assert!(result.contains("<h2 id=\"code-example-test\">"));
assert!(result.contains("Code <example> & test"));
}
#[test]
fn smoke_test_render_paragraph_with_text() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Paragraph,
span: None,
children: vec![Node {
kind: NodeKind::Text("This is a paragraph.".to_string()),
span: None,
children: vec![],
}],
}],
..Default::default()
};
let options = RenderOptions::default();
let result = render_html(&doc, &options).unwrap();
assert_eq!(result, "<p>This is a paragraph.</p>\n");
}
#[test]
fn smoke_test_render_code_block_without_language() {
let doc = Document {
children: vec![Node {
kind: NodeKind::CodeBlock {
language: None,
code: "fn main() {\n println!(\"Hello\");\n}".to_string(),
},
span: None,
children: vec![],
}],
..Default::default()
};
let options = RenderOptions::default();
let result = render_html(&doc, &options).unwrap();
// Should contain wrapper div and copy button
assert!(result.contains("<div class=\"marco-code-block\">"));
assert!(result.contains("<button class=\"marco-copy-btn\""));
assert!(result.contains("icon-tabler-copy"));
assert!(result
.contains("<pre><code>fn main() {\n println!("Hello");\n}</code></pre>"));
assert!(result.contains("</div>\n"));
}
#[test]
fn smoke_test_render_code_block_with_language() {
let doc = Document {
children: vec![Node {
kind: NodeKind::CodeBlock {
language: Some("rust".to_string()),
code: "let x = 42;".to_string(),
},
span: None,
children: vec![],
}],
..Default::default()
};
let options = RenderOptions {
syntax_highlighting: false,
..RenderOptions::default()
};
let result = render_html(&doc, &options).unwrap();
// Should contain wrapper div, copy button, and language attribute
assert!(result.contains("<div class=\"marco-code-block\">"));
assert!(result.contains("<button class=\"marco-copy-btn\""));
assert!(result.contains(
"<pre data-language=\"Rust\"><code class=\"language-rust\">let x = 42;</code></pre>"
));
assert!(result.contains("</div>\n"));
}
#[test]
fn smoke_test_render_code_block_escapes_html() {
let doc = Document {
children: vec![Node {
kind: NodeKind::CodeBlock {
language: Some("html".to_string()),
code: "<div>Test & verify</div>".to_string(),
},
span: None,
children: vec![],
}],
..Default::default()
};
let options = RenderOptions {
syntax_highlighting: false,
..RenderOptions::default()
};
let result = render_html(&doc, &options).unwrap();
// Should contain wrapper, copy button, and properly escaped HTML
assert!(result.contains("<div class=\"marco-code-block\">"));
assert!(result.contains("<button class=\"marco-copy-btn\""));
assert!(result.contains("<pre data-language=\"HTML\"><code class=\"language-html\"><div>Test & verify</div></code></pre>"));
assert!(result.contains("</div>\n"));
}
#[test]
fn smoke_test_render_code_span() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Paragraph,
span: None,
children: vec![
Node {
kind: NodeKind::Text("Use ".to_string()),
span: None,
children: vec![],
},
Node {
kind: NodeKind::CodeSpan("println!()".to_string()),
span: None,
children: vec![],
},
Node {
kind: NodeKind::Text(" for output.".to_string()),
span: None,
children: vec![],
},
],
}],
..Default::default()
};
let options = RenderOptions::default();
let result = render_html(&doc, &options).unwrap();
assert_eq!(result, "<p>Use <code>println!()</code> for output.</p>\n");
}
#[test]
fn smoke_test_render_mixed_inlines() {
let doc = Document {
children: vec![
Node {
kind: NodeKind::Heading {
level: 1,
text: "Title".to_string(),
id: None,
},
span: None,
children: vec![],
},
Node {
kind: NodeKind::Paragraph,
span: None,
children: vec![Node {
kind: NodeKind::Text("Some text.".to_string()),
span: None,
children: vec![],
}],
},
Node {
kind: NodeKind::CodeBlock {
language: Some("python".to_string()),
code: "print('hello')".to_string(),
},
span: None,
children: vec![],
},
],
..Default::default()
};
let options = RenderOptions {
syntax_highlighting: false,
..RenderOptions::default()
};
let result = render_html(&doc, &options).unwrap();
// Should contain heading (now with auto-slug id), paragraph, and code block with wrapper
assert!(result.contains("<h1 id=\"title\">"));
assert!(result.contains("<p>Some text.</p>\n"));
assert!(result.contains("<div class=\"marco-code-block\">"));
assert!(result.contains("<button class=\"marco-copy-btn\""));
assert!(result.contains("<pre data-language=\"Python\"><code class=\"language-python\">print('hello')</code></pre>"));
assert!(result.contains("</div>\n"));
}
#[test]
fn smoke_test_render_strong_emphasis() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Paragraph,
span: None,
children: vec![Node {
kind: NodeKind::StrongEmphasis,
span: None,
children: vec![Node {
kind: NodeKind::Text("bold+italic".to_string()),
span: None,
children: vec![],
}],
}],
}],
..Default::default()
};
let options = RenderOptions::default();
let result = render_html(&doc, &options).unwrap();
assert_eq!(result, "<p><em><strong>bold+italic</strong></em></p>\n");
}
#[test]
fn smoke_test_render_strike_mark_sup_sub() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Paragraph,
span: None,
children: vec![
Node {
kind: NodeKind::Strikethrough,
span: None,
children: vec![Node {
kind: NodeKind::Text("del".to_string()),
span: None,
children: vec![],
}],
},
Node {
kind: NodeKind::Text(" ".to_string()),
span: None,
children: vec![],
},
Node {
kind: NodeKind::Mark,
span: None,
children: vec![Node {
kind: NodeKind::Text("mark".to_string()),
span: None,
children: vec![],
}],
},
Node {
kind: NodeKind::Text(" ".to_string()),
span: None,
children: vec![],
},
Node {
kind: NodeKind::Superscript,
span: None,
children: vec![Node {
kind: NodeKind::Text("sup".to_string()),
span: None,
children: vec![],
}],
},
Node {
kind: NodeKind::Text(" ".to_string()),
span: None,
children: vec![],
},
Node {
kind: NodeKind::Subscript,
span: None,
children: vec![Node {
kind: NodeKind::Text("sub".to_string()),
span: None,
children: vec![],
}],
},
],
}],
..Default::default()
};
let options = RenderOptions::default();
let result = render_html(&doc, &options).unwrap();
assert_eq!(
result,
"<p><del>del</del> <mark>mark</mark> <sup>sup</sup> <sub>sub</sub></p>\n"
);
}
#[test]
fn smoke_test_render_table_with_alignment() {
let doc = Document {
children: vec![Node {
kind: NodeKind::Table {
alignments: vec![TableAlignment::Left, TableAlignment::Center],
},
span: None,
children: vec![
Node {
kind: NodeKind::TableRow { header: true },
span: None,
children: vec![
Node {
kind: NodeKind::TableCell {
header: true,
alignment: TableAlignment::Left,
},
span: None,
children: vec![Node {
kind: NodeKind::Text("h1".to_string()),
span: None,
children: vec![],
}],
},
Node {
kind: NodeKind::TableCell {
header: true,
alignment: TableAlignment::Center,
},
span: None,
children: vec![Node {
kind: NodeKind::Text("h2".to_string()),
span: None,
children: vec![],
}],
},
],
},
Node {
kind: NodeKind::TableRow { header: false },
span: None,
children: vec![
Node {
kind: NodeKind::TableCell {
header: false,
alignment: TableAlignment::Left,
},
span: None,
children: vec![Node {
kind: NodeKind::Text("c1".to_string()),
span: None,
children: vec![],
}],
},
Node {
kind: NodeKind::TableCell {
header: false,
alignment: TableAlignment::Center,
},
span: None,
children: vec![Node {
kind: NodeKind::Text("c2".to_string()),
span: None,
children: vec![],
}],
},
],
},
],
}],
..Default::default()
};
let options = RenderOptions::default();
let result = render_html(&doc, &options).expect("render failed");
assert!(result.contains("<table>"));
assert!(result.contains("<thead>"));
assert!(result.contains("<tbody>"));
assert!(result.contains("<th style=\"text-align: left;\">h1</th>"));
assert!(result.contains("<th style=\"text-align: center;\">h2</th>"));
assert!(result.contains("<td style=\"text-align: left;\">c1</td>"));
assert!(result.contains("<td style=\"text-align: center;\">c2</td>"));
}
#[test]
fn smoke_image_as_link() {
// `[](url)` must render as `<a href="url"><img .../></a>`, not broken.
let input =
"[](https://github.com/Ranrar/Marco)\n";
let doc = crate::parser::parse(input).expect("parse failed");
let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
.expect("render failed");
assert!(
html.contains("<a href=\"https://github.com/Ranrar/Marco\"><img"),
"image-as-link must render as <a><img/></a>, got: {}",
html
);
}
#[test]
fn smoke_hard_break_backslash() {
// Backslash + newline → <br />
let input = "Hello\\\nworld\n";
let doc = crate::parser::parse(input).expect("parse failed");
let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
.expect("render failed");
assert!(
html.contains("<br"),
"backslash hard break should render <br />, got: {}",
html
);
}
#[test]
fn smoke_hard_break_two_spaces() {
// Two trailing spaces + newline → <br /> (used by Shift+Enter in editor)
let input = "Hello \nworld\n";
let doc = crate::parser::parse(input).expect("parse failed");
let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
.expect("render failed");
assert!(
html.contains("<br"),
"two-space hard break should render <br />, got: {}",
html
);
}
#[test]
fn smoke_hard_break_three_spaces() {
// Three trailing spaces must also produce a clean <br /> with no stray space before it.
let input = "Hello \nworld\n";
let doc = crate::parser::parse(input).expect("parse failed");
let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
.expect("render failed");
assert!(
html.contains("<br"),
"three-space hard break should render <br />, got: {}",
html
);
// Must not produce a stray space text node before <br />
assert!(
!html.contains("Hello <br"),
"three-space hard break should not leave a stray space before <br />, got: {}",
html
);
}
#[test]
fn smoke_nbsp_spacer_paragraph() {
// Shift+Enter inserts "\u{00A0}\n\n" — a non-breaking space on its own line.
// Rust's trim() does NOT strip \u{00A0} (only strips ASCII whitespace),
// so the block parser accepts it as a real paragraph.
// The rendered <p> has CSS line-height height → visible spacer in preview.
let input = "before\n\n\u{00A0}\n\nafter\n";
let doc = crate::parser::parse(input).expect("parse failed");
let html = crate::render::render(&doc, &crate::render::RenderOptions::default())
.expect("render failed");
// Must have a paragraph containing the nbsp character (as literal or escaped)
let has_nbsp_para =
html.contains("\u{00A0}") || html.contains(" ") || html.contains(" ");
assert!(
has_nbsp_para,
"nbsp spacer paragraph must appear in HTML output, got: {}",
html
);
// Must still have both surrounding paragraphs
assert!(
html.contains(">before<"),
"before paragraph must be present, got: {}",
html
);
assert!(
html.contains(">after<"),
"after paragraph must be present, got: {}",
html
);
}
/// Regression test for the Phase 3 (`parallel-render`) node-address-keyed
/// highlight cache in `precompute_highlights`. Footnote definitions
/// render in first-reference order at the end of the document, which can
/// differ from their order in the tree — this is the exact mismatch that
/// motivated keying the cache by node address instead of traversal
/// position/index (see the "parser-render-optimization-plan.md" Phase 3
/// notes). Only compiled when `parallel-render` is enabled, since that's
/// the only configuration where `precomputed_highlights` is ever `Some`
/// and this cache is actually exercised.
#[cfg(all(feature = "render-syntax-highlighting", feature = "parallel-render"))]
#[test]
fn parallel_render_matches_each_code_block_to_its_own_node_across_footnote_reorder() {
let top_code = "fn top() -> i32 { 1 }";
let a_code = "def a():\n return 1\n";
let b_code = "function b() { return 2; }";
// Tree order defines footnote "a" before "b", but the paragraph
// references "b" before "a" — so the footnotes section (rendered in
// first-reference order) emits "b" before "a", the reverse of tree
// order. `precompute_highlights` walks the tree once (a-then-b
// order); `render_node` walks it again but emits the footnotes
// section in b-then-a order. Node-address keying must still resolve
// each code block to its own highlighted result despite that.
let doc = Document {
children: vec![
Node {
kind: NodeKind::Paragraph,
span: None,
children: vec![
Node {
kind: NodeKind::FootnoteReference {
label: "b".to_string(),
},
span: None,
children: vec![],
},
Node {
kind: NodeKind::FootnoteReference {
label: "a".to_string(),
},
span: None,
children: vec![],
},
],
},
Node {
kind: NodeKind::CodeBlock {
language: Some("rust".to_string()),
code: top_code.to_string(),
},
span: None,
children: vec![],
},
Node {
kind: NodeKind::FootnoteDefinition {
label: "a".to_string(),
},
span: None,
children: vec![Node {
kind: NodeKind::CodeBlock {
language: Some("python".to_string()),
code: a_code.to_string(),
},
span: None,
children: vec![],
}],
},
Node {
kind: NodeKind::FootnoteDefinition {
label: "b".to_string(),
},
span: None,
children: vec![Node {
kind: NodeKind::CodeBlock {
language: Some("javascript".to_string()),
code: b_code.to_string(),
},
span: None,
children: vec![],
}],
},
],
..Default::default()
};
let options = RenderOptions::default();
let result = render_html(&doc, &options).unwrap();
// Independently highlight each snippet via the same underlying
// function the parallel path calls, to build the expected fragment
// for each node without hardcoding syntect's exact class output.
let top_html = highlight_code_to_classed_html(top_code, "rust")
.expect("rust snippet should highlight");
let a_html = highlight_code_to_classed_html(a_code, "python")
.expect("python snippet should highlight");
let b_html = highlight_code_to_classed_html(b_code, "javascript")
.expect("javascript snippet should highlight");
assert!(
result.contains(&top_html),
"top-level rust code block should carry its own highlighted HTML"
);
assert!(
result.contains(&a_html),
"footnote \"a\"'s python code block should carry its own \
highlighted HTML, not another block's"
);
assert!(
result.contains(&b_html),
"footnote \"b\"'s javascript code block should carry its own \
highlighted HTML, not another block's"
);
// And the footnote reordering itself must still hold: "b" (first
// referenced) before "a", even though "a" was collected first by
// `precompute_highlights`'s tree walk.
let b_pos = result
.find(&b_html)
.expect("b highlighted fragment present");
let a_pos = result
.find(&a_html)
.expect("a highlighted fragment present");
assert!(
b_pos < a_pos,
"footnote \"b\" should render before footnote \"a\" \
(first-reference order) while each keeps its own code block's \
highlighting"
);
}
}