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}