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 `` 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;