Skip to main content

moss_core/ast/
document.rs

1//! Top-level parsed document.
2
3use serde::{Deserialize, Serialize};
4
5use super::node::Block;
6
7/// Per-top-level-block metadata.
8///
9/// Holds parse-time annotations that don't belong on the `Block` enum
10/// itself (which is shape-only). Lives in a parallel `Vec<BlockMeta>` on
11/// [`Document`] so existing pattern matches over `Block` don't need to
12/// unwrap a meta wrapper.
13///
14/// Today the only field is `source_line` (set by the parser when
15/// [`crate::ast::ParseConfig::emit_source_lines`] is true). Additional
16/// per-block annotations (block IDs, custom attrs, etc.) land here too
17/// when needed.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19pub struct BlockMeta {
20    /// 1-based source line where this block begins, or `None` when source
21    /// tracking is off (or the block was synthesized — e.g. shortcode
22    /// substitution — and has no faithful source position).
23    ///
24    /// Consumed by the renderer to emit `data-source-line="N"` on the
25    /// opening tag, which the preview's `cm-scroll-sync` consumes for
26    /// paragraph-level editor↔preview scroll sync.
27    pub source_line: Option<usize>,
28}
29
30/// A parsed markdown document body.
31///
32/// Wraps `Vec<Block>` with document-level flags. The frontmatter sits in
33/// [`crate::frontmatter::ParsedDocument`]; this struct is body-only.
34/// Higher-level code combines the two as needed.
35#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
36pub struct Document {
37    /// Body content as a flat list of block-level nodes.
38    pub blocks: Vec<Block>,
39    /// Parallel-to-`blocks` metadata. **Invariant:** `block_meta.len() ==
40    /// blocks.len()`. The renderer asserts this in debug builds and
41    /// gracefully degrades (treats missing entries as `BlockMeta::default()`)
42    /// in release builds.
43    ///
44    /// Defaults to an all-`BlockMeta::default()` vec sized to match
45    /// `blocks` when constructed via [`Document::from_blocks`]; only the
46    /// parser populates `source_line` (under
47    /// [`crate::ast::ParseConfig::emit_source_lines`]).
48    #[serde(default)]
49    pub block_meta: Vec<BlockMeta>,
50    /// True if this document is a slot file (e.g. `footer.md`) — its
51    /// content fills a layout slot rather than being rendered as an
52    /// article. The renderer suppresses auto-injected article chrome
53    /// (`<h1 class="moss-article-title">`) when this is set.
54    ///
55    /// Replaces the `heading: false` YAML synthesis hack at
56    /// `src-tauri/src/build/footer.rs:160`. Lands in Phase A.5 of the
57    /// typed-AST migration; in Phase A it's available but not yet
58    /// consumed by src-tauri.
59    pub slot_only: bool,
60    /// Non-fatal problems found while parsing this body — today, every
61    /// `:::name` fence whose name is not a registered shortcode.
62    ///
63    /// The extractor has collected these since the unknown-name fallback
64    /// landed, but nothing carried them out of the parse, so the only trace
65    /// a misspelling left was a `moss-unknown-shortcode` div in the built
66    /// HTML. An author (or an agent) got a page that had silently lost its
67    /// gallery and a build that said nothing. This field is that missing
68    /// hop; `build::markdown::pipeline` prints each entry against the file.
69    ///
70    /// Scope, precisely: top-level fences, fences nested inside an
71    /// *unknown* fence (that branch recurses through `extract_with_state`,
72    /// threading one collection), AND fences nested inside a *valid*
73    /// shortcode's body — grid cells (`parse_cell_to_blocks`) and the hero
74    /// overlay (`parse_overlay_to_blocks`) each re-parse their body as an
75    /// independent fragment via `parse_fragment_with_config`; both now
76    /// return that fragment `Document`'s `warnings` alongside its blocks,
77    /// and `parse_shortcode_block` merges them into the `Vec<String>` it
78    /// already threads into `extract_with_state`'s shared collection. So a
79    /// misspelling inside `:::grid` or `:::hero` warns exactly like a
80    /// top-level one; see `unknown_shortcode_inside_a_valid_shortcode_warns`
81    /// and `unknown_shortcode_inside_a_hero_overlay_warns`.
82    #[serde(default)]
83    pub warnings: Vec<String>,
84}
85
86impl Document {
87    /// Construct an empty document.
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// Construct a document from a list of blocks. All `block_meta` entries
93    /// are default (no source-line tracking). Use
94    /// [`Document::from_blocks_with_meta`] when meta is known.
95    pub fn from_blocks(blocks: Vec<Block>) -> Self {
96        let block_meta = vec![BlockMeta::default(); blocks.len()];
97        Self {
98            blocks,
99            block_meta,
100            slot_only: false,
101            warnings: Vec::new(),
102        }
103    }
104
105    /// Construct a document from blocks + parallel meta. Panics in debug if
106    /// the two slices have different lengths.
107    pub fn from_blocks_with_meta(blocks: Vec<Block>, block_meta: Vec<BlockMeta>) -> Self {
108        debug_assert_eq!(
109            blocks.len(),
110            block_meta.len(),
111            "Document::from_blocks_with_meta: blocks and block_meta must be equal length"
112        );
113        Self {
114            blocks,
115            block_meta,
116            slot_only: false,
117            warnings: Vec::new(),
118        }
119    }
120
121    /// True if any top-level block is a shortcode of the given kind.
122    ///
123    /// Shallow check: does NOT descend into nested blocks (e.g. a
124    /// `:::subscribe` inside a `:::grid` cell would not be found by this
125    /// query alone). Phase A models the shallow case; deeper
126    /// `has_shortcode_recursive` lands when nested-shortcode AST queries
127    /// are first needed.
128    pub fn has_shortcode(&self, kind: super::shortcode::ShortcodeKind) -> bool {
129        self.blocks.iter().any(|b| match b {
130            Block::Shortcode(sc) => sc.kind() == kind,
131            _ => false,
132        })
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::super::node::Inline;
139    use super::*;
140
141    #[test]
142    fn empty_document_has_no_blocks() {
143        let d = Document::new();
144        assert!(d.blocks.is_empty());
145        assert!(!d.slot_only);
146    }
147
148    #[test]
149    fn from_blocks_constructs_with_blocks() {
150        let d = Document::from_blocks(vec![Block::ThematicBreak]);
151        assert_eq!(d.blocks.len(), 1);
152        assert!(!d.slot_only);
153    }
154
155    #[test]
156    fn slot_only_default_is_false() {
157        // Crucial: an article doc must NOT silently become a slot.
158        let d = Document::default();
159        assert!(!d.slot_only);
160    }
161
162    #[test]
163    fn slot_only_settable() {
164        let mut d = Document::new();
165        d.slot_only = true;
166        assert!(d.slot_only);
167    }
168
169    #[test]
170    fn document_round_trips_through_serde() {
171        let mut original = Document::new();
172        original.blocks.push(Block::Paragraph(vec![Inline::Text(
173            "hello".to_string(),
174        )]));
175        original.slot_only = true;
176        original.warnings.push("unknown shortcode `:::nope`".to_string());
177        let s = serde_json::to_string(&original).expect("serialize");
178        let back: Document = serde_json::from_str(&s).expect("deserialize");
179        assert_eq!(original, back);
180    }
181
182    #[test]
183    fn has_shortcode_returns_false_when_empty() {
184        // The Shortcode enum is empty in Phase A; this test exercises the
185        // query path on the empty case (which is the only constructable
186        // case until Phase B). Extended per-kind tests land alongside
187        // each shortcode migration.
188        let d = Document::new();
189        assert!(!d.has_shortcode(super::super::shortcode::ShortcodeKind::Subscribe));
190    }
191}