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) => out.push_str(html),
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::super::hooks::DefaultHooks;
589    use super::super::node::Inline;
590    use super::super::url::{Url, UrlKind};
591    use super::*;
592
593    fn render(blocks: Vec<Block>) -> String {
594        let doc = Document::from_blocks(blocks);
595        render_document(&doc, &DefaultHooks::new())
596    }
597
598    #[test]
599    fn renders_empty_document_to_empty_string() {
600        assert_eq!(render(vec![]), "");
601    }
602
603    #[test]
604    fn renders_paragraph() {
605        let html = render(vec![Block::Paragraph(vec![Inline::Text("hi".into())])]);
606        assert_eq!(html, "<p>hi</p>\n");
607    }
608
609    #[test]
610    fn renders_heading_with_id() {
611        let html = render(vec![Block::Heading {
612            level: 2,
613            children: vec![Inline::Text("Setup".into())],
614            id: Some("setup".into()),
615        }]);
616        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");
617    }
618
619    #[test]
620    fn renders_resolved_link_internal() {
621        let html = render(vec![Block::Paragraph(vec![Inline::Link {
622            url: Url::resolved("docs/", UrlKind::Internal),
623            title: None,
624            children: vec![Inline::Text("Docs".into())],
625            is_wikilink: false,
626        }])]);
627        assert_eq!(html, "<p><a href=\"docs/\">Docs</a></p>\n");
628    }
629
630    #[test]
631    fn renders_resolved_link_wikilink_carries_class() {
632        // PR7a: wikilink class can come from either the resolved URL kind
633        // (legacy production path) OR the new is_wikilink AST flag.
634        let html = render(vec![Block::Paragraph(vec![Inline::Link {
635            url: Url::resolved("../docs/", UrlKind::Wikilink),
636            title: None,
637            children: vec![Inline::Text("Docs".into())],
638            is_wikilink: false,
639        }])]);
640        assert!(html.contains(r#"class="wikilink""#), "got: {html}");
641    }
642
643    #[test]
644    fn renders_link_with_is_wikilink_flag_emits_class() {
645        // PR7a: is_wikilink: true on a non-wikilink-kind URL still
646        // produces the wikilink class. Parser sets this for any
647        // pulldown-cmark Tag::Link { link_type: LinkType::WikiLink, .. }.
648        let html = render(vec![Block::Paragraph(vec![Inline::Link {
649            url: Url::resolved("../docs/", UrlKind::Internal),
650            title: None,
651            children: vec![Inline::Text("Docs".into())],
652            is_wikilink: true,
653        }])]);
654        assert!(
655            html.contains(r#"class="wikilink""#),
656            "is_wikilink: true should produce class=\"wikilink\"; got: {html}"
657        );
658    }
659
660    #[test]
661    fn renders_resolved_image() {
662        let html = render(vec![Block::Paragraph(vec![Inline::Image {
663            src: Url::resolved("cat.jpg", UrlKind::Asset),
664            alt: "Cat".into(),
665            title: None,
666            is_wikilink: false,
667            wikilink_pothole: None,
668        }])]);
669        assert_eq!(html, "<p><img src=\"cat.jpg\" alt=\"Cat\" /></p>\n");
670    }
671
672    #[test]
673    fn renders_emphasis_and_strong() {
674        let html = render(vec![Block::Paragraph(vec![
675            Inline::Emphasis(vec![Inline::Text("em".into())]),
676            Inline::Text(" ".into()),
677            Inline::Strong(vec![Inline::Text("strong".into())]),
678        ])]);
679        assert_eq!(html, "<p><em>em</em> <strong>strong</strong></p>\n");
680    }
681
682    #[test]
683    fn renders_inline_code_with_escaping() {
684        let html = render(vec![Block::Paragraph(vec![Inline::Code("a<b>c".into())])]);
685        assert_eq!(html, "<p><code>a&lt;b&gt;c</code></p>\n");
686    }
687
688    #[test]
689    fn renders_unordered_list_tight() {
690        let html = render(vec![Block::List {
691            ordered: false,
692            start: None,
693            items: vec![
694                vec![Block::Paragraph(vec![Inline::Text("one".into())])],
695                vec![Block::Paragraph(vec![Inline::Text("two".into())])],
696            ],
697            item_source_lines: vec![],
698        }]);
699        assert_eq!(html, "<ul>\n<li>one</li>\n<li>two</li>\n</ul>\n");
700    }
701
702    #[test]
703    fn renders_ordered_list() {
704        let html = render(vec![Block::List {
705            ordered: true,
706            start: None,
707            items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
708            item_source_lines: vec![],
709        }]);
710        assert!(html.starts_with("<ol>"));
711    }
712
713    #[test]
714    fn render_ordered_list_emits_start_attribute_when_non_default() {
715        // `3. foo` should produce `<ol start="3">…</ol>`. The attribute
716        // appears immediately after `<ol`, before any `data-source-line`
717        // (Phase 4 followup B contract).
718        let html = render(vec![Block::List {
719            ordered: true,
720            start: Some(3),
721            items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
722            item_source_lines: vec![],
723        }]);
724        assert!(
725            html.starts_with(r#"<ol start="3">"#),
726            "expected start attr immediately after <ol, got: {html}"
727        );
728    }
729
730    #[test]
731    fn render_ordered_list_omits_start_when_default_1() {
732        // `start: None` is the canonical shape for "default 1." lists.
733        // The renderer must NOT emit `start="1"` (semantically
734        // identical to omitting the attr, but noisier).
735        let html = render(vec![Block::List {
736            ordered: true,
737            start: None,
738            items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
739            item_source_lines: vec![],
740        }]);
741        assert!(html.starts_with("<ol>"), "expected bare <ol>, got: {html}");
742        assert!(
743            !html.contains("start="),
744            "ordered list with default start should not emit start attr, got: {html}"
745        );
746    }
747
748    #[test]
749    fn render_unordered_list_emits_no_start() {
750        // Even if `start: Some(N)` were somehow set on an unordered
751        // list (shouldn't happen via the parser, but defense in depth),
752        // `<ul>` must never carry `start=`.
753        let html = render(vec![Block::List {
754            ordered: false,
755            start: Some(5),
756            items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
757            item_source_lines: vec![],
758        }]);
759        assert!(html.starts_with("<ul>"), "expected bare <ul>, got: {html}");
760        assert!(
761            !html.contains("start="),
762            "unordered list must never carry start attr, got: {html}"
763        );
764    }
765
766    #[test]
767    fn renders_code_block_with_lang() {
768        let html = render(vec![Block::CodeBlock {
769            lang: Some("rust".into()),
770            value: "fn main() {}".into(),
771        }]);
772        assert_eq!(
773            html,
774            "<pre><code class=\"language-rust\">fn main() {}</code></pre>\n"
775        );
776    }
777
778    #[test]
779    fn renders_code_block_without_lang() {
780        let html = render(vec![Block::CodeBlock {
781            lang: None,
782            value: "bare".into(),
783        }]);
784        assert_eq!(html, "<pre><code>bare</code></pre>\n");
785    }
786
787    #[test]
788    fn renders_thematic_break() {
789        let html = render(vec![Block::ThematicBreak]);
790        assert_eq!(html, "<hr />\n");
791    }
792
793    // -----------------------------------------------------------------
794    // Phase 4 PR4: Block::Callout render shape
795    // -----------------------------------------------------------------
796
797    use super::super::node::{CalloutKind, Fold};
798
799    #[test]
800    fn renders_basic_callout_with_title() {
801        let html = render(vec![Block::Callout {
802            kind: CalloutKind::Note,
803            fold: None,
804            title: Some("Heads up".into()),
805            children: vec![Block::Paragraph(vec![Inline::Text("Body.".into())])],
806        }]);
807        assert!(
808            html.contains(r#"<div class="callout" data-type="note">"#),
809            "expected callout div with data-type, got: {html}"
810        );
811        assert!(
812            html.contains(r#"<div class="callout-title">Heads up</div>"#),
813            "expected inline title slot, got: {html}"
814        );
815        assert!(
816            html.contains(r#"<div class="callout-content">"#),
817            "expected content slot, got: {html}"
818        );
819        assert!(html.contains("<p>Body.</p>"), "body must render: {html}");
820    }
821
822    #[test]
823    fn renders_callout_falls_back_to_default_title() {
824        let html = render(vec![Block::Callout {
825            kind: CalloutKind::Warning,
826            fold: None,
827            title: None,
828            children: vec![],
829        }]);
830        assert!(
831            html.contains(r#"<div class="callout-title">Warning</div>"#),
832            "expected capitalized fallback title, got: {html}"
833        );
834    }
835
836    #[test]
837    fn renders_foldable_callout_with_data_fold_attribute() {
838        let html_open = render(vec![Block::Callout {
839            kind: CalloutKind::Tip,
840            fold: Some(Fold::Open),
841            title: Some("Open".into()),
842            children: vec![],
843        }]);
844        assert!(
845            html_open.contains(r#"data-type="tip""#) && html_open.contains(r#"data-fold="open""#),
846            "expected data-fold='open' attribute, got: {html_open}"
847        );
848
849        let html_closed = render(vec![Block::Callout {
850            kind: CalloutKind::Tip,
851            fold: Some(Fold::Closed),
852            title: None,
853            children: vec![],
854        }]);
855        assert!(
856            html_closed.contains(r#"data-fold="closed""#),
857            "expected data-fold='closed' attribute, got: {html_closed}"
858        );
859    }
860
861    #[test]
862    fn callout_alias_renders_canonical_data_type_slug() {
863        // tldr → abstract; ensures the canonicalized slug is what
864        // appears in HTML.
865        let html = render(vec![Block::Callout {
866            kind: CalloutKind::Abstract,
867            fold: None,
868            title: Some("TL;DR".into()),
869            children: vec![],
870        }]);
871        assert!(
872            html.contains(r#"data-type="abstract""#),
873            "expected canonical slug 'abstract', got: {html}"
874        );
875    }
876
877    #[test]
878    fn callout_title_is_html_escaped() {
879        // Title is rendered through escape_text (the same function the
880        // existing renderer uses for text content). escape_text escapes
881        // `<`, `>`, `&` but NOT `"` — `"` is only dangerous inside HTML
882        // attribute values, and title sits between `<div>` tags as text.
883        let html = render(vec![Block::Callout {
884            kind: CalloutKind::Warning,
885            fold: None,
886            title: Some(r#"Use <script> & "quotes""#.into()),
887            children: vec![],
888        }]);
889        assert!(
890            html.contains("Use &lt;script&gt; &amp;"),
891            "title must escape lt/gt/amp, got: {html}"
892        );
893        // No raw `<script>` may appear inside the title div.
894        assert!(
895            !html.contains("<div class=\"callout-title\">Use <script>"),
896            "unescaped angle brackets leaked, got: {html}"
897        );
898    }
899
900    #[test]
901    fn renders_blockquote_with_paragraph() {
902        let html = render(vec![Block::BlockQuote(vec![Block::Paragraph(vec![
903            Inline::Text("q".into()),
904        ])])]);
905        assert_eq!(html, "<blockquote>\n<p>q</p>\n</blockquote>\n");
906    }
907
908    #[test]
909    fn renders_table() {
910        let html = render(vec![Block::Table {
911            header: vec![vec![Inline::Text("A".into())]],
912            rows: vec![vec![vec![Inline::Text("1".into())]]],
913            header_source_line: None,
914            row_source_lines: vec![],
915        }]);
916        assert!(html.contains("<thead>"));
917        assert!(html.contains("<tbody>"));
918        assert!(html.contains("<th>A</th>"));
919        assert!(html.contains("<td>1</td>"));
920    }
921
922    #[test]
923    fn renders_other_block_passes_html_through() {
924        let html = render(vec![Block::Other("<custom></custom>".into())]);
925        assert_eq!(html, "<custom></custom>");
926    }
927
928    #[test]
929    fn text_escapes_lt_gt_amp() {
930        let html = render(vec![Block::Paragraph(vec![Inline::Text("a<b>c&d".into())])]);
931        assert_eq!(html, "<p>a&lt;b&gt;c&amp;d</p>\n");
932    }
933
934    #[test]
935    fn round_trips_parse_to_render_for_canonical_doc() {
936        // End-to-end: post-resolve markdown → parse → simulate visit
937        // (mark every URL Internal) → render → check shape.
938        //
939        // Phase 4 PR2: the parser now populates Block::Heading.id with the
940        // Obsidian anchor slug, so the rendered <h1> carries id="title".
941        let md = "# Title\n\npara with [link](docs/) and *em*.\n";
942        let mut doc = super::super::parser::parse(md);
943        super::super::visit::visit_urls_mut(&mut doc, |u| match u {
944            Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Internal),
945            _ => {}
946        });
947        let html = render_document(&doc, &DefaultHooks::new());
948        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}");
949        assert!(html.contains(r#"<a href="docs/">link</a>"#));
950        assert!(html.contains("<em>em</em>"));
951    }
952
953    // -----------------------------------------------------------------
954    // Phase 4 PR3 (2026-05-27): Block::Figure render
955    // -----------------------------------------------------------------
956
957    #[test]
958    fn figure_renders_with_caption() {
959        // Canonical shape: <figure class="moss-image">{inner img}{figcaption}</figure>.
960        // DefaultHooks::new() has no snapshot, so inner is the bare <img>
961        // (test path). Production wires DefaultHooks::with_snapshot which
962        // routes inner through synth — same shape, richer attrs.
963        let html = render(vec![Block::Figure {
964            image: Inline::Image {
965                src: Url::resolved("logo.png", UrlKind::Asset),
966                alt: "A logo".into(),
967                title: None,
968                is_wikilink: false,
969                wikilink_pothole: None,
970            },
971            caption: Some(vec![Inline::Text("A logo".into())]),
972            width: None,
973            align: None,
974            class_names: Vec::new(),
975            img_style: None,
976        }]);
977        assert!(
978            html.starts_with(r#"<figure class="moss-image">"#),
979            "expected figure wrap, got: {html}"
980        );
981        assert!(html.contains(r#"src="logo.png""#), "got: {html}");
982        assert!(html.contains(r#"alt="A logo""#), "got: {html}");
983        assert!(
984            html.contains("<figcaption>A logo</figcaption>"),
985            "got: {html}"
986        );
987        assert!(html.ends_with("</figure>\n"), "got: {html}");
988    }
989
990    #[test]
991    fn figure_renders_without_caption_when_none() {
992        // Empty-alt case: caption: None → no <figcaption> element.
993        let html = render(vec![Block::Figure {
994            image: Inline::Image {
995                src: Url::resolved("x.png", UrlKind::Asset),
996                alt: String::new(),
997                title: None,
998                is_wikilink: false,
999                wikilink_pothole: None,
1000            },
1001            caption: None,
1002            width: None,
1003            align: None,
1004            class_names: Vec::new(),
1005            img_style: None,
1006        }]);
1007        assert!(html.contains("<figure"), "got: {html}");
1008        assert!(
1009            !html.contains("<figcaption"),
1010            "expected no figcaption, got: {html}"
1011        );
1012        assert!(html.contains("</figure>"), "got: {html}");
1013    }
1014
1015    #[test]
1016    fn figure_renders_no_figcaption_for_empty_caption_vec() {
1017        // Defensive: caption: Some(vec![]) is treated identically to None.
1018        let html = render(vec![Block::Figure {
1019            image: Inline::Image {
1020                src: Url::resolved("x.png", UrlKind::Asset),
1021                alt: "x".into(),
1022                title: None,
1023                is_wikilink: false,
1024                wikilink_pothole: None,
1025            },
1026            caption: Some(vec![]),
1027            width: None,
1028            align: None,
1029            class_names: Vec::new(),
1030            img_style: None,
1031        }]);
1032        assert!(!html.contains("<figcaption"), "got: {html}");
1033    }
1034
1035    // Editor Image UX (2026-06-04): a `%`-suffixed Figure width renders as
1036    // an inline style="width:NN%"; a named token stays data-width=.
1037    // -----------------------------------------------------------------
1038
1039    #[test]
1040    fn figure_percent_width_emits_inline_style() {
1041        let html = render(vec![Block::Figure {
1042            image: Inline::Image {
1043                src: Url::resolved("pic.jpg", UrlKind::Asset),
1044                alt: "alt".into(),
1045                title: None,
1046                is_wikilink: false,
1047                wikilink_pothole: None,
1048            },
1049            caption: None,
1050            width: Some("55%".to_string()),
1051            align: None,
1052            class_names: vec![],
1053            img_style: None,
1054        }]);
1055        assert!(
1056            html.contains(r#"<figure class="moss-image" style="width:55%""#),
1057            "got: {html}"
1058        );
1059        assert!(
1060            !html.contains("data-width="),
1061            "percent must not emit data-width: {html}"
1062        );
1063    }
1064
1065    #[test]
1066    fn figure_named_width_still_emits_data_width() {
1067        let html = render(vec![Block::Figure {
1068            image: Inline::Image {
1069                src: Url::resolved("pic.jpg", UrlKind::Asset),
1070                alt: "alt".into(),
1071                title: None,
1072                is_wikilink: false,
1073                wikilink_pothole: None,
1074            },
1075            caption: None,
1076            width: Some("wide".to_string()),
1077            align: None,
1078            class_names: vec![],
1079            img_style: None,
1080        }]);
1081        assert!(html.contains(r#"data-width="wide""#), "got: {html}");
1082        assert!(
1083            !html.contains("style=\"width"),
1084            "named token must not emit style: {html}"
1085        );
1086    }
1087
1088    #[test]
1089    fn figure_caption_escapes_html_unsafe_chars() {
1090        // Caption is a Vec<Inline>; Inline::Text passes through
1091        // escape_text. The figure renderer must NOT double-escape; the
1092        // existing inline path is the single source of escaping.
1093        let html = render(vec![Block::Figure {
1094            image: Inline::Image {
1095                src: Url::resolved("p.jpg", UrlKind::Asset),
1096                alt: "a<b>c".into(),
1097                title: None,
1098                is_wikilink: false,
1099                wikilink_pothole: None,
1100            },
1101            caption: Some(vec![Inline::Text("a<b>c".into())]),
1102            width: None,
1103            align: None,
1104            class_names: Vec::new(),
1105            img_style: None,
1106        }]);
1107        assert!(
1108            html.contains("<figcaption>a&lt;b&gt;c</figcaption>"),
1109            "got: {html}"
1110        );
1111    }
1112
1113    #[test]
1114    fn figure_end_to_end_from_parser_to_render() {
1115        // Parse → visit (resolve URL) → render: covers the full path.
1116        let md = "![A photo](photo.jpg)\n";
1117        let mut doc = super::super::parser::parse(md);
1118        super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1119            Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Asset),
1120            _ => {}
1121        });
1122        let html = render_document(&doc, &DefaultHooks::new());
1123        assert!(
1124            html.contains(r#"<figure class="moss-image">"#),
1125            "expected figure, got: {html}"
1126        );
1127        assert!(html.contains(r#"src="photo.jpg""#), "got: {html}");
1128        assert!(
1129            html.contains("<figcaption>A photo</figcaption>"),
1130            "got: {html}"
1131        );
1132    }
1133
1134    #[test]
1135    fn paragraph_with_image_and_text_does_not_become_figure() {
1136        // End-to-end regression guard: ![img](u) caption text MUST stay
1137        // as a paragraph (not get the figure wrap) so the prose isn't
1138        // swallowed. Mirrors the parser-side guard `image_with_caption_text_does_not_promote`.
1139        let md = "![alt](a.jpg) plain text\n";
1140        let mut doc = super::super::parser::parse(md);
1141        super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1142            Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Asset),
1143            _ => {}
1144        });
1145        let html = render_document(&doc, &DefaultHooks::new());
1146        assert!(
1147            !html.contains("<figure"),
1148            "image+text must not be wrapped in figure, got: {html}"
1149        );
1150        assert!(html.contains("plain text"), "got: {html}");
1151    }
1152
1153    #[test]
1154    #[cfg(debug_assertions)]
1155    #[should_panic(expected = "visit_urls_mut missing")]
1156    fn unresolved_url_in_link_panics_in_debug() {
1157        // Critical contract: the bypass class is a debug-time crash.
1158        let _ = render(vec![Block::Paragraph(vec![Inline::Link {
1159            url: Url::unresolved("docs/"),
1160            title: None,
1161            children: vec![],
1162            is_wikilink: false,
1163        }])]);
1164    }
1165
1166    // -----------------------------------------------------------------
1167    // 2026-05-28 (Phase 4 source-line wiring): BlockMeta → data-source-line
1168    // emission.
1169    // -----------------------------------------------------------------
1170
1171    /// Render with explicit per-block meta. Helper for the source-line tests.
1172    fn render_with_meta(blocks: Vec<Block>, meta: Vec<BlockMeta>) -> String {
1173        let doc = Document::from_blocks_with_meta(blocks, meta);
1174        render_document(&doc, &DefaultHooks::new())
1175    }
1176
1177    #[test]
1178    fn paragraph_emits_data_source_line_when_meta_set() {
1179        let html = render_with_meta(
1180            vec![Block::Paragraph(vec![Inline::Text("hi".into())])],
1181            vec![BlockMeta {
1182                source_line: Some(7),
1183            }],
1184        );
1185        assert_eq!(html, "<p data-source-line=\"7\">hi</p>\n");
1186    }
1187
1188    #[test]
1189    fn heading_emits_data_source_line_through_hook() {
1190        let html = render_with_meta(
1191            vec![Block::Heading {
1192                level: 2,
1193                children: vec![Inline::Text("Setup".into())],
1194                id: Some("setup".into()),
1195            }],
1196            vec![BlockMeta {
1197                source_line: Some(3),
1198            }],
1199        );
1200        assert!(
1201            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>"##),
1202            "got: {html}"
1203        );
1204    }
1205
1206    #[test]
1207    fn list_blockquote_codeblock_table_hr_emit_data_source_line() {
1208        // Each block type that the legacy transform_events annotated
1209        // must emit `data-source-line` when meta carries it. Single
1210        // smoke test covering every top-level block kind.
1211        let blocks = vec![
1212            Block::BlockQuote(vec![Block::Paragraph(vec![Inline::Text("q".into())])]),
1213            Block::List {
1214                ordered: false,
1215                start: None,
1216                items: vec![vec![Block::Paragraph(vec![Inline::Text("a".into())])]],
1217                item_source_lines: vec![],
1218            },
1219            Block::List {
1220                ordered: true,
1221                start: None,
1222                items: vec![vec![Block::Paragraph(vec![Inline::Text("b".into())])]],
1223                item_source_lines: vec![],
1224            },
1225            Block::CodeBlock {
1226                lang: Some("rust".into()),
1227                value: "x".into(),
1228            },
1229            Block::Table {
1230                header: vec![vec![Inline::Text("H".into())]],
1231                rows: vec![vec![vec![Inline::Text("c".into())]]],
1232                header_source_line: None,
1233                row_source_lines: vec![],
1234            },
1235            Block::ThematicBreak,
1236        ];
1237        let meta = vec![
1238            BlockMeta {
1239                source_line: Some(1),
1240            },
1241            BlockMeta {
1242                source_line: Some(2),
1243            },
1244            BlockMeta {
1245                source_line: Some(3),
1246            },
1247            BlockMeta {
1248                source_line: Some(4),
1249            },
1250            BlockMeta {
1251                source_line: Some(5),
1252            },
1253            BlockMeta {
1254                source_line: Some(6),
1255            },
1256        ];
1257        let html = render_with_meta(blocks, meta);
1258        assert!(
1259            html.contains(r#"<blockquote data-source-line="1">"#),
1260            "blockquote missing: {html}"
1261        );
1262        assert!(
1263            html.contains(r#"<ul data-source-line="2">"#),
1264            "ul missing: {html}"
1265        );
1266        assert!(
1267            html.contains(r#"<ol data-source-line="3">"#),
1268            "ol missing: {html}"
1269        );
1270        assert!(
1271            html.contains(r#"<pre data-source-line="4">"#),
1272            "pre missing: {html}"
1273        );
1274        assert!(
1275            html.contains(r#"<table data-source-line="5">"#),
1276            "table missing: {html}"
1277        );
1278        assert!(
1279            html.contains(r#"<hr data-source-line="6" />"#),
1280            "hr missing: {html}"
1281        );
1282    }
1283
1284    #[test]
1285    fn list_emits_per_li_data_source_line_when_parser_tracks() {
1286        // 2026-05-28 (Phase 4 source-lines followup): `<li>` carries
1287        // `data-source-line="N"` when the parser populated
1288        // `item_source_lines`. Mirrors the legacy transform_events shape
1289        // (commit f91aca8fa, 2026-04-01) that emitted on `<li>` for
1290        // proportional scroll-sync interpolation. The outer `<ul>` carries
1291        // BlockMeta.source_line separately.
1292        let blocks = vec![Block::List {
1293            ordered: false,
1294            start: None,
1295            items: vec![
1296                vec![Block::Paragraph(vec![Inline::Text("one".into())])],
1297                vec![Block::Paragraph(vec![Inline::Text("two".into())])],
1298                vec![Block::Paragraph(vec![Inline::Text("three".into())])],
1299            ],
1300            item_source_lines: vec![Some(10), Some(11), Some(12)],
1301        }];
1302        let meta = vec![BlockMeta {
1303            source_line: Some(10),
1304        }];
1305        let html = render_with_meta(blocks, meta);
1306        assert!(
1307            html.contains(r#"<ul data-source-line="10">"#),
1308            "ul opener missing: {html}"
1309        );
1310        assert!(
1311            html.contains(r#"<li data-source-line="10">one</li>"#),
1312            "li 10 missing: {html}"
1313        );
1314        assert!(
1315            html.contains(r#"<li data-source-line="11">two</li>"#),
1316            "li 11 missing: {html}"
1317        );
1318        assert!(
1319            html.contains(r#"<li data-source-line="12">three</li>"#),
1320            "li 12 missing: {html}"
1321        );
1322    }
1323
1324    #[test]
1325    fn list_omits_li_data_source_line_when_parser_did_not_track() {
1326        // When `item_source_lines` is empty (default — parser ran with
1327        // `emit_source_lines: false`), no per-`<li>` attribute is emitted.
1328        // Locks the publish-build invariant: byte-identical output to the
1329        // pre-followup renderer.
1330        let blocks = vec![Block::List {
1331            ordered: false,
1332            start: None,
1333            items: vec![
1334                vec![Block::Paragraph(vec![Inline::Text("a".into())])],
1335                vec![Block::Paragraph(vec![Inline::Text("b".into())])],
1336            ],
1337            item_source_lines: vec![],
1338        }];
1339        let html = render_with_meta(blocks, vec![BlockMeta::default()]);
1340        assert_eq!(html, "<ul>\n<li>a</li>\n<li>b</li>\n</ul>\n");
1341    }
1342
1343    #[test]
1344    fn table_emits_per_tr_data_source_line_when_parser_tracks() {
1345        // Header `<tr>` carries `header_source_line`; each body `<tr>`
1346        // carries the matching `row_source_lines[i]`.
1347        let blocks = vec![Block::Table {
1348            header: vec![vec![Inline::Text("H".into())]],
1349            rows: vec![
1350                vec![vec![Inline::Text("1".into())]],
1351                vec![vec![Inline::Text("2".into())]],
1352                vec![vec![Inline::Text("3".into())]],
1353            ],
1354            header_source_line: Some(5),
1355            row_source_lines: vec![Some(7), Some(8), Some(9)],
1356        }];
1357        let meta = vec![BlockMeta {
1358            source_line: Some(5),
1359        }];
1360        let html = render_with_meta(blocks, meta);
1361        assert!(
1362            html.contains(r#"<table data-source-line="5">"#),
1363            "table opener missing: {html}"
1364        );
1365        assert!(html.contains(r#"<thead>"#), "thead missing: {html}");
1366        // Header row line — note the header tr is on the marker line
1367        // because pulldown-cmark anchors the head row to the line of the
1368        // `| h |` header markdown row.
1369        assert!(
1370            html.contains(r#"<tr data-source-line="5"><th>H</th>"#),
1371            "head tr missing: {html}"
1372        );
1373        assert!(
1374            html.contains(r#"<tr data-source-line="7"><td>1</td>"#),
1375            "body tr 7 missing: {html}"
1376        );
1377        assert!(
1378            html.contains(r#"<tr data-source-line="8"><td>2</td>"#),
1379            "body tr 8 missing: {html}"
1380        );
1381        assert!(
1382            html.contains(r#"<tr data-source-line="9"><td>3</td>"#),
1383            "body tr 9 missing: {html}"
1384        );
1385    }
1386
1387    #[test]
1388    fn table_omits_tr_data_source_line_when_parser_did_not_track() {
1389        // Publish-build invariant: byte-identical output to pre-followup.
1390        let blocks = vec![Block::Table {
1391            header: vec![vec![Inline::Text("A".into())]],
1392            rows: vec![vec![vec![Inline::Text("1".into())]]],
1393            header_source_line: None,
1394            row_source_lines: vec![],
1395        }];
1396        let html = render_with_meta(blocks, vec![BlockMeta::default()]);
1397        // No `data-source-line` anywhere — the table opener also has
1398        // BlockMeta::default() (None), so the entire `<table>...</table>`
1399        // block is annotation-free.
1400        assert!(
1401            !html.contains("data-source-line"),
1402            "no annotation expected: {html}"
1403        );
1404        assert!(html.contains("<thead>"));
1405        assert!(html.contains("<tr><th>A</th></tr>"));
1406        assert!(html.contains("<tr><td>1</td></tr>"));
1407    }
1408
1409    #[test]
1410    fn figure_emits_data_source_line_on_outer_tag() {
1411        let blocks = vec![Block::Figure {
1412            image: Inline::Image {
1413                src: Url::resolved("p.jpg", UrlKind::Asset),
1414                alt: "A".into(),
1415                title: None,
1416                is_wikilink: false,
1417                wikilink_pothole: None,
1418            },
1419            caption: Some(vec![Inline::Text("A".into())]),
1420            width: None,
1421            align: None,
1422            class_names: Vec::new(),
1423            img_style: None,
1424        }];
1425        let meta = vec![BlockMeta {
1426            source_line: Some(9),
1427        }];
1428        let html = render_with_meta(blocks, meta);
1429        assert!(
1430            html.contains(r#"<figure class="moss-image" data-source-line="9">"#),
1431            "got: {html}"
1432        );
1433    }
1434
1435    #[test]
1436    fn no_data_source_line_when_meta_none() {
1437        // Default `Document::from_blocks` creates meta vec of all
1438        // `BlockMeta::default()`; nothing should leak.
1439        let html = render(vec![
1440            Block::Paragraph(vec![Inline::Text("hi".into())]),
1441            Block::ThematicBreak,
1442        ]);
1443        assert!(
1444            !html.contains("data-source-line"),
1445            "default render must NOT emit data-source-line, got: {html}"
1446        );
1447    }
1448
1449    #[test]
1450    fn end_to_end_parse_with_config_emits_data_source_line() {
1451        // The full path: parse_with_config → visit_urls_mut → render_document.
1452        let md = "# Title\n\nfirst paragraph\n\n## Sub\n\nsecond paragraph\n";
1453        let config = super::super::parser::ParseConfig {
1454            emit_source_lines: true,
1455            implicit_figure: true,
1456            source_line_offset: 0,
1457        };
1458        let mut doc = super::super::parser::parse_with_config(md, &config);
1459        super::super::visit::visit_urls_mut(&mut doc, |u| match u {
1460            Url::Unresolved(s) => *u = Url::resolved(s.clone(), UrlKind::Internal),
1461            _ => {}
1462        });
1463        let html = render_document(&doc, &DefaultHooks::new());
1464        assert!(
1465            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>"##),
1466            "H1 should carry data-source-line=1: {html}"
1467        );
1468        assert!(
1469            html.contains(r#"<p data-source-line="3">first paragraph</p>"#),
1470            "first paragraph should carry data-source-line=3: {html}"
1471        );
1472        assert!(
1473            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>"##),
1474            "H2 should carry data-source-line=5: {html}"
1475        );
1476        assert!(
1477            html.contains(r#"<p data-source-line="7">second paragraph</p>"#),
1478            "second paragraph should carry data-source-line=7: {html}"
1479        );
1480    }
1481}