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}
61
62impl Document {
63 /// Construct an empty document.
64 pub fn new() -> Self {
65 Self::default()
66 }
67
68 /// Construct a document from a list of blocks. All `block_meta` entries
69 /// are default (no source-line tracking). Use
70 /// [`Document::from_blocks_with_meta`] when meta is known.
71 pub fn from_blocks(blocks: Vec<Block>) -> Self {
72 let block_meta = vec![BlockMeta::default(); blocks.len()];
73 Self {
74 blocks,
75 block_meta,
76 slot_only: false,
77 }
78 }
79
80 /// Construct a document from blocks + parallel meta. Panics in debug if
81 /// the two slices have different lengths.
82 pub fn from_blocks_with_meta(blocks: Vec<Block>, block_meta: Vec<BlockMeta>) -> Self {
83 debug_assert_eq!(
84 blocks.len(),
85 block_meta.len(),
86 "Document::from_blocks_with_meta: blocks and block_meta must be equal length"
87 );
88 Self {
89 blocks,
90 block_meta,
91 slot_only: false,
92 }
93 }
94
95 /// True if any top-level block is a shortcode of the given kind.
96 ///
97 /// Shallow check: does NOT descend into nested blocks (e.g. a
98 /// `:::subscribe` inside a `:::grid` cell would not be found by this
99 /// query alone). Phase A models the shallow case; deeper
100 /// `has_shortcode_recursive` lands when nested-shortcode AST queries
101 /// are first needed.
102 pub fn has_shortcode(&self, kind: super::shortcode::ShortcodeKind) -> bool {
103 self.blocks.iter().any(|b| match b {
104 Block::Shortcode(sc) => sc.kind() == kind,
105 _ => false,
106 })
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::super::node::Inline;
113 use super::*;
114
115 #[test]
116 fn empty_document_has_no_blocks() {
117 let d = Document::new();
118 assert!(d.blocks.is_empty());
119 assert!(!d.slot_only);
120 }
121
122 #[test]
123 fn from_blocks_constructs_with_blocks() {
124 let d = Document::from_blocks(vec![Block::ThematicBreak]);
125 assert_eq!(d.blocks.len(), 1);
126 assert!(!d.slot_only);
127 }
128
129 #[test]
130 fn slot_only_default_is_false() {
131 // Crucial: an article doc must NOT silently become a slot.
132 let d = Document::default();
133 assert!(!d.slot_only);
134 }
135
136 #[test]
137 fn slot_only_settable() {
138 let mut d = Document::new();
139 d.slot_only = true;
140 assert!(d.slot_only);
141 }
142
143 #[test]
144 fn document_round_trips_through_serde() {
145 let mut original = Document::new();
146 original.blocks.push(Block::Paragraph(vec![Inline::Text(
147 "hello".to_string(),
148 )]));
149 original.slot_only = true;
150 let s = serde_json::to_string(&original).expect("serialize");
151 let back: Document = serde_json::from_str(&s).expect("deserialize");
152 assert_eq!(original, back);
153 }
154
155 #[test]
156 fn has_shortcode_returns_false_when_empty() {
157 // The Shortcode enum is empty in Phase A; this test exercises the
158 // query path on the empty case (which is the only constructable
159 // case until Phase B). Extended per-kind tests land alongside
160 // each shortcode migration.
161 let d = Document::new();
162 assert!(!d.has_shortcode(super::super::shortcode::ShortcodeKind::Subscribe));
163 }
164}