Skip to main content

quillmark_core/document/
mod.rs

1//! # Document Module
2//!
3//! Parsing functionality for markdown documents with YAML frontmatter.
4//!
5//! ## Overview
6//!
7//! The `document` module provides the [`Document::from_markdown`] function for parsing
8//! markdown documents into a typed in-memory model.
9//!
10//! ## Key Types
11//!
12//! - [`Document`]: Typed in-memory Quillmark document — `main` card plus composable cards.
13//! - [`Card`]: A single card block, main or composable, with a sentinel,
14//!   typed frontmatter, and a body.
15//! - [`Sentinel`]: Discriminates the `QUILL:` frontmatter from composable card blocks.
16//! - [`Frontmatter`]: Ordered list of items (fields + comments) parsed from a YAML fence.
17//!
18//! ## Examples
19//!
20//! ### Basic Parsing
21//!
22//! ```
23//! use quillmark_core::Document;
24//!
25//! let markdown = r#"---
26//! QUILL: my_quill
27//! title: My Document
28//! author: John Doe
29//! ---
30//!
31//! # Introduction
32//!
33//! Document content here.
34//! "#;
35//!
36//! let doc = Document::from_markdown(markdown).unwrap();
37//! let title = doc.main()
38//!     .frontmatter()
39//!     .get("title")
40//!     .and_then(|v| v.as_str())
41//!     .unwrap_or("Untitled");
42//! assert_eq!(title, "My Document");
43//! assert_eq!(doc.cards().len(), 0);
44//! ```
45//!
46//! ### Accessing the plate wire format
47//!
48//! ```
49//! use quillmark_core::Document;
50//!
51//! let doc = Document::from_markdown(
52//!     "---\nQUILL: my_quill\ntitle: Hi\n---\n\nBody here.\n"
53//! ).unwrap();
54//! let json = doc.to_plate_json();
55//! assert_eq!(json["QUILL"], "my_quill");
56//! assert_eq!(json["title"], "Hi");
57//! assert_eq!(json["BODY"], "\nBody here.\n");
58//! assert!(json["CARDS"].is_array());
59//! ```
60//!
61//! ## Error Handling
62//!
63//! [`Document::from_markdown`] returns errors for:
64//! - Malformed YAML syntax
65//! - Unclosed frontmatter blocks
66//! - Multiple global frontmatter blocks
67//! - Both QUILL and CARD specified in the same block
68//! - Reserved field name usage
69//! - Name collisions
70//!
71//! See [MARKDOWN.md](https://github.com/quillmark-org/quillmark/blob/main/prose/canon/MARKDOWN.md)
72//! for the Quillmark Markdown specification, including frontmatter and card syntax.
73
74use serde::{Deserialize, Serialize};
75
76use crate::error::ParseError;
77use crate::version::QuillReference;
78use crate::Diagnostic;
79
80pub mod assemble;
81pub mod dto;
82pub mod edit;
83pub mod emit;
84pub mod fences;
85pub mod frontmatter;
86pub mod limits;
87pub mod prescan;
88pub mod sentinel;
89
90pub use dto::{peek_schema_version, StorageError, StoredDocument, SCHEMA_V0_81_0};
91pub use edit::EditError;
92pub use frontmatter::{Frontmatter, FrontmatterItem};
93
94// Re-export the sentinel type (defined below in this module file).
95// `Sentinel` is exported at the crate root via `lib.rs`.
96
97#[cfg(test)]
98mod tests;
99
100/// Parse result carrying both the parsed document and any non-fatal warnings
101/// (e.g. near-miss sentinel lints emitted per spec §4.2).
102#[derive(Debug)]
103pub struct ParseOutput {
104    /// The successfully parsed document.
105    pub document: Document,
106    /// Non-fatal warnings collected during parsing.
107    pub warnings: Vec<Diagnostic>,
108}
109
110/// Discriminator for a [`Card`].
111///
112/// The document frontmatter carries `QUILL: <ref>` and is the document-level
113/// *main* card; every composable card carries a kind — written as the
114/// ```` ```card <kind> ```` info string (canonical) or a legacy `CARD: <kind>`
115/// sentinel. `Sentinel` captures that distinction in the typed model so every
116/// card is one uniform shape.
117#[derive(Debug, Clone, PartialEq)]
118pub enum Sentinel {
119    /// The document entry card, carrying the `QUILL` reference.
120    Main(QuillReference),
121    /// A composable card with the given kind tag.
122    Card(String),
123}
124
125impl Sentinel {
126    /// String form of this sentinel's value: the quill reference for `Main`,
127    /// the tag for `Card`.
128    pub fn as_str(&self) -> String {
129        match self {
130            Sentinel::Main(r) => r.to_string(),
131            Sentinel::Card(t) => t.clone(),
132        }
133    }
134
135    /// Returns `true` if this is a `Main` sentinel.
136    pub fn is_main(&self) -> bool {
137        matches!(self, Sentinel::Main(_))
138    }
139}
140
141/// A single metadata fence parsed from a Quillmark Markdown document.
142///
143/// A `Card` is the uniform shape for both the document entry (main) fence and
144/// composable card fences. `sentinel` distinguishes the two.
145///
146/// Every card has:
147/// - `sentinel` — the `QUILL` reference (for main) or `CARD` tag (for composable).
148/// - `frontmatter` — ordered items parsed from the YAML fence body (with the
149///   sentinel key already removed).
150/// - `body` — the Markdown text that follows the closing fence, up to the next
151///   fence (or EOF).
152///
153/// ## Card body absence
154///
155/// If a card block has no trailing Markdown content (e.g. the next block or
156/// EOF immediately follows the closing fence), `body` is the empty string `""`.
157/// It is never `None`; callers that need to distinguish "absent" from "empty"
158/// should check `card.body().is_empty()`.
159#[derive(Debug, Clone, PartialEq)]
160pub struct Card {
161    sentinel: Sentinel,
162    frontmatter: Frontmatter,
163    body: String,
164}
165
166impl Card {
167    /// Create a `Card` directly from a sentinel, a typed frontmatter, and a
168    /// body. Does **not** validate the sentinel tag or any field names —
169    /// callers are responsible for providing already-valid data. For
170    /// user-facing construction of composable cards use [`Card::new`]
171    /// (defined in `edit.rs`).
172    pub fn new_with_sentinel(sentinel: Sentinel, frontmatter: Frontmatter, body: String) -> Self {
173        Self {
174            sentinel,
175            frontmatter,
176            body,
177        }
178    }
179
180    /// The sentinel discriminating this card as main or composable.
181    pub fn sentinel(&self) -> &Sentinel {
182        &self.sentinel
183    }
184
185    /// The card tag — the card kind for composable cards, or the string
186    /// form of the quill reference for main cards.
187    pub fn tag(&self) -> String {
188        self.sentinel.as_str()
189    }
190
191    /// Typed frontmatter (map-keyed view and ordered item list).
192    pub fn frontmatter(&self) -> &Frontmatter {
193        &self.frontmatter
194    }
195
196    /// Mutable access to the frontmatter.
197    pub fn frontmatter_mut(&mut self) -> &mut Frontmatter {
198        &mut self.frontmatter
199    }
200
201    /// Markdown body that follows this card's closing fence.
202    ///
203    /// Empty string when no trailing content is present.
204    pub fn body(&self) -> &str {
205        &self.body
206    }
207
208    /// Returns `true` if this is the document entry (main) card.
209    pub fn is_main(&self) -> bool {
210        self.sentinel.is_main()
211    }
212
213    /// Replace this card's sentinel. Internal helper; public mutators
214    /// ([`Document::set_quill_ref`], the parser) call this.
215    pub(crate) fn replace_sentinel(&mut self, sentinel: Sentinel) {
216        self.sentinel = sentinel;
217    }
218
219    /// Overwrite the body string. Internal helper used by [`Card::replace_body`].
220    pub(crate) fn overwrite_body(&mut self, body: String) {
221        self.body = body;
222    }
223}
224
225/// A fully-parsed, typed in-memory Quillmark document.
226///
227/// `Document` is the canonical representation of a Quillmark Markdown file.
228/// Markdown is both the import and export format; the structured data here
229/// is primary.
230///
231/// ## Structure
232///
233/// - `main` — the entry `Card` (sentinel is `Sentinel::Main(reference)`).
234/// - `cards` — ordered composable cards (each with `Sentinel::Card(tag)`).
235///
236/// Backend plates consume the flat JSON wire shape produced by
237/// [`Document::to_plate_json`]. That method is the **only** place in core
238/// that reconstructs `{"QUILL": ..., "CARDS": [...], "BODY": "..."}`.
239///
240/// ## Serialization
241///
242/// `Document` implements `serde::Serialize`/`Deserialize` for persistence
243/// (e.g. storing documents in a database). Serialization is routed through
244/// the versioned [`StoredDocument`] envelope — see the [`dto`] module — so
245/// the stored JSON is decoupled from both the evolving Markdown syntax and
246/// this struct's in-memory layout. This is distinct from the plate wire
247/// shape ([`to_plate_json`](Document::to_plate_json), a one-way export for
248/// backends) and from Markdown ([`to_markdown`](Document::to_markdown)).
249#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
250#[serde(into = "StoredDocument", try_from = "StoredDocument")]
251pub struct Document {
252    main: Card,
253    cards: Vec<Card>,
254}
255
256impl Document {
257    /// Create a `Document` from a pre-built main `Card` and composable cards.
258    ///
259    /// The caller must guarantee that `main.sentinel` is `Sentinel::Main(_)`
260    /// and every card in `cards` has `sentinel` = `Sentinel::Card(_)`.
261    pub fn from_main_and_cards(main: Card, cards: Vec<Card>) -> Self {
262        debug_assert!(main.sentinel.is_main(), "main card must be Sentinel::Main");
263        debug_assert!(
264            cards.iter().all(|c| !c.sentinel.is_main()),
265            "composable cards must be Sentinel::Card"
266        );
267        Self { main, cards }
268    }
269
270    /// Parse a Quillmark Markdown document, discarding any non-fatal warnings.
271    pub fn from_markdown(markdown: &str) -> Result<Self, ParseError> {
272        assemble::decompose(markdown)
273    }
274
275    /// Parse a Quillmark Markdown document, returning warnings alongside the document.
276    pub fn from_markdown_with_warnings(markdown: &str) -> Result<ParseOutput, ParseError> {
277        assemble::decompose_with_warnings(markdown)
278            .map(|(document, warnings)| ParseOutput { document, warnings })
279    }
280
281    // ── Accessors ──────────────────────────────────────────────────────────────
282
283    /// The document's main (entry) card.
284    pub fn main(&self) -> &Card {
285        &self.main
286    }
287
288    /// Mutable access to the main card.
289    pub fn main_mut(&mut self) -> &mut Card {
290        &mut self.main
291    }
292
293    /// The quill reference (`name@version-selector`) carried by the main card's
294    /// sentinel. Convenience reader over `doc.main().sentinel()`.
295    pub fn quill_reference(&self) -> &QuillReference {
296        match &self.main.sentinel {
297            Sentinel::Main(r) => r,
298            Sentinel::Card(_) => {
299                unreachable!("main card must carry Sentinel::Main by construction")
300            }
301        }
302    }
303
304    /// Ordered list of composable card blocks.
305    pub fn cards(&self) -> &[Card] {
306        &self.cards
307    }
308
309    /// Mutable access to the composable cards slice.
310    pub fn cards_mut(&mut self) -> &mut [Card] {
311        &mut self.cards
312    }
313
314    /// Internal mutable access to the backing `Vec<Card>`. Used by edit
315    /// operations ([`Document::push_card`], etc.) that need to insert or
316    /// remove elements.
317    pub(crate) fn cards_vec_mut(&mut self) -> &mut Vec<Card> {
318        &mut self.cards
319    }
320
321    // ── Wire format ────────────────────────────────────────────────────────────
322
323    /// Serialize this document to the JSON shape expected by backend plates.
324    ///
325    /// The output has the following top-level keys, which match what
326    /// `lib.typ.template` reads at Typst runtime:
327    ///
328    /// ```json
329    /// {
330    ///   "QUILL": "<ref>",
331    ///   "<field>": <value>,
332    ///   ...
333    ///   "BODY": "<global-body>",
334    ///   "CARDS": [
335    ///     { "CARD": "<tag>", "<field>": <value>, ..., "BODY": "<card-body>" },
336    ///     ...
337    ///   ]
338    /// }
339    /// ```
340    ///
341    /// This is the **only** place in `quillmark-core` that knows about the plate
342    /// wire format. All internal consumers (Quill, backends) call this instead
343    /// of constructing the shape by hand.
344    pub fn to_plate_json(&self) -> serde_json::Value {
345        let mut map = serde_json::Map::new();
346
347        // QUILL first — plate authors expect this at the top.
348        map.insert(
349            "QUILL".to_string(),
350            serde_json::Value::String(self.quill_reference().to_string()),
351        );
352
353        // Frontmatter fields in insertion order.
354        for (key, value) in self.main.frontmatter.iter() {
355            map.insert(key.clone(), value.as_json().clone());
356        }
357
358        // Global body.
359        map.insert(
360            "BODY".to_string(),
361            serde_json::Value::String(self.main.body.clone()),
362        );
363
364        // Cards array.
365        let cards_array: Vec<serde_json::Value> = self
366            .cards
367            .iter()
368            .map(|card| {
369                let mut card_map = serde_json::Map::new();
370                card_map.insert("CARD".to_string(), serde_json::Value::String(card.tag()));
371                for (key, value) in card.frontmatter.iter() {
372                    card_map.insert(key.clone(), value.as_json().clone());
373                }
374                card_map.insert(
375                    "BODY".to_string(),
376                    serde_json::Value::String(card.body.clone()),
377                );
378                serde_json::Value::Object(card_map)
379            })
380            .collect();
381
382        map.insert("CARDS".to_string(), serde_json::Value::Array(cards_array));
383
384        serde_json::Value::Object(map)
385    }
386}