Skip to main content

damascene_markdown/
transformer.rs

1//! Walk pulldown-cmark events into an Damascene `El` tree.
2
3use std::ops::Range;
4
5use damascene_core::prelude::*;
6use damascene_core::selection::SelectionSource;
7use pulldown_cmark::{
8    Alignment, BlockQuoteKind, CodeBlockKind, Event, HeadingLevel, Options as CmarkOptions, Parser,
9    Tag, TagEnd,
10};
11
12/// Optional markdown extensions that can change rendered output.
13///
14/// [`md`] uses `Default`, which keeps output conservative while still
15/// enabling the GFM features Damascene renders directly today: tables,
16/// strikethrough, and task lists.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
18pub struct MarkdownOptions {
19    /// Replace ASCII punctuation with typographic punctuation during
20    /// parsing (`--`, `---`, `...`, straight quotes).
21    pub smart_punctuation: bool,
22    /// Render GFM alert blockquotes (`[!NOTE]`, `[!WARNING]`, ...)
23    /// through Damascene's `alert` widget instead of a plain blockquote.
24    pub gfm_alerts: bool,
25    /// Parse `$...$` and `$$...$$` as native Damascene math instead of
26    /// leaving pulldown-cmark's math extension disabled.
27    pub math: bool,
28    /// Options forwarded to [`damascene_html`] for embedded HTML
29    /// (`html` feature) — most notably
30    /// [`sanitize_styles`](damascene_html::HtmlOptions::sanitize_styles)
31    /// for untrusted input. The findings the HTML transformer emits are
32    /// surfaced through [`md_with_lints`].
33    #[cfg(feature = "html")]
34    pub html: damascene_html::HtmlOptions,
35}
36
37impl MarkdownOptions {
38    pub fn smart_punctuation(mut self, enabled: bool) -> Self {
39        self.smart_punctuation = enabled;
40        self
41    }
42
43    pub fn gfm_alerts(mut self, enabled: bool) -> Self {
44        self.gfm_alerts = enabled;
45        self
46    }
47
48    pub fn math(mut self, enabled: bool) -> Self {
49        self.math = enabled;
50        self
51    }
52
53    /// Set the options forwarded to the embedded-HTML transformer.
54    #[cfg(feature = "html")]
55    pub fn html_options(mut self, html: damascene_html::HtmlOptions) -> Self {
56        self.html = html;
57        self
58    }
59}
60
61/// Render a markdown document as an Damascene `El`.
62///
63/// The result is a `column([...])` of block-level Damascene widgets — the
64/// same shape an author would have hand-written. See the crate-level
65/// docs for the supported subset and the deferred features.
66pub fn md(input: &str) -> El {
67    md_with_options(input, MarkdownOptions::default())
68}
69
70/// Render a markdown document with explicit extension options.
71pub fn md_with_options(input: &str, options: MarkdownOptions) -> El {
72    walk(input, options).finish()
73}
74
75/// Render a markdown document and surface the lint findings the
76/// embedded-HTML transformer produced (dropped declarations,
77/// unsupported tags, sanitized styles, …). Pure markdown emits no
78/// findings; everything comes from `Event::Html` / `Event::InlineHtml`
79/// content routed through [`damascene_html`].
80#[cfg(feature = "html")]
81pub fn md_with_lints(input: &str, options: MarkdownOptions) -> (El, Vec<damascene_html::Finding>) {
82    walk(input, options).finish_with_lints()
83}
84
85fn walk(input: &str, options: MarkdownOptions) -> Walker {
86    // GFM tables, task lists, and `~~strike~~` all have direct widget-kit
87    // / inline-modifier analogs. Footnotes and math stay off until the
88    // markdown surface grows first-class support for references and TeX.
89    let mut parser_options = CmarkOptions::ENABLE_TABLES
90        | CmarkOptions::ENABLE_STRIKETHROUGH
91        | CmarkOptions::ENABLE_TASKLISTS;
92    if options.smart_punctuation {
93        parser_options |= CmarkOptions::ENABLE_SMART_PUNCTUATION;
94    }
95    if options.gfm_alerts {
96        parser_options |= CmarkOptions::ENABLE_GFM;
97    }
98    if options.math {
99        parser_options |= CmarkOptions::ENABLE_MATH;
100    }
101
102    let parser = Parser::new_ext(input, parser_options);
103    let mut walker = Walker::new(input, options);
104    for (event, range) in parser.into_offset_iter() {
105        walker.handle(event, range);
106    }
107    walker
108}
109
110/// Block-level frame on the parser's open-container stack. `Walker`
111/// pops these on the matching `End` event and folds the collected
112/// child content into a single Damascene widget.
113enum Frame {
114    /// Open `<p>` — accumulates inline runs.
115    Paragraph(InlineBuffer),
116    /// Open `<h1..h6>` — accumulates inline runs.
117    Heading(HeadingLevel, InlineBuffer),
118    /// Open `<blockquote>` — accumulates block children.
119    BlockQuote {
120        kind: Option<BlockQuoteKind>,
121        blocks: Vec<El>,
122    },
123    /// Open `<ul>` / `<ol>` — collects items as nested block lists.
124    List {
125        /// `None` ↔ bullet list, `Some(start)` ↔ ordered list starting
126        /// at `start` (CommonMark allows non-1 starts).
127        start: Option<u64>,
128        items: Vec<ListItem>,
129    },
130    /// Open `<li>` — accumulates one item's block children plus an
131    /// optional GFM task marker.
132    Item {
133        blocks: Vec<El>,
134        task_checked: Option<bool>,
135    },
136    /// Open `<pre><code>` — accumulating verbatim text. The optional
137    /// `lang` is the fenced info string (`` ```rust `` → `Some("rust")`,
138    /// indented blocks and bare `` ``` `` fences → `None`); when
139    /// highlighting is enabled and the lang resolves to a known syntax,
140    /// the close handler tokenises the body, otherwise it emits the
141    /// existing plain-mono `code_block(...)`.
142    CodeBlock {
143        lang: Option<String>,
144        text: String,
145        text_source: Option<Range<usize>>,
146        indented: bool,
147    },
148    /// Open `<a>` — accumulates inline children that share its URL.
149    /// The URL is applied to each text run on close (not via inline
150    /// style flags) so a link spanning multiple text events groups
151    /// correctly under one href in the painter.
152    Link(String, InlineBuffer),
153    /// Open `<img>` — accumulates alt text and keeps the destination
154    /// for placeholder rendering. Image content loading is deferred
155    /// (see crate docs).
156    Image {
157        alt: String,
158        dest_url: String,
159        title: String,
160    },
161    /// Open `<table>` — collects the header and body rows the matching
162    /// `End(Table)` folds into a `widgets::table` block.
163    Table {
164        /// Per-column alignments (`:---`, `:---:`, `---:`), applied to
165        /// header and body cell text.
166        alignments: Vec<Alignment>,
167        /// Header row, populated on `TagEnd::TableHead`. `None` if the
168        /// document somehow ends a table without a header (CommonMark
169        /// + GFM always emits one but this stays defensive).
170        head: Option<Vec<El>>,
171        /// Body rows, accumulated on each `TagEnd::TableRow`.
172        body: Vec<Vec<El>>,
173    },
174    /// Open `<thead>` — accumulates the header rows.
175    TableHead(Vec<El>),
176    /// Open `<tr>` — accumulates the row's cells.
177    TableRow(Vec<El>),
178    /// Open `<th>` / `<td>`. `in_header` toggles the header-styled
179    /// `table_head(...)` builder on close vs. the body-styled
180    /// `table_cell(...)`.
181    TableCell {
182        runs: InlineBuffer,
183        in_header: bool,
184        alignment: Alignment,
185    },
186}
187
188#[derive(Clone, Debug, Default)]
189struct InlineBuffer {
190    runs: Vec<El>,
191    visible: String,
192    spans: Vec<InlineSourceSpan>,
193}
194
195#[derive(Clone, Debug)]
196struct InlineSourceSpan {
197    visible: Range<usize>,
198    source: Range<usize>,
199    source_full: Range<usize>,
200    atomic: bool,
201}
202
203impl InlineBuffer {
204    fn is_empty(&self) -> bool {
205        self.runs.is_empty()
206    }
207
208    fn visible_len(&self) -> usize {
209        self.visible.len()
210    }
211
212    fn push(
213        &mut self,
214        el: El,
215        visible: &str,
216        source: Range<usize>,
217        source_full: Range<usize>,
218        atomic: bool,
219    ) {
220        let start = self.visible.len();
221        self.visible.push_str(visible);
222        let end = self.visible.len();
223        if start < end {
224            self.spans.push(InlineSourceSpan {
225                visible: start..end,
226                source,
227                source_full,
228                atomic,
229            });
230        }
231        self.runs.push(el);
232    }
233
234    fn append(&mut self, mut other: InlineBuffer) {
235        let offset = self.visible.len();
236        self.visible.push_str(&other.visible);
237        for span in other.spans.drain(..) {
238            self.spans.push(InlineSourceSpan {
239                visible: (span.visible.start + offset)..(span.visible.end + offset),
240                source: span.source,
241                source_full: span.source_full,
242                atomic: span.atomic,
243            });
244        }
245        self.runs.append(&mut other.runs);
246    }
247
248    fn mark_full_source(&mut self, visible: Range<usize>, source_full: Range<usize>) {
249        for span in &mut self.spans {
250            if span.visible.start >= visible.start && span.visible.end <= visible.end {
251                span.source_full = source_full.clone();
252            }
253        }
254    }
255
256    fn mark_all_full_source(&mut self, source_full: Range<usize>) {
257        self.mark_full_source(0..self.visible_len(), source_full);
258    }
259
260    fn into_runs(self) -> Vec<El> {
261        self.runs
262    }
263
264    fn selection_source(
265        &self,
266        input: &str,
267        source_range: Option<Range<usize>>,
268    ) -> Option<SelectionSource> {
269        let source_range = source_range?;
270        let source_text = input.get(source_range.clone())?.to_string();
271        let mut source = SelectionSource::new(source_text, self.visible.clone());
272        for span in &self.spans {
273            let start = span.source.start.saturating_sub(source_range.start);
274            let end = span.source.end.saturating_sub(source_range.start);
275            let full_start = span.source_full.start.saturating_sub(source_range.start);
276            let full_end = span.source_full.end.saturating_sub(source_range.start);
277            source.push_span_with_full_source(
278                span.visible.clone(),
279                start..end,
280                full_start..full_end,
281                span.atomic,
282            );
283        }
284        Some(source)
285    }
286}
287
288struct ListItem {
289    content: El,
290    task_checked: Option<bool>,
291}
292
293/// Inline styling currently in effect for new text runs.
294///
295/// Markdown inline tags (`*em*`, `**strong**`, `~~strike~~`) can nest;
296/// each pair pushes / pops a depth counter. The Code and Link cases
297/// are scoped through their own frames in `Walker::stack` rather than
298/// as flags here, since they carry data (the run's `code_role` shape
299/// and the link URL respectively).
300#[derive(Default)]
301struct InlineState {
302    italic_depth: u32,
303    bold_depth: u32,
304    strike_depth: u32,
305}
306
307impl InlineState {
308    fn apply(&self, mut el: El) -> El {
309        if self.bold_depth > 0 {
310            el = el.bold();
311        }
312        if self.italic_depth > 0 {
313            el = el.italic();
314        }
315        if self.strike_depth > 0 {
316            el = el.strikethrough();
317        }
318        el
319    }
320}
321
322struct InlineSourceMarker {
323    visible_start: usize,
324    source_start: usize,
325}
326
327/// Inline tags whose open/close pair carries styling state worth
328/// buffering across fragmented `InlineHtml` events. Void / replaced
329/// tags (`<br>`, `<img>`, `<input>`, `<wbr>`, `<button>`) are
330/// self-contained and parse standalone instead.
331#[cfg(feature = "html")]
332const STATEFUL_INLINE_TAGS: &[&str] = &[
333    "a", "abbr", "b", "bdi", "bdo", "cite", "code", "data", "del", "dfn", "em", "i", "kbd", "mark",
334    "q", "s", "samp", "small", "span", "strike", "strong", "sub", "sup", "time", "u", "var",
335];
336
337/// Backstop against pathological input holding the open-tag stack
338/// (and its re-parsed template) unboundedly deep.
339#[cfg(feature = "html")]
340const MAX_OPEN_INLINE_HTML_TAGS: usize = 16;
341
342/// Shape of a single `Event::InlineHtml` fragment.
343#[cfg(feature = "html")]
344enum InlineHtmlFragment {
345    /// Exactly one open tag, e.g. `<b>` or `<span style="…">`.
346    Open(String),
347    /// Exactly one close tag, e.g. `</b>`.
348    Close(String),
349    /// Anything else: self-closing tags, comments, doctypes, or a
350    /// multi-tag scrap — parsed standalone.
351    Other,
352}
353
354#[cfg(feature = "html")]
355fn classify_inline_html_fragment(s: &str) -> InlineHtmlFragment {
356    let trimmed = s.trim();
357    let Some(inner) = trimmed
358        .strip_prefix('<')
359        .and_then(|rest| rest.strip_suffix('>'))
360    else {
361        return InlineHtmlFragment::Other;
362    };
363    if inner.contains('<') || inner.contains('>') {
364        return InlineHtmlFragment::Other;
365    }
366    if let Some(rest) = inner.strip_prefix('/') {
367        let name = rest.trim().to_ascii_lowercase();
368        if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric()) {
369            return InlineHtmlFragment::Close(name);
370        }
371        return InlineHtmlFragment::Other;
372    }
373    if inner.trim_end().ends_with('/') {
374        // Self-closing (`<span/>`) — no state to buffer.
375        return InlineHtmlFragment::Other;
376    }
377    let name: String = inner
378        .chars()
379        .take_while(|c| c.is_ascii_alphanumeric())
380        .collect::<String>()
381        .to_ascii_lowercase();
382    if name.is_empty() {
383        // `<!-- comment -->`, `<!DOCTYPE …>`, `<?pi?>`.
384        return InlineHtmlFragment::Other;
385    }
386    // The name must be a whole token (`<b>`, `<span style="…">`), not
387    // a prefix of something longer.
388    let after = &inner[name.len()..];
389    if !(after.is_empty() || after.starts_with(char::is_whitespace)) {
390        return InlineHtmlFragment::Other;
391    }
392    InlineHtmlFragment::Open(name)
393}
394
395struct Walker {
396    input: String,
397    options: MarkdownOptions,
398    /// Open block-level frames + open `<a>` / `<img>` containers,
399    /// innermost last. `<a>` and `<img>` are stack-tracked rather than
400    /// stored as inline-state flags because they own the text events
401    /// between Start/End and need to fold them into their own El on
402    /// close.
403    stack: Vec<Frame>,
404    source_stack: Vec<Option<Range<usize>>>,
405    /// Inline-style flags for upcoming text events.
406    inline: InlineState,
407    /// Findings collected from the embedded-HTML transformer; surfaced
408    /// by [`md_with_lints`], discarded by [`md_with_options`].
409    #[cfg(feature = "html")]
410    html_findings: Vec<damascene_html::Finding>,
411    /// Open inline-HTML tags. pulldown-cmark fragments `<b>text</b>`
412    /// into separate `InlineHtml` open / `Text` / `InlineHtml` close
413    /// events, so a bare open tag is buffered here (innermost last)
414    /// and its styling applied to the text events until the matching
415    /// close pops it. Entries are `(tag_name, raw_fragment)`.
416    #[cfg(feature = "html")]
417    open_inline_html: Vec<(String, String)>,
418    /// Cached styled template derived from [`Self::open_inline_html`]:
419    /// a text `El` carrying the combined inline styling of every open
420    /// tag, cloned for each text event while tags stay open.
421    #[cfg(feature = "html")]
422    html_template: Option<El>,
423    inline_source_stack: Vec<InlineSourceMarker>,
424    /// Top-level blocks collected outside any open frame.
425    root: Vec<El>,
426}
427
428impl Walker {
429    fn new(input: &str, options: MarkdownOptions) -> Self {
430        Self {
431            input: input.to_string(),
432            options,
433            stack: Vec::new(),
434            source_stack: Vec::new(),
435            inline: InlineState::default(),
436            #[cfg(feature = "html")]
437            html_findings: Vec::new(),
438            #[cfg(feature = "html")]
439            open_inline_html: Vec::new(),
440            #[cfg(feature = "html")]
441            html_template: None,
442            inline_source_stack: Vec::new(),
443            root: Vec::new(),
444        }
445    }
446
447    fn handle(&mut self, event: Event<'_>, range: Range<usize>) {
448        match event {
449            Event::Start(tag) => {
450                self.extend_top_source(range.clone());
451                self.start(tag, range);
452            }
453            Event::End(end) => self.end(end, range),
454            Event::Text(text) => self.text(text.into_string(), range),
455            Event::Code(text) => self.code_span(text.into_string(), range),
456            Event::SoftBreak => self.text(" ".to_string(), range),
457            Event::HardBreak => {
458                self.ensure_inline_frame(range.clone());
459                self.extend_top_source(range.clone());
460                self.push_inline_mapped(hard_break(), "\n", range.clone(), range, false);
461            }
462            Event::Rule => {
463                self.extend_top_source(range);
464                self.push_block(divider());
465            }
466            Event::InlineMath(text) => self.inline_math(text.into_string(), range),
467            Event::DisplayMath(text) => self.display_math(text.into_string(), range),
468            #[cfg(feature = "html")]
469            Event::Html(s) => self.block_html(s.into_string(), range),
470            #[cfg(feature = "html")]
471            Event::InlineHtml(s) => self.inline_html(s.into_string(), range),
472            #[cfg(not(feature = "html"))]
473            Event::Html(_) | Event::InlineHtml(_) => {}
474            Event::FootnoteReference(_) => {}
475            Event::TaskListMarker(checked) => {
476                self.extend_top_source(range);
477                self.task_list_marker(checked);
478            }
479        }
480    }
481
482    fn push_frame(&mut self, frame: Frame, range: Range<usize>) {
483        self.stack.push(frame);
484        self.source_stack.push(Some(range));
485    }
486
487    fn pop_frame(&mut self) -> Option<(Frame, Option<Range<usize>>)> {
488        let frame = self.stack.pop()?;
489        let range = self.source_stack.pop().flatten();
490        Some((frame, range))
491    }
492
493    fn parent_item_source_range(&self) -> Option<Range<usize>> {
494        let frame_index = self
495            .stack
496            .iter()
497            .rposition(|frame| matches!(frame, Frame::Item { .. }))?;
498        self.source_stack.get(frame_index).cloned().flatten()
499    }
500
501    fn parent_table_line_source_range(&self) -> Option<Range<usize>> {
502        let frame_index = self
503            .stack
504            .iter()
505            .rposition(|frame| matches!(frame, Frame::TableHead(_) | Frame::TableRow(_)))?;
506        self.source_stack.get(frame_index).cloned().flatten()
507    }
508
509    fn extend_top_source(&mut self, range: Range<usize>) {
510        if range.start >= range.end {
511            return;
512        }
513        if let Some(slot) = self.source_stack.last_mut() {
514            match slot {
515                Some(existing) => {
516                    existing.start = existing.start.min(range.start);
517                    existing.end = existing.end.max(range.end);
518                }
519                None => *slot = Some(range),
520            }
521        }
522    }
523
524    fn open_inline_source(&mut self, range: Range<usize>) {
525        self.ensure_inline_frame(range.clone());
526        self.extend_top_source(range.clone());
527        let visible_start = self.current_inline_visible_len().unwrap_or(0);
528        self.inline_source_stack.push(InlineSourceMarker {
529            visible_start,
530            source_start: range.start,
531        });
532    }
533
534    fn close_inline_source(&mut self, range: Range<usize>) {
535        let Some(marker) = self.inline_source_stack.pop() else {
536            return;
537        };
538        let Some(visible_end) = self.current_inline_visible_len() else {
539            return;
540        };
541        if visible_end <= marker.visible_start {
542            return;
543        }
544        if let Some(buffer) = self.current_inline_buffer_mut() {
545            buffer.mark_full_source(
546                marker.visible_start..visible_end,
547                marker.source_start..range.end,
548            );
549        }
550    }
551
552    fn start(&mut self, tag: Tag<'_>, range: Range<usize>) {
553        match tag {
554            Tag::Paragraph => self.push_frame(Frame::Paragraph(InlineBuffer::default()), range),
555            Tag::Heading { level, .. } => {
556                self.push_frame(Frame::Heading(level, InlineBuffer::default()), range)
557            }
558            Tag::BlockQuote(kind) => self.push_frame(
559                Frame::BlockQuote {
560                    kind: kind.filter(|_| self.options.gfm_alerts),
561                    blocks: Vec::new(),
562                },
563                range,
564            ),
565            Tag::List(start) => self.push_frame(
566                Frame::List {
567                    start,
568                    items: Vec::new(),
569                },
570                range,
571            ),
572            Tag::Item => self.push_frame(
573                Frame::Item {
574                    blocks: Vec::new(),
575                    task_checked: None,
576                },
577                range,
578            ),
579            Tag::CodeBlock(kind) => {
580                let (lang, indented) = match kind {
581                    CodeBlockKind::Fenced(info) => {
582                        // The info string can carry attributes after a
583                        // space (`rust ignore`); first token is the
584                        // language tag, anything else we don't speak.
585                        let token = info.split_whitespace().next().unwrap_or("");
586                        if token.is_empty() {
587                            (None, false)
588                        } else {
589                            (Some(token.to_string()), false)
590                        }
591                    }
592                    CodeBlockKind::Indented => (None, true),
593                };
594                self.push_frame(
595                    Frame::CodeBlock {
596                        lang,
597                        text: String::new(),
598                        text_source: None,
599                        indented,
600                    },
601                    range,
602                );
603            }
604            Tag::Emphasis => {
605                self.inline.italic_depth += 1;
606                self.open_inline_source(range);
607            }
608            Tag::Strong => {
609                self.inline.bold_depth += 1;
610                self.open_inline_source(range);
611            }
612            Tag::Strikethrough => {
613                self.inline.strike_depth += 1;
614                self.open_inline_source(range);
615            }
616            Tag::Link { dest_url, .. } => {
617                self.push_frame(
618                    Frame::Link(dest_url.into_string(), InlineBuffer::default()),
619                    range,
620                );
621            }
622            Tag::Image {
623                dest_url, title, ..
624            } => {
625                // Alt text accumulates through inline events while the
626                // image frame is open; on End we fold into a placeholder.
627                self.push_frame(
628                    Frame::Image {
629                        alt: String::new(),
630                        dest_url: dest_url.into_string(),
631                        title: title.into_string(),
632                    },
633                    range,
634                );
635            }
636            Tag::Table(alignments) => {
637                self.push_frame(
638                    Frame::Table {
639                        alignments,
640                        head: None,
641                        body: Vec::new(),
642                    },
643                    range,
644                );
645            }
646            Tag::TableHead => self.push_frame(Frame::TableHead(Vec::new()), range),
647            Tag::TableRow => self.push_frame(Frame::TableRow(Vec::new()), range),
648            Tag::TableCell => {
649                // Header vs body is decided by what the current table
650                // section is at cell-start time. pulldown-cmark emits
651                // header cells directly under `TableHead` today, but
652                // this also handles a nested `TableRow` shape.
653                let in_header = self
654                    .stack
655                    .iter()
656                    .rev()
657                    .any(|frame| matches!(frame, Frame::TableHead(_)));
658                let alignment = self.next_table_cell_alignment();
659                self.push_frame(
660                    Frame::TableCell {
661                        runs: InlineBuffer::default(),
662                        in_header,
663                        alignment,
664                    },
665                    range,
666                );
667            }
668            // Subscript / superscript have no baseline-shift primitive
669            // yet; treating them as no-ops lets their content flow into
670            // the enclosing paragraph (flattened, not dropped).
671            Tag::Subscript | Tag::Superscript => {}
672            // Footnote definitions and definition lists are deferred —
673            // open a paragraph frame so inline content between Start
674            // and End is captured; the matching End *flushes* it as a
675            // plain paragraph (flattened, not dropped).
676            Tag::FootnoteDefinition(_)
677            | Tag::DefinitionList
678            | Tag::DefinitionListTitle
679            | Tag::DefinitionListDefinition => {
680                self.push_frame(Frame::Paragraph(InlineBuffer::default()), range);
681            }
682            // Metadata blocks (front matter) are not content, and
683            // HtmlBlock's Event::Html children route through the HTML
684            // path themselves — the throwaway frame only absorbs stray
685            // text, which stays dropped.
686            Tag::HtmlBlock | Tag::MetadataBlock(_) => {
687                self.push_frame(Frame::Paragraph(InlineBuffer::default()), range);
688            }
689        }
690    }
691
692    fn end(&mut self, end: TagEnd, range: Range<usize>) {
693        match end {
694            TagEnd::Paragraph => {
695                // Unclosed inline-HTML tags don't bleed across blocks.
696                self.clear_open_inline_html();
697                let inside_blockquote = self.inside_blockquote();
698                if let Some((Frame::Paragraph(inlines), source_range)) = self.pop_frame() {
699                    // Empty paragraph: pulldown-cmark wraps inline
700                    // images in their own paragraph, so once the image
701                    // pops out as a block the wrapping paragraph is
702                    // empty. Skip emission for that case (and for any
703                    // other zero-run paragraph) so the document
704                    // doesn't carry phantom empty blocks.
705                    if inlines.is_empty() {
706                        return;
707                    }
708                    let source_range = if inside_blockquote {
709                        expand_source_range_start_to_line_start(&self.input, source_range)
710                            .map(|range| trim_source_range_end(&self.input, range))
711                    } else {
712                        source_range
713                    };
714                    let block = build_paragraph(inlines, &self.input, source_range);
715                    self.push_block(block);
716                }
717            }
718            TagEnd::Heading(_) => {
719                self.clear_open_inline_html();
720                let inside_blockquote = self.inside_blockquote();
721                if let Some((Frame::Heading(level, inlines), source_range)) = self.pop_frame() {
722                    let source_range = if inside_blockquote {
723                        expand_source_range_start_to_line_start(&self.input, source_range)
724                            .map(|range| trim_source_range_end(&self.input, range))
725                    } else {
726                        source_range
727                    };
728                    let block = build_heading(level, inlines, &self.input, source_range);
729                    self.push_block(block);
730                }
731            }
732            TagEnd::BlockQuote(_) => {
733                if let Some((Frame::BlockQuote { kind, blocks }, _)) = self.pop_frame() {
734                    self.push_block(build_blockquote(kind, blocks));
735                }
736            }
737            TagEnd::List(_) => {
738                if let Some((Frame::List { start, items }, _)) = self.pop_frame() {
739                    let block = build_list(start, items);
740                    self.push_block(block);
741                }
742            }
743            TagEnd::Item => {
744                // Tight-list items in pulldown-cmark omit the wrapping
745                // `Paragraph` events — text events arrive directly
746                // under `Item`. We lazily push a `Paragraph` frame on
747                // the first inline event under such an item (see
748                // `ensure_inline_frame`). Drain any such open
749                // paragraphs into the item's blocks before closing.
750                while matches!(self.stack.last(), Some(Frame::Paragraph(_))) {
751                    let item_source_range = self.parent_item_source_range();
752                    if let Some((Frame::Paragraph(mut inlines), source_range)) = self.pop_frame()
753                        && !inlines.is_empty()
754                    {
755                        if let Some(item_source_range) = item_source_range {
756                            let item_source_range =
757                                trim_source_range_end(&self.input, item_source_range);
758                            let item_source_range = if self.inside_blockquote() {
759                                expand_source_range_start_to_line_start(
760                                    &self.input,
761                                    Some(item_source_range.clone()),
762                                )
763                                .unwrap_or(item_source_range)
764                            } else {
765                                item_source_range
766                            };
767                            let source_range =
768                                union_source_ranges(source_range, Some(item_source_range.clone()));
769                            inlines.mark_all_full_source(item_source_range);
770                            let block = build_paragraph(inlines, &self.input, source_range);
771                            self.push_block(block);
772                            continue;
773                        }
774                        let block = build_paragraph(inlines, &self.input, source_range);
775                        self.push_block(block);
776                    }
777                }
778                if let Some((
779                    Frame::Item {
780                        blocks,
781                        task_checked,
782                    },
783                    _,
784                )) = self.pop_frame()
785                {
786                    let item_el = build_list_item(blocks);
787                    if let Some(Frame::List { items, .. }) = self.stack.last_mut() {
788                        items.push(ListItem {
789                            content: item_el,
790                            task_checked,
791                        });
792                    }
793                }
794            }
795            TagEnd::CodeBlock => {
796                let inside_blockquote = self.inside_blockquote();
797                if let Some((
798                    Frame::CodeBlock {
799                        lang,
800                        text,
801                        text_source,
802                        indented,
803                    },
804                    source_range,
805                )) = self.pop_frame()
806                {
807                    let source_range = if indented || inside_blockquote {
808                        expand_source_range_start_to_line_start(&self.input, source_range)
809                    } else {
810                        source_range
811                    };
812                    self.push_block(build_code_block(
813                        lang.as_deref(),
814                        text,
815                        &self.input,
816                        source_range,
817                        text_source,
818                    ));
819                }
820            }
821            TagEnd::Emphasis => {
822                self.close_inline_source(range);
823                self.inline.italic_depth = self.inline.italic_depth.saturating_sub(1)
824            }
825            TagEnd::Strong => {
826                self.close_inline_source(range);
827                self.inline.bold_depth = self.inline.bold_depth.saturating_sub(1);
828            }
829            TagEnd::Strikethrough => {
830                self.close_inline_source(range);
831                self.inline.strike_depth = self.inline.strike_depth.saturating_sub(1);
832            }
833            TagEnd::Link => {
834                if let Some((Frame::Link(url, mut inlines), source_range)) = self.pop_frame() {
835                    if let Some(source_range) = source_range {
836                        inlines.mark_full_source(0..inlines.visible_len(), source_range);
837                    }
838                    for run in &mut inlines.runs {
839                        // Each text leaf inside the `<a>` adopts the
840                        // same href so the renderer groups them into
841                        // one link for hit-testing.
842                        *run = std::mem::take(run).link(url.clone());
843                    }
844                    self.push_inline_buffer(inlines);
845                }
846            }
847            TagEnd::Image => {
848                if let Some((
849                    Frame::Image {
850                        alt,
851                        dest_url,
852                        title,
853                    },
854                    source_range,
855                )) = self.pop_frame()
856                {
857                    let placeholder = build_image_placeholder(&alt, &dest_url, &title);
858                    let visible = image_placeholder_label(&alt, &dest_url, &title);
859                    if self.in_inline_container() {
860                        if let Some(source_range) = source_range {
861                            self.push_inline_mapped(
862                                placeholder,
863                                &visible,
864                                source_range.clone(),
865                                source_range,
866                                true,
867                            );
868                        } else {
869                            self.push_inline_mapped(placeholder, &visible, 0..0, 0..0, false);
870                        }
871                    } else {
872                        let block = with_atomic_source_selection(
873                            placeholder,
874                            "img",
875                            &self.input,
876                            source_range,
877                            visible,
878                        );
879                        self.push_block(block);
880                    }
881                }
882            }
883            TagEnd::Table => {
884                if let Some((Frame::Table { head, body, .. }, _)) = self.pop_frame() {
885                    self.push_block(build_table(head, body));
886                }
887            }
888            TagEnd::TableHead => {
889                if let Some((Frame::TableHead(items), _)) = self.pop_frame() {
890                    let rows = normalize_table_head_rows(items);
891                    if let Some(Frame::Table { head, .. }) = self.stack.last_mut() {
892                        *head = Some(rows);
893                    }
894                }
895            }
896            TagEnd::TableRow => {
897                if let Some((Frame::TableRow(cells), _)) = self.pop_frame() {
898                    let row = table_row(cells);
899                    match self.stack.last_mut() {
900                        Some(Frame::TableHead(rows)) => rows.push(row),
901                        Some(Frame::Table { body, .. }) => body.push(vec![row]),
902                        _ => {}
903                    }
904                }
905            }
906            TagEnd::TableCell => {
907                let raw_row_source_range = self
908                    .parent_table_line_source_range()
909                    .map(|range| trim_source_range_end(&self.input, range));
910                if let Some((
911                    Frame::TableCell {
912                        runs,
913                        in_header,
914                        alignment,
915                    },
916                    source_range,
917                )) = self.pop_frame()
918                {
919                    let row_source_range = raw_row_source_range.map(|range| {
920                        if in_header {
921                            expand_table_header_source_to_delimiter(&self.input, range)
922                        } else {
923                            range
924                        }
925                    });
926                    let cell = build_table_cell(
927                        runs,
928                        in_header,
929                        alignment,
930                        &self.input,
931                        source_range,
932                        row_source_range,
933                    );
934                    match self.stack.last_mut() {
935                        Some(Frame::TableHead(cells)) | Some(Frame::TableRow(cells)) => {
936                            cells.push(cell);
937                        }
938                        _ => {}
939                    }
940                }
941            }
942            // No frame was opened — content flowed into the enclosing
943            // paragraph.
944            TagEnd::Subscript | TagEnd::Superscript => {}
945            // Flush (not discard) the captured frame: a definition
946            // title or footnote body renders as a plain paragraph
947            // until these grow dedicated widgets.
948            TagEnd::FootnoteDefinition
949            | TagEnd::DefinitionList
950            | TagEnd::DefinitionListTitle
951            | TagEnd::DefinitionListDefinition => {
952                if let Some((Frame::Paragraph(inlines), source_range)) = self.pop_frame()
953                    && !inlines.is_empty()
954                {
955                    let block = build_paragraph(inlines, &self.input, source_range);
956                    self.push_block(block);
957                }
958            }
959            TagEnd::HtmlBlock | TagEnd::MetadataBlock(_) => {
960                // Drain the throwaway frame from `start`.
961                self.pop_frame();
962            }
963        }
964    }
965
966    fn text(&mut self, s: String, range: Range<usize>) {
967        // CodeBlock receives raw text; everything else flows through
968        // an inline buffer with the active style applied.
969        if let Some(Frame::CodeBlock {
970            text: buf,
971            text_source,
972            ..
973        }) = self.stack.last_mut()
974        {
975            buf.push_str(&s);
976            *text_source = union_source_ranges(text_source.take(), Some(range));
977            return;
978        }
979        if let Some(Frame::Image { alt, .. }) = self.stack.last_mut() {
980            alt.push_str(&s);
981            return;
982        }
983        self.ensure_inline_frame(range.clone());
984        self.extend_top_source(range.clone());
985        let run = self.inline.apply(self.html_styled_text(&s));
986        let source = self.source_range_for_visible(range.clone(), &s);
987        self.push_inline_mapped(run, &s, source, range, false);
988    }
989
990    /// A text run carrying the styling of any open inline-HTML tags
991    /// (`<b>`, `<span style="…">`, …) buffered by
992    /// [`Self::inline_html`]. Plain `text(s)` when none are open.
993    #[cfg(feature = "html")]
994    fn html_styled_text(&self, s: &str) -> El {
995        match &self.html_template {
996            Some(template) => {
997                let mut el = template.clone();
998                el.text = Some(s.to_string());
999                el
1000            }
1001            None => text(s.to_string()),
1002        }
1003    }
1004
1005    #[cfg(not(feature = "html"))]
1006    fn html_styled_text(&self, s: &str) -> El {
1007        text(s.to_string())
1008    }
1009
1010    fn code_span(&mut self, s: String, range: Range<usize>) {
1011        // Inline code: `text(...).code()` carries the code role, which
1012        // theme application maps to mono + foreground. Strikethrough
1013        // / italic / bold can wrap a code span in CommonMark, so the
1014        // current InlineState still applies on top of `.code()`.
1015        if matches!(self.stack.last(), Some(Frame::CodeBlock { .. })) {
1016            // Inside a fenced code block, `Event::Code` shouldn't
1017            // arrive — but if it does, treat as raw text.
1018            if let Some(Frame::CodeBlock {
1019                text: buf,
1020                text_source,
1021                ..
1022            }) = self.stack.last_mut()
1023            {
1024                buf.push_str(&s);
1025                *text_source = union_source_ranges(text_source.take(), Some(range));
1026            }
1027            return;
1028        }
1029        if let Some(Frame::Image { alt, .. }) = self.stack.last_mut() {
1030            alt.push_str(&s);
1031            return;
1032        }
1033        self.ensure_inline_frame(range.clone());
1034        self.extend_top_source(range.clone());
1035        let run = self.inline.apply(text(s.clone()).code());
1036        let source = self.source_range_for_visible(range.clone(), &s);
1037        self.push_inline_mapped(run, &s, source, range, false);
1038    }
1039
1040    fn inline_math(&mut self, source: String, range: Range<usize>) {
1041        let expr = parse_tex_or_error(&source);
1042        self.ensure_inline_frame(range.clone());
1043        self.extend_top_source(range.clone());
1044        self.push_inline_mapped(math_inline(expr), "\u{fffc}", range.clone(), range, true);
1045    }
1046
1047    /// Handle a block-level `Event::Html`. Parses the HTML through
1048    /// [`damascene_html::html_blocks`] and pushes each produced block via
1049    /// the existing block-push path so blockquote / list / root
1050    /// nesting all work without special-casing.
1051    ///
1052    /// CommonMark treats block-level HTML as opaque, so each event
1053    /// arrives as a complete block (or a nearly-complete block; we
1054    /// hand whatever pulldown-cmark produced to html5ever, which is
1055    /// permissive about unclosed tags). No buffering is required for
1056    /// this v1 cut.
1057    #[cfg(feature = "html")]
1058    fn block_html(&mut self, s: String, range: Range<usize>) {
1059        let _ = range;
1060        if matches!(self.stack.last(), Some(Frame::CodeBlock { .. })) {
1061            // Defensive: pulldown-cmark shouldn't emit Html inside a
1062            // code block, but if it does, treat as raw text.
1063            if let Some(Frame::CodeBlock {
1064                text: buf,
1065                text_source,
1066                ..
1067            }) = self.stack.last_mut()
1068            {
1069                buf.push_str(&s);
1070                *text_source = union_source_ranges(text_source.take(), None);
1071            }
1072            return;
1073        }
1074        let (blocks, findings) = damascene_html::html_blocks_with_lints(&s, self.options.html);
1075        self.html_findings.extend(findings);
1076        for block in blocks {
1077            self.push_block(block);
1078        }
1079    }
1080
1081    /// Handle an inline `Event::InlineHtml`.
1082    ///
1083    /// pulldown-cmark fragments inline HTML across events: `<b>bold</b>`
1084    /// arrives as `InlineHtml("<b>")`, `Text("bold")`,
1085    /// `InlineHtml("</b>")`. A bare open tag is therefore buffered on
1086    /// [`Self::open_inline_html`] — its styling (tag semantics plus any
1087    /// inline `style="…"`) is captured into [`Self::html_template`] and
1088    /// applied to the following text events, until the matching close
1089    /// tag pops it. Markdown emphasis nested inside the pair keeps
1090    /// working because the text events still flow through the normal
1091    /// markdown inline state.
1092    ///
1093    /// Self-contained fragments (`<br>`, `<img …>`, comments, or a
1094    /// complete `<b>x</b>` in one event) parse through
1095    /// [`damascene_html::html_fragment_inline_with_lints`] as before,
1096    /// with the current markdown [`InlineState`] applied on top.
1097    #[cfg(feature = "html")]
1098    fn inline_html(&mut self, s: String, range: Range<usize>) {
1099        if matches!(self.stack.last(), Some(Frame::CodeBlock { .. })) {
1100            if let Some(Frame::CodeBlock {
1101                text: buf,
1102                text_source,
1103                ..
1104            }) = self.stack.last_mut()
1105            {
1106                buf.push_str(&s);
1107                *text_source = union_source_ranges(text_source.take(), Some(range));
1108            }
1109            return;
1110        }
1111        if let Some(Frame::Image { alt, .. }) = self.stack.last_mut() {
1112            alt.push_str(&s);
1113            return;
1114        }
1115        match classify_inline_html_fragment(&s) {
1116            InlineHtmlFragment::Open(name) if STATEFUL_INLINE_TAGS.contains(&name.as_str()) => {
1117                if self.open_inline_html.len() >= MAX_OPEN_INLINE_HTML_TAGS {
1118                    self.html_findings.push(damascene_html::Finding {
1119                        kind: damascene_html::FindingKind::UnsupportedTag,
1120                        detail: format!(
1121                            "<{name}> dropped (more than {MAX_OPEN_INLINE_HTML_TAGS} \
1122                             unclosed inline tags)"
1123                        ),
1124                    });
1125                    return;
1126                }
1127                // Lint the tag's own attributes once (style-parse
1128                // findings); the template recomputes discard theirs to
1129                // avoid duplicates.
1130                let (_, findings) =
1131                    damascene_html::html_fragment_inline_with_lints(&s, self.options.html);
1132                self.html_findings.extend(findings);
1133                self.open_inline_html.push((name, s));
1134                self.recompute_html_template();
1135                return;
1136            }
1137            InlineHtmlFragment::Close(name) => {
1138                // Innermost matching open tag wins; a stray close with
1139                // no match falls through (and parses to nothing).
1140                if let Some(pos) = self
1141                    .open_inline_html
1142                    .iter()
1143                    .rposition(|(open, _)| *open == name)
1144                {
1145                    self.open_inline_html.remove(pos);
1146                    self.recompute_html_template();
1147                    return;
1148                }
1149            }
1150            _ => {}
1151        }
1152        self.ensure_inline_frame(range.clone());
1153        self.extend_top_source(range.clone());
1154        let (runs, findings) =
1155            damascene_html::html_fragment_inline_with_lints(&s, self.options.html);
1156        self.html_findings.extend(findings);
1157        for run in runs {
1158            let styled = self.inline.apply(run);
1159            // Source mapping uses the whole InlineHtml event range —
1160            // no per-character mapping into the HTML is meaningful.
1161            let visible = styled.text.clone().unwrap_or_default();
1162            self.push_inline_mapped(styled, &visible, range.clone(), range.clone(), false);
1163        }
1164    }
1165
1166    /// Rebuild [`Self::html_template`] from the open-tag stack: parse
1167    /// the concatenated open tags around a sentinel character and keep
1168    /// the styled run the HTML transformer produces for it (html5ever
1169    /// auto-closes the dangling tags). Findings from this re-parse are
1170    /// discarded — each tag was linted once when pushed.
1171    #[cfg(feature = "html")]
1172    fn recompute_html_template(&mut self) {
1173        if self.open_inline_html.is_empty() {
1174            self.html_template = None;
1175            return;
1176        }
1177        let mut src: String = self
1178            .open_inline_html
1179            .iter()
1180            .map(|(_, raw)| raw.as_str())
1181            .collect();
1182        src.push('X');
1183        let (runs, _) = damascene_html::html_fragment_inline_with_lints(&src, self.options.html);
1184        self.html_template = runs
1185            .into_iter()
1186            .find(|run| run.text.as_deref() == Some("X"));
1187    }
1188
1189    /// Drop any unclosed inline-HTML tags at a block boundary so a
1190    /// dangling `<b>` doesn't bleed styling into the next paragraph.
1191    #[cfg(feature = "html")]
1192    fn clear_open_inline_html(&mut self) {
1193        self.open_inline_html.clear();
1194        self.html_template = None;
1195    }
1196
1197    #[cfg(not(feature = "html"))]
1198    fn clear_open_inline_html(&mut self) {}
1199
1200    fn display_math(&mut self, source: String, range: Range<usize>) {
1201        let expr = parse_tex_or_error(&source);
1202        let source_text = self.input.get(range.clone()).unwrap_or(&source).to_string();
1203        let visible = "\u{fffc}".to_string();
1204        let mut selection_source = SelectionSource::new(source_text.clone(), visible);
1205        selection_source.push_span(0.."\u{fffc}".len(), 0..source_text.len(), true);
1206        self.push_block(
1207            math_block(expr)
1208                .key(markdown_key("math", &range))
1209                .selectable()
1210                .selection_source(selection_source),
1211        );
1212    }
1213
1214    /// Lazily open a `Paragraph` frame so an inline event arriving
1215    /// directly under an `Item` (CommonMark's tight-list shape — no
1216    /// wrapping `<p>`) has somewhere to land. The matching
1217    /// `TagEnd::Item` drains any such open paragraph back into the
1218    /// item before closing it. Table cells already accept inlines
1219    /// directly so they don't need the lazy paragraph.
1220    fn ensure_inline_frame(&mut self, source_range: Range<usize>) {
1221        match self.stack.last() {
1222            Some(
1223                Frame::Paragraph(_)
1224                | Frame::Heading(_, _)
1225                | Frame::Link(_, _)
1226                | Frame::TableCell { .. },
1227            ) => {}
1228            Some(Frame::Item { .. }) => {
1229                self.push_frame(Frame::Paragraph(InlineBuffer::default()), source_range)
1230            }
1231            _ => {}
1232        }
1233    }
1234
1235    /// Append a block-level El to the innermost block container, or
1236    /// the root if none is open.
1237    fn push_block(&mut self, el: El) {
1238        for frame in self.stack.iter_mut().rev() {
1239            match frame {
1240                Frame::BlockQuote { blocks, .. } | Frame::Item { blocks, .. } => {
1241                    blocks.push(el);
1242                    return;
1243                }
1244                _ => {}
1245            }
1246        }
1247        self.root.push(el);
1248    }
1249
1250    /// Append an inline-level El to the innermost inline-accepting
1251    /// container (paragraph, heading, link, table cell). Drops if
1252    /// none is open — stray text outside a paragraph should not be
1253    /// reachable from a well-formed pulldown-cmark stream.
1254    fn current_inline_visible_len(&self) -> Option<usize> {
1255        self.stack.iter().rev().find_map(|frame| match frame {
1256            Frame::Paragraph(runs)
1257            | Frame::Heading(_, runs)
1258            | Frame::Link(_, runs)
1259            | Frame::TableCell { runs, .. } => Some(runs.visible_len()),
1260            _ => None,
1261        })
1262    }
1263
1264    fn current_inline_buffer_mut(&mut self) -> Option<&mut InlineBuffer> {
1265        self.stack.iter_mut().rev().find_map(|frame| match frame {
1266            Frame::Paragraph(runs)
1267            | Frame::Heading(_, runs)
1268            | Frame::Link(_, runs)
1269            | Frame::TableCell { runs, .. } => Some(runs),
1270            _ => None,
1271        })
1272    }
1273
1274    fn push_inline_mapped(
1275        &mut self,
1276        el: El,
1277        visible: &str,
1278        source: Range<usize>,
1279        source_full: Range<usize>,
1280        atomic: bool,
1281    ) {
1282        for frame in self.stack.iter_mut().rev() {
1283            match frame {
1284                Frame::Paragraph(runs)
1285                | Frame::Heading(_, runs)
1286                | Frame::Link(_, runs)
1287                | Frame::TableCell { runs, .. } => {
1288                    runs.push(el, visible, source, source_full, atomic);
1289                    return;
1290                }
1291                _ => {}
1292            }
1293        }
1294    }
1295
1296    fn push_inline_buffer(&mut self, buffer: InlineBuffer) {
1297        for frame in self.stack.iter_mut().rev() {
1298            match frame {
1299                Frame::Paragraph(runs)
1300                | Frame::Heading(_, runs)
1301                | Frame::Link(_, runs)
1302                | Frame::TableCell { runs, .. } => {
1303                    runs.append(buffer);
1304                    return;
1305                }
1306                _ => {}
1307            }
1308        }
1309    }
1310
1311    fn source_range_for_visible(&self, range: Range<usize>, visible: &str) -> Range<usize> {
1312        let Some(fragment) = self.input.get(range.clone()) else {
1313            return range;
1314        };
1315        let Some(start) = fragment.find(visible) else {
1316            return range;
1317        };
1318        (range.start + start)..(range.start + start + visible.len())
1319    }
1320
1321    /// [`Self::finish`], also returning the findings the embedded-HTML
1322    /// transformer emitted along the way.
1323    #[cfg(feature = "html")]
1324    fn finish_with_lints(mut self) -> (El, Vec<damascene_html::Finding>) {
1325        let findings = std::mem::take(&mut self.html_findings);
1326        (self.finish(), findings)
1327    }
1328
1329    fn finish(mut self) -> El {
1330        // Defensive: a malformed input could leave open frames. Drain
1331        // anything still on the stack into root order so we still
1332        // produce a valid El rather than panicking.
1333        while let Some((frame, source_range)) = self.pop_frame() {
1334            match frame {
1335                Frame::Paragraph(runs) => {
1336                    self.root
1337                        .push(build_paragraph(runs, &self.input, source_range))
1338                }
1339                Frame::Heading(level, runs) => {
1340                    self.root
1341                        .push(build_heading(level, runs, &self.input, source_range))
1342                }
1343                Frame::BlockQuote { kind, blocks } => {
1344                    self.root.push(build_blockquote(kind, blocks))
1345                }
1346                Frame::List { start, items } => self.root.push(build_list(start, items)),
1347                Frame::Item { blocks, .. } => self.root.push(build_list_item(blocks)),
1348                Frame::CodeBlock {
1349                    lang,
1350                    text,
1351                    text_source,
1352                    indented,
1353                } => {
1354                    let source_range = if indented {
1355                        expand_source_range_start_to_line_start(&self.input, source_range)
1356                    } else {
1357                        source_range
1358                    };
1359                    self.root.push(build_code_block(
1360                        lang.as_deref(),
1361                        text,
1362                        &self.input,
1363                        source_range,
1364                        text_source,
1365                    ))
1366                }
1367                Frame::Link(_, runs) => {
1368                    for run in runs.into_runs() {
1369                        self.root.push(run);
1370                    }
1371                }
1372                Frame::Image {
1373                    alt,
1374                    dest_url,
1375                    title,
1376                } => self
1377                    .root
1378                    .push(build_image_placeholder(&alt, &dest_url, &title)),
1379                Frame::Table { head, body, .. } => self.root.push(build_table(head, body)),
1380                Frame::TableHead(_) | Frame::TableRow(_) | Frame::TableCell { .. } => {
1381                    // Cells / rows whose enclosing table never closed
1382                    // can't usefully be rendered standalone — drop.
1383                }
1384            }
1385        }
1386        column(self.root)
1387            .gap(tokens::SPACE_4)
1388            .width(Size::Fill(1.0))
1389            .height(Size::Hug)
1390    }
1391
1392    fn task_list_marker(&mut self, checked: bool) {
1393        for frame in self.stack.iter_mut().rev() {
1394            if let Frame::Item { task_checked, .. } = frame {
1395                *task_checked = Some(checked);
1396                return;
1397            }
1398        }
1399    }
1400
1401    fn in_inline_container(&self) -> bool {
1402        matches!(
1403            self.stack.last(),
1404            Some(
1405                Frame::Paragraph(_)
1406                    | Frame::Heading(_, _)
1407                    | Frame::Link(_, _)
1408                    | Frame::TableCell { .. }
1409            )
1410        )
1411    }
1412
1413    fn inside_blockquote(&self) -> bool {
1414        self.stack
1415            .iter()
1416            .rev()
1417            .skip(1)
1418            .any(|frame| matches!(frame, Frame::BlockQuote { .. }))
1419    }
1420
1421    fn next_table_cell_alignment(&self) -> Alignment {
1422        let index = match self.stack.last() {
1423            Some(Frame::TableHead(cells)) | Some(Frame::TableRow(cells)) => cells.len(),
1424            _ => 0,
1425        };
1426        self.stack
1427            .iter()
1428            .rev()
1429            .find_map(|frame| {
1430                if let Frame::Table { alignments, .. } = frame {
1431                    alignments.get(index).copied()
1432                } else {
1433                    None
1434                }
1435            })
1436            .unwrap_or(Alignment::None)
1437    }
1438}
1439
1440/// Build a fenced/indented code block. With the `highlighting` feature
1441/// and a recognised `lang`, tokenise the body through `syntect` and
1442/// wrap the styled `text_runs([...])` paragraph in the standard
1443/// [`code_block_chrome`] surface — palette tokens flow through to
1444/// paint, so `Theme::damascene_light()` recolours the result without
1445/// re-rendering the markdown. Otherwise (feature off, no lang,
1446/// unknown lang) fall through to the plain-mono [`code_block`] path.
1447fn build_code_block(
1448    lang: Option<&str>,
1449    raw_text: String,
1450    input: &str,
1451    source_range: Option<Range<usize>>,
1452    text_source: Option<Range<usize>>,
1453) -> El {
1454    let body = strip_trailing_newline(raw_text);
1455    let body_selection =
1456        code_block_selection_source(input, source_range.clone(), text_source, &body);
1457    let body_key_range = source_range.clone();
1458    #[cfg(feature = "highlighting")]
1459    if let Some(lang) = lang
1460        && let Some(syntax) = crate::highlight::find_syntax(lang)
1461    {
1462        let runs = crate::highlight::highlight_to_runs(&body, syntax);
1463        if !runs.is_empty() {
1464            let mut body = text_runs(runs)
1465                .mono()
1466                .nowrap_text()
1467                .font_size(tokens::TEXT_SM.size)
1468                .width(Size::Hug)
1469                .height(Size::Hug);
1470            if let Some(source) = body_selection.clone() {
1471                body = body
1472                    .key(markdown_key(
1473                        "code",
1474                        &body_key_range.clone().unwrap_or(0..source.source.len()),
1475                    ))
1476                    .selectable()
1477                    .selection_source(source);
1478            }
1479            return code_block_chrome(body);
1480        }
1481    }
1482    #[cfg(not(feature = "highlighting"))]
1483    let _ = lang;
1484    let mut body_el = text(body.clone())
1485        .mono()
1486        .font_size(tokens::TEXT_SM.size)
1487        .nowrap_text()
1488        .width(Size::Hug)
1489        .height(Size::Hug);
1490    if let Some(source) = body_selection {
1491        body_el = body_el
1492            .key(markdown_key(
1493                "code",
1494                &body_key_range.unwrap_or(0..source.source.len()),
1495            ))
1496            .selectable()
1497            .selection_source(source);
1498    }
1499    code_block_chrome(body_el)
1500}
1501
1502fn code_block_selection_source(
1503    input: &str,
1504    source_range: Option<Range<usize>>,
1505    text_source: Option<Range<usize>>,
1506    visible: &str,
1507) -> Option<SelectionSource> {
1508    let source_range = source_range?;
1509    let source_text = input.get(source_range.clone())?.to_string();
1510    let mut source = SelectionSource::new(source_text.clone(), visible.to_string());
1511    if visible.is_empty() {
1512        return Some(source);
1513    }
1514
1515    let search_range = text_source
1516        .map(|range| trim_source_range_end(input, range))
1517        .and_then(|range| {
1518            (range.start >= source_range.start && range.end <= source_range.end)
1519                .then_some((range.start - source_range.start)..(range.end - source_range.start))
1520        })
1521        .unwrap_or(0..source_text.len());
1522
1523    let mut visible_start = 0;
1524    let mut source_cursor = search_range.start;
1525    for segment in visible.split_inclusive('\n') {
1526        if segment.is_empty() {
1527            continue;
1528        }
1529        let search = &source.source[source_cursor..search_range.end];
1530        let found = search.find(segment)?;
1531        let source_start = source_cursor + found;
1532        let source_end = source_start + segment.len();
1533        let visible_end = visible_start + segment.len();
1534        source.push_span_with_full_source(
1535            visible_start..visible_end,
1536            source_start..source_end,
1537            source_start..source_end,
1538            false,
1539        );
1540        visible_start = visible_end;
1541        source_cursor = source_end;
1542    }
1543    Some(source)
1544}
1545
1546fn union_source_ranges(a: Option<Range<usize>>, b: Option<Range<usize>>) -> Option<Range<usize>> {
1547    match (a, b) {
1548        (Some(a), Some(b)) => Some(a.start.min(b.start)..a.end.max(b.end)),
1549        (Some(a), None) => Some(a),
1550        (None, Some(b)) => Some(b),
1551        (None, None) => None,
1552    }
1553}
1554
1555fn trim_source_range_end(input: &str, mut range: Range<usize>) -> Range<usize> {
1556    while range.end > range.start {
1557        let Some((idx, ch)) = input[..range.end].char_indices().next_back() else {
1558            break;
1559        };
1560        if matches!(ch, '\n' | '\r') {
1561            range.end = idx;
1562        } else {
1563            break;
1564        }
1565    }
1566    range
1567}
1568
1569fn expand_table_header_source_to_delimiter(input: &str, mut range: Range<usize>) -> Range<usize> {
1570    let mut cursor = range.end;
1571    if input.as_bytes().get(cursor) == Some(&b'\n') {
1572        cursor += 1;
1573    }
1574
1575    let delimiter_start = cursor;
1576    let delimiter_end = input[delimiter_start..]
1577        .find('\n')
1578        .map(|end| delimiter_start + end)
1579        .unwrap_or(input.len());
1580    let delimiter = input[delimiter_start..delimiter_end].trim();
1581    if is_table_delimiter_row(delimiter) {
1582        range.end = delimiter_end;
1583    }
1584    range
1585}
1586
1587fn is_table_delimiter_row(line: &str) -> bool {
1588    let mut saw_dash = false;
1589    for ch in line.chars() {
1590        match ch {
1591            '|' | ':' | '-' | ' ' | '\t' => {
1592                saw_dash |= ch == '-';
1593            }
1594            _ => return false,
1595        }
1596    }
1597    saw_dash
1598}
1599
1600fn expand_source_range_start_to_line_start(
1601    input: &str,
1602    range: Option<Range<usize>>,
1603) -> Option<Range<usize>> {
1604    let mut range = range?;
1605    while range.start > 0 && input.as_bytes().get(range.start - 1) != Some(&b'\n') {
1606        range.start -= 1;
1607    }
1608    Some(range)
1609}
1610
1611fn parse_tex_or_error(source: &str) -> MathExpr {
1612    match parse_tex(source) {
1613        Ok(expr) => expr,
1614        Err(err) => MathExpr::Error(format!("math parse error at {}: {}", err.byte, err.message)),
1615    }
1616}
1617
1618/// Build a paragraph block. A single plain `text(...)` run can become
1619/// a `paragraph(...)` (one wrapped string); anything richer collapses
1620/// to `text_runs([...])`.
1621fn build_paragraph(inlines: InlineBuffer, input: &str, source_range: Option<Range<usize>>) -> El {
1622    let key_range = source_range.clone();
1623    let selection_source = inlines.selection_source(input, source_range);
1624    let runs = inlines.into_runs();
1625    let mut el = if let Some(plain) = single_plain_text(&runs) {
1626        paragraph(plain)
1627    } else {
1628        text_runs(runs)
1629            .wrap_text()
1630            .width(Size::Fill(1.0))
1631            .height(Size::Hug)
1632    };
1633    if let Some(source) = selection_source {
1634        el = el
1635            .key(markdown_key(
1636                "p",
1637                &key_range.unwrap_or(0..source.source.len()),
1638            ))
1639            .selectable()
1640            .selection_source(source);
1641    }
1642    el
1643}
1644
1645fn with_source_selection(
1646    mut el: El,
1647    kind: &str,
1648    source: Option<SelectionSource>,
1649    key_range: Option<Range<usize>>,
1650) -> El {
1651    if let Some(source) = source {
1652        el = el
1653            .key(markdown_key(
1654                kind,
1655                &key_range.unwrap_or(0..source.source.len()),
1656            ))
1657            .selectable()
1658            .selection_source(source);
1659    }
1660    el
1661}
1662
1663fn with_atomic_source_selection(
1664    mut el: El,
1665    kind: &str,
1666    input: &str,
1667    source_range: Option<Range<usize>>,
1668    visible: String,
1669) -> El {
1670    let Some(source_range) = source_range else {
1671        return el;
1672    };
1673    let Some(source_text) = input.get(source_range.clone()) else {
1674        return el;
1675    };
1676    let mut source = SelectionSource::new(source_text.to_string(), visible.clone());
1677    source.push_span(0..visible.len(), 0..source_text.len(), true);
1678    el = el
1679        .key(markdown_key(kind, &source_range))
1680        .selectable()
1681        .selection_source(source);
1682    el
1683}
1684
1685fn markdown_key(kind: &str, range: &Range<usize>) -> String {
1686    format!("md:{kind}:{}..{}", range.start, range.end)
1687}
1688
1689/// Build a heading block. For headings whose only content is plain
1690/// text, use `h1` / `h2` / `h3` so the result carries the semantic
1691/// `Kind::Heading` (visible in tree dumps and inspect output). For
1692/// styled headings we fall back to a heading-roled `text_runs`.
1693fn build_heading(
1694    level: HeadingLevel,
1695    inlines: InlineBuffer,
1696    input: &str,
1697    source_range: Option<Range<usize>>,
1698) -> El {
1699    let key_range = source_range.clone();
1700    let selection_source = inlines.selection_source(input, source_range);
1701    let runs = inlines.into_runs();
1702    if let Some(plain) = single_plain_text(&runs) {
1703        let el = match level {
1704            HeadingLevel::H1 => h1(plain),
1705            HeadingLevel::H2 => h2(plain),
1706            // h4–h6 are rare and Damascene's heading vocabulary stops at
1707            // h3 — clamp the rest so deep nesting still renders.
1708            _ => h3(plain),
1709        };
1710        return with_source_selection(el, "h", selection_source, key_range);
1711    }
1712    let role = match level {
1713        HeadingLevel::H1 => TextRole::Display,
1714        HeadingLevel::H2 => TextRole::Heading,
1715        _ => TextRole::Title,
1716    };
1717    let el = text_runs(runs)
1718        .text_role(role)
1719        .wrap_text()
1720        .width(Size::Fill(1.0))
1721        .height(Size::Hug);
1722    with_source_selection(el, "h", selection_source, key_range)
1723}
1724
1725fn build_blockquote(kind: Option<BlockQuoteKind>, blocks: Vec<El>) -> El {
1726    let Some(kind) = kind else {
1727        return blockquote(blocks);
1728    };
1729
1730    let title = match kind {
1731        BlockQuoteKind::Note => "Note",
1732        BlockQuoteKind::Tip => "Tip",
1733        BlockQuoteKind::Important => "Important",
1734        BlockQuoteKind::Warning => "Warning",
1735        BlockQuoteKind::Caution => "Caution",
1736    };
1737    let body = match blocks.len() {
1738        0 => column(Vec::<El>::new()),
1739        1 => blocks.into_iter().next().unwrap(),
1740        _ => column(blocks)
1741            .gap(tokens::SPACE_2)
1742            .width(Size::Fill(1.0))
1743            .height(Size::Hug),
1744    };
1745    let alert = alert([alert_title(title), body]);
1746    match kind {
1747        BlockQuoteKind::Note | BlockQuoteKind::Important => alert.info(),
1748        BlockQuoteKind::Tip => alert.success(),
1749        BlockQuoteKind::Warning => alert.warning(),
1750        BlockQuoteKind::Caution => alert.destructive(),
1751    }
1752}
1753
1754fn build_list(start: Option<u64>, items: Vec<ListItem>) -> El {
1755    match start {
1756        None if !items.is_empty() && items.iter().all(|item| item.task_checked.is_some()) => {
1757            task_list(
1758                items
1759                    .into_iter()
1760                    .map(|item| (item.task_checked.unwrap_or(false), item.content)),
1761            )
1762        }
1763        None => bullet_list(items.into_iter().map(|item| item.content)),
1764        Some(start) => numbered_list_from(start, items.into_iter().map(|item| item.content)),
1765    }
1766}
1767
1768/// Collapse one item's accumulated blocks into a single El. Single
1769/// block → that block; multiple blocks → wrap in `column`.
1770fn build_list_item(mut blocks: Vec<El>) -> El {
1771    if blocks.len() == 1 {
1772        blocks.pop().unwrap()
1773    } else {
1774        column(blocks)
1775            .gap(tokens::SPACE_2)
1776            .width(Size::Fill(1.0))
1777            .height(Size::Hug)
1778    }
1779}
1780
1781/// Build a `widgets::table` block from the parsed header and body
1782/// rows. Falls back to body-only if no header was emitted.
1783fn build_table(head: Option<Vec<El>>, body: Vec<Vec<El>>) -> El {
1784    // `body` came in as `Vec<Vec<El>>` (one outer per row) but each
1785    // inner Vec is single-element since `TagEnd::TableRow` already
1786    // built one `table_row(...)` per row. Re-flatten into row-Els.
1787    let body_rows: Vec<El> = body.into_iter().flatten().collect();
1788    match head {
1789        Some(header_rows) => table([table_header(header_rows), table_body(body_rows)]),
1790        None => table([table_body(body_rows)]),
1791    }
1792}
1793
1794fn normalize_table_head_rows(items: Vec<El>) -> Vec<El> {
1795    if items.is_empty()
1796        || items
1797            .iter()
1798            .all(|item| item.metrics_role == Some(MetricsRole::TableRow))
1799    {
1800        items
1801    } else {
1802        vec![table_row(items)]
1803    }
1804}
1805
1806/// Wrap accumulated inline runs into a header-styled or body-styled
1807/// table cell. Plain-text-only cells flow through `table_head` /
1808/// `text(...)` so the cell carries the right typography defaults; mixed
1809/// inline content uses `text_runs([...])` and keeps per-run styling.
1810fn build_table_cell(
1811    inlines: InlineBuffer,
1812    in_header: bool,
1813    alignment: Alignment,
1814    input: &str,
1815    source_range: Option<Range<usize>>,
1816    row_source_range: Option<Range<usize>>,
1817) -> El {
1818    let key_range = source_range.clone().or_else(|| row_source_range.clone());
1819    let row_group = row_source_range
1820        .as_ref()
1821        .map(|range| format!("md:table-row:{}..{}", range.start, range.end));
1822    let selection_source_range = row_source_range.or(source_range);
1823    let selection_source = inlines
1824        .selection_source(input, selection_source_range)
1825        .map(|source| {
1826            if let Some(group) = row_group {
1827                source.full_selection_group(group)
1828            } else {
1829                source
1830            }
1831        });
1832    let runs = inlines.into_runs();
1833    if in_header {
1834        let cell = if let Some(plain) = single_plain_text(&runs) {
1835            table_head(plain)
1836        } else if runs.is_empty() {
1837            table_head("")
1838        } else {
1839            table_head_el(text_runs(runs).width(Size::Fill(1.0)))
1840        };
1841        let cell = with_source_selection(cell, "th", selection_source, key_range);
1842        return apply_table_alignment(cell, alignment);
1843    }
1844    let cell = if let Some(plain) = single_plain_text(&runs) {
1845        table_cell(text(plain))
1846    } else if runs.is_empty() {
1847        table_cell(text(""))
1848    } else {
1849        table_cell(text_runs(runs).width(Size::Fill(1.0)))
1850    };
1851    let cell = with_source_selection(cell, "td", selection_source, key_range);
1852    apply_table_alignment(cell, alignment)
1853}
1854
1855fn apply_table_alignment(mut el: El, alignment: Alignment) -> El {
1856    let text_align = match alignment {
1857        Alignment::None | Alignment::Left => TextAlign::Start,
1858        Alignment::Center => TextAlign::Center,
1859        Alignment::Right => TextAlign::End,
1860    };
1861    apply_text_align(&mut el, text_align);
1862    el
1863}
1864
1865fn apply_text_align(el: &mut El, text_align: TextAlign) {
1866    el.text_align = text_align;
1867    for child in &mut el.children {
1868        apply_text_align(child, text_align);
1869    }
1870}
1871
1872fn build_image_placeholder(alt: &str, dest_url: &str, title: &str) -> El {
1873    // Phase 2 doesn't wire image loading. Surface the alt text plus
1874    // source metadata so the page reads sensibly until image resolution
1875    // lands; muted + italic so it doesn't look like first-class content.
1876    let label = image_placeholder_label(alt, dest_url, title);
1877    let mut el = text(label).muted().italic();
1878    if !dest_url.is_empty() {
1879        el = el.link(dest_url.to_string());
1880    }
1881    el
1882}
1883
1884fn image_placeholder_label(alt: &str, dest_url: &str, title: &str) -> String {
1885    let mut label = match (alt.is_empty(), dest_url.is_empty()) {
1886        (true, true) => "[image]".to_string(),
1887        (false, true) => format!("[image: {alt}]"),
1888        (true, false) => format!("[image: {dest_url}]"),
1889        (false, false) => format!("[image: {alt}] {dest_url}"),
1890    };
1891    if !title.is_empty() {
1892        label.push_str(" \"");
1893        label.push_str(title);
1894        label.push('"');
1895    }
1896    label
1897}
1898
1899/// Inspect a run vector and return a single plain string if every run
1900/// is a default-styled `Kind::Text` leaf (no bold, italic, strike,
1901/// link, code, custom color). Drives the heading + paragraph builder
1902/// fast paths. The `Body` role's auto-applied `FOREGROUND` text color
1903/// counts as "default"; a run that explicitly sets a different color
1904/// disqualifies the fast path.
1905fn single_plain_text(runs: &[El]) -> Option<String> {
1906    let mut out = String::new();
1907    for run in runs {
1908        if !matches!(run.kind, Kind::Text) {
1909            return None;
1910        }
1911        if run.font_weight != FontWeight::Regular
1912            || run.text_italic
1913            || run.text_strikethrough
1914            || run.text_underline
1915            || run.text_link.is_some()
1916            || run.text_role != TextRole::Body
1917        {
1918            return None;
1919        }
1920        // Body role auto-sets `text_color = Some(FOREGROUND)`. Treat
1921        // `None` and `Some(FOREGROUND)` both as "default"; anything
1922        // else (a `.color(...)` override) is styled.
1923        if let Some(c) = run.text_color
1924            && c != tokens::FOREGROUND
1925        {
1926            return None;
1927        }
1928        let s = run.text.as_deref()?;
1929        out.push_str(s);
1930    }
1931    Some(out)
1932}
1933
1934/// Trim a single trailing `\n` (pulldown-cmark always emits one at
1935/// the end of a fenced or indented code block).
1936fn strip_trailing_newline(mut s: String) -> String {
1937    if s.ends_with('\n') {
1938        s.pop();
1939    }
1940    s
1941}
1942
1943#[cfg(test)]
1944mod tests {
1945    use super::*;
1946    use damascene_core::draw_ops::draw_ops;
1947    use damascene_core::ir::DrawOp;
1948    use damascene_core::layout::layout;
1949    use damascene_core::selection::{Selection, SelectionPoint, SelectionRange, selected_text};
1950    use damascene_core::state::UiState;
1951
1952    /// The transformer always wraps blocks in a `column`. Reach into
1953    /// it for the test assertions.
1954    fn blocks(input: &str) -> Vec<El> {
1955        match md(input) {
1956            el if matches!(el.kind, Kind::Group) && el.axis == Axis::Column => el.children,
1957            other => panic!("expected outer column, got {:?}", other.kind),
1958        }
1959    }
1960
1961    fn blocks_with_options(input: &str, options: MarkdownOptions) -> Vec<El> {
1962        match md_with_options(input, options) {
1963            el if matches!(el.kind, Kind::Group) && el.axis == Axis::Column => el.children,
1964            other => panic!("expected outer column, got {:?}", other.kind),
1965        }
1966    }
1967
1968    fn first_source_backed(el: &El) -> Option<&El> {
1969        if el.selection_source.is_some() {
1970            return Some(el);
1971        }
1972        el.children.iter().find_map(first_source_backed)
1973    }
1974
1975    fn collect_source_backed<'a>(el: &'a El, out: &mut Vec<&'a El>) {
1976        if el.selection_source.is_some() {
1977            out.push(el);
1978        }
1979        for child in &el.children {
1980            collect_source_backed(child, out);
1981        }
1982    }
1983
1984    fn selection(key: &str, start: usize, end: usize) -> Selection {
1985        Selection {
1986            range: Some(SelectionRange {
1987                anchor: SelectionPoint::new(key, start),
1988                head: SelectionPoint::new(key, end),
1989            }),
1990        }
1991    }
1992
1993    #[test]
1994    fn empty_document_yields_an_empty_column() {
1995        let bs = blocks("");
1996        assert!(bs.is_empty());
1997    }
1998
1999    #[test]
2000    fn h1_h2_h3_map_to_heading_constructors() {
2001        let bs = blocks("# Title\n\n## Subtitle\n\n### Section");
2002        assert_eq!(bs.len(), 3);
2003        assert_eq!(bs[0].kind, Kind::Heading);
2004        assert_eq!(bs[0].text.as_deref(), Some("Title"));
2005        assert_eq!(bs[0].text_role, TextRole::Display);
2006        assert_eq!(bs[1].text_role, TextRole::Heading);
2007        assert_eq!(bs[2].text_role, TextRole::Title);
2008    }
2009
2010    #[test]
2011    fn h4_h5_h6_clamp_to_h3() {
2012        let bs = blocks("#### Four\n\n##### Five\n\n###### Six");
2013        for b in &bs {
2014            assert_eq!(b.kind, Kind::Heading);
2015            assert_eq!(b.text_role, TextRole::Title);
2016        }
2017    }
2018
2019    #[test]
2020    fn markdown_heading_selection_copies_heading_marker_when_whole_heading_selected() {
2021        let input = "## Subtitle";
2022        let doc = md(input);
2023        let node = first_source_backed(&doc).expect("source-backed heading");
2024        let source = node.selection_source.as_ref().unwrap();
2025        let key = node.key.as_deref().unwrap();
2026
2027        assert_eq!(
2028            selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2029            Some(input)
2030        );
2031        assert_eq!(
2032            selected_text(&doc, &selection(key, 1, source.visible_len() - 1)).as_deref(),
2033            Some("ubtitl")
2034        );
2035    }
2036
2037    #[test]
2038    fn markdown_blockquote_selection_copies_quote_marker_when_whole_line_selected() {
2039        let input = "> Quoted text";
2040        let doc = md(input);
2041        let node = first_source_backed(&doc).expect("source-backed quote paragraph");
2042        let source = node.selection_source.as_ref().unwrap();
2043        let key = node.key.as_deref().unwrap();
2044
2045        assert_eq!(
2046            selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2047            Some(input)
2048        );
2049        assert_eq!(
2050            selected_text(&doc, &selection(key, 1, source.visible_len() - 1)).as_deref(),
2051            Some("uoted tex")
2052        );
2053    }
2054
2055    #[test]
2056    fn markdown_blockquote_selection_preserves_markers_for_heading_and_list_items() {
2057        let input = "> ## Quoted heading\n>\n> - quoted item";
2058        let doc = md(input);
2059        let mut nodes = Vec::new();
2060        collect_source_backed(&doc, &mut nodes);
2061
2062        let selected_whole = |visible: &str| {
2063            let node = nodes
2064                .iter()
2065                .find(|node| {
2066                    node.selection_source
2067                        .as_ref()
2068                        .is_some_and(|source| source.visible == visible)
2069                })
2070                .expect("source-backed quoted node");
2071            let source = node.selection_source.as_ref().unwrap();
2072            let key = node.key.as_deref().unwrap();
2073            selected_text(&doc, &selection(key, 0, source.visible_len()))
2074        };
2075
2076        assert_eq!(
2077            selected_whole("Quoted heading").as_deref(),
2078            Some("> ## Quoted heading")
2079        );
2080        assert_eq!(
2081            selected_whole("quoted item").as_deref(),
2082            Some("> - quoted item")
2083        );
2084    }
2085
2086    #[test]
2087    fn plain_paragraph_collapses_to_paragraph_widget() {
2088        let bs = blocks("Just some prose.");
2089        assert_eq!(bs.len(), 1);
2090        assert_eq!(bs[0].kind, Kind::Text);
2091        assert_eq!(bs[0].text.as_deref(), Some("Just some prose."));
2092        assert_eq!(bs[0].text_wrap, TextWrap::Wrap);
2093    }
2094
2095    #[test]
2096    fn paragraph_with_inline_styling_uses_text_runs() {
2097        let bs = blocks("Hello **world** and *italic* and `code`.");
2098        assert_eq!(bs.len(), 1);
2099        assert_eq!(bs[0].kind, Kind::Inlines);
2100        let runs: Vec<&El> = bs[0].children.iter().collect();
2101        // Plain "Hello " + bold "world" + " and " + italic "italic" +
2102        // " and " + code "code" + ".".
2103        assert!(
2104            runs.iter()
2105                .any(|r| r.font_weight == FontWeight::Bold && r.text.as_deref() == Some("world"))
2106        );
2107        assert!(
2108            runs.iter()
2109                .any(|r| r.text_italic && r.text.as_deref() == Some("italic"))
2110        );
2111        assert!(
2112            runs.iter()
2113                .any(|r| r.text_role == TextRole::Code && r.text.as_deref() == Some("code"))
2114        );
2115    }
2116
2117    #[test]
2118    fn markdown_selection_copies_paragraph_source() {
2119        let input = "This is **bold**.";
2120        let doc = md(input);
2121        let node = first_source_backed(&doc).expect("source-backed paragraph");
2122        let source = node.selection_source.as_ref().unwrap();
2123        let key = node.key.as_deref().unwrap();
2124
2125        let bold_start = source.visible.find("bold").unwrap();
2126        let bold_end = bold_start + "bold".len();
2127        assert_eq!(
2128            selected_text(&doc, &selection(key, bold_start, bold_end)).as_deref(),
2129            Some("**bold**")
2130        );
2131        assert_eq!(
2132            selected_text(&doc, &selection(key, bold_start + 1, bold_end - 1)).as_deref(),
2133            Some("ol")
2134        );
2135        assert_eq!(
2136            selected_text(&doc, &selection(key, 0, bold_end)).as_deref(),
2137            Some("This is **bold**")
2138        );
2139        assert_eq!(
2140            selected_text(&doc, &selection(key, bold_start, source.visible_len())).as_deref(),
2141            Some("**bold**.")
2142        );
2143        assert_eq!(
2144            selected_text(&doc, &selection(key, bold_start + 1, source.visible_len())).as_deref(),
2145            Some("old.")
2146        );
2147        assert_eq!(
2148            selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2149            Some(input)
2150        );
2151    }
2152
2153    #[test]
2154    fn markdown_selection_copies_all_delimiters_when_styled_text_fills_paragraph() {
2155        let input = "**bold**";
2156        let doc = md(input);
2157        let node = first_source_backed(&doc).expect("source-backed paragraph");
2158        let source = node.selection_source.as_ref().unwrap();
2159        let key = node.key.as_deref().unwrap();
2160
2161        assert_eq!(source.visible, "bold");
2162        assert_eq!(
2163            selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2164            Some(input)
2165        );
2166    }
2167
2168    #[test]
2169    fn markdown_selection_copies_full_inline_construct_source() {
2170        let input = "Use `code` and [site](https://damascene.dev).";
2171        let doc = md(input);
2172        let node = first_source_backed(&doc).expect("source-backed paragraph");
2173        let source = node.selection_source.as_ref().unwrap();
2174        let key = node.key.as_deref().unwrap();
2175
2176        let code_start = source.visible.find("code").unwrap();
2177        let code_end = code_start + "code".len();
2178        assert_eq!(
2179            selected_text(&doc, &selection(key, code_start, code_end)).as_deref(),
2180            Some("`code`")
2181        );
2182        assert_eq!(
2183            selected_text(&doc, &selection(key, code_start + 1, code_end - 1)).as_deref(),
2184            Some("od")
2185        );
2186
2187        let site_start = source.visible.find("site").unwrap();
2188        let site_end = site_start + "site".len();
2189        assert_eq!(
2190            selected_text(&doc, &selection(key, site_start, site_end)).as_deref(),
2191            Some("[site](https://damascene.dev)")
2192        );
2193        assert_eq!(
2194            selected_text(&doc, &selection(key, site_start + 1, site_end - 1)).as_deref(),
2195            Some("it")
2196        );
2197    }
2198
2199    #[test]
2200    fn markdown_list_selection_does_not_pull_in_previous_document_source() {
2201        let input = "Intro paragraph.\n\n- first item\n- second item\n- third item";
2202        let doc = md(input);
2203        let mut nodes = Vec::new();
2204        collect_source_backed(&doc, &mut nodes);
2205        let list_nodes: Vec<&El> = nodes
2206            .into_iter()
2207            .filter(|node| {
2208                node.selection_source
2209                    .as_ref()
2210                    .is_some_and(|source| source.visible.contains("item"))
2211            })
2212            .collect();
2213        assert_eq!(list_nodes.len(), 3);
2214
2215        let first = list_nodes[0];
2216        let third = list_nodes[2];
2217        let first_key = first.key.as_deref().unwrap();
2218        let third_key = third.key.as_deref().unwrap();
2219        let third_len = third.selection_source.as_ref().unwrap().visible_len();
2220
2221        let selected = selected_text(
2222            &doc,
2223            &Selection {
2224                range: Some(SelectionRange {
2225                    anchor: SelectionPoint::new(first_key, 0),
2226                    head: SelectionPoint::new(third_key, third_len),
2227                }),
2228            },
2229        );
2230        assert_eq!(
2231            selected.as_deref(),
2232            Some("- first item\n- second item\n- third item")
2233        );
2234    }
2235
2236    #[test]
2237    fn markdown_list_whole_item_selection_copies_marker_source() {
2238        let input = "Intro paragraph.\n\n- first item\n- second item";
2239        let doc = md(input);
2240        let mut nodes = Vec::new();
2241        collect_source_backed(&doc, &mut nodes);
2242        let first_item = nodes
2243            .into_iter()
2244            .find(|node| {
2245                node.selection_source
2246                    .as_ref()
2247                    .is_some_and(|source| source.visible == "first item")
2248            })
2249            .expect("first list item");
2250        let source = first_item.selection_source.as_ref().unwrap();
2251        let key = first_item.key.as_deref().unwrap();
2252
2253        assert_eq!(
2254            selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2255            Some("- first item")
2256        );
2257        assert_eq!(
2258            selected_text(&doc, &selection(key, 1, source.visible_len() - 1)).as_deref(),
2259            Some("irst ite")
2260        );
2261    }
2262
2263    #[test]
2264    fn markdown_list_selection_preserves_ordered_and_task_markers() {
2265        let input = "3. third item\n4. fourth item\n\n- [x] done item\n- [ ] todo item";
2266        let doc = md(input);
2267        let mut nodes = Vec::new();
2268        collect_source_backed(&doc, &mut nodes);
2269
2270        let selected_whole_item = |visible: &str| {
2271            let item = nodes
2272                .iter()
2273                .find(|node| {
2274                    node.selection_source
2275                        .as_ref()
2276                        .is_some_and(|source| source.visible == visible)
2277                })
2278                .expect("list item");
2279            let source = item.selection_source.as_ref().unwrap();
2280            let key = item.key.as_deref().unwrap();
2281            selected_text(&doc, &selection(key, 0, source.visible_len()))
2282        };
2283
2284        assert_eq!(
2285            selected_whole_item("third item").as_deref(),
2286            Some("3. third item")
2287        );
2288        assert_eq!(
2289            selected_whole_item("done item").as_deref(),
2290            Some("- [x] done item")
2291        );
2292        assert_eq!(
2293            selected_whole_item("todo item").as_deref(),
2294            Some("- [ ] todo item")
2295        );
2296    }
2297
2298    #[test]
2299    fn math_option_routes_inline_and_display_math_to_math_nodes() {
2300        let bs = blocks_with_options(
2301            "Euler $e^{i\\pi}+1=0$\n\n$$\\frac{a}{b}$$",
2302            MarkdownOptions::default().math(true),
2303        );
2304        assert_eq!(bs.len(), 2);
2305        assert_eq!(bs[0].kind, Kind::Inlines);
2306        assert!(
2307            bs[0]
2308                .children
2309                .iter()
2310                .any(|child| matches!(child.kind, Kind::Math) && child.math.is_some())
2311        );
2312        assert_eq!(bs[1].kind, Kind::Math);
2313        assert_eq!(bs[1].math_display, MathDisplay::Block);
2314    }
2315
2316    #[test]
2317    fn inline_math_selection_copies_original_tex_source_atomically() {
2318        let input = "Inline $x_1^2$ math.";
2319        let doc = md_with_options(input, MarkdownOptions::default().math(true));
2320        let node = first_source_backed(&doc).expect("source-backed paragraph");
2321        let source = node.selection_source.as_ref().unwrap();
2322        let key = node.key.as_deref().unwrap();
2323        let math_start = source.visible.find('\u{fffc}').unwrap();
2324        let math_end = math_start + "\u{fffc}".len();
2325
2326        assert_eq!(
2327            selected_text(&doc, &selection(key, math_start, math_end)).as_deref(),
2328            Some("$x_1^2$")
2329        );
2330        assert_eq!(
2331            selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2332            Some(input)
2333        );
2334    }
2335
2336    #[test]
2337    fn display_math_selection_copies_original_tex_source_atomically() {
2338        let input = "$$\\frac{a}{b}$$";
2339        let doc = md_with_options(input, MarkdownOptions::default().math(true));
2340        let node = first_source_backed(&doc).expect("source-backed display math");
2341        let source = node.selection_source.as_ref().unwrap();
2342        let key = node.key.as_deref().unwrap();
2343
2344        assert_eq!(
2345            selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2346            Some(input)
2347        );
2348    }
2349
2350    #[test]
2351    fn link_groups_runs_under_the_same_url() {
2352        let bs = blocks("Check [the **bold** site](https://damascene.dev) for info.");
2353        assert_eq!(bs[0].kind, Kind::Inlines);
2354        let linked: Vec<&El> = bs[0]
2355            .children
2356            .iter()
2357            .filter(|r| r.text_link.as_deref() == Some("https://damascene.dev"))
2358            .collect();
2359        assert!(!linked.is_empty(), "expected at least one linked run");
2360        // The bold word inside the link keeps its bold flag plus the
2361        // shared href.
2362        assert!(linked.iter().any(|r| r.font_weight == FontWeight::Bold));
2363    }
2364
2365    #[test]
2366    fn bullet_list_emits_bullet_list_widget() {
2367        let bs = blocks("- one\n- two\n- three");
2368        assert_eq!(bs.len(), 1);
2369        // bullet_list returns a column of overlay-stack items — the
2370        // children count matches the item count.
2371        assert_eq!(bs[0].kind, Kind::Group);
2372        assert_eq!(bs[0].axis, Axis::Column);
2373        assert_eq!(bs[0].children.len(), 3);
2374    }
2375
2376    #[test]
2377    fn ordered_list_emits_numbered_list_widget() {
2378        let bs = blocks("1. alpha\n2. beta\n3. gamma");
2379        assert_eq!(bs[0].kind, Kind::Group);
2380        assert_eq!(bs[0].axis, Axis::Column);
2381        assert_eq!(bs[0].children.len(), 3);
2382        // The first item's marker slot should carry the "1." label.
2383        let first_marker_slot = &bs[0].children[0].children[0];
2384        let first_marker = &first_marker_slot.children[0];
2385        assert_eq!(first_marker.text.as_deref(), Some("1."));
2386    }
2387
2388    #[test]
2389    fn ordered_list_preserves_non_one_start_number() {
2390        let bs = blocks("42. alpha\n43. beta");
2391        assert_eq!(bs[0].children.len(), 2);
2392        let first_marker_slot = &bs[0].children[0].children[0];
2393        let second_marker_slot = &bs[0].children[1].children[0];
2394        assert_eq!(first_marker_slot.children[0].text.as_deref(), Some("42."));
2395        assert_eq!(second_marker_slot.children[0].text.as_deref(), Some("43."));
2396    }
2397
2398    #[test]
2399    fn task_list_emits_static_task_markers() {
2400        let bs = blocks("- [x] done\n- [ ] todo");
2401        assert_eq!(bs.len(), 1);
2402        assert_eq!(bs[0].children.len(), 2);
2403
2404        let checked = &bs[0].children[0].children[0].children[0];
2405        let unchecked = &bs[0].children[1].children[0].children[0];
2406        assert_eq!(checked.kind, Kind::Custom("task_marker"));
2407        assert_eq!(unchecked.kind, Kind::Custom("task_marker"));
2408        assert_eq!(checked.fill, Some(tokens::PRIMARY));
2409        assert_eq!(unchecked.fill, Some(tokens::CARD));
2410        assert!(!checked.focusable);
2411        assert!(!unchecked.focusable);
2412    }
2413
2414    #[test]
2415    fn nested_list_lives_inside_the_outer_item() {
2416        let input = "- outer one\n  - inner a\n  - inner b\n- outer two";
2417        let bs = blocks(input);
2418        assert_eq!(bs.len(), 1);
2419        let outer = &bs[0];
2420        assert_eq!(outer.children.len(), 2);
2421        // First outer item collapses to a multi-block column (paragraph
2422        // + nested list). The transformer wraps multi-block items in
2423        // `column`; reach into it.
2424        let first_item_body = &outer.children[0].children[1];
2425        // first_item_body is the body slot in the overlay-stack item
2426        // shape. It contains a single child: the list-item content.
2427        let inner_content = &first_item_body.children[0];
2428        // For nested-list items, the content is a column of [paragraph,
2429        // nested bullet_list]. The second child is the nested list.
2430        assert_eq!(inner_content.kind, Kind::Group);
2431        assert!(inner_content.children.len() >= 2);
2432    }
2433
2434    #[test]
2435    fn blockquote_wraps_inner_paragraphs() {
2436        let bs = blocks("> First line.\n>\n> Second line.");
2437        assert_eq!(bs.len(), 1);
2438        // blockquote is a stack of [rule, body_column].
2439        assert_eq!(bs[0].kind, Kind::Group);
2440        assert_eq!(bs[0].axis, Axis::Overlay);
2441        assert_eq!(bs[0].children.len(), 2);
2442        let body = &bs[0].children[1];
2443        assert_eq!(body.children.len(), 2);
2444    }
2445
2446    #[test]
2447    fn fenced_code_block_keeps_verbatim_text() {
2448        let bs = blocks("```\nfn main() {}\n```");
2449        assert_eq!(bs.len(), 1);
2450        // code_block surface contains a single mono text leaf that
2451        // resolves to the JBM monospace face via the El default
2452        // (themes can override with `with_mono_font_family`).
2453        let surface = &bs[0];
2454        assert_eq!(surface.surface_role, SurfaceRole::Sunken);
2455        let body = &surface.children[0];
2456        assert_eq!(body.text.as_deref(), Some("fn main() {}"));
2457        assert!(body.font_mono);
2458        assert_eq!(
2459            body.mono_font_family,
2460            damascene_core::tree::FontFamily::JetBrainsMono
2461        );
2462    }
2463
2464    #[test]
2465    fn indented_code_block_keeps_verbatim_text() {
2466        let bs = blocks("    let x = 1;\n    let y = 2;");
2467        assert_eq!(bs.len(), 1);
2468        let body = &bs[0].children[0];
2469        assert_eq!(body.text.as_deref(), Some("let x = 1;\nlet y = 2;"));
2470    }
2471
2472    #[test]
2473    fn markdown_code_block_selection_copies_fence_when_whole_body_selected() {
2474        let input = "```rust\nfn main() {}\nlet x = 1;\n```";
2475        let doc = md(input);
2476        let mut nodes = Vec::new();
2477        collect_source_backed(&doc, &mut nodes);
2478        let body = nodes
2479            .into_iter()
2480            .find(|node| {
2481                node.selection_source
2482                    .as_ref()
2483                    .is_some_and(|source| source.visible == "fn main() {}\nlet x = 1;")
2484            })
2485            .expect("source-backed code body");
2486        let source = body.selection_source.as_ref().unwrap();
2487        let key = body.key.as_deref().unwrap();
2488        let main_start = source.visible.find("main").unwrap();
2489
2490        assert_eq!(
2491            selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2492            Some(input)
2493        );
2494        assert_eq!(
2495            selected_text(&doc, &selection(key, main_start, main_start + "main".len())).as_deref(),
2496            Some("main")
2497        );
2498    }
2499
2500    #[test]
2501    fn markdown_indented_code_partial_selection_copies_visible_code() {
2502        let input = "    let x = 1;\n    let y = 2;";
2503        let doc = md(input);
2504        let node = first_source_backed(&doc).expect("source-backed code body");
2505        let source = node.selection_source.as_ref().unwrap();
2506        let key = node.key.as_deref().unwrap();
2507        let y_start = source.visible.find("let y").unwrap();
2508
2509        assert_eq!(
2510            selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2511            Some(input)
2512        );
2513        assert_eq!(
2514            selected_text(&doc, &selection(key, y_start, source.visible_len())).as_deref(),
2515            Some("let y = 2;")
2516        );
2517    }
2518
2519    /// Fenced block with an unrecognised language falls back to the
2520    /// plain-mono `code_block(...)` path (single text leaf, no inline
2521    /// runs) — same behaviour as a fence with no info string.
2522    #[test]
2523    fn fenced_code_block_unknown_language_falls_back_to_plain_mono() {
2524        let bs = blocks("```nothinglikethis\nfn x() {}\n```");
2525        assert_eq!(bs.len(), 1);
2526        let body = &bs[0].children[0];
2527        assert_eq!(body.kind, Kind::Text);
2528        assert_eq!(body.text.as_deref(), Some("fn x() {}"));
2529        assert!(body.font_mono);
2530    }
2531
2532    /// With the `highlighting` feature enabled (default), a fenced
2533    /// block tagged with a recognised language tokenises into a
2534    /// `text_runs` paragraph wrapped in the same code-block chrome.
2535    /// Tokens carry palette colors (here we just confirm there's more
2536    /// than one Text run and at least one carries a non-default color);
2537    /// finer mapping assertions live in `highlight::tests`.
2538    #[cfg(feature = "highlighting")]
2539    #[test]
2540    fn fenced_rust_code_block_emits_highlighted_runs() {
2541        let bs = blocks("```rust\n// hi\nfn main() {}\n```");
2542        assert_eq!(bs.len(), 1);
2543        let surface = &bs[0];
2544        assert_eq!(surface.surface_role, SurfaceRole::Sunken);
2545        let body = &surface.children[0];
2546        assert_eq!(body.kind, Kind::Inlines);
2547        assert!(body.font_mono);
2548        let text_runs: Vec<&El> = body
2549            .children
2550            .iter()
2551            .filter(|c| c.kind == Kind::Text)
2552            .collect();
2553        assert!(
2554            text_runs.len() > 2,
2555            "expected multiple highlighted runs, got {}",
2556            text_runs.len()
2557        );
2558        assert!(
2559            text_runs.iter().all(|r| r.font_mono),
2560            "every highlighted run should ride the mono path"
2561        );
2562        assert!(
2563            text_runs.iter().any(|r| r.text_color.is_some()),
2564            "expected at least one run to carry a syntax color"
2565        );
2566    }
2567
2568    #[test]
2569    fn horizontal_rule_emits_a_divider() {
2570        let bs = blocks("Above.\n\n---\n\nBelow.");
2571        let kinds: Vec<&Kind> = bs.iter().map(|b| &b.kind).collect();
2572        assert!(kinds.iter().any(|k| matches!(k, Kind::Divider)));
2573    }
2574
2575    #[test]
2576    fn hard_break_inside_paragraph_emits_hard_break_node() {
2577        // CommonMark hard break = trailing two spaces + newline.
2578        let bs = blocks("line one  \nline two");
2579        assert_eq!(bs[0].kind, Kind::Inlines);
2580        assert!(
2581            bs[0]
2582                .children
2583                .iter()
2584                .any(|c| matches!(c.kind, Kind::HardBreak))
2585        );
2586    }
2587
2588    #[test]
2589    fn soft_break_renders_as_a_space() {
2590        let bs = blocks("line one\nline two");
2591        assert_eq!(bs[0].kind, Kind::Text);
2592        // Plain paragraph fast path; soft break became a single space.
2593        let s = bs[0].text.as_deref().unwrap();
2594        assert!(s.contains("line one line two"), "got {s:?}");
2595    }
2596
2597    #[test]
2598    fn image_renders_as_alt_placeholder() {
2599        let bs = blocks("![diagram of pipeline](pipeline.png \"Pipeline\")");
2600        assert_eq!(bs.len(), 1);
2601        assert_eq!(bs[0].kind, Kind::Inlines);
2602        let run = &bs[0].children[0];
2603        let s = run.text.as_deref().unwrap_or("");
2604        assert!(s.contains("diagram of pipeline"), "got {s:?}");
2605        assert!(s.contains("pipeline.png"), "got {s:?}");
2606        assert!(s.contains("Pipeline"), "got {s:?}");
2607        assert_eq!(run.text_link.as_deref(), Some("pipeline.png"));
2608    }
2609
2610    #[test]
2611    fn inline_image_placeholder_preserves_order() {
2612        let bs = blocks("Before ![alt](img.png) after");
2613        assert_eq!(bs[0].kind, Kind::Inlines);
2614        let text: String = bs[0]
2615            .children
2616            .iter()
2617            .filter_map(|run| run.text.as_deref())
2618            .collect();
2619        assert!(
2620            text.contains("Before [image: alt] img.png after"),
2621            "got {text:?}"
2622        );
2623    }
2624
2625    #[test]
2626    fn markdown_image_selection_copies_image_source_atomically() {
2627        let input = "Before ![alt text](img.png \"Title\") after";
2628        let doc = md(input);
2629        let node = first_source_backed(&doc).expect("source-backed paragraph");
2630        let source = node.selection_source.as_ref().unwrap();
2631        let key = node.key.as_deref().unwrap();
2632        let label = "[image: alt text] img.png \"Title\"";
2633        let image_start = source.visible.find(label).unwrap();
2634        let image_end = image_start + label.len();
2635
2636        assert_eq!(
2637            selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2638            Some(input)
2639        );
2640        assert_eq!(
2641            selected_text(&doc, &selection(key, image_start, image_end)).as_deref(),
2642            Some("![alt text](img.png \"Title\")")
2643        );
2644    }
2645
2646    #[test]
2647    fn document_outer_column_carries_block_gap() {
2648        let el = md("# A\n\nb");
2649        assert_eq!(el.kind, Kind::Group);
2650        assert_eq!(el.gap, tokens::SPACE_4);
2651    }
2652
2653    #[test]
2654    fn table_emits_header_plus_body_widget() {
2655        let bs = blocks(
2656            "\
2657| Name  | Role |\n\
2658|-------|------|\n\
2659| Ada   | dev  |\n\
2660| Grace | ops  |\n",
2661        );
2662        assert_eq!(bs.len(), 1);
2663        let t = &bs[0];
2664        // `widgets::table` is `Kind::Custom("table")` with a header
2665        // child and a body child.
2666        assert_eq!(t.kind, Kind::Custom("table"));
2667        assert_eq!(t.children.len(), 2);
2668        let header = &t.children[0];
2669        let body = &t.children[1];
2670        assert_eq!(header.kind, Kind::Custom("table_header"));
2671        assert_eq!(body.kind, Kind::Custom("table_body"));
2672        // Header has one row of two cells.
2673        assert_eq!(header.children.len(), 1);
2674        assert_eq!(header.children[0].children.len(), 2);
2675        assert_eq!(header.children[0].children[0].text.as_deref(), Some("Name"));
2676        assert_eq!(header.children[0].children[1].text.as_deref(), Some("Role"));
2677        // Body has two rows, each with two cells.
2678        assert_eq!(body.children.len(), 2);
2679        assert_eq!(body.children[0].children.len(), 2);
2680        assert_eq!(body.children[0].children[0].text.as_deref(), Some("Ada"));
2681        assert_eq!(body.children[0].children[1].text.as_deref(), Some("dev"));
2682    }
2683
2684    #[test]
2685    fn table_header_cells_carry_caption_styling() {
2686        let bs = blocks(
2687            "\
2688| Header |\n\
2689|--------|\n\
2690| body   |\n",
2691        );
2692        let t = &bs[0];
2693        let header_cell = &t.children[0].children[0].children[0];
2694        assert_eq!(header_cell.text.as_deref(), Some("Header"));
2695        // `table_head(...)` applies the caption role.
2696        assert_eq!(header_cell.text_role, TextRole::Caption);
2697    }
2698
2699    #[test]
2700    fn table_body_cells_with_inline_styling_use_text_runs() {
2701        let bs = blocks(
2702            "\
2703| Col |\n\
2704|-----|\n\
2705| **bold** word |\n",
2706        );
2707        let t = &bs[0];
2708        let body_cell = &t.children[1].children[0].children[0];
2709        // Body cell wraps the styled content in an Inlines paragraph.
2710        assert_eq!(body_cell.kind, Kind::Inlines);
2711        assert!(
2712            body_cell
2713                .children
2714                .iter()
2715                .any(|r| r.font_weight == FontWeight::Bold && r.text.as_deref() == Some("bold"))
2716        );
2717    }
2718
2719    #[test]
2720    fn table_alignment_applies_to_header_and_body_cells() {
2721        let bs = blocks(
2722            "\
2723| Left | Center | Right |\n\
2724|:-----|:------:|------:|\n\
2725| a    | b      | c     |\n",
2726        );
2727        let t = &bs[0];
2728        let header_row = &t.children[0].children[0];
2729        let body_row = &t.children[1].children[0];
2730
2731        assert_eq!(header_row.children[0].text_align, TextAlign::Start);
2732        assert_eq!(header_row.children[1].text_align, TextAlign::Center);
2733        assert_eq!(header_row.children[2].text_align, TextAlign::End);
2734        assert_eq!(body_row.children[0].text_align, TextAlign::Start);
2735        assert_eq!(body_row.children[1].text_align, TextAlign::Center);
2736        assert_eq!(body_row.children[2].text_align, TextAlign::End);
2737    }
2738
2739    #[test]
2740    fn table_header_cells_preserve_inline_styling() {
2741        let bs = blocks(
2742            "\
2743| **Header** |\n\
2744|------------|\n\
2745| body       |\n",
2746        );
2747        let header_cell = &bs[0].children[0].children[0].children[0];
2748        assert_eq!(header_cell.kind, Kind::Inlines);
2749        assert!(
2750            header_cell
2751                .children
2752                .iter()
2753                .any(|r| r.font_weight == FontWeight::Bold
2754                    && r.text_role == TextRole::Caption
2755                    && r.text.as_deref() == Some("Header"))
2756        );
2757    }
2758
2759    #[test]
2760    fn markdown_table_cell_selection_copies_row_source_when_whole_cell_selected() {
2761        let input = "\
2762| Name | Role |\n\
2763|------|------|\n\
2764| **Ada** | dev |\n";
2765        let doc = md(input);
2766        let mut nodes = Vec::new();
2767        collect_source_backed(&doc, &mut nodes);
2768        let ada_cell = nodes
2769            .into_iter()
2770            .find(|node| {
2771                node.selection_source
2772                    .as_ref()
2773                    .is_some_and(|source| source.visible == "Ada")
2774            })
2775            .expect("source-backed Ada table cell");
2776        let source = ada_cell.selection_source.as_ref().unwrap();
2777        let key = ada_cell.key.as_deref().unwrap();
2778
2779        assert_eq!(
2780            selected_text(&doc, &selection(key, 0, source.visible_len())).as_deref(),
2781            Some("| **Ada** | dev |")
2782        );
2783        assert_eq!(
2784            selected_text(&doc, &selection(key, 1, source.visible_len() - 1)).as_deref(),
2785            Some("d")
2786        );
2787    }
2788
2789    #[test]
2790    fn markdown_table_row_selection_copies_pipe_row_source_once() {
2791        let input = "\
2792| Name | Role |\n\
2793|------|------|\n\
2794| **Ada** | dev |\n";
2795        let doc = md(input);
2796        let mut nodes = Vec::new();
2797        collect_source_backed(&doc, &mut nodes);
2798        let ada_cell = nodes
2799            .iter()
2800            .find(|node| {
2801                node.selection_source
2802                    .as_ref()
2803                    .is_some_and(|source| source.visible == "Ada")
2804            })
2805            .expect("source-backed Ada table cell");
2806        let role_cell = nodes
2807            .iter()
2808            .find(|node| {
2809                node.selection_source
2810                    .as_ref()
2811                    .is_some_and(|source| source.visible == "dev")
2812            })
2813            .expect("source-backed role table cell");
2814        let ada_key = ada_cell.key.as_deref().unwrap();
2815        let role_key = role_cell.key.as_deref().unwrap();
2816        let role_len = role_cell.selection_source.as_ref().unwrap().visible_len();
2817
2818        let selected = selected_text(
2819            &doc,
2820            &Selection {
2821                range: Some(SelectionRange {
2822                    anchor: SelectionPoint::new(ada_key, 0),
2823                    head: SelectionPoint::new(role_key, role_len),
2824                }),
2825            },
2826        );
2827        assert_eq!(selected.as_deref(), Some("| **Ada** | dev |"));
2828    }
2829
2830    #[test]
2831    fn markdown_table_header_draws_and_copy_preserves_separator() {
2832        let input = "\
2833| Construct  | Maps to            |\n\
2834|------------|--------------------|\n\
2835| Heading    | `h1` / `h2` / `h3` |\n\
2836| List       | `bullet_list` / `numbered_list` |\n\
2837| Blockquote | `blockquote`       |\n\
2838| Code block | `code_block`       |\n\
2839| Table      | `table`            |\n";
2840        let mut doc = scroll([column([md(input)])
2841            .gap(tokens::SPACE_4)
2842            .align(Align::Start)
2843            .width(Size::Fill(1.0))])
2844        .height(Size::Fill(1.0));
2845        let mut state = UiState::new();
2846        layout(&mut doc, &mut state, Rect::new(0.0, 0.0, 640.0, 240.0));
2847        let ops = draw_ops(&doc, &state);
2848
2849        let text_y = |needle: &str| {
2850            ops.iter().find_map(|op| match op {
2851                DrawOp::GlyphRun { text, rect, .. } if text == needle => Some(rect.y),
2852                _ => None,
2853            })
2854        };
2855        let construct_y = text_y("Construct").expect("Construct header should draw");
2856        let heading_y = text_y("Heading").expect("Heading body cell should draw");
2857        assert!(
2858            construct_y < heading_y,
2859            "expected header above body, got Construct y={construct_y}, Heading y={heading_y}"
2860        );
2861
2862        let mut nodes = Vec::new();
2863        collect_source_backed(&doc, &mut nodes);
2864        let construct_cell = nodes
2865            .iter()
2866            .find(|node| {
2867                node.selection_source
2868                    .as_ref()
2869                    .is_some_and(|source| source.visible == "Construct")
2870            })
2871            .expect("source-backed Construct table cell");
2872        let heading_cell = nodes
2873            .iter()
2874            .find(|node| {
2875                node.selection_source
2876                    .as_ref()
2877                    .is_some_and(|source| source.visible == "Heading")
2878            })
2879            .expect("source-backed Heading table cell");
2880        let construct_key = construct_cell.key.as_deref().unwrap();
2881        let heading_key = heading_cell.key.as_deref().unwrap();
2882        let heading_len = heading_cell
2883            .selection_source
2884            .as_ref()
2885            .unwrap()
2886            .visible_len();
2887        let selected = selected_text(
2888            &doc,
2889            &Selection {
2890                range: Some(SelectionRange {
2891                    anchor: SelectionPoint::new(construct_key, 0),
2892                    head: SelectionPoint::new(heading_key, heading_len),
2893                }),
2894            },
2895        );
2896        assert_eq!(
2897            selected.as_deref(),
2898            Some(
2899                "| Construct  | Maps to            |\n|------------|--------------------|\n| Heading    | `h1` / `h2` / `h3` |"
2900            )
2901        );
2902    }
2903
2904    #[test]
2905    fn strikethrough_inline_run_marks_text_strikethrough() {
2906        // GFM strikethrough is gated behind ENABLE_STRIKETHROUGH; the
2907        // walker already had the Tag matcher, but the parser only
2908        // emits the events when the option is on.
2909        let bs = blocks("Some ~~obsolete~~ text.");
2910        assert_eq!(bs[0].kind, Kind::Inlines);
2911        let strike: Vec<&El> = bs[0]
2912            .children
2913            .iter()
2914            .filter(|r| r.text_strikethrough)
2915            .collect();
2916        assert!(!strike.is_empty(), "expected a strikethrough run");
2917        assert_eq!(strike[0].text.as_deref(), Some("obsolete"));
2918    }
2919
2920    #[test]
2921    fn smart_punctuation_is_opt_in() {
2922        let plain = blocks("Wait...");
2923        assert_eq!(plain[0].text.as_deref(), Some("Wait..."));
2924
2925        let smart = match md_with_options(
2926            "Wait...",
2927            MarkdownOptions::default().smart_punctuation(true),
2928        ) {
2929            el if matches!(el.kind, Kind::Group) && el.axis == Axis::Column => el.children,
2930            other => panic!("expected outer column, got {:?}", other.kind),
2931        };
2932        assert_eq!(smart[0].text.as_deref(), Some("Wait\u{2026}"));
2933    }
2934
2935    #[test]
2936    fn gfm_alerts_are_opt_in() {
2937        let plain = blocks("> [!WARNING]\n> Careful.");
2938        assert_eq!(plain[0].axis, Axis::Overlay);
2939
2940        let alert_blocks = match md_with_options(
2941            "> [!WARNING]\n> Careful.",
2942            MarkdownOptions::default().gfm_alerts(true),
2943        ) {
2944            el if matches!(el.kind, Kind::Group) && el.axis == Axis::Column => el.children,
2945            other => panic!("expected outer column, got {:?}", other.kind),
2946        };
2947        assert_eq!(alert_blocks[0].kind, Kind::Custom("alert"));
2948        assert_eq!(alert_blocks[0].children[0].text.as_deref(), Some("Warning"));
2949        assert_eq!(
2950            alert_blocks[0].fill,
2951            Some(tokens::WARNING.with_alpha_u8(38))
2952        );
2953    }
2954
2955    #[cfg(feature = "html")]
2956    #[test]
2957    fn html_block_event_lands_as_block_when_feature_enabled() {
2958        // A block-level HTML scrap in the middle of markdown gets
2959        // parsed by damascene-html and appended in source order.
2960        let bs = blocks("First paragraph.\n\n<div><h2>From HTML</h2></div>\n\nLast paragraph.");
2961        assert_eq!(bs.len(), 3);
2962        assert_eq!(bs[0].text.as_deref(), Some("First paragraph."));
2963        // The middle block is the heading parsed out of the div.
2964        assert_eq!(bs[1].kind, Kind::Heading);
2965        assert_eq!(bs[1].text.as_deref(), Some("From HTML"));
2966        assert_eq!(bs[2].text.as_deref(), Some("Last paragraph."));
2967    }
2968
2969    #[cfg(feature = "html")]
2970    #[test]
2971    fn html_block_script_is_dropped() {
2972        // Sanitization carries through the markdown → html bridge.
2973        let bs = blocks("Before.\n\n<script>alert('xss')</script>\n\nAfter.");
2974        let combined: String = bs
2975            .iter()
2976            .filter_map(|b| b.text.as_deref())
2977            .collect::<Vec<_>>()
2978            .join("\n");
2979        assert!(combined.contains("Before."));
2980        assert!(combined.contains("After."));
2981        assert!(!combined.contains("alert"));
2982    }
2983
2984    #[cfg(feature = "html")]
2985    fn paragraph_runs(block: &El) -> Vec<&El> {
2986        block.children.iter().collect()
2987    }
2988
2989    #[cfg(feature = "html")]
2990    #[test]
2991    fn fragmented_inline_tag_pair_styles_the_text_between() {
2992        // pulldown-cmark splits `<b>bold</b>` into open / Text / close
2993        // events; the open tag must style the text in between.
2994        let bs = blocks("before <b>bold bit</b> after");
2995        let p = &bs[0];
2996        let runs = paragraph_runs(p);
2997        let bold = runs
2998            .iter()
2999            .find(|r| r.text.as_deref() == Some("bold bit"))
3000            .expect("bold run");
3001        assert_eq!(bold.font_weight, FontWeight::Bold);
3002        let after = runs
3003            .iter()
3004            .find(|r| r.text.as_deref().is_some_and(|t| t.contains("after")))
3005            .expect("after run");
3006        assert_eq!(after.font_weight, FontWeight::Regular);
3007    }
3008
3009    #[cfg(feature = "html")]
3010    #[test]
3011    fn fragmented_span_style_carries_color_onto_text() {
3012        let bs = blocks("a <span style=\"color: #ff0000\">red</span> b");
3013        let p = &bs[0];
3014        let red = p
3015            .children
3016            .iter()
3017            .find(|r| r.text.as_deref() == Some("red"))
3018            .expect("red run");
3019        assert_eq!(red.text_color, Some(Color::srgb_u8(255, 0, 0)));
3020    }
3021
3022    #[cfg(feature = "html")]
3023    #[test]
3024    fn nested_fragmented_tags_compose_and_markdown_emphasis_survives() {
3025        let bs = blocks("<u><b>x *and md*</b></u> tail");
3026        let p = &bs[0];
3027        let x = p
3028            .children
3029            .iter()
3030            .find(|r| r.text.as_deref().is_some_and(|t| t.contains('x')))
3031            .expect("x run");
3032        assert_eq!(x.font_weight, FontWeight::Bold);
3033        assert!(x.text_underline);
3034        // `*and md*` between the HTML tags still goes through markdown
3035        // emphasis, stacked on the HTML styling.
3036        let md_run = p
3037            .children
3038            .iter()
3039            .find(|r| r.text.as_deref() == Some("and md"))
3040            .expect("emphasis run");
3041        assert!(md_run.text_italic);
3042        assert_eq!(md_run.font_weight, FontWeight::Bold);
3043        assert!(md_run.text_underline);
3044        // The close tags release the styling.
3045        let tail = p
3046            .children
3047            .iter()
3048            .find(|r| r.text.as_deref().is_some_and(|t| t.contains("tail")))
3049            .expect("tail run");
3050        assert!(!tail.text_underline);
3051        assert_eq!(tail.font_weight, FontWeight::Regular);
3052    }
3053
3054    #[cfg(feature = "html")]
3055    #[test]
3056    fn unclosed_inline_tag_does_not_bleed_into_the_next_paragraph() {
3057        let bs = blocks("<b>dangling\n\nnext paragraph");
3058        assert_eq!(bs.len(), 2);
3059        let dangling = bs[0]
3060            .children
3061            .iter()
3062            .find(|r| r.text.as_deref().is_some_and(|t| t.contains("dangling")))
3063            .or_else(|| bs[0].text.is_some().then_some(&bs[0]))
3064            .expect("dangling run");
3065        assert_eq!(dangling.font_weight, FontWeight::Bold);
3066        // The next paragraph is plain.
3067        assert_eq!(bs[1].font_weight, FontWeight::Regular);
3068    }
3069
3070    #[cfg(feature = "html")]
3071    #[test]
3072    fn md_with_lints_surfaces_embedded_html_findings() {
3073        let (_, findings) = md_with_lints(
3074            "text\n\n<div style=\"color: oklch(0.7 0.1 200)\">styled</div>",
3075            MarkdownOptions::default(),
3076        );
3077        assert!(
3078            findings.iter().any(|f| matches!(
3079                f.kind,
3080                damascene_html::FindingKind::DroppedDeclaration
3081            ) && f.detail.contains("oklch")),
3082            "expected the embedded HTML's finding to surface, got {findings:?}"
3083        );
3084        // Pure markdown emits none.
3085        let (_, clean) = md_with_lints("just *markdown*", MarkdownOptions::default());
3086        assert!(clean.is_empty());
3087    }
3088
3089    #[cfg(feature = "html")]
3090    #[test]
3091    fn sanitize_styles_knob_plumbs_through_to_embedded_html() {
3092        let input = "x <span style=\"color: #ff0000\">styled</span> y";
3093        let options = MarkdownOptions::default()
3094            .html_options(damascene_html::HtmlOptions::default().sanitize_styles(true));
3095        let (el, findings) = md_with_lints(input, options);
3096        assert!(
3097            findings
3098                .iter()
3099                .any(|f| matches!(f.kind, damascene_html::FindingKind::SanitizedStyle)),
3100            "expected a SanitizedStyle finding, got {findings:?}"
3101        );
3102        // The author's color must not have been applied (the run keeps
3103        // the themed default).
3104        let p = &el.children[0];
3105        let styled = p
3106            .children
3107            .iter()
3108            .find(|r| r.text.as_deref() == Some("styled"))
3109            .or_else(|| p.text.is_some().then_some(p))
3110            .expect("styled run");
3111        assert_ne!(styled.text_color, Some(Color::srgb_u8(255, 0, 0)));
3112    }
3113
3114    #[cfg(feature = "html")]
3115    #[test]
3116    fn fragmented_anchor_pair_links_the_text_between() {
3117        let bs = blocks("see <a href=\"https://damascene.dev\">the site</a> now");
3118        let p = &bs[0];
3119        let link = p
3120            .children
3121            .iter()
3122            .find(|r| r.text.as_deref() == Some("the site"))
3123            .expect("link run");
3124        assert_eq!(link.text_link.as_deref(), Some("https://damascene.dev"));
3125    }
3126
3127    #[cfg(not(feature = "html"))]
3128    #[test]
3129    fn html_block_event_is_dropped_when_feature_disabled() {
3130        // Without the `html` feature, raw HTML blocks render as nothing.
3131        let bs = blocks("First.\n\n<div><h2>Hidden</h2></div>\n\nLast.");
3132        let combined: String = bs
3133            .iter()
3134            .filter_map(|b| b.text.as_deref())
3135            .collect::<Vec<_>>()
3136            .join(" ");
3137        assert!(!combined.contains("Hidden"));
3138    }
3139}