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/architecture/typed-ast-cross-ssg-research-2026-05-27.md](../../../../docs/architecture/typed-ast-cross-ssg-research-2026-05-27.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/architecture/typed-body-ast.md](../../../../docs/architecture/typed-body-ast.md)
43//! for the design intent + 7 principles, and
44//! [docs/plans/2026-05-27-phase4-typed-ast-completion.md](../../../../docs/plans/2026-05-27-phase4-typed-ast-completion.md)
45//! for the Phase 4 execution plan.
46
47use super::document::{BlockMeta, Document};
48use super::hooks::{escape_attr, escape_text, RenderHooks};
49use super::node::{Block, Fold, Inline};
50use super::url::Url;
51
52/// Render a [`Document`] to an HTML string using the given hooks.
53///
54/// # Panics (debug only)
55///
56/// If any URL is still `Url::Unresolved` when the renderer reaches it.
57/// `visit_urls_mut` must run before this function. In release builds the
58/// raw unresolved string is emitted as-is to avoid crashing on a bug.
59pub fn render_document<H: RenderHooks>(doc: &Document, hooks: &H) -> String {
60    let mut out = String::new();
61    // Walk blocks + meta in lockstep. Invariant: block_meta.len() ==
62    // blocks.len() (asserted in debug, defensive in release).
63    debug_assert_eq!(
64        doc.blocks.len(),
65        doc.block_meta.len(),
66        "Document invariant: blocks.len() == block_meta.len()"
67    );
68    for (i, block) in doc.blocks.iter().enumerate() {
69        let meta = doc.block_meta.get(i).copied().unwrap_or_default();
70        render_block(hooks, &mut out, block, &meta);
71    }
72    out
73}
74
75/// Render a sequence of blocks to HTML. Used by [`render_document`]
76/// and by src-tauri's `render_hero_html_typed` (Phase 4 PR4.5) to render
77/// a `Vec<Block>` that didn't come from a full `Document` (e.g. a hero
78/// overlay).
79///
80/// **Source-line caveat:** this entry point has no per-block meta vec, so
81/// every block renders without `data-source-line`. Callers that need
82/// source-line annotations must walk meta-block pairs themselves (see
83/// [`render_document`]). Today only [`render_document`] consumes meta;
84/// nested-block walks (list items, callout bodies, blockquotes) are
85/// also meta-free — `data-source-line` is a top-level-block-only
86/// concern, matching the legacy `transform_events` emit shape.
87///
88/// `H: ?Sized` so the function can be called with `&dyn RenderHooks` or
89/// with `self: &Self` from inside a trait default method (where `Self`
90/// is not statically `Sized`). The hook surface is a thin dispatch
91/// boundary; monomorphization across all concrete impls is not required.
92pub fn render_blocks<H: RenderHooks + ?Sized>(hooks: &H, out: &mut String, blocks: &[Block]) {
93    for block in blocks {
94        // Nested blocks render without source-line annotations (the
95        // legacy transform_events emitted `data-source-line` on the
96        // outer `<ul>`/`<ol>`/`<blockquote>` and inner `<li>` only —
97        // top-level + list-item depth. We omit the `<li>` annotation
98        // for now; the iframe-bridge consumer picks the outer wrapper
99        // when no inner annotation exists.
100        render_block(hooks, out, block, &BlockMeta::default());
101    }
102}
103
104fn render_block<H: RenderHooks + ?Sized>(
105    hooks: &H,
106    out: &mut String,
107    block: &Block,
108    meta: &BlockMeta,
109) {
110    match block {
111        Block::Heading {
112            level,
113            children,
114            id,
115        } => {
116            let mut content = String::new();
117            render_inlines(hooks, &mut content, children);
118            hooks.render_heading(out, *level, id.as_deref(), meta.source_line, &content);
119            out.push('\n');
120        }
121        Block::Paragraph(children) => {
122            out.push_str("<p");
123            push_source_line_attr(out, meta.source_line);
124            out.push('>');
125            render_inlines(hooks, out, children);
126            out.push_str("</p>\n");
127        }
128        Block::Callout {
129            kind,
130            fold,
131            title,
132            children,
133        } => {
134            // Phase 4 PR4: byte-shape mirrors the (now-deleted) Stage 1
135            // `resolve/callouts.rs` output that production HTML still
136            // assumes — `<div class="callout" data-type="{slug}"> /
137            //   <div class="callout-title">{title}</div> /
138            //   <div class="callout-content">…</div>
139            // </div>`. The `data-fold` attribute is new in PR4 (Obsidian
140            // foldable callouts); absent on non-foldable callouts so
141            // existing fixtures remain byte-identical.
142            //
143            // `data-source-line` injected when meta carries it; matches the
144            // legacy `transform_events` shape on the blockquote-promoted
145            // callout (the legacy emit was for `<blockquote>` since
146            // callouts hadn't moved to a typed `<div>` shape yet at the
147            // time; downstream consumer (iframe-bridge) accepts the attr
148            // on any wrapper element).
149            out.push_str(r#"<div class="callout" data-type=""#);
150            out.push_str(kind.as_slug());
151            out.push_str(r#"""#);
152            push_source_line_attr(out, meta.source_line);
153            if let Some(fold_state) = fold {
154                let fold_attr = match fold_state {
155                    Fold::Open => "open",
156                    Fold::Closed => "closed",
157                };
158                out.push_str(r#" data-fold=""#);
159                out.push_str(fold_attr);
160                out.push_str(r#"""#);
161            }
162            out.push_str(">\n");
163            // Title slot: prefer the parser-extracted title; fall back
164            // to the kind's capitalized default (matches Stage 1).
165            let display_title = title
166                .as_deref()
167                .map(|t| t.trim())
168                .filter(|t| !t.is_empty())
169                .map(|t| escape_text(t))
170                .unwrap_or_else(|| kind.default_title().to_string());
171            out.push_str(r#"  <div class="callout-title">"#);
172            out.push_str(&display_title);
173            out.push_str("</div>\n");
174            out.push_str(r#"  <div class="callout-content">"#);
175            out.push('\n');
176            render_blocks(hooks, out, children);
177            out.push_str("</div>\n");
178            out.push_str("</div>\n");
179        }
180        Block::List {
181            ordered,
182            start,
183            items,
184            item_source_lines,
185        } => {
186            // Parallel-vec invariant: when the parser populated per-item
187            // source lines, the vector must align 1:1 with `items` so the
188            // `idx`-keyed lookup at `item_source_lines.get(idx)` is
189            // well-defined. Empty (default) means "parser ran without
190            // `emit_source_lines`" — that's the legitimate skip case.
191            // Mirrors the document-level `blocks.len() == block_meta.len()`
192            // invariant asserted at the top of `render_document`.
193            debug_assert!(
194                item_source_lines.is_empty() || item_source_lines.len() == items.len(),
195                "Block::List invariant: item_source_lines.len() ({}) must equal items.len() ({}) when populated",
196                item_source_lines.len(),
197                items.len()
198            );
199            if *ordered {
200                out.push_str("<ol");
201                // Emit `start="N"` when the parser captured an explicit
202                // non-default start number (`3. foo` → `Some(3)`).
203                // `None` for the default `1. foo` case keeps the
204                // shorter `<ol>` shape. Attribute order mirrors other
205                // typed-AST blocks: existing tag attrs first, then
206                // `data-source-line`. Phase 4 followup B (2026-05-28).
207                if let Some(n) = start {
208                    out.push_str(" start=\"");
209                    out.push_str(&n.to_string());
210                    out.push('"');
211                }
212                push_source_line_attr(out, meta.source_line);
213                out.push_str(">\n");
214            } else {
215                out.push_str("<ul");
216                push_source_line_attr(out, meta.source_line);
217                out.push_str(">\n");
218            }
219            for (idx, item_blocks) in items.iter().enumerate() {
220                // Per-`<li>` source line — populated only when the parser
221                // ran with `emit_source_lines: true` (otherwise
222                // `item_source_lines` is empty). Mirrors the legacy
223                // transform_events shape (commit f91aca8fa, 2026-04-01) that
224                // emitted `data-source-line` on `<li>` for proportional
225                // scroll-sync interpolation between editor and preview.
226                out.push_str("<li");
227                let item_line = item_source_lines.get(idx).copied().flatten();
228                push_source_line_attr(out, item_line);
229                out.push('>');
230                // Single-paragraph items render their inline content inline
231                // (no extra <p>). Mirrors pulldown-cmark's "tight list" output.
232                if let [Block::Paragraph(inlines)] = item_blocks.as_slice() {
233                    render_inlines(hooks, out, inlines);
234                } else {
235                    out.push('\n');
236                    render_blocks(hooks, out, item_blocks);
237                }
238                out.push_str("</li>\n");
239            }
240            if *ordered {
241                out.push_str("</ol>\n");
242            } else {
243                out.push_str("</ul>\n");
244            }
245        }
246        Block::CodeBlock { lang, value } => {
247            out.push_str("<pre");
248            push_source_line_attr(out, meta.source_line);
249            out.push('>');
250            match lang {
251                Some(l) => {
252                    out.push_str(r#"<code class="language-"#);
253                    out.push_str(&escape_attr(l));
254                    out.push_str(r#"">"#);
255                }
256                None => out.push_str("<code>"),
257            }
258            out.push_str(&escape_text(value));
259            out.push_str("</code></pre>\n");
260        }
261        Block::Table {
262            header,
263            rows,
264            header_source_line,
265            row_source_lines,
266        } => {
267            // Parallel-vec invariant: when the parser populated per-row
268            // source lines, the vector must align 1:1 with `rows`.
269            // Empty (default) means "parser ran without
270            // `emit_source_lines`" — that's the legitimate skip case.
271            // Mirrors the `Block::List` and document-level invariants.
272            debug_assert!(
273                row_source_lines.is_empty() || row_source_lines.len() == rows.len(),
274                "Block::Table invariant: row_source_lines.len() ({}) must equal rows.len() ({}) when populated",
275                row_source_lines.len(),
276                rows.len()
277            );
278            out.push_str("<table");
279            push_source_line_attr(out, meta.source_line);
280            out.push_str(">\n<thead>\n<tr");
281            // Header `<tr>` source line. Same f91aca8fa shape — annotated
282            // when the parser tracked lines, omitted otherwise.
283            push_source_line_attr(out, *header_source_line);
284            out.push('>');
285            for cell in header {
286                out.push_str("<th>");
287                render_inlines(hooks, out, cell);
288                out.push_str("</th>");
289            }
290            out.push_str("</tr>\n</thead>\n");
291            if !rows.is_empty() {
292                out.push_str("<tbody>\n");
293                for (idx, row) in rows.iter().enumerate() {
294                    out.push_str("<tr");
295                    let row_line = row_source_lines.get(idx).copied().flatten();
296                    push_source_line_attr(out, row_line);
297                    out.push('>');
298                    for cell in row {
299                        out.push_str("<td>");
300                        render_inlines(hooks, out, cell);
301                        out.push_str("</td>");
302                    }
303                    out.push_str("</tr>\n");
304                }
305                out.push_str("</tbody>\n");
306            }
307            out.push_str("</table>\n");
308        }
309        Block::BlockQuote(children) => {
310            out.push_str("<blockquote");
311            push_source_line_attr(out, meta.source_line);
312            out.push_str(">\n");
313            render_blocks(hooks, out, children);
314            out.push_str("</blockquote>\n");
315        }
316        Block::Shortcode(sc) => {
317            hooks.render_shortcode(out, sc, meta.source_line);
318            out.push('\n');
319        }
320        Block::ThematicBreak => {
321            out.push_str("<hr");
322            push_source_line_attr(out, meta.source_line);
323            out.push_str(" />\n");
324        }
325        Block::Figure {
326            image,
327            caption,
328            width,
329            align,
330            class_names,
331            img_style,
332        } => {
333            // Phase 4 PR3 (2026-05-27): image-only paragraphs promoted at
334            // parse time become Block::Figure. The render shape is a
335            // `<figure class="moss-image">` wrap around the image hook's
336            // output, optionally followed by `<figcaption>{caption}</figcaption>`.
337            //
338            // The inner image renders via `hooks.render_image` (the same
339            // path as Inline::Image — production wires this through
340            // `DefaultHooks::with_snapshot` / `PipelineHooks` which uses
341            // `ImageContext::MarkdownInline`, producing the bare
342            // `<picture><img></picture>` shape). The structural `<figure>`
343            // wrapper is the Figure renderer's responsibility — this keeps
344            // the byte shape contract with shape-spec § 1: the spec sample
345            // shows `<figure>` containing exactly the MarkdownInline inner.
346            //
347            // Caption omission: `caption: None` means "no figcaption" (the
348            // empty-alt case). Empty caption Vec is also treated as no
349            // figcaption — defensive, since `caption: Some(vec![])` would
350            // otherwise emit `<figcaption></figcaption>`.
351            //
352            // Figure-level display params (`width`, `align`, `class_names`,
353            // `img_style`) are populated only by parameterized wikilink
354            // embeds (image-embed synth-collapse). The class list /
355            // `data-width=` byte shape matches
356            // `render::image::wrap_in_figure_full` so an embed-sourced figure
357            // and a CommonMark `![](url)` figure with the same params are
358            // byte-identical. For the CommonMark path these are all defaults,
359            // so `class="moss-image"` with no `data-width=` — unchanged from
360            // before the collapse.
361            let mut class_value = String::from("moss-image");
362            if let Some(a) = align {
363                class_value.push(' ');
364                class_value.push_str(a);
365            }
366            for cn in class_names {
367                if cn.is_empty() {
368                    continue;
369                }
370                class_value.push(' ');
371                class_value.push_str(cn);
372            }
373            out.push_str(r#"<figure class=""#);
374            out.push_str(&escape_attr(&class_value));
375            out.push('"');
376            if let Some(w) = width {
377                if w.ends_with('%') {
378                    // Content-relative percent → inline style on the figure.
379                    // The figure has never carried `style=` (img_style lives on
380                    // the inner <img>), so there is no collision; the centering
381                    // CSS keys off the same shape.
382                    out.push_str(r#" style="width:"#);
383                    out.push_str(&escape_attr(w));
384                    out.push('"');
385                } else {
386                    // Named token → data-width contract (unchanged).
387                    out.push_str(r#" data-width=""#);
388                    out.push_str(&escape_attr(w));
389                    out.push('"');
390                }
391            }
392            push_source_line_attr(out, meta.source_line);
393            out.push('>');
394            // Render the inner image. Pattern-match the constrained shape;
395            // any other inline falls back to the standard inline path so
396            // the renderer never panics on a malformed Figure.
397            match image {
398                Inline::Image {
399                    src, alt, title, ..
400                } => match src {
401                    Url::Resolved(r) => {
402                        hooks.render_image_styled(out, r, alt, title.as_deref(), img_style.as_deref());
403                    }
404                    Url::Unresolved(s) => {
405                        debug_assert!(
406                                false,
407                                "Url::Unresolved({s:?}) reached Block::Figure renderer — visit_urls_mut missing or buggy"
408                            );
409                        out.push_str(r#"<img src=""#);
410                        out.push_str(&escape_attr(s));
411                        out.push_str(r#"" alt=""#);
412                        out.push_str(&escape_attr(alt));
413                        out.push_str(r#"" />"#);
414                    }
415                },
416                _ => {
417                    // Defensive: a non-Image inline in a Figure violates
418                    // the parser-enforced shape, but the renderer must
419                    // still emit something rather than crash.
420                    render_inline(hooks, out, image);
421                }
422            }
423            if let Some(cap_inlines) = caption {
424                if !cap_inlines.is_empty() {
425                    out.push_str("<figcaption>");
426                    render_inlines(hooks, out, cap_inlines);
427                    out.push_str("</figcaption>");
428                }
429            }
430            out.push_str("</figure>\n");
431        }
432        Block::LinkCard { url, children } => {
433            // Phase 4 PR4.5 (2026-05-28): the compound-link grid-cell shape.
434            // External URLs render as a link-preview wrapper; internal URLs
435            // render as `data-kind="link"` grid-card.
436            //
437            // Production byte shape matches today's src-tauri
438            // `render_compound_link_cell` output (ported here so that
439            // shape was deleted from src-tauri in PR4.5). The wrapping
440            // `<div class="moss-grid">` chrome lives in the Grid render
441            // arm in hooks.rs; LinkCard is the per-cell shape.
442            let resolved = match url {
443                Url::Resolved(r) => r,
444                Url::Unresolved(s) => {
445                    debug_assert!(
446                        false,
447                        "Url::Unresolved({s:?}) reached Block::LinkCard renderer — visit_urls_mut missing or buggy"
448                    );
449                    out.push_str(r#"<a href=""#);
450                    out.push_str(&escape_attr(s));
451                    out.push_str(r#"" class="moss-grid-card" data-kind="link">"#);
452                    render_blocks(hooks, out, children);
453                    out.push_str("</a>");
454                    return;
455                }
456            };
457            use super::url::UrlKind;
458            let is_external = matches!(resolved.kind, UrlKind::External | UrlKind::AssetNewtab);
459            if is_external {
460                out.push_str(r#"<a href=""#);
461                out.push_str(&escape_attr(&resolved.href));
462                out.push_str(
463                    r#"" class="moss-grid-card link-preview" target="_blank" rel="noopener">"#,
464                );
465            } else {
466                out.push_str(r#"<a href=""#);
467                out.push_str(&escape_attr(&resolved.href));
468                out.push_str(r#"" class="moss-grid-card" data-kind="link">"#);
469            }
470            render_blocks(hooks, out, children);
471            out.push_str("</a>");
472        }
473        Block::Other(html) => {
474            out.push_str(html);
475        }
476    }
477}
478
479/// Append ` data-source-line="N"` to `out` when `source_line` is `Some`.
480/// No-op otherwise.
481///
482/// Used at every top-level block's opening tag arm so the preview's
483/// `cm-scroll-sync` (in `frontend/bridge/iframe-bridge.ts`) can locate
484/// the DOM element that corresponds to a given editor source line.
485///
486/// Matches the legacy `transform_events` emit byte shape — leading space,
487/// double-quoted attribute value, decimal integer — verified against
488/// `src-tauri/src/build/ship.rs::apply_strip_removes_data_source_line`
489/// which scrubs this exact pattern from the ship-stage output.
490fn push_source_line_attr(out: &mut String, source_line: Option<usize>) {
491    if let Some(n) = source_line {
492        use std::fmt::Write as _;
493        // unwrap_or: writing into a String never fails, but the API
494        // returns Result. Keep this honest.
495        let _ = write!(out, r#" data-source-line="{}""#, n);
496    }
497}
498
499pub(super) fn render_inlines<H: RenderHooks + ?Sized>(
500    hooks: &H,
501    out: &mut String,
502    inlines: &[Inline],
503) {
504    for inline in inlines {
505        render_inline(hooks, out, inline);
506    }
507}
508
509fn render_inline<H: RenderHooks + ?Sized>(hooks: &H, out: &mut String, inline: &Inline) {
510    match inline {
511        Inline::Text(t) => out.push_str(&escape_text(t)),
512        Inline::Link {
513            url,
514            title: _title,
515            children,
516            is_wikilink,
517        } => {
518            let resolved = match url {
519                Url::Resolved(r) => r,
520                Url::Unresolved(s) => {
521                    debug_assert!(
522                        false,
523                        "Url::Unresolved({s:?}) reached renderer — visit_urls_mut missing or buggy"
524                    );
525                    // In release: emit href as-is so we don't crash, but
526                    // the wide-net invariant test will catch the leak.
527                    out.push_str(r#"<a href=""#);
528                    out.push_str(&escape_attr(s));
529                    out.push_str(r#"">"#);
530                    render_inlines(hooks, out, children);
531                    out.push_str("</a>");
532                    return;
533                }
534            };
535            let mut content = String::new();
536            render_inlines(hooks, &mut content, children);
537            // Phase 4 PR7a-flip-core-A (2026-05-28): pass the
538            // `is_wikilink` flag directly to the hook. Pre-flip-core-A,
539            // this arm synthesized a wikilink-kinded `ResolvedUrl` to
540            // coax the hook's wikilink branch — a lossy workaround that
541            // dropped the original `UrlKind` (`AssetNewtab` wikilinks
542            // lost their `target="_blank" rel="noopener"`). The hook's
543            // new signature carries both concerns orthogonally.
544            hooks.render_link(out, resolved, *is_wikilink, &content);
545        }
546        Inline::Image {
547            src, alt, title, ..
548        } => {
549            let resolved = match src {
550                Url::Resolved(r) => r,
551                Url::Unresolved(s) => {
552                    debug_assert!(
553                        false,
554                        "Url::Unresolved({s:?}) reached renderer — visit_urls_mut missing or buggy"
555                    );
556                    out.push_str(r#"<img src=""#);
557                    out.push_str(&escape_attr(s));
558                    out.push_str(r#"" alt=""#);
559                    out.push_str(&escape_attr(alt));
560                    out.push_str(r#"" />"#);
561                    return;
562                }
563            };
564            hooks.render_image(out, resolved, alt, title.as_deref());
565        }
566        Inline::Emphasis(children) => {
567            out.push_str("<em>");
568            render_inlines(hooks, out, children);
569            out.push_str("</em>");
570        }
571        Inline::Strong(children) => {
572            out.push_str("<strong>");
573            render_inlines(hooks, out, children);
574            out.push_str("</strong>");
575        }
576        Inline::Code(c) => {
577            out.push_str("<code>");
578            out.push_str(&escape_text(c));
579            out.push_str("</code>");
580        }
581        Inline::LineBreak => out.push_str("<br />\n"),
582        Inline::Other(html) => {
583            // A math node (ADR-030) is an `Inline::Other` carrying the P1
584            // escaped-source `<code class="moss-math">` payload. Route it
585            // through `render_math` so a typesetting hook (src-tauri's
586            // `PipelineHooks`) can replace it with an SVG; the default hook
587            // re-emits `html` verbatim, so non-pipeline renders are byte-
588            // identical to P1. Any non-math `Inline::Other` falls straight
589            // through to a raw push.
590            match super::math_text::math_node_parts(html) {
591                Some((tex, display)) => hooks.render_math(out, &tex, display, html),
592                None => out.push_str(html),
593            }
594        }
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::super::hooks::DefaultHooks;
601    use super::super::node::Inline;
602    use super::super::url::{Url, UrlKind};
603    use super::*;
604
605    fn render(blocks: Vec<Block>) -> String {
606        let doc = Document::from_blocks(blocks);
607        render_document(&doc, &DefaultHooks::new())
608    }
609
610    #[test]
611    fn renders_empty_document_to_empty_string() {
612        assert_eq!(render(vec![]), "");
613    }
614
615    #[test]
616    fn renders_paragraph() {
617        let html = render(vec![Block::Paragraph(vec![Inline::Text("hi".into())])]);
618        assert_eq!(html, "<p>hi</p>\n");
619    }
620
621    #[test]
622    fn renders_heading_with_id() {
623        let html = render(vec![Block::Heading {
624            level: 2,
625            children: vec![Inline::Text("Setup".into())],
626            id: Some("setup".into()),
627        }]);
628        assert_eq!(html, "<h2 id=\"setup\">Setup<a class=\"moss-heading-anchor\" href=\"#setup\" aria-label=\"Permalink to this section\"><span aria-hidden=\"true\">#</span></a></h2>\n");
629    }
630
631    #[test]
632    fn renders_resolved_link_internal() {
633        let html = render(vec![Block::Paragraph(vec![Inline::Link {
634            url: Url::resolved("docs/", UrlKind::Internal),
635            title: None,
636            children: vec![Inline::Text("Docs".into())],
637            is_wikilink: false,
638        }])]);
639        assert_eq!(html, "<p><a href=\"docs/\">Docs</a></p>\n");
640    }
641
642    #[test]
643    fn renders_resolved_link_wikilink_carries_class() {
644        // PR7a: wikilink class can come from either the resolved URL kind
645        // (legacy production path) OR the new is_wikilink AST flag.
646        let html = render(vec![Block::Paragraph(vec![Inline::Link {
647            url: Url::resolved("../docs/", UrlKind::Wikilink),
648            title: None,
649            children: vec![Inline::Text("Docs".into())],
650            is_wikilink: false,
651        }])]);
652        assert!(html.contains(r#"class="wikilink""#), "got: {html}");
653    }
654
655    #[test]
656    fn renders_link_with_is_wikilink_flag_emits_class() {
657        // PR7a: is_wikilink: true on a non-wikilink-kind URL still
658        // produces the wikilink class. Parser sets this for any
659        // pulldown-cmark Tag::Link { link_type: LinkType::WikiLink, .. }.
660        let html = render(vec![Block::Paragraph(vec![Inline::Link {
661            url: Url::resolved("../docs/", UrlKind::Internal),
662            title: None,
663            children: vec![Inline::Text("Docs".into())],
664            is_wikilink: true,
665        }])]);
666        assert!(
667            html.contains(r#"class="wikilink""#),
668            "is_wikilink: true should produce class=\"wikilink\"; got: {html}"
669        );
670    }
671
672    #[test]
673    fn renders_resolved_image() {
674        let html = render(vec![Block::Paragraph(vec![Inline::Image {
675            src: Url::resolved("cat.jpg", UrlKind::Asset),
676            alt: "Cat".into(),
677            title: None,
678            is_wikilink: false,
679            wikilink_pothole: None,
680        }])]);
681        assert_eq!(html, "<p><img src=\"cat.jpg\" alt=\"Cat\" /></p>\n");
682    }
683
684    #[test]
685    fn renders_emphasis_and_strong() {
686        let html = render(vec![Block::Paragraph(vec![
687            Inline::Emphasis(vec![Inline::Text("em".into())]),
688            Inline::Text(" ".into()),
689            Inline::Strong(vec![Inline::Text("strong".into())]),
690        ])]);
691        assert_eq!(html, "<p><em>em</em> <strong>strong</strong></p>\n");
692    }
693
694    #[test]
695    fn renders_inline_code_with_escaping() {
696        let html = render(vec![Block::Paragraph(vec![Inline::Code("a<b>c".into())])]);
697        assert_eq!(html, "<p><code>a&lt;b&gt;c</code></p>\n");
698    }
699
700    #[test]
701    fn renders_unordered_list_tight() {
702        let html = render(vec![Block::List {
703            ordered: false,
704            start: None,
705            items: vec![
706                vec![Block::Paragraph(vec![Inline::Text("one".into())])],
707                vec![Block::Paragraph(vec![Inline::Text("two".into())])],
708            ],
709            item_source_lines: vec![],
710        }]);
711        assert_eq!(html, "<ul>\n<li>one</li>\n<li>two</li>\n</ul>\n");
712    }
713
714    #[test]
715    fn renders_ordered_list() {
716        let html = render(vec![Block::List {
717            ordered: true,
718            start: None,
719            items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
720            item_source_lines: vec![],
721        }]);
722        assert!(html.starts_with("<ol>"));
723    }
724
725    #[test]
726    fn render_ordered_list_emits_start_attribute_when_non_default() {
727        // `3. foo` should produce `<ol start="3">…</ol>`. The attribute
728        // appears immediately after `<ol`, before any `data-source-line`
729        // (Phase 4 followup B contract).
730        let html = render(vec![Block::List {
731            ordered: true,
732            start: Some(3),
733            items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
734            item_source_lines: vec![],
735        }]);
736        assert!(
737            html.starts_with(r#"<ol start="3">"#),
738            "expected start attr immediately after <ol, got: {html}"
739        );
740    }
741
742    #[test]
743    fn render_ordered_list_omits_start_when_default_1() {
744        // `start: None` is the canonical shape for "default 1." lists.
745        // The renderer must NOT emit `start="1"` (semantically
746        // identical to omitting the attr, but noisier).
747        let html = render(vec![Block::List {
748            ordered: true,
749            start: None,
750            items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
751            item_source_lines: vec![],
752        }]);
753        assert!(html.starts_with("<ol>"), "expected bare <ol>, got: {html}");
754        assert!(
755            !html.contains("start="),
756            "ordered list with default start should not emit start attr, got: {html}"
757        );
758    }
759
760    #[test]
761    fn render_unordered_list_emits_no_start() {
762        // Even if `start: Some(N)` were somehow set on an unordered
763        // list (shouldn't happen via the parser, but defense in depth),
764        // `<ul>` must never carry `start=`.
765        let html = render(vec![Block::List {
766            ordered: false,
767            start: Some(5),
768            items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
769            item_source_lines: vec![],
770        }]);
771        assert!(html.starts_with("<ul>"), "expected bare <ul>, got: {html}");
772        assert!(
773            !html.contains("start="),
774            "unordered list must never carry start attr, got: {html}"
775        );
776    }
777
778    #[test]
779    fn renders_code_block_with_lang() {
780        let html = render(vec![Block::CodeBlock {
781            lang: Some("rust".into()),
782            value: "fn main() {}".into(),
783        }]);
784        assert_eq!(
785            html,
786            "<pre><code class=\"language-rust\">fn main() {}</code></pre>\n"
787        );
788    }
789
790    #[test]
791    fn renders_code_block_without_lang() {
792        let html = render(vec![Block::CodeBlock {
793            lang: None,
794            value: "bare".into(),
795        }]);
796        assert_eq!(html, "<pre><code>bare</code></pre>\n");
797    }
798
799    #[test]
800    fn renders_thematic_break() {
801        let html = render(vec![Block::ThematicBreak]);
802        assert_eq!(html, "<hr />\n");
803    }
804
805    // -----------------------------------------------------------------
806    // Phase 4 PR4: Block::Callout render shape
807    // -----------------------------------------------------------------
808
809    use super::super::node::{CalloutKind, Fold};
810
811    #[test]
812    fn renders_basic_callout_with_title() {
813        let html = render(vec![Block::Callout {
814            kind: CalloutKind::Note,
815            fold: None,
816            title: Some("Heads up".into()),
817            children: vec![Block::Paragraph(vec![Inline::Text("Body.".into())])],
818        }]);
819        assert!(
820            html.contains(r#"<div class="callout" data-type="note">"#),
821            "expected callout div with data-type, got: {html}"
822        );
823        assert!(
824            html.contains(r#"<div class="callout-title">Heads up</div>"#),
825            "expected inline title slot, got: {html}"
826        );
827        assert!(
828            html.contains(r#"<div class="callout-content">"#),
829            "expected content slot, got: {html}"
830        );
831        assert!(html.contains("<p>Body.</p>"), "body must render: {html}");
832    }
833
834    #[test]
835    fn renders_callout_falls_back_to_default_title() {
836        let html = render(vec![Block::Callout {
837            kind: CalloutKind::Warning,
838            fold: None,
839            title: None,
840            children: vec![],
841        }]);
842        assert!(
843            html.contains(r#"<div class="callout-title">Warning</div>"#),
844            "expected capitalized fallback title, got: {html}"
845        );
846    }
847
848    #[test]
849    fn renders_foldable_callout_with_data_fold_attribute() {
850        let html_open = render(vec![Block::Callout {
851            kind: CalloutKind::Tip,
852            fold: Some(Fold::Open),
853            title: Some("Open".into()),
854            children: vec![],
855        }]);
856        assert!(
857            html_open.contains(r#"data-type="tip""#) && html_open.contains(r#"data-fold="open""#),
858            "expected data-fold='open' attribute, got: {html_open}"
859        );
860
861        let html_closed = render(vec![Block::Callout {
862            kind: CalloutKind::Tip,
863            fold: Some(Fold::Closed),
864            title: None,
865            children: vec![],
866        }]);
867        assert!(
868            html_closed.contains(r#"data-fold="closed""#),
869            "expected data-fold='closed' attribute, got: {html_closed}"
870        );
871    }
872
873    #[test]
874    fn callout_alias_renders_canonical_data_type_slug() {
875        // tldr → abstract; ensures the canonicalized slug is what
876        // appears in HTML.
877        let html = render(vec![Block::Callout {
878            kind: CalloutKind::Abstract,
879            fold: None,
880            title: Some("TL;DR".into()),
881            children: vec![],
882        }]);
883        assert!(
884            html.contains(r#"data-type="abstract""#),
885            "expected canonical slug 'abstract', got: {html}"
886        );
887    }
888
889    #[test]
890    fn callout_title_is_html_escaped() {
891        // Title is rendered through escape_text (the same function the
892        // existing renderer uses for text content). escape_text escapes
893        // `<`, `>`, `&` but NOT `"` — `"` is only dangerous inside HTML
894        // attribute values, and title sits between `<div>` tags as text.
895        let html = render(vec![Block::Callout {
896            kind: CalloutKind::Warning,
897            fold: None,
898            title: Some(r#"Use <script> & "quotes""#.into()),
899            children: vec![],
900        }]);
901        assert!(
902            html.contains("Use &lt;script&gt; &amp;"),
903            "title must escape lt/gt/amp, got: {html}"
904        );
905        // No raw `<script>` may appear inside the title div.
906        assert!(
907            !html.contains("<div class=\"callout-title\">Use <script>"),
908            "unescaped angle brackets leaked, got: {html}"
909        );
910    }
911
912    #[test]
913    fn renders_blockquote_with_paragraph() {
914        let html = render(vec![Block::BlockQuote(vec![Block::Paragraph(vec![
915            Inline::Text("q".into()),
916        ])])]);
917        assert_eq!(html, "<blockquote>\n<p>q</p>\n</blockquote>\n");
918    }
919
920    #[test]
921    fn renders_table() {
922        let html = render(vec![Block::Table {
923            header: vec![vec![Inline::Text("A".into())]],
924            rows: vec![vec![vec![Inline::Text("1".into())]]],
925            header_source_line: None,
926            row_source_lines: vec![],
927        }]);
928        assert!(html.contains("<thead>"));
929        assert!(html.contains("<tbody>"));
930        assert!(html.contains("<th>A</th>"));
931        assert!(html.contains("<td>1</td>"));
932    }
933
934    #[test]
935    fn renders_other_block_passes_html_through() {
936        let html = render(vec![Block::Other("<custom></custom>".into())]);
937        assert_eq!(html, "<custom></custom>");
938    }
939
940    #[test]
941    fn text_escapes_lt_gt_amp() {
942        let html = render(vec![Block::Paragraph(vec![Inline::Text("a<b>c&d".into())])]);
943        assert_eq!(html, "<p>a&lt;b&gt;c&amp;d</p>\n");
944    }
945
946    #[test]
947    fn round_trips_parse_to_render_for_canonical_doc() {
948        // End-to-end: post-resolve markdown → parse → simulate visit
949        // (mark every URL Internal) → render → check shape.
950        //
951        // Phase 4 PR2: the parser now populates Block::Heading.id with the
952        // Obsidian anchor slug, so the rendered <h1> carries id="title".
953        let md = "# Title\n\npara with [link](docs/) and *em*.\n";
954        let mut doc = super::super::parser::parse(md);
955        super::super::visit::visit_urls_mut(&mut doc, |u| match u {
956            Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Internal),
957            _ => {}
958        });
959        let html = render_document(&doc, &DefaultHooks::new());
960        assert!(html.contains(r##"<h1 id="title">Title<a class="moss-heading-anchor" href="#title" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h1>"##), "got: {html}");
961        assert!(html.contains(r#"<a href="docs/">link</a>"#));
962        assert!(html.contains("<em>em</em>"));
963    }
964
965    // -----------------------------------------------------------------
966    // Phase 4 PR3 (2026-05-27): Block::Figure render
967    // -----------------------------------------------------------------
968
969    #[test]
970    fn figure_renders_with_caption() {
971        // Canonical shape: <figure class="moss-image">{inner img}{figcaption}</figure>.
972        // DefaultHooks::new() has no snapshot, so inner is the bare <img>
973        // (test path). Production wires DefaultHooks::with_snapshot which
974        // routes inner through synth — same shape, richer attrs.
975        let html = render(vec![Block::Figure {
976            image: Inline::Image {
977                src: Url::resolved("logo.png", UrlKind::Asset),
978                alt: "A logo".into(),
979                title: None,
980                is_wikilink: false,
981                wikilink_pothole: None,
982            },
983            caption: Some(vec![Inline::Text("A logo".into())]),
984            width: None,
985            align: None,
986            class_names: Vec::new(),
987            img_style: None,
988        }]);
989        assert!(
990            html.starts_with(r#"<figure class="moss-image">"#),
991            "expected figure wrap, got: {html}"
992        );
993        assert!(html.contains(r#"src="logo.png""#), "got: {html}");
994        assert!(html.contains(r#"alt="A logo""#), "got: {html}");
995        assert!(
996            html.contains("<figcaption>A logo</figcaption>"),
997            "got: {html}"
998        );
999        assert!(html.ends_with("</figure>\n"), "got: {html}");
1000    }
1001
1002    #[test]
1003    fn figure_renders_without_caption_when_none() {
1004        // Empty-alt case: caption: None → no <figcaption> element.
1005        let html = render(vec![Block::Figure {
1006            image: Inline::Image {
1007                src: Url::resolved("x.png", UrlKind::Asset),
1008                alt: String::new(),
1009                title: None,
1010                is_wikilink: false,
1011                wikilink_pothole: None,
1012            },
1013            caption: None,
1014            width: None,
1015            align: None,
1016            class_names: Vec::new(),
1017            img_style: None,
1018        }]);
1019        assert!(html.contains("<figure"), "got: {html}");
1020        assert!(
1021            !html.contains("<figcaption"),
1022            "expected no figcaption, got: {html}"
1023        );
1024        assert!(html.contains("</figure>"), "got: {html}");
1025    }
1026
1027    #[test]
1028    fn figure_renders_no_figcaption_for_empty_caption_vec() {
1029        // Defensive: caption: Some(vec![]) is treated identically to None.
1030        let html = render(vec![Block::Figure {
1031            image: Inline::Image {
1032                src: Url::resolved("x.png", UrlKind::Asset),
1033                alt: "x".into(),
1034                title: None,
1035                is_wikilink: false,
1036                wikilink_pothole: None,
1037            },
1038            caption: Some(vec![]),
1039            width: None,
1040            align: None,
1041            class_names: Vec::new(),
1042            img_style: None,
1043        }]);
1044        assert!(!html.contains("<figcaption"), "got: {html}");
1045    }
1046
1047    // Editor Image UX (2026-06-04): a `%`-suffixed Figure width renders as
1048    // an inline style="width:NN%"; a named token stays data-width=.
1049    // -----------------------------------------------------------------
1050
1051    #[test]
1052    fn figure_percent_width_emits_inline_style() {
1053        let html = render(vec![Block::Figure {
1054            image: Inline::Image {
1055                src: Url::resolved("pic.jpg", UrlKind::Asset),
1056                alt: "alt".into(),
1057                title: None,
1058                is_wikilink: false,
1059                wikilink_pothole: None,
1060            },
1061            caption: None,
1062            width: Some("55%".to_string()),
1063            align: None,
1064            class_names: vec![],
1065            img_style: None,
1066        }]);
1067        assert!(
1068            html.contains(r#"<figure class="moss-image" style="width:55%""#),
1069            "got: {html}"
1070        );
1071        assert!(
1072            !html.contains("data-width="),
1073            "percent must not emit data-width: {html}"
1074        );
1075    }
1076
1077    #[test]
1078    fn figure_named_width_still_emits_data_width() {
1079        let html = render(vec![Block::Figure {
1080            image: Inline::Image {
1081                src: Url::resolved("pic.jpg", UrlKind::Asset),
1082                alt: "alt".into(),
1083                title: None,
1084                is_wikilink: false,
1085                wikilink_pothole: None,
1086            },
1087            caption: None,
1088            width: Some("wide".to_string()),
1089            align: None,
1090            class_names: vec![],
1091            img_style: None,
1092        }]);
1093        assert!(html.contains(r#"data-width="wide""#), "got: {html}");
1094        assert!(
1095            !html.contains("style=\"width"),
1096            "named token must not emit style: {html}"
1097        );
1098    }
1099
1100    #[test]
1101    fn figure_caption_escapes_html_unsafe_chars() {
1102        // Caption is a Vec<Inline>; Inline::Text passes through
1103        // escape_text. The figure renderer must NOT double-escape; the
1104        // existing inline path is the single source of escaping.
1105        let html = render(vec![Block::Figure {
1106            image: Inline::Image {
1107                src: Url::resolved("p.jpg", UrlKind::Asset),
1108                alt: "a<b>c".into(),
1109                title: None,
1110                is_wikilink: false,
1111                wikilink_pothole: None,
1112            },
1113            caption: Some(vec![Inline::Text("a<b>c".into())]),
1114            width: None,
1115            align: None,
1116            class_names: Vec::new(),
1117            img_style: None,
1118        }]);
1119        assert!(
1120            html.contains("<figcaption>a&lt;b&gt;c</figcaption>"),
1121            "got: {html}"
1122        );
1123    }
1124
1125    #[test]
1126    fn figure_end_to_end_from_parser_to_render() {
1127        // Parse → visit (resolve URL) → render: covers the full path.
1128        let md = "![A photo](photo.jpg)\n";
1129        let mut doc = super::super::parser::parse(md);
1130        super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1131            Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Asset),
1132            _ => {}
1133        });
1134        let html = render_document(&doc, &DefaultHooks::new());
1135        assert!(
1136            html.contains(r#"<figure class="moss-image">"#),
1137            "expected figure, got: {html}"
1138        );
1139        assert!(html.contains(r#"src="photo.jpg""#), "got: {html}");
1140        assert!(
1141            html.contains("<figcaption>A photo</figcaption>"),
1142            "got: {html}"
1143        );
1144    }
1145
1146    #[test]
1147    fn paragraph_with_image_and_text_does_not_become_figure() {
1148        // End-to-end regression guard: ![img](u) caption text MUST stay
1149        // as a paragraph (not get the figure wrap) so the prose isn't
1150        // swallowed. Mirrors the parser-side guard `image_with_caption_text_does_not_promote`.
1151        let md = "![alt](a.jpg) plain text\n";
1152        let mut doc = super::super::parser::parse(md);
1153        super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1154            Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Asset),
1155            _ => {}
1156        });
1157        let html = render_document(&doc, &DefaultHooks::new());
1158        assert!(
1159            !html.contains("<figure"),
1160            "image+text must not be wrapped in figure, got: {html}"
1161        );
1162        assert!(html.contains("plain text"), "got: {html}");
1163    }
1164
1165    #[test]
1166    #[cfg(debug_assertions)]
1167    #[should_panic(expected = "visit_urls_mut missing")]
1168    fn unresolved_url_in_link_panics_in_debug() {
1169        // Critical contract: the bypass class is a debug-time crash.
1170        let _ = render(vec![Block::Paragraph(vec![Inline::Link {
1171            url: Url::unresolved("docs/"),
1172            title: None,
1173            children: vec![],
1174            is_wikilink: false,
1175        }])]);
1176    }
1177
1178    // -----------------------------------------------------------------
1179    // 2026-05-28 (Phase 4 source-line wiring): BlockMeta → data-source-line
1180    // emission.
1181    // -----------------------------------------------------------------
1182
1183    /// Render with explicit per-block meta. Helper for the source-line tests.
1184    fn render_with_meta(blocks: Vec<Block>, meta: Vec<BlockMeta>) -> String {
1185        let doc = Document::from_blocks_with_meta(blocks, meta);
1186        render_document(&doc, &DefaultHooks::new())
1187    }
1188
1189    #[test]
1190    fn paragraph_emits_data_source_line_when_meta_set() {
1191        let html = render_with_meta(
1192            vec![Block::Paragraph(vec![Inline::Text("hi".into())])],
1193            vec![BlockMeta {
1194                source_line: Some(7),
1195            }],
1196        );
1197        assert_eq!(html, "<p data-source-line=\"7\">hi</p>\n");
1198    }
1199
1200    #[test]
1201    fn heading_emits_data_source_line_through_hook() {
1202        let html = render_with_meta(
1203            vec![Block::Heading {
1204                level: 2,
1205                children: vec![Inline::Text("Setup".into())],
1206                id: Some("setup".into()),
1207            }],
1208            vec![BlockMeta {
1209                source_line: Some(3),
1210            }],
1211        );
1212        assert!(
1213            html.contains(r##"<h2 id="setup" data-source-line="3">Setup<a class="moss-heading-anchor" href="#setup" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h2>"##),
1214            "got: {html}"
1215        );
1216    }
1217
1218    #[test]
1219    fn list_blockquote_codeblock_table_hr_emit_data_source_line() {
1220        // Each block type that the legacy transform_events annotated
1221        // must emit `data-source-line` when meta carries it. Single
1222        // smoke test covering every top-level block kind.
1223        let blocks = vec![
1224            Block::BlockQuote(vec![Block::Paragraph(vec![Inline::Text("q".into())])]),
1225            Block::List {
1226                ordered: false,
1227                start: None,
1228                items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
1229                item_source_lines: vec![],
1230            },
1231            Block::List {
1232                ordered: true,
1233                start: None,
1234                items: vec![vec![Block::Paragraph(vec![Inline::Text("b".into())])]],
1235                item_source_lines: vec![],
1236            },
1237            Block::CodeBlock {
1238                lang: Some("rust".into()),
1239                value: "x".into(),
1240            },
1241            Block::Table {
1242                header: vec![vec![Inline::Text("H".into())]],
1243                rows: vec![vec![vec![Inline::Text("c".into())]]],
1244                header_source_line: None,
1245                row_source_lines: vec![],
1246            },
1247            Block::ThematicBreak,
1248        ];
1249        let meta = vec![
1250            BlockMeta {
1251                source_line: Some(1),
1252            },
1253            BlockMeta {
1254                source_line: Some(2),
1255            },
1256            BlockMeta {
1257                source_line: Some(3),
1258            },
1259            BlockMeta {
1260                source_line: Some(4),
1261            },
1262            BlockMeta {
1263                source_line: Some(5),
1264            },
1265            BlockMeta {
1266                source_line: Some(6),
1267            },
1268        ];
1269        let html = render_with_meta(blocks, meta);
1270        assert!(
1271            html.contains(r#"<blockquote data-source-line="1">"#),
1272            "blockquote missing: {html}"
1273        );
1274        assert!(
1275            html.contains(r#"<ul data-source-line="2">"#),
1276            "ul missing: {html}"
1277        );
1278        assert!(
1279            html.contains(r#"<ol data-source-line="3">"#),
1280            "ol missing: {html}"
1281        );
1282        assert!(
1283            html.contains(r#"<pre data-source-line="4">"#),
1284            "pre missing: {html}"
1285        );
1286        assert!(
1287            html.contains(r#"<table data-source-line="5">"#),
1288            "table missing: {html}"
1289        );
1290        assert!(
1291            html.contains(r#"<hr data-source-line="6" />"#),
1292            "hr missing: {html}"
1293        );
1294    }
1295
1296    #[test]
1297    fn list_emits_per_li_data_source_line_when_parser_tracks() {
1298        // 2026-05-28 (Phase 4 source-lines followup): `<li>` carries
1299        // `data-source-line="N"` when the parser populated
1300        // `item_source_lines`. Mirrors the legacy transform_events shape
1301        // (commit f91aca8fa, 2026-04-01) that emitted on `<li>` for
1302        // proportional scroll-sync interpolation. The outer `<ul>` carries
1303        // BlockMeta.source_line separately.
1304        let blocks = vec![Block::List {
1305            ordered: false,
1306            start: None,
1307            items: vec![
1308                vec![Block::Paragraph(vec![Inline::Text("one".into())])],
1309                vec![Block::Paragraph(vec![Inline::Text("two".into())])],
1310                vec![Block::Paragraph(vec![Inline::Text("three".into())])],
1311            ],
1312            item_source_lines: vec![Some(10), Some(11), Some(12)],
1313        }];
1314        let meta = vec![BlockMeta {
1315            source_line: Some(10),
1316        }];
1317        let html = render_with_meta(blocks, meta);
1318        assert!(
1319            html.contains(r#"<ul data-source-line="10">"#),
1320            "ul opener missing: {html}"
1321        );
1322        assert!(
1323            html.contains(r#"<li data-source-line="10">one</li>"#),
1324            "li 10 missing: {html}"
1325        );
1326        assert!(
1327            html.contains(r#"<li data-source-line="11">two</li>"#),
1328            "li 11 missing: {html}"
1329        );
1330        assert!(
1331            html.contains(r#"<li data-source-line="12">three</li>"#),
1332            "li 12 missing: {html}"
1333        );
1334    }
1335
1336    #[test]
1337    fn list_omits_li_data_source_line_when_parser_did_not_track() {
1338        // When `item_source_lines` is empty (default — parser ran with
1339        // `emit_source_lines: false`), no per-`<li>` attribute is emitted.
1340        // Locks the publish-build invariant: byte-identical output to the
1341        // pre-followup renderer.
1342        let blocks = vec![Block::List {
1343            ordered: false,
1344            start: None,
1345            items: vec![
1346                vec![Block::Paragraph(vec![Inline::Text("a".into())])],
1347                vec![Block::Paragraph(vec![Inline::Text("b".into())])],
1348            ],
1349            item_source_lines: vec![],
1350        }];
1351        let html = render_with_meta(blocks, vec![BlockMeta::default()]);
1352        assert_eq!(html, "<ul>\n<li>a</li>\n<li>b</li>\n</ul>\n");
1353    }
1354
1355    #[test]
1356    fn table_emits_per_tr_data_source_line_when_parser_tracks() {
1357        // Header `<tr>` carries `header_source_line`; each body `<tr>`
1358        // carries the matching `row_source_lines[i]`.
1359        let blocks = vec![Block::Table {
1360            header: vec![vec![Inline::Text("H".into())]],
1361            rows: vec![
1362                vec![vec![Inline::Text("1".into())]],
1363                vec![vec![Inline::Text("2".into())]],
1364                vec![vec![Inline::Text("3".into())]],
1365            ],
1366            header_source_line: Some(5),
1367            row_source_lines: vec![Some(7), Some(8), Some(9)],
1368        }];
1369        let meta = vec![BlockMeta {
1370            source_line: Some(5),
1371        }];
1372        let html = render_with_meta(blocks, meta);
1373        assert!(
1374            html.contains(r#"<table data-source-line="5">"#),
1375            "table opener missing: {html}"
1376        );
1377        assert!(html.contains(r#"<thead>"#), "thead missing: {html}");
1378        // Header row line — note the header tr is on the marker line
1379        // because pulldown-cmark anchors the head row to the line of the
1380        // `| h |` header markdown row.
1381        assert!(
1382            html.contains(r#"<tr data-source-line="5"><th>H</th>"#),
1383            "head tr missing: {html}"
1384        );
1385        assert!(
1386            html.contains(r#"<tr data-source-line="7"><td>1</td>"#),
1387            "body tr 7 missing: {html}"
1388        );
1389        assert!(
1390            html.contains(r#"<tr data-source-line="8"><td>2</td>"#),
1391            "body tr 8 missing: {html}"
1392        );
1393        assert!(
1394            html.contains(r#"<tr data-source-line="9"><td>3</td>"#),
1395            "body tr 9 missing: {html}"
1396        );
1397    }
1398
1399    #[test]
1400    fn table_omits_tr_data_source_line_when_parser_did_not_track() {
1401        // Publish-build invariant: byte-identical output to pre-followup.
1402        let blocks = vec![Block::Table {
1403            header: vec![vec![Inline::Text("A".into())]],
1404            rows: vec![vec![vec![Inline::Text("1".into())]]],
1405            header_source_line: None,
1406            row_source_lines: vec![],
1407        }];
1408        let html = render_with_meta(blocks, vec![BlockMeta::default()]);
1409        // No `data-source-line` anywhere — the table opener also has
1410        // BlockMeta::default() (None), so the entire `<table>...</table>`
1411        // block is annotation-free.
1412        assert!(
1413            !html.contains("data-source-line"),
1414            "no annotation expected: {html}"
1415        );
1416        assert!(html.contains("<thead>"));
1417        assert!(html.contains("<tr><th>A</th></tr>"));
1418        assert!(html.contains("<tr><td>1</td></tr>"));
1419    }
1420
1421    #[test]
1422    fn figure_emits_data_source_line_on_outer_tag() {
1423        let blocks = vec![Block::Figure {
1424            image: Inline::Image {
1425                src: Url::resolved("p.jpg", UrlKind::Asset),
1426                alt: "A".into(),
1427                title: None,
1428                is_wikilink: false,
1429                wikilink_pothole: None,
1430            },
1431            caption: Some(vec![Inline::Text("A".into())]),
1432            width: None,
1433            align: None,
1434            class_names: Vec::new(),
1435            img_style: None,
1436        }];
1437        let meta = vec![BlockMeta {
1438            source_line: Some(9),
1439        }];
1440        let html = render_with_meta(blocks, meta);
1441        assert!(
1442            html.contains(r#"<figure class="moss-image" data-source-line="9">"#),
1443            "got: {html}"
1444        );
1445    }
1446
1447    #[test]
1448    fn no_data_source_line_when_meta_none() {
1449        // Default `Document::from_blocks` creates meta vec of all
1450        // `BlockMeta::default()`; nothing should leak.
1451        let html = render(vec![
1452            Block::Paragraph(vec![Inline::Text("hi".into())]),
1453            Block::ThematicBreak,
1454        ]);
1455        assert!(
1456            !html.contains("data-source-line"),
1457            "default render must NOT emit data-source-line, got: {html}"
1458        );
1459    }
1460
1461    #[test]
1462    fn end_to_end_parse_with_config_emits_data_source_line() {
1463        // The full path: parse_with_config → visit_urls_mut → render_document.
1464        let md = "# Title\n\nfirst paragraph\n\n## Sub\n\nsecond paragraph\n";
1465        let config = super::super::parser::ParseConfig {
1466            emit_source_lines: true,
1467            implicit_figure: true,
1468            source_line_offset: 0,
1469            math: false,
1470        };
1471        let mut doc = super::super::parser::parse_with_config(md, &config);
1472        super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1473            Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Internal),
1474            _ => {}
1475        });
1476        let html = render_document(&doc, &DefaultHooks::new());
1477        assert!(
1478            html.contains(r##"<h1 id="title" data-source-line="1">Title<a class="moss-heading-anchor" href="#title" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h1>"##),
1479            "H1 should carry data-source-line=1: {html}"
1480        );
1481        assert!(
1482            html.contains(r#"<p data-source-line="3">first paragraph</p>"#),
1483            "first paragraph should carry data-source-line=3: {html}"
1484        );
1485        assert!(
1486            html.contains(r##"<h2 id="sub" data-source-line="5">Sub<a class="moss-heading-anchor" href="#sub" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h2>"##),
1487            "H2 should carry data-source-line=5: {html}"
1488        );
1489        assert!(
1490            html.contains(r#"<p data-source-line="7">second paragraph</p>"#),
1491            "second paragraph should carry data-source-line=7: {html}"
1492        );
1493    }
1494}