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