Skip to main content

quillmark_core/document/
mod.rs

1//! Parsing and typed in-memory model for Quillmark card-yaml documents.
2//!
3//! A [`Document`] holds a root [`Card`] plus ordered composable cards; each
4//! card carries a [`Payload`], source-ordered items ([`PayloadItem`]:
5//! `$quill`/`$kind`/`$ext` metadata, user fields, and comments, in the order
6//! they appear in the block's YAML content), and a Markdown body.
7//! [`Document::parse`] returns errors for malformed YAML, unclosed
8//! fences, a missing root `$quill`, or unknown `$`-prefixed system keys.
9//!
10//! See [markdown-spec.md](https://github.com/borb-sh/quillmark/blob/main/prose/references/markdown-spec.md)
11//! for the card-yaml format specification.
12
13use serde::{Deserialize, Serialize};
14
15use quillmark_content::import::{from_markdown as import_markdown, ImportError};
16use quillmark_content::Content;
17
18use crate::error::ParseError;
19use crate::version::QuillReference;
20use crate::Diagnostic;
21
22/// The single markdown→content boundary for card bodies. Every construction path
23/// that starts from an authored markdown string ([`Document::parse`],
24/// wire/storage deserialization, seeding, blueprint) routes through it, so the
25/// markdown parser is reached from exactly one helper. An empty string yields
26/// the empty content without invoking the parser.
27pub(crate) fn import_body(md: &str) -> Result<Content, ImportError> {
28    if md.is_empty() {
29        Ok(Content::empty())
30    } else {
31        import_markdown(md)
32    }
33}
34
35/// Which encoding a `decode_richtext_value` failure came from, so a call site
36/// can prefix its diagnostic per encoding without re-deriving the dispatch.
37/// Crate-internal: the schema-bound reader converts it into
38/// [`EditError::FieldDecode`] before any caller sees it.
39#[derive(Debug, Clone, PartialEq, Eq)]
40#[non_exhaustive]
41pub(crate) enum RichtextDecodeError {
42    /// A JSON object that is not a valid canonical content.
43    NotContent(String),
44    /// A markdown string that failed to import.
45    BadMarkdown(String),
46}
47
48impl RichtextDecodeError {
49    /// The inner failure message, without an encoding-specific prefix.
50    pub(crate) fn into_message(self) -> String {
51        match self {
52            RichtextDecodeError::NotContent(m) | RichtextDecodeError::BadMarkdown(m) => m,
53        }
54    }
55}
56
57/// Decode a JSON value in either accepted richtext encoding: a canonical content
58/// **object** ([`from_canonical_value`](quillmark_content::serial::from_canonical_value))
59/// or an authored markdown **string** (via [`import_body`], the single markdown
60/// boundary). The one place the object-vs-string dispatch lives; a call site
61/// handles the shapes that are neither (`null`, array, scalar) and maps the
62/// error into its own type.
63///
64/// - `Some(Ok(rt))`: decoded.
65/// - `Some(Err(e))`: an object that is not a content, or a string that failed
66///   to import; `e` names the encoding so the caller can prefix its message.
67/// - `None`: the value is neither an object nor a string.
68pub(crate) fn decode_richtext_value(
69    value: &serde_json::Value,
70) -> Option<Result<Content, RichtextDecodeError>> {
71    match value {
72        serde_json::Value::Object(_) => Some(
73            quillmark_content::serial::from_canonical_value(value)
74                .map_err(|e| RichtextDecodeError::NotContent(e.to_string())),
75        ),
76        serde_json::Value::String(md) => {
77            Some(import_body(md).map_err(|e| RichtextDecodeError::BadMarkdown(e.to_string())))
78        }
79        _ => None,
80    }
81}
82
83/// Decode a JSON value for a `plaintext` field: a canonical content **object**
84/// (revalidated) or a literal **string** imported verbatim
85/// ([`from_plaintext`](quillmark_content::from_plaintext), never markdown, so
86/// `*hi*` stays four plain characters). The plaintext twin of
87/// [`decode_richtext_value`]: the string branch is infallible (literal import
88/// can't fail), so only the object branch yields `Err` (`String` message). A
89/// call site handles the shapes that are neither: `null`, array, scalar. This
90/// is the single plaintext object-vs-string dispatch, shared by the coercion
91/// literal-import site and the validation shape check.
92///
93/// - `Some(Ok(rt))`: decoded.
94/// - `Some(Err(msg))`: an object that is not a valid content.
95/// - `None`: the value is neither an object nor a string.
96pub(crate) fn decode_plaintext_value(
97    value: &serde_json::Value,
98) -> Option<Result<Content, String>> {
99    match value {
100        serde_json::Value::Object(_) => {
101            Some(quillmark_content::serial::from_canonical_value(value).map_err(|e| e.to_string()))
102        }
103        serde_json::Value::String(s) => Some(Ok(quillmark_content::from_plaintext(s))),
104        _ => None,
105    }
106}
107
108pub mod assemble;
109pub mod dto;
110pub mod edit;
111pub mod emit;
112pub mod fences;
113pub mod limits;
114pub mod meta;
115pub mod payload;
116pub(crate) mod prescan;
117pub mod wire;
118pub(crate) mod yaml_hints;
119
120pub use dto::{peek_storage_version, StorageError, StoredDocument, STORAGE_V0_93_0};
121pub use edit::EditError;
122pub use meta::{is_valid_kind_name, validate_composable_kind, CardKindError};
123pub use payload::{MetaKey, Payload, PayloadItem};
124// Reachable through `PayloadItem::nested_comments`, so nameable from here.
125pub use prescan::{CommentPathSegment, NestedComment};
126pub use wire::{CardWire, PayloadItemWire, WireError};
127
128/// Authoring-format rules for the `~~~` card-yaml markdown surface.
129///
130/// Surfaced verbatim to LLM/MCP consumers (and to CLI / Python bindings via
131/// the same text) so error parity holds: every consumer reads the same
132/// rules. This is the single source of truth; bindings should call into it
133/// rather than re-stating the rules in their own glue.
134pub const FORMAT_RULES: &str = "Document format rules:
135\u{2022} Block opener and closer are EXACTLY `~~~` (three tildes, no info string). The `~~~card-yaml` opener is also accepted as a non-canonical alias.
136\u{2022} A blank line must precede every `~~~` block opener (unless it is line 1), and the opener must be at column zero (no leading spaces). An indented `~~~` is an ordinary code block, not a card.
137\u{2022} The first block is the root and MUST contain `$quill: <name>@<version>`. Its `$kind` is `main` by position \u{2014} an explicit `$kind: main` is accepted but not required. Additional blocks declare composable cards via `$kind: <card_kind>`.
138\u{2022} Reserved `$`-keys: `$quill`, `$kind`, `$ext`, `$seed`. User fields use lowercase snake_case.
139\u{2022} Prose body is the text after a block's closing `~~~`, up to the next opener or EOF. To include a literal fenced code block in prose, use a backtick fence (```); any column-zero `~~~` block is parsed as card metadata.
140\u{2022} A field that already shows a concrete value carries a default and is shippable as-is \u{2014} keep the line, override the value, or delete it to fall back to the default. A blank or null value (`field:`, `field: null`, `field: ~`) is treated the same as omitting the field: it falls back to the default, or to the type-empty zero value.
141\u{2022} `field: !must_fill <value>` marks a placeholder awaiting your input \u{2014} replace it with a real value and drop the `!must_fill` tag before shipping. A bare `field: !must_fill` is an empty placeholder. A leftover marker never blocks rendering, but it is reported as a warning until you replace it.
142\u{2022} Numbers and booleans MUST be unquoted (`year: 2025`, `pinned: true`); quoting turns them into strings and fails validation.
143\u{2022} Plain-scalar values cannot start with `*` or `&` (YAML alias/anchor markers) and cannot contain `: ` (colon-space). For markdown emphasis, embedded colons, or other special prefixes, quote the value: `field: '**bold**'` or `field: \"Name: subtitle\"`. Multi-line values use `|-`, not multi-line quoted scalars.";
144
145/// Authoring-ergonomics header that introduces a blueprint to an LLM/MCP
146/// consumer. The `{quill}` placeholder is substituted with the quill name.
147/// Designed to be shown above [`FORMAT_RULES`], which covers field-level
148/// semantics like the `!must_fill` marker: keep the wording tight here so the
149/// two strings do not duplicate guidance.
150const BLUEPRINT_INSTRUCTION_TEMPLATE: &str =
151    "Fill in the `{quill}` blueprint below: replace each `!must_fill` placeholder with a real \
152value and edit the body prose. Submit the filled markdown as `content` to `create_document`.";
153
154/// Render the blueprint-instruction header with `quill_name` substituted in.
155/// Single source of truth for the prose so every binding shows identical text.
156pub fn blueprint_instruction(quill_name: &str) -> String {
157    BLUEPRINT_INSTRUCTION_TEMPLATE.replace("{quill}", quill_name)
158}
159
160#[cfg(test)]
161mod tests;
162
163/// The record of one load: the [`Document`] and any non-fatal warnings.
164/// Returned by both doors, the quill-free [`Document::parse`] and the bound
165/// [`Quill::parse`](crate::Quill::parse), whose `warnings` carry the parse's
166/// plus the `conform::*` ones. Warnings live here and only here: `Document` is
167/// the value (equality, the storage DTO, and mutators all exclude warnings);
168/// `Parsed` is the load *event*. A caller that wants only the document writes
169/// `Document::parse(md)?.document`.
170#[derive(Debug)]
171#[must_use = "carries parse warnings; read `.document`/`.warnings` or bind it"]
172#[non_exhaustive]
173pub struct Parsed {
174    pub document: Document,
175    pub warnings: Vec<Diagnostic>,
176}
177
178/// A single card-yaml block (root or composable). `body` is the content
179/// ([`Content`]) form of the prose after the closing fence: the empty content
180/// when none follows; check `card.body().is_blank()`. Markdown is a projection:
181/// [`Card::body_markdown`] re-emits it.
182#[derive(Debug, Clone, PartialEq)]
183pub struct Card {
184    payload: Payload,
185    body: Content,
186}
187
188impl Card {
189    /// Create a `Card` from its parts without validation. `body` is the content
190    /// form; to build from an authored markdown string, import it first via the
191    /// crate-internal `import_body` boundary. Crate-internal, with [`Payload`]'s
192    /// mutation half: user-facing construction of composable cards is
193    /// [`Card::new`].
194    pub(crate) fn from_parts(payload: Payload, body: Content) -> Self {
195        Self { payload, body }
196    }
197
198    pub fn quill(&self) -> Option<&QuillReference> {
199        self.payload.quill()
200    }
201
202    pub fn kind(&self) -> Option<&str> {
203        self.payload.kind()
204    }
205
206    /// Opaque `$ext` map for out-of-band extension data (UI editor state,
207    /// agent annotations, …). Carried through Markdown and storage DTO
208    /// round-trips; never emitted into the plate JSON consumed by
209    /// backends.
210    pub fn ext(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
211        self.payload.ext()
212    }
213
214    /// The card's card-yaml storage as a read view. Writes go through the
215    /// enforcing verbs ([`store_field`](Card::store_field),
216    /// [`TypedWriter`](crate::TypedWriter)), not through here.
217    pub fn payload(&self) -> &Payload {
218        &self.payload
219    }
220
221    pub(crate) fn payload_mut(&mut self) -> &mut Payload {
222        &mut self.payload
223    }
224
225    /// The card body as a [`Content`] content: the canonical content model.
226    /// For the markdown projection use [`Card::body_markdown`].
227    pub fn body(&self) -> &Content {
228        &self.body
229    }
230
231    /// The card body rendered back to its markdown projection. This is a
232    /// derived view (`export ∘ body`), not stored state; a `Document` round-trip
233    /// therefore canonicalizes the body (e.g. `__b__` → `**b**`).
234    pub fn body_markdown(&self) -> String {
235        quillmark_content::export::to_markdown(&self.body)
236    }
237
238    pub(crate) fn body_mut(&mut self) -> &mut Content {
239        &mut self.body
240    }
241
242    /// Read a richtext-valued user field back as a [`Content`] content: the
243    /// field-level twin of [`Card::body`]. Decodes the stored value through the
244    /// same object-or-markdown dispatch the writer
245    /// ([`commit_field`](Card::commit_field)) commits, so a field
246    /// stored as a canonical content reads back losslessly (identity marks
247    /// intact) and a still-authored markdown string imports.
248    ///
249    /// - `None`: the field is absent.
250    /// - `Some(Ok(rt))`: decoded content.
251    /// - `Some(Err(_))`: the field is present but neither a content object nor
252    ///   an importable markdown string (e.g. a bare number a `store_field` wrote).
253    ///
254    /// A `Document` carries no schema, so this cannot itself tell a richtext
255    /// field from a plain string field; the caller names a field it knows is
256    /// richtext, exactly as it does when writing.
257    pub(crate) fn field_richtext(&self, name: &str) -> Option<Result<Content, RichtextDecodeError>> {
258        let value = self.payload.get(name)?.as_json();
259        Some(match crate::document::decode_richtext_value(value) {
260            Some(result) => result,
261            None => match value {
262                serde_json::Value::Null => Ok(Content::empty()),
263                _ => Err(RichtextDecodeError::NotContent(
264                    "expected a richtext content object or a markdown string".to_string(),
265                )),
266            },
267        })
268    }
269
270    /// The markdown projection of a richtext-valued field (`export ∘ decode`):
271    /// the field-level twin of [`Card::body_markdown`], and the projection an
272    /// emit or a markdown save writes for a content-valued field. The projection
273    /// twin of [`field_richtext`](Card::field_richtext), carrying its `Ok`/`Err`
274    /// decode outcome:
275    ///
276    /// - `None`: the field is absent.
277    /// - `Some(Ok(md))`: the projected markdown.
278    /// - `Some(Err(_))`: the field is present but does not decode as richtext
279    ///   (a scalar/array/object a `store_field` wrote, or a non-content object).
280    ///
281    /// Absence returns `None`; a present non-richtext value returns `Some(Err)`,
282    /// so the projection surfaces the type mismatch instead of blanking on it.
283    pub(crate) fn field_markdown(&self, name: &str) -> Option<Result<String, RichtextDecodeError>> {
284        Some(self.field_richtext(name)?.map(|rt| quillmark_content::export::to_markdown(&rt)))
285    }
286
287    /// Read a plaintext-valued user field back as a [`Content`] content: the
288    /// literal-codec twin of [`field_richtext`](Card::field_richtext). A stored
289    /// string imports verbatim (`*hi*` is four characters, not emphasis), an
290    /// already-canonical content decodes losslessly: the same dispatch coercion
291    /// and validation run for a `plaintext` field, so the read and the render
292    /// agree on the codec.
293    ///
294    /// - `None`: the field is absent.
295    /// - `Some(Ok(rt))`: decoded content.
296    /// - `Some(Err(_))`: the field is present but neither a content object nor a
297    ///   string (e.g. a bare number a `store_field` wrote).
298    ///
299    /// A `Document` carries no schema, so the caller names a field it knows is
300    /// plaintext; the schema-bound door is
301    /// [`TypedReader::get_content`](crate::TypedReader::get_content).
302    pub(crate) fn field_plaintext_content(
303        &self,
304        name: &str,
305    ) -> Option<Result<Content, RichtextDecodeError>> {
306        let value = self.payload.get(name)?.as_json();
307        Some(match crate::document::decode_plaintext_value(value) {
308            Some(result) => result.map_err(RichtextDecodeError::NotContent),
309            None => match value {
310                serde_json::Value::Null => Ok(Content::empty()),
311                _ => Err(RichtextDecodeError::NotContent(
312                    "expected a plaintext content object or a string".to_string(),
313                )),
314            },
315        })
316    }
317
318    /// The plaintext projection of a content-valued field (`to_plaintext ∘
319    /// decode`), the literal-codec twin of [`field_markdown`](Card::field_markdown),
320    /// for a `plaintext`-typed field: marks are never interpreted, so the text is
321    /// verbatim both ways. Carries
322    /// [`field_plaintext_content`](Card::field_plaintext_content)'s `Ok`/`Err`
323    /// decode outcome:
324    ///
325    /// - `None`: the field is absent.
326    /// - `Some(Ok(text))`: the projected literal text.
327    /// - `Some(Err(_))`: the field is present but does not decode as content.
328    pub(crate) fn field_plaintext(&self, name: &str) -> Option<Result<String, RichtextDecodeError>> {
329        Some(
330            self.field_plaintext_content(name)?
331                .map(|rt| quillmark_content::export::to_plaintext(&rt)),
332        )
333    }
334}
335
336/// A parsed, per-kind **seed overlay**: the sparse fields (and optional body)
337/// a newly-added card of a given kind starts with. Built from a `$seed[<kind>]`
338/// entry of the main card's [`Card::seed`] map via [`SeedOverlay::from_json`],
339/// and layered over the quill's schema-example seed by
340/// [`crate::Quill::seed_card`] (overlay › example › absent). The reserved inner
341/// key `$body` carries the body override; every other user field becomes an
342/// entry, while any other `$`-prefixed key is reserved and dropped.
343#[derive(Debug, Clone, PartialEq, Default)]
344#[non_exhaustive]
345pub struct SeedOverlay {
346    /// Field-value overrides, keyed by field name.
347    pub fields: indexmap::IndexMap<String, crate::value::QuillValue>,
348    /// Body override, when the overlay declares a `$body` string.
349    pub body: Option<String>,
350}
351
352impl SeedOverlay {
353    /// Parse an overlay from a `$seed[<kind>]` JSON value, or `None` when it is
354    /// not a mapping. Use this to turn the raw overlay object a consumer reads
355    /// from the main card's `$seed` map ([`Card::seed`]) into a typed overlay to
356    /// hand to [`crate::Quill::seed_card`]; e.g.
357    /// `doc.main().seed().and_then(|m| m.get(kind)).and_then(SeedOverlay::from_json)`.
358    pub fn from_json(value: &serde_json::Value) -> Option<Self> {
359        value.as_object().map(Self::from_json_map)
360    }
361
362    /// Build an overlay from a single `$seed[<kind>]` JSON map: the reserved
363    /// `$body` string becomes [`body`](Self::body); every other user-field entry
364    /// becomes a field. A non-string `$body` is ignored (no body override). Any
365    /// other `$`-prefixed key is reserved and dropped (never stored as a user
366    /// field) since an overlay only ever carries user fields plus `$body`.
367    fn from_json_map(map: &serde_json::Map<String, serde_json::Value>) -> Self {
368        let mut fields = indexmap::IndexMap::new();
369        let mut body = None;
370        for (key, value) in map {
371            if key == "$body" {
372                if let Some(s) = value.as_str() {
373                    body = Some(s.to_string());
374                }
375            } else if key.starts_with('$') {
376                // Reserved key other than `$body`: not a user field. Drop it
377                // rather than smuggle a `$`-key into the field set.
378                continue;
379            } else {
380                fields.insert(
381                    key.clone(),
382                    crate::value::QuillValue::from_json(value.clone()),
383                );
384            }
385        }
386        SeedOverlay { fields, body }
387    }
388}
389
390/// A fully-parsed Quillmark document. Serde routes through [`StoredDocument`];
391/// for the plate wire shape see [`Document::to_plate_json`].
392///
393/// Parse-time warnings are *not* document state: they ride out-of-band on
394/// [`Parsed`] from [`Document::parse`], the single owner. Equality and the
395/// storage DTO therefore cover only structural content (`main` and `cards`).
396#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
397#[serde(into = "StoredDocument", try_from = "StoredDocument")]
398pub struct Document {
399    main: Card,
400    cards: Vec<Card>,
401}
402
403impl Document {
404    /// Create a blank document: a main card carrying only `$quill`, an empty
405    /// body, and no composable cards. The programmatic blank canvas: every
406    /// schema field is absent and resolves at render time (`default`, else
407    /// type-empty zero), so nothing the caller did not set reaches the
408    /// output. For an example-filled starter shaped like the blueprint, use
409    /// `Quill::seed_document`.
410    pub fn new(quill: QuillReference) -> Self {
411        let mut payload = Payload::new();
412        payload.set_quill(quill);
413        // Parsed main cards always carry `$kind: main` (the parser normalizes
414        // it in); match that shape so a blank document round-trips equal.
415        payload.set_kind("main");
416        Self {
417            main: Card::from_parts(payload, Content::empty()),
418            cards: Vec::new(),
419        }
420    }
421
422    /// Create a `Document` from a pre-built main card and composable cards.
423    /// `main` must carry `$quill`; composable cards must not carry `$quill` or
424    /// `$seed`.
425    ///
426    /// The invariants are `debug_assert`s, so a release build accepts a main
427    /// card without `$quill` and [`quill_reference`](Self::quill_reference)
428    /// panics on it: every caller pre-validates. [`Document::new`] is the
429    /// public blank canvas, and `TryFrom<StoredDocument>` the public door for
430    /// external data, checking all three and returning
431    /// `StorageError::Malformed`.
432    pub(crate) fn from_main_and_cards(main: Card, cards: Vec<Card>) -> Self {
433        debug_assert!(main.quill().is_some(), "main card must carry `$quill`");
434        debug_assert!(
435            cards.iter().all(|c| c.quill().is_none()),
436            "composable cards must not carry `$quill`"
437        );
438        debug_assert!(
439            cards.iter().all(|c| c.seed().is_none()),
440            "composable cards must not carry `$seed`"
441        );
442        Self { main, cards }
443    }
444
445    /// Parse card-yaml Markdown into a [`Parsed`]: the [`Document`] plus any
446    /// non-fatal warnings. The single parse entry; a caller that wants only the
447    /// document writes `Document::parse(md)?.document`. Errors on malformed
448    /// YAML, a missing root `$quill`, an over-size input, and the other
449    /// [`ParseError`] variants.
450    #[doc(alias = "from_markdown")]
451    pub fn parse(markdown: &str) -> Result<Parsed, ParseError> {
452        assemble::decompose_with_warnings(markdown)
453            .map(|(document, warnings)| Parsed { document, warnings })
454    }
455
456    pub fn main(&self) -> &Card {
457        &self.main
458    }
459
460    pub fn main_mut(&mut self) -> &mut Card {
461        &mut self.main
462    }
463
464    /// The `$quill` reference from the root block. Always present on parsed documents.
465    pub fn quill_reference(&self) -> QuillReference {
466        self.main
467            .quill()
468            .cloned()
469            .expect("root block's $quill is validated at parse time")
470    }
471
472    pub fn cards(&self) -> &[Card] {
473        &self.cards
474    }
475
476    pub fn cards_mut(&mut self) -> &mut [Card] {
477        &mut self.cards
478    }
479
480    /// A single composable card by index: the immutable twin of
481    /// [`card_mut`](Document::card_mut), so reading one card's payload does not
482    /// require materializing every card via [`cards`](Document::cards). `None`
483    /// when out of range.
484    pub fn card(&self, index: usize) -> Option<&Card> {
485        self.cards.get(index)
486    }
487
488    pub(crate) fn cards_vec_mut(&mut self) -> &mut Vec<Card> {
489        &mut self.cards
490    }
491
492    /// Serialize to the JSON wire shape consumed by backend plates. This is
493    /// the **only** place in `quillmark-core` that produces this shape:
494    ///
495    /// ```json
496    /// {
497    ///   "$quill": "<ref>",
498    ///   "$body": { "text": "…", "lines": [...], "marks": [...], "islands": [...] },
499    ///   "$cards": [{ "$kind": "<tag>", "$body": <content>, "<field>": <value>, ... }],
500    ///   "<field>": <value>, ...
501    /// }
502    /// ```
503    ///
504    /// `$body` (global and per-card) is canonical Content-JSON: the content as
505    /// a nested object, not a markdown string. Richtext payload fields likewise
506    /// cross as content objects (committed at coercion time).
507    ///
508    /// `$`-prefixed keys carry document-level metadata (quill ref, body
509    /// text, card list, card kind). User payload fields stay flat at the
510    /// root: they cannot collide with `$` keys because user field names are
511    /// never `$`-prefixed (they match `[A-Za-z_][A-Za-z0-9_]*`).
512    ///
513    /// `$kind` is document-defined and omitted for a kindless card (never a
514    /// fabricated `""`). This method is schema-free and emits `$body` for every
515    /// card and the root; the schema-gated render plate
516    /// (`QuillConfig::compile_data`) instead calls `to_plate_json_gated` with the
517    /// per-card body-presence it resolved, so a card whose kind enables no body
518    /// carries no `$body`: issue 1030's "absent on undefined".
519    pub fn to_plate_json(&self) -> serde_json::Value {
520        // Schema-free: the root and every card carry `$body`.
521        self.to_plate_json_gated(true, None)
522    }
523
524    /// [`to_plate_json`](Self::to_plate_json) with the body-presence decision
525    /// supplied by the caller: the root carries `$body` iff `main_body`, and card
526    /// *i* iff `card_bodies` is `None` (all present) or `card_bodies[i]` holds.
527    /// The schema-gated render plate (`QuillConfig::compile_data`) passes the
528    /// body-enabled bit it already resolved per card, so a body-disabled card
529    /// never carries `$body` (issue 1030, "absent on undefined") and the decision
530    /// is never re-derived from the serialized plate. `Document` stays schema-free:
531    /// it receives the decision, not a schema.
532    pub(crate) fn to_plate_json_gated(
533        &self,
534        main_body: bool,
535        card_bodies: Option<&[bool]>,
536    ) -> serde_json::Value {
537        let mut map = serde_json::Map::new();
538
539        map.insert(
540            "$quill".to_string(),
541            serde_json::Value::String(self.quill_reference().to_string()),
542        );
543
544        // The seam carries the body as canonical Content-JSON (Option A): a
545        // nested content object, byte-identical to `to_canonical_json`, never a lossy
546        // markdown string. Backends lower the content (typst → markup + source
547        // map; pdfform → `.text`); the markdown projection is `body_markdown`.
548        if main_body {
549            map.insert(
550                "$body".to_string(),
551                quillmark_content::serial::to_canonical_value(self.main.body()),
552            );
553        }
554
555        let cards_array: Vec<serde_json::Value> = self
556            .cards
557            .iter()
558            .enumerate()
559            .map(|(i, card)| {
560                let mut card_map = serde_json::Map::new();
561                // A kindless card carries no `$kind`, never a fabricated `""`:
562                // matching the resolved view's `kind: None`.
563                if let Some(kind) = card.kind() {
564                    card_map.insert(
565                        "$kind".to_string(),
566                        serde_json::Value::String(kind.to_string()),
567                    );
568                }
569                // `$body` iff the caller's schema defines a body for this card.
570                if card_bodies.map_or(true, |f| f.get(i).copied().unwrap_or(true)) {
571                    card_map.insert(
572                        "$body".to_string(),
573                        quillmark_content::serial::to_canonical_value(card.body()),
574                    );
575                }
576                for (key, value) in card.payload.iter() {
577                    card_map.insert(key.clone(), value.as_json().clone());
578                }
579                serde_json::Value::Object(card_map)
580            })
581            .collect();
582
583        map.insert("$cards".to_string(), serde_json::Value::Array(cards_array));
584
585        for (key, value) in self.main.payload.iter() {
586            map.insert(key.clone(), value.as_json().clone());
587        }
588
589        serde_json::Value::Object(map)
590    }
591}