Skip to main content

moss_core/ast/
render.rs

1//! Render typed AST → HTML via [`RenderHooks`].
2//!
3//! Walks every variant; calls hooks at interceptable points. Debug-asserts
4//! on `Url::Unresolved` reaching the renderer — a missing visitor is a bug.
5//!
6//! # Phase 4: render_document IS the production rendering path (target)
7//!
8//! Today (2026-05-27) this function runs as a parallel observer via
9//! `observe_typed_ast` in `src-tauri/src/build/markdown/pipeline.rs`;
10//! production HTML still comes from `pulldown_cmark::html::push_html` over
11//! the event stream. Phase 4 PR7a flips this: `render_document` becomes
12//! the production renderer, `html::push_html` is no longer called in the
13//! main pipeline, and `transform_events` is reduced to a thin
14//! events-to-Document adapter (or deleted).
15//!
16//! # Why the AST renders (not pulldown-cmark)
17//!
18//! Cross-SSG research (2026-05-27) — see
19//! [docs/archive/2026-05-27-typed-ast-cross-ssg-research.md](../../../../docs/archive/2026-05-27-typed-ast-cross-ssg-research.md)
20//! — confirms every AST-bearing SSG with secondary consumers (link
21//! graphs, editors, validators, multi-target rendering) puts the AST at
22//! the rendering source:
23//!
24//! - **mdBook** (same parser as moss) recently migrated from
25//!   `html::push_html` to a typed `Tree<Node>` via `ego_tree`. Same
26//!   destination, same motivation.
27//! - **Hugo** dispatches NodeRenderer per AST node-kind; render hooks
28//!   fire during AST walk.
29//! - **Markdoc** ships `AstNode → RenderableTreeNode → HTML/React`.
30//! - **Pandoc** has been AST-first since 2006; output is a writer per
31//!   target format.
32//! - **Quarto 2** is mid-migration from Stage 1 pre-parsers to AST-first
33//!   for three reasons: performance, fragility, information loss.
34//!
35//! Streaming-only SSGs (Zola, markdown-it ecosystem) live without an AST,
36//! but pay the cost: structural reshape requires fragile token-window
37//! pattern matching; secondary consumers can't ride on event streams.
38//! moss has secondary consumers (#599 page threading, editor's
39//! `scan_shortcodes`, `has_shortcode_recursive`, future WASM editor,
40//! future LSP-style diagnostics) — AST is non-optional.
41//!
42//! See [docs/reference/typed-body-ast.md](../../../../docs/reference/typed-body-ast.md)
43//! for the design intent + 7 principles, and
44//! [docs/archive/2026-05-27-phase4-typed-ast-completion.md](../../../../docs/archive/2026-05-27-phase4-typed-ast-completion.md)
45//! for the Phase 4 execution plan.
46
47use super::document::{BlockMeta, Document};
48use super::footnotes::{self, FootnoteCtx};
49use super::hooks::{escape_attr, escape_text, RenderHooks};
50use super::node::{Block, ColumnAlignment, Fold, Inline};
51use super::url::Url;
52
53/// Resolved (post-detection) per-column alignment used only for table HTML
54/// emission. Distinct from [`ColumnAlignment`] (the source-faithful AST value):
55/// this folds in numeric auto-detection and never carries a `None`.
56#[derive(Clone, Copy, PartialEq, Eq)]
57enum CellAlign {
58    Left,
59    Center,
60    Right,
61}
62
63impl CellAlign {
64    /// `class="…"` attribute fragment (with a leading space) for a cell of this
65    /// alignment; empty for the default left alignment so unaligned tables emit
66    /// exactly `<td>`.
67    fn class_attr(self) -> &'static str {
68        match self {
69            CellAlign::Left => "",
70            CellAlign::Center => " class=\"moss-col-center\"",
71            CellAlign::Right => " class=\"moss-col-right\"",
72        }
73    }
74}
75
76/// Whether a single cell reads as a number for right-alignment: an optional
77/// sign and currency mark, an ASCII digit run (with `,`/`.` group/decimal
78/// separators), an optional percent, and an optional short unit tail (≤3
79/// non-digit chars, e.g. `天`, `人`, `%`). Deliberately conservative so
80/// CJK-mixed labels like `第1名` and ranges like `2020-2021` stay left.
81fn cell_reads_as_number(s: &str) -> bool {
82    let chars: Vec<char> = s.trim().chars().collect();
83    if chars.is_empty() {
84        return false;
85    }
86    let mut i = 0;
87    if matches!(chars.get(i), Some('+' | '-')) {
88        i += 1;
89    }
90    if matches!(chars.get(i), Some('¥' | '$' | '€' | '£')) {
91        i += 1;
92    }
93    let mut saw_digit = false;
94    while let Some(&c) = chars.get(i) {
95        if c.is_ascii_digit() {
96            saw_digit = true;
97            i += 1;
98        } else if c == ',' || c == '.' {
99            i += 1;
100        } else {
101            break;
102        }
103    }
104    if !saw_digit {
105        return false;
106    }
107    if matches!(chars.get(i), Some('%' | '‰')) {
108        i += 1;
109    }
110    let tail: String = chars[i..].iter().collect();
111    let tail = tail.trim();
112    tail.is_empty() || (tail.chars().count() <= 3 && !tail.chars().any(|c| c.is_ascii_digit()))
113}
114
115/// Whether body-column `col` is numeric: ≥1 non-empty cell and ≥80% of the
116/// non-empty cells read as numbers. The header is intentionally excluded (a
117/// numeric header label like `2024` should not flip an otherwise-text column).
118fn column_reads_as_numeric(rows: &[Vec<Vec<Inline>>], col: usize) -> bool {
119    let mut non_empty = 0usize;
120    let mut numeric = 0usize;
121    for row in rows {
122        let Some(cell) = row.get(col) else { continue };
123        let text = crate::ast::plain_text::inlines_to_plain_text(cell);
124        if text.trim().is_empty() {
125            continue;
126        }
127        non_empty += 1;
128        if cell_reads_as_number(&text) {
129            numeric += 1;
130        }
131    }
132    non_empty > 0 && numeric * 5 >= non_empty * 4
133}
134
135/// Resolve effective per-column alignment. Author GFM alignment always wins;
136/// an unaligned column right-aligns iff its body reads as numeric. Length
137/// equals `header.len()`; cells beyond it fall back to left in emission.
138fn resolve_column_alignment(
139    header: &[Vec<Inline>],
140    rows: &[Vec<Vec<Inline>>],
141    alignments: &[ColumnAlignment],
142) -> Vec<CellAlign> {
143    (0..header.len())
144        .map(
145            |col| match alignments.get(col).copied().unwrap_or(ColumnAlignment::None) {
146                ColumnAlignment::Left => CellAlign::Left,
147                ColumnAlignment::Center => CellAlign::Center,
148                ColumnAlignment::Right => CellAlign::Right,
149                ColumnAlignment::None => {
150                    if column_reads_as_numeric(rows, col) {
151                        CellAlign::Right
152                    } else {
153                        CellAlign::Left
154                    }
155                }
156            },
157        )
158        .collect()
159}
160
161/// Render a [`Document`] to an HTML string using the given hooks.
162///
163/// # Panics (debug only)
164///
165/// If any URL is still `Url::Unresolved` when the renderer reaches it.
166/// `visit_urls_mut` must run before this function. In release builds the
167/// raw unresolved string is emitted as-is to avoid crashing on a bug.
168pub fn render_document<H: RenderHooks>(doc: &Document, hooks: &H) -> String {
169    let mut out = String::new();
170    // Walk blocks + meta in lockstep. Invariant: block_meta.len() ==
171    // blocks.len() (asserted in debug, defensive in release).
172    debug_assert_eq!(
173        doc.blocks.len(),
174        doc.block_meta.len(),
175        "Document invariant: blocks.len() == block_meta.len()"
176    );
177    let mut fnotes = FootnoteCtx::for_document(&doc.blocks);
178    for (i, block) in doc.blocks.iter().enumerate() {
179        let meta = doc.block_meta.get(i).copied().unwrap_or_default();
180        render_block(hooks, &mut out, block, &meta, &mut fnotes);
181    }
182    footnotes::render_section(hooks, &mut out, &doc.blocks, &mut fnotes);
183    out
184}
185
186/// Render ONE top-level block with its [`BlockMeta`] — the body of
187/// [`render_document`]'s loop, exposed.
188///
189/// A host that serializes a document one top-level block at a time (so it can
190/// record where each block's output begins and ends, instead of scanning the
191/// finished string for structure later — see src-tauri's `BodyPlan` and
192/// ADR-034) must go through this rather than [`render_blocks`]: the latter has
193/// no meta vec and would silently drop every `data-source-line` annotation.
194///
195/// Takes the caller's [`FootnoteCtx`] (built once via `FootnoteCtx::
196/// for_document`, same as [`render_document`]) rather than owning one, so
197/// footnote numbering/hoisting stays document-wide even when the caller
198/// walks blocks one at a time instead of via `render_document`'s loop.
199///
200/// Concatenating this over `doc.blocks`/`doc.block_meta` in lockstep, sharing
201/// one `FootnoteCtx` across the walk, is byte-identical to [`render_document`]
202/// by construction.
203pub fn render_block_with_meta<H: RenderHooks + ?Sized>(
204    hooks: &H,
205    out: &mut String,
206    block: &Block,
207    meta: &BlockMeta,
208    fnotes: &mut FootnoteCtx,
209) {
210    render_block(hooks, out, block, meta, fnotes);
211}
212
213/// Render a sequence of blocks to HTML. Used by shortcode-body renderers —
214/// grid cells and, via src-tauri's `render_hero_html_typed` (Phase 4 PR4.5),
215/// the hero overlay — to render a `Vec<Block>` that didn't come from a full
216/// `Document`. [`render_document`] does NOT call this: it walks its blocks
217/// and meta in lockstep, calling `render_block` directly.
218///
219/// **Not table cells.** A `Block::Table` holds `Vec<Vec<Vec<Inline>>>` — its
220/// cells are inlines, rendered by `render_inlines` inside the table arm, so
221/// they never reach any block-level entry point. Listing them here (and in
222/// ADR-035) described a caller that cannot exist.
223///
224/// **Source-line caveat:** this entry point has no per-block meta vec, so
225/// every block renders without `data-source-line`. Callers that need
226/// source-line annotations must walk meta-block pairs themselves (see
227/// [`render_document`]). Today only [`render_document`] consumes meta;
228/// nested-block walks (list items, callout bodies, blockquotes) are
229/// also meta-free — `data-source-line` is a top-level-block-only
230/// concern, matching the legacy `transform_events` emit shape.
231///
232/// `H: ?Sized` so the function can be called with `&dyn RenderHooks` or
233/// with `self: &Self` from inside a trait default method (where `Self`
234/// is not statically `Sized`). The hook surface is a thin dispatch
235/// boundary; monomorphization across all concrete impls is not required.
236/// **Footnote caveat:** this entry point carries no [`FootnoteIndex`], so a
237/// marker inside `blocks` renders as its literal `[^label]` source and a
238/// definition renders in place. That is the honest shape for the callers
239/// this function has — shortcode bodies, which are their own little
240/// documents with no endnote section of their own. Nested walks INSIDE a
241/// document use `render_blocks_with`. ADR-035 § Three call paths.
242pub fn render_blocks<H: RenderHooks + ?Sized>(hooks: &H, out: &mut String, blocks: &[Block]) {
243    render_blocks_with(hooks, out, blocks, &mut FootnoteCtx::default());
244}
245
246/// The in-document nested-block walk: same as [`render_blocks`] but keeps
247/// the caller's footnote state, so a marker inside a blockquote, callout,
248/// multi-paragraph list item or link card is numbered like any other.
249pub(super) fn render_blocks_with<H: RenderHooks + ?Sized>(
250    hooks: &H,
251    out: &mut String,
252    blocks: &[Block],
253    fnotes: &mut FootnoteCtx,
254) {
255    for block in blocks {
256        // Nested blocks render without source-line annotations (the
257        // legacy transform_events emitted `data-source-line` on the
258        // outer `<ul>`/`<ol>`/`<blockquote>` and inner `<li>` only —
259        // top-level + list-item depth. We omit the `<li>` annotation
260        // for now; the iframe-bridge consumer picks the outer wrapper
261        // when no inner annotation exists.
262        render_block(hooks, out, block, &BlockMeta::default(), fnotes);
263    }
264}
265
266fn render_block<H: RenderHooks + ?Sized>(
267    hooks: &H,
268    out: &mut String,
269    block: &Block,
270    meta: &BlockMeta,
271    fnotes: &mut FootnoteCtx,
272) {
273    match block {
274        Block::Heading {
275            level,
276            children,
277            id,
278        } => {
279            let mut content = String::new();
280            render_inlines(hooks, &mut content, children, fnotes);
281            hooks.render_heading(out, *level, id.as_deref(), meta.source_line, &content);
282            out.push('\n');
283        }
284        Block::Paragraph(children) => {
285            out.push_str("<p");
286            push_source_line_attr(out, meta.source_line);
287            out.push('>');
288            render_inlines(hooks, out, children, fnotes);
289            out.push_str("</p>\n");
290        }
291        Block::Callout {
292            kind,
293            fold,
294            title,
295            children,
296        } => {
297            // Phase 4 PR4: byte-shape mirrors the (now-deleted) Stage 1
298            // `resolve/callouts.rs` output that production HTML still
299            // assumes — `<div class="callout" data-type="{slug}"> /
300            //   <div class="callout-title">{title}</div> /
301            //   <div class="callout-content">…</div>
302            // </div>`. The `data-fold` attribute is new in PR4 (Obsidian
303            // foldable callouts); absent on non-foldable callouts so
304            // existing fixtures remain byte-identical.
305            //
306            // `data-source-line` injected when meta carries it; matches the
307            // legacy `transform_events` shape on the blockquote-promoted
308            // callout (the legacy emit was for `<blockquote>` since
309            // callouts hadn't moved to a typed `<div>` shape yet at the
310            // time; downstream consumer (iframe-bridge) accepts the attr
311            // on any wrapper element).
312            out.push_str(r#"<div class="callout" data-type=""#);
313            out.push_str(kind.as_slug());
314            out.push_str(r#"""#);
315            push_source_line_attr(out, meta.source_line);
316            if let Some(fold_state) = fold {
317                let fold_attr = match fold_state {
318                    Fold::Open => "open",
319                    Fold::Closed => "closed",
320                };
321                out.push_str(r#" data-fold=""#);
322                out.push_str(fold_attr);
323                out.push_str(r#"""#);
324            }
325            out.push_str(">\n");
326            // Title slot: prefer the parser-extracted title; fall back
327            // to the kind's capitalized default (matches Stage 1).
328            let display_title = title
329                .as_deref()
330                .map(|t| t.trim())
331                .filter(|t| !t.is_empty())
332                .map(|t| escape_text(t))
333                .unwrap_or_else(|| kind.default_title().to_string());
334            out.push_str(r#"  <div class="callout-title">"#);
335            out.push_str(&display_title);
336            out.push_str("</div>\n");
337            // The box itself always survives: its title is text the AUTHOR
338            // typed, and hoisting a footnote definition moves the note — it
339            // must not delete the prose around it. What goes is the empty
340            // content div a hoist leaves behind.
341            let body = render_children_to_string(hooks, children, fnotes);
342            if !hoist_emptied(children, &body) {
343                out.push_str(r#"  <div class="callout-content">"#);
344                out.push('\n');
345                out.push_str(&body);
346                out.push_str("</div>\n");
347            }
348            out.push_str("</div>\n");
349        }
350        Block::List {
351            ordered,
352            start,
353            items,
354            item_source_lines,
355        } => {
356            // Parallel-vec invariant: when the parser populated per-item
357            // source lines, the vector must align 1:1 with `items` so the
358            // `idx`-keyed lookup at `item_source_lines.get(idx)` is
359            // well-defined. Empty (default) means "parser ran without
360            // `emit_source_lines`" — that's the legitimate skip case.
361            // Mirrors the document-level `blocks.len() == block_meta.len()`
362            // invariant asserted at the top of `render_document`.
363            debug_assert!(
364                item_source_lines.is_empty() || item_source_lines.len() == items.len(),
365                "Block::List invariant: item_source_lines.len() ({}) must equal items.len() ({}) when populated",
366                item_source_lines.len(),
367                items.len()
368            );
369            // Items render into a buffer first so an item the hoist emptied
370            // can be dropped — and, if that was the only item, so can the
371            // list. The render still happens exactly once, in order, so the
372            // `FootnoteCtx` side effects (hoist decisions, marker ids,
373            // back-link counts) are identical either way.
374            let mut body = String::new();
375            let mut kept = 0usize;
376            for (idx, item_blocks) in items.iter().enumerate() {
377                let mut item = String::new();
378                // Single-paragraph items render their inline content inline
379                // (no extra <p>). Mirrors pulldown-cmark's "tight list" output.
380                let tight = matches!(item_blocks.as_slice(), [Block::Paragraph(_)]);
381                if let [Block::Paragraph(inlines)] = item_blocks.as_slice() {
382                    render_inlines(hooks, &mut item, inlines, fnotes);
383                } else {
384                    render_blocks_with(hooks, &mut item, item_blocks, fnotes);
385                }
386                if hoist_emptied(item_blocks, &item) {
387                    continue;
388                }
389                // The non-tight `<li>\n…</li>` shape is restored only AFTER the
390                // emptiness test, so the buffer under test is purely child
391                // output. Prefixing first made every non-tight item look
392                // non-empty, which forced `hoist_emptied` to `.trim()` — and
393                // that trim deleted whitespace the author typed.
394                if !tight {
395                    item.insert(0, '\n');
396                }
397                kept += 1;
398                // Per-`<li>` source line — populated only when the parser
399                // ran with `emit_source_lines: true` (otherwise
400                // `item_source_lines` is empty). Mirrors the legacy
401                // transform_events shape (commit f91aca8fa, 2026-04-01) that
402                // emitted `data-source-line` on `<li>` for proportional
403                // scroll-sync interpolation between editor and preview.
404                body.push_str("<li");
405                let item_line = item_source_lines.get(idx).copied().flatten();
406                push_source_line_attr(&mut body, item_line);
407                body.push('>');
408                body.push_str(&item);
409                body.push_str("</li>\n");
410            }
411            if items.is_empty() || kept > 0 {
412                if *ordered {
413                    out.push_str("<ol");
414                    // Emit `start="N"` when the parser captured an explicit
415                    // non-default start number (`3. foo` → `Some(3)`).
416                    // `None` for the default `1. foo` case keeps the
417                    // shorter `<ol>` shape. Attribute order mirrors other
418                    // typed-AST blocks: existing tag attrs first, then
419                    // `data-source-line`. Phase 4 followup B (2026-05-28).
420                    if let Some(n) = start {
421                        out.push_str(" start=\"");
422                        out.push_str(&n.to_string());
423                        out.push('"');
424                    }
425                    push_source_line_attr(out, meta.source_line);
426                    out.push_str(">\n");
427                } else {
428                    out.push_str("<ul");
429                    push_source_line_attr(out, meta.source_line);
430                    out.push_str(">\n");
431                }
432                out.push_str(&body);
433                if *ordered {
434                    out.push_str("</ol>\n");
435                } else {
436                    out.push_str("</ul>\n");
437                }
438            }
439        }
440        Block::CodeBlock { lang, value } => {
441            out.push_str("<pre");
442            push_source_line_attr(out, meta.source_line);
443            out.push('>');
444            match lang {
445                Some(l) => {
446                    out.push_str(r#"<code class="language-"#);
447                    out.push_str(&escape_attr(l));
448                    out.push_str(r#"">"#);
449                }
450                None => out.push_str("<code>"),
451            }
452            out.push_str(&escape_text(value));
453            out.push_str("</code></pre>\n");
454        }
455        Block::Table {
456            header,
457            rows,
458            alignments,
459            header_source_line,
460            row_source_lines,
461        } => {
462            // Parallel-vec invariant: when the parser populated per-row
463            // source lines, the vector must align 1:1 with `rows`.
464            // Empty (default) means "parser ran without
465            // `emit_source_lines`" — that's the legitimate skip case.
466            // Mirrors the `Block::List` and document-level invariants.
467            debug_assert!(
468                row_source_lines.is_empty() || row_source_lines.len() == rows.len(),
469                "Block::Table invariant: row_source_lines.len() ({}) must equal rows.len() ({}) when populated",
470                row_source_lines.len(),
471                rows.len()
472            );
473            // Per-column alignment: author GFM `|--:|` wins; otherwise numeric
474            // columns auto-right-align (so figure columns stop reading ragged).
475            let col_align = resolve_column_alignment(header, rows, alignments);
476            let cell_class = |col: usize| -> &'static str {
477                col_align.get(col).copied().map_or("", CellAlign::class_attr)
478            };
479            // Accessible horizontal-scroll wrapper: keeps the `<table>`
480            // semantically intact (unlike a `display:block` table), and
481            // `tabindex` makes an overflowing table keyboard-scrollable.
482            out.push_str("<div class=\"moss-table-scroll\" tabindex=\"0\">\n");
483            out.push_str("<table");
484            push_source_line_attr(out, meta.source_line);
485            out.push_str(">\n<thead>\n<tr");
486            // Header `<tr>` source line. Same f91aca8fa shape — annotated
487            // when the parser tracked lines, omitted otherwise.
488            push_source_line_attr(out, *header_source_line);
489            out.push('>');
490            for (col, cell) in header.iter().enumerate() {
491                out.push_str("<th");
492                out.push_str(cell_class(col));
493                out.push('>');
494                render_inlines(hooks, out, cell, fnotes);
495                out.push_str("</th>");
496            }
497            out.push_str("</tr>\n</thead>\n");
498            if !rows.is_empty() {
499                out.push_str("<tbody>\n");
500                for (idx, row) in rows.iter().enumerate() {
501                    out.push_str("<tr");
502                    let row_line = row_source_lines.get(idx).copied().flatten();
503                    push_source_line_attr(out, row_line);
504                    out.push('>');
505                    for (col, cell) in row.iter().enumerate() {
506                        out.push_str("<td");
507                        out.push_str(cell_class(col));
508                        out.push('>');
509                        render_inlines(hooks, out, cell, fnotes);
510                        out.push_str("</td>");
511                    }
512                    out.push_str("</tr>\n");
513                }
514                out.push_str("</tbody>\n");
515            }
516            out.push_str("</table>\n");
517            out.push_str("</div>\n");
518        }
519        Block::BlockQuote(children) => {
520            let body = render_children_to_string(hooks, children, fnotes);
521            if !hoist_emptied(children, &body) {
522                out.push_str("<blockquote");
523                push_source_line_attr(out, meta.source_line);
524                out.push_str(">\n");
525                out.push_str(&body);
526                out.push_str("</blockquote>\n");
527            }
528        }
529        Block::Shortcode(sc) => {
530            hooks.render_shortcode(out, sc, meta.source_line);
531            out.push('\n');
532        }
533        Block::ThematicBreak => {
534            out.push_str("<hr");
535            push_source_line_attr(out, meta.source_line);
536            out.push_str(" />\n");
537        }
538        Block::Figure {
539            image,
540            caption,
541            width,
542            align,
543            class_names,
544            img_style,
545        } => {
546            // Phase 4 PR3 (2026-05-27): image-only paragraphs promoted at
547            // parse time become Block::Figure. The render shape is a
548            // `<figure class="moss-image">` wrap around the image hook's
549            // output, optionally followed by `<figcaption>{caption}</figcaption>`.
550            //
551            // The inner image renders via `hooks.render_image` (the same
552            // path as Inline::Image — production wires this through
553            // `DefaultHooks::with_snapshot` / `PipelineHooks` which uses
554            // `ImageContext::MarkdownInline`, producing the bare
555            // `<picture><img></picture>` shape). The structural `<figure>`
556            // wrapper is the Figure renderer's responsibility — this keeps
557            // the byte shape contract with shape-spec § 1: the spec sample
558            // shows `<figure>` containing exactly the MarkdownInline inner.
559            //
560            // Caption omission: `caption: None` means "no figcaption" (the
561            // empty-alt case). Empty caption Vec is also treated as no
562            // figcaption — defensive, since `caption: Some(vec![])` would
563            // otherwise emit `<figcaption></figcaption>`.
564            //
565            // Figure-level display params (`width`, `align`, `class_names`,
566            // `img_style`) are populated only by parameterized wikilink
567            // embeds (image-embed synth-collapse). The class list /
568            // `data-width=` byte shape matches
569            // `render::image::wrap_in_figure_full` so an embed-sourced figure
570            // and a CommonMark `![](url)` figure with the same params are
571            // byte-identical. For the CommonMark path these are all defaults,
572            // so `class="moss-image"` with no `data-width=` — unchanged from
573            // before the collapse.
574            let mut class_value = String::from("moss-image");
575            if let Some(a) = align {
576                class_value.push(' ');
577                class_value.push_str(a);
578            }
579            for cn in class_names {
580                if cn.is_empty() {
581                    continue;
582                }
583                class_value.push(' ');
584                class_value.push_str(cn);
585            }
586            out.push_str(r#"<figure class=""#);
587            out.push_str(&escape_attr(&class_value));
588            out.push('"');
589            if let Some(w) = width {
590                if w.ends_with('%') {
591                    // Content-relative percent → inline style on the figure.
592                    // The figure has never carried `style=` (img_style lives on
593                    // the inner <img>), so there is no collision; the centering
594                    // CSS keys off the same shape.
595                    out.push_str(r#" style="width:"#);
596                    out.push_str(&escape_attr(w));
597                    out.push('"');
598                } else {
599                    // Named token → data-width contract (unchanged).
600                    out.push_str(r#" data-width=""#);
601                    out.push_str(&escape_attr(w));
602                    out.push('"');
603                }
604            }
605            push_source_line_attr(out, meta.source_line);
606            out.push('>');
607            // A caption that is about to be shown OWNS the description, so
608            // the image inside it is decorative *relative to that caption*
609            // and takes `alt=""`.
610            //
611            // Every figure moss builds derives its caption from the same
612            // authored string as the alt — the implicit-figure promotion
613            // uses the markdown alt (`try_promote_to_figure`), and a
614            // wikilink embed uses the pothole alias for both
615            // (`resolve::wikilink_dispatch`). Emitting the sentence twice
616            // makes a screen reader announce it twice: once as the image's
617            // accessible name, once as the caption. Per W3C/WAI guidance on
618            // captioned images, `alt` and the caption must not duplicate
619            // each other; when only one text exists, the visible caption is
620            // the one to keep.
621            //
622            // Keyed off the caption that is ACTUALLY emitted below (same
623            // `Some(non-empty)` test), so an uncaptioned figure — and every
624            // non-figure image, which never reaches this arm — keeps its
625            // alt as its only accessible name.
626            let caption_owns_text = caption.as_ref().is_some_and(|c| !c.is_empty());
627            // Render the inner image. Pattern-match the constrained shape;
628            // any other inline falls back to the standard inline path so
629            // the renderer never panics on a malformed Figure.
630            match image {
631                Inline::Image {
632                    src, alt, title, ..
633                } => match src {
634                    Url::Resolved(r) => {
635                        let alt = if caption_owns_text { "" } else { alt.as_str() };
636                        hooks.render_image(
637                            out,
638                            r,
639                            alt,
640                            title.as_deref(),
641                            img_style.as_deref(),
642                            width.as_deref(),
643                        );
644                    }
645                    Url::Unresolved(s) => {
646                        debug_assert!(
647                                false,
648                                "Url::Unresolved({s:?}) reached Block::Figure renderer — visit_urls_mut missing or buggy"
649                            );
650                        out.push_str(r#"<img src=""#);
651                        out.push_str(&escape_attr(s));
652                        out.push_str(r#"" alt=""#);
653                        out.push_str(&escape_attr(alt));
654                        out.push_str(r#"" />"#);
655                    }
656                },
657                _ => {
658                    // Defensive: a non-Image inline in a Figure violates
659                    // the parser-enforced shape, but the renderer must
660                    // still emit something rather than crash.
661                    render_inline(hooks, out, image, fnotes);
662                }
663            }
664            if let Some(cap_inlines) = caption {
665                if !cap_inlines.is_empty() {
666                    out.push_str("<figcaption>");
667                    render_inlines(hooks, out, cap_inlines, fnotes);
668                    out.push_str("</figcaption>");
669                }
670            }
671            out.push_str("</figure>\n");
672        }
673        Block::LinkCard { url, children } => {
674            // Phase 4 PR4.5 (2026-05-28): the compound-link grid-cell shape.
675            // External URLs render as a link-preview wrapper; internal URLs
676            // render as `data-kind="link"` grid-card.
677            //
678            // Production byte shape matches today's src-tauri
679            // `render_compound_link_cell` output (ported here so that
680            // shape was deleted from src-tauri in PR4.5). The wrapping
681            // `<div class="moss-grid">` chrome lives in the Grid render
682            // arm in hooks.rs; LinkCard is the per-cell shape.
683            let body = render_children_to_string(hooks, children, fnotes);
684            if hoist_emptied(children, &body) {
685                return;
686            }
687            let resolved = match url {
688                Url::Resolved(r) => r,
689                Url::Unresolved(s) => {
690                    debug_assert!(
691                        false,
692                        "Url::Unresolved({s:?}) reached Block::LinkCard renderer — visit_urls_mut missing or buggy"
693                    );
694                    out.push_str(r#"<a href=""#);
695                    out.push_str(&escape_attr(s));
696                    out.push_str(r#"" class="moss-grid-card" data-kind="link">"#);
697                    out.push_str(&body);
698                    out.push_str("</a>");
699                    return;
700                }
701            };
702            use super::url::UrlKind;
703            let is_external = matches!(resolved.kind, UrlKind::External | UrlKind::AssetNewtab);
704            if is_external {
705                out.push_str(r#"<a href=""#);
706                out.push_str(&escape_attr(&resolved.href));
707                out.push_str(
708                    r#"" class="moss-grid-card link-preview" target="_blank" rel="noopener">"#,
709                );
710            } else {
711                out.push_str(r#"<a href=""#);
712                out.push_str(&escape_attr(&resolved.href));
713                out.push_str(r#"" class="moss-grid-card" data-kind="link">"#);
714            }
715            out.push_str(&body);
716            out.push_str("</a>");
717        }
718        Block::FootnoteDefinition { label, children } => {
719            // The first definition of a label is hoisted into the endnote
720            // section, so it emits nothing here. Identity-decided, so
721            // skipping this subtree leaves no counter stale for a nested
722            // definition inside it.
723            if footnotes::is_hoisted(label, children, fnotes) {
724                return;
725            }
726            render_blocks_with(hooks, out, children, fnotes);
727        }
728        Block::Other(html) => {
729            out.push_str(html);
730        }
731    }
732}
733
734/// Render a container's children into their own buffer.
735///
736/// The buffer is what lets a container ask "did my children produce
737/// anything?" before it commits to a wrapper element. Rendering still
738/// happens exactly once and in document order, so the [`FootnoteCtx`]
739/// side effects — hoist decisions, marker ids, back-link counts — are
740/// byte-for-byte what they were when the children rendered straight into
741/// `out`.
742fn render_children_to_string<H: RenderHooks + ?Sized>(
743    hooks: &H,
744    children: &[Block],
745    fnotes: &mut FootnoteCtx,
746) -> String {
747    let mut buf = String::new();
748    render_blocks_with(hooks, &mut buf, children, fnotes);
749    buf
750}
751
752/// True when a container HAD children and they all rendered to nothing —
753/// which today means the only thing inside it was a footnote definition, and
754/// `footnotes::render_section` hoisted that to the end of the page.
755///
756/// The `!children.is_empty()` half is the whole guard: a container the AUTHOR
757/// left empty (`- one\n-\n- two`, a bare `>`) still renders, because pruning
758/// is about the hoist moving content out from under a wrapper, not about
759/// tidying up the author's markup. Without it, `- one\n-\n- two` would drop
760/// from three bullets to two.
761///
762/// The test is EXACT emptiness, never `.trim()`: `str::trim` strips every
763/// `char::is_whitespace`, so an item holding only U+00A0 or U+3000 (the
764/// full-width space CJK authors type to indent) read as residue and was
765/// deleted. Callers must hand this child output and nothing else — the list
766/// arm adds its `'\n'` prefix only after this returns.
767fn hoist_emptied(children: &[Block], rendered: &str) -> bool {
768    !children.is_empty() && rendered.is_empty()
769}
770
771/// Append ` data-source-line="N"` to `out` when `source_line` is `Some`.
772/// No-op otherwise.
773///
774/// Used at every top-level block's opening tag arm so the preview's
775/// `cm-scroll-sync` (in `frontend/bridge/iframe-bridge.ts`) can locate
776/// the DOM element that corresponds to a given editor source line.
777///
778/// Matches the legacy `transform_events` emit byte shape — leading space,
779/// double-quoted attribute value, decimal integer — verified against
780/// `src-tauri/src/build/ship.rs::apply_strip_removes_data_source_line`
781/// which scrubs this exact pattern from the ship-stage output.
782fn push_source_line_attr(out: &mut String, source_line: Option<usize>) {
783    if let Some(n) = source_line {
784        use std::fmt::Write as _;
785        // unwrap_or: writing into a String never fails, but the API
786        // returns Result. Keep this honest.
787        let _ = write!(out, r#" data-source-line="{}""#, n);
788    }
789}
790
791pub(super) fn render_inlines<H: RenderHooks + ?Sized>(
792    hooks: &H,
793    out: &mut String,
794    inlines: &[Inline],
795    fnotes: &mut FootnoteCtx,
796) {
797    for inline in inlines {
798        render_inline(hooks, out, inline, fnotes);
799    }
800}
801
802fn render_inline<H: RenderHooks + ?Sized>(
803    hooks: &H,
804    out: &mut String,
805    inline: &Inline,
806    fnotes: &mut FootnoteCtx,
807) {
808    match inline {
809        Inline::Text(t) => out.push_str(&escape_text(t)),
810        Inline::Link {
811            url,
812            title: _title,
813            children,
814            is_wikilink,
815        } => {
816            let resolved = match url {
817                Url::Resolved(r) => r,
818                Url::Unresolved(s) => {
819                    debug_assert!(
820                        false,
821                        "Url::Unresolved({s:?}) reached renderer — visit_urls_mut missing or buggy"
822                    );
823                    // In release: emit href as-is so we don't crash, but
824                    // the wide-net invariant test will catch the leak.
825                    out.push_str(r#"<a href=""#);
826                    out.push_str(&escape_attr(s));
827                    out.push_str(r#"">"#);
828                    render_inlines(hooks, out, children, fnotes);
829                    out.push_str("</a>");
830                    return;
831                }
832            };
833            let mut content = String::new();
834            render_inlines(hooks, &mut content, children, fnotes);
835            // Phase 4 PR7a-flip-core-A (2026-05-28): pass the
836            // `is_wikilink` flag directly to the hook. Pre-flip-core-A,
837            // this arm synthesized a wikilink-kinded `ResolvedUrl` to
838            // coax the hook's wikilink branch — a lossy workaround that
839            // dropped the original `UrlKind` (`AssetNewtab` wikilinks
840            // lost their `target="_blank" rel="noopener"`). The hook's
841            // new signature carries both concerns orthogonally.
842            hooks.render_link(out, resolved, *is_wikilink, &content);
843        }
844        Inline::Image {
845            src, alt, title, ..
846        } => {
847            let resolved = match src {
848                Url::Resolved(r) => r,
849                Url::Unresolved(s) => {
850                    debug_assert!(
851                        false,
852                        "Url::Unresolved({s:?}) reached renderer — visit_urls_mut missing or buggy"
853                    );
854                    out.push_str(r#"<img src=""#);
855                    out.push_str(&escape_attr(s));
856                    out.push_str(r#"" alt=""#);
857                    out.push_str(&escape_attr(alt));
858                    out.push_str(r#"" />"#);
859                    return;
860                }
861            };
862            hooks.render_image(out, resolved, alt, title.as_deref(), None, None);
863        }
864        Inline::Emphasis(children) => {
865            out.push_str("<em>");
866            render_inlines(hooks, out, children, fnotes);
867            out.push_str("</em>");
868        }
869        Inline::Strong(children) => {
870            out.push_str("<strong>");
871            render_inlines(hooks, out, children, fnotes);
872            out.push_str("</strong>");
873        }
874        Inline::Code(c) => {
875            out.push_str("<code>");
876            out.push_str(&escape_text(c));
877            out.push_str("</code>");
878        }
879        Inline::Strikethrough(children) => {
880            out.push_str("<del>");
881            render_inlines(hooks, out, children, fnotes);
882            out.push_str("</del>");
883        }
884        Inline::FootnoteRef(label) => footnotes::render_marker(hooks, out, label, fnotes),
885        // GFM's shape, which is also Obsidian's: a disabled checkbox inline at
886        // the head of the item. `disabled` because a published page is not an
887        // app — the state is whatever the source says. The `<li>` needs no
888        // class; `site.css` selects both shapes this renderer produces —
889        // `li:has(> input[type="checkbox"])` for tight items, and
890        // `li:has(> p:first-child > input[type="checkbox"]:first-child)` for
891        // loose/multi-block items whose leading paragraph is wrapped in `<p>`.
892        Inline::TaskMarker(checked) => {
893            out.push_str(if *checked {
894                "<input type=\"checkbox\" disabled checked /> "
895            } else {
896                "<input type=\"checkbox\" disabled /> "
897            });
898        }
899        Inline::LineBreak => out.push_str("<br />\n"),
900        Inline::Other(html) => {
901            // A math node (ADR-030) is an `Inline::Other` carrying the P1
902            // escaped-source `<code class="moss-math">` payload. Route it
903            // through `render_math` so a typesetting hook (src-tauri's
904            // `PipelineHooks`) can replace it with an SVG; the default hook
905            // re-emits `html` verbatim, so non-pipeline renders are byte-
906            // identical to P1. Any non-math `Inline::Other` falls straight
907            // through to a raw push.
908            match super::math_text::math_node_parts(html) {
909                Some((tex, display)) => hooks.render_math(out, &tex, display, html),
910                None => out.push_str(html),
911            }
912        }
913    }
914}
915
916#[cfg(test)]
917#[path = "render_tests.rs"]
918mod tests;