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