Skip to main content

mcd_core/
document.rs

1//! Canonical document stream types.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{Manifest, McdPackage, markdown};
6
7/// Parsed MCD document with a canonical block stream.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct McdDocument {
10    /// Source Markdown path inside the package.
11    pub source_path: String,
12    /// Blocks in source order.
13    pub blocks: Vec<DocumentBlock>,
14}
15
16impl McdDocument {
17    /// Parse the manifest entrypoint Markdown from a package.
18    pub fn from_package(package: &McdPackage, manifest: &Manifest) -> crate::Result<Self> {
19        let markdown = package.read_to_string(&manifest.entrypoint)?;
20        markdown::parse_markdown(&manifest.entrypoint, &markdown)
21    }
22}
23
24/// A canonical document block.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(tag = "type", rename_all = "snake_case")]
27pub enum DocumentBlock {
28    /// Markdown heading.
29    Heading {
30        /// Stable generated block id.
31        id: String,
32        /// Heading level.
33        level: u8,
34        /// Plain heading text.
35        text: String,
36        /// Source span when available.
37        #[serde(skip_serializing_if = "Option::is_none")]
38        source: Option<SourceSpan>,
39        /// Inline annotation markers in this block.
40        #[serde(default, skip_serializing_if = "Vec::is_empty")]
41        annotations: Vec<AnnotationRef>,
42    },
43    /// Markdown paragraph.
44    Paragraph {
45        /// Stable generated block id.
46        id: String,
47        /// Plain paragraph text.
48        text: String,
49        /// Source span when available.
50        #[serde(skip_serializing_if = "Option::is_none")]
51        source: Option<SourceSpan>,
52        /// Inline annotation markers in this block.
53        #[serde(default, skip_serializing_if = "Vec::is_empty")]
54        annotations: Vec<AnnotationRef>,
55    },
56    /// Markdown list.
57    List {
58        /// Stable generated block id.
59        id: String,
60        /// Plain list text.
61        text: String,
62        /// Source span when available.
63        #[serde(skip_serializing_if = "Option::is_none")]
64        source: Option<SourceSpan>,
65        /// Inline annotation markers in this block.
66        #[serde(default, skip_serializing_if = "Vec::is_empty")]
67        annotations: Vec<AnnotationRef>,
68    },
69    /// Markdown code block.
70    CodeBlock {
71        /// Stable generated block id.
72        id: String,
73        /// Optional language/info string.
74        #[serde(skip_serializing_if = "Option::is_none")]
75        language: Option<String>,
76        /// Literal code text.
77        text: String,
78        /// Source span when available.
79        #[serde(skip_serializing_if = "Option::is_none")]
80        source: Option<SourceSpan>,
81        /// Inline annotation markers in this block.
82        #[serde(default, skip_serializing_if = "Vec::is_empty")]
83        annotations: Vec<AnnotationRef>,
84    },
85    /// Markdown block quote.
86    Quote {
87        /// Stable generated block id.
88        id: String,
89        /// Plain quote text.
90        text: String,
91        /// Source span when available.
92        #[serde(skip_serializing_if = "Option::is_none")]
93        source: Option<SourceSpan>,
94        /// Inline annotation markers in this block.
95        #[serde(default, skip_serializing_if = "Vec::is_empty")]
96        annotations: Vec<AnnotationRef>,
97    },
98    /// Markdown display math block.
99    MathBlock {
100        /// Stable generated block id.
101        id: String,
102        /// Literal math text.
103        text: String,
104        /// Source span when available.
105        #[serde(skip_serializing_if = "Option::is_none")]
106        source: Option<SourceSpan>,
107    },
108    /// MCD table placement.
109    TableRef {
110        /// Stable generated block id.
111        id: String,
112        /// Parsed table placement.
113        placement: crate::directives::TablePlacement,
114        /// Source span when available.
115        #[serde(skip_serializing_if = "Option::is_none")]
116        source: Option<SourceSpan>,
117    },
118    /// MCD image placement.
119    ImageRef {
120        /// Stable generated block id.
121        id: String,
122        /// Parsed image placement.
123        placement: crate::directives::ImagePlacement,
124        /// Source span when available.
125        #[serde(skip_serializing_if = "Option::is_none")]
126        source: Option<SourceSpan>,
127    },
128}
129
130impl DocumentBlock {
131    /// Return the stable block id.
132    #[must_use]
133    pub fn id(&self) -> &str {
134        match self {
135            Self::Heading { id, .. }
136            | Self::Paragraph { id, .. }
137            | Self::List { id, .. }
138            | Self::CodeBlock { id, .. }
139            | Self::Quote { id, .. }
140            | Self::MathBlock { id, .. }
141            | Self::TableRef { id, .. }
142            | Self::ImageRef { id, .. } => id,
143        }
144    }
145
146    /// Return annotation refs attached to the block.
147    #[must_use]
148    pub fn annotation_refs(&self) -> &[AnnotationRef] {
149        match self {
150            Self::Heading { annotations, .. }
151            | Self::Paragraph { annotations, .. }
152            | Self::List { annotations, .. }
153            | Self::Quote { annotations, .. } => annotations,
154            Self::TableRef { placement, .. } => &placement.annotations,
155            Self::ImageRef { placement, .. } => &placement.annotations,
156            Self::CodeBlock { .. } | Self::MathBlock { .. } => &[],
157        }
158    }
159}
160
161/// Annotation marker embedded in Markdown and resolved to sidecar metadata.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct AnnotationRef {
165    /// Referenced annotation id.
166    pub id: String,
167    /// UTF-8 byte offset in the cleaned block text, when the marker is inline.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub text_offset: Option<usize>,
170}
171
172/// 1-based source span in the Markdown entrypoint.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct SourceSpan {
176    /// First line.
177    pub start_line: usize,
178    /// First column.
179    pub start_column: usize,
180    /// Last line.
181    pub end_line: usize,
182    /// Last column.
183    pub end_column: usize,
184}
185
186impl std::fmt::Display for SourceSpan {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        write!(
189            f,
190            "{}:{}-{}:{}",
191            self.start_line, self.start_column, self.end_line, self.end_column
192        )
193    }
194}