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, HashSet};
22
23use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
24
25use super::document::{BlockMeta, Document};
26use super::footnotes::FootnoteIndex;
27use super::math_text::{math_inline, math_source};
28use super::node::{Block, CalloutKind, ColumnAlignment, Fold, Inline};
29use super::shortcode::Shortcode;
30use super::shortcode_extract::{extract_shortcodes_with_config, parse_placeholder, ExtractedShortcode};
31use super::url::Url;
32use crate::heading::anchor::obsidian_heading_anchor;
33
34/// Parser configuration flags.
35///
36/// Threaded through [`parse_with_config`] to gate optional parser behaviors
37/// that the renderer needs to coordinate with (source-line tracking for
38/// preview scroll sync, implicit-figure promotion).
39///
40/// [`Default`] = "production preview off" — `emit_source_lines: false`,
41/// `implicit_figure: true`. The `implicit_figure` default mirrors today's
42/// always-on behavior of the parser before this config existed; flipping it
43/// off is opt-in for the small set of fragment-render call sites that need
44/// bare `<img>` (none today, but the flag exists for symmetry with the
45/// legacy `transform_events` API and the production `site_config` field).
46#[derive(Debug, Clone, Copy)]
47pub struct ParseConfig {
48    /// When true, populates [`BlockMeta::source_line`] for top-level
49    /// blocks. The renderer emits `data-source-line="N"` on the opening
50    /// tag for any block whose meta carries `Some(N)`.
51    ///
52    /// Production wires this from `process_markdown_file`'s
53    /// `emit_source_lines` argument (`true` during preview builds, `false`
54    /// during ship-stage publish builds — `data-source-line` is stripped
55    /// at ship time anyway, but emitting fewer attrs upstream is cheaper
56    /// and keeps published HTML clean from earlier stages).
57    pub emit_source_lines: bool,
58
59    /// When true (default), image-only paragraphs promote to
60    /// [`Block::Figure`] via [`try_promote_to_figure`]. When false, they
61    /// stay as [`Block::Paragraph`] containing one [`Inline::Image`].
62    ///
63    /// Production wires this from `site_config.implicit_figure` (default
64    /// `true`). The flag mirrors the legacy `transform_events`
65    /// implicit-figure pass: sites that prefer bare `<img>` (no `<figure>`
66    /// wrap) can opt out.
67    pub implicit_figure: bool,
68
69    /// Added to every computed `source_line` so the emitted
70    /// `data-source-line` / `data-source-range` values match the editor's
71    /// REAL FILE line numbers (CM6 `doc.lineAt`), not body-relative lines.
72    ///
73    /// The parser only ever sees the markdown BODY (frontmatter is stripped
74    /// upstream), so its byte offsets — and thus `LineLookup` — are
75    /// body-relative. The editor, however, reports raw-file lines including
76    /// the frontmatter. Without this offset, every annotation is short by the
77    /// frontmatter line count, so editor→preview scroll-sync maps to the wrong
78    /// element (the home page's grid scrolled the preview to the bottom). Set
79    /// to the number of lines the frontmatter consumes (0 when there is none).
80    /// See `process_markdown_file` and docs/reference/editor-preview-sync.md
81    /// "Known defect — source-line coordinate-system mismatch".
82    pub source_line_offset: usize,
83
84    /// When true, `$…$` / `$$…$$` parse as math ([`Options::ENABLE_MATH`])
85    /// and render as escaped LaTeX source in `<code class="moss-math">`.
86    /// When false (default), `$` is an ordinary character and math source
87    /// passes through as literal text.
88    ///
89    /// Default is `false` — unlike the other flags, this one changes what
90    /// the *characters* mean, so every in-crate `parse()` caller and every
91    /// committed snapshot fixture keeps today's behavior until a site opts
92    /// in. Production wires it from `site_config.math` (`[site].math`,
93    /// default on), which is where the "is `$5` currency or an unclosed
94    /// equation?" judgment belongs.
95    pub math: bool,
96
97    /// When true, a single newline inside a paragraph renders as `<br>`,
98    /// matching Obsidian's default (`strictLineBreaks = false`, i.e. remark
99    /// `breaks: true`). When false, CommonMark applies and the newline is a
100    /// space.
101    ///
102    /// Default is `false` for the same reason `math` is: it changes what the
103    /// author's *characters* mean, so every in-crate `parse()` caller and
104    /// every committed fixture keeps today's behavior until a site opts in.
105    /// Production wires it from `site_config.hard_line_breaks`.
106    pub hard_line_breaks: bool,
107}
108
109impl Default for ParseConfig {
110    fn default() -> Self {
111        Self {
112            emit_source_lines: false,
113            // `true` matches today's always-on behavior of the parser
114            // before ParseConfig existed; the ~40 in-crate `parse()`
115            // callers all assume figure promotion happens.
116            implicit_figure: true,
117            source_line_offset: 0,
118            // Off by default so the ~40 in-crate `parse()` callers and every
119            // committed snapshot fixture are untouched by math landing.
120            // Production opts in via `[site].math`.
121            math: false,
122            // Off by default so in-crate callers and committed fixtures keep
123            // CommonMark's "newline is a space". Production opts in.
124            hard_line_breaks: false,
125        }
126    }
127}
128
129/// **The** pulldown-cmark option set moss parses markdown with.
130///
131/// Every parser construction site in the repo must call this rather than
132/// hand-assembling its own `Options` — moss previously had five independent
133/// `Options` blocks (typed AST, newsletter ×2, `llms_txt`, the markdown
134/// pipeline), and each one that drifted became a surface where the same
135/// document parsed differently depending on which output it was headed for.
136/// A site that legitimately needs a different set calls this and then adjusts
137/// the one option, so the divergence reads as an explicit delta at the call
138/// site instead of being invisibly re-hand-rolled. There is no such delta
139/// today: this comment used to cite the newsletter walker omitting
140/// `ENABLE_FOOTNOTES`, which stopped being true when email gained footnote
141/// arms — with the bit off, CommonMark reads `[^x]: <url>` as a link reference
142/// definition and deletes the note outright.
143///
144/// `math` gates `ENABLE_MATH` (`$…$` / `$$…$$` → [`Event::InlineMath`] /
145/// [`Event::DisplayMath`]). It is a parameter rather than part of the base
146/// set because it changes the meaning of a character that appears in
147/// ordinary prose (`$5`), so it is the one option a site must opt into —
148/// production wires it from `[site].math` on `SiteConfig`.
149///
150/// **Enabling `math` obliges the caller's event walker to handle both math
151/// events.** pulldown emits them as leaf inline events; a walker that
152/// pattern-matches known events and ignores the rest will *silently delete*
153/// every equation in the document (measured: `Energy $E = mc^2$.` →
154/// `<p>Energy .</p>`). See `src-tauri/tests/math_wiring_invariant_test.rs`,
155/// which fails any site that turns math on without arms in the same walker.
156///
157/// `ENABLE_TASKLISTS` carries the same obligation, and it is met by
158/// [`Inline::TaskMarker`]: the flag makes pulldown emit
159/// `Event::TaskListMarker` as the first event inside `Tag::Item`, and both
160/// the leaf arm in `parse_inline` and the whitelist in `parse_inline_event`
161/// model it. Turning the flag on WITHOUT those arms silently deletes the
162/// checkbox — measured on `- [ ] todo\n- [x] done`, which rendered
163/// `<ul><li>todo</li><li>done</li></ul>`. See ADR-035 § Task lists.
164pub fn parser_options(math: bool) -> Options {
165    let mut options = Options::empty();
166    options.insert(Options::ENABLE_STRIKETHROUGH);
167    options.insert(Options::ENABLE_TABLES);
168    options.insert(Options::ENABLE_FOOTNOTES);
169    options.insert(Options::ENABLE_TASKLISTS);
170    // Phase 3 PR2: pulldown-cmark emits `LinkType::WikiLink` events for
171    // `[[…]]` / `![[…]]` natively. The typed-AST parser preserves them as
172    // `Inline::Link`/`Inline::Image` with `Url::Unresolved`; resolution
173    // happens in the later `visit_urls_mut` pass.
174    options.insert(Options::ENABLE_WIKILINKS);
175    // CJK-friendly emphasis: closes a `*`/`**` run whose delimiter sits between
176    // a CJK punctuation mark and a CJK ideograph (no ASCII space, as CJK prose
177    // never has one) — vanilla CommonMark flanking leaves it as a literal `**`.
178    // pulldown-cmark#1059, implementing the `tats-u/markdown-cjk-friendly`
179    // amendment to CommonMark 0.31.2; backward-compatible on every existing
180    // CommonMark example. Feature-gated because the flag exists only in the
181    // `[patch]` fork until pulldown releases it, keeping the published crate
182    // buildable against crates.io. Pinned by `src-tauri/tests/cjk_emphasis.rs`.
183    #[cfg(feature = "cjk-friendly-emphasis")]
184    options.insert(Options::ENABLE_CJK_FRIENDLY_EMPHASIS);
185    if math {
186        options.insert(Options::ENABLE_MATH);
187    }
188    options
189}
190
191/// Parse markdown into a typed [`Document`] using the default config.
192///
193/// Equivalent to `parse_with_config(markdown, &ParseConfig::default())`.
194/// This is the entry point for the ~40 in-crate callers that don't need
195/// per-parse configuration (URL resolution tests, frontmatter round-trip
196/// tests, etc.). Production paths that need source-line tracking or
197/// implicit-figure toggling call [`parse_with_config`].
198pub fn parse(markdown: &str) -> Document {
199    parse_with_config(markdown, &ParseConfig::default())
200}
201
202/// Parse markdown into a typed [`Document`].
203///
204/// This is the AST entry point. The input is post-resolve markdown (the
205/// upstream resolve pipeline has already rewritten wikilinks into standard
206/// markdown links with `moss-resolved:` prefixes).
207///
208/// Two-stage parse:
209/// 1. [`extract_shortcodes`] pre-scans for `:::name` blocks, replacing
210///    each with a sentinel HTML comment.
211/// 2. Pulldown-cmark parses the substituted markdown into events; each
212///    sentinel comes back as a `Block::Other` raw HTML.
213/// 3. A final pass walks the AST and substitutes `Block::Other` sentinel
214///    payloads with the corresponding typed [`Block::Shortcode`].
215///
216/// When `config.emit_source_lines` is true, the parser walks events via
217/// `into_offset_iter()` so each top-level block carries the byte offset
218/// of its first event; a [`LineLookup`] converts the offset to a 1-based
219/// line number stored in [`BlockMeta::source_line`].
220pub fn parse_with_config(markdown: &str, config: &ParseConfig) -> Document {
221    parse_document(markdown, config, HeadingIds::Number)
222}
223
224/// Parse a FRAGMENT that will be embedded in some other document's tree —
225/// a `:::grid` cell, a compound-link card's inner blocks, a `:::hero`
226/// overlay — and leave its heading ids holding their bare base slugs.
227///
228/// Duplicate-id numbering is a whole-PAGE decision: the fragment renders into
229/// the same document as the body, so the only counter that can keep every
230/// `id=` unique is the outer parse's. Numbering here as well suffixed twice —
231/// a cell holding two `## Notes` arrived as `notes` / `notes-1`, and the outer
232/// walk then bumped the first to `notes-1` (colliding with the second) and the
233/// second to `notes-1-1`, a shape no slug rule can produce.
234///
235/// The outer [`assign_heading_id_suffixes`] reaches every fragment:
236/// [`collect_heading_id_slots`] is exhaustive over `Block` and descends into
237/// grid cells, hero overlays and link cards. Nested fragments inherit this
238/// entry point, because [`super::shortcode_extract::parse_cell_to_blocks`] is
239/// the only way a cell is parsed at any depth.
240pub(super) fn parse_fragment_with_config(markdown: &str, config: &ParseConfig) -> Document {
241    parse_document(markdown, config, HeadingIds::LeaveBare)
242}
243
244/// Whether a parse owns duplicate-heading-id numbering for its blocks.
245#[derive(Clone, Copy, PartialEq, Eq)]
246enum HeadingIds {
247    /// Top-level document parse: number every id in render order.
248    Number,
249    /// Embedded fragment: the enclosing document's parse numbers these.
250    LeaveBare,
251}
252
253fn parse_document(markdown: &str, config: &ParseConfig, heading_ids: HeadingIds) -> Document {
254    let extraction = extract_shortcodes_with_config(markdown, config);
255
256    // `[![[x.png]]](/url)`: the embed is lifted to a sentinel, restored below.
257    let (source, linked_embeds) =
258        super::linked_embed::substitute(&extraction.markdown_with_placeholders, &extraction.nonce);
259
260    let options = parser_options(config.math);
261
262    // Source-line tracking requires the `into_offset_iter` form of the
263    // parser, which yields (Event, Range<usize>). When tracking is off,
264    // we use the plain iterator (no per-event offset overhead).
265    let (events, offsets): (Vec<Event<'_>>, Vec<Option<std::ops::Range<usize>>>) =
266        if config.emit_source_lines {
267            let mut evs = Vec::new();
268            let mut offs = Vec::new();
269            for (event, range) in Parser::new_ext(&source, options).into_offset_iter() {
270                evs.push(event);
271                offs.push(Some(range));
272            }
273            (evs, offs)
274        } else {
275            let evs: Vec<Event<'_>> = Parser::new_ext(&source, options).collect();
276            let len = evs.len();
277            (evs, vec![None; len])
278        };
279
280    // Build the prefix-sum line table once (only when needed).
281    //
282    // CAVEAT: the markdown that the offsets index into is `source` (the
283    // post-extraction, post-linked-embed-substitution string), NOT the
284    // original `markdown` passed in. Shortcode extraction may rewrite some bytes
285    // into sentinel HTML comments of a different length; line numbers
286    // would be off for blocks following an extracted shortcode if we
287    // built the lookup against the original. We build against the
288    // post-extraction string, so the line numbers match the
289    // post-extraction view — which is what users see in their editor
290    // before shortcode-block lines, and is "close enough" after (the
291    // sentinel preserves one line per extracted block, so line counts
292    // after the extracted block are within one of the source). See the
293    // architecture note in `shortcode_extract.rs` for the placeholder
294    // shape.
295    //
296    // For the source-line-off path, lookup is unused.
297    let line_lookup = if config.emit_source_lines {
298        Some(LineLookup::build(&source, config.source_line_offset))
299    } else {
300        None
301    };
302
303    // Line-tracking context handed to every recursive parser entry; the
304    // Tag::List / Tag::Table arms consult it to annotate per-item / per-row
305    // source lines. `None` when `emit_source_lines` is off; the inner
306    // arms see this as "skip annotation" and emit empty parallel vecs.
307    let line_ctx: Option<LineCtx<'_>> = line_lookup.as_ref().map(|lookup| LineCtx {
308        lookup,
309        offsets: &offsets,
310    });
311
312    let mut blocks = Vec::new();
313    let mut block_meta: Vec<BlockMeta> = Vec::new();
314    let mut i = 0;
315    while i < events.len() {
316        let event_start_idx = i;
317        let (block, advance) = parse_block(&events, i, line_ctx.as_ref());
318        if let Some(b) = block {
319            // Compute source_line from the first event's byte offset, if
320            // we collected offsets and a lookup is in scope.
321            let source_line = match (line_lookup.as_ref(), offsets.get(event_start_idx)) {
322                (Some(lookup), Some(Some(range))) => Some(lookup.line_at(range.start)),
323                _ => None,
324            };
325            blocks.push(b);
326            block_meta.push(BlockMeta { source_line });
327        }
328        i += advance.max(1);
329    }
330
331    // Put the lifted things back: placeholders become typed Shortcodes, `![[…]]`
332    // sentinels their `Inline::Image`. Both BEFORE `assign_heading_id_suffixes`.
333    substitute_shortcode_placeholders(&mut blocks, &extraction.nonce, &extraction.extracted);
334    super::linked_embed::restore(&mut blocks, &linked_embeds, config);
335
336    // Implicit-figure gating: the per-paragraph `try_promote_to_figure`
337    // inside `parse_block_with_tag` always runs (so the figure promotion
338    // happens at parse time inside the Tag::Paragraph arm). When
339    // `config.implicit_figure` is false, we walk the assembled blocks
340    // and "undo" the promotion — converting `Block::Figure { image, ..}`
341    // back to `Block::Paragraph(vec![image])`.
342    //
343    // The unwinding-at-the-end approach was chosen over threading the
344    // flag into `parse_block_with_tag` because the latter would mean
345    // propagating `config` through ~14 inner parser functions whose
346    // signatures are already tight. The unwind is O(N) and only fires
347    // on the rare opt-out path; production keeps the default `true`.
348    if !config.implicit_figure {
349        for block in blocks.iter_mut() {
350            unwrap_implicit_figure(block);
351        }
352    }
353
354    // Apply duplicate-suffix numbering to heading IDs in document order.
355    // Each Tag::Heading arm computes the base slug; this pass disambiguates
356    // collisions across the whole document, matching production's id_counts
357    // HashMap behavior in pipeline.rs::transform_events.
358    //
359    // Skipped for embedded fragments — see [`parse_fragment_with_config`].
360    // The page that hosts them owns the one counter that can keep every id
361    // on the rendered page unique.
362    if heading_ids == HeadingIds::Number {
363        assign_heading_id_suffixes(&mut blocks);
364    }
365
366    let mut doc = Document::from_blocks_with_meta(blocks, block_meta);
367    // Carry the extractor's findings out of the parse. Collected since the
368    // unknown-name fallback landed, dropped here until now.
369    doc.warnings = extraction.warnings;
370
371    // Obsidian parity: a single newline inside a paragraph becomes `<br>`.
372    // Runs last, over the finished tree, because pulldown-cmark 0.13 has no
373    // hard-break option — see `ast/line_breaks.rs` for why a post-parse
374    // transform is the only mechanism and why it is exact.
375    if config.hard_line_breaks {
376        super::line_breaks::apply(&mut doc);
377    }
378
379    doc
380}
381
382/// Recursively undo implicit-figure promotion in `block` and its children.
383///
384/// Called when `ParseConfig::implicit_figure` is false. Walks the block
385/// tree (descending into containers — `BlockQuote`, `Callout`, `List`,
386/// `LinkCard`, `FootnoteDefinition`) and rewrites any `Block::Figure` back
387/// to `Block::Paragraph(vec![image])` with the original alt text preserved.
388/// The caption is discarded (matches the legacy bare-`<img>` shape).
389///
390/// The opt-out is a whole-document setting, so a container that holds
391/// blocks and is NOT listed here silently keeps promoting — the `_ => {}`
392/// below is why adding `Block::FootnoteDefinition` compiled fine while an
393/// opted-out site published a `<figcaption>` in its endnotes. Deliberately
394/// excluded: `Block::Shortcode` (a cell is its own parse and runs this walk
395/// itself) and `Block::Table` (cells are `Vec<Inline>`, never blocks, so
396/// nothing there can be a `Figure`). Every other block-holding variant
397/// belongs in the arm below.
398///
399/// A figure the author explicitly ASKED for stays. `![pic|55%](x.png)` and
400/// `![[x.png|wide]]` need the `<figure>` to hold their `style="width:…"` /
401/// `data-width=` / align class, and none of that is implicit — the author
402/// typed it. Only an undecorated figure is the promotion this pass undoes.
403/// See [`is_implicit_figure`].
404pub(crate) fn unwrap_implicit_figure(block: &mut Block) {
405    // Replace this block if it's an undecorated Figure.
406    if is_implicit_figure(block) {
407        if let Block::Figure { image, .. } = block {
408            let img = std::mem::replace(
409                image,
410                Inline::Text(String::new()), // placeholder, overwritten below
411            );
412            *block = Block::Paragraph(vec![img]);
413        }
414        return;
415    }
416    // Recurse into containers.
417    match block {
418        Block::BlockQuote(children)
419        | Block::Callout { children, .. }
420        | Block::LinkCard { children, .. }
421        | Block::FootnoteDefinition { children, .. } => {
422            for child in children.iter_mut() {
423                unwrap_implicit_figure(child);
424            }
425        }
426        Block::List { items, .. } => {
427            for item in items.iter_mut() {
428                for child in item.iter_mut() {
429                    unwrap_implicit_figure(child);
430                }
431            }
432        }
433        // Grid cells and a hero overlay. At parse time this is a no-op — a
434        // cell is its own parse and has already run this walk — but the
435        // post-dispatch caller needs it: `dispatch_wikilink_embeds` descends
436        // into shortcode bodies (see `dispatch_in_shortcode`), so a
437        // `![[tile.png]]` in a grid cell becomes a figure there and nowhere
438        // else. The walk is idempotent, so running it twice costs a visit.
439        Block::Shortcode(sc) => match sc {
440            Shortcode::Grid(args) => {
441                for cell in args.cells.iter_mut() {
442                    for child in cell.iter_mut() {
443                        unwrap_implicit_figure(child);
444                    }
445                }
446            }
447            Shortcode::Hero(args) => {
448                for child in args.overlay.iter_mut() {
449                    unwrap_implicit_figure(child);
450                }
451            }
452            Shortcode::Subscribe(_)
453            | Shortcode::Buttons(_)
454            | Shortcode::Gallery(_)
455            | Shortcode::Recent(_)
456            | Shortcode::Apply(_) => {}
457        },
458        _ => {}
459    }
460}
461
462/// Is this block a figure moss decided on by itself?
463///
464/// True only for a `Block::Figure` carrying none of the display parameters an
465/// author can ask for. `|55%`, `|wide`, `|left` and a class all live ON the
466/// figure, so unwrapping one of those would discard what the author typed —
467/// the opt-out undoes an inference, never an instruction.
468fn is_implicit_figure(block: &Block) -> bool {
469    matches!(
470        block,
471        Block::Figure {
472            width: None,
473            align: None,
474            class_names,
475            img_style: None,
476            ..
477        } if class_names.is_empty()
478    )
479}
480
481/// Undo implicit-figure promotion across a whole document.
482///
483/// Public because it runs twice, at times only the caller knows.
484/// `parse_with_config` runs it on the blocks it just built; the build pipeline
485/// runs it again after `dispatch_wikilink_embeds`, which mints `Block::Figure`
486/// nodes of its own long after the parser finished. Without that second call
487/// `implicit_figure = false` was honoured for `![alt](x.png)` and ignored for
488/// `![[x.png]]` — one intent, two boxes, and any theme rule keyed on
489/// `.moss-image` reached only the wikilink half of a page's images.
490pub fn unwrap_implicit_figures(doc: &mut Document) {
491    for block in doc.blocks.iter_mut() {
492        unwrap_implicit_figure(block);
493    }
494}
495
496/// Bundle of borrowed line-tracking state threaded through recursive
497/// parser entries. Constructed once per `parse_with_config` when
498/// `emit_source_lines` is true; `None` everywhere else.
499///
500/// `parse_block` / `parse_block_with_tag` consult `line_at_event` to
501/// annotate per-`<li>` and per-`<tr>` source lines. The outer
502/// top-level-block source line is computed at the parse loop itself
503/// (already in place), not here.
504struct LineCtx<'a> {
505    lookup: &'a LineLookup,
506    offsets: &'a [Option<std::ops::Range<usize>>],
507}
508
509impl<'a> LineCtx<'a> {
510    /// 1-based source line of the event at `event_index`, or `None` if
511    /// the offset is missing (defensive — shouldn't happen when the
512    /// parser is operating with `emit_source_lines: true`).
513    fn line_at_event(&self, event_index: usize) -> Option<usize> {
514        match self.offsets.get(event_index) {
515            Some(Some(range)) => Some(self.lookup.line_at(range.start)),
516            _ => None,
517        }
518    }
519}
520
521/// Prefix-sum line-number lookup for byte offsets in a source string.
522///
523/// Built once per parse (when `emit_source_lines` is on). Stores the byte
524/// offset of every `\n` in `source`; `line_at(offset)` returns the
525/// 1-based line number containing that offset via binary search.
526///
527/// Equivalent (slower) form: `source[..offset].matches('\n').count() + 1`
528/// — O(N) per call vs. O(log N) here. For documents with ~25 blocks the
529/// difference is negligible, but the binary-search form is the canonical
530/// pattern and is the cheaper hot-path shape.
531struct LineLookup {
532    /// Byte offsets of every `\n` in the source. Sorted ascending by
533    /// construction. `newline_offsets[i]` is the byte index of the i-th
534    /// newline (0-based).
535    newline_offsets: Vec<usize>,
536    /// Added to every `line_at` result so body-relative lines become
537    /// raw-file lines (the frontmatter line count). See
538    /// `ParseConfig::source_line_offset`.
539    line_offset: usize,
540}
541
542impl LineLookup {
543    fn build(source: &str, line_offset: usize) -> Self {
544        let mut newline_offsets = Vec::new();
545        for (i, b) in source.bytes().enumerate() {
546            if b == b'\n' {
547                newline_offsets.push(i);
548            }
549        }
550        Self {
551            newline_offsets,
552            line_offset,
553        }
554    }
555
556    /// 1-based line number containing `byte_offset`, plus `line_offset`.
557    ///
558    /// Offset 0 (before any newline) → line 1. After the first newline →
559    /// line 2. Etc. Offsets past the end of the source clamp to the last
560    /// line + 1. `line_offset` (the frontmatter line count) is added so the
561    /// result is a raw-file line, matching the editor's `doc.lineAt`.
562    fn line_at(&self, byte_offset: usize) -> usize {
563        // Find the number of newlines strictly before `byte_offset`.
564        // That count + 1 is the 1-based line number.
565        let body_line = match self.newline_offsets.binary_search(&byte_offset) {
566            // Exact match: offset IS a newline byte; the newline belongs
567            // to the line that ENDS at it, so line number = idx + 1.
568            // (The next byte starts line idx + 2; this matches the legacy
569            // count-and-add-1 semantics, which counts newlines BEFORE the
570            // offset.)
571            Ok(idx) => idx + 1,
572            Err(idx) => idx + 1,
573        };
574        body_line + self.line_offset
575    }
576}
577
578/// Walk top-level blocks; replace any `Block::Other` whose payload is a
579/// `<!--MOSS_SC_{nonce}_{index}-->` sentinel with the corresponding typed
580/// [`Block::Shortcode`].
581fn substitute_shortcode_placeholders(
582    blocks: &mut Vec<Block>,
583    nonce: &str,
584    extracted: &[ExtractedShortcode],
585) {
586    for block in blocks.iter_mut() {
587        if let Block::Other(html) = block {
588            if let Some(index) = parse_placeholder(nonce, html) {
589                if let Some(entry) = extracted.iter().find(|e| e.index == index) {
590                    *block = Block::Shortcode(entry.shortcode.clone());
591                }
592            }
593        }
594        // Future: descend into BlockQuote / List items / Callouts when
595        // shortcodes inside those constructs are modeled. Phase B Tasks
596        // 7-10 only need top-level shortcodes.
597    }
598}
599
600/// Parse one block-level construct starting at `events[start]`. Returns
601/// the parsed block (or `None` if `events[start]` was a closing tag /
602/// stray event we skip) and how many events to advance.
603///
604/// `line_ctx` carries the optional line-tracking context for per-item
605/// (`<li>`) and per-row (`<tr>`) source-line annotation; threaded through
606/// to `parse_block_with_tag`.
607fn parse_block(
608    events: &[Event<'_>],
609    start: usize,
610    line_ctx: Option<&LineCtx<'_>>,
611) -> (Option<Block>, usize) {
612    // Block-level dispatch. pulldown always wraps loose inlines (math
613    // included) in Tag::Paragraph at top level, so no math event ever reaches
614    // this match; the paragraph's inlines are collected by the math-aware
615    // parse_inline. Pinned by `display_math_block_survives_on_its_own_lines`.
616    // allow:math-events-ignored — see above.
617    match &events[start] {
618        Event::Start(tag) => parse_block_with_tag(events, start, tag, line_ctx),
619        Event::Text(_) | Event::Code(_) | Event::Html(_) | Event::SoftBreak | Event::HardBreak => {
620            // Top-level stray inlines: pulldown-cmark always wraps these in
621            // `Tag::Paragraph` at top level, so this branch is dead in practice.
622            //
623            // The tight-list-item case where the inlines are emitted directly
624            // (no Tag::Paragraph wrap) was the load-bearing reason this branch
625            // looked relevant; PR0.6 moved that responsibility into
626            // `collect_item_blocks`, which synthesizes a Block::Paragraph for
627            // stray inlines inside Tag::Item. See parser.rs's collect_item_blocks
628            // helper.
629            (None, 1)
630        }
631        Event::End(_) => (None, 1),
632        Event::Rule => (Some(Block::ThematicBreak), 1),
633        _ => (None, 1),
634    }
635}
636
637fn parse_block_with_tag(
638    events: &[Event<'_>],
639    start: usize,
640    tag: &Tag<'_>,
641    line_ctx: Option<&LineCtx<'_>>,
642) -> (Option<Block>, usize) {
643    match tag {
644        Tag::Heading { level, .. } => {
645            let (children, end) = collect_inlines_until(events, start + 1, |e| {
646                matches!(e, Event::End(TagEnd::Heading(_)))
647            });
648            let level_num = match level {
649                HeadingLevel::H1 => 1,
650                HeadingLevel::H2 => 2,
651                HeadingLevel::H3 => 3,
652                HeadingLevel::H4 => 4,
653                HeadingLevel::H5 => 5,
654                HeadingLevel::H6 => 6,
655            };
656            // Phase 4 PR2: compute the heading-anchor base slug from the
657            // text/code content between Start(Heading) and End(Heading),
658            // matching production's transform_events behavior. Inline HTML
659            // (`<br>` etc.), images, and link href text are NOT included —
660            // only Event::Text and Event::Code. The post-parse
661            // `assign_heading_id_suffixes` pass disambiguates collisions.
662            let heading_text = crate::heading::text::events_to_text(events, start + 1, end);
663            let base_slug = obsidian_heading_anchor(&heading_text);
664            (
665                Some(Block::Heading {
666                    level: level_num,
667                    children,
668                    id: Some(base_slug),
669                }),
670                end - start + 1,
671            )
672        }
673        Tag::Paragraph => {
674            let (children, end) = collect_inlines_until(events, start + 1, |e| {
675                matches!(e, Event::End(TagEnd::Paragraph))
676            });
677            // Phase 4 PR3 (2026-05-27): detect image-only paragraphs and
678            // promote to `Block::Figure`. See shape-spec § 1 detection
679            // rule: exactly one `Inline::Image` plus any number of
680            // whitespace-only `Inline::Text` / `Inline::LineBreak`
681            // siblings qualifies. Caption defaults to the image's alt
682            // text (mirroring transform_events' implicit-figure path);
683            // empty alt yields `caption: None` so no `<figcaption>` is
684            // emitted.
685            //
686            // A paragraph with image+prose (e.g. `![img](src) caption text`)
687            // does NOT qualify; it stays as `Block::Paragraph`. This is the
688            // critical regression guard — see PR1 v2 (commit 71c657af3)
689            // for the analogous shape decision at the inline image hook
690            // level: inline images use `MarkdownInline` (no figure wrap);
691            // only the standalone figure case here uses the figure wrap.
692            let block = match try_promote_to_figure(children, events, start) {
693                Ok(figure) => figure,
694                Err(original_inlines) => Block::Paragraph(original_inlines),
695            };
696            (Some(block), end - start + 1)
697        }
698        Tag::CodeBlock(kind) => {
699            let lang = match kind {
700                pulldown_cmark::CodeBlockKind::Fenced(s) if !s.is_empty() => Some(s.to_string()),
701                _ => None,
702            };
703            let mut value = String::new();
704            let mut i = start + 1;
705            while i < events.len() {
706                match &events[i] {
707                    // allow:math-events-ignored — pulldown does not parse math
708                    // inside a code fence, so it emits no math event here;
709                    // ```\n$x^2$\n``` is byte-identical at math on and off.
710                    Event::End(TagEnd::CodeBlock) => break,
711                    Event::Text(t) => value.push_str(t),
712                    _ => {}
713                }
714                i += 1;
715            }
716            (Some(Block::CodeBlock { lang, value }), i - start + 1)
717        }
718        Tag::BlockQuote(_) => {
719            // Phase 4 PR4: detect Obsidian-style callouts. A blockquote
720            // whose first paragraph's leading text matches `[!<kind>]`
721            // (with optional `+`/`-` foldable suffix and optional
722            // inline title) promotes to `Block::Callout`. Otherwise it
723            // stays a plain blockquote. See shape-spec § 1.
724            //
725            // Detection works on the EVENT stream (not the parsed
726            // children) because pulldown-cmark's SoftBreak events
727            // become `Inline::Text("\n")` during inline parsing
728            // (PR4.5 aligned to CommonMark spec — see
729            // `parse_inline` SoftBreak handling). Working on events
730            // preserves the structural break before inline
731            // collapse, which is what the marker-line-vs-body-line
732            // boundary check needs.
733            match detect_and_assemble_callout(events, start + 1, line_ctx) {
734                Some((block, body_end)) => (Some(block), body_end - start + 1),
735                None => {
736                    let (children, end) = collect_blocks_until(events, start + 1, line_ctx, |e| {
737                        matches!(e, Event::End(TagEnd::BlockQuote(_)))
738                    });
739                    (Some(Block::BlockQuote(children)), end - start + 1)
740                }
741            }
742        }
743        Tag::List(start_num) => {
744            let ordered = start_num.is_some();
745            // Preserve explicit ordered-list start number when it's not
746            // the implicit default `1`. `3. foo` → `Some(3)` so the
747            // renderer can emit `<ol start="3">`. pulldown-cmark
748            // normalizes `1. foo` to `Some(1)`, which we collapse to
749            // `None` because `<ol>` and `<ol start="1">` are
750            // semantically identical and we prefer the cleaner attr-free
751            // shape for the common case. Bound name is `list_start` to
752            // avoid shadowing the outer `start: usize` event-index
753            // parameter.
754            let list_start = match start_num {
755                Some(n) if *n != 1 => Some(*n),
756                _ => None,
757            };
758            let mut items: Vec<Vec<Block>> = Vec::new();
759            // Parallel-to-`items` per-`<li>` source-line annotations.
760            // Empty when `line_ctx` is None; otherwise tracks each
761            // `Event::Start(Tag::Item)`'s byte offset → line. The renderer
762            // emits `<li data-source-line="N">` for entries that are Some.
763            let mut item_source_lines: Vec<Option<usize>> = Vec::new();
764            let track_lines = line_ctx.is_some();
765            let mut i = start + 1;
766            while i < events.len() {
767                match &events[i] {
768                    // allow:math-events-ignored — structural walk that only
769                    // locates item boundaries; every item's content is parsed
770                    // by the math-aware collect_item_blocks. Pinned by
771                    // `math_survives_inside_list_items`.
772                    Event::End(TagEnd::List(_)) => break,
773                    Event::Start(Tag::Item) => {
774                        if track_lines {
775                            item_source_lines.push(line_ctx.and_then(|ctx| ctx.line_at_event(i)));
776                        }
777                        let (item_blocks, end) = collect_item_blocks(events, i + 1, line_ctx);
778                        items.push(item_blocks);
779                        i = end + 1;
780                    }
781                    _ => i += 1,
782                }
783            }
784            (
785                Some(Block::List {
786                    ordered,
787                    start: list_start,
788                    items,
789                    item_source_lines,
790                }),
791                i - start + 1,
792            )
793        }
794        Tag::Table(column_alignments) => {
795            // GFM per-column alignment (`|:--|`, `|:-:|`, `|--:|`). Kept
796            // source-faithful in the AST; numeric auto-alignment for unaligned
797            // columns is resolved later, at render time.
798            //
799            // pulldown emits a full-width `Vec` of `Alignment::None` for a bare
800            // `|---|` table. Normalize that to an empty vec so an unaligned
801            // table carries no `alignments` — which keeps serialized ASTs
802            // byte-stable (via `skip_serializing_if`) and lets the renderer read
803            // "empty ⇒ every column auto-detects".
804            let alignments: Vec<ColumnAlignment> = if column_alignments
805                .iter()
806                .all(|a| matches!(a, pulldown_cmark::Alignment::None))
807            {
808                Vec::new()
809            } else {
810                column_alignments
811                    .iter()
812                    .map(|a| match a {
813                        pulldown_cmark::Alignment::None => ColumnAlignment::None,
814                        pulldown_cmark::Alignment::Left => ColumnAlignment::Left,
815                        pulldown_cmark::Alignment::Center => ColumnAlignment::Center,
816                        pulldown_cmark::Alignment::Right => ColumnAlignment::Right,
817                    })
818                    .collect()
819            };
820            let mut header: Vec<Vec<Inline>> = Vec::new();
821            let mut rows: Vec<Vec<Vec<Inline>>> = Vec::new();
822            // Per-`<tr>` source-line tracking. `header_source_line` is the
823            // `<thead><tr>` line; `row_source_lines` is parallel to `rows`.
824            // Both stay empty / None when `line_ctx` is None.
825            let mut header_source_line: Option<usize> = None;
826            let mut row_source_lines: Vec<Option<usize>> = Vec::new();
827            let track_lines = line_ctx.is_some();
828            let mut current_row: Vec<Vec<Inline>> = Vec::new();
829            let mut in_head = false;
830            let mut in_body_row = false;
831            let mut i = start + 1;
832            while i < events.len() {
833                match &events[i] {
834                    // allow:math-events-ignored — structural walk over table
835                    // section/row/cell boundaries; cell content is collected by
836                    // the math-aware collect_inlines_until. Pinned by
837                    // `math_survives_inside_a_table_cell`.
838                    Event::End(TagEnd::Table) => break,
839                    Event::Start(Tag::TableHead) => {
840                        in_head = true;
841                        // pulldown-cmark does NOT emit `Tag::TableRow` for the
842                        // header row — it goes straight from `Tag::TableHead`
843                        // to the cells. So we anchor the header `<tr>` line
844                        // to the `TableHead` event itself (line of the
845                        // markdown `| h |` row).
846                        if track_lines {
847                            header_source_line = line_ctx.and_then(|ctx| ctx.line_at_event(i));
848                        }
849                        i += 1;
850                    }
851                    Event::End(TagEnd::TableHead) => {
852                        in_head = false;
853                        i += 1;
854                    }
855                    Event::Start(Tag::TableRow) => {
856                        in_body_row = true;
857                        current_row = Vec::new();
858                        if track_lines {
859                            // pulldown-cmark only emits `TableRow` for body
860                            // rows (header cells live directly inside
861                            // `TableHead`). Always push to body lines here.
862                            row_source_lines.push(line_ctx.and_then(|ctx| ctx.line_at_event(i)));
863                        }
864                        i += 1;
865                    }
866                    Event::End(TagEnd::TableRow) => {
867                        if in_body_row {
868                            rows.push(std::mem::take(&mut current_row));
869                            in_body_row = false;
870                        }
871                        i += 1;
872                    }
873                    Event::Start(Tag::TableCell) => {
874                        let (cell_inlines, end) = collect_inlines_until(events, i + 1, |e| {
875                            matches!(e, Event::End(TagEnd::TableCell))
876                        });
877                        if in_head {
878                            header.push(cell_inlines);
879                        } else {
880                            current_row.push(cell_inlines);
881                        }
882                        i = end + 1;
883                    }
884                    _ => i += 1,
885                }
886            }
887            (
888                Some(Block::Table {
889                    header,
890                    rows,
891                    alignments,
892                    header_source_line,
893                    row_source_lines,
894                }),
895                i - start + 1,
896            )
897        }
898        Tag::HtmlBlock => {
899            let mut html = String::new();
900            let mut i = start + 1;
901            while i < events.len() {
902                match &events[i] {
903                    // allow:math-events-ignored — a raw HTML block is passed
904                    // through verbatim; pulldown emits only Html/Text inside
905                    // one, never a math event.
906                    Event::End(TagEnd::HtmlBlock) => break,
907                    Event::Html(s) | Event::Text(s) => html.push_str(s),
908                    _ => {}
909                }
910                i += 1;
911            }
912            (Some(Block::Other(html)), i - start + 1)
913        }
914        // `[^label]: body`. pulldown emits this wherever the author wrote it,
915        // including nested inside a blockquote or list item, so this arm is
916        // reached from every block collector. Hoisting to the endnote section
917        // is the renderer's job (ADR-035).
918        Tag::FootnoteDefinition(label) => {
919            let (children, end) = collect_blocks_until(events, start + 1, line_ctx, |e| {
920                matches!(e, Event::End(TagEnd::FootnoteDefinition))
921            });
922            let label = label.to_string();
923            (
924                Some(Block::FootnoteDefinition { label, children }),
925                end - start + 1,
926            )
927        }
928        // Unmodeled containers: skip to End and emit nothing. The events
929        // inside are dropped — anything moss cares about should be modeled
930        // explicitly.
931        _ => (None, 1),
932    }
933}
934
935/// Decide whether a paragraph's inlines qualify for promotion to
936/// [`Block::Figure`]. Per shape-spec § 1: exactly one [`Inline::Image`]
937/// plus any number of whitespace-only [`Inline::Text`] /
938/// [`Inline::LineBreak`] siblings. Any other inline shape (Emphasis,
939/// Strong, Link, Code, non-whitespace Text, …) disqualifies the
940/// paragraph and it stays as [`Block::Paragraph`].
941///
942/// **Empty-alt guard:** if the matched image has an empty alt (decorative
943/// image), the paragraph is NOT promoted. This mirrors production's
944/// `transform_events` implicit-figure pass which gates on non-empty alt
945/// (a `<figure>` whose caption duplicates a missing alt would be useless
946/// for assistive tech and adds visual noise). The empty-alt image stays
947/// as `<p><img></p>`, matching the production byte shape for the same
948/// input — verified via the parity probe's `other` category on 刘果 CJK
949/// fixtures (image-only paragraphs with empty alt).
950///
951/// On qualification, returns `Ok(Block::Figure { image, caption })`. For a
952/// standard-markdown image the caption renders the alt as INLINE MARKDOWN
953/// (option B, matching Pandoc's implicit-figure model): `*em*`, links,
954/// `` `code` `` and typeset math survive, built from the image's parsed
955/// inline children (`events`/`para_start` re-parse the alt event span). The
956/// `alt=` attribute stays the flat plain-text source. A plain-text alt (no
957/// inline markup) keeps the flat single-[`Inline::Text`] caption, byte-
958/// identical to before, so only captions that actually carry markup change.
959///
960/// On disqualification, returns `Err(original_inlines)` so the caller
961/// can fall back to constructing the standard `Block::Paragraph` without
962/// re-walking events.
963fn try_promote_to_figure(
964    mut inlines: Vec<Inline>,
965    events: &[Event<'_>],
966    para_start: usize,
967) -> Result<Block, Vec<Inline>> {
968    let mut image_count = 0;
969    for inline in &inlines {
970        match inline {
971            Inline::Image { .. } => image_count += 1,
972            Inline::Text(s) if s.trim().is_empty() => {} // whitespace OK
973            Inline::LineBreak => {}                      // line break OK
974            _ => return Err(inlines),
975        }
976    }
977    if image_count != 1 {
978        return Err(inlines);
979    }
980
981    // Non-image wikilink embeds never promote. pulldown-cmark parses every
982    // `![[…]]` as an Image event, but Figure is an image concept: a video /
983    // pdf / audio wikilink promoted here bypasses `dispatch_wikilink_embeds`
984    // (which only dispatches Paragraph-shaped lone embeds), so its typed
985    // synthesizer never runs and the page ships `<figure><img src="clip.mov">`
986    // — a broken image. The gate keys off the same classifier the dispatcher
987    // uses (`resolve::ext_kind`), so parse-time promotion and dispatch-time
988    // synthesis cannot disagree about who owns the block. Extension-less
989    // wikilinks (`![[draft|55%]]`) also stay Paragraph: only the with-graph
990    // dispatcher can resolve their kind, and committing them to an image
991    // Figure here would be a guess.
992    if let Some(Inline::Image {
993        src,
994        is_wikilink: true,
995        ..
996    }) = inlines.iter().find(|i| matches!(i, Inline::Image { .. }))
997    {
998        let dest = match src {
999            Url::Unresolved(s) => s.as_str(),
1000            Url::Resolved(r) => r.href.as_str(),
1001        };
1002        let ext = crate::path_ext::path_extension_lower(dest);
1003        if !matches!(
1004            crate::resolve::ext_kind::reference_kind_for_ext(&ext),
1005            crate::resolve::ext_kind::ExtKind::Image
1006        ) {
1007            return Err(inlines);
1008        }
1009    }
1010
1011    // Probe the width + remaining alt on a BORROW first, so the empty-alt
1012    // guard can still return `Err(inlines)` with the original whitespace /
1013    // line-break siblings intact (production `<p><img>…</p>` parity).
1014    //
1015    // Standard-markdown images carry no structured pothole — a `|55%`/`|wide`
1016    // width rides in the raw alt text. Split it out so the figure carries the
1017    // width and the caption is the remaining alt.
1018    //
1019    // Wikilink images carry the raw pothole in `wikilink_pothole`; named width
1020    // tokens are already classified by `parse_pothole_params` (WidthToken arm),
1021    // but a content-relative percent (`55%`) is classified as `Alias` and
1022    // lands in `alt` (or is stripped from alt by our parser-level Alias fix).
1023    // Recover the percent from `wikilink_pothole` directly so the figure
1024    // carries the width on both the with-graph path (wikilink_dispatch) and
1025    // the no-graph path (fragment/test render with no ContentGraph).
1026    let mut figure_width: Option<String> = None;
1027    let mut rewritten_alt: Option<String> = None;
1028    match inlines.iter().find(|i| matches!(i, Inline::Image { .. })) {
1029        Some(Inline::Image {
1030            alt,
1031            is_wikilink: false,
1032            ..
1033        }) => {
1034            let (rest_alt, w) = crate::media::split_alt_width(alt);
1035            if w.is_some() {
1036                figure_width = w;
1037                rewritten_alt = Some(rest_alt);
1038            }
1039        }
1040        Some(Inline::Image {
1041            is_wikilink: true,
1042            wikilink_pothole,
1043            ..
1044        }) => {
1045            // Recover a content-relative percent from the raw pothole.
1046            // Named tokens are already absent from `alt` (WidthToken arm in
1047            // parse_pothole_params clears them); only the percent case falls
1048            // through as `Alias` and still needs extracting.
1049            // Sync: the with-graph twin lives in resolve/wikilink_dispatch.rs
1050            // (image branch, ~line 565) — both split width via media::split_alt_width.
1051            if let Some(pothole) = wikilink_pothole {
1052                let (remaining, w) = crate::media::split_alt_width(pothole);
1053                if w.is_some() {
1054                    figure_width = w;
1055                    // The remaining pothole (caption after stripping the %) is
1056                    // the intended caption; propagate it as the rewritten alt if
1057                    // the current alt is empty (percent-only pothole) or already
1058                    // stripped to the same value.
1059                    rewritten_alt = Some(remaining);
1060                }
1061            }
1062        }
1063        _ => {}
1064    }
1065
1066    // The figure's caption text is the effective alt (width-stripped if a
1067    // width was present, else the raw alt), trimmed.
1068    let raw_alt = inlines.iter().find_map(|i| match i {
1069        Inline::Image { alt, .. } => Some(alt.as_str()),
1070        _ => None,
1071    });
1072    let alt_text = rewritten_alt
1073        .as_deref()
1074        .or(raw_alt)
1075        .map(|s| s.trim().to_string())
1076        .unwrap_or_default();
1077
1078    // Empty-alt guard: refuse to promote a decorative image (preserve the
1079    // original `<p><img></p>` shape with its whitespace siblings) — UNLESS it
1080    // carries a width, which needs a figure to hold the inline
1081    // `style="width:NN%"` / `data-width=`.
1082    if alt_text.is_empty() && figure_width.is_none() {
1083        return Err(inlines);
1084    }
1085
1086    // Extract the single image, applying the width-stripped alt if any.
1087    let Some(image_pos) = inlines.iter().position(|i| matches!(i, Inline::Image { .. }))
1088    else {
1089        // `image_count == 1` was checked above, so this never fires. Handing the
1090        // inlines back is this function's own "can't promote" path — a better
1091        // failure than a panic if that invariant ever stops holding.
1092        return Err(inlines);
1093    };
1094    let mut image = inlines.swap_remove(image_pos);
1095    if let (Some(new_alt), Inline::Image { alt, .. }) = (rewritten_alt, &mut image) {
1096        *alt = new_alt;
1097    }
1098
1099    // Caption. Empty alt yields None so no empty <figcaption> is emitted.
1100    // Otherwise, for a standard-markdown image, render the alt as inline
1101    // markdown (option B) — `*em*`, links, `` `code` ``, typeset math — built
1102    // from the image's parsed inline children. A wikilink image keeps its
1103    // flat pothole-derived caption (its alias is a literal string, not
1104    // markdown), and a plain-text alt keeps the flat single-Text caption so
1105    // the byte shape is unchanged for the common case.
1106    let caption = if alt_text.is_empty() {
1107        None
1108    } else {
1109        Some(build_caption_inlines(
1110            &image,
1111            events,
1112            para_start,
1113            alt_text,
1114            figure_width.is_some(),
1115        ))
1116    };
1117
1118    Ok(Block::Figure {
1119        image,
1120        caption,
1121        width: figure_width,
1122        align: None,
1123        class_names: Vec::new(),
1124        img_style: None,
1125    })
1126}
1127
1128/// Build the implicit-figure caption inlines for the promoted image.
1129///
1130/// Option B (matching Pandoc's implicit-figure model): a standard-markdown
1131/// image's caption is the alt CONTENT parsed as inline markdown — the typed
1132/// `Emphasis` / `Link` / `Code` / math nodes from the image's own event
1133/// span — so the renderer's hook-aware inline path (`render_inlines`)
1134/// emits `<em>`, `<a>`, and typeset math in the `<figcaption>`. The
1135/// `alt=` attribute (the `Inline::Image.alt` string) is untouched: it stays
1136/// the flat plain-text source (math as `$…$`) for assistive tech and
1137/// blocked-image fallback.
1138///
1139/// Falls back to the flat single-`Inline::Text` caption (byte-identical to
1140/// the pre-option-B shape) when:
1141/// - the image is a wikilink embed — its pothole alias is a literal
1142///   caption string by grammar, not markdown; and
1143/// - a width token was split out of the alt (`![cap|50%](p)`) — the raw
1144///   event span still contains the `|50%` text, so re-parsing it would
1145///   leak the width token into the caption.
1146fn build_caption_inlines(
1147    image: &Inline,
1148    events: &[Event<'_>],
1149    para_start: usize,
1150    alt_text: String,
1151    has_width: bool,
1152) -> Vec<Inline> {
1153    let is_wikilink = matches!(
1154        image,
1155        Inline::Image {
1156            is_wikilink: true,
1157            ..
1158        }
1159    );
1160    if is_wikilink || has_width {
1161        return vec![Inline::Text(alt_text)];
1162    }
1163
1164    // Locate the image's own event span inside the paragraph:
1165    // Start(Tag::Image) … End(TagEnd::Image). The promotion invariant
1166    // guarantees exactly one image among the paragraph's inlines, so the
1167    // FIRST Start(Tag::Image) after `para_start` is that image's own start.
1168    //
1169    // Below, `collect_inlines_until` stops at the first End(TagEnd::Image)
1170    // its `is_end` check observes — but for a nested image
1171    // (`![a ![b](inner.png) c](outer.png)`, valid CommonMark) that is never
1172    // the inner image's own End: `parse_inline`'s `Tag::Image` arm
1173    // depth-tracks and fully consumes a nested inner image — including its
1174    // matching End — before returning control to this loop, the same way it
1175    // builds the depth-tracked `Inline::Image.alt` string. So the first End
1176    // this loop's `is_end` check actually sees is the OUTER image's own
1177    // close, and both surfaces (the flat alt string and this re-parsed
1178    // caption) agree on the span.
1179    let mut img_children_start: Option<usize> = None;
1180    let mut i = para_start + 1;
1181    while i < events.len() {
1182        // This arm set only LOCATES the image span (routes on event kind:
1183        // where Start(Image) is); it builds no output. The alt payload, math
1184        // included, is collected right below by the math-aware
1185        // collect_inlines_until/parse_inline, pinned by
1186        // implicit_figure_caption_carries_link_and_math_nodes.
1187        // allow:math-events-ignored — span locator, payload survives below.
1188        match &events[i] {
1189            Event::Start(Tag::Image { .. }) => {
1190                img_children_start = Some(i + 1);
1191                break;
1192            }
1193            Event::End(TagEnd::Paragraph) => break,
1194            _ => {}
1195        }
1196        i += 1;
1197    }
1198    let Some(children_start) = img_children_start else {
1199        // Defensive: no image span found (should be unreachable given the
1200        // promotion invariant) — keep the flat caption rather than guess.
1201        return vec![Inline::Text(alt_text)];
1202    };
1203
1204    // Re-parse the alt event span through the SAME inline machinery as body
1205    // text, so `*em*` → Inline::Emphasis, `[l](/x)` → Inline::Link, and
1206    // `$x^2$` → the math Inline::Other node (which the renderer routes
1207    // through PipelineHooks::render_math for typesetting).
1208    let (mut caption, _end) = collect_inlines_until(events, children_start, |e| {
1209        matches!(e, Event::End(TagEnd::Image))
1210    });
1211
1212    // A plain-text alt (every child is bare Text) keeps the flat trimmed
1213    // single-Text caption — byte-identical to the pre-option-B shape, so
1214    // only captions that actually carry markup change output.
1215    if caption.iter().all(|c| matches!(c, Inline::Text(_))) {
1216        return vec![Inline::Text(alt_text)];
1217    }
1218
1219    // Trim the caption edges the way the flat path's `.trim()` did: leading
1220    // whitespace off the first Text node, trailing off the last, dropping
1221    // nodes that become empty.
1222    if let Some(Inline::Text(first)) = caption.first_mut() {
1223        *first = first.trim_start().to_string();
1224        if first.is_empty() {
1225            caption.remove(0);
1226        }
1227    }
1228    if let Some(Inline::Text(last)) = caption.last_mut() {
1229        *last = last.trim_end().to_string();
1230        if last.is_empty() {
1231            caption.pop();
1232        }
1233    }
1234    if caption.is_empty() {
1235        // Defensive: markup collapsed to nothing — fall back to the flat
1236        // alt so we never emit an empty <figcaption>.
1237        return vec![Inline::Text(alt_text)];
1238    }
1239    caption
1240}
1241
1242/// Collect a contiguous run of inline events into `Vec<Inline>`. Stops
1243/// when `is_end(event)` returns true or events run out. Returns the
1244/// collected inlines and the end-event index.
1245fn collect_inlines_until<F>(events: &[Event<'_>], start: usize, is_end: F) -> (Vec<Inline>, usize)
1246where
1247    F: Fn(&Event<'_>) -> bool,
1248{
1249    let mut out: Vec<Inline> = Vec::new();
1250    let mut i = start;
1251    while i < events.len() {
1252        if is_end(&events[i]) {
1253            return (out, i);
1254        }
1255        let (inline, advance) = parse_inline(events, i);
1256        if let Some(node) = inline {
1257            out.push(node);
1258        }
1259        i += advance.max(1);
1260    }
1261    (out, i)
1262}
1263
1264/// Parse one inline construct starting at `events[start]`.
1265fn parse_inline(events: &[Event<'_>], start: usize) -> (Option<Inline>, usize) {
1266    match &events[start] {
1267        Event::Text(t) => (Some(Inline::Text(t.to_string())), 1),
1268        Event::Code(c) => (Some(Inline::Code(c.to_string())), 1),
1269        // Phase 4 PR4.5 (2026-05-28): match pulldown-cmark's `push_html`
1270        // byte shape — SoftBreak emits `\n` between inline siblings, not a
1271        // space. The space form was a long-standing AST quirk surfaced
1272        // by Grid cells now flowing through the AST renderer; production
1273        // baselines (chps-site, SoCiviC, snapshot fixtures) preserve the
1274        // newline (e.g. `Flamboyan Theater · The Clemente\n107 Suffolk
1275        // Street`). Aligning here closes one row of the parity probe's
1276        // `whitespace_attribute_order` category.
1277        Event::SoftBreak => (Some(Inline::Text("\n".to_string())), 1),
1278        Event::HardBreak => (Some(Inline::LineBreak), 1),
1279        Event::Html(s) | Event::InlineHtml(s) => (Some(Inline::Other(s.to_string())), 1),
1280        // Math (ADR-030). Both are LEAF inline events carrying the raw TeX.
1281        // These arms are load-bearing: without them the two catch-alls below
1282        // return `(None, 1)` and every equation is silently deleted from the
1283        // document (`Energy $E = mc^2$.` → `<p>Energy .</p>`).
1284        //
1285        // P1 has no typesetting engine, so math renders as its own escaped
1286        // source — honest, never blank. `Inline::Other` is a RAW passthrough
1287        // at render time (render.rs), which is exactly why the escaping has
1288        // to happen HERE, at construction: the TeX is author input and is
1289        // full of `<`, `>` and `&`. ADR-030 §4 records why this rides
1290        // `Inline::Other` instead of a new `Inline::Math` variant (the enum
1291        // is published, serialized and not `#[non_exhaustive]`, so a variant
1292        // is a semver one-way door).
1293        Event::InlineMath(tex) => (Some(math_inline(tex, false)), 1),
1294        Event::DisplayMath(tex) => (Some(math_inline(tex, true)), 1),
1295        // `[^label]`. A LEAF event, same hazard as math: without this arm the
1296        // catch-all deletes the marker and the reader loses the pointer to
1297        // the note. pulldown only emits it when a matching definition exists,
1298        // so a bare `[^abc]` in prose stays literal text.
1299        Event::FootnoteReference(label) => (Some(Inline::FootnoteRef(label.to_string())), 1),
1300        // `[ ]` / `[x]` at the head of a task-list item. Another LEAF, same
1301        // hazard as the two above: no arm here and the checkbox disappears
1302        // while the item text survives, so the list silently loses its
1303        // meaning rather than looking broken.
1304        Event::TaskListMarker(checked) => (Some(Inline::TaskMarker(*checked)), 1),
1305        Event::Start(tag) => match tag {
1306            Tag::Emphasis => {
1307                let (children, end) = collect_inlines_until(events, start + 1, |e| {
1308                    matches!(e, Event::End(TagEnd::Emphasis))
1309                });
1310                (Some(Inline::Emphasis(children)), end - start + 1)
1311            }
1312            Tag::Strong => {
1313                let (children, end) = collect_inlines_until(events, start + 1, |e| {
1314                    matches!(e, Event::End(TagEnd::Strong))
1315                });
1316                (Some(Inline::Strong(children)), end - start + 1)
1317            }
1318            Tag::Strikethrough => {
1319                let (children, end) = collect_inlines_until(events, start + 1, |e| {
1320                    matches!(e, Event::End(TagEnd::Strikethrough))
1321                });
1322                (Some(Inline::Strikethrough(children)), end - start + 1)
1323            }
1324            Tag::Link {
1325                link_type,
1326                dest_url,
1327                title,
1328                ..
1329            } => {
1330                let (children, end) = collect_inlines_until(events, start + 1, |e| {
1331                    matches!(e, Event::End(TagEnd::Link))
1332                });
1333                let title_opt = if title.is_empty() {
1334                    None
1335                } else {
1336                    Some(title.to_string())
1337                };
1338                // Phase 4 PR7a (2026-05-28): preserve pulldown-cmark's
1339                // `LinkType::WikiLink` discriminator on the typed AST so
1340                // the renderer can emit `class="wikilink"` and graph
1341                // builders can identify wikilink targets.
1342                let is_wikilink = matches!(*link_type, pulldown_cmark::LinkType::WikiLink { .. });
1343                (
1344                    Some(Inline::Link {
1345                        url: Url::unresolved(dest_url.to_string()),
1346                        title: title_opt,
1347                        children,
1348                        is_wikilink,
1349                    }),
1350                    end - start + 1,
1351                )
1352            }
1353            Tag::Image {
1354                link_type,
1355                dest_url,
1356                title,
1357                ..
1358            } => {
1359                // Collect alt text from text events between Start/End. A
1360                // nested image (`![a ![b](inner.png) c](outer.png)`, valid
1361                // CommonMark) emits its own Start/End(Image) pair inside this
1362                // span — depth-track so only the OUTER's own matching End
1363                // stops the loop; otherwise trailing content after the inner
1364                // image (here " c") escapes as a sibling paragraph inline
1365                // instead of folding into the outer alt, matching
1366                // `infra/newsletter.rs`'s `image_depth` counter on the email
1367                // side.
1368                let mut alt = String::new();
1369                let mut i = start + 1;
1370                let mut depth: u32 = 1;
1371                while i < events.len() {
1372                    match &events[i] {
1373                        Event::Start(Tag::Image { .. }) => depth += 1,
1374                        Event::End(TagEnd::Image) => {
1375                            depth -= 1;
1376                            if depth == 0 {
1377                                break;
1378                            }
1379                        }
1380                        Event::Text(t) => alt.push_str(t),
1381                        Event::Code(c) => alt.push_str(c),
1382                        // `alt` is a plain-text attribute AND (via the
1383                        // implicit-figure path) the visible `<figcaption>`,
1384                        // so math is carried as its markdown source, not as
1385                        // the `<code>` node. Dropping it deleted the
1386                        // equation from both surfaces.
1387                        Event::InlineMath(t) => alt.push_str(&math_source(t, false)),
1388                        Event::DisplayMath(t) => alt.push_str(&math_source(t, true)),
1389                        // A line break inside alt is a SPACE — how browsers
1390                        // and Obsidian flatten it, and the rule
1391                        // `infra/newsletter.rs` already applies on the email
1392                        // side. Dropping the break ran a soft-wrapped
1393                        // sentence together (`Cover art\nby Jane` →
1394                        // `Cover artby Jane`) in the `alt=` attribute and, via
1395                        // the implicit-figure path, in the visible
1396                        // `<figcaption>`. pulldown hands the wrapped line's
1397                        // trailing spaces to the preceding Text run, so guard
1398                        // against emitting a second one.
1399                        Event::SoftBreak | Event::HardBreak => {
1400                            if !alt.is_empty() && !alt.ends_with(' ') {
1401                                alt.push(' ');
1402                            }
1403                        }
1404                        _ => {}
1405                    }
1406                    i += 1;
1407                }
1408                // PR3.5 (2026-05-28): for wikilink images (`![[file]]` /
1409                // `![[file|pothole]]`), pulldown-cmark synthesizes text
1410                // events that aren't always author-intended alt:
1411                //   - `![[logo.png]]` → text "logo.png" (synthesized from
1412                //     dest); production treats as empty alt.
1413                //   - `![[logo.png|contain center]]` → text "contain center"
1414                //     (display-attrs); production classifies as styling,
1415                //     NOT alt.
1416                //   - `![[logo.png|width=400]]` → text "width=400" (typed
1417                //     params); production classifies as params, NOT alt.
1418                //   - `![[logo.png|My caption]]` → text "My caption";
1419                //     genuine alt.
1420                //
1421                // Without this classification, PR3's Block::Figure
1422                // detection (Wave 1) promotes wikilink-image paragraphs
1423                // with synth-derived "alt" to Figure with bogus
1424                // figcaptions ("logo.png", "contain center"). Match
1425                // production's transform_events wikilink-dispatch by
1426                // running the same classifiers (`is_all_display_keywords`
1427                // + `parse_pothole_params`) here.
1428                //
1429                // PR7a-flip-core-B (2026-05-28): preserve the ORIGINAL
1430                // pothole text on `Inline::Image.wikilink_pothole`
1431                // BEFORE alt-classification consumes it.
1432                // `dispatch_wikilink_embeds` needs the raw pothole to
1433                // route `![[v.mp4|width=400]]` → typed video synth with
1434                // the `width=400` param intact (alt-classification would
1435                // erase it). The pothole is the substring after `|`;
1436                // pulldown-cmark gives us the synthesized text, so we
1437                // strip the dest synth case (text == dest_url ⇒ no
1438                // pothole) and otherwise carry the trimmed alt.
1439                let is_wikilink_image =
1440                    matches!(link_type, pulldown_cmark::LinkType::WikiLink { .. });
1441                let wikilink_pothole: Option<String> = if is_wikilink_image {
1442                    let dest_str: &str = dest_url;
1443                    let trimmed = alt.trim();
1444                    if trimmed.is_empty() || trimmed == dest_str {
1445                        None
1446                    } else {
1447                        Some(trimmed.to_string())
1448                    }
1449                } else {
1450                    None
1451                };
1452                if is_wikilink_image {
1453                    let dest_str: &str = dest_url;
1454                    let trimmed = alt.trim().to_string();
1455                    if trimmed.is_empty() || trimmed == dest_str {
1456                        // Empty pothole OR pulldown-cmark synthesized
1457                        // dest_url as text → no author alt.
1458                        alt.clear();
1459                    } else if crate::media::is_all_display_keywords(&trimmed) {
1460                        // `contain center`, `left top`, etc. → display
1461                        // attrs (production maps to style), not alt.
1462                        alt.clear();
1463                    } else {
1464                        use crate::resolve::wikilink_dispatch::{
1465                            parse_pothole_params, PotholeContent,
1466                        };
1467                        match parse_pothole_params(&trimmed) {
1468                            PotholeContent::Empty | PotholeContent::Params(_) => {
1469                                alt.clear();
1470                            }
1471                            PotholeContent::WidthToken { rest_alias, .. } => {
1472                                alt = rest_alias;
1473                            }
1474                            PotholeContent::Alias(text) => {
1475                                // `parse_pothole_params` classifies a content-relative
1476                                // percent (e.g. `55%`) as `Alias` because it is not a
1477                                // named width token. Intercept it here: a bare percent
1478                                // is NOT a caption — strip it from the alt so it does
1479                                // not leak to `<figcaption>`. The actual width is
1480                                // recovered from `wikilink_pothole` by
1481                                // `dispatch_wikilink_embeds` (with-graph path) or
1482                                // directly from `split_alt_width` in the parser's
1483                                // `try_promote_to_figure` (no-graph path via `alt`).
1484                                //
1485                                // `split_alt_width` returns the remaining caption and
1486                                // the width token. If the whole alias was a width
1487                                // (nothing remaining), clear alt.
1488                                let (remaining, _w) = crate::media::split_alt_width(&text);
1489                                alt = remaining;
1490                            }
1491                        }
1492                    }
1493                }
1494                let title_opt = if title.is_empty() {
1495                    None
1496                } else {
1497                    Some(title.to_string())
1498                };
1499                (
1500                    Some(Inline::Image {
1501                        src: Url::unresolved(dest_url.to_string()),
1502                        alt,
1503                        title: title_opt,
1504                        is_wikilink: is_wikilink_image,
1505                        wikilink_pothole,
1506                    }),
1507                    i - start + 1,
1508                )
1509            }
1510            // Unmodeled inline container: skip to its End.
1511            _ => (None, 1),
1512        },
1513        // End / unhandled — caller handles.
1514        _ => (None, 1),
1515    }
1516}
1517
1518/// Collect a contiguous run of block events into `Vec<Block>`. Stops when
1519/// `is_end(event)` returns true or events run out.
1520fn collect_blocks_until<F>(
1521    events: &[Event<'_>],
1522    start: usize,
1523    line_ctx: Option<&LineCtx<'_>>,
1524    is_end: F,
1525) -> (Vec<Block>, usize)
1526where
1527    F: Fn(&Event<'_>) -> bool,
1528{
1529    let mut out: Vec<Block> = Vec::new();
1530    let mut i = start;
1531    while i < events.len() {
1532        if is_end(&events[i]) {
1533            return (out, i);
1534        }
1535        let (block, advance) = parse_block(events, i, line_ctx);
1536        if let Some(b) = block {
1537            out.push(b);
1538        }
1539        i += advance.max(1);
1540    }
1541    (out, i)
1542}
1543
1544/// Collect the children of a `Tag::Item` until the matching `End(Item)`.
1545///
1546/// Pulldown-cmark's **tight-list** mode emits item contents as inline
1547/// events (Text/Code/SoftBreak/inline-tag Start...) DIRECTLY inside
1548/// `Tag::Item` without wrapping in `Tag::Paragraph`. The plain
1549/// [`collect_blocks_until`] dispatcher would route those events through
1550/// [`parse_block`], which drops stray inlines — yielding empty `<li></li>`.
1551///
1552/// This helper preserves both modes:
1553/// - Inline events accumulate into a synthesized [`Block::Paragraph`] that
1554///   is flushed when a block-level event (Tag::Paragraph, Tag::List,
1555///   nested Tag::Item, etc.) appears or at the end of the item.
1556/// - Block-level events are parsed via [`parse_block_with_tag`] (the
1557///   standard path).
1558///
1559/// The renderer recognises a single-paragraph item shape and emits
1560/// `<li>...inline...</li>` without an inner `<p>`, matching production's
1561/// tight-list output byte-for-byte.
1562fn collect_item_blocks(
1563    events: &[Event<'_>],
1564    start: usize,
1565    line_ctx: Option<&LineCtx<'_>>,
1566) -> (Vec<Block>, usize) {
1567    let mut out: Vec<Block> = Vec::new();
1568    let mut pending_inlines: Vec<Inline> = Vec::new();
1569    let mut i = start;
1570    while i < events.len() {
1571        if matches!(&events[i], Event::End(TagEnd::Item)) {
1572            flush_pending_paragraph(&mut out, &mut pending_inlines);
1573            return (out, i);
1574        }
1575        if let Some((inline, advance)) = parse_inline_event(events, i) {
1576            if let Some(node) = inline {
1577                pending_inlines.push(node);
1578            }
1579            i += advance.max(1);
1580            continue;
1581        }
1582        // Block-level event: flush any accumulated inlines, then parse
1583        // through the standard dispatcher.
1584        flush_pending_paragraph(&mut out, &mut pending_inlines);
1585        let (block, advance) = parse_block(events, i, line_ctx);
1586        if let Some(b) = block {
1587            out.push(b);
1588        }
1589        i += advance.max(1);
1590    }
1591    flush_pending_paragraph(&mut out, &mut pending_inlines);
1592    (out, i)
1593}
1594
1595/// Phase 4 PR4: detect a callout marker inside a blockquote and, if
1596/// found, assemble the entire `Block::Callout` (with body blocks).
1597///
1598/// `start` is the event index AFTER `Start(BlockQuote)`. Returns
1599/// `Some((Block::Callout, end_index))` where `end_index` is the event
1600/// index of the matching `End(TagEnd::BlockQuote(_))`, so the outer
1601/// caller can compute the advance. Returns `None` for plain
1602/// blockquotes (no `[!type]` marker on the first paragraph).
1603///
1604/// Detection rule (shape-spec § 1):
1605/// - The first event must be `Start(Tag::Paragraph)`.
1606/// - The leading `Event::Text` run (before the first `SoftBreak` or
1607///   any non-Text inline event) must match `[!<kind>]`, optionally
1608///   followed by `+` or `-` for foldable callouts, optionally followed
1609///   by space + inline title.
1610/// - The kind is canonicalized via [`CalloutKind::from_raw`]; unknown
1611///   kinds fall back to [`CalloutKind::Note`]. (Diagnostic threading
1612///   is a Phase 4 followup — `validation::Diagnostic` is scoped to
1613///   frontmatter validation today.)
1614///
1615/// Why detection runs on events (not parsed children): the inline
1616/// parser collapses `SoftBreak` events into `Inline::Text` (in PR4.5,
1617/// emitting `"\n"` to match pulldown-cmark's `push_html`), which makes
1618/// the marker-line vs body-line boundary an embedded `\n` rather than a
1619/// distinct AST node. Working at the event layer preserves the
1620/// SoftBreak boundary so we can split "title" (before SoftBreak) from
1621/// "body" (after SoftBreak) correctly.
1622fn detect_and_assemble_callout(
1623    events: &[Event<'_>],
1624    start: usize,
1625    line_ctx: Option<&LineCtx<'_>>,
1626) -> Option<(Block, usize)> {
1627    if !matches!(events.get(start), Some(Event::Start(Tag::Paragraph))) {
1628        return None;
1629    }
1630    // Coalesce the leading run of `Event::Text` into one logical
1631    // string. Stops at SoftBreak, HardBreak, any Start/End tag, or
1632    // any non-Text inline.
1633    //
1634    // Math events join the run as their markdown source. `Callout.title`
1635    // is a `String`, so source text is the only shape it can hold — and
1636    // breaking here instead would not merely drop the equation, it would
1637    // TRUNCATE the title at the first `$` and spill the remainder into the
1638    // callout body (`[!note] Energy $E=mc^2$ explained` → title "Energy ").
1639    // If a later phase needs a typed title, this is the line that has to
1640    // become `Vec<Inline>`.
1641    let mut leading = String::new();
1642    let mut i = start + 1;
1643    while let Some(event) = events.get(i) {
1644        match event {
1645            Event::Text(t) => {
1646                leading.push_str(t);
1647                i += 1;
1648            }
1649            Event::InlineMath(t) => {
1650                leading.push_str(&math_source(t, false));
1651                i += 1;
1652            }
1653            Event::DisplayMath(t) => {
1654                leading.push_str(&math_source(t, true));
1655                i += 1;
1656            }
1657            _ => break,
1658        }
1659    }
1660    if leading.is_empty() {
1661        return None;
1662    }
1663
1664    let (raw_kind, fold, title, _marker_byte_len) = parse_callout_marker(&leading)?;
1665    let kind = CalloutKind::from_raw(raw_kind).unwrap_or(CalloutKind::Note);
1666    let title: Option<String> = title.map(|s| s.to_string()).filter(|s| !s.is_empty());
1667
1668    // We've consumed the leading Text events. `i` now points at the
1669    // first non-Text event in the (still-open) marker paragraph.
1670    //
1671    // Three shapes from here:
1672    //   (A) SoftBreak / HardBreak → body lines continue in the same
1673    //       Paragraph. Skip the break, then collect inlines until
1674    //       End(Paragraph). Wrap them in a synthetic Block::Paragraph.
1675    //   (B) End(Paragraph) immediately → marker-only callout (no body
1676    //       in the marker paragraph). Skip End(Paragraph).
1677    //   (C) Another inline event (Start(Emphasis), Code, etc.) → the
1678    //       marker was actually followed by inline markup on the same
1679    //       line. Currently treated as title continuation — but we
1680    //       lack a clean event-level coalescer for inline tags, so we
1681    //       just collect remaining inlines and wrap them as a body
1682    //       paragraph. The author can use a separator paragraph for
1683    //       clarity if they want clean title isolation.
1684    let mut body_blocks: Vec<Block> = Vec::new();
1685    // This match chooses WHERE the callout body starts; it does not collect
1686    // content. A math event directly after the marker falls into the `_` arm,
1687    // which starts the body at `i` and hands it to the math-aware
1688    // collect_inlines_until. Pinned by
1689    // `callout_title_is_not_truncated_at_the_first_dollar`.
1690    // allow:math-events-ignored — see above.
1691    let body_paragraph_start: Option<usize> = match events.get(i) {
1692        Some(Event::SoftBreak) | Some(Event::HardBreak) => {
1693            // Skip the break; collect remaining inlines for the body
1694            // paragraph.
1695            Some(i + 1)
1696        }
1697        Some(Event::End(TagEnd::Paragraph)) => {
1698            // Marker was the entire paragraph. Skip past End.
1699            i += 1;
1700            None
1701        }
1702        _ => {
1703            // Other inline events directly following the marker —
1704            // collect them as body paragraph content. (Edge case;
1705            // see method comment.)
1706            Some(i)
1707        }
1708    };
1709
1710    if let Some(body_start) = body_paragraph_start {
1711        // Collect inlines until End(Paragraph) and synthesize a
1712        // Block::Paragraph for the marker-paragraph body content.
1713        let (body_inlines, after_para) = collect_inlines_until(events, body_start, |e| {
1714            matches!(e, Event::End(TagEnd::Paragraph))
1715        });
1716        // Skip past End(Paragraph) itself.
1717        i = after_para + 1;
1718        // Trim leading whitespace-only Text inlines (e.g. if the
1719        // line-break Text(" ") leaks through).
1720        let trimmed_empty = body_inlines.iter().all(|x| match x {
1721            Inline::Text(t) => t.trim().is_empty(),
1722            _ => false,
1723        });
1724        if !trimmed_empty {
1725            body_blocks.push(Block::Paragraph(body_inlines));
1726        }
1727    }
1728
1729    // Continue collecting subsequent blocks until End(BlockQuote).
1730    while let Some(event) = events.get(i) {
1731        if matches!(event, Event::End(TagEnd::BlockQuote(_))) {
1732            break;
1733        }
1734        let (block, advance) = parse_block(events, i, line_ctx);
1735        if let Some(b) = block {
1736            body_blocks.push(b);
1737        }
1738        i += advance.max(1);
1739    }
1740
1741    // `i` now points at `End(BlockQuote)`. Return total event
1742    // span: outer caller computes `i - start + 1` (where `start` here
1743    // is the pre-Start-BlockQuote index in the outer scope; but we
1744    // were called with `start = outer_start + 1`, so the outer
1745    // caller's `start` correctly indexes the opening `Start(BlockQuote)`).
1746    // Per the call shape in `parse_block_with_tag` Tag::BlockQuote arm:
1747    //   `match detect_and_assemble_callout(events, start + 1)`
1748    //   `Some((block, body_end)) => (Some(block), body_end - start + 1)`
1749    // we must return `body_end = i` (the `End(BlockQuote)` index).
1750    let block = Block::Callout {
1751        kind,
1752        fold,
1753        title,
1754        children: body_blocks,
1755    };
1756    Some((block, i))
1757}
1758
1759/// Parse the leading text of a callout-shaped paragraph.
1760///
1761/// Accepts text shaped like `[!kind] title text…`, `[!kind]+ title`,
1762/// `[!kind]-`, etc. Returns:
1763/// - `raw_kind` — the kind identifier verbatim (lowercased on
1764///   canonicalization, not here).
1765/// - `fold` — `Some(Fold::Open)` for `+`, `Some(Fold::Closed)` for `-`,
1766///   `None` otherwise.
1767/// - `title` — `Some(title_text)` when text follows the marker (space
1768///   separator consumed); `None` when the marker is the entire string.
1769///   Title may be empty (`""`) if author wrote `[!note] ` with trailing
1770///   whitespace only — caller treats empty as None.
1771/// - `marker_byte_len` — number of bytes from the start of `text` that
1772///   constituted the marker + the single separator space (if any). The
1773///   caller slices `&text[marker_byte_len..]` to recover trailing body
1774///   text that should stay in the paragraph (multi-line callouts where
1775///   pulldown-cmark concatenated lines).
1776fn parse_callout_marker(text: &str) -> Option<(&str, Option<Fold>, Option<&str>, usize)> {
1777    let after_open = text.strip_prefix("[!")?;
1778    let close_offset = after_open.find(']')?;
1779    let raw_kind = after_open.get(..close_offset)?;
1780    if raw_kind.is_empty() || raw_kind.chars().any(|c| c.is_whitespace()) {
1781        return None;
1782    }
1783    // Offset within `text` immediately after the `]`.
1784    let after_bracket_offset = 2 + close_offset + 1;
1785    let rest = text.get(after_bracket_offset..)?;
1786
1787    let (fold, after_fold_offset) = match rest.chars().next() {
1788        Some('+') => (Some(Fold::Open), after_bracket_offset + 1),
1789        Some('-') => (Some(Fold::Closed), after_bracket_offset + 1),
1790        _ => (None, after_bracket_offset),
1791    };
1792
1793    let rest_after_fold = text.get(after_fold_offset..)?;
1794    let (title, marker_byte_len) = if rest_after_fold.is_empty() {
1795        // Marker only, no title segment.
1796        (None, after_fold_offset)
1797    } else if let Some(remainder) = rest_after_fold.strip_prefix(' ') {
1798        // ` title text…` — title is everything in this coalesced
1799        // leading-text string. Pulldown-cmark splits line breaks into
1800        // SoftBreak inlines, so this Text inline never contains
1801        // newlines; the title is bounded by the next non-Text inline.
1802        let title_str = remainder;
1803        let consumed = after_fold_offset + 1 + remainder.len();
1804        (Some(title_str), consumed)
1805    } else {
1806        // No separator after marker but more text follows (e.g.
1807        // `[!note]+body` with no space). Treat as no title; keep the
1808        // text intact.
1809        (None, after_fold_offset)
1810    };
1811
1812    Some((raw_kind, fold, title, marker_byte_len))
1813}
1814
1815/// If `events[i]` is an inline-level event, parse it via the existing
1816/// [`parse_inline`] machinery and return `(inline, advance)`. Returns
1817/// `None` for block-level events, end tags, or anything the inline
1818/// dispatcher doesn't own — letting the caller fall back to the block
1819/// path.
1820fn parse_inline_event(events: &[Event<'_>], i: usize) -> Option<(Option<Inline>, usize)> {
1821    match &events[i] {
1822        Event::Text(_)
1823        | Event::Code(_)
1824        | Event::Html(_)
1825        | Event::InlineHtml(_)
1826        | Event::SoftBreak
1827        | Event::HardBreak
1828        // Math and footnote markers are inline LEAVES. This whitelist is the
1829        // ONLY way they reach `parse_inline` from `collect_item_blocks` (its
1830        // sole caller), so omitting one deletes it in LIST ITEMS while the
1831        // same construct in a paragraph still looks fine — a wiring failure a
1832        // mechanism test cannot see. Table cells/blockquotes take other
1833        // routes (tests/math_parsing.rs). The Start-tag arm below carries the
1834        // same obligation for inline CONTAINERS, where a miss does worse than
1835        // delete: the unknown tag flushes the pending inlines, splitting one
1836        // item into two paragraphs (measured on `- ~~gone~~ stays`, which
1837        // rendered `<li><p>gone</p><p> stays</p></li>`).
1838        | Event::InlineMath(_)
1839        | Event::DisplayMath(_)
1840        | Event::FootnoteReference(_)
1841        // Task markers reach the AST ONLY through this arm — they occur
1842        // exclusively inside `Tag::Item`, whose content is collected by
1843        // `collect_item_blocks`, whose sole inline route is this function.
1844        // Omitting it deletes every checkbox in the document.
1845        | Event::TaskListMarker(_) => Some(parse_inline(events, i)),
1846        Event::Start(tag) => match tag {
1847            Tag::Emphasis
1848            | Tag::Strong
1849            | Tag::Strikethrough
1850            | Tag::Link { .. }
1851            | Tag::Image { .. } => Some(parse_inline(events, i)),
1852            _ => None,
1853        },
1854        _ => None,
1855    }
1856}
1857
1858/// Drain `pending_inlines` into a [`Block::Paragraph`] appended to `out`,
1859/// unless it's empty. No-op when there are no pending inlines.
1860fn flush_pending_paragraph(out: &mut Vec<Block>, pending_inlines: &mut Vec<Inline>) {
1861    if !pending_inlines.is_empty() {
1862        out.push(Block::Paragraph(std::mem::take(pending_inlines)));
1863    }
1864}
1865
1866/// Post-parse pass: disambiguate duplicate heading IDs by appending `-1`,
1867/// `-2`, … to the slug, in the order the headings will appear ON THE PAGE.
1868///
1869/// Mirrors the `id_counts: HashMap<String, usize>` behavior at
1870/// `src-tauri/src/build/markdown/pipeline.rs:1798-1805`:
1871///
1872/// - First occurrence of slug `foo` keeps id `foo`; counter starts at 1.
1873/// - Second occurrence becomes `foo-1`; counter becomes 2.
1874/// - Third occurrence becomes `foo-2`; counter becomes 3.
1875///
1876/// **Render order, not source order.** [`super::render::render_document`]
1877/// emits the body first — the FIRST definition of every footnote label
1878/// emitting nothing, because [`super::footnotes::render_section`] hoists it
1879/// to the end of the page — and then the endnote section, in first-reference
1880/// order (a third ordering, agreeing with neither of the other two).
1881/// Numbering in source order therefore handed the bare slug to a heading
1882/// that renders LAST: `[^a]: ## Notes` written above a body `## Notes`
1883/// published `id="notes"` inside the endnote list and `id="notes-1"` on the
1884/// visible section, while `[[Note#Notes]]` — slugged from heading text with
1885/// no counter — kept pointing at `#notes`.
1886///
1887/// Headings whose base slug is `None` (shouldn't happen post-PR2, but
1888/// safe-guarded) are left untouched.
1889fn assign_heading_id_suffixes(blocks: &mut [Block]) {
1890    // Built BEFORE the mutable walk: `entries()` is the order
1891    // `render_section` emits the `<li id="fn-N">`s, and it needs a shared
1892    // borrow of the same tree.
1893    let note_order: Vec<String> = FootnoteIndex::build(blocks)
1894        .entries()
1895        .iter()
1896        .map(|(_, label)| label.clone())
1897        .collect();
1898
1899    let mut body: Vec<&mut Option<String>> = Vec::new();
1900    let mut notes: Vec<(String, Vec<&mut Option<String>>)> = Vec::new();
1901    let mut hoisted: HashSet<String> = HashSet::new();
1902    let scope = HoistScope {
1903        document_notes: &note_order,
1904        in_shortcode: false,
1905    };
1906    collect_heading_id_slots(blocks, &mut body, &mut hoisted, &mut notes, scope);
1907
1908    // Stable sort into endnote order. A label the index doesn't own is
1909    // impossible today (every defined label is numbered); if one ever
1910    // appears it keeps its document position, at the end.
1911    notes.sort_by_key(|(label, _)| {
1912        note_order
1913            .iter()
1914            .position(|l| l == label)
1915            .unwrap_or(usize::MAX)
1916    });
1917
1918    let mut id_counts: HashMap<String, usize> = HashMap::new();
1919    for slot in body {
1920        disambiguate_heading_id(slot, &mut id_counts);
1921    }
1922    for (_, slots) in notes {
1923        for slot in slots {
1924            disambiguate_heading_id(slot, &mut id_counts);
1925        }
1926    }
1927}
1928
1929/// Apply the `-N` suffix rule to one heading id slot.
1930fn disambiguate_heading_id(id: &mut Option<String>, id_counts: &mut HashMap<String, usize>) {
1931    let Some(slug) = id else { return };
1932    let count = *id_counts.entry(slug.clone()).or_insert(0);
1933    id_counts.insert(slug.clone(), count + 1);
1934    if count > 0 {
1935        let suffixed = format!("{slug}-{count}");
1936        *id = Some(suffixed);
1937    }
1938}
1939
1940/// Collect a mutable handle to every heading id in the tree, bucketed by
1941/// WHERE the renderer puts it: `body` in document order, and one bucket per
1942/// hoisted footnote definition so the caller can order those the way
1943/// `render_section` emits them.
1944///
1945/// `hoisted` records which labels have had their first definition seen, in
1946/// document order — the same rule [`super::footnotes::FootnoteIndex::definition`]
1947/// uses to pick the definition whose body the endnote renders. A REPEAT
1948/// definition of a label is not hoisted (it renders in place), so its
1949/// headings stay in the surrounding bucket.
1950/// Where the walk currently is, for deciding whether a footnote definition
1951/// will be HOISTED to the endnote section (so its headings are numbered after
1952/// the whole body) or rendered IN PLACE (so they are numbered where they sit).
1953///
1954/// Two independent reasons a definition is not hoisted, and both must be
1955/// checked or the id order inverts against the DOM:
1956///
1957/// 1. The document index doesn't own the label. `footnotes::is_hoisted`
1958///    answers from a map built only from the index's entries, and that index
1959///    is exactly `document_notes`. A repeat definition of an already-defined
1960///    label is left in place by the renderer.
1961/// 2. The walk is inside a shortcode body. `footnotes::collect_definitions`
1962///    stops at shortcode bodies, so a `[^x]: …` written inside a `:::grid`
1963///    cell is never collected, never numbered, and never hoisted — it renders
1964///    in the cell. Bucketing it as an endnote numbered a grid heading after
1965///    the body even though it renders before it.
1966#[derive(Clone, Copy)]
1967struct HoistScope<'a> {
1968    /// Labels the document's `FootnoteIndex` owns, in endnote order.
1969    document_notes: &'a [String],
1970    /// Whether the walk is inside a `:::grid` cell or `:::hero` overlay.
1971    in_shortcode: bool,
1972}
1973
1974impl HoistScope<'_> {
1975    /// Whether a definition of `label` here becomes an endnote. Consumes the
1976    /// first-wins claim on `hoisted` only when it genuinely hoists, so a
1977    /// repeat definition later in the body is still judged on its own terms.
1978    fn hoists(&self, label: &str, hoisted: &mut HashSet<String>) -> bool {
1979        if self.in_shortcode {
1980            return false;
1981        }
1982        if !self.document_notes.iter().any(|l| l == label) {
1983            return false;
1984        }
1985        hoisted.insert(label.to_string())
1986    }
1987
1988    /// The same scope, entering a shortcode body.
1989    fn inside_shortcode(self) -> Self {
1990        Self {
1991            in_shortcode: true,
1992            ..self
1993        }
1994    }
1995}
1996
1997fn collect_heading_id_slots<'a>(
1998    blocks: &'a mut [Block],
1999    sink: &mut Vec<&'a mut Option<String>>,
2000    hoisted: &mut HashSet<String>,
2001    notes: &mut Vec<(String, Vec<&'a mut Option<String>>)>,
2002    scope: HoistScope<'_>,
2003) {
2004    for block in blocks.iter_mut() {
2005        match block {
2006            Block::Heading { id, .. } => sink.push(id),
2007            Block::FootnoteDefinition { label, children } => {
2008                let label = label.clone();
2009                // A definition is bucketed as an endnote only when the
2010                // renderer will actually hoist it — `footnotes::is_hoisted`
2011                // only ever hoists labels the document index owns, and the
2012                // index is exactly `document_notes`. Deciding from `hoisted` alone put
2013                // a definition the renderer leaves IN PLACE into a bucket
2014                // that is numbered after the whole body, so a heading that
2015                // renders first was numbered last.
2016                if scope.hoists(&label, hoisted) {
2017                    let mut note_sink: Vec<&'a mut Option<String>> = Vec::new();
2018                    collect_heading_id_slots(children, &mut note_sink, hoisted, notes, scope);
2019                    notes.push((label, note_sink));
2020                } else {
2021                    collect_heading_id_slots(children, sink, hoisted, notes, scope);
2022                }
2023            }
2024            // Every container whose children render into the SAME page
2025            // shares one id counter, or the page emits duplicate DOM ids and
2026            // `#slug` resolves to whichever copy the browser meets first.
2027            // LinkCard counts: it is the compound-link grid cell, and its
2028            // children include headings.
2029            Block::BlockQuote(children)
2030            | Block::Callout { children, .. }
2031            | Block::LinkCard { children, .. } => {
2032                collect_heading_id_slots(children, sink, hoisted, notes, scope);
2033            }
2034            Block::List { items, .. } => {
2035                for item in items.iter_mut() {
2036                    collect_heading_id_slots(item, sink, hoisted, notes, scope);
2037                }
2038            }
2039            Block::Shortcode(sc) => match sc {
2040                // Grid cells and the Hero overlay are typed `Vec<Block>`
2041                // (PR4.5) rendered into this page. Each is parsed by a
2042                // RECURSIVE `parse_fragment_with_config`, which is told to
2043                // skip `assign_heading_id_suffixes` precisely so they arrive
2044                // here holding un-disambiguated base slugs — this walk is the
2045                // page's only numbering pass. When the nested parse numbered
2046                // too, a cell with two `## Notes` arrived as `notes` /
2047                // `notes-1` and got suffixed a second time, yielding a
2048                // duplicate `notes-1` and an impossible `notes-1-1`.
2049                Shortcode::Grid(args) => {
2050                    for cell in args.cells.iter_mut() {
2051                        collect_heading_id_slots(cell, sink, hoisted, notes, scope.inside_shortcode());
2052                    }
2053                }
2054                Shortcode::Hero(args) => {
2055                    collect_heading_id_slots(&mut args.overlay, sink, hoisted, notes, scope.inside_shortcode());
2056                }
2057                // No block children: Subscribe/Apply bodies must be empty,
2058                // Buttons/Gallery carry typed item lists, and Recent's
2059                // fallback is unparsed markdown.
2060                Shortcode::Subscribe(_)
2061                | Shortcode::Buttons(_)
2062                | Shortcode::Gallery(_)
2063                | Shortcode::Recent(_)
2064                | Shortcode::Apply(_) => {}
2065            },
2066            // Deliberately exhaustive, no `_` arm: these carry no block
2067            // children, and the next `Block` variant that gains a
2068            // `Vec<Block>` must fail to compile HERE rather than silently
2069            // leak duplicate ids onto the page.
2070            Block::Paragraph(_)
2071            | Block::CodeBlock { .. }
2072            | Block::Table { .. }
2073            | Block::ThematicBreak
2074            | Block::Figure { .. }
2075            | Block::Other(_) => {}
2076        }
2077    }
2078}
2079
2080#[cfg(test)]
2081#[path = "parser_tests.rs"]
2082mod tests;