Skip to main content

moss_core/ast/
parser.rs

1//! Pulldown-cmark → typed AST parser.
2//!
3//! Walks `pulldown_cmark::Event` and assembles a [`Document`]. The parser
4//! enables the same extensions moss's pipeline does: tables, footnotes,
5//! strikethrough.
6//!
7//! All URL nodes start as [`Url::Unresolved`]; classifying into
8//! [`Url::Resolved`] is the job of [`crate::ast::visit::visit_urls_mut`]
9//! (a separate pass).
10//!
11//! Heading IDs ARE assigned by this parser. Phase 4 PR2: each
12//! `Tag::Heading` arm computes the Obsidian-compatible anchor slug from
13//! the heading's text content (only `Event::Text` / `Event::Code`,
14//! matching production's `transform_events` behavior in
15//! `src-tauri/src/build/markdown/pipeline.rs` lines 1776-1845); a
16//! post-parse pass ([`assign_heading_id_suffixes`]) walks all headings in
17//! document order (recursively into BlockQuotes, lists, callouts) and
18//! applies duplicate-suffix numbering (`{slug}-1`, `-2`, …) matching the
19//! `id_counts` HashMap behavior at `pipeline.rs:1798`.
20
21use std::collections::HashMap;
22
23use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
24
25use super::document::{BlockMeta, Document};
26use super::node::{Block, CalloutKind, Fold, Inline};
27use super::shortcode_extract::{extract_shortcodes, parse_placeholder, ExtractedShortcode};
28use super::url::Url;
29use crate::heading_anchor::obsidian_heading_anchor;
30
31/// Parser configuration flags.
32///
33/// Threaded through [`parse_with_config`] to gate optional parser behaviors
34/// that the renderer needs to coordinate with (source-line tracking for
35/// preview scroll sync, implicit-figure promotion).
36///
37/// [`Default`] = "production preview off" — `emit_source_lines: false`,
38/// `implicit_figure: true`. The `implicit_figure` default mirrors today's
39/// always-on behavior of the parser before this config existed; flipping it
40/// off is opt-in for the small set of fragment-render call sites that need
41/// bare `<img>` (none today, but the flag exists for symmetry with the
42/// legacy `transform_events` API and the production `site_config` field).
43#[derive(Debug, Clone, Copy)]
44pub struct ParseConfig {
45    /// When true, populates [`BlockMeta::source_line`] for top-level
46    /// blocks. The renderer emits `data-source-line="N"` on the opening
47    /// tag for any block whose meta carries `Some(N)`.
48    ///
49    /// Production wires this from `process_markdown_file`'s
50    /// `emit_source_lines` argument (`true` during preview builds, `false`
51    /// during ship-stage publish builds — `data-source-line` is stripped
52    /// at ship time anyway, but emitting fewer attrs upstream is cheaper
53    /// and keeps published HTML clean from earlier stages).
54    pub emit_source_lines: bool,
55
56    /// When true (default), image-only paragraphs promote to
57    /// [`Block::Figure`] via [`try_promote_to_figure`]. When false, they
58    /// stay as [`Block::Paragraph`] containing one [`Inline::Image`].
59    ///
60    /// Production wires this from `site_config.implicit_figure` (default
61    /// `true`). The flag mirrors the legacy `transform_events`
62    /// implicit-figure pass: sites that prefer bare `<img>` (no `<figure>`
63    /// wrap) can opt out.
64    pub implicit_figure: bool,
65
66    /// Added to every computed `source_line` so the emitted
67    /// `data-source-line` / `data-source-range` values match the editor's
68    /// REAL FILE line numbers (CM6 `doc.lineAt`), not body-relative lines.
69    ///
70    /// The parser only ever sees the markdown BODY (frontmatter is stripped
71    /// upstream), so its byte offsets — and thus `LineLookup` — are
72    /// body-relative. The editor, however, reports raw-file lines including
73    /// the frontmatter. Without this offset, every annotation is short by the
74    /// frontmatter line count, so editor→preview scroll-sync maps to the wrong
75    /// element (the home page's grid scrolled the preview to the bottom). Set
76    /// to the number of lines the frontmatter consumes (0 when there is none).
77    /// See `process_markdown_file` and docs/architecture/editor-preview-sync.md
78    /// "Known defect — source-line coordinate-system mismatch".
79    pub source_line_offset: usize,
80}
81
82impl Default for ParseConfig {
83    fn default() -> Self {
84        Self {
85            emit_source_lines: false,
86            // `true` matches today's always-on behavior of the parser
87            // before ParseConfig existed; the ~40 in-crate `parse()`
88            // callers all assume figure promotion happens.
89            implicit_figure: true,
90            source_line_offset: 0,
91        }
92    }
93}
94
95/// Parse markdown into a typed [`Document`] using the default config.
96///
97/// Equivalent to `parse_with_config(markdown, &ParseConfig::default())`.
98/// This is the entry point for the ~40 in-crate callers that don't need
99/// per-parse configuration (URL resolution tests, frontmatter round-trip
100/// tests, etc.). Production paths that need source-line tracking or
101/// implicit-figure toggling call [`parse_with_config`].
102pub fn parse(markdown: &str) -> Document {
103    parse_with_config(markdown, &ParseConfig::default())
104}
105
106/// Parse markdown into a typed [`Document`].
107///
108/// This is the AST entry point. The input is post-resolve markdown (the
109/// upstream resolve pipeline has already rewritten wikilinks into standard
110/// markdown links with `moss-resolved:` prefixes).
111///
112/// Two-stage parse:
113/// 1. [`extract_shortcodes`] pre-scans for `:::name` blocks, replacing
114///    each with a sentinel HTML comment.
115/// 2. Pulldown-cmark parses the substituted markdown into events; each
116///    sentinel comes back as a `Block::Other` raw HTML.
117/// 3. A final pass walks the AST and substitutes `Block::Other` sentinel
118///    payloads with the corresponding typed [`Block::Shortcode`].
119///
120/// When `config.emit_source_lines` is true, the parser walks events via
121/// `into_offset_iter()` so each top-level block carries the byte offset
122/// of its first event; a [`LineLookup`] converts the offset to a 1-based
123/// line number stored in [`BlockMeta::source_line`].
124pub fn parse_with_config(markdown: &str, config: &ParseConfig) -> Document {
125    let extraction = extract_shortcodes(markdown);
126
127    let mut options = Options::empty();
128    options.insert(Options::ENABLE_STRIKETHROUGH);
129    options.insert(Options::ENABLE_TABLES);
130    options.insert(Options::ENABLE_FOOTNOTES);
131    // Phase 3 PR2: pulldown-cmark emits `LinkType::WikiLink` events for
132    // `[[…]]` / `![[…]]` natively. The typed-AST parser preserves them as
133    // `Inline::Link`/`Inline::Image` with `Url::Unresolved`; resolution
134    // happens in the later `visit_urls_mut` pass.
135    options.insert(Options::ENABLE_WIKILINKS);
136
137    // Source-line tracking requires the `into_offset_iter` form of the
138    // parser, which yields (Event, Range<usize>). When tracking is off,
139    // we use the plain iterator (no per-event offset overhead).
140    let (events, offsets): (Vec<Event<'_>>, Vec<Option<std::ops::Range<usize>>>) =
141        if config.emit_source_lines {
142            let mut evs = Vec::new();
143            let mut offs = Vec::new();
144            for (event, range) in
145                Parser::new_ext(&extraction.markdown_with_placeholders, options).into_offset_iter()
146            {
147                evs.push(event);
148                offs.push(Some(range));
149            }
150            (evs, offs)
151        } else {
152            let evs: Vec<Event<'_>> =
153                Parser::new_ext(&extraction.markdown_with_placeholders, options).collect();
154            let len = evs.len();
155            (evs, vec![None; len])
156        };
157
158    // Build the prefix-sum line table once (only when needed).
159    //
160    // CAVEAT: the markdown that the offsets index into is
161    // `extraction.markdown_with_placeholders`, NOT the original
162    // `markdown` passed in. Shortcode extraction may rewrite some bytes
163    // into sentinel HTML comments of a different length; line numbers
164    // would be off for blocks following an extracted shortcode if we
165    // built the lookup against the original. We build against the
166    // post-extraction string, so the line numbers match the
167    // post-extraction view — which is what users see in their editor
168    // before shortcode-block lines, and is "close enough" after (the
169    // sentinel preserves one line per extracted block, so line counts
170    // after the extracted block are within one of the source). See the
171    // architecture note in `shortcode_extract.rs` for the placeholder
172    // shape.
173    //
174    // For the source-line-off path, lookup is unused.
175    let line_lookup = if config.emit_source_lines {
176        Some(LineLookup::build(
177            &extraction.markdown_with_placeholders,
178            config.source_line_offset,
179        ))
180    } else {
181        None
182    };
183
184    // Line-tracking context handed to every recursive parser entry; the
185    // Tag::List / Tag::Table arms consult it to annotate per-item / per-row
186    // source lines. `None` when `emit_source_lines` is off; the inner
187    // arms see this as "skip annotation" and emit empty parallel vecs.
188    let line_ctx: Option<LineCtx<'_>> = line_lookup.as_ref().map(|lookup| LineCtx {
189        lookup,
190        offsets: &offsets,
191    });
192
193    let mut blocks = Vec::new();
194    let mut block_meta: Vec<BlockMeta> = Vec::new();
195    let mut i = 0;
196    while i < events.len() {
197        let event_start_idx = i;
198        let (block, advance) = parse_block(&events, i, line_ctx.as_ref());
199        if let Some(b) = block {
200            // Compute source_line from the first event's byte offset, if
201            // we collected offsets and a lookup is in scope.
202            let source_line = match (line_lookup.as_ref(), offsets.get(event_start_idx)) {
203                (Some(lookup), Some(Some(range))) => Some(lookup.line_at(range.start)),
204                _ => None,
205            };
206            blocks.push(b);
207            block_meta.push(BlockMeta { source_line });
208        }
209        i += advance.max(1);
210    }
211
212    // Substitute sentinel placeholders with their typed Shortcode variants.
213    substitute_shortcode_placeholders(&mut blocks, &extraction.nonce, &extraction.extracted);
214
215    // Implicit-figure gating: the per-paragraph `try_promote_to_figure`
216    // inside `parse_block_with_tag` always runs (so the figure promotion
217    // happens at parse time inside the Tag::Paragraph arm). When
218    // `config.implicit_figure` is false, we walk the assembled blocks
219    // and "undo" the promotion — converting `Block::Figure { image, ..}`
220    // back to `Block::Paragraph(vec![image])`.
221    //
222    // The unwinding-at-the-end approach was chosen over threading the
223    // flag into `parse_block_with_tag` because the latter would mean
224    // propagating `config` through ~14 inner parser functions whose
225    // signatures are already tight. The unwind is O(N) and only fires
226    // on the rare opt-out path; production keeps the default `true`.
227    if !config.implicit_figure {
228        for block in blocks.iter_mut() {
229            unwrap_implicit_figure(block);
230        }
231    }
232
233    // Apply duplicate-suffix numbering to heading IDs in document order.
234    // Each Tag::Heading arm computes the base slug; this pass disambiguates
235    // collisions across the whole document, matching production's id_counts
236    // HashMap behavior in pipeline.rs::transform_events.
237    assign_heading_id_suffixes(&mut blocks);
238
239    Document::from_blocks_with_meta(blocks, block_meta)
240}
241
242/// Recursively undo implicit-figure promotion in `block` and its children.
243///
244/// Called when `ParseConfig::implicit_figure` is false. Walks the block
245/// tree (descending into containers — `BlockQuote`, `Callout`, `List`,
246/// `LinkCard`) and rewrites any `Block::Figure` back to
247/// `Block::Paragraph(vec![image])` with the original alt text preserved.
248/// The caption is discarded (matches the legacy bare-`<img>` shape).
249fn unwrap_implicit_figure(block: &mut Block) {
250    // Replace this block if it's a Figure.
251    if let Block::Figure { image, .. } = block {
252        let img = std::mem::replace(
253            image,
254            Inline::Text(String::new()), // placeholder, overwritten below
255        );
256        *block = Block::Paragraph(vec![img]);
257        return;
258    }
259    // Recurse into containers.
260    match block {
261        Block::BlockQuote(children) => {
262            for child in children.iter_mut() {
263                unwrap_implicit_figure(child);
264            }
265        }
266        Block::Callout { children, .. } => {
267            for child in children.iter_mut() {
268                unwrap_implicit_figure(child);
269            }
270        }
271        Block::List { items, .. } => {
272            for item in items.iter_mut() {
273                for child in item.iter_mut() {
274                    unwrap_implicit_figure(child);
275                }
276            }
277        }
278        Block::LinkCard { children, .. } => {
279            for child in children.iter_mut() {
280                unwrap_implicit_figure(child);
281            }
282        }
283        _ => {}
284    }
285}
286
287/// Bundle of borrowed line-tracking state threaded through recursive
288/// parser entries. Constructed once per `parse_with_config` when
289/// `emit_source_lines` is true; `None` everywhere else.
290///
291/// `parse_block` / `parse_block_with_tag` consult `line_at_event` to
292/// annotate per-`<li>` and per-`<tr>` source lines. The outer
293/// top-level-block source line is computed at the parse loop itself
294/// (already in place), not here.
295struct LineCtx<'a> {
296    lookup: &'a LineLookup,
297    offsets: &'a [Option<std::ops::Range<usize>>],
298}
299
300impl<'a> LineCtx<'a> {
301    /// 1-based source line of the event at `event_index`, or `None` if
302    /// the offset is missing (defensive — shouldn't happen when the
303    /// parser is operating with `emit_source_lines: true`).
304    fn line_at_event(&self, event_index: usize) -> Option<usize> {
305        match self.offsets.get(event_index) {
306            Some(Some(range)) => Some(self.lookup.line_at(range.start)),
307            _ => None,
308        }
309    }
310}
311
312/// Prefix-sum line-number lookup for byte offsets in a source string.
313///
314/// Built once per parse (when `emit_source_lines` is on). Stores the byte
315/// offset of every `\n` in `source`; `line_at(offset)` returns the
316/// 1-based line number containing that offset via binary search.
317///
318/// Equivalent (slower) form: `source[..offset].matches('\n').count() + 1`
319/// — O(N) per call vs. O(log N) here. For documents with ~25 blocks the
320/// difference is negligible, but the binary-search form is the canonical
321/// pattern and is the cheaper hot-path shape.
322struct LineLookup {
323    /// Byte offsets of every `\n` in the source. Sorted ascending by
324    /// construction. `newline_offsets[i]` is the byte index of the i-th
325    /// newline (0-based).
326    newline_offsets: Vec<usize>,
327    /// Added to every `line_at` result so body-relative lines become
328    /// raw-file lines (the frontmatter line count). See
329    /// `ParseConfig::source_line_offset`.
330    line_offset: usize,
331}
332
333impl LineLookup {
334    fn build(source: &str, line_offset: usize) -> Self {
335        let mut newline_offsets = Vec::new();
336        for (i, b) in source.bytes().enumerate() {
337            if b == b'\n' {
338                newline_offsets.push(i);
339            }
340        }
341        Self {
342            newline_offsets,
343            line_offset,
344        }
345    }
346
347    /// 1-based line number containing `byte_offset`, plus `line_offset`.
348    ///
349    /// Offset 0 (before any newline) → line 1. After the first newline →
350    /// line 2. Etc. Offsets past the end of the source clamp to the last
351    /// line + 1. `line_offset` (the frontmatter line count) is added so the
352    /// result is a raw-file line, matching the editor's `doc.lineAt`.
353    fn line_at(&self, byte_offset: usize) -> usize {
354        // Find the number of newlines strictly before `byte_offset`.
355        // That count + 1 is the 1-based line number.
356        let body_line = match self.newline_offsets.binary_search(&byte_offset) {
357            // Exact match: offset IS a newline byte; the newline belongs
358            // to the line that ENDS at it, so line number = idx + 1.
359            // (The next byte starts line idx + 2; this matches the legacy
360            // count-and-add-1 semantics, which counts newlines BEFORE the
361            // offset.)
362            Ok(idx) => idx + 1,
363            Err(idx) => idx + 1,
364        };
365        body_line + self.line_offset
366    }
367}
368
369/// Walk top-level blocks; replace any `Block::Other` whose payload is a
370/// `<!--MOSS_SC_{nonce}_{index}-->` sentinel with the corresponding typed
371/// [`Block::Shortcode`].
372fn substitute_shortcode_placeholders(
373    blocks: &mut Vec<Block>,
374    nonce: &str,
375    extracted: &[ExtractedShortcode],
376) {
377    for block in blocks.iter_mut() {
378        if let Block::Other(html) = block {
379            if let Some(index) = parse_placeholder(nonce, html) {
380                if let Some(entry) = extracted.iter().find(|e| e.index == index) {
381                    *block = Block::Shortcode(entry.shortcode.clone());
382                }
383            }
384        }
385        // Future: descend into BlockQuote / List items / Callouts when
386        // shortcodes inside those constructs are modeled. Phase B Tasks
387        // 7-10 only need top-level shortcodes.
388    }
389}
390
391/// Parse one block-level construct starting at `events[start]`. Returns
392/// the parsed block (or `None` if `events[start]` was a closing tag /
393/// stray event we skip) and how many events to advance.
394///
395/// `line_ctx` carries the optional line-tracking context for per-item
396/// (`<li>`) and per-row (`<tr>`) source-line annotation; threaded through
397/// to `parse_block_with_tag`.
398fn parse_block(
399    events: &[Event<'_>],
400    start: usize,
401    line_ctx: Option<&LineCtx<'_>>,
402) -> (Option<Block>, usize) {
403    match &events[start] {
404        Event::Start(tag) => parse_block_with_tag(events, start, tag, line_ctx),
405        Event::Text(_) | Event::Code(_) | Event::Html(_) | Event::SoftBreak | Event::HardBreak => {
406            // Top-level stray inlines: pulldown-cmark always wraps these in
407            // `Tag::Paragraph` at top level, so this branch is dead in practice.
408            //
409            // The tight-list-item case where the inlines are emitted directly
410            // (no Tag::Paragraph wrap) was the load-bearing reason this branch
411            // looked relevant; PR0.6 moved that responsibility into
412            // `collect_item_blocks`, which synthesizes a Block::Paragraph for
413            // stray inlines inside Tag::Item. See parser.rs's collect_item_blocks
414            // helper.
415            (None, 1)
416        }
417        Event::End(_) => (None, 1),
418        Event::Rule => (Some(Block::ThematicBreak), 1),
419        _ => (None, 1),
420    }
421}
422
423fn parse_block_with_tag(
424    events: &[Event<'_>],
425    start: usize,
426    tag: &Tag<'_>,
427    line_ctx: Option<&LineCtx<'_>>,
428) -> (Option<Block>, usize) {
429    match tag {
430        Tag::Heading { level, .. } => {
431            let (children, end) = collect_inlines_until(events, start + 1, |e| {
432                matches!(e, Event::End(TagEnd::Heading(_)))
433            });
434            let level_num = match level {
435                HeadingLevel::H1 => 1,
436                HeadingLevel::H2 => 2,
437                HeadingLevel::H3 => 3,
438                HeadingLevel::H4 => 4,
439                HeadingLevel::H5 => 5,
440                HeadingLevel::H6 => 6,
441            };
442            // Phase 4 PR2: compute the heading-anchor base slug from the
443            // text/code content between Start(Heading) and End(Heading),
444            // matching production's transform_events behavior. Inline HTML
445            // (`<br>` etc.), images, and link href text are NOT included —
446            // only Event::Text and Event::Code. The post-parse
447            // `assign_heading_id_suffixes` pass disambiguates collisions.
448            let heading_text = collect_heading_text(events, start + 1, end);
449            let base_slug = obsidian_heading_anchor(&heading_text);
450            (
451                Some(Block::Heading {
452                    level: level_num,
453                    children,
454                    id: Some(base_slug),
455                }),
456                end - start + 1,
457            )
458        }
459        Tag::Paragraph => {
460            let (children, end) = collect_inlines_until(events, start + 1, |e| {
461                matches!(e, Event::End(TagEnd::Paragraph))
462            });
463            // Phase 4 PR3 (2026-05-27): detect image-only paragraphs and
464            // promote to `Block::Figure`. See shape-spec § 1 detection
465            // rule: exactly one `Inline::Image` plus any number of
466            // whitespace-only `Inline::Text` / `Inline::LineBreak`
467            // siblings qualifies. Caption defaults to the image's alt
468            // text (mirroring transform_events' implicit-figure path);
469            // empty alt yields `caption: None` so no `<figcaption>` is
470            // emitted.
471            //
472            // A paragraph with image+prose (e.g. `![img](src) caption text`)
473            // does NOT qualify; it stays as `Block::Paragraph`. This is the
474            // critical regression guard — see PR1 v2 (commit 71c657af3)
475            // for the analogous shape decision at the inline image hook
476            // level: inline images use `MarkdownInline` (no figure wrap);
477            // only the standalone figure case here uses the figure wrap.
478            let block = match try_promote_to_figure(children) {
479                Ok(figure) => figure,
480                Err(original_inlines) => Block::Paragraph(original_inlines),
481            };
482            (Some(block), end - start + 1)
483        }
484        Tag::CodeBlock(kind) => {
485            let lang = match kind {
486                pulldown_cmark::CodeBlockKind::Fenced(s) if !s.is_empty() => Some(s.to_string()),
487                _ => None,
488            };
489            let mut value = String::new();
490            let mut i = start + 1;
491            while i < events.len() {
492                match &events[i] {
493                    Event::End(TagEnd::CodeBlock) => break,
494                    Event::Text(t) => value.push_str(t),
495                    _ => {}
496                }
497                i += 1;
498            }
499            (Some(Block::CodeBlock { lang, value }), i - start + 1)
500        }
501        Tag::BlockQuote(_) => {
502            // Phase 4 PR4: detect Obsidian-style callouts. A blockquote
503            // whose first paragraph's leading text matches `[!<kind>]`
504            // (with optional `+`/`-` foldable suffix and optional
505            // inline title) promotes to `Block::Callout`. Otherwise it
506            // stays a plain blockquote. See shape-spec § 1.
507            //
508            // Detection works on the EVENT stream (not the parsed
509            // children) because pulldown-cmark's SoftBreak events
510            // become `Inline::Text("\n")` during inline parsing
511            // (PR4.5 aligned to CommonMark spec — see
512            // `parse_inline` SoftBreak handling). Working on events
513            // preserves the structural break before inline
514            // collapse, which is what the marker-line-vs-body-line
515            // boundary check needs.
516            match detect_and_assemble_callout(events, start + 1, line_ctx) {
517                Some((block, body_end)) => (Some(block), body_end - start + 1),
518                None => {
519                    let (children, end) = collect_blocks_until(events, start + 1, line_ctx, |e| {
520                        matches!(e, Event::End(TagEnd::BlockQuote(_)))
521                    });
522                    (Some(Block::BlockQuote(children)), end - start + 1)
523                }
524            }
525        }
526        Tag::List(start_num) => {
527            let ordered = start_num.is_some();
528            // Preserve explicit ordered-list start number when it's not
529            // the implicit default `1`. `3. foo` → `Some(3)` so the
530            // renderer can emit `<ol start="3">`. pulldown-cmark
531            // normalizes `1. foo` to `Some(1)`, which we collapse to
532            // `None` because `<ol>` and `<ol start="1">` are
533            // semantically identical and we prefer the cleaner attr-free
534            // shape for the common case. Bound name is `list_start` to
535            // avoid shadowing the outer `start: usize` event-index
536            // parameter.
537            let list_start = match start_num {
538                Some(n) if *n != 1 => Some(*n),
539                _ => None,
540            };
541            let mut items: Vec<Vec<Block>> = Vec::new();
542            // Parallel-to-`items` per-`<li>` source-line annotations.
543            // Empty when `line_ctx` is None; otherwise tracks each
544            // `Event::Start(Tag::Item)`'s byte offset → line. The renderer
545            // emits `<li data-source-line="N">` for entries that are Some.
546            let mut item_source_lines: Vec<Option<usize>> = Vec::new();
547            let track_lines = line_ctx.is_some();
548            let mut i = start + 1;
549            while i < events.len() {
550                match &events[i] {
551                    Event::End(TagEnd::List(_)) => break,
552                    Event::Start(Tag::Item) => {
553                        if track_lines {
554                            item_source_lines.push(line_ctx.and_then(|ctx| ctx.line_at_event(i)));
555                        }
556                        let (item_blocks, end) = collect_item_blocks(events, i + 1, line_ctx);
557                        items.push(item_blocks);
558                        i = end + 1;
559                    }
560                    _ => i += 1,
561                }
562            }
563            (
564                Some(Block::List {
565                    ordered,
566                    start: list_start,
567                    items,
568                    item_source_lines,
569                }),
570                i - start + 1,
571            )
572        }
573        Tag::Table(_) => {
574            let mut header: Vec<Vec<Inline>> = Vec::new();
575            let mut rows: Vec<Vec<Vec<Inline>>> = Vec::new();
576            // Per-`<tr>` source-line tracking. `header_source_line` is the
577            // `<thead><tr>` line; `row_source_lines` is parallel to `rows`.
578            // Both stay empty / None when `line_ctx` is None.
579            let mut header_source_line: Option<usize> = None;
580            let mut row_source_lines: Vec<Option<usize>> = Vec::new();
581            let track_lines = line_ctx.is_some();
582            let mut current_row: Vec<Vec<Inline>> = Vec::new();
583            let mut in_head = false;
584            let mut in_body_row = false;
585            let mut i = start + 1;
586            while i < events.len() {
587                match &events[i] {
588                    Event::End(TagEnd::Table) => break,
589                    Event::Start(Tag::TableHead) => {
590                        in_head = true;
591                        // pulldown-cmark does NOT emit `Tag::TableRow` for the
592                        // header row — it goes straight from `Tag::TableHead`
593                        // to the cells. So we anchor the header `<tr>` line
594                        // to the `TableHead` event itself (line of the
595                        // markdown `| h |` row).
596                        if track_lines {
597                            header_source_line = line_ctx.and_then(|ctx| ctx.line_at_event(i));
598                        }
599                        i += 1;
600                    }
601                    Event::End(TagEnd::TableHead) => {
602                        in_head = false;
603                        i += 1;
604                    }
605                    Event::Start(Tag::TableRow) => {
606                        in_body_row = true;
607                        current_row = Vec::new();
608                        if track_lines {
609                            // pulldown-cmark only emits `TableRow` for body
610                            // rows (header cells live directly inside
611                            // `TableHead`). Always push to body lines here.
612                            row_source_lines.push(line_ctx.and_then(|ctx| ctx.line_at_event(i)));
613                        }
614                        i += 1;
615                    }
616                    Event::End(TagEnd::TableRow) => {
617                        if in_body_row {
618                            rows.push(std::mem::take(&mut current_row));
619                            in_body_row = false;
620                        }
621                        i += 1;
622                    }
623                    Event::Start(Tag::TableCell) => {
624                        let (cell_inlines, end) = collect_inlines_until(events, i + 1, |e| {
625                            matches!(e, Event::End(TagEnd::TableCell))
626                        });
627                        if in_head {
628                            header.push(cell_inlines);
629                        } else {
630                            current_row.push(cell_inlines);
631                        }
632                        i = end + 1;
633                    }
634                    _ => i += 1,
635                }
636            }
637            (
638                Some(Block::Table {
639                    header,
640                    rows,
641                    header_source_line,
642                    row_source_lines,
643                }),
644                i - start + 1,
645            )
646        }
647        Tag::HtmlBlock => {
648            let mut html = String::new();
649            let mut i = start + 1;
650            while i < events.len() {
651                match &events[i] {
652                    Event::End(TagEnd::HtmlBlock) => break,
653                    Event::Html(s) | Event::Text(s) => html.push_str(s),
654                    _ => {}
655                }
656                i += 1;
657            }
658            (Some(Block::Other(html)), i - start + 1)
659        }
660        // Unmodeled containers: skip to End and emit nothing. The events
661        // inside are dropped — anything moss cares about should be modeled
662        // explicitly.
663        _ => (None, 1),
664    }
665}
666
667/// Decide whether a paragraph's inlines qualify for promotion to
668/// [`Block::Figure`]. Per shape-spec § 1: exactly one [`Inline::Image`]
669/// plus any number of whitespace-only [`Inline::Text`] /
670/// [`Inline::LineBreak`] siblings. Any other inline shape (Emphasis,
671/// Strong, Link, Code, non-whitespace Text, …) disqualifies the
672/// paragraph and it stays as [`Block::Paragraph`].
673///
674/// **Empty-alt guard:** if the matched image has an empty alt (decorative
675/// image), the paragraph is NOT promoted. This mirrors production's
676/// `transform_events` implicit-figure pass which gates on non-empty alt
677/// (a `<figure>` whose caption duplicates a missing alt would be useless
678/// for assistive tech and adds visual noise). The empty-alt image stays
679/// as `<p><img></p>`, matching the production byte shape for the same
680/// input — verified via the parity probe's `other` category on 刘果 CJK
681/// fixtures (image-only paragraphs with empty alt).
682///
683/// On qualification, returns `Ok(Block::Figure { image, caption })` with
684/// caption defaulting to the image's alt text (parsed as a single
685/// [`Inline::Text`] so the renderer's figcaption emission can escape it
686/// uniformly with other inline content).
687///
688/// On disqualification, returns `Err(original_inlines)` so the caller
689/// can fall back to constructing the standard `Block::Paragraph` without
690/// re-walking events.
691fn try_promote_to_figure(inlines: Vec<Inline>) -> Result<Block, Vec<Inline>> {
692    let mut image_count = 0;
693    for inline in &inlines {
694        match inline {
695            Inline::Image { .. } => image_count += 1,
696            Inline::Text(s) if s.trim().is_empty() => {} // whitespace OK
697            Inline::LineBreak => {}                      // line break OK
698            _ => return Err(inlines),
699        }
700    }
701    if image_count != 1 {
702        return Err(inlines);
703    }
704
705    // Non-image wikilink embeds never promote. pulldown-cmark parses every
706    // `![[…]]` as an Image event, but Figure is an image concept: a video /
707    // pdf / audio wikilink promoted here bypasses `dispatch_wikilink_embeds`
708    // (which only dispatches Paragraph-shaped lone embeds), so its typed
709    // synthesizer never runs and the page ships `<figure><img src="clip.mov">`
710    // — a broken image. The gate keys off the same classifier the dispatcher
711    // uses (`resolve::ext_kind`), so parse-time promotion and dispatch-time
712    // synthesis cannot disagree about who owns the block. Extension-less
713    // wikilinks (`![[draft|55%]]`) also stay Paragraph: only the with-graph
714    // dispatcher can resolve their kind, and committing them to an image
715    // Figure here would be a guess.
716    if let Some(Inline::Image {
717        src,
718        is_wikilink: true,
719        ..
720    }) = inlines.iter().find(|i| matches!(i, Inline::Image { .. }))
721    {
722        let dest = match src {
723            Url::Unresolved(s) => s.as_str(),
724            Url::Resolved(r) => r.href.as_str(),
725        };
726        let ext = crate::path_ext::path_extension_lower(dest);
727        if !matches!(
728            crate::resolve::ext_kind::reference_kind_for_ext(&ext),
729            crate::resolve::ext_kind::ExtKind::Image
730        ) {
731            return Err(inlines);
732        }
733    }
734
735    // Probe the width + remaining alt on a BORROW first, so the empty-alt
736    // guard can still return `Err(inlines)` with the original whitespace /
737    // line-break siblings intact (production `<p><img>…</p>` parity).
738    //
739    // Standard-markdown images carry no structured pothole — a `|55%`/`|wide`
740    // width rides in the raw alt text. Split it out so the figure carries the
741    // width and the caption is the remaining alt.
742    //
743    // Wikilink images carry the raw pothole in `wikilink_pothole`; named width
744    // tokens are already classified by `parse_pothole_params` (WidthToken arm),
745    // but a content-relative percent (`55%`) is classified as `Alias` and
746    // lands in `alt` (or is stripped from alt by our parser-level Alias fix).
747    // Recover the percent from `wikilink_pothole` directly so the figure
748    // carries the width on both the with-graph path (wikilink_dispatch) and
749    // the no-graph path (fragment/test render with no ContentGraph).
750    let mut figure_width: Option<String> = None;
751    let mut rewritten_alt: Option<String> = None;
752    match inlines.iter().find(|i| matches!(i, Inline::Image { .. })) {
753        Some(Inline::Image {
754            alt,
755            is_wikilink: false,
756            ..
757        }) => {
758            let (rest_alt, w) = crate::media::split_alt_width(alt);
759            if w.is_some() {
760                figure_width = w;
761                rewritten_alt = Some(rest_alt);
762            }
763        }
764        Some(Inline::Image {
765            is_wikilink: true,
766            wikilink_pothole,
767            ..
768        }) => {
769            // Recover a content-relative percent from the raw pothole.
770            // Named tokens are already absent from `alt` (WidthToken arm in
771            // parse_pothole_params clears them); only the percent case falls
772            // through as `Alias` and still needs extracting.
773            // Sync: the with-graph twin lives in resolve/wikilink_dispatch.rs
774            // (image branch, ~line 565) — both split width via media::split_alt_width.
775            if let Some(pothole) = wikilink_pothole {
776                let (remaining, w) = crate::media::split_alt_width(pothole);
777                if w.is_some() {
778                    figure_width = w;
779                    // The remaining pothole (caption after stripping the %) is
780                    // the intended caption; propagate it as the rewritten alt if
781                    // the current alt is empty (percent-only pothole) or already
782                    // stripped to the same value.
783                    rewritten_alt = Some(remaining);
784                }
785            }
786        }
787        _ => {}
788    }
789
790    // The figure's caption text is the effective alt (width-stripped if a
791    // width was present, else the raw alt), trimmed.
792    let raw_alt = inlines.iter().find_map(|i| match i {
793        Inline::Image { alt, .. } => Some(alt.as_str()),
794        _ => None,
795    });
796    let alt_text = rewritten_alt
797        .as_deref()
798        .or(raw_alt)
799        .map(|s| s.trim().to_string())
800        .unwrap_or_default();
801
802    // Empty-alt guard: refuse to promote a decorative image (preserve the
803    // original `<p><img></p>` shape with its whitespace siblings) — UNLESS it
804    // carries a width, which needs a figure to hold the inline
805    // `style="width:NN%"` / `data-width=`.
806    if alt_text.is_empty() && figure_width.is_none() {
807        return Err(inlines);
808    }
809
810    // Extract the single image, applying the width-stripped alt if any.
811    let mut image_owned: Option<Inline> = None;
812    for inline in inlines.into_iter() {
813        if matches!(inline, Inline::Image { .. }) {
814            image_owned = Some(inline);
815            break;
816        }
817    }
818    let mut image =
819        image_owned.expect("invariant: image_count == 1 implies one Image present");
820    if let (Some(new_alt), Inline::Image { alt, .. }) = (rewritten_alt, &mut image) {
821        *alt = new_alt;
822    }
823
824    // Caption defaults to the remaining alt text; empty alt yields None so no
825    // empty <figcaption> is emitted.
826    let caption = if alt_text.is_empty() {
827        None
828    } else {
829        Some(vec![Inline::Text(alt_text)])
830    };
831
832    Ok(Block::Figure {
833        image,
834        caption,
835        width: figure_width,
836        align: None,
837        class_names: Vec::new(),
838        img_style: None,
839    })
840}
841
842/// Collect a contiguous run of inline events into `Vec<Inline>`. Stops
843/// when `is_end(event)` returns true or events run out. Returns the
844/// collected inlines and the end-event index.
845fn collect_inlines_until<F>(events: &[Event<'_>], start: usize, is_end: F) -> (Vec<Inline>, usize)
846where
847    F: Fn(&Event<'_>) -> bool,
848{
849    let mut out: Vec<Inline> = Vec::new();
850    let mut i = start;
851    while i < events.len() {
852        if is_end(&events[i]) {
853            return (out, i);
854        }
855        let (inline, advance) = parse_inline(events, i);
856        if let Some(node) = inline {
857            out.push(node);
858        }
859        i += advance.max(1);
860    }
861    (out, i)
862}
863
864/// Parse one inline construct starting at `events[start]`.
865fn parse_inline(events: &[Event<'_>], start: usize) -> (Option<Inline>, usize) {
866    match &events[start] {
867        Event::Text(t) => (Some(Inline::Text(t.to_string())), 1),
868        Event::Code(c) => (Some(Inline::Code(c.to_string())), 1),
869        // Phase 4 PR4.5 (2026-05-28): match pulldown-cmark's `push_html`
870        // byte shape — SoftBreak emits `\n` between inline siblings, not a
871        // space. The space form was a long-standing AST quirk surfaced
872        // by Grid cells now flowing through the AST renderer; production
873        // baselines (chps-site, SoCiviC, snapshot fixtures) preserve the
874        // newline (e.g. `Flamboyan Theater · The Clemente\n107 Suffolk
875        // Street`). Aligning here closes one row of the parity probe's
876        // `whitespace_attribute_order` category.
877        Event::SoftBreak => (Some(Inline::Text("\n".to_string())), 1),
878        Event::HardBreak => (Some(Inline::LineBreak), 1),
879        Event::Html(s) | Event::InlineHtml(s) => (Some(Inline::Other(s.to_string())), 1),
880        Event::Start(tag) => match tag {
881            Tag::Emphasis => {
882                let (children, end) = collect_inlines_until(events, start + 1, |e| {
883                    matches!(e, Event::End(TagEnd::Emphasis))
884                });
885                (Some(Inline::Emphasis(children)), end - start + 1)
886            }
887            Tag::Strong => {
888                let (children, end) = collect_inlines_until(events, start + 1, |e| {
889                    matches!(e, Event::End(TagEnd::Strong))
890                });
891                (Some(Inline::Strong(children)), end - start + 1)
892            }
893            Tag::Link {
894                link_type,
895                dest_url,
896                title,
897                ..
898            } => {
899                let (children, end) = collect_inlines_until(events, start + 1, |e| {
900                    matches!(e, Event::End(TagEnd::Link))
901                });
902                let title_opt = if title.is_empty() {
903                    None
904                } else {
905                    Some(title.to_string())
906                };
907                // Phase 4 PR7a (2026-05-28): preserve pulldown-cmark's
908                // `LinkType::WikiLink` discriminator on the typed AST so
909                // the renderer can emit `class="wikilink"` and graph
910                // builders can identify wikilink targets.
911                let is_wikilink = matches!(*link_type, pulldown_cmark::LinkType::WikiLink { .. });
912                (
913                    Some(Inline::Link {
914                        url: Url::unresolved(dest_url.to_string()),
915                        title: title_opt,
916                        children,
917                        is_wikilink,
918                    }),
919                    end - start + 1,
920                )
921            }
922            Tag::Image {
923                link_type,
924                dest_url,
925                title,
926                ..
927            } => {
928                // Collect alt text from text events between Start/End.
929                let mut alt = String::new();
930                let mut i = start + 1;
931                while i < events.len() {
932                    match &events[i] {
933                        Event::End(TagEnd::Image) => break,
934                        Event::Text(t) => alt.push_str(t),
935                        Event::Code(c) => alt.push_str(c),
936                        _ => {}
937                    }
938                    i += 1;
939                }
940                // PR3.5 (2026-05-28): for wikilink images (`![[file]]` /
941                // `![[file|pothole]]`), pulldown-cmark synthesizes text
942                // events that aren't always author-intended alt:
943                //   - `![[logo.png]]` → text "logo.png" (synthesized from
944                //     dest); production treats as empty alt.
945                //   - `![[logo.png|contain center]]` → text "contain center"
946                //     (display-attrs); production classifies as styling,
947                //     NOT alt.
948                //   - `![[logo.png|width=400]]` → text "width=400" (typed
949                //     params); production classifies as params, NOT alt.
950                //   - `![[logo.png|My caption]]` → text "My caption";
951                //     genuine alt.
952                //
953                // Without this classification, PR3's Block::Figure
954                // detection (Wave 1) promotes wikilink-image paragraphs
955                // with synth-derived "alt" to Figure with bogus
956                // figcaptions ("logo.png", "contain center"). Match
957                // production's transform_events wikilink-dispatch by
958                // running the same classifiers (`is_all_display_keywords`
959                // + `parse_pothole_params`) here.
960                //
961                // PR7a-flip-core-B (2026-05-28): preserve the ORIGINAL
962                // pothole text on `Inline::Image.wikilink_pothole`
963                // BEFORE alt-classification consumes it.
964                // `dispatch_wikilink_embeds` needs the raw pothole to
965                // route `![[v.mp4|width=400]]` → typed video synth with
966                // the `width=400` param intact (alt-classification would
967                // erase it). The pothole is the substring after `|`;
968                // pulldown-cmark gives us the synthesized text, so we
969                // strip the dest synth case (text == dest_url ⇒ no
970                // pothole) and otherwise carry the trimmed alt.
971                let is_wikilink_image =
972                    matches!(link_type, pulldown_cmark::LinkType::WikiLink { .. });
973                let wikilink_pothole: Option<String> = if is_wikilink_image {
974                    let dest_str: &str = dest_url;
975                    let trimmed = alt.trim();
976                    if trimmed.is_empty() || trimmed == dest_str {
977                        None
978                    } else {
979                        Some(trimmed.to_string())
980                    }
981                } else {
982                    None
983                };
984                if is_wikilink_image {
985                    let dest_str: &str = dest_url;
986                    let trimmed = alt.trim().to_string();
987                    if trimmed.is_empty() || trimmed == dest_str {
988                        // Empty pothole OR pulldown-cmark synthesized
989                        // dest_url as text → no author alt.
990                        alt.clear();
991                    } else if crate::media::is_all_display_keywords(&trimmed) {
992                        // `contain center`, `left top`, etc. → display
993                        // attrs (production maps to style), not alt.
994                        alt.clear();
995                    } else {
996                        use crate::resolve::wikilink_dispatch::{
997                            parse_pothole_params, PotholeContent,
998                        };
999                        match parse_pothole_params(&trimmed) {
1000                            PotholeContent::Empty | PotholeContent::Params(_) => {
1001                                alt.clear();
1002                            }
1003                            PotholeContent::WidthToken { rest_alias, .. } => {
1004                                alt = rest_alias;
1005                            }
1006                            PotholeContent::Alias(text) => {
1007                                // `parse_pothole_params` classifies a content-relative
1008                                // percent (e.g. `55%`) as `Alias` because it is not a
1009                                // named width token. Intercept it here: a bare percent
1010                                // is NOT a caption — strip it from the alt so it does
1011                                // not leak to `<figcaption>`. The actual width is
1012                                // recovered from `wikilink_pothole` by
1013                                // `dispatch_wikilink_embeds` (with-graph path) or
1014                                // directly from `split_alt_width` in the parser's
1015                                // `try_promote_to_figure` (no-graph path via `alt`).
1016                                //
1017                                // `split_alt_width` returns the remaining caption and
1018                                // the width token. If the whole alias was a width
1019                                // (nothing remaining), clear alt.
1020                                let (remaining, _w) = crate::media::split_alt_width(&text);
1021                                alt = remaining;
1022                            }
1023                        }
1024                    }
1025                }
1026                let title_opt = if title.is_empty() {
1027                    None
1028                } else {
1029                    Some(title.to_string())
1030                };
1031                (
1032                    Some(Inline::Image {
1033                        src: Url::unresolved(dest_url.to_string()),
1034                        alt,
1035                        title: title_opt,
1036                        is_wikilink: is_wikilink_image,
1037                        wikilink_pothole,
1038                    }),
1039                    i - start + 1,
1040                )
1041            }
1042            // Unmodeled inline container: skip to its End.
1043            _ => (None, 1),
1044        },
1045        // End / unhandled — caller handles.
1046        _ => (None, 1),
1047    }
1048}
1049
1050/// Collect a contiguous run of block events into `Vec<Block>`. Stops when
1051/// `is_end(event)` returns true or events run out.
1052fn collect_blocks_until<F>(
1053    events: &[Event<'_>],
1054    start: usize,
1055    line_ctx: Option<&LineCtx<'_>>,
1056    is_end: F,
1057) -> (Vec<Block>, usize)
1058where
1059    F: Fn(&Event<'_>) -> bool,
1060{
1061    let mut out: Vec<Block> = Vec::new();
1062    let mut i = start;
1063    while i < events.len() {
1064        if is_end(&events[i]) {
1065            return (out, i);
1066        }
1067        let (block, advance) = parse_block(events, i, line_ctx);
1068        if let Some(b) = block {
1069            out.push(b);
1070        }
1071        i += advance.max(1);
1072    }
1073    (out, i)
1074}
1075
1076/// Collect the children of a `Tag::Item` until the matching `End(Item)`.
1077///
1078/// Pulldown-cmark's **tight-list** mode emits item contents as inline
1079/// events (Text/Code/SoftBreak/inline-tag Start...) DIRECTLY inside
1080/// `Tag::Item` without wrapping in `Tag::Paragraph`. The plain
1081/// [`collect_blocks_until`] dispatcher would route those events through
1082/// [`parse_block`], which drops stray inlines — yielding empty `<li></li>`.
1083///
1084/// This helper preserves both modes:
1085/// - Inline events accumulate into a synthesized [`Block::Paragraph`] that
1086///   is flushed when a block-level event (Tag::Paragraph, Tag::List,
1087///   nested Tag::Item, etc.) appears or at the end of the item.
1088/// - Block-level events are parsed via [`parse_block_with_tag`] (the
1089///   standard path).
1090///
1091/// The renderer recognises a single-paragraph item shape and emits
1092/// `<li>...inline...</li>` without an inner `<p>`, matching production's
1093/// tight-list output byte-for-byte.
1094fn collect_item_blocks(
1095    events: &[Event<'_>],
1096    start: usize,
1097    line_ctx: Option<&LineCtx<'_>>,
1098) -> (Vec<Block>, usize) {
1099    let mut out: Vec<Block> = Vec::new();
1100    let mut pending_inlines: Vec<Inline> = Vec::new();
1101    let mut i = start;
1102    while i < events.len() {
1103        if matches!(&events[i], Event::End(TagEnd::Item)) {
1104            flush_pending_paragraph(&mut out, &mut pending_inlines);
1105            return (out, i);
1106        }
1107        if let Some((inline, advance)) = parse_inline_event(events, i) {
1108            if let Some(node) = inline {
1109                pending_inlines.push(node);
1110            }
1111            i += advance.max(1);
1112            continue;
1113        }
1114        // Block-level event: flush any accumulated inlines, then parse
1115        // through the standard dispatcher.
1116        flush_pending_paragraph(&mut out, &mut pending_inlines);
1117        let (block, advance) = parse_block(events, i, line_ctx);
1118        if let Some(b) = block {
1119            out.push(b);
1120        }
1121        i += advance.max(1);
1122    }
1123    flush_pending_paragraph(&mut out, &mut pending_inlines);
1124    (out, i)
1125}
1126
1127/// Phase 4 PR4: detect a callout marker inside a blockquote and, if
1128/// found, assemble the entire `Block::Callout` (with body blocks).
1129///
1130/// `start` is the event index AFTER `Start(BlockQuote)`. Returns
1131/// `Some((Block::Callout, end_index))` where `end_index` is the event
1132/// index of the matching `End(TagEnd::BlockQuote(_))`, so the outer
1133/// caller can compute the advance. Returns `None` for plain
1134/// blockquotes (no `[!type]` marker on the first paragraph).
1135///
1136/// Detection rule (shape-spec § 1):
1137/// - The first event must be `Start(Tag::Paragraph)`.
1138/// - The leading `Event::Text` run (before the first `SoftBreak` or
1139///   any non-Text inline event) must match `[!<kind>]`, optionally
1140///   followed by `+` or `-` for foldable callouts, optionally followed
1141///   by space + inline title.
1142/// - The kind is canonicalized via [`CalloutKind::from_raw`]; unknown
1143///   kinds fall back to [`CalloutKind::Note`]. (Diagnostic threading
1144///   is a Phase 4 followup — `validation::Diagnostic` is scoped to
1145///   frontmatter validation today.)
1146///
1147/// Why detection runs on events (not parsed children): the inline
1148/// parser collapses `SoftBreak` events into `Inline::Text` (in PR4.5,
1149/// emitting `"\n"` to match pulldown-cmark's `push_html`), which makes
1150/// the marker-line vs body-line boundary an embedded `\n` rather than a
1151/// distinct AST node. Working at the event layer preserves the
1152/// SoftBreak boundary so we can split "title" (before SoftBreak) from
1153/// "body" (after SoftBreak) correctly.
1154fn detect_and_assemble_callout(
1155    events: &[Event<'_>],
1156    start: usize,
1157    line_ctx: Option<&LineCtx<'_>>,
1158) -> Option<(Block, usize)> {
1159    if !matches!(events.get(start), Some(Event::Start(Tag::Paragraph))) {
1160        return None;
1161    }
1162    // Coalesce the leading run of `Event::Text` into one logical
1163    // string. Stops at SoftBreak, HardBreak, any Start/End tag, or
1164    // any non-Text inline.
1165    let mut leading = String::new();
1166    let mut i = start + 1;
1167    while let Some(event) = events.get(i) {
1168        match event {
1169            Event::Text(t) => {
1170                leading.push_str(t);
1171                i += 1;
1172            }
1173            _ => break,
1174        }
1175    }
1176    if leading.is_empty() {
1177        return None;
1178    }
1179
1180    let (raw_kind, fold, title, _marker_byte_len) = parse_callout_marker(&leading)?;
1181    let kind = CalloutKind::from_raw(raw_kind).unwrap_or(CalloutKind::Note);
1182    let title: Option<String> = title.map(|s| s.to_string()).filter(|s| !s.is_empty());
1183
1184    // We've consumed the leading Text events. `i` now points at the
1185    // first non-Text event in the (still-open) marker paragraph.
1186    //
1187    // Three shapes from here:
1188    //   (A) SoftBreak / HardBreak → body lines continue in the same
1189    //       Paragraph. Skip the break, then collect inlines until
1190    //       End(Paragraph). Wrap them in a synthetic Block::Paragraph.
1191    //   (B) End(Paragraph) immediately → marker-only callout (no body
1192    //       in the marker paragraph). Skip End(Paragraph).
1193    //   (C) Another inline event (Start(Emphasis), Code, etc.) → the
1194    //       marker was actually followed by inline markup on the same
1195    //       line. Currently treated as title continuation — but we
1196    //       lack a clean event-level coalescer for inline tags, so we
1197    //       just collect remaining inlines and wrap them as a body
1198    //       paragraph. The author can use a separator paragraph for
1199    //       clarity if they want clean title isolation.
1200    let mut body_blocks: Vec<Block> = Vec::new();
1201    let body_paragraph_start: Option<usize> = match events.get(i) {
1202        Some(Event::SoftBreak) | Some(Event::HardBreak) => {
1203            // Skip the break; collect remaining inlines for the body
1204            // paragraph.
1205            Some(i + 1)
1206        }
1207        Some(Event::End(TagEnd::Paragraph)) => {
1208            // Marker was the entire paragraph. Skip past End.
1209            i += 1;
1210            None
1211        }
1212        _ => {
1213            // Other inline events directly following the marker —
1214            // collect them as body paragraph content. (Edge case;
1215            // see method comment.)
1216            Some(i)
1217        }
1218    };
1219
1220    if let Some(body_start) = body_paragraph_start {
1221        // Collect inlines until End(Paragraph) and synthesize a
1222        // Block::Paragraph for the marker-paragraph body content.
1223        let (body_inlines, after_para) = collect_inlines_until(events, body_start, |e| {
1224            matches!(e, Event::End(TagEnd::Paragraph))
1225        });
1226        // Skip past End(Paragraph) itself.
1227        i = after_para + 1;
1228        // Trim leading whitespace-only Text inlines (e.g. if the
1229        // line-break Text(" ") leaks through).
1230        let trimmed_empty = body_inlines.iter().all(|x| match x {
1231            Inline::Text(t) => t.trim().is_empty(),
1232            _ => false,
1233        });
1234        if !trimmed_empty {
1235            body_blocks.push(Block::Paragraph(body_inlines));
1236        }
1237    }
1238
1239    // Continue collecting subsequent blocks until End(BlockQuote).
1240    while let Some(event) = events.get(i) {
1241        if matches!(event, Event::End(TagEnd::BlockQuote(_))) {
1242            break;
1243        }
1244        let (block, advance) = parse_block(events, i, line_ctx);
1245        if let Some(b) = block {
1246            body_blocks.push(b);
1247        }
1248        i += advance.max(1);
1249    }
1250
1251    // `i` now points at `End(BlockQuote)`. Return total event
1252    // span: outer caller computes `i - start + 1` (where `start` here
1253    // is the pre-Start-BlockQuote index in the outer scope; but we
1254    // were called with `start = outer_start + 1`, so the outer
1255    // caller's `start` correctly indexes the opening `Start(BlockQuote)`).
1256    // Per the call shape in `parse_block_with_tag` Tag::BlockQuote arm:
1257    //   `match detect_and_assemble_callout(events, start + 1)`
1258    //   `Some((block, body_end)) => (Some(block), body_end - start + 1)`
1259    // we must return `body_end = i` (the `End(BlockQuote)` index).
1260    let block = Block::Callout {
1261        kind,
1262        fold,
1263        title,
1264        children: body_blocks,
1265    };
1266    Some((block, i))
1267}
1268
1269/// Parse the leading text of a callout-shaped paragraph.
1270///
1271/// Accepts text shaped like `[!kind] title text…`, `[!kind]+ title`,
1272/// `[!kind]-`, etc. Returns:
1273/// - `raw_kind` — the kind identifier verbatim (lowercased on
1274///   canonicalization, not here).
1275/// - `fold` — `Some(Fold::Open)` for `+`, `Some(Fold::Closed)` for `-`,
1276///   `None` otherwise.
1277/// - `title` — `Some(title_text)` when text follows the marker (space
1278///   separator consumed); `None` when the marker is the entire string.
1279///   Title may be empty (`""`) if author wrote `[!note] ` with trailing
1280///   whitespace only — caller treats empty as None.
1281/// - `marker_byte_len` — number of bytes from the start of `text` that
1282///   constituted the marker + the single separator space (if any). The
1283///   caller slices `&text[marker_byte_len..]` to recover trailing body
1284///   text that should stay in the paragraph (multi-line callouts where
1285///   pulldown-cmark concatenated lines).
1286fn parse_callout_marker(text: &str) -> Option<(&str, Option<Fold>, Option<&str>, usize)> {
1287    let after_open = text.strip_prefix("[!")?;
1288    let close_offset = after_open.find(']')?;
1289    let raw_kind = &after_open[..close_offset];
1290    if raw_kind.is_empty() || raw_kind.chars().any(|c| c.is_whitespace()) {
1291        return None;
1292    }
1293    // Offset within `text` immediately after the `]`.
1294    let after_bracket_offset = 2 + close_offset + 1;
1295    let rest = &text[after_bracket_offset..];
1296
1297    let (fold, after_fold_offset) = match rest.chars().next() {
1298        Some('+') => (Some(Fold::Open), after_bracket_offset + 1),
1299        Some('-') => (Some(Fold::Closed), after_bracket_offset + 1),
1300        _ => (None, after_bracket_offset),
1301    };
1302
1303    let rest_after_fold = &text[after_fold_offset..];
1304    let (title, marker_byte_len) = if rest_after_fold.is_empty() {
1305        // Marker only, no title segment.
1306        (None, after_fold_offset)
1307    } else if let Some(remainder) = rest_after_fold.strip_prefix(' ') {
1308        // ` title text…` — title is everything in this coalesced
1309        // leading-text string. Pulldown-cmark splits line breaks into
1310        // SoftBreak inlines, so this Text inline never contains
1311        // newlines; the title is bounded by the next non-Text inline.
1312        let title_str = remainder;
1313        let consumed = after_fold_offset + 1 + remainder.len();
1314        (Some(title_str), consumed)
1315    } else {
1316        // No separator after marker but more text follows (e.g.
1317        // `[!note]+body` with no space). Treat as no title; keep the
1318        // text intact.
1319        (None, after_fold_offset)
1320    };
1321
1322    Some((raw_kind, fold, title, marker_byte_len))
1323}
1324
1325/// If `events[i]` is an inline-level event, parse it via the existing
1326/// [`parse_inline`] machinery and return `(inline, advance)`. Returns
1327/// `None` for block-level events, end tags, or anything the inline
1328/// dispatcher doesn't own — letting the caller fall back to the block
1329/// path.
1330fn parse_inline_event(events: &[Event<'_>], i: usize) -> Option<(Option<Inline>, usize)> {
1331    match &events[i] {
1332        Event::Text(_)
1333        | Event::Code(_)
1334        | Event::Html(_)
1335        | Event::InlineHtml(_)
1336        | Event::SoftBreak
1337        | Event::HardBreak => Some(parse_inline(events, i)),
1338        Event::Start(tag) => match tag {
1339            Tag::Emphasis | Tag::Strong | Tag::Link { .. } | Tag::Image { .. } => {
1340                Some(parse_inline(events, i))
1341            }
1342            _ => None,
1343        },
1344        _ => None,
1345    }
1346}
1347
1348/// Drain `pending_inlines` into a [`Block::Paragraph`] appended to `out`,
1349/// unless it's empty. No-op when there are no pending inlines.
1350fn flush_pending_paragraph(out: &mut Vec<Block>, pending_inlines: &mut Vec<Inline>) {
1351    if !pending_inlines.is_empty() {
1352        out.push(Block::Paragraph(std::mem::take(pending_inlines)));
1353    }
1354}
1355
1356/// Collect the text content of a heading by walking events between
1357/// `start..end` (exclusive of the matching `Event::End(TagEnd::Heading)`)
1358/// and concatenating every `Event::Text` and `Event::Code` payload.
1359///
1360/// Mirrors production's `transform_events` heading-text collection at
1361/// `src-tauri/src/build/markdown/pipeline.rs:1784-1795`. Inline HTML
1362/// (`Event::InlineHtml` / `Event::Html`) is intentionally skipped so that
1363/// e.g. `# FAREWELL,<br>AND ERASE` yields the slug for
1364/// `FAREWELL,AND ERASE` (no `<br>` in the slug). Soft/hard breaks are
1365/// skipped — production only captures Text + Code. Image alt text and
1366/// link href text are NOT included; the events inside `Tag::Link` /
1367/// `Tag::Image` are walked transparently and their `Event::Text`
1368/// payloads (the link/image label) ARE captured, matching production.
1369fn collect_heading_text(events: &[Event<'_>], start: usize, end: usize) -> String {
1370    let mut text = String::new();
1371    for i in start..end {
1372        match &events[i] {
1373            Event::Text(t) => text.push_str(t),
1374            Event::Code(c) => text.push_str(c),
1375            _ => {}
1376        }
1377    }
1378    text
1379}
1380
1381/// Post-parse pass: walk every heading in document order (recursively
1382/// descending into BlockQuote, List items, and Callout children) and
1383/// disambiguate duplicate IDs by appending `-1`, `-2`, … to the slug.
1384///
1385/// Mirrors the `id_counts: HashMap<String, usize>` behavior at
1386/// `src-tauri/src/build/markdown/pipeline.rs:1798-1805`:
1387///
1388/// - First occurrence of slug `foo` keeps id `foo`; counter starts at 1.
1389/// - Second occurrence becomes `foo-1`; counter becomes 2.
1390/// - Third occurrence becomes `foo-2`; counter becomes 3.
1391///
1392/// Headings whose base slug is `None` (shouldn't happen post-PR2, but
1393/// safe-guarded) are left untouched.
1394fn assign_heading_id_suffixes(blocks: &mut [Block]) {
1395    let mut id_counts: HashMap<String, usize> = HashMap::new();
1396    assign_heading_id_suffixes_walk(blocks, &mut id_counts);
1397}
1398
1399fn assign_heading_id_suffixes_walk(blocks: &mut [Block], id_counts: &mut HashMap<String, usize>) {
1400    for block in blocks.iter_mut() {
1401        match block {
1402            Block::Heading { id, .. } => {
1403                if let Some(slug) = id {
1404                    let count_entry = id_counts.entry(slug.clone()).or_insert(0);
1405                    let count = *count_entry;
1406                    if count > 0 {
1407                        *id = Some(format!("{}-{}", slug, count));
1408                    }
1409                    *count_entry = count + 1;
1410                }
1411            }
1412            Block::BlockQuote(children) | Block::Callout { children, .. } => {
1413                assign_heading_id_suffixes_walk(children, id_counts);
1414            }
1415            Block::List { items, .. } => {
1416                for item in items.iter_mut() {
1417                    assign_heading_id_suffixes_walk(item, id_counts);
1418                }
1419            }
1420            // Tables/CodeBlocks/Shortcodes/Paragraphs/ThematicBreak/Other
1421            // cannot contain block-level headings — nothing to descend
1422            // into. Shortcode bodies (Hero overlay, Grid cells) currently
1423            // carry their content as String (pre-PR4.5); once promoted to
1424            // Vec<Block>, this walker will need to descend there too.
1425            _ => {}
1426        }
1427    }
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432    use super::super::node::{CalloutKind, Fold, Inline};
1433    use super::*;
1434
1435    fn first_block(md: &str) -> Block {
1436        parse(md)
1437            .blocks
1438            .into_iter()
1439            .next()
1440            .expect("at least one block")
1441    }
1442
1443    // -----------------------------------------------------------------
1444    // Phase 4 PR4: Block::Callout migration + Obsidian alias canonicalization
1445    // -----------------------------------------------------------------
1446
1447    #[test]
1448    fn parses_basic_callout_with_inline_title() {
1449        match first_block("> [!note] Heads up\n> Body line 1.\n") {
1450            Block::Callout {
1451                kind,
1452                fold,
1453                title,
1454                children,
1455            } => {
1456                assert_eq!(kind, CalloutKind::Note);
1457                assert!(fold.is_none(), "non-foldable callout");
1458                assert_eq!(title.as_deref(), Some("Heads up"));
1459                assert!(!children.is_empty(), "body should remain");
1460            }
1461            other => panic!("expected Callout, got {other:?}"),
1462        }
1463    }
1464
1465    #[test]
1466    fn parses_titleless_callout() {
1467        match first_block("> [!warning]\n> Watch out.\n") {
1468            Block::Callout {
1469                kind,
1470                fold,
1471                title,
1472                children,
1473            } => {
1474                assert_eq!(kind, CalloutKind::Warning);
1475                assert!(fold.is_none());
1476                assert!(title.is_none(), "no inline title");
1477                assert!(!children.is_empty());
1478            }
1479            other => panic!("expected Callout, got {other:?}"),
1480        }
1481    }
1482
1483    #[test]
1484    fn callout_alias_tldr_canonicalizes_to_abstract() {
1485        match first_block("> [!tldr] Short summary\n> body\n") {
1486            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Abstract),
1487            other => panic!("expected Callout, got {other:?}"),
1488        }
1489    }
1490
1491    #[test]
1492    fn callout_alias_hint_canonicalizes_to_tip() {
1493        match first_block("> [!hint] Pro tip\n> body\n") {
1494            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Tip),
1495            other => panic!("expected Callout, got {other:?}"),
1496        }
1497    }
1498
1499    #[test]
1500    fn callout_alias_important_canonicalizes_to_tip() {
1501        match first_block("> [!important] Read this\n> body\n") {
1502            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Tip),
1503            other => panic!("expected Callout, got {other:?}"),
1504        }
1505    }
1506
1507    #[test]
1508    fn callout_alias_check_done_canonicalizes_to_success() {
1509        for alias in &["check", "done"] {
1510            let md = format!("> [!{alias}] Yes\n> body\n");
1511            match first_block(&md) {
1512                Block::Callout { kind, .. } => assert_eq!(
1513                    kind,
1514                    CalloutKind::Success,
1515                    "alias `{alias}` should canonicalize to Success"
1516                ),
1517                other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
1518            }
1519        }
1520    }
1521
1522    #[test]
1523    fn callout_alias_help_faq_canonicalizes_to_question() {
1524        for alias in &["help", "faq"] {
1525            let md = format!("> [!{alias}] question\n> body\n");
1526            match first_block(&md) {
1527                Block::Callout { kind, .. } => assert_eq!(
1528                    kind,
1529                    CalloutKind::Question,
1530                    "alias `{alias}` should canonicalize to Question"
1531                ),
1532                other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
1533            }
1534        }
1535    }
1536
1537    #[test]
1538    fn callout_alias_caution_attention_canonicalizes_to_warning() {
1539        for alias in &["caution", "attention"] {
1540            let md = format!("> [!{alias}] careful\n> body\n");
1541            match first_block(&md) {
1542                Block::Callout { kind, .. } => assert_eq!(
1543                    kind,
1544                    CalloutKind::Warning,
1545                    "alias `{alias}` should canonicalize to Warning"
1546                ),
1547                other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
1548            }
1549        }
1550    }
1551
1552    #[test]
1553    fn callout_alias_fail_missing_canonicalizes_to_failure() {
1554        for alias in &["fail", "missing"] {
1555            let md = format!("> [!{alias}] oops\n> body\n");
1556            match first_block(&md) {
1557                Block::Callout { kind, .. } => assert_eq!(
1558                    kind,
1559                    CalloutKind::Failure,
1560                    "alias `{alias}` should canonicalize to Failure"
1561                ),
1562                other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
1563            }
1564        }
1565    }
1566
1567    #[test]
1568    fn callout_alias_error_canonicalizes_to_danger() {
1569        match first_block("> [!error] bad\n> body\n") {
1570            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Danger),
1571            other => panic!("expected Callout, got {other:?}"),
1572        }
1573    }
1574
1575    #[test]
1576    fn callout_alias_cite_canonicalizes_to_quote() {
1577        match first_block("> [!cite] source\n> body\n") {
1578            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Quote),
1579            other => panic!("expected Callout, got {other:?}"),
1580        }
1581    }
1582
1583    #[test]
1584    fn callout_foldable_open_suffix() {
1585        match first_block("> [!note]+ Open by default\n> body\n") {
1586            Block::Callout {
1587                kind, fold, title, ..
1588            } => {
1589                assert_eq!(kind, CalloutKind::Note);
1590                assert_eq!(fold, Some(Fold::Open));
1591                assert_eq!(title.as_deref(), Some("Open by default"));
1592            }
1593            other => panic!("expected Callout, got {other:?}"),
1594        }
1595    }
1596
1597    #[test]
1598    fn callout_foldable_closed_suffix() {
1599        match first_block("> [!note]- Closed by default\n> body\n") {
1600            Block::Callout {
1601                kind, fold, title, ..
1602            } => {
1603                assert_eq!(kind, CalloutKind::Note);
1604                assert_eq!(fold, Some(Fold::Closed));
1605                assert_eq!(title.as_deref(), Some("Closed by default"));
1606            }
1607            other => panic!("expected Callout, got {other:?}"),
1608        }
1609    }
1610
1611    #[test]
1612    fn callout_foldable_without_title() {
1613        match first_block("> [!tip]+\n> body\n") {
1614            Block::Callout {
1615                kind, fold, title, ..
1616            } => {
1617                assert_eq!(kind, CalloutKind::Tip);
1618                assert_eq!(fold, Some(Fold::Open));
1619                assert!(title.is_none());
1620            }
1621            other => panic!("expected Callout, got {other:?}"),
1622        }
1623    }
1624
1625    #[test]
1626    fn callout_unknown_kind_falls_back_to_note() {
1627        // Per shape-spec § 1 — unknown kind canonicalizes to Note.
1628        // Diagnostic emission is a Phase 4 followup (see parser.rs
1629        // `promote_callout` comment).
1630        match first_block("> [!unknownkind] body\n") {
1631            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Note),
1632            other => panic!("expected Callout (fallback to Note), got {other:?}"),
1633        }
1634    }
1635
1636    #[test]
1637    fn callout_multi_paragraph_body_preserves_blocks() {
1638        let md = "> [!info] Multi\n> First paragraph.\n>\n> Second paragraph.\n";
1639        match first_block(md) {
1640            Block::Callout {
1641                kind,
1642                title,
1643                children,
1644                ..
1645            } => {
1646                assert_eq!(kind, CalloutKind::Info);
1647                assert_eq!(title.as_deref(), Some("Multi"));
1648                // pulldown-cmark emits two paragraphs in the blockquote
1649                // body when separated by an empty `>` line.
1650                let para_count = children
1651                    .iter()
1652                    .filter(|b| matches!(b, Block::Paragraph(_)))
1653                    .count();
1654                assert!(
1655                    para_count >= 2,
1656                    "expected at least 2 paragraphs, got {children:?}"
1657                );
1658            }
1659            other => panic!("expected Callout, got {other:?}"),
1660        }
1661    }
1662
1663    #[test]
1664    fn callout_nested_inside_callout() {
1665        // The docs promise nested callouts. After PR4 the outer is
1666        // Block::Callout containing an inner Block::Callout in its
1667        // children (no Stage 1 rewrite needed).
1668        let md = "> [!warning] Outer\n> Outer content.\n>\n> > [!tip] Inner\n> > Inner content.\n";
1669        match first_block(md) {
1670            Block::Callout {
1671                kind: outer_kind,
1672                children,
1673                ..
1674            } => {
1675                assert_eq!(outer_kind, CalloutKind::Warning);
1676                let inner = children.iter().find_map(|b| match b {
1677                    Block::Callout { kind, title, .. } => Some((*kind, title.clone())),
1678                    _ => None,
1679                });
1680                let (inner_kind, inner_title) =
1681                    inner.expect("inner Block::Callout missing from outer's children");
1682                assert_eq!(inner_kind, CalloutKind::Tip);
1683                assert_eq!(inner_title.as_deref(), Some("Inner"));
1684            }
1685            other => panic!("expected outer Callout, got {other:?}"),
1686        }
1687    }
1688
1689    #[test]
1690    fn plain_blockquote_without_marker_stays_blockquote() {
1691        // Regression: an ordinary blockquote (no `[!type]` marker) must
1692        // remain Block::BlockQuote — only callout-shaped blockquotes
1693        // promote.
1694        match first_block("> Just a quote.\n> More of the quote.\n") {
1695            Block::BlockQuote(_) => {} // expected
1696            other => panic!("expected BlockQuote, got {other:?}"),
1697        }
1698    }
1699
1700    #[test]
1701    fn blockquote_with_text_starting_like_callout_but_unknown_kind_still_promotes() {
1702        // The marker `[!xyz]` is structurally a callout — we promote
1703        // and fall back to Note (per shape-spec). The author can fix
1704        // by removing the bracket prefix if they wanted a plain quote.
1705        match first_block("> [!xyz] not a real kind\n> body\n") {
1706            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Note),
1707            other => panic!("expected Callout fallback, got {other:?}"),
1708        }
1709    }
1710
1711    #[test]
1712    fn callout_case_insensitive_kind() {
1713        // Stage 1 was case-insensitive; preserve that contract.
1714        match first_block("> [!WARNING] Loud\n> body\n") {
1715            Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Warning),
1716            other => panic!("expected Callout, got {other:?}"),
1717        }
1718    }
1719
1720    #[test]
1721    fn callout_pending_alias_canonicalizes_to_todo() {
1722        // SoCiviC Theatre's voices.md uses `> [!pending]` — carried
1723        // over from Stage 1 support.
1724        match first_block("> [!pending] Trailer video\n> Add when ready.\n") {
1725            Block::Callout { kind, title, .. } => {
1726                assert_eq!(kind, CalloutKind::Todo);
1727                assert_eq!(title.as_deref(), Some("Trailer video"));
1728            }
1729            other => panic!("expected Callout, got {other:?}"),
1730        }
1731    }
1732
1733    #[test]
1734    fn empty_input_yields_empty_document() {
1735        let d = parse("");
1736        assert!(d.blocks.is_empty());
1737    }
1738
1739    #[test]
1740    fn parses_h1_heading() {
1741        match first_block("# Hello\n") {
1742            Block::Heading {
1743                level,
1744                children,
1745                id,
1746            } => {
1747                assert_eq!(level, 1);
1748                // Phase 4 PR2: parser populates id with the Obsidian anchor slug.
1749                assert_eq!(id.as_deref(), Some("hello"));
1750                assert!(matches!(&children[0], Inline::Text(t) if t == "Hello"));
1751            }
1752            other => panic!("expected Heading, got {other:?}"),
1753        }
1754    }
1755
1756    #[test]
1757    fn parses_h6_heading() {
1758        match first_block("###### tiny\n") {
1759            Block::Heading { level, .. } => assert_eq!(level, 6),
1760            other => panic!("expected Heading, got {other:?}"),
1761        }
1762    }
1763
1764    #[test]
1765    fn parses_paragraph_with_text() {
1766        match first_block("hello world\n") {
1767            Block::Paragraph(children) => {
1768                // pulldown-cmark may split into multiple Text events; merge.
1769                let s: String = children
1770                    .iter()
1771                    .filter_map(|i| match i {
1772                        Inline::Text(t) => Some(t.as_str()),
1773                        _ => None,
1774                    })
1775                    .collect();
1776                assert_eq!(s, "hello world");
1777            }
1778            other => panic!("expected Paragraph, got {other:?}"),
1779        }
1780    }
1781
1782    #[test]
1783    fn parses_link_with_unresolved_url() {
1784        // Critical contract: every URL starts as Unresolved.
1785        match first_block("[Docs](docs/)\n") {
1786            Block::Paragraph(children) => match &children[0] {
1787                Inline::Link {
1788                    url,
1789                    title,
1790                    children,
1791                    is_wikilink,
1792                } => {
1793                    assert!(url.is_unresolved());
1794                    match url {
1795                        Url::Unresolved(s) => assert_eq!(s, "docs/"),
1796                        _ => unreachable!(),
1797                    }
1798                    assert!(title.is_none());
1799                    assert!(!is_wikilink, "standard markdown link is not a wikilink");
1800                    assert!(matches!(&children[0], Inline::Text(t) if t == "Docs"));
1801                }
1802                other => panic!("expected Link, got {other:?}"),
1803            },
1804            other => panic!("expected Paragraph, got {other:?}"),
1805        }
1806    }
1807
1808    #[test]
1809    fn parses_link_with_moss_resolved_prefix_unchanged() {
1810        // The upstream resolve pipeline emits this shape; the parser must
1811        // preserve it verbatim for the visitor to classify later.
1812        match first_block("[t](moss-resolved:foo.md)\n") {
1813            Block::Paragraph(children) => match &children[0] {
1814                Inline::Link {
1815                    url: Url::Unresolved(s),
1816                    ..
1817                } => assert_eq!(s, "moss-resolved:foo.md"),
1818                other => panic!("expected unresolved Link, got {other:?}"),
1819            },
1820            other => panic!("expected Paragraph, got {other:?}"),
1821        }
1822    }
1823
1824    #[test]
1825    fn parser_link_inherits_wikilink_from_pulldown_cmark() {
1826        // PR7a Decision 2: pulldown-cmark with ENABLE_WIKILINKS emits
1827        // `Tag::Link { link_type: LinkType::WikiLink, .. }` for `[[target]]`
1828        // syntax. The typed AST must preserve that discriminator via
1829        // `Inline::Link::is_wikilink`. After PR7a flips render_document
1830        // to production, this flag drives the `class="wikilink"` emission
1831        // on the <a> tag.
1832        match first_block("[[wikilink-target]]\n") {
1833            Block::Paragraph(children) => {
1834                let link = children
1835                    .iter()
1836                    .find(|i| matches!(i, Inline::Link { .. }))
1837                    .expect("expected an Inline::Link from [[…]]");
1838                match link {
1839                    Inline::Link { is_wikilink, .. } => {
1840                        assert!(
1841                            *is_wikilink,
1842                            "[[…]] must set is_wikilink: true on the typed AST"
1843                        );
1844                    }
1845                    _ => unreachable!(),
1846                }
1847            }
1848            other => panic!("expected Paragraph, got {other:?}"),
1849        }
1850
1851        // Negative case: a standard markdown link is NOT a wikilink.
1852        match first_block("[text](href)\n") {
1853            Block::Paragraph(children) => match &children[0] {
1854                Inline::Link { is_wikilink, .. } => {
1855                    assert!(!is_wikilink, "[](…) must set is_wikilink: false");
1856                }
1857                _ => panic!("expected Link"),
1858            },
1859            _ => panic!("expected Paragraph"),
1860        }
1861    }
1862
1863    #[test]
1864    fn parses_link_with_title() {
1865        match first_block(r#"[t](u "the title")"#) {
1866            Block::Paragraph(children) => match &children[0] {
1867                Inline::Link { title, .. } => assert_eq!(title.as_deref(), Some("the title")),
1868                other => panic!("expected Link, got {other:?}"),
1869            },
1870            other => panic!("expected Paragraph, got {other:?}"),
1871        }
1872    }
1873
1874    #[test]
1875    fn parses_image_with_alt() {
1876        // Phase 4 PR3 (2026-05-27): an image-only paragraph is now
1877        // promoted to Block::Figure. Inline::Image lives inside the
1878        // Figure variant; the URL/alt/title contract is unchanged.
1879        // For image+text (where Block::Paragraph still applies), see
1880        // `image_with_caption_text_does_not_promote` below.
1881        match first_block("![cat photo](cat.jpg)\n") {
1882            Block::Figure { image, caption, .. } => {
1883                match image {
1884                    Inline::Image {
1885                        src, alt, title, ..
1886                    } => {
1887                        assert!(src.is_unresolved());
1888                        assert_eq!(alt, "cat photo");
1889                        assert!(title.is_none());
1890                    }
1891                    other => panic!("expected Image inside Figure, got {other:?}"),
1892                }
1893                let cap = caption.expect("caption from alt text");
1894                assert_eq!(cap.len(), 1);
1895            }
1896            other => panic!("expected Figure, got {other:?}"),
1897        }
1898    }
1899
1900    #[test]
1901    fn parses_image_inside_paragraph_with_text() {
1902        // Companion to `parses_image_with_alt`: an image with sibling
1903        // prose stays as Block::Paragraph (no figure promotion). Holds
1904        // the parser's image-extraction contract for the non-figure case.
1905        match first_block("see ![cat photo](cat.jpg) here\n") {
1906            Block::Paragraph(children) => {
1907                let img = children
1908                    .iter()
1909                    .find(|i| matches!(i, Inline::Image { .. }))
1910                    .expect("expected Inline::Image among siblings");
1911                match img {
1912                    Inline::Image { src, alt, .. } => {
1913                        assert!(src.is_unresolved());
1914                        assert_eq!(alt, "cat photo");
1915                    }
1916                    _ => unreachable!(),
1917                }
1918            }
1919            other => panic!("expected Paragraph, got {other:?}"),
1920        }
1921    }
1922
1923    #[test]
1924    fn parses_emphasis_and_strong() {
1925        let para = parse("*em* and **strong**\n")
1926            .blocks
1927            .into_iter()
1928            .next()
1929            .unwrap();
1930        match para {
1931            Block::Paragraph(children) => {
1932                let has_em = children.iter().any(|i| matches!(i, Inline::Emphasis(_)));
1933                let has_strong = children.iter().any(|i| matches!(i, Inline::Strong(_)));
1934                assert!(has_em, "missing Emphasis: {children:?}");
1935                assert!(has_strong, "missing Strong: {children:?}");
1936            }
1937            _ => panic!("expected Paragraph"),
1938        }
1939    }
1940
1941    #[test]
1942    fn parses_inline_code() {
1943        match first_block("`some code`\n") {
1944            Block::Paragraph(children) => {
1945                assert!(matches!(&children[0], Inline::Code(c) if c == "some code"));
1946            }
1947            other => panic!("expected Paragraph, got {other:?}"),
1948        }
1949    }
1950
1951    #[test]
1952    fn parses_unordered_list() {
1953        match first_block("- one\n- two\n") {
1954            Block::List { ordered, items, .. } => {
1955                assert!(!ordered);
1956                assert_eq!(items.len(), 2);
1957            }
1958            other => panic!("expected List, got {other:?}"),
1959        }
1960    }
1961
1962    #[test]
1963    fn parser_handles_tight_list_items_with_inline_content() {
1964        // Phase 4 PR0.6 regression — pulldown-cmark's tight-list mode emits
1965        // inline events (Text/Strong/etc.) directly inside Tag::Item without
1966        // wrapping in Tag::Paragraph. Previously `parse_block` dropped these
1967        // stray inlines, producing empty <li></li> instead of the expected
1968        // <li><strong>bold</strong> text</li>.
1969        match first_block("- **bold** text\n- another item\n") {
1970            Block::List { ordered, items, .. } => {
1971                assert!(!ordered);
1972                assert_eq!(items.len(), 2, "expected two items, got {items:?}");
1973                let first_item = &items[0];
1974                assert_eq!(
1975                    first_item.len(),
1976                    1,
1977                    "tight item should synthesize a single Paragraph, got {first_item:?}"
1978                );
1979                match &first_item[0] {
1980                    Block::Paragraph(inlines) => {
1981                        let has_strong = inlines.iter().any(|i| matches!(i, Inline::Strong(_)));
1982                        let has_text = inlines
1983                            .iter()
1984                            .any(|i| matches!(i, Inline::Text(t) if t.contains("text")));
1985                        assert!(
1986                            has_strong,
1987                            "expected Inline::Strong inside item, got {inlines:?}"
1988                        );
1989                        assert!(has_text, "expected ' text' Inline::Text, got {inlines:?}");
1990                    }
1991                    other => panic!("expected Paragraph inside tight item, got {other:?}"),
1992                }
1993            }
1994            other => panic!("expected List, got {other:?}"),
1995        }
1996    }
1997
1998    #[test]
1999    fn tight_list_items_with_links_preserved() {
2000        // Mirrors folder-note-site/obsidian/index.md — wikilinks + images
2001        // inside list items. Today these parse as Inline::Link / Inline::Image;
2002        // the contract is just that the inline content is NOT dropped.
2003        match first_block("- [link](url)\n- ![alt](img.jpg)\n") {
2004            Block::List { items, .. } => {
2005                assert_eq!(items.len(), 2);
2006                let first = &items[0];
2007                assert_eq!(
2008                    first.len(),
2009                    1,
2010                    "expected one Block::Paragraph, got {first:?}"
2011                );
2012                match &first[0] {
2013                    Block::Paragraph(inlines) => {
2014                        assert!(
2015                            inlines.iter().any(|i| matches!(i, Inline::Link { .. })),
2016                            "expected Inline::Link, got {inlines:?}"
2017                        );
2018                    }
2019                    other => panic!("expected Paragraph, got {other:?}"),
2020                }
2021                let second = &items[1];
2022                match &second[0] {
2023                    Block::Paragraph(inlines) => {
2024                        assert!(
2025                            inlines.iter().any(|i| matches!(i, Inline::Image { .. })),
2026                            "expected Inline::Image, got {inlines:?}"
2027                        );
2028                    }
2029                    other => panic!("expected Paragraph, got {other:?}"),
2030                }
2031            }
2032            other => panic!("expected List, got {other:?}"),
2033        }
2034    }
2035
2036    #[test]
2037    fn loose_list_items_with_paragraphs_still_work() {
2038        // Loose-list mode (blank lines between items) emits items as
2039        // Tag::Paragraph-wrapped blocks. The fix must not break this path.
2040        let md = "- first item\n\n- second item\n";
2041        match first_block(md) {
2042            Block::List { items, .. } => {
2043                assert_eq!(items.len(), 2);
2044                for item in &items {
2045                    assert_eq!(item.len(), 1, "expected one block per item");
2046                    assert!(
2047                        matches!(&item[0], Block::Paragraph(_)),
2048                        "expected Paragraph, got {:?}",
2049                        item[0]
2050                    );
2051                }
2052            }
2053            other => panic!("expected List, got {other:?}"),
2054        }
2055    }
2056
2057    #[test]
2058    fn tight_list_items_with_nested_list_preserve_structure() {
2059        // - first
2060        //   - nested
2061        // The outer item carries inline "first" + a nested Block::List.
2062        let md = "- first\n  - nested\n";
2063        match first_block(md) {
2064            Block::List { items, .. } => {
2065                assert_eq!(items.len(), 1);
2066                let outer = &items[0];
2067                assert!(
2068                    outer.iter().any(|b| matches!(b, Block::Paragraph(_))),
2069                    "expected outer item to carry a Paragraph for 'first', got {outer:?}"
2070                );
2071                assert!(
2072                    outer.iter().any(|b| matches!(b, Block::List { .. })),
2073                    "expected outer item to carry a nested List, got {outer:?}"
2074                );
2075            }
2076            other => panic!("expected List, got {other:?}"),
2077        }
2078    }
2079
2080    #[test]
2081    fn parses_ordered_list() {
2082        match first_block("1. first\n2. second\n") {
2083            Block::List { ordered, items, .. } => {
2084                assert!(ordered);
2085                assert_eq!(items.len(), 2);
2086            }
2087            other => panic!("expected List, got {other:?}"),
2088        }
2089    }
2090
2091    #[test]
2092    fn parses_fenced_code_block_with_lang() {
2093        match first_block("```rust\nfn main() {}\n```\n") {
2094            Block::CodeBlock { lang, value } => {
2095                assert_eq!(lang.as_deref(), Some("rust"));
2096                assert!(value.contains("fn main"));
2097            }
2098            other => panic!("expected CodeBlock, got {other:?}"),
2099        }
2100    }
2101
2102    #[test]
2103    fn parses_fenced_code_block_without_lang() {
2104        match first_block("```\nbare\n```\n") {
2105            Block::CodeBlock { lang, value } => {
2106                assert!(lang.is_none());
2107                assert!(value.contains("bare"));
2108            }
2109            other => panic!("expected CodeBlock, got {other:?}"),
2110        }
2111    }
2112
2113    #[test]
2114    fn code_block_is_not_parsed_as_shortcode() {
2115        // Adversarial: the literal `:::buttons` inside a fenced code block
2116        // must NOT be treated as a shortcode. (Phase A's parser doesn't
2117        // recognize :::buttons at all yet; this test locks the contract.)
2118        let md = "```\n:::buttons\n[t](u)\n:::\n```\n";
2119        match first_block(md) {
2120            Block::CodeBlock { value, .. } => assert!(value.contains(":::buttons")),
2121            other => panic!("expected CodeBlock, got {other:?}"),
2122        }
2123    }
2124
2125    #[test]
2126    fn parses_blockquote() {
2127        match first_block("> quoted\n") {
2128            Block::BlockQuote(children) => {
2129                assert!(!children.is_empty());
2130            }
2131            other => panic!("expected BlockQuote, got {other:?}"),
2132        }
2133    }
2134
2135    #[test]
2136    fn parses_thematic_break() {
2137        match first_block("---\n") {
2138            Block::ThematicBreak => {}
2139            // Pulldown-cmark may emit a thematic break or treat `---` at the
2140            // start of a doc as a heading underline. Accept either by
2141            // checking that the parse produces SOMETHING.
2142            _other => {
2143                // Test the unambiguous mid-doc case.
2144                let d = parse("para\n\n---\n\nmore\n");
2145                let has_break = d.blocks.iter().any(|b| matches!(b, Block::ThematicBreak));
2146                assert!(
2147                    has_break,
2148                    "expected at least one ThematicBreak: {:?}",
2149                    d.blocks
2150                );
2151            }
2152        }
2153    }
2154
2155    #[test]
2156    fn parses_table() {
2157        let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n| c | d |\n";
2158        match first_block(md) {
2159            Block::Table { header, rows, .. } => {
2160                assert_eq!(header.len(), 2);
2161                assert_eq!(rows.len(), 2);
2162                assert_eq!(rows[0].len(), 2);
2163            }
2164            other => panic!("expected Table, got {other:?}"),
2165        }
2166    }
2167
2168    #[test]
2169    fn html_block_passes_through_as_other() {
2170        match first_block("<div class=\"raw\">hi</div>\n\n") {
2171            Block::Other(html) => assert!(html.contains("<div")),
2172            other => panic!("expected Other, got {other:?}"),
2173        }
2174    }
2175
2176    #[test]
2177    fn parses_multiple_blocks() {
2178        let d = parse("# T\n\npara\n\n- li\n");
2179        assert_eq!(d.blocks.len(), 3);
2180        assert!(matches!(d.blocks[0], Block::Heading { .. }));
2181        assert!(matches!(d.blocks[1], Block::Paragraph(_)));
2182        assert!(matches!(d.blocks[2], Block::List { .. }));
2183    }
2184
2185    #[test]
2186    fn frontmatter_only_input_is_handled() {
2187        // Frontmatter is stripped by upstream code before reaching the
2188        // parser. If somehow a `---\nfoo:bar\n---` reaches us, the parser
2189        // must not panic.
2190        let _ = parse("---\nfoo: bar\n---\n");
2191    }
2192
2193    #[test]
2194    fn link_inside_heading_is_preserved() {
2195        match first_block("# [t](u)\n") {
2196            Block::Heading { children, .. } => {
2197                assert!(matches!(&children[0], Inline::Link { .. }));
2198            }
2199            other => panic!("expected Heading, got {other:?}"),
2200        }
2201    }
2202
2203    // -----------------------------------------------------------------
2204    // Phase 4 PR2: heading ID injection
2205    // -----------------------------------------------------------------
2206
2207    fn heading_id(md: &str) -> Option<String> {
2208        let blocks = parse(md).blocks;
2209        for block in &blocks {
2210            if let Block::Heading { id, .. } = block {
2211                return id.clone();
2212            }
2213        }
2214        None
2215    }
2216
2217    #[test]
2218    fn heading_id_simple_phrase() {
2219        // SoCiviC `## Mission` baseline case.
2220        assert_eq!(heading_id("## Mission\n"), Some("mission".to_string()));
2221    }
2222
2223    #[test]
2224    fn heading_id_spaces_become_hyphens() {
2225        assert_eq!(
2226            heading_id("# Getting Started\n"),
2227            Some("getting-started".to_string())
2228        );
2229    }
2230
2231    #[test]
2232    fn heading_id_with_emphasis_uses_text_content() {
2233        // `*em*` inside a heading: the inner text is `em`, no surrounding
2234        // chars come from emphasis itself (production captures only Text/Code).
2235        assert_eq!(
2236            heading_id("# Hello *world*\n"),
2237            Some("hello-world".to_string())
2238        );
2239    }
2240
2241    #[test]
2242    fn heading_id_with_strong_uses_text_content() {
2243        assert_eq!(
2244            heading_id("# Bold **stuff**\n"),
2245            Some("bold-stuff".to_string())
2246        );
2247    }
2248
2249    #[test]
2250    fn heading_id_with_inline_link_uses_link_text() {
2251        // `# [Docs](url)` — the link label "Docs" comes through as Event::Text.
2252        assert_eq!(heading_id("# [Docs](url)\n"), Some("docs".to_string()));
2253    }
2254
2255    #[test]
2256    fn heading_id_with_inline_code_includes_code_payload() {
2257        // Production captures Event::Code, so `` `fn(x)` `` enters the slug.
2258        assert_eq!(
2259            heading_id("# call `fn(x)`\n"),
2260            Some("call-fn(x)".to_string())
2261        );
2262    }
2263
2264    #[test]
2265    fn heading_id_with_inline_html_strips_html() {
2266        // SoCiviC `# FAREWELL,<br>AND ERASE` — the `<br>` is Event::InlineHtml
2267        // and must NOT appear in the slug. Production's slug for this is
2268        // derived from "FAREWELL,AND ERASE".
2269        let id = heading_id("# FAREWELL,<br>AND ERASE\n").expect("heading id");
2270        // No `<br>` or `br` injected; punctuation preserved (`,`), spaces → `-`.
2271        assert!(!id.contains("br"), "got: {id}");
2272        assert_eq!(id, "farewell,and-erase");
2273    }
2274
2275    #[test]
2276    fn heading_id_cjk_preserved() {
2277        // 刘果's CJK headings exercise Unicode anchor normalization —
2278        // characters pass through unchanged (lowercase already, no whitespace).
2279        assert_eq!(heading_id("## 视频\n"), Some("视频".to_string()));
2280        assert_eq!(heading_id("## 中文标题\n"), Some("中文标题".to_string()));
2281    }
2282
2283    #[test]
2284    fn heading_id_obsidian_strip_chars() {
2285        // Pipes / brackets / hashes / backslashes / carets are stripped.
2286        assert_eq!(heading_id("# Note ^ref\n"), Some("note-ref".to_string()));
2287        assert_eq!(heading_id("# A | B\n"), Some("a-b".to_string()));
2288    }
2289
2290    #[test]
2291    fn duplicate_headings_get_suffixed_ids() {
2292        // Production behavior: first occurrence keeps slug; second gets `-1`,
2293        // third gets `-2`. The HashMap in pipeline.rs:1798 is the contract.
2294        let md = "# Mission\n\n# Mission\n\n# Mission\n";
2295        let doc = parse(md);
2296        let ids: Vec<Option<String>> = doc
2297            .blocks
2298            .iter()
2299            .filter_map(|b| match b {
2300                Block::Heading { id, .. } => Some(id.clone()),
2301                _ => None,
2302            })
2303            .collect();
2304        assert_eq!(
2305            ids,
2306            vec![
2307                Some("mission".to_string()),
2308                Some("mission-1".to_string()),
2309                Some("mission-2".to_string()),
2310            ]
2311        );
2312    }
2313
2314    #[test]
2315    fn duplicate_suffix_descends_into_blockquote() {
2316        // Headings inside a blockquote share the same id-counter as top-level.
2317        let md = "# Notes\n\n> # Notes\n";
2318        let doc = parse(md);
2319        let mut found_ids: Vec<String> = Vec::new();
2320        collect_heading_ids_recursive(&doc.blocks, &mut found_ids);
2321        assert_eq!(found_ids, vec!["notes".to_string(), "notes-1".to_string()]);
2322    }
2323
2324    fn collect_heading_ids_recursive(blocks: &[Block], out: &mut Vec<String>) {
2325        for b in blocks {
2326            match b {
2327                Block::Heading { id, .. } => {
2328                    if let Some(s) = id {
2329                        out.push(s.clone());
2330                    }
2331                }
2332                Block::BlockQuote(children) | Block::Callout { children, .. } => {
2333                    collect_heading_ids_recursive(children, out);
2334                }
2335                Block::List { items, .. } => {
2336                    for item in items {
2337                        collect_heading_ids_recursive(item, out);
2338                    }
2339                }
2340                _ => {}
2341            }
2342        }
2343    }
2344
2345    #[test]
2346    fn heading_id_empty_text_yields_empty_slug() {
2347        // Edge case: `# ###` strips to empty slug; suffix counter still ticks.
2348        // (obsidian_heading_anchor("") == "")
2349        let md = "# ###\n";
2350        let id = heading_id(md);
2351        assert_eq!(id, Some(String::new()));
2352    }
2353
2354    #[test]
2355    fn link_inside_emphasis_unwraps_correctly() {
2356        // *[link](u)* — emphasis wrapping a link is a real authoring pattern.
2357        match first_block("*[t](u)*\n") {
2358            Block::Paragraph(children) => match &children[0] {
2359                Inline::Emphasis(inner) => {
2360                    assert!(matches!(&inner[0], Inline::Link { .. }));
2361                }
2362                other => panic!("expected Emphasis, got {other:?}"),
2363            },
2364            other => panic!("expected Paragraph, got {other:?}"),
2365        }
2366    }
2367
2368    // -----------------------------------------------------------------
2369    // Phase 4 PR3 (2026-05-27): Block::Figure detection in Tag::Paragraph
2370    // -----------------------------------------------------------------
2371
2372    #[test]
2373    fn image_only_paragraph_promotes_to_figure() {
2374        // Canonical case: a paragraph containing exactly one image, no
2375        // sibling inline content, becomes Block::Figure. Caption defaults
2376        // to the image's alt text.
2377        match first_block("![A logo](logo.png)\n") {
2378            Block::Figure { image, caption, .. } => {
2379                match image {
2380                    Inline::Image { src, alt, .. } => {
2381                        assert!(src.is_unresolved());
2382                        assert_eq!(alt, "A logo");
2383                    }
2384                    other => panic!("expected Image inside Figure, got {other:?}"),
2385                }
2386                let cap = caption.expect("caption from alt text");
2387                assert_eq!(cap.len(), 1);
2388                assert!(matches!(&cap[0], Inline::Text(t) if t == "A logo"));
2389            }
2390            other => panic!("expected Figure, got {other:?}"),
2391        }
2392    }
2393
2394    #[test]
2395    fn image_only_paragraph_with_empty_alt_stays_as_paragraph() {
2396        // Empty-alt guard: a decorative image (no alt) does NOT promote
2397        // to Figure. Production's implicit-figure pass gates on
2398        // non-empty alt — wrapping a no-alt image in `<figure>` adds
2399        // visual noise (no figcaption text) without a11y benefit. The
2400        // bytes match production's `<p><img></p>` shape.
2401        //
2402        // Parity-probe evidence: pre-guard, 7 CJK 刘果 fixtures with
2403        // trailing empty-alt images flipped to "other" because the AST
2404        // emitted `<figure>` and prod did not. Guard restores parity.
2405        match first_block("![](logo.png)\n") {
2406            Block::Paragraph(children) => {
2407                assert_eq!(children.len(), 1);
2408                match &children[0] {
2409                    Inline::Image { alt, .. } => assert_eq!(alt, ""),
2410                    other => panic!("expected Image inside Paragraph, got {other:?}"),
2411                }
2412            }
2413            other => panic!("empty-alt image-only paragraph must stay as Paragraph, got {other:?}"),
2414        }
2415    }
2416
2417    #[test]
2418    fn image_with_whitespace_text_still_promotes_to_figure() {
2419        // Whitespace-only text or line-break siblings don't disqualify
2420        // (matches transform_events' "image-only modulo whitespace"
2421        // behavior). Verifying via a wikilink + trailing whitespace would
2422        // require an actual whitespace event; pulldown-cmark typically
2423        // strips this. The detector is defensive for the cases that
2424        // DO surface whitespace inlines (line breaks after the image).
2425        let md = "![alt](a.jpg)  \n";
2426        // The trailing "  \n" inside a paragraph emits a HardBreak event
2427        // (Inline::LineBreak). Promotion must still succeed.
2428        match first_block(md) {
2429            Block::Figure { image, .. } => assert!(matches!(image, Inline::Image { .. })),
2430            // pulldown-cmark may also collapse this differently; accept
2431            // Paragraph(LineBreak) as a tolerated fallback so the test is
2432            // not over-specified on pulldown-cmark whitespace semantics.
2433            // The critical regression we want to lock is that genuine
2434            // image+text mixes DON'T promote (covered by the test below).
2435            Block::Paragraph(_) => {}
2436            other => panic!("expected Figure or Paragraph, got {other:?}"),
2437        }
2438    }
2439
2440    #[test]
2441    fn image_with_caption_text_does_not_promote() {
2442        // Critical regression guard (cf. PR1 v2 commit 71c657af3): a
2443        // paragraph carrying image + prose / emphasis must NOT be
2444        // promoted to a figure. If we promoted, the caption text would
2445        // be lost and we'd produce a malformed figure with sibling
2446        // content swallowed.
2447        match first_block("![alt](a.jpg) plain caption text\n") {
2448            Block::Paragraph(children) => {
2449                assert!(children.iter().any(|i| matches!(i, Inline::Image { .. })));
2450                assert!(
2451                    children
2452                        .iter()
2453                        .any(|i| matches!(i, Inline::Text(t) if t.contains("plain"))),
2454                    "expected sibling Text to remain, got {children:?}"
2455                );
2456            }
2457            other => panic!("expected Paragraph, got {other:?}"),
2458        }
2459    }
2460
2461    #[test]
2462    fn image_with_emphasis_sibling_does_not_promote() {
2463        // Pandoc-style "image + emphasis caption" is recognized in the
2464        // legacy transform_events as a captioned figure, but PR3's
2465        // simplified detection (one Image, no other content modulo
2466        // whitespace) leaves these as Paragraph. PR0's parity probe
2467        // already classifies these under image_emission / image_figures
2468        // depending on production behavior; PR3 owns ONLY the simple
2469        // image-only case. The downstream image+emphasis case is closed
2470        // out at PR7a when production flips.
2471        match first_block("![alt](a.jpg) *caption*\n") {
2472            Block::Paragraph(children) => {
2473                assert!(children.iter().any(|i| matches!(i, Inline::Image { .. })));
2474                assert!(
2475                    children.iter().any(|i| matches!(i, Inline::Emphasis(_))),
2476                    "expected Emphasis to remain, got {children:?}"
2477                );
2478            }
2479            other => panic!("expected Paragraph, got {other:?}"),
2480        }
2481    }
2482
2483    #[test]
2484    fn two_images_in_one_paragraph_do_not_promote() {
2485        // Detection rule requires EXACTLY one image. Two images stay as
2486        // a paragraph (no figure wrap chosen — production would also
2487        // not wrap this in a figure).
2488        match first_block("![a](a.jpg) ![b](b.jpg)\n") {
2489            Block::Paragraph(children) => {
2490                let img_count = children
2491                    .iter()
2492                    .filter(|i| matches!(i, Inline::Image { .. }))
2493                    .count();
2494                assert_eq!(img_count, 2);
2495            }
2496            other => panic!("expected Paragraph (two images), got {other:?}"),
2497        }
2498    }
2499
2500    // Editor Image UX (2026-06-04): standard-image `|NN%` width carries
2501    // into Block::Figure.width instead of leaking into the caption.
2502    // ------------------------------------------------------------------
2503
2504    #[test]
2505    fn standard_image_percent_promotes_with_width() {
2506        // ![alt|55%](pic.jpg) → Figure { width: Some("55%"), caption "alt" }
2507        match first_block("![alt|55%](pic.jpg)\n") {
2508            Block::Figure { width, caption, .. } => {
2509                assert_eq!(width.as_deref(), Some("55%"));
2510                // caption is the remaining alt (width segment removed)
2511                let cap = caption.expect("caption from remaining alt");
2512                assert!(matches!(cap.as_slice(), [Inline::Text(t)] if t == "alt"));
2513            }
2514            other => panic!("expected a Figure, got {other:?}"),
2515        }
2516    }
2517
2518    #[test]
2519    fn standard_image_percent_empty_alt_still_promotes() {
2520        // ![|55%](pic.jpg) → Figure (no caption) carrying the width.
2521        match first_block("![|55%](pic.jpg)\n") {
2522            Block::Figure { width, caption, .. } => {
2523                assert_eq!(width.as_deref(), Some("55%"));
2524                assert!(
2525                    caption.is_none() || matches!(caption.as_deref(), Some([])),
2526                    "empty-alt-with-width figure must not carry a caption: {caption:?}"
2527                );
2528            }
2529            other => panic!("expected a Figure even with empty alt when width present, got {other:?}"),
2530        }
2531    }
2532
2533    #[test]
2534    fn standard_image_no_width_unchanged() {
2535        // ![alt](pic.jpg) → Figure { width: None } (existing behavior)
2536        match first_block("![alt](pic.jpg)\n") {
2537            Block::Figure { width, .. } => assert_eq!(width, None),
2538            other => panic!("expected a Figure, got {other:?}"),
2539        }
2540    }
2541
2542    #[test]
2543    fn plain_paragraph_still_parses_as_paragraph() {
2544        // No regression: a normal text paragraph stays as Block::Paragraph.
2545        match first_block("just some prose\n") {
2546            Block::Paragraph(_) => {}
2547            other => panic!("expected Paragraph, got {other:?}"),
2548        }
2549    }
2550
2551    // -----------------------------------------------------------------
2552    // Phase B Task 7: :::subscribe end-to-end
2553    // -----------------------------------------------------------------
2554
2555    use super::super::shortcode::Shortcode;
2556
2557    #[test]
2558    fn parses_subscribe_block_into_typed_shortcode() {
2559        let md = r#":::subscribe {placeholder="you@domain.com" button="Sign me up"}
2560:::
2561"#;
2562        let doc = parse(md);
2563        // Should find one Block::Shortcode(Subscribe) at top level.
2564        let mut found: Option<&Shortcode> = None;
2565        for block in &doc.blocks {
2566            if let Block::Shortcode(sc) = block {
2567                found = Some(sc);
2568                break;
2569            }
2570        }
2571        let sc = found.expect("expected Block::Shortcode");
2572        match sc {
2573            Shortcode::Subscribe(args) => {
2574                assert_eq!(args.placeholder.as_deref(), Some("you@domain.com"));
2575                assert_eq!(args.button.as_deref(), Some("Sign me up"));
2576            }
2577            other => panic!("expected Subscribe, got {other:?}"),
2578        }
2579    }
2580
2581    #[test]
2582    fn subscribe_block_does_not_leave_sentinel_in_other_block() {
2583        let md = ":::subscribe\n:::\n";
2584        let doc = parse(md);
2585        // No Block::Other should contain the sentinel string.
2586        for block in &doc.blocks {
2587            if let Block::Other(html) = block {
2588                assert!(
2589                    !html.contains("MOSS_SHORTCODE"),
2590                    "unsubstituted sentinel remained in AST: {html:?}"
2591                );
2592            }
2593        }
2594    }
2595
2596    #[test]
2597    fn subscribe_inside_paragraph_text_is_not_extracted() {
2598        // Adversarial: `:::subscribe` appearing inside running prose
2599        // (not as a block opener on its own line) is not a shortcode.
2600        // The extractor only matches when `:::name` is on its own line.
2601        let md = "Read more about :::subscribe in the docs.\n";
2602        let doc = parse(md);
2603        for block in &doc.blocks {
2604            assert!(
2605                !matches!(block, Block::Shortcode(_)),
2606                "`:::subscribe` inline-text was wrongly extracted as a shortcode"
2607            );
2608        }
2609    }
2610
2611    #[test]
2612    fn subscribe_block_alongside_other_content_preserves_order() {
2613        let md = "# H\n\nfirst para\n\n:::subscribe\ndescription: d\n:::\n\nlast para\n";
2614        let doc = parse(md);
2615        let kinds: Vec<&'static str> = doc
2616            .blocks
2617            .iter()
2618            .map(|b| match b {
2619                Block::Heading { .. } => "h",
2620                Block::Paragraph(_) => "p",
2621                Block::Shortcode(_) => "sc",
2622                _ => "x",
2623            })
2624            .collect();
2625        assert_eq!(kinds, vec!["h", "p", "sc", "p"]);
2626    }
2627
2628    // -----------------------------------------------------------------
2629    // 2026-05-28 (Phase 4 source-line wiring): ParseConfig threading
2630    // -----------------------------------------------------------------
2631
2632    #[test]
2633    fn parse_default_config_keeps_block_meta_empty() {
2634        let doc = parse("# H1\n\npara one\n\npara two\n");
2635        assert_eq!(doc.blocks.len(), 3);
2636        assert_eq!(doc.block_meta.len(), doc.blocks.len());
2637        for meta in &doc.block_meta {
2638            assert!(
2639                meta.source_line.is_none(),
2640                "default parse should not populate source_line: {meta:?}"
2641            );
2642        }
2643    }
2644
2645    #[test]
2646    fn parse_with_source_lines_assigns_1_based_line_numbers() {
2647        let md = "# H1\n\npara on line 3\n\n## H2 on line 5\n\npara on line 7\n";
2648        let config = ParseConfig {
2649            emit_source_lines: true,
2650            implicit_figure: true,
2651            source_line_offset: 0,
2652        };
2653        let doc = parse_with_config(md, &config);
2654        // Expected blocks: H1, P, H2, P (4 blocks).
2655        assert_eq!(doc.blocks.len(), 4);
2656        assert_eq!(doc.block_meta.len(), 4);
2657        // Line numbers should track the markdown source.
2658        assert_eq!(doc.block_meta[0].source_line, Some(1), "H1 on line 1");
2659        assert_eq!(doc.block_meta[1].source_line, Some(3), "P on line 3");
2660        assert_eq!(doc.block_meta[2].source_line, Some(5), "H2 on line 5");
2661        assert_eq!(doc.block_meta[3].source_line, Some(7), "P on line 7");
2662    }
2663
2664    #[test]
2665    fn source_line_offset_shifts_to_real_file_lines() {
2666        // Frontmatter is stripped before parsing, so source lines are body-
2667        // relative. source_line_offset (= the frontmatter line count) realigns
2668        // them with the editor's real file lines (CM6 doc.lineAt).
2669        let md = "# H1\n\npara on line 3\n";
2670        let config = ParseConfig {
2671            emit_source_lines: true,
2672            implicit_figure: true,
2673            source_line_offset: 7,
2674        };
2675        let doc = parse_with_config(md, &config);
2676        assert_eq!(
2677            doc.block_meta[0].source_line,
2678            Some(8),
2679            "H1 body-line 1 + offset 7"
2680        );
2681        assert_eq!(
2682            doc.block_meta[1].source_line,
2683            Some(10),
2684            "P body-line 3 + offset 7"
2685        );
2686    }
2687
2688    #[test]
2689    fn source_lines_not_collapsed_across_multiline_shortcode() {
2690        // A multi-line shortcode (grid) must NOT collapse the source lines of
2691        // blocks after it. The grid spans lines 3–11; the heading after is on
2692        // line 13. Before the line-count-preserving placeholder fix it
2693        // collapsed to ~line 5, so editor→preview scroll-sync sent any cursor
2694        // past the block to the page bottom.
2695        let md = "# Title\n\n:::grid 3\n[\n![](a.jpg)\n](/x)\n+++\n[\n![](b.jpg)\n](/y)\n:::\n\n## After\n";
2696        let config = ParseConfig {
2697            emit_source_lines: true,
2698            implicit_figure: true,
2699            source_line_offset: 0,
2700        };
2701        let doc = parse_with_config(md, &config);
2702        // Blocks: H1 (line 1), Shortcode grid (line 3), H2 "After" (line 13).
2703        let last = doc
2704            .block_meta
2705            .last()
2706            .expect("at least one block")
2707            .source_line;
2708        assert_eq!(
2709            last,
2710            Some(13),
2711            "heading after a multi-line grid must keep its real line 13, not a collapsed line"
2712        );
2713    }
2714
2715    #[test]
2716    fn parse_with_source_lines_lists_and_blockquotes() {
2717        let md = "- item one\n- item two\n\n> quote on line 4\n";
2718        let config = ParseConfig {
2719            emit_source_lines: true,
2720            implicit_figure: true,
2721            source_line_offset: 0,
2722        };
2723        let doc = parse_with_config(md, &config);
2724        assert_eq!(doc.blocks.len(), 2);
2725        assert_eq!(doc.block_meta[0].source_line, Some(1), "ul on line 1");
2726        assert_eq!(doc.block_meta[1].source_line, Some(4), "bq on line 4");
2727    }
2728
2729    // -----------------------------------------------------------------
2730    // 2026-05-28 (Phase 4 source-line followup): per-<li> + per-<tr>
2731    // line tracking on Block::List and Block::Table.
2732    // -----------------------------------------------------------------
2733
2734    #[test]
2735    fn parse_with_source_lines_populates_item_lines_on_list() {
2736        // Multi-item list spanning consecutive source lines; the parser
2737        // must capture the 1-based line of each `Tag::Item` start.
2738        let md = "- one\n- two\n- three\n";
2739        let config = ParseConfig {
2740            emit_source_lines: true,
2741            implicit_figure: true,
2742            source_line_offset: 0,
2743        };
2744        let doc = parse_with_config(md, &config);
2745        assert_eq!(doc.blocks.len(), 1);
2746        match &doc.blocks[0] {
2747            Block::List {
2748                items,
2749                item_source_lines,
2750                ..
2751            } => {
2752                assert_eq!(items.len(), 3);
2753                assert_eq!(
2754                    item_source_lines.len(),
2755                    3,
2756                    "item_source_lines must be parallel to items"
2757                );
2758                assert_eq!(item_source_lines[0], Some(1));
2759                assert_eq!(item_source_lines[1], Some(2));
2760                assert_eq!(item_source_lines[2], Some(3));
2761            }
2762            other => panic!("expected List, got {other:?}"),
2763        }
2764    }
2765
2766    #[test]
2767    fn parse_default_config_leaves_item_source_lines_empty() {
2768        // Production publish builds (default config — `emit_source_lines:
2769        // false`) must NOT populate `item_source_lines`. The renderer
2770        // treats empty as "no annotations" so the published HTML is
2771        // byte-identical to the pre-followup output.
2772        let doc = parse("- one\n- two\n");
2773        assert_eq!(doc.blocks.len(), 1);
2774        match &doc.blocks[0] {
2775            Block::List {
2776                item_source_lines, ..
2777            } => {
2778                assert!(
2779                    item_source_lines.is_empty(),
2780                    "default config must NOT populate item_source_lines (publish builds): {item_source_lines:?}"
2781                );
2782            }
2783            other => panic!("expected List, got {other:?}"),
2784        }
2785    }
2786
2787    #[test]
2788    fn parse_with_source_lines_populates_row_lines_on_table() {
2789        // Multi-row table: header on line 1, separator on line 2, body
2790        // rows on lines 3, 4, 5. The parser must capture the 1-based
2791        // line of each `Tag::TableRow` start.
2792        let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n| c | d |\n| e | f |\n";
2793        let config = ParseConfig {
2794            emit_source_lines: true,
2795            implicit_figure: true,
2796            source_line_offset: 0,
2797        };
2798        let doc = parse_with_config(md, &config);
2799        assert_eq!(doc.blocks.len(), 1);
2800        match &doc.blocks[0] {
2801            Block::Table {
2802                rows,
2803                header_source_line,
2804                row_source_lines,
2805                ..
2806            } => {
2807                assert_eq!(rows.len(), 3);
2808                // The header tr anchors at the markdown header row (line 1).
2809                assert_eq!(*header_source_line, Some(1), "header tr line");
2810                assert_eq!(
2811                    row_source_lines.len(),
2812                    3,
2813                    "row_source_lines must be parallel to rows"
2814                );
2815                assert_eq!(row_source_lines[0], Some(3));
2816                assert_eq!(row_source_lines[1], Some(4));
2817                assert_eq!(row_source_lines[2], Some(5));
2818            }
2819            other => panic!("expected Table, got {other:?}"),
2820        }
2821    }
2822
2823    #[test]
2824    fn parse_default_config_leaves_row_source_lines_empty() {
2825        // Production publish builds must not populate table row lines.
2826        let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n";
2827        let doc = parse(md);
2828        assert_eq!(doc.blocks.len(), 1);
2829        match &doc.blocks[0] {
2830            Block::Table {
2831                header_source_line,
2832                row_source_lines,
2833                ..
2834            } => {
2835                assert!(header_source_line.is_none());
2836                assert!(row_source_lines.is_empty());
2837            }
2838            other => panic!("expected Table, got {other:?}"),
2839        }
2840    }
2841
2842    // -----------------------------------------------------------------
2843    // 2026-05-28 (Phase 4 followup B): ordered-list explicit start
2844    // number captured from pulldown-cmark's `Tag::List(Option<u64>)`
2845    // payload and round-tripped to the renderer as `<ol start="N">`.
2846    // -----------------------------------------------------------------
2847
2848    #[test]
2849    fn parse_ordered_list_start_3_captures_start_number() {
2850        // `3. foo` should capture `start: Some(3)` so the renderer can
2851        // emit `<ol start="3">`. CommonMark only honors the first
2852        // item's number — subsequent items are re-derived.
2853        let doc = parse("3. foo\n4. bar\n");
2854        assert_eq!(doc.blocks.len(), 1);
2855        match &doc.blocks[0] {
2856            Block::List {
2857                ordered,
2858                start,
2859                items,
2860                ..
2861            } => {
2862                assert!(ordered, "ordered list");
2863                assert_eq!(*start, Some(3), "explicit start number captured");
2864                assert_eq!(items.len(), 2);
2865            }
2866            other => panic!("expected ordered List, got {other:?}"),
2867        }
2868    }
2869
2870    #[test]
2871    fn parse_ordered_list_default_start_collapses_to_none() {
2872        // pulldown-cmark normalizes `1. foo` to `Tag::List(Some(1))`,
2873        // but the AST canonicalizes this to `start: None` (semantically
2874        // identical to `<ol>` without a `start=` attribute, but cleaner).
2875        let doc = parse("1. foo\n2. bar\n");
2876        assert_eq!(doc.blocks.len(), 1);
2877        match &doc.blocks[0] {
2878            Block::List { ordered, start, .. } => {
2879                assert!(ordered);
2880                assert!(
2881                    start.is_none(),
2882                    "implicit start=1 must collapse to None, got {start:?}"
2883                );
2884            }
2885            other => panic!("expected ordered List, got {other:?}"),
2886        }
2887    }
2888
2889    #[test]
2890    fn parse_unordered_list_has_no_start() {
2891        // `- foo` is unordered (`Tag::List(None)`). `start` must always
2892        // be `None` regardless of any subsequent reasoning.
2893        let doc = parse("- foo\n- bar\n");
2894        assert_eq!(doc.blocks.len(), 1);
2895        match &doc.blocks[0] {
2896            Block::List { ordered, start, .. } => {
2897                assert!(!ordered, "unordered list");
2898                assert!(
2899                    start.is_none(),
2900                    "unordered list must have start=None, got {start:?}"
2901                );
2902            }
2903            other => panic!("expected unordered List, got {other:?}"),
2904        }
2905    }
2906
2907    #[test]
2908    fn parse_with_source_lines_handles_list_after_blank_line_offset() {
2909        // List items can start past the document start; verify the
2910        // 1-based numbering tracks the actual source line, not a
2911        // 0-based index from the list opener.
2912        let md = "intro paragraph\n\n- item on line 3\n- item on line 4\n";
2913        let config = ParseConfig {
2914            emit_source_lines: true,
2915            implicit_figure: true,
2916            source_line_offset: 0,
2917        };
2918        let doc = parse_with_config(md, &config);
2919        assert_eq!(doc.blocks.len(), 2);
2920        match &doc.blocks[1] {
2921            Block::List {
2922                item_source_lines, ..
2923            } => {
2924                assert_eq!(item_source_lines.len(), 2);
2925                assert_eq!(item_source_lines[0], Some(3));
2926                assert_eq!(item_source_lines[1], Some(4));
2927            }
2928            other => panic!("expected List as second block, got {other:?}"),
2929        }
2930    }
2931
2932    #[test]
2933    fn parse_implicit_figure_default_promotes_image_only_paragraph() {
2934        // Image-only paragraph with non-empty alt → promoted to Block::Figure.
2935        let doc = parse("![alt](photo.jpg)\n");
2936        assert_eq!(doc.blocks.len(), 1);
2937        assert!(
2938            matches!(doc.blocks[0], Block::Figure { .. }),
2939            "default config (implicit_figure=true) should promote: got {:?}",
2940            doc.blocks[0]
2941        );
2942    }
2943
2944    #[test]
2945    fn parse_implicit_figure_off_leaves_image_paragraph_unpromoted() {
2946        let config = ParseConfig {
2947            emit_source_lines: false,
2948            implicit_figure: false,
2949            source_line_offset: 0,
2950        };
2951        let doc = parse_with_config("![alt](photo.jpg)\n", &config);
2952        assert_eq!(doc.blocks.len(), 1);
2953        match &doc.blocks[0] {
2954            Block::Paragraph(inlines) => {
2955                assert!(matches!(inlines[0], Inline::Image { .. }));
2956            }
2957            other => panic!("expected Paragraph with image, got {other:?}"),
2958        }
2959    }
2960
2961    // -----------------------------------------------------------------
2962    // LineLookup unit tests (binary-search prefix-sum line table)
2963    // -----------------------------------------------------------------
2964
2965    #[test]
2966    fn line_lookup_offset_zero_is_line_one() {
2967        let lookup = LineLookup::build("hello\nworld\n", 0);
2968        assert_eq!(lookup.line_at(0), 1);
2969    }
2970
2971    #[test]
2972    fn line_lookup_after_first_newline_is_line_two() {
2973        let lookup = LineLookup::build("hello\nworld\n", 0);
2974        // Byte 6 is the 'w' of "world", which is on line 2.
2975        assert_eq!(lookup.line_at(6), 2);
2976    }
2977
2978    #[test]
2979    fn line_lookup_handles_multiline_block_starts() {
2980        let lookup = LineLookup::build("line1\nline2\nline3\n", 0);
2981        // First non-newline byte of each line.
2982        assert_eq!(lookup.line_at(0), 1, "byte 0 → line 1");
2983        assert_eq!(lookup.line_at(6), 2, "byte 6 → line 2");
2984        assert_eq!(lookup.line_at(12), 3, "byte 12 → line 3");
2985    }
2986
2987    #[test]
2988    fn line_lookup_empty_source() {
2989        let lookup = LineLookup::build("", 0);
2990        assert_eq!(lookup.line_at(0), 1, "empty source still has line 1");
2991    }
2992
2993    // -----------------------------------------------------------------
2994    // Wikilink percent-width — no-ContentGraph path
2995    //
2996    // `try_promote_to_figure` recovers a content-relative percent (`|55%`)
2997    // from `wikilink_pothole` so the no-graph parse path (fragment/test
2998    // render) carries width in `Block::Figure.width`, not as a spurious
2999    // caption. These are regression guards for the no-graph fix.
3000    //
3001    // Sync: the with-graph twin lives in
3002    // resolve/wikilink_dispatch.rs (image branch, `split_alt_width` call)
3003    // — both split width via `media::split_alt_width`.
3004    // -----------------------------------------------------------------
3005
3006    #[test]
3007    fn wikilink_image_percent_no_graph_promotes_with_width() {
3008        // ![[pic.jpg|55%]] must carry |55% into Figure.width, not leak it
3009        // into the caption (regression guard for the no-graph fix).
3010        let block = first_block("![[pic.jpg|55%]]\n");
3011        match block {
3012            Block::Figure { width, caption, .. } => {
3013                assert_eq!(width.as_deref(), Some("55%"));
3014                assert!(caption.is_none(), "percent must not become a caption");
3015            }
3016            other => panic!("expected Figure, got {other:?}"),
3017        }
3018    }
3019
3020    #[test]
3021    fn wikilink_image_percent_with_caption_no_graph() {
3022        // ![[pic.jpg|My cap|55%]] — width Some("55%"), caption "My cap".
3023        let block = first_block("![[pic.jpg|My cap|55%]]\n");
3024        match block {
3025            Block::Figure { width, caption, .. } => {
3026                assert_eq!(width.as_deref(), Some("55%"));
3027                let cap = caption.as_ref().expect("caption must be present");
3028                assert!(
3029                    matches!(cap.as_slice(), [Inline::Text(t)] if t == "My cap"),
3030                    "caption should be the non-width segment, got {cap:?}"
3031                );
3032            }
3033            other => panic!("expected Figure, got {other:?}"),
3034        }
3035    }
3036
3037    // -----------------------------------------------------------------
3038    // Non-image wikilink embeds must NOT promote to Figure
3039    //
3040    // Figure is an image concept. pulldown-cmark parses every `![[…]]`
3041    // as an Image event, so without a kind gate a video pothole
3042    // (`![[clip.mov|77%]]`) was hijacked into Block::Figure — and
3043    // `dispatch_wikilink_embeds` only dispatches Paragraph-shaped lone
3044    // embeds, so the video synthesizer never ran: the page shipped
3045    // `<figure><img src="clip.mov">` (broken image). The gate keys off
3046    // the same classifier the dispatcher uses (`resolve::ext_kind`), so
3047    // parse-time promotion and dispatch-time synthesis can never
3048    // disagree about who owns the block.
3049    // -----------------------------------------------------------------
3050
3051    #[test]
3052    fn wikilink_video_percent_stays_paragraph() {
3053        let block = first_block("![[clip.mov|77%]]\n");
3054        match block {
3055            Block::Paragraph(inlines) => assert!(
3056                matches!(
3057                    inlines.as_slice(),
3058                    [Inline::Image {
3059                        is_wikilink: true,
3060                        ..
3061                    }]
3062                ),
3063                "paragraph must hold the lone wikilink image, got {inlines:?}"
3064            ),
3065            other => panic!("video embed must stay Paragraph for dispatch, got {other:?}"),
3066        }
3067    }
3068
3069    #[test]
3070    fn wikilink_video_box_sizing_stays_paragraph() {
3071        // `|640x360` is the documented video sizing alias — it must reach
3072        // the dispatcher, not become a figcaption.
3073        let block = first_block("![[clip.mov|640x360]]\n");
3074        assert!(
3075            matches!(block, Block::Paragraph(_)),
3076            "expected Paragraph, got {block:?}"
3077        );
3078    }
3079
3080    #[test]
3081    fn wikilink_pdf_alias_stays_paragraph() {
3082        let block = first_block("![[report.pdf|80%]]\n");
3083        assert!(
3084            matches!(block, Block::Paragraph(_)),
3085            "expected Paragraph, got {block:?}"
3086        );
3087    }
3088
3089    #[test]
3090    fn wikilink_extensionless_stays_paragraph() {
3091        // `![[draft|55%]]` carries no extension intent — only the
3092        // with-graph dispatcher can resolve its kind, so the parser must
3093        // not commit it to an image Figure.
3094        let block = first_block("![[draft|55%]]\n");
3095        assert!(
3096            matches!(block, Block::Paragraph(_)),
3097            "expected Paragraph, got {block:?}"
3098        );
3099    }
3100
3101    #[test]
3102    fn wikilink_uppercase_image_ext_still_promotes() {
3103        // Extension matching is case-insensitive (vault files like
3104        // `photo.JPG` are common iPhone/camera exports).
3105        let block = first_block("![[photo.JPG|55%]]\n");
3106        assert!(
3107            matches!(block, Block::Figure { .. }),
3108            "expected Figure, got {block:?}"
3109        );
3110    }
3111}