Skip to main content

moss_core/ast/
node.rs

1//! Block-level and inline-level AST nodes.
2//!
3//! Closed enums; pattern matching is the visitor framework. The variants
4//! cover what moss emits today (CommonMark + GFM extensions enabled in the
5//! pipeline: tables, strikethrough, footnotes — see
6//! `src-tauri/src/build/markdown/pipeline.rs`'s `Options` setup).
7//!
8//! Anything pulldown-cmark emits that the AST hasn't modeled flows through
9//! `Block::Other` / `Inline::Other`, which carries the raw HTML so the
10//! renderer passes it through unchanged. New variants may be promoted out
11//! of `Other` over time as a need is identified.
12
13use serde::{Deserialize, Serialize};
14
15use super::shortcode::Shortcode;
16use super::url::Url;
17
18/// Canonical callout kind. Obsidian-dialect aliases canonicalize via
19/// [`CalloutKind::from_raw`] (e.g. `tldr`/`summary` → [`CalloutKind::Abstract`]).
20/// Unknown kinds fall back to [`CalloutKind::Note`]; the parser logs at
21/// trace level (Diagnostic threading is a Phase 4 followup — see
22/// `validation::Diagnostic`, today scoped to frontmatter validation).
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum CalloutKind {
26    Note,
27    Abstract,
28    Info,
29    Todo,
30    Tip,
31    Success,
32    Question,
33    Warning,
34    Failure,
35    Danger,
36    Bug,
37    Example,
38    Quote,
39    Important,
40    Summary,
41    Help,
42}
43
44impl CalloutKind {
45    /// Canonicalize a raw callout name (case-insensitive) to a
46    /// [`CalloutKind`]. Returns `None` if the name is not a recognized
47    /// canonical kind or alias.
48    ///
49    /// Alias table (Obsidian-dialect, per shape-spec § 1):
50    /// - `tldr` / `summary` → `Abstract`
51    /// - `hint` / `important` → `Tip`
52    /// - `check` / `done` → `Success`
53    /// - `help` / `faq` → `Question`
54    /// - `caution` / `attention` → `Warning`
55    /// - `fail` / `missing` → `Failure`
56    /// - `error` → `Danger`
57    /// - `cite` → `Quote`
58    ///
59    /// `pending` is also accepted as an alias for `Todo` (used by
60    /// SoCiviC Theatre voices.md; carried over from pre-Phase-4 Stage 1
61    /// support in `crates/moss-core/src/resolve/callouts.rs`).
62    ///
63    /// Note: the [`CalloutKind`] enum reserves `Important`, `Summary`,
64    /// and `Help` as canonical variants for future Stage 2 use (e.g.
65    /// editor-emitted callouts that should not name-clash with the
66    /// Obsidian aliases above). Author markdown can't currently produce
67    /// these three through `from_raw`; they're reachable only via
68    /// programmatic construction.
69    pub fn from_raw(raw: &str) -> Option<Self> {
70        let lower = raw.to_lowercase();
71        let canonical = match lower.as_str() {
72            // Canonical kinds (exact match, alias-free names)
73            "note" => Self::Note,
74            "abstract" => Self::Abstract,
75            "info" => Self::Info,
76            "todo" => Self::Todo,
77            "tip" => Self::Tip,
78            "success" => Self::Success,
79            "question" => Self::Question,
80            "warning" => Self::Warning,
81            "failure" => Self::Failure,
82            "danger" => Self::Danger,
83            "bug" => Self::Bug,
84            "example" => Self::Example,
85            "quote" => Self::Quote,
86            // Obsidian-dialect aliases (shape-spec § 1)
87            "tldr" | "summary" => Self::Abstract,
88            "hint" | "important" => Self::Tip,
89            "check" | "done" => Self::Success,
90            "help" | "faq" => Self::Question,
91            "caution" | "attention" => Self::Warning,
92            "fail" | "missing" => Self::Failure,
93            "error" => Self::Danger,
94            "cite" => Self::Quote,
95            // Legacy alias retained from pre-Phase-4 Stage 1
96            // (`crates/moss-core/src/resolve/callouts.rs`). SoCiviC
97            // Theatre's voices.md uses `> [!pending]`; map to Todo.
98            "pending" => Self::Todo,
99            _ => return None,
100        };
101        Some(canonical)
102    }
103
104    /// Slug form used in the rendered `data-type` attribute.
105    pub fn as_slug(self) -> &'static str {
106        match self {
107            Self::Note => "note",
108            Self::Abstract => "abstract",
109            Self::Info => "info",
110            Self::Todo => "todo",
111            Self::Tip => "tip",
112            Self::Success => "success",
113            Self::Question => "question",
114            Self::Warning => "warning",
115            Self::Failure => "failure",
116            Self::Danger => "danger",
117            Self::Bug => "bug",
118            Self::Example => "example",
119            Self::Quote => "quote",
120            Self::Important => "important",
121            Self::Summary => "summary",
122            Self::Help => "help",
123        }
124    }
125
126    /// Default display title (capitalized canonical kind) used when the
127    /// author wrote `> [!type]` with no inline title text.
128    pub fn default_title(self) -> &'static str {
129        match self {
130            Self::Note => "Note",
131            Self::Abstract => "Abstract",
132            Self::Info => "Info",
133            Self::Todo => "Todo",
134            Self::Tip => "Tip",
135            Self::Success => "Success",
136            Self::Question => "Question",
137            Self::Warning => "Warning",
138            Self::Failure => "Failure",
139            Self::Danger => "Danger",
140            Self::Bug => "Bug",
141            Self::Example => "Example",
142            Self::Quote => "Quote",
143            Self::Important => "Important",
144            Self::Summary => "Summary",
145            Self::Help => "Help",
146        }
147    }
148}
149
150/// Foldable callout state. `> [!type]+` → [`Fold::Open`] (foldable,
151/// open by default); `> [!type]-` → [`Fold::Closed`] (foldable, closed
152/// by default). Non-foldable callouts have `fold: None` on the
153/// containing [`Block::Callout`].
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum Fold {
157    Open,
158    Closed,
159}
160
161/// A block-level AST node.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum Block {
165    /// `# Heading` (level 1) through `###### Heading` (level 6).
166    Heading {
167        level: u8,
168        children: Vec<Inline>,
169        /// Heading anchor id (slug). Computed by the parser via
170        /// [`crate::heading_anchor::obsidian_heading_anchor`].
171        id: Option<String>,
172    },
173    /// A paragraph of inline content.
174    Paragraph(Vec<Inline>),
175    /// `> [!type] body` — typed callouts. The `kind` is canonicalized
176    /// via [`CalloutKind::from_raw`] (Obsidian-dialect aliases collapse
177    /// to the canonical 16-kind set). Foldable callouts (`> [!type]+`
178    /// open by default, `> [!type]-` closed) carry the [`Fold`] state;
179    /// non-foldable callouts have `fold: None`.
180    ///
181    /// Phase 4 PR4 extended the shape from `kind: String` to
182    /// `kind: CalloutKind` + added `fold: Option<Fold>` and `title: Option<String>`.
183    /// Title is the optional inline text following the marker
184    /// (`> [!note] My title` → `title: Some("My title")`).
185    Callout {
186        kind: CalloutKind,
187        fold: Option<Fold>,
188        title: Option<String>,
189        children: Vec<Block>,
190    },
191    /// `- item` / `1. item`. Each item is a list of blocks (so list items
192    /// can carry paragraphs, sub-lists, etc).
193    ///
194    /// `item_source_lines` is a parallel-to-`items` vec of 1-based source
195    /// line numbers, populated by the parser only when
196    /// [`crate::ast::ParseConfig::emit_source_lines`] is true. When tracking
197    /// is off (production publish builds, the ~40 in-crate `parse()` callers
198    /// that use the default config), the vec is empty (`vec![]`) and the
199    /// renderer treats every item as `None` — no `data-source-line` on the
200    /// emitted `<li>`. When tracking is on, length matches `items.len()`
201    /// exactly; individual entries may still be `None` for synthesized
202    /// items that have no faithful source position (none today, but kept
203    /// for symmetry with [`crate::ast::document::BlockMeta::source_line`]).
204    ///
205    /// Phase 4 source-lines followup (2026-05-28): added because the
206    /// preview's scroll-sync (cm-scroll-sync via
207    /// `frontend/bridge/iframe-bridge.ts`) interpolates editor positions
208    /// proportionally between annotated DOM elements. A 30-item list
209    /// spanning 50 source lines without per-`<li>` annotations forces
210    /// interpolation between the outer `<ul>` and the next top-level
211    /// block — potentially 100 lines away. Legacy `transform_events`
212    /// (commit f91aca8fa, 2026-04-01) emitted on `<li>` and `<tr>` for
213    /// this reason; the typed-AST renderer now matches.
214    List {
215        ordered: bool,
216        /// Explicit ordered-list start number (pulldown-cmark's
217        /// `Tag::List(Option<u64>)` payload). `Some(N)` when the source
218        /// is `N. item` and the renderer should emit `<ol start="N">`;
219        /// `None` for unordered lists and for ordered lists where N is
220        /// the implicit default `1`. CommonMark only honors the FIRST
221        /// item's number as the list start; subsequent numbers are
222        /// re-derived. Phase 4 followup B (2026-05-28): added because
223        /// `<ol>` was previously emitted for any ordered list,
224        /// silently dropping the explicit start number — `3. foo`
225        /// rendered as `<ol><li>foo</li></ol>` instead of
226        /// `<ol start="3"><li>foo</li></ol>`.
227        #[serde(default)]
228        start: Option<u64>,
229        items: Vec<Vec<Block>>,
230        #[serde(default)]
231        item_source_lines: Vec<Option<usize>>,
232    },
233    /// A fenced code block.
234    CodeBlock { lang: Option<String>, value: String },
235    /// Markdown table.
236    ///
237    /// `header_source_line` and `row_source_lines` are populated by the
238    /// parser only when [`crate::ast::ParseConfig::emit_source_lines`] is
239    /// true. When tracking is off, `header_source_line` is `None` and
240    /// `row_source_lines` is empty (`vec![]`); the renderer emits no
241    /// `data-source-line` attributes. When tracking is on,
242    /// `row_source_lines.len() == rows.len()`.
243    ///
244    /// Phase 4 source-lines followup (2026-05-28): see the corresponding
245    /// doc comment on `Block::List` for the scroll-sync interpolation
246    /// rationale.
247    Table {
248        header: Vec<Vec<Inline>>,
249        rows: Vec<Vec<Vec<Inline>>>,
250        #[serde(default)]
251        header_source_line: Option<usize>,
252        #[serde(default)]
253        row_source_lines: Vec<Option<usize>>,
254    },
255    /// `> blockquote`
256    BlockQuote(Vec<Block>),
257    /// A typed shortcode block (`:::name ...args\n body :::`).
258    Shortcode(Shortcode),
259    /// `<hr>` thematic break.
260    ThematicBreak,
261    /// Image-only paragraph promoted to a typed figure.
262    ///
263    /// Detected by the parser's `Tag::Paragraph` arm (Phase 4 PR3,
264    /// 2026-05-27): a paragraph that contains exactly one
265    /// [`Inline::Image`] modulo whitespace text and line breaks. The
266    /// renderer emits `<figure class="moss-image">…<figcaption>…</figcaption></figure>`,
267    /// wrapping the image hook's output and appending the caption when
268    /// present.
269    ///
270    /// `image` is constrained by the parser to be an [`Inline::Image`];
271    /// the renderer pattern-matches and falls back gracefully if the
272    /// variant is anything else.
273    ///
274    /// `caption` defaults to the image's alt text at parse time. `None`
275    /// means "figure wrap but no `<figcaption>`" — reserved for the
276    /// empty-alt case (omit caption when there is nothing to read).
277    ///
278    /// The figure-level display params (`width`, `align`, `class_names`,
279    /// `img_style`) are populated only when a figure originates from a
280    /// parameterized wikilink embed (`![[photo.jpg|wide cover]]`) — the
281    /// image-embed synth-collapse routes such embeds through this typed
282    /// node so width/fit/position/align survive (previously dropped by the
283    /// markdown round-trip). The CommonMark `![](url)` promotion path
284    /// (`try_promote_to_figure`) leaves them at their defaults, so its
285    /// rendered output is byte-identical to before the collapse.
286    Figure {
287        image: Inline,
288        caption: Option<Vec<Inline>>,
289        /// Canonical width token (`body | wide | page | screen`) emitted as
290        /// `data-width="…"` on the `<figure>`. `None` omits the attribute.
291        /// `String` (not `&'static str`) so `Block` keeps its `Deserialize`
292        /// derive; the value is always one of the canonical tokens.
293        width: Option<String>,
294        /// Figure-level align class (`moss-align-left` / `moss-align-right`),
295        /// appended to the `<figure>` class list. `None` omits it.
296        align: Option<String>,
297        /// Extra author-supplied class names appended to the `<figure>` class
298        /// list (after `moss-image` and any align class). Empty = none.
299        class_names: Vec<String>,
300        /// Inline `style=` fragment for the INNER `<img>` (e.g.
301        /// `object-fit:cover;object-position:left` from a `|cover left`
302        /// embed). `None` omits it. Distinct from any figure-level attribute:
303        /// fit/position style belongs on the image element, not the figure.
304        img_style: Option<String>,
305    },
306    /// Compound-link grid cell: the entire cell is a single markdown
307    /// link `[inner](url)` whose `inner` is parsed as block-level content
308    /// (images, headings, paragraphs, emphasis). The SoCiviC Theatre
309    /// pattern: `[![[poster]] ### Title *date* description](/url)`.
310    ///
311    /// Phase 4 PR4.5 (2026-05-28): added because CommonMark restricts
312    /// `Inline::Link.children` to inline-level content; a markdown link
313    /// wrapping `### Heading` + paragraphs cannot round-trip through
314    /// pulldown-cmark's inline parser. The cell-string-level shape
315    /// (`[...](url)` with multi-paragraph inner content) is detected by
316    /// `crate::ast::shortcode_extract::parse_grid` BEFORE the cell flows
317    /// through `crate::ast::parser::parse`; the matched cell yields a
318    /// single-element `vec![Block::LinkCard { url, children }]` with the
319    /// inner markdown parsed into typed blocks.
320    ///
321    /// Render shape (matches today's `render_compound_link_cell` byte
322    /// shape):
323    /// - External URL (`http(s)://...`): `<a href=URL class="moss-grid-card link-preview" target="_blank" rel="noopener">children</a>`.
324    /// - Internal URL: `<a href=URL class="moss-grid-card" data-kind="link">children</a>`.
325    LinkCard { url: Url, children: Vec<Block> },
326    /// Escape hatch: anything pulldown-cmark emits that the AST hasn't
327    /// modeled. Carries the raw HTML so the renderer passes it through
328    /// unchanged.
329    Other(String),
330}
331
332/// An inline-level AST node.
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(rename_all = "snake_case")]
335pub enum Inline {
336    Text(String),
337    /// `[content](url "title")` or `[[wikilink]]`.
338    ///
339    /// `is_wikilink` preserves pulldown-cmark's `LinkType::WikiLink`
340    /// discriminator at parse time so the renderer can emit
341    /// `class="wikilink"` on the `<a>` tag and downstream consumers
342    /// (graph builder, link-resolver) can distinguish wikilink targets
343    /// from standard markdown links. Added Phase 4 PR7a (2026-05-28) as
344    /// the smallest AST change matching mdast convention (Link node +
345    /// extension flag, mirroring `LinkType::WikiLink` as a tag).
346    Link {
347        url: Url,
348        title: Option<String>,
349        children: Vec<Inline>,
350        /// True when pulldown-cmark emitted `Tag::Link { link_type:
351        /// LinkType::WikiLink, .. }` (i.e. the markdown source was
352        /// `[[target]]` or `[[target|alias]]`, post-Stage-1 rewrite).
353        /// Renderer adds `class="wikilink"` for true.
354        #[serde(default)]
355        is_wikilink: bool,
356    },
357    /// `![alt](src "title")` or `![[wikilink]]`.
358    ///
359    /// `is_wikilink` + `wikilink_pothole` (Phase 4 PR7a-flip-core-B,
360    /// 2026-05-28) preserve pulldown-cmark's `LinkType::WikiLink`
361    /// discriminator and the original pothole text so the
362    /// `dispatch_wikilink_embeds` visitor can route `![[v.mp4|width=400]]`
363    /// → per-extension renderer (video / pdf / audio / iframe / 3D /
364    /// notebook / etc) with the typed params intact. The parser's
365    /// `Tag::Image` arm captures the raw pothole BEFORE PR3.5's
366    /// wikilink-alt classification consumes it into the `alt` field;
367    /// without preservation, the `width=400` token is erased after
368    /// alt-classification.
369    ///
370    /// `is_wikilink: false` and `wikilink_pothole: None` for standard
371    /// `![alt](src)` markdown images.
372    Image {
373        src: Url,
374        alt: String,
375        title: Option<String>,
376        /// True when pulldown-cmark emitted `Tag::Image { link_type:
377        /// LinkType::WikiLink, .. }` (i.e. the markdown source was
378        /// `![[target]]` / `![[target|pothole]]`). Mirrors
379        /// `Inline::Link.is_wikilink`.
380        #[serde(default)]
381        is_wikilink: bool,
382        /// Original pothole text (after `|`) preserved verbatim from the
383        /// pulldown-cmark text events for wikilink images. `None` for
384        /// non-wikilink images and for wikilinks without a pothole
385        /// (pulldown-cmark synthesizes the dest as text when no pothole
386        /// is present).
387        #[serde(default)]
388        wikilink_pothole: Option<String>,
389    },
390    /// `*emphasis*`
391    Emphasis(Vec<Inline>),
392    /// `**strong**`
393    Strong(Vec<Inline>),
394    /// `` `code` ``
395    Code(String),
396    /// Hard line break.
397    LineBreak,
398    /// Escape hatch for unmodeled inline HTML.
399    Other(String),
400}
401
402#[cfg(test)]
403mod tests {
404    use super::super::url::{Url, UrlKind};
405    use super::*;
406
407    fn text(s: &str) -> Inline {
408        Inline::Text(s.to_string())
409    }
410
411    #[test]
412    fn block_heading_constructable() {
413        let b = Block::Heading {
414            level: 1,
415            children: vec![text("Hello")],
416            id: Some("hello".to_string()),
417        };
418        match b {
419            Block::Heading {
420                level,
421                children,
422                id,
423            } => {
424                assert_eq!(level, 1);
425                assert_eq!(children.len(), 1);
426                assert_eq!(id.as_deref(), Some("hello"));
427            }
428            _ => panic!("expected Heading"),
429        }
430    }
431
432    #[test]
433    fn block_paragraph_holds_inlines() {
434        let b = Block::Paragraph(vec![text("hi"), Inline::LineBreak, text("there")]);
435        match b {
436            Block::Paragraph(items) => assert_eq!(items.len(), 3),
437            _ => panic!("expected Paragraph"),
438        }
439    }
440
441    #[test]
442    fn block_list_each_item_is_block_vec() {
443        let b = Block::List {
444            ordered: false,
445            start: None,
446            items: vec![
447                vec![Block::Paragraph(vec![text("first")])],
448                vec![Block::Paragraph(vec![text("second")])],
449            ],
450            item_source_lines: vec![],
451        };
452        match b {
453            Block::List {
454                ordered,
455                start,
456                items,
457                ..
458            } => {
459                assert!(!ordered);
460                assert!(start.is_none());
461                assert_eq!(items.len(), 2);
462            }
463            _ => panic!("expected List"),
464        }
465    }
466
467    #[test]
468    fn block_list_carries_explicit_start_number() {
469        // Phase 4 followup B (2026-05-28): ordered lists with an
470        // explicit non-default start number round-trip through the AST.
471        let b = Block::List {
472            ordered: true,
473            start: Some(3),
474            items: vec![vec![Block::Paragraph(vec![text("foo")])]],
475            item_source_lines: vec![],
476        };
477        match b {
478            Block::List {
479                ordered,
480                start,
481                items,
482                ..
483            } => {
484                assert!(ordered);
485                assert_eq!(start, Some(3));
486                assert_eq!(items.len(), 1);
487            }
488            _ => panic!("expected List"),
489        }
490    }
491
492    #[test]
493    fn block_table_two_dim_rows() {
494        let b = Block::Table {
495            header: vec![vec![text("A")], vec![text("B")]],
496            rows: vec![
497                vec![vec![text("1")], vec![text("2")]],
498                vec![vec![text("3")], vec![text("4")]],
499            ],
500            header_source_line: None,
501            row_source_lines: vec![],
502        };
503        match b {
504            Block::Table { header, rows, .. } => {
505                assert_eq!(header.len(), 2);
506                assert_eq!(rows.len(), 2);
507                assert_eq!(rows[0].len(), 2);
508            }
509            _ => panic!("expected Table"),
510        }
511    }
512
513    #[test]
514    fn block_other_carries_raw_html() {
515        let b = Block::Other("<custom>raw</custom>".to_string());
516        match b {
517            Block::Other(s) => assert_eq!(s, "<custom>raw</custom>"),
518            _ => panic!("expected Other"),
519        }
520    }
521
522    #[test]
523    fn block_thematic_break_is_unit_variant() {
524        let b = Block::ThematicBreak;
525        assert!(matches!(b, Block::ThematicBreak));
526    }
527
528    #[test]
529    fn block_figure_carries_image_and_optional_caption() {
530        // Phase 4 PR3: Block::Figure wraps a single Inline::Image and an
531        // optional caption (vector of inlines so emphasis/strong can ride
532        // through). Caption defaults to the image's alt text at parse time;
533        // None is reserved for the empty-alt case.
534        let image = Inline::Image {
535            src: Url::resolved("photo.jpg", UrlKind::Asset),
536            alt: "A photo".to_string(),
537            title: None,
538            is_wikilink: false,
539            wikilink_pothole: None,
540        };
541        let b = Block::Figure {
542            image: image.clone(),
543            caption: Some(vec![text("A photo")]),
544            width: None,
545            align: None,
546            class_names: Vec::new(),
547            img_style: None,
548        };
549        match b {
550            Block::Figure {
551                image: img,
552                caption,
553                ..
554            } => {
555                assert!(matches!(img, Inline::Image { .. }));
556                let cap = caption.expect("caption present");
557                assert_eq!(cap.len(), 1);
558            }
559            _ => panic!("expected Figure"),
560        }
561    }
562
563    #[test]
564    fn block_figure_without_caption_serializes() {
565        // Empty-alt case: caption: None means "no figcaption emission."
566        let b = Block::Figure {
567            image: Inline::Image {
568                src: Url::resolved("x.jpg", UrlKind::Asset),
569                alt: String::new(),
570                title: None,
571                is_wikilink: false,
572                wikilink_pothole: None,
573            },
574            caption: None,
575            width: None,
576            align: None,
577            class_names: Vec::new(),
578            img_style: None,
579        };
580        let s = serde_json::to_string(&b).expect("serialize");
581        let back: Block = serde_json::from_str(&s).expect("deserialize");
582        assert_eq!(b, back);
583    }
584
585    #[test]
586    fn inline_link_carries_url_and_children() {
587        let i = Inline::Link {
588            url: Url::unresolved("docs/"),
589            title: None,
590            children: vec![text("Documentation")],
591            is_wikilink: false,
592        };
593        match i {
594            Inline::Link {
595                url,
596                title,
597                children,
598                is_wikilink,
599            } => {
600                assert!(url.is_unresolved());
601                assert!(title.is_none());
602                assert_eq!(children.len(), 1);
603                assert!(!is_wikilink);
604            }
605            _ => panic!("expected Link"),
606        }
607    }
608
609    #[test]
610    fn inline_image_uses_url_for_src() {
611        // Per R6: Inline::Image carries Url (not a separate Src type).
612        // UrlKind::Asset is the relevant variant after resolution.
613        let i = Inline::Image {
614            src: Url::resolved("img/cat.jpg", UrlKind::Asset),
615            alt: "Cat".to_string(),
616            title: None,
617            is_wikilink: false,
618            wikilink_pothole: None,
619        };
620        match i {
621            Inline::Image {
622                src,
623                alt,
624                title: _,
625                is_wikilink: _,
626                wikilink_pothole: _,
627            } => {
628                let Url::Resolved(r) = src else {
629                    panic!("expected Resolved, got {src:?}")
630                };
631                assert_eq!(r.kind, UrlKind::Asset);
632                assert_eq!(alt, "Cat");
633            }
634            _ => panic!("expected Image"),
635        }
636    }
637
638    #[test]
639    fn inline_emphasis_and_strong_nest() {
640        let i = Inline::Strong(vec![Inline::Emphasis(vec![text("nested")])]);
641        match i {
642            Inline::Strong(children) => match &children[0] {
643                Inline::Emphasis(inner) => assert_eq!(inner.len(), 1),
644                _ => panic!("expected Emphasis"),
645            },
646            _ => panic!("expected Strong"),
647        }
648    }
649
650    #[test]
651    fn block_round_trips_through_serde() {
652        let original = Block::Heading {
653            level: 2,
654            children: vec![Inline::Text("Setup".to_string())],
655            id: Some("setup".to_string()),
656        };
657        let s = serde_json::to_string(&original).expect("serialize");
658        let back: Block = serde_json::from_str(&s).expect("deserialize");
659        assert_eq!(original, back);
660    }
661
662    // -----------------------------------------------------------------
663    // Phase 4 PR4: CalloutKind canonicalization
664    // -----------------------------------------------------------------
665
666    #[test]
667    fn callout_kind_canonicalizes_canonical_names() {
668        assert_eq!(CalloutKind::from_raw("note"), Some(CalloutKind::Note));
669        assert_eq!(CalloutKind::from_raw("tip"), Some(CalloutKind::Tip));
670        assert_eq!(CalloutKind::from_raw("warning"), Some(CalloutKind::Warning));
671        assert_eq!(CalloutKind::from_raw("danger"), Some(CalloutKind::Danger));
672        assert_eq!(CalloutKind::from_raw("info"), Some(CalloutKind::Info));
673        assert_eq!(CalloutKind::from_raw("todo"), Some(CalloutKind::Todo));
674        assert_eq!(CalloutKind::from_raw("success"), Some(CalloutKind::Success));
675        assert_eq!(
676            CalloutKind::from_raw("question"),
677            Some(CalloutKind::Question)
678        );
679        assert_eq!(CalloutKind::from_raw("failure"), Some(CalloutKind::Failure));
680        assert_eq!(CalloutKind::from_raw("bug"), Some(CalloutKind::Bug));
681        assert_eq!(CalloutKind::from_raw("example"), Some(CalloutKind::Example));
682        assert_eq!(CalloutKind::from_raw("quote"), Some(CalloutKind::Quote));
683        assert_eq!(
684            CalloutKind::from_raw("abstract"),
685            Some(CalloutKind::Abstract)
686        );
687    }
688
689    #[test]
690    fn callout_kind_canonicalizes_all_obsidian_aliases() {
691        // The 8 alias mappings from shape-spec § 1.
692        assert_eq!(CalloutKind::from_raw("tldr"), Some(CalloutKind::Abstract));
693        assert_eq!(
694            CalloutKind::from_raw("summary"),
695            Some(CalloutKind::Abstract)
696        );
697        assert_eq!(CalloutKind::from_raw("hint"), Some(CalloutKind::Tip));
698        assert_eq!(CalloutKind::from_raw("important"), Some(CalloutKind::Tip));
699        assert_eq!(CalloutKind::from_raw("check"), Some(CalloutKind::Success));
700        assert_eq!(CalloutKind::from_raw("done"), Some(CalloutKind::Success));
701        assert_eq!(CalloutKind::from_raw("help"), Some(CalloutKind::Question));
702        assert_eq!(CalloutKind::from_raw("faq"), Some(CalloutKind::Question));
703        assert_eq!(CalloutKind::from_raw("caution"), Some(CalloutKind::Warning));
704        assert_eq!(
705            CalloutKind::from_raw("attention"),
706            Some(CalloutKind::Warning)
707        );
708        assert_eq!(CalloutKind::from_raw("fail"), Some(CalloutKind::Failure));
709        assert_eq!(CalloutKind::from_raw("missing"), Some(CalloutKind::Failure));
710        assert_eq!(CalloutKind::from_raw("error"), Some(CalloutKind::Danger));
711        assert_eq!(CalloutKind::from_raw("cite"), Some(CalloutKind::Quote));
712        // Legacy alias for SoCiviC Theatre's `> [!pending]` syntax.
713        assert_eq!(CalloutKind::from_raw("pending"), Some(CalloutKind::Todo));
714    }
715
716    #[test]
717    fn callout_kind_is_case_insensitive() {
718        assert_eq!(CalloutKind::from_raw("NOTE"), Some(CalloutKind::Note));
719        assert_eq!(CalloutKind::from_raw("Warning"), Some(CalloutKind::Warning));
720        assert_eq!(CalloutKind::from_raw("TLDR"), Some(CalloutKind::Abstract));
721    }
722
723    #[test]
724    fn callout_kind_unknown_returns_none() {
725        assert_eq!(CalloutKind::from_raw("xyz"), None);
726        assert_eq!(CalloutKind::from_raw(""), None);
727        assert_eq!(CalloutKind::from_raw("not-a-kind"), None);
728    }
729
730    #[test]
731    fn callout_kind_slug_matches_canonical_name() {
732        assert_eq!(CalloutKind::Note.as_slug(), "note");
733        assert_eq!(CalloutKind::Abstract.as_slug(), "abstract");
734        assert_eq!(CalloutKind::Warning.as_slug(), "warning");
735        assert_eq!(CalloutKind::Danger.as_slug(), "danger");
736    }
737
738    #[test]
739    fn callout_kind_default_title_is_capitalized() {
740        assert_eq!(CalloutKind::Note.default_title(), "Note");
741        assert_eq!(CalloutKind::Warning.default_title(), "Warning");
742        assert_eq!(CalloutKind::Abstract.default_title(), "Abstract");
743    }
744
745    #[test]
746    fn block_callout_round_trips_through_serde() {
747        let original = Block::Callout {
748            kind: CalloutKind::Warning,
749            fold: Some(Fold::Open),
750            title: Some("Hey".to_string()),
751            children: vec![Block::Paragraph(vec![Inline::Text("body".into())])],
752        };
753        let s = serde_json::to_string(&original).expect("serialize");
754        let back: Block = serde_json::from_str(&s).expect("deserialize");
755        assert_eq!(original, back);
756    }
757
758    #[test]
759    fn inline_link_with_resolved_url_round_trips() {
760        let original = Inline::Link {
761            url: Url::resolved("../docs/", UrlKind::Wikilink),
762            title: Some("Docs".to_string()),
763            children: vec![Inline::Text("see".to_string())],
764            is_wikilink: true,
765        };
766        let s = serde_json::to_string(&original).expect("serialize");
767        let back: Inline = serde_json::from_str(&s).expect("deserialize");
768        assert_eq!(original, back);
769    }
770
771    #[test]
772    fn inline_link_is_wikilink_serde_defaults_to_false() {
773        // When deserializing AST JSON authored before PR7a, missing
774        // `is_wikilink` field must default to `false` (back-compat for
775        // any serialized snapshots that pre-date the wikilink AST work).
776        let json =
777            r#"{"link":{"url":{"unresolved":"docs/"},"title":null,"children":[{"text":"Docs"}]}}"#;
778        let back: Inline = serde_json::from_str(json).expect("deserialize");
779        match back {
780            Inline::Link { is_wikilink, .. } => assert!(!is_wikilink),
781            _ => panic!("expected Link"),
782        }
783    }
784}