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 `` 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 /// `` 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 /// `` 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}