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);
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                out.push_str(r#" data-width=""#);
378                out.push_str(&escape_attr(w));
379                out.push('"');
380            }
381            push_source_line_attr(out, meta.source_line);
382            out.push('>');
383            // Render the inner image. Pattern-match the constrained shape;
384            // any other inline falls back to the standard inline path so
385            // the renderer never panics on a malformed Figure.
386            match image {
387                Inline::Image {
388                    src, alt, title, ..
389                } => match src {
390                    Url::Resolved(r) => {
391                        hooks.render_image_styled(out, r, alt, title.as_deref(), img_style.as_deref());
392                    }
393                    Url::Unresolved(s) => {
394                        debug_assert!(
395                                false,
396                                "Url::Unresolved({s:?}) reached Block::Figure renderer — visit_urls_mut missing or buggy"
397                            );
398                        out.push_str(r#"<img src=""#);
399                        out.push_str(&escape_attr(s));
400                        out.push_str(r#"" alt=""#);
401                        out.push_str(&escape_attr(alt));
402                        out.push_str(r#"" />"#);
403                    }
404                },
405                _ => {
406                    // Defensive: a non-Image inline in a Figure violates
407                    // the parser-enforced shape, but the renderer must
408                    // still emit something rather than crash.
409                    render_inline(hooks, out, image);
410                }
411            }
412            if let Some(cap_inlines) = caption {
413                if !cap_inlines.is_empty() {
414                    out.push_str("<figcaption>");
415                    render_inlines(hooks, out, cap_inlines);
416                    out.push_str("</figcaption>");
417                }
418            }
419            out.push_str("</figure>\n");
420        }
421        Block::LinkCard { url, children } => {
422            // Phase 4 PR4.5 (2026-05-28): the compound-link grid-cell shape.
423            // External URLs render as a link-preview wrapper; internal URLs
424            // render as `data-kind="link"` grid-card.
425            //
426            // Production byte shape matches today's src-tauri
427            // `render_compound_link_cell` output (ported here so that
428            // shape was deleted from src-tauri in PR4.5). The wrapping
429            // `<div class="moss-grid">` chrome lives in the Grid render
430            // arm in hooks.rs; LinkCard is the per-cell shape.
431            let resolved = match url {
432                Url::Resolved(r) => r,
433                Url::Unresolved(s) => {
434                    debug_assert!(
435                        false,
436                        "Url::Unresolved({s:?}) reached Block::LinkCard renderer — visit_urls_mut missing or buggy"
437                    );
438                    out.push_str(r#"<a href=""#);
439                    out.push_str(&escape_attr(s));
440                    out.push_str(r#"" class="moss-grid-card" data-kind="link">"#);
441                    render_blocks(hooks, out, children);
442                    out.push_str("</a>");
443                    return;
444                }
445            };
446            use super::url::UrlKind;
447            let is_external = matches!(resolved.kind, UrlKind::External | UrlKind::AssetNewtab);
448            if is_external {
449                out.push_str(r#"<a href=""#);
450                out.push_str(&escape_attr(&resolved.href));
451                out.push_str(
452                    r#"" class="moss-grid-card link-preview" target="_blank" rel="noopener">"#,
453                );
454            } else {
455                out.push_str(r#"<a href=""#);
456                out.push_str(&escape_attr(&resolved.href));
457                out.push_str(r#"" class="moss-grid-card" data-kind="link">"#);
458            }
459            render_blocks(hooks, out, children);
460            out.push_str("</a>");
461        }
462        Block::Other(html) => {
463            out.push_str(html);
464        }
465    }
466}
467
468/// Append ` data-source-line="N"` to `out` when `source_line` is `Some`.
469/// No-op otherwise.
470///
471/// Used at every top-level block's opening tag arm so the preview's
472/// `cm-scroll-sync` (in `frontend/bridge/iframe-bridge.ts`) can locate
473/// the DOM element that corresponds to a given editor source line.
474///
475/// Matches the legacy `transform_events` emit byte shape — leading space,
476/// double-quoted attribute value, decimal integer — verified against
477/// `src-tauri/src/build/ship.rs::apply_strip_removes_data_source_line`
478/// which scrubs this exact pattern from the ship-stage output.
479fn push_source_line_attr(out: &mut String, source_line: Option<usize>) {
480    if let Some(n) = source_line {
481        use std::fmt::Write as _;
482        // unwrap_or: writing into a String never fails, but the API
483        // returns Result. Keep this honest.
484        let _ = write!(out, r#" data-source-line="{}""#, n);
485    }
486}
487
488pub(super) fn render_inlines<H: RenderHooks + ?Sized>(
489    hooks: &H,
490    out: &mut String,
491    inlines: &[Inline],
492) {
493    for inline in inlines {
494        render_inline(hooks, out, inline);
495    }
496}
497
498fn render_inline<H: RenderHooks + ?Sized>(hooks: &H, out: &mut String, inline: &Inline) {
499    match inline {
500        Inline::Text(t) => out.push_str(&escape_text(t)),
501        Inline::Link {
502            url,
503            title: _title,
504            children,
505            is_wikilink,
506        } => {
507            let resolved = match url {
508                Url::Resolved(r) => r,
509                Url::Unresolved(s) => {
510                    debug_assert!(
511                        false,
512                        "Url::Unresolved({s:?}) reached renderer — visit_urls_mut missing or buggy"
513                    );
514                    // In release: emit href as-is so we don't crash, but
515                    // the wide-net invariant test will catch the leak.
516                    out.push_str(r#"<a href=""#);
517                    out.push_str(&escape_attr(s));
518                    out.push_str(r#"">"#);
519                    render_inlines(hooks, out, children);
520                    out.push_str("</a>");
521                    return;
522                }
523            };
524            let mut content = String::new();
525            render_inlines(hooks, &mut content, children);
526            // Phase 4 PR7a-flip-core-A (2026-05-28): pass the
527            // `is_wikilink` flag directly to the hook. Pre-flip-core-A,
528            // this arm synthesized a wikilink-kinded `ResolvedUrl` to
529            // coax the hook's wikilink branch — a lossy workaround that
530            // dropped the original `UrlKind` (`AssetNewtab` wikilinks
531            // lost their `target="_blank" rel="noopener"`). The hook's
532            // new signature carries both concerns orthogonally.
533            hooks.render_link(out, resolved, *is_wikilink, &content);
534        }
535        Inline::Image {
536            src, alt, title, ..
537        } => {
538            let resolved = match src {
539                Url::Resolved(r) => r,
540                Url::Unresolved(s) => {
541                    debug_assert!(
542                        false,
543                        "Url::Unresolved({s:?}) reached renderer — visit_urls_mut missing or buggy"
544                    );
545                    out.push_str(r#"<img src=""#);
546                    out.push_str(&escape_attr(s));
547                    out.push_str(r#"" alt=""#);
548                    out.push_str(&escape_attr(alt));
549                    out.push_str(r#"" />"#);
550                    return;
551                }
552            };
553            hooks.render_image(out, resolved, alt, title.as_deref());
554        }
555        Inline::Emphasis(children) => {
556            out.push_str("<em>");
557            render_inlines(hooks, out, children);
558            out.push_str("</em>");
559        }
560        Inline::Strong(children) => {
561            out.push_str("<strong>");
562            render_inlines(hooks, out, children);
563            out.push_str("</strong>");
564        }
565        Inline::Code(c) => {
566            out.push_str("<code>");
567            out.push_str(&escape_text(c));
568            out.push_str("</code>");
569        }
570        Inline::LineBreak => out.push_str("<br />\n"),
571        Inline::Other(html) => out.push_str(html),
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::super::hooks::DefaultHooks;
578    use super::super::node::Inline;
579    use super::super::url::{Url, UrlKind};
580    use super::*;
581
582    fn render(blocks: Vec<Block>) -> String {
583        let doc = Document::from_blocks(blocks);
584        render_document(&doc, &DefaultHooks::new())
585    }
586
587    #[test]
588    fn renders_empty_document_to_empty_string() {
589        assert_eq!(render(vec![]), "");
590    }
591
592    #[test]
593    fn renders_paragraph() {
594        let html = render(vec![Block::Paragraph(vec![Inline::Text("hi".into())])]);
595        assert_eq!(html, "<p>hi</p>\n");
596    }
597
598    #[test]
599    fn renders_heading_with_id() {
600        let html = render(vec![Block::Heading {
601            level: 2,
602            children: vec![Inline::Text("Setup".into())],
603            id: Some("setup".into()),
604        }]);
605        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");
606    }
607
608    #[test]
609    fn renders_resolved_link_internal() {
610        let html = render(vec![Block::Paragraph(vec![Inline::Link {
611            url: Url::resolved("docs/", UrlKind::Internal),
612            title: None,
613            children: vec![Inline::Text("Docs".into())],
614            is_wikilink: false,
615        }])]);
616        assert_eq!(html, "<p><a href=\"docs/\">Docs</a></p>\n");
617    }
618
619    #[test]
620    fn renders_resolved_link_wikilink_carries_class() {
621        // PR7a: wikilink class can come from either the resolved URL kind
622        // (legacy production path) OR the new is_wikilink AST flag.
623        let html = render(vec![Block::Paragraph(vec![Inline::Link {
624            url: Url::resolved("../docs/", UrlKind::Wikilink),
625            title: None,
626            children: vec![Inline::Text("Docs".into())],
627            is_wikilink: false,
628        }])]);
629        assert!(html.contains(r#"class="wikilink""#), "got: {html}");
630    }
631
632    #[test]
633    fn renders_link_with_is_wikilink_flag_emits_class() {
634        // PR7a: is_wikilink: true on a non-wikilink-kind URL still
635        // produces the wikilink class. Parser sets this for any
636        // pulldown-cmark Tag::Link { link_type: LinkType::WikiLink, .. }.
637        let html = render(vec![Block::Paragraph(vec![Inline::Link {
638            url: Url::resolved("../docs/", UrlKind::Internal),
639            title: None,
640            children: vec![Inline::Text("Docs".into())],
641            is_wikilink: true,
642        }])]);
643        assert!(
644            html.contains(r#"class="wikilink""#),
645            "is_wikilink: true should produce class=\"wikilink\"; got: {html}"
646        );
647    }
648
649    #[test]
650    fn renders_resolved_image() {
651        let html = render(vec![Block::Paragraph(vec![Inline::Image {
652            src: Url::resolved("cat.jpg", UrlKind::Asset),
653            alt: "Cat".into(),
654            title: None,
655            is_wikilink: false,
656            wikilink_pothole: None,
657        }])]);
658        assert_eq!(html, "<p><img src=\"cat.jpg\" alt=\"Cat\" /></p>\n");
659    }
660
661    #[test]
662    fn renders_emphasis_and_strong() {
663        let html = render(vec![Block::Paragraph(vec![
664            Inline::Emphasis(vec![Inline::Text("em".into())]),
665            Inline::Text(" ".into()),
666            Inline::Strong(vec![Inline::Text("strong".into())]),
667        ])]);
668        assert_eq!(html, "<p><em>em</em> <strong>strong</strong></p>\n");
669    }
670
671    #[test]
672    fn renders_inline_code_with_escaping() {
673        let html = render(vec![Block::Paragraph(vec![Inline::Code("a<b>c".into())])]);
674        assert_eq!(html, "<p><code>a&lt;b&gt;c</code></p>\n");
675    }
676
677    #[test]
678    fn renders_unordered_list_tight() {
679        let html = render(vec![Block::List {
680            ordered: false,
681            start: None,
682            items: vec![
683                vec![Block::Paragraph(vec![Inline::Text("one".into())])],
684                vec![Block::Paragraph(vec![Inline::Text("two".into())])],
685            ],
686            item_source_lines: vec![],
687        }]);
688        assert_eq!(html, "<ul>\n<li>one</li>\n<li>two</li>\n</ul>\n");
689    }
690
691    #[test]
692    fn renders_ordered_list() {
693        let html = render(vec![Block::List {
694            ordered: true,
695            start: None,
696            items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
697            item_source_lines: vec![],
698        }]);
699        assert!(html.starts_with("<ol>"));
700    }
701
702    #[test]
703    fn render_ordered_list_emits_start_attribute_when_non_default() {
704        // `3. foo` should produce `<ol start="3">…</ol>`. The attribute
705        // appears immediately after `<ol`, before any `data-source-line`
706        // (Phase 4 followup B contract).
707        let html = render(vec![Block::List {
708            ordered: true,
709            start: Some(3),
710            items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
711            item_source_lines: vec![],
712        }]);
713        assert!(
714            html.starts_with(r#"<ol start="3">"#),
715            "expected start attr immediately after <ol, got: {html}"
716        );
717    }
718
719    #[test]
720    fn render_ordered_list_omits_start_when_default_1() {
721        // `start: None` is the canonical shape for "default 1." lists.
722        // The renderer must NOT emit `start="1"` (semantically
723        // identical to omitting the attr, but noisier).
724        let html = render(vec![Block::List {
725            ordered: true,
726            start: None,
727            items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
728            item_source_lines: vec![],
729        }]);
730        assert!(html.starts_with("<ol>"), "expected bare <ol>, got: {html}");
731        assert!(
732            !html.contains("start="),
733            "ordered list with default start should not emit start attr, got: {html}"
734        );
735    }
736
737    #[test]
738    fn render_unordered_list_emits_no_start() {
739        // Even if `start: Some(N)` were somehow set on an unordered
740        // list (shouldn't happen via the parser, but defense in depth),
741        // `<ul>` must never carry `start=`.
742        let html = render(vec![Block::List {
743            ordered: false,
744            start: Some(5),
745            items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
746            item_source_lines: vec![],
747        }]);
748        assert!(html.starts_with("<ul>"), "expected bare <ul>, got: {html}");
749        assert!(
750            !html.contains("start="),
751            "unordered list must never carry start attr, got: {html}"
752        );
753    }
754
755    #[test]
756    fn renders_code_block_with_lang() {
757        let html = render(vec![Block::CodeBlock {
758            lang: Some("rust".into()),
759            value: "fn main() {}".into(),
760        }]);
761        assert_eq!(
762            html,
763            "<pre><code class=\"language-rust\">fn main() {}</code></pre>\n"
764        );
765    }
766
767    #[test]
768    fn renders_code_block_without_lang() {
769        let html = render(vec![Block::CodeBlock {
770            lang: None,
771            value: "bare".into(),
772        }]);
773        assert_eq!(html, "<pre><code>bare</code></pre>\n");
774    }
775
776    #[test]
777    fn renders_thematic_break() {
778        let html = render(vec![Block::ThematicBreak]);
779        assert_eq!(html, "<hr />\n");
780    }
781
782    // -----------------------------------------------------------------
783    // Phase 4 PR4: Block::Callout render shape
784    // -----------------------------------------------------------------
785
786    use super::super::node::{CalloutKind, Fold};
787
788    #[test]
789    fn renders_basic_callout_with_title() {
790        let html = render(vec![Block::Callout {
791            kind: CalloutKind::Note,
792            fold: None,
793            title: Some("Heads up".into()),
794            children: vec![Block::Paragraph(vec![Inline::Text("Body.".into())])],
795        }]);
796        assert!(
797            html.contains(r#"<div class="callout" data-type="note">"#),
798            "expected callout div with data-type, got: {html}"
799        );
800        assert!(
801            html.contains(r#"<div class="callout-title">Heads up</div>"#),
802            "expected inline title slot, got: {html}"
803        );
804        assert!(
805            html.contains(r#"<div class="callout-content">"#),
806            "expected content slot, got: {html}"
807        );
808        assert!(html.contains("<p>Body.</p>"), "body must render: {html}");
809    }
810
811    #[test]
812    fn renders_callout_falls_back_to_default_title() {
813        let html = render(vec![Block::Callout {
814            kind: CalloutKind::Warning,
815            fold: None,
816            title: None,
817            children: vec![],
818        }]);
819        assert!(
820            html.contains(r#"<div class="callout-title">Warning</div>"#),
821            "expected capitalized fallback title, got: {html}"
822        );
823    }
824
825    #[test]
826    fn renders_foldable_callout_with_data_fold_attribute() {
827        let html_open = render(vec![Block::Callout {
828            kind: CalloutKind::Tip,
829            fold: Some(Fold::Open),
830            title: Some("Open".into()),
831            children: vec![],
832        }]);
833        assert!(
834            html_open.contains(r#"data-type="tip""#) && html_open.contains(r#"data-fold="open""#),
835            "expected data-fold='open' attribute, got: {html_open}"
836        );
837
838        let html_closed = render(vec![Block::Callout {
839            kind: CalloutKind::Tip,
840            fold: Some(Fold::Closed),
841            title: None,
842            children: vec![],
843        }]);
844        assert!(
845            html_closed.contains(r#"data-fold="closed""#),
846            "expected data-fold='closed' attribute, got: {html_closed}"
847        );
848    }
849
850    #[test]
851    fn callout_alias_renders_canonical_data_type_slug() {
852        // tldr → abstract; ensures the canonicalized slug is what
853        // appears in HTML.
854        let html = render(vec![Block::Callout {
855            kind: CalloutKind::Abstract,
856            fold: None,
857            title: Some("TL;DR".into()),
858            children: vec![],
859        }]);
860        assert!(
861            html.contains(r#"data-type="abstract""#),
862            "expected canonical slug 'abstract', got: {html}"
863        );
864    }
865
866    #[test]
867    fn callout_title_is_html_escaped() {
868        // Title is rendered through escape_text (the same function the
869        // existing renderer uses for text content). escape_text escapes
870        // `<`, `>`, `&` but NOT `"` — `"` is only dangerous inside HTML
871        // attribute values, and title sits between `<div>` tags as text.
872        let html = render(vec![Block::Callout {
873            kind: CalloutKind::Warning,
874            fold: None,
875            title: Some(r#"Use <script> & "quotes""#.into()),
876            children: vec![],
877        }]);
878        assert!(
879            html.contains("Use &lt;script&gt; &amp;"),
880            "title must escape lt/gt/amp, got: {html}"
881        );
882        // No raw `<script>` may appear inside the title div.
883        assert!(
884            !html.contains("<div class=\"callout-title\">Use <script>"),
885            "unescaped angle brackets leaked, got: {html}"
886        );
887    }
888
889    #[test]
890    fn renders_blockquote_with_paragraph() {
891        let html = render(vec![Block::BlockQuote(vec![Block::Paragraph(vec![
892            Inline::Text("q".into()),
893        ])])]);
894        assert_eq!(html, "<blockquote>\n<p>q</p>\n</blockquote>\n");
895    }
896
897    #[test]
898    fn renders_table() {
899        let html = render(vec![Block::Table {
900            header: vec![vec![Inline::Text("A".into())]],
901            rows: vec![vec![vec![Inline::Text("1".into())]]],
902            header_source_line: None,
903            row_source_lines: vec![],
904        }]);
905        assert!(html.contains("<thead>"));
906        assert!(html.contains("<tbody>"));
907        assert!(html.contains("<th>A</th>"));
908        assert!(html.contains("<td>1</td>"));
909    }
910
911    #[test]
912    fn renders_other_block_passes_html_through() {
913        let html = render(vec![Block::Other("<custom></custom>".into())]);
914        assert_eq!(html, "<custom></custom>");
915    }
916
917    #[test]
918    fn text_escapes_lt_gt_amp() {
919        let html = render(vec![Block::Paragraph(vec![Inline::Text("a<b>c&d".into())])]);
920        assert_eq!(html, "<p>a&lt;b&gt;c&amp;d</p>\n");
921    }
922
923    #[test]
924    fn round_trips_parse_to_render_for_canonical_doc() {
925        // End-to-end: post-resolve markdown → parse → simulate visit
926        // (mark every URL Internal) → render → check shape.
927        //
928        // Phase 4 PR2: the parser now populates Block::Heading.id with the
929        // Obsidian anchor slug, so the rendered <h1> carries id="title".
930        let md = "# Title\n\npara with [link](docs/) and *em*.\n";
931        let mut doc = super::super::parser::parse(md);
932        super::super::visit::visit_urls_mut(&mut doc, |u| match u {
933            Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Internal),
934            _ => {}
935        });
936        let html = render_document(&doc, &DefaultHooks::new());
937        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}");
938        assert!(html.contains(r#"<a href="docs/">link</a>"#));
939        assert!(html.contains("<em>em</em>"));
940    }
941
942    // -----------------------------------------------------------------
943    // Phase 4 PR3 (2026-05-27): Block::Figure render
944    // -----------------------------------------------------------------
945
946    #[test]
947    fn figure_renders_with_caption() {
948        // Canonical shape: <figure class="moss-image">{inner img}{figcaption}</figure>.
949        // DefaultHooks::new() has no snapshot, so inner is the bare <img>
950        // (test path). Production wires DefaultHooks::with_snapshot which
951        // routes inner through synth — same shape, richer attrs.
952        let html = render(vec![Block::Figure {
953            image: Inline::Image {
954                src: Url::resolved("logo.png", UrlKind::Asset),
955                alt: "A logo".into(),
956                title: None,
957                is_wikilink: false,
958                wikilink_pothole: None,
959            },
960            caption: Some(vec![Inline::Text("A logo".into())]),
961            width: None,
962            align: None,
963            class_names: Vec::new(),
964            img_style: None,
965        }]);
966        assert!(
967            html.starts_with(r#"<figure class="moss-image">"#),
968            "expected figure wrap, got: {html}"
969        );
970        assert!(html.contains(r#"src="logo.png""#), "got: {html}");
971        assert!(html.contains(r#"alt="A logo""#), "got: {html}");
972        assert!(
973            html.contains("<figcaption>A logo</figcaption>"),
974            "got: {html}"
975        );
976        assert!(html.ends_with("</figure>\n"), "got: {html}");
977    }
978
979    #[test]
980    fn figure_renders_without_caption_when_none() {
981        // Empty-alt case: caption: None → no <figcaption> element.
982        let html = render(vec![Block::Figure {
983            image: Inline::Image {
984                src: Url::resolved("x.png", UrlKind::Asset),
985                alt: String::new(),
986                title: None,
987                is_wikilink: false,
988                wikilink_pothole: None,
989            },
990            caption: None,
991            width: None,
992            align: None,
993            class_names: Vec::new(),
994            img_style: None,
995        }]);
996        assert!(html.contains("<figure"), "got: {html}");
997        assert!(
998            !html.contains("<figcaption"),
999            "expected no figcaption, got: {html}"
1000        );
1001        assert!(html.contains("</figure>"), "got: {html}");
1002    }
1003
1004    #[test]
1005    fn figure_renders_no_figcaption_for_empty_caption_vec() {
1006        // Defensive: caption: Some(vec![]) is treated identically to None.
1007        let html = render(vec![Block::Figure {
1008            image: Inline::Image {
1009                src: Url::resolved("x.png", UrlKind::Asset),
1010                alt: "x".into(),
1011                title: None,
1012                is_wikilink: false,
1013                wikilink_pothole: None,
1014            },
1015            caption: Some(vec![]),
1016            width: None,
1017            align: None,
1018            class_names: Vec::new(),
1019            img_style: None,
1020        }]);
1021        assert!(!html.contains("<figcaption"), "got: {html}");
1022    }
1023
1024    #[test]
1025    fn figure_caption_escapes_html_unsafe_chars() {
1026        // Caption is a Vec<Inline>; Inline::Text passes through
1027        // escape_text. The figure renderer must NOT double-escape; the
1028        // existing inline path is the single source of escaping.
1029        let html = render(vec![Block::Figure {
1030            image: Inline::Image {
1031                src: Url::resolved("p.jpg", UrlKind::Asset),
1032                alt: "a<b>c".into(),
1033                title: None,
1034                is_wikilink: false,
1035                wikilink_pothole: None,
1036            },
1037            caption: Some(vec![Inline::Text("a<b>c".into())]),
1038            width: None,
1039            align: None,
1040            class_names: Vec::new(),
1041            img_style: None,
1042        }]);
1043        assert!(
1044            html.contains("<figcaption>a&lt;b&gt;c</figcaption>"),
1045            "got: {html}"
1046        );
1047    }
1048
1049    #[test]
1050    fn figure_end_to_end_from_parser_to_render() {
1051        // Parse → visit (resolve URL) → render: covers the full path.
1052        let md = "![A photo](photo.jpg)\n";
1053        let mut doc = super::super::parser::parse(md);
1054        super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1055            Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Asset),
1056            _ => {}
1057        });
1058        let html = render_document(&doc, &DefaultHooks::new());
1059        assert!(
1060            html.contains(r#"<figure class="moss-image">"#),
1061            "expected figure, got: {html}"
1062        );
1063        assert!(html.contains(r#"src="photo.jpg""#), "got: {html}");
1064        assert!(
1065            html.contains("<figcaption>A photo</figcaption>"),
1066            "got: {html}"
1067        );
1068    }
1069
1070    #[test]
1071    fn paragraph_with_image_and_text_does_not_become_figure() {
1072        // End-to-end regression guard: ![img](u) caption text MUST stay
1073        // as a paragraph (not get the figure wrap) so the prose isn't
1074        // swallowed. Mirrors the parser-side guard `image_with_caption_text_does_not_promote`.
1075        let md = "![alt](a.jpg) plain text\n";
1076        let mut doc = super::super::parser::parse(md);
1077        super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1078            Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Asset),
1079            _ => {}
1080        });
1081        let html = render_document(&doc, &DefaultHooks::new());
1082        assert!(
1083            !html.contains("<figure"),
1084            "image+text must not be wrapped in figure, got: {html}"
1085        );
1086        assert!(html.contains("plain text"), "got: {html}");
1087    }
1088
1089    #[test]
1090    #[cfg(debug_assertions)]
1091    #[should_panic(expected = "visit_urls_mut missing")]
1092    fn unresolved_url_in_link_panics_in_debug() {
1093        // Critical contract: the bypass class is a debug-time crash.
1094        let _ = render(vec![Block::Paragraph(vec![Inline::Link {
1095            url: Url::unresolved("docs/"),
1096            title: None,
1097            children: vec![],
1098            is_wikilink: false,
1099        }])]);
1100    }
1101
1102    // -----------------------------------------------------------------
1103    // 2026-05-28 (Phase 4 source-line wiring): BlockMeta → data-source-line
1104    // emission.
1105    // -----------------------------------------------------------------
1106
1107    /// Render with explicit per-block meta. Helper for the source-line tests.
1108    fn render_with_meta(blocks: Vec<Block>, meta: Vec<BlockMeta>) -> String {
1109        let doc = Document::from_blocks_with_meta(blocks, meta);
1110        render_document(&doc, &DefaultHooks::new())
1111    }
1112
1113    #[test]
1114    fn paragraph_emits_data_source_line_when_meta_set() {
1115        let html = render_with_meta(
1116            vec![Block::Paragraph(vec![Inline::Text("hi".into())])],
1117            vec![BlockMeta {
1118                source_line: Some(7),
1119            }],
1120        );
1121        assert_eq!(html, "<p data-source-line=\"7\">hi</p>\n");
1122    }
1123
1124    #[test]
1125    fn heading_emits_data_source_line_through_hook() {
1126        let html = render_with_meta(
1127            vec![Block::Heading {
1128                level: 2,
1129                children: vec![Inline::Text("Setup".into())],
1130                id: Some("setup".into()),
1131            }],
1132            vec![BlockMeta {
1133                source_line: Some(3),
1134            }],
1135        );
1136        assert!(
1137            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>"##),
1138            "got: {html}"
1139        );
1140    }
1141
1142    #[test]
1143    fn list_blockquote_codeblock_table_hr_emit_data_source_line() {
1144        // Each block type that the legacy transform_events annotated
1145        // must emit `data-source-line` when meta carries it. Single
1146        // smoke test covering every top-level block kind.
1147        let blocks = vec![
1148            Block::BlockQuote(vec![Block::Paragraph(vec![Inline::Text("q".into())])]),
1149            Block::List {
1150                ordered: false,
1151                start: None,
1152                items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
1153                item_source_lines: vec![],
1154            },
1155            Block::List {
1156                ordered: true,
1157                start: None,
1158                items: vec![vec![Block::Paragraph(vec![Inline::Text("b".into())])]],
1159                item_source_lines: vec![],
1160            },
1161            Block::CodeBlock {
1162                lang: Some("rust".into()),
1163                value: "x".into(),
1164            },
1165            Block::Table {
1166                header: vec![vec![Inline::Text("H".into())]],
1167                rows: vec![vec![vec![Inline::Text("c".into())]]],
1168                header_source_line: None,
1169                row_source_lines: vec![],
1170            },
1171            Block::ThematicBreak,
1172        ];
1173        let meta = vec![
1174            BlockMeta {
1175                source_line: Some(1),
1176            },
1177            BlockMeta {
1178                source_line: Some(2),
1179            },
1180            BlockMeta {
1181                source_line: Some(3),
1182            },
1183            BlockMeta {
1184                source_line: Some(4),
1185            },
1186            BlockMeta {
1187                source_line: Some(5),
1188            },
1189            BlockMeta {
1190                source_line: Some(6),
1191            },
1192        ];
1193        let html = render_with_meta(blocks, meta);
1194        assert!(
1195            html.contains(r#"<blockquote data-source-line="1">"#),
1196            "blockquote missing: {html}"
1197        );
1198        assert!(
1199            html.contains(r#"<ul data-source-line="2">"#),
1200            "ul missing: {html}"
1201        );
1202        assert!(
1203            html.contains(r#"<ol data-source-line="3">"#),
1204            "ol missing: {html}"
1205        );
1206        assert!(
1207            html.contains(r#"<pre data-source-line="4">"#),
1208            "pre missing: {html}"
1209        );
1210        assert!(
1211            html.contains(r#"<table data-source-line="5">"#),
1212            "table missing: {html}"
1213        );
1214        assert!(
1215            html.contains(r#"<hr data-source-line="6" />"#),
1216            "hr missing: {html}"
1217        );
1218    }
1219
1220    #[test]
1221    fn list_emits_per_li_data_source_line_when_parser_tracks() {
1222        // 2026-05-28 (Phase 4 source-lines followup): `<li>` carries
1223        // `data-source-line="N"` when the parser populated
1224        // `item_source_lines`. Mirrors the legacy transform_events shape
1225        // (commit f91aca8fa, 2026-04-01) that emitted on `<li>` for
1226        // proportional scroll-sync interpolation. The outer `<ul>` carries
1227        // BlockMeta.source_line separately.
1228        let blocks = vec![Block::List {
1229            ordered: false,
1230            start: None,
1231            items: vec![
1232                vec![Block::Paragraph(vec![Inline::Text("one".into())])],
1233                vec![Block::Paragraph(vec![Inline::Text("two".into())])],
1234                vec![Block::Paragraph(vec![Inline::Text("three".into())])],
1235            ],
1236            item_source_lines: vec![Some(10), Some(11), Some(12)],
1237        }];
1238        let meta = vec![BlockMeta {
1239            source_line: Some(10),
1240        }];
1241        let html = render_with_meta(blocks, meta);
1242        assert!(
1243            html.contains(r#"<ul data-source-line="10">"#),
1244            "ul opener missing: {html}"
1245        );
1246        assert!(
1247            html.contains(r#"<li data-source-line="10">one</li>"#),
1248            "li 10 missing: {html}"
1249        );
1250        assert!(
1251            html.contains(r#"<li data-source-line="11">two</li>"#),
1252            "li 11 missing: {html}"
1253        );
1254        assert!(
1255            html.contains(r#"<li data-source-line="12">three</li>"#),
1256            "li 12 missing: {html}"
1257        );
1258    }
1259
1260    #[test]
1261    fn list_omits_li_data_source_line_when_parser_did_not_track() {
1262        // When `item_source_lines` is empty (default — parser ran with
1263        // `emit_source_lines: false`), no per-`<li>` attribute is emitted.
1264        // Locks the publish-build invariant: byte-identical output to the
1265        // pre-followup renderer.
1266        let blocks = vec![Block::List {
1267            ordered: false,
1268            start: None,
1269            items: vec![
1270                vec![Block::Paragraph(vec![Inline::Text("a".into())])],
1271                vec![Block::Paragraph(vec![Inline::Text("b".into())])],
1272            ],
1273            item_source_lines: vec![],
1274        }];
1275        let html = render_with_meta(blocks, vec![BlockMeta::default()]);
1276        assert_eq!(html, "<ul>\n<li>a</li>\n<li>b</li>\n</ul>\n");
1277    }
1278
1279    #[test]
1280    fn table_emits_per_tr_data_source_line_when_parser_tracks() {
1281        // Header `<tr>` carries `header_source_line`; each body `<tr>`
1282        // carries the matching `row_source_lines[i]`.
1283        let blocks = vec![Block::Table {
1284            header: vec![vec![Inline::Text("H".into())]],
1285            rows: vec![
1286                vec![vec![Inline::Text("1".into())]],
1287                vec![vec![Inline::Text("2".into())]],
1288                vec![vec![Inline::Text("3".into())]],
1289            ],
1290            header_source_line: Some(5),
1291            row_source_lines: vec![Some(7), Some(8), Some(9)],
1292        }];
1293        let meta = vec![BlockMeta {
1294            source_line: Some(5),
1295        }];
1296        let html = render_with_meta(blocks, meta);
1297        assert!(
1298            html.contains(r#"<table data-source-line="5">"#),
1299            "table opener missing: {html}"
1300        );
1301        assert!(html.contains(r#"<thead>"#), "thead missing: {html}");
1302        // Header row line — note the header tr is on the marker line
1303        // because pulldown-cmark anchors the head row to the line of the
1304        // `| h |` header markdown row.
1305        assert!(
1306            html.contains(r#"<tr data-source-line="5"><th>H</th>"#),
1307            "head tr missing: {html}"
1308        );
1309        assert!(
1310            html.contains(r#"<tr data-source-line="7"><td>1</td>"#),
1311            "body tr 7 missing: {html}"
1312        );
1313        assert!(
1314            html.contains(r#"<tr data-source-line="8"><td>2</td>"#),
1315            "body tr 8 missing: {html}"
1316        );
1317        assert!(
1318            html.contains(r#"<tr data-source-line="9"><td>3</td>"#),
1319            "body tr 9 missing: {html}"
1320        );
1321    }
1322
1323    #[test]
1324    fn table_omits_tr_data_source_line_when_parser_did_not_track() {
1325        // Publish-build invariant: byte-identical output to pre-followup.
1326        let blocks = vec![Block::Table {
1327            header: vec![vec![Inline::Text("A".into())]],
1328            rows: vec![vec![vec![Inline::Text("1".into())]]],
1329            header_source_line: None,
1330            row_source_lines: vec![],
1331        }];
1332        let html = render_with_meta(blocks, vec![BlockMeta::default()]);
1333        // No `data-source-line` anywhere — the table opener also has
1334        // BlockMeta::default() (None), so the entire `<table>...</table>`
1335        // block is annotation-free.
1336        assert!(
1337            !html.contains("data-source-line"),
1338            "no annotation expected: {html}"
1339        );
1340        assert!(html.contains("<thead>"));
1341        assert!(html.contains("<tr><th>A</th></tr>"));
1342        assert!(html.contains("<tr><td>1</td></tr>"));
1343    }
1344
1345    #[test]
1346    fn figure_emits_data_source_line_on_outer_tag() {
1347        let blocks = vec![Block::Figure {
1348            image: Inline::Image {
1349                src: Url::resolved("p.jpg", UrlKind::Asset),
1350                alt: "A".into(),
1351                title: None,
1352                is_wikilink: false,
1353                wikilink_pothole: None,
1354            },
1355            caption: Some(vec![Inline::Text("A".into())]),
1356            width: None,
1357            align: None,
1358            class_names: Vec::new(),
1359            img_style: None,
1360        }];
1361        let meta = vec![BlockMeta {
1362            source_line: Some(9),
1363        }];
1364        let html = render_with_meta(blocks, meta);
1365        assert!(
1366            html.contains(r#"<figure class="moss-image" data-source-line="9">"#),
1367            "got: {html}"
1368        );
1369    }
1370
1371    #[test]
1372    fn no_data_source_line_when_meta_none() {
1373        // Default `Document::from_blocks` creates meta vec of all
1374        // `BlockMeta::default()`; nothing should leak.
1375        let html = render(vec![
1376            Block::Paragraph(vec![Inline::Text("hi".into())]),
1377            Block::ThematicBreak,
1378        ]);
1379        assert!(
1380            !html.contains("data-source-line"),
1381            "default render must NOT emit data-source-line, got: {html}"
1382        );
1383    }
1384
1385    #[test]
1386    fn end_to_end_parse_with_config_emits_data_source_line() {
1387        // The full path: parse_with_config → visit_urls_mut → render_document.
1388        let md = "# Title\n\nfirst paragraph\n\n## Sub\n\nsecond paragraph\n";
1389        let config = super::super::parser::ParseConfig {
1390            emit_source_lines: true,
1391            implicit_figure: true,
1392        };
1393        let mut doc = super::super::parser::parse_with_config(md, &config);
1394        super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1395            Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Internal),
1396            _ => {}
1397        });
1398        let html = render_document(&doc, &DefaultHooks::new());
1399        assert!(
1400            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>"##),
1401            "H1 should carry data-source-line=1: {html}"
1402        );
1403        assert!(
1404            html.contains(r#"<p data-source-line="3">first paragraph</p>"#),
1405            "first paragraph should carry data-source-line=3: {html}"
1406        );
1407        assert!(
1408            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>"##),
1409            "H2 should carry data-source-line=5: {html}"
1410        );
1411        assert!(
1412            html.contains(r#"<p data-source-line="7">second paragraph</p>"#),
1413            "second paragraph should carry data-source-line=7: {html}"
1414        );
1415    }
1416}