Skip to main content

zenith_core/format/writer/
mod.rs

1//! Hand-written deterministic serializer for the Zenith AST.
2//!
3//! Produces canonical `.zen` text from a [`Document`]. The output is
4//! idempotent: `format(format(doc)) == format(doc)` for all valid documents.
5//!
6//! Rules:
7//! - Two-space indentation per nesting level.
8//! - Root `zenith` node at column 0.
9//! - Child order under `zenith`: project, assets, libraries, tokens, styles, components, masters, sections, provenance, variants, recipes, actions, document.
10//! - Structural containers (`tokens`, `styles`, `document`, `page`) always emit
11//!   a brace block, even when empty.
12//! - Leaf nodes (`project`, a `rect` with no children) emit a single line.
13//! - `text` emits a brace block containing `span` children.
14//! - Numbers: integral `f64` values emit without a decimal point (`640`, not
15//!   `640.0`); non-integral emit the shortest representation.
16//! - Booleans: `#true` / `#false` (KDL v2 form).
17//! - Token refs: `fill=(token)"color.bg"`. String values: `name="One"`.
18//! - Dimensions: `x=(px)0`.
19//! - Unknown properties emit after known ones, in BTreeMap (sorted) key order.
20//! - File ends with a single trailing newline.
21//!
22//! The implementation is split across focused submodules:
23//! - this module root holds the public entry point, the `zenith`/`project`/
24//!   `assets`/`libraries`/`components`/`masters`/`sections`/`provenance`/
25//!   `variants`/`recipes`/`actions` orchestration, and the shared low-level
26//!   primitives;
27//! - [`tokens`] writes the `tokens` block;
28//! - [`styles`] writes the `styles` block;
29//! - [`nodes`] writes the `document` body, pages, and every node kind.
30
31use std::fmt::Write as _;
32
33use crate::ast::{
34    ActionDef, AssetBlock, AssetDecl, BrandContract, ComponentDef, DiagnosticPolicy, Dimension,
35    Document, LibraryDef, MasterDef, ObjectPosition, PolicyVerb, Project, PropertyValue,
36    ProvenanceDef, RecipeDef, RecipeParam, SectionDef, UnknownProperty, UnknownValue, VariantDef,
37};
38use crate::error::FormatError;
39
40mod nodes;
41mod styles;
42mod tokens;
43
44#[cfg(test)]
45mod tests;
46
47use nodes::{write_component_children, write_document_body};
48use styles::write_style_block;
49use tokens::write_token_block;
50
51// ---------------------------------------------------------------------------
52// Unknown property value formatting
53// ---------------------------------------------------------------------------
54
55/// Produce a KDL-valid serialization for an `UnknownValue`, preserving the
56/// original KDL type so that parse→format→parse is a perfect round-trip:
57///
58/// - `String(s)` → a double-quoted, escaped KDL string (`"hello"`)
59/// - `Integer(n)` → a bare decimal integer (`42`)
60/// - `Float(f)` → a bare number via the canonical f64 formatter (integral
61///   floats emit without `.0`: `1` not `1.0`)
62/// - `Bool(b)` → KDL v2 boolean keyword (`#true` / `#false`)
63/// - `Null` → KDL v2 null keyword (`#null`)
64fn fmt_unknown_value(v: &UnknownValue) -> String {
65    match v {
66        UnknownValue::String(s) => {
67            let mut out = String::with_capacity(s.len() + 2);
68            out.push('"');
69            out.push_str(&escape_kdl_string(s));
70            out.push('"');
71            out
72        }
73        UnknownValue::Integer(n) => n.to_string(),
74        UnknownValue::Float(f) => fmt_f64(*f),
75        UnknownValue::Bool(b) => (if *b { "#true" } else { "#false" }).to_owned(),
76        UnknownValue::Null => "#null".to_owned(),
77    }
78}
79
80/// Serialize an [`UnknownProperty`]'s value, including its KDL type annotation
81/// when present, so that an annotated value round-trips byte-identically.
82///
83/// The annotation is emitted as a `(ty)` prefix in the value position, matching
84/// KDL v2 syntax `name=(type)value`:
85///
86/// - annotated → `(px)10`, `(token)"color.navy"`
87/// - unannotated → identical to [`fmt_unknown_value`]
88pub(super) fn fmt_unknown_property(p: &UnknownProperty) -> String {
89    match &p.ty {
90        Some(ty) => format!("({}){}", ty, fmt_unknown_value(&p.value)),
91        None => fmt_unknown_value(&p.value),
92    }
93}
94
95// ---------------------------------------------------------------------------
96// Public entry point
97// ---------------------------------------------------------------------------
98
99/// Serialize `doc` to canonical `.zen` UTF-8 bytes.
100pub fn format_document(doc: &Document) -> Result<Vec<u8>, FormatError> {
101    let mut out = String::new();
102    write_document(doc, &mut out);
103    out.push('\n');
104    Ok(out.into_bytes())
105}
106
107// ---------------------------------------------------------------------------
108// Internal helpers
109// ---------------------------------------------------------------------------
110
111/// Append `count * 2` spaces of indentation.
112pub(super) fn indent(out: &mut String, depth: usize) {
113    for _ in 0..depth * 2 {
114        out.push(' ');
115    }
116}
117
118/// Format a `f64` canonically: no trailing `.0` for integral values.
119pub(super) fn fmt_f64(v: f64) -> String {
120    if v.fract() == 0.0 && v.is_finite() {
121        format!("{}", v as i64)
122    } else {
123        format!("{v}")
124    }
125}
126
127/// Format a dimension annotation + value, e.g. `(px)640` or `(pt)10.5`.
128pub(super) fn fmt_dimension(d: &Dimension) -> String {
129    d.to_kdl_string()
130}
131
132/// Format a `PropertyValue` as a KDL value.
133///
134/// - `TokenRef("color.bg")`  →  `(token)"color.bg"`
135/// - `Literal("center")`     →  `"center"`
136/// - `Dimension((px)24)`     →  `(px)24`
137pub(super) fn fmt_property_value(pv: &PropertyValue) -> String {
138    match pv {
139        PropertyValue::TokenRef(id) => format!("(token)\"{id}\""),
140        PropertyValue::Literal(s) => format!("\"{s}\""),
141        PropertyValue::Dimension(d) => fmt_dimension(d),
142        PropertyValue::DataRef(path) => format!("(data)\"{path}\""),
143    }
144}
145
146/// Emit `key=value` for a `PropertyValue` property (if present).
147pub(super) fn write_opt_property_value(out: &mut String, key: &str, opt: &Option<PropertyValue>) {
148    if let Some(pv) = opt {
149        out.push(' ');
150        out.push_str(key);
151        out.push('=');
152        out.push_str(&fmt_property_value(pv));
153    }
154}
155
156/// Emit `key=(unit)N` for an optional `Dimension`.
157pub(super) fn write_opt_dimension(out: &mut String, key: &str, opt: &Option<Dimension>) {
158    if let Some(d) = opt {
159        out.push(' ');
160        out.push_str(key);
161        out.push('=');
162        out.push_str(&fmt_dimension(d));
163    }
164}
165
166/// Emit `key="string"` for an optional string (quoted, no escaping).
167pub(super) fn write_opt_str(out: &mut String, key: &str, opt: &Option<String>) {
168    if let Some(s) = opt {
169        out.push(' ');
170        out.push_str(key);
171        out.push_str("=\"");
172        out.push_str(s);
173        out.push('"');
174    }
175}
176
177/// Emit `key="string"` for an optional string, running the value through
178/// [`escape_kdl_string`] so that backslashes, quotes, and whitespace control
179/// characters survive as a single-line KDL string.
180pub(super) fn write_opt_str_escaped(out: &mut String, key: &str, opt: &Option<String>) {
181    if let Some(s) = opt {
182        out.push(' ');
183        out.push_str(key);
184        out.push_str("=\"");
185        out.push_str(&escape_kdl_string(s));
186        out.push('"');
187    }
188}
189
190/// Emit `key=#true` or `key=#false` for an optional bool.
191pub(super) fn write_opt_bool(out: &mut String, key: &str, opt: &Option<bool>) {
192    if let Some(b) = opt {
193        out.push(' ');
194        out.push_str(key);
195        out.push('=');
196        out.push_str(if *b { "#true" } else { "#false" });
197    }
198}
199
200/// Emit `key="anchor"` (string) or `key=(pct)N` (annotated number) for an
201/// optional [`ObjectPosition`].
202pub(super) fn write_opt_object_position(out: &mut String, key: &str, opt: &Option<ObjectPosition>) {
203    if let Some(pos) = opt {
204        out.push(' ');
205        out.push_str(key);
206        out.push('=');
207        match pos {
208            ObjectPosition::Start => out.push_str("\"start\""),
209            ObjectPosition::Center => out.push_str("\"center\""),
210            ObjectPosition::End => out.push_str("\"end\""),
211            ObjectPosition::Pct(n) => {
212                out.push_str("(pct)");
213                out.push_str(&fmt_f64(*n));
214            }
215        }
216    }
217}
218
219/// Emit `key=N` for an optional `f64` (bare number, no unit).
220pub(super) fn write_opt_f64(out: &mut String, key: &str, opt: &Option<f64>) {
221    if let Some(v) = opt {
222        out.push(' ');
223        out.push_str(key);
224        out.push('=');
225        out.push_str(&fmt_f64(*v));
226    }
227}
228
229/// Escape a string for emission as a single-line KDL v2 quoted string.
230///
231/// Unlike the inline span/unknown-prop escapers (which only handle `\` and `"`),
232/// this also encodes the whitespace control characters `\n`, `\r`, and `\t` as
233/// backslash escapes so that a multi-line `code` blob survives as ONE physical
234/// line. All other characters pass through verbatim. This is the inverse of the
235/// `kdl` crate's decode on parse, guaranteeing a byte-exact content round-trip.
236pub(super) fn escape_kdl_string(s: &str) -> String {
237    let mut out = String::with_capacity(s.len() + 2);
238    for ch in s.chars() {
239        match ch {
240            '\\' => out.push_str("\\\\"),
241            '"' => out.push_str("\\\""),
242            '\n' => out.push_str("\\n"),
243            '\r' => out.push_str("\\r"),
244            '\t' => out.push_str("\\t"),
245            other => out.push(other),
246        }
247    }
248    out
249}
250
251// ---------------------------------------------------------------------------
252// Document
253// ---------------------------------------------------------------------------
254
255fn write_document(doc: &Document, out: &mut String) {
256    // `zenith version=1 {`
257    out.push_str("zenith version=");
258    // Writing to a String via fmt::Write is infallible; the Err variant is
259    // unreachable but we must handle it — discard rather than unwrap.
260    let _ = write!(out, "{}", doc.version);
261    // Optional export color space attribute, emitted right after version so the
262    // canonical form round-trips (parse → format → parse is byte-stable).
263    write_opt_str(out, "colorspace", &doc.colorspace);
264    // Optional stable document identity (ULID, Crockford base-32). Value is
265    // always safe to emit without escaping (no special characters). Emitted
266    // right after colorspace — grouped with version/colorspace as identity metadata.
267    write_opt_str(out, "doc-id", &doc.doc_id);
268    write_opt_bool(out, "mirror-margins", &doc.mirror_margins);
269    // Facing-pages and spread-gutter are emitted right after mirror-margins (the
270    // spread-layout metadata group). Both are omitted when None so a document
271    // without these attrs round-trips byte-identically.
272    write_opt_bool(out, "facing-pages", &doc.facing_pages);
273    write_opt_dimension(out, "spread-gutter", &doc.spread_gutter);
274    // Document-level default margins, grouped right after `mirror-margins` (the
275    // other margin doc attr). Canonical order: inner, outer, top, bottom — same
276    // order and spelling as on a page. Emitted only when set, so a document with
277    // no defaults round-trips byte-identically.
278    write_opt_dimension(out, "margin-inner", &doc.margin_inner);
279    write_opt_dimension(out, "margin-outer", &doc.margin_outer);
280    write_opt_dimension(out, "margin-top", &doc.margin_top);
281    write_opt_dimension(out, "margin-bottom", &doc.margin_bottom);
282    write_opt_str(out, "page-progression", &doc.page_progression);
283    write_opt_str(out, "page-parity-start", &doc.page_parity_start);
284    out.push_str(" {\n");
285
286    // Child order: diagnostics, brand, project, assets, libraries, tokens, styles, components, masters, sections, provenance, variants, recipes, actions, document.
287    // `diagnostics` is emitted first — right after the document-level attributes
288    // and before `tokens` — so the lint policy reads at the top of the file. It
289    // is omitted entirely when the policy is empty, so a document with no
290    // `diagnostics` block round-trips byte-identically.
291    write_diagnostics_block(&doc.diagnostic_policy, out, 1);
292    // `brand` is emitted right after `diagnostics` (both are structural metadata
293    // that live outside the token/style pipeline). Omitted when the contract is
294    // empty so a document with no `brand` block round-trips byte-identically.
295    write_brand_block(&doc.brand_contract, out, 1);
296    if let Some(proj) = &doc.project {
297        write_project(proj, out, 1);
298    }
299    write_asset_block(&doc.assets, out, 1);
300    write_library_block(&doc.libraries, out, 1);
301    write_token_block(&doc.tokens, out, 1);
302    write_style_block(&doc.styles, out, 1);
303    write_component_block(&doc.components, out, 1);
304    write_master_block(&doc.masters, out, 1);
305    write_section_block(&doc.sections, out, 1);
306    write_provenance_block(&doc.provenance, out, 1);
307    write_variants_block(&doc.variants, out, 1);
308    write_recipes_block(&doc.recipes, out, 1);
309    write_action_block(&doc.actions, out, 1);
310    write_document_body(&doc.body, out, 1);
311
312    out.push('}');
313}
314
315// ---------------------------------------------------------------------------
316// Diagnostics policy
317// ---------------------------------------------------------------------------
318
319/// Emit the `diagnostics { … }` block.
320///
321/// Stable position: first child of `zenith`, before `tokens`. Emitted ONLY when
322/// the policy has at least one entry, so documents without a policy keep their
323/// existing canonical form (and round-trip) byte-identically. Entry order is
324/// preserved (last-wins resolution is applied at consult time, not here). Each
325/// entry emits a single leaf line: `<verb> "<code>"`, followed by any scoped
326/// subject ids as positional strings.
327fn write_diagnostics_block(policy: &DiagnosticPolicy, out: &mut String, depth: usize) {
328    if policy.entries.is_empty() {
329        return;
330    }
331    indent(out, depth);
332    out.push_str("diagnostics {\n");
333    for entry in &policy.entries {
334        indent(out, depth + 1);
335        let verb = match entry.verb {
336            PolicyVerb::Allow => "allow",
337            PolicyVerb::Deny => "deny",
338            PolicyVerb::Warn => "warn",
339        };
340        out.push_str(verb);
341        out.push_str(" \"");
342        out.push_str(&escape_kdl_string(&entry.code));
343        out.push('"');
344        for subject in &entry.subjects {
345            out.push_str(" \"");
346            out.push_str(&escape_kdl_string(subject));
347            out.push('"');
348        }
349        out.push('\n');
350    }
351    indent(out, depth);
352    out.push_str("}\n");
353}
354
355// ---------------------------------------------------------------------------
356// Brand contract
357// ---------------------------------------------------------------------------
358
359/// Emit the `brand { … }` block.
360///
361/// Stable position: right after `diagnostics`, before `project`. Emitted ONLY
362/// when the contract has at least one constrained category (`!is_empty()`), so
363/// documents without a brand contract keep their existing canonical form and
364/// round-trip byte-identically. Canonical child order: `colors`, `fonts`,
365/// `weights` (matching the declaration order in the KDL syntax docs).
366///
367/// Each value is emitted as a positional argument on its child node:
368/// `colors "#0b1f33" "#ffffff"`, `fonts "Noto Sans"`, `weights 400 700`.
369/// Absent categories are not emitted (a `None` field → no line).
370fn write_brand_block(contract: &BrandContract, out: &mut String, depth: usize) {
371    if contract.is_empty() {
372        return;
373    }
374    indent(out, depth);
375    out.push_str("brand {\n");
376
377    if let Some(colors) = &contract.allowed_colors {
378        indent(out, depth + 1);
379        out.push_str("colors");
380        for color in colors {
381            out.push_str(" \"");
382            out.push_str(color);
383            out.push('"');
384        }
385        out.push('\n');
386    }
387
388    if let Some(fonts) = &contract.allowed_fonts {
389        indent(out, depth + 1);
390        out.push_str("fonts");
391        for font in fonts {
392            out.push_str(" \"");
393            out.push_str(&escape_kdl_string(font));
394            out.push('"');
395        }
396        out.push('\n');
397    }
398
399    if let Some(weights) = &contract.allowed_weights {
400        indent(out, depth + 1);
401        out.push_str("weights");
402        for weight in weights {
403            out.push(' ');
404            let _ = write!(out, "{weight}");
405        }
406        out.push('\n');
407    }
408
409    indent(out, depth);
410    out.push_str("}\n");
411}
412
413// ---------------------------------------------------------------------------
414// Masters
415// ---------------------------------------------------------------------------
416
417/// Emit the `masters { … }` block.
418///
419/// Stable position: after `components`, before `document`. Emitted ONLY when at
420/// least one master is declared, so documents without masters keep their
421/// existing canonical form (and round-trip) unchanged. Each master emits
422/// `master id="…" { <child nodes> }`. Mirrors [`write_component_block`].
423fn write_master_block(masters: &[MasterDef], out: &mut String, depth: usize) {
424    if masters.is_empty() {
425        return;
426    }
427    indent(out, depth);
428    out.push_str("masters {\n");
429    for def in masters {
430        indent(out, depth + 1);
431        out.push_str("master id=\"");
432        out.push_str(&def.id);
433        out.push_str("\" {\n");
434        write_component_children(&def.children, out, depth + 1);
435        indent(out, depth + 1);
436        out.push_str("}\n");
437    }
438    indent(out, depth);
439    out.push_str("}\n");
440}
441
442// ---------------------------------------------------------------------------
443// Sections
444// ---------------------------------------------------------------------------
445
446/// Emit the `sections { … }` block.
447///
448/// Stable position: after `masters`, before `document`. Emitted ONLY when at
449/// least one section is declared, so documents without sections keep their
450/// existing canonical form (and round-trip) unchanged. Each section emits a
451/// single leaf line: `section id="…" name="…" folio-start=N folio-style="…"
452/// start-page="…"`. Optional attributes are omitted when `None`. Mirrors
453/// [`write_master_block`].
454fn write_section_block(sections: &[SectionDef], out: &mut String, depth: usize) {
455    if sections.is_empty() {
456        return;
457    }
458    indent(out, depth);
459    out.push_str("sections {\n");
460    for def in sections {
461        indent(out, depth + 1);
462        out.push_str("section id=\"");
463        out.push_str(&def.id);
464        out.push_str("\" name=\"");
465        out.push_str(&escape_kdl_string(&def.name));
466        out.push('"');
467        if let Some(fs) = def.folio_start {
468            out.push_str(" folio-start=");
469            // Writing to a String via fmt::Write is infallible; the Err variant
470            // is unreachable but we must handle it.
471            let _ = write!(out, "{fs}");
472        }
473        write_opt_str(out, "folio-style", &def.folio_style);
474        out.push_str(" start-page=\"");
475        out.push_str(&def.start_page);
476        out.push_str("\"\n");
477    }
478    indent(out, depth);
479    out.push_str("}\n");
480}
481
482// ---------------------------------------------------------------------------
483// Components
484// ---------------------------------------------------------------------------
485
486/// Emit the `components { … }` block.
487///
488/// Stable position: after `styles`, before `document`. The block is emitted ONLY
489/// when at least one component is declared, so documents without components keep
490/// their existing canonical form (and round-trip) unchanged. Each component emits
491/// `component id="…" { <child nodes> }`.
492fn write_component_block(components: &[ComponentDef], out: &mut String, depth: usize) {
493    if components.is_empty() {
494        return;
495    }
496    indent(out, depth);
497    out.push_str("components {\n");
498    for def in components {
499        indent(out, depth + 1);
500        out.push_str("component id=\"");
501        out.push_str(&def.id);
502        out.push_str("\" {\n");
503        write_component_children(&def.children, out, depth + 1);
504        indent(out, depth + 1);
505        out.push_str("}\n");
506    }
507    indent(out, depth);
508    out.push_str("}\n");
509}
510
511// ---------------------------------------------------------------------------
512// Project
513// ---------------------------------------------------------------------------
514
515fn write_project(proj: &Project, out: &mut String, depth: usize) {
516    indent(out, depth);
517    out.push_str("project");
518    // Canonical order: id, name
519    out.push_str(" id=\"");
520    out.push_str(&proj.id);
521    out.push('"');
522    out.push_str(" name=\"");
523    out.push_str(&proj.name);
524    out.push('"');
525    // author: if present, emit as a block child
526    if let Some(author) = &proj.author {
527        out.push_str(" {\n");
528        indent(out, depth + 1);
529        out.push_str("author \"");
530        out.push_str(author);
531        out.push_str("\"\n");
532        indent(out, depth);
533        out.push_str("}\n");
534    } else {
535        out.push('\n');
536    }
537}
538
539// ---------------------------------------------------------------------------
540// Assets
541// ---------------------------------------------------------------------------
542
543/// Emit the `assets { … }` block.
544///
545/// Mirrors `write_token_block`: always emits the block (even when empty),
546/// consistent with how `tokens` and `styles` always emit their brace blocks.
547fn write_asset_block(block: &AssetBlock, out: &mut String, depth: usize) {
548    indent(out, depth);
549    out.push_str("assets {\n");
550
551    for decl in &block.assets {
552        write_asset_decl(decl, out, depth + 1);
553    }
554
555    indent(out, depth);
556    out.push_str("}\n");
557}
558
559fn write_asset_decl(decl: &AssetDecl, out: &mut String, depth: usize) {
560    indent(out, depth);
561    out.push_str("asset");
562
563    // Canonical property order: id, kind, src, sha256, ai-* provenance fields
564    // (in the order below), then unknown_props (sorted).
565    out.push_str(" id=\"");
566    out.push_str(&decl.id);
567    out.push('"');
568
569    out.push_str(" kind=\"");
570    out.push_str(decl.kind.kind_str());
571    out.push('"');
572
573    out.push_str(" src=\"");
574    out.push_str(&decl.src);
575    out.push('"');
576
577    if let Some(sha256) = &decl.sha256 {
578        out.push_str(" sha256=\"");
579        out.push_str(sha256);
580        out.push('"');
581    }
582
583    // AI-generation provenance fields — all optional, emitted only when Some.
584    // Free-form string fields pass through escape_kdl_string so quotes and
585    // newlines (common in prompts) survive as single-line KDL strings.
586    write_opt_str_escaped(out, "ai-prompt", &decl.ai_prompt);
587    write_opt_str_escaped(out, "ai-model", &decl.ai_model);
588    write_opt_str_escaped(out, "ai-provider", &decl.ai_provider);
589    if let Some(seed) = decl.ai_seed {
590        out.push_str(" ai-seed=");
591        let _ = write!(out, "{seed}");
592    }
593    write_opt_str_escaped(out, "ai-generation-date", &decl.ai_generation_date);
594    write_opt_str_escaped(out, "ai-license", &decl.ai_license);
595    write_opt_str_escaped(out, "ai-source-rights", &decl.ai_source_rights);
596    write_opt_str_escaped(out, "ai-safety-status", &decl.ai_safety_status);
597    write_opt_str_escaped(out, "ai-reuse-policy", &decl.ai_reuse_policy);
598
599    // Unknown properties in sorted key order (BTreeMap iteration is sorted).
600    for (key, prop) in &decl.unknown_props {
601        out.push(' ');
602        out.push_str(key);
603        out.push('=');
604        out.push_str(&fmt_unknown_property(prop));
605    }
606
607    out.push('\n');
608}
609
610// ---------------------------------------------------------------------------
611// Libraries
612// ---------------------------------------------------------------------------
613
614/// Emit the `libraries { … }` block.
615///
616/// Stable position: after `assets`, before `tokens`. Emitted ONLY when at least
617/// one library is declared, so documents without imported packages keep their
618/// existing canonical form (and round-trip) unchanged. Each library emits a
619/// single leaf line: `library id="…" version="…" hash="…"`, with optional
620/// attributes omitted when `None`, then any unknown props in BTreeMap key order.
621/// Mirrors [`write_section_block`].
622fn write_library_block(libraries: &[LibraryDef], out: &mut String, depth: usize) {
623    if libraries.is_empty() {
624        return;
625    }
626    indent(out, depth);
627    out.push_str("libraries {\n");
628    for def in libraries {
629        indent(out, depth + 1);
630        out.push_str("library id=\"");
631        out.push_str(&def.id);
632        out.push('"');
633        if let Some(version) = &def.version {
634            out.push_str(" version=\"");
635            out.push_str(version);
636            out.push('"');
637        }
638        if let Some(hash) = &def.hash {
639            out.push_str(" hash=\"");
640            out.push_str(hash);
641            out.push('"');
642        }
643        // Unknown properties in sorted key order (BTreeMap iteration is sorted).
644        for (key, prop) in &def.unknown_props {
645            out.push(' ');
646            out.push_str(key);
647            out.push('=');
648            out.push_str(&fmt_unknown_property(prop));
649        }
650        out.push('\n');
651    }
652    indent(out, depth);
653    out.push_str("}\n");
654}
655
656// ---------------------------------------------------------------------------
657// Provenance
658// ---------------------------------------------------------------------------
659
660/// Emit the `provenance { … }` block.
661///
662/// Stable position: after `sections`, before `document`. Emitted ONLY when at
663/// least one origin record is declared, so documents without provenance keep
664/// their existing canonical form (and round-trip) unchanged. Each record emits a
665/// single leaf line: `origin id="…" node="…" library="…"`, then optional
666/// `item="…"` and `linked=#true`/`#false` when set, then any unknown props in
667/// BTreeMap key order. Mirrors [`write_library_block`].
668fn write_provenance_block(provenance: &[ProvenanceDef], out: &mut String, depth: usize) {
669    if provenance.is_empty() {
670        return;
671    }
672    indent(out, depth);
673    out.push_str("provenance {\n");
674    for def in provenance {
675        indent(out, depth + 1);
676        out.push_str("origin id=\"");
677        out.push_str(&def.id);
678        out.push_str("\" node=\"");
679        out.push_str(&def.node);
680        out.push_str("\" library=\"");
681        out.push_str(&def.library);
682        out.push('"');
683        if let Some(item) = &def.item {
684            out.push_str(" item=\"");
685            out.push_str(item);
686            out.push('"');
687        }
688        write_opt_bool(out, "linked", &def.linked);
689        // Unknown properties in sorted key order (BTreeMap iteration is sorted).
690        for (key, prop) in &def.unknown_props {
691            out.push(' ');
692            out.push_str(key);
693            out.push('=');
694            out.push_str(&fmt_unknown_property(prop));
695        }
696        out.push('\n');
697    }
698    indent(out, depth);
699    out.push_str("}\n");
700}
701
702// ---------------------------------------------------------------------------
703// Variants
704// ---------------------------------------------------------------------------
705
706/// Emit the `variants { … }` block.
707///
708/// Stable position: after `provenance`, before `actions`. Emitted ONLY when at
709/// least one variant is declared, so documents without variants keep their
710/// existing canonical form (and round-trip) unchanged. Each variant emits:
711///
712/// ```text
713/// variant id="…" source="…" w=(px)N h=(px)N {
714///   override node="…" visible=#false x=(px)N y=(px)N w=(px)N h=(px)N fill=… text="…"
715/// }
716/// ```
717///
718/// Optional override props (`visible`, `x`, `y`, `w`, `h`, `fill`, `text`) are omitted when `None`.
719/// Unknown props follow known ones in BTreeMap key order. Variants with no
720/// overrides still emit a brace block (consistent with other block nodes).
721/// Mirrors [`write_provenance_block`].
722fn write_variants_block(variants: &[VariantDef], out: &mut String, depth: usize) {
723    if variants.is_empty() {
724        return;
725    }
726    indent(out, depth);
727    out.push_str("variants {\n");
728    for def in variants {
729        indent(out, depth + 1);
730        out.push_str("variant id=\"");
731        out.push_str(&def.id);
732        out.push_str("\" source=\"");
733        out.push_str(&def.source);
734        out.push_str("\" w=");
735        out.push_str(&fmt_dimension(&def.w));
736        out.push_str(" h=");
737        out.push_str(&fmt_dimension(&def.h));
738        // Unknown props on the variant node itself, in sorted key order.
739        for (key, prop) in &def.unknown_props {
740            out.push(' ');
741            out.push_str(key);
742            out.push('=');
743            out.push_str(&fmt_unknown_property(prop));
744        }
745        out.push_str(" {\n");
746        for ov in &def.overrides {
747            indent(out, depth + 2);
748            out.push_str("override node=\"");
749            out.push_str(&ov.node);
750            out.push('"');
751            write_opt_bool(out, "visible", &ov.visible);
752            write_opt_dimension(out, "x", &ov.x);
753            write_opt_dimension(out, "y", &ov.y);
754            write_opt_dimension(out, "w", &ov.w);
755            write_opt_dimension(out, "h", &ov.h);
756            write_opt_property_value(out, "fill", &ov.fill);
757            write_opt_str_escaped(out, "text", &ov.text);
758            // Unknown props on the override node, in sorted key order.
759            for (key, prop) in &ov.unknown_props {
760                out.push(' ');
761                out.push_str(key);
762                out.push('=');
763                out.push_str(&fmt_unknown_property(prop));
764            }
765            out.push('\n');
766        }
767        indent(out, depth + 1);
768        out.push_str("}\n");
769    }
770    indent(out, depth);
771    out.push_str("}\n");
772}
773
774// ---------------------------------------------------------------------------
775// Recipes
776// ---------------------------------------------------------------------------
777
778/// Emit the `recipes { … }` block.
779///
780/// Stable position: after `variants`, before `actions`. Emitted ONLY when at
781/// least one recipe is declared, so documents without recipes keep their
782/// existing canonical form (and round-trip) unchanged. Each recipe emits:
783///
784/// ```text
785/// recipe id="…" kind="…" seed=N generator="…" bounds="…" detached=#false {
786///   param name="…" value=…
787///   palette token="…"
788///   expanded node="…"
789/// }
790/// ```
791///
792/// Optional props (`seed`, `generator`, `bounds`, `detached`) are omitted when
793/// `None`. Unknown props follow known ones in BTreeMap key order. Free-form
794/// string fields (`generator`, `bounds`) pass through the same `escape_kdl_string`
795/// guard as `variants` uses for `text`. Mirrors [`write_variants_block`].
796fn write_recipes_block(recipes: &[RecipeDef], out: &mut String, depth: usize) {
797    if recipes.is_empty() {
798        return;
799    }
800    indent(out, depth);
801    out.push_str("recipes {\n");
802    for def in recipes {
803        indent(out, depth + 1);
804        out.push_str("recipe id=\"");
805        out.push_str(&def.id);
806        out.push_str("\" kind=\"");
807        out.push_str(&escape_kdl_string(&def.kind));
808        out.push('"');
809        if let Some(seed) = def.seed {
810            out.push_str(" seed=");
811            let _ = write!(out, "{seed}");
812        }
813        if let Some(generator) = &def.generator {
814            out.push_str(" generator=\"");
815            out.push_str(&escape_kdl_string(generator));
816            out.push('"');
817        }
818        if let Some(bounds) = &def.bounds {
819            out.push_str(" bounds=\"");
820            out.push_str(&escape_kdl_string(bounds));
821            out.push('"');
822        }
823        write_opt_bool(out, "detached", &def.detached);
824        // Unknown props on the recipe node itself, in sorted key order.
825        for (key, prop) in &def.unknown_props {
826            out.push(' ');
827            out.push_str(key);
828            out.push('=');
829            out.push_str(&fmt_unknown_property(prop));
830        }
831        out.push_str(" {\n");
832        for param in &def.params {
833            write_recipe_param(param, out, depth + 2);
834        }
835        for token_id in &def.palette {
836            indent(out, depth + 2);
837            out.push_str("palette token=\"");
838            out.push_str(token_id);
839            out.push_str("\"\n");
840        }
841        for node_id in &def.expanded {
842            indent(out, depth + 2);
843            out.push_str("expanded node=\"");
844            out.push_str(node_id);
845            out.push_str("\"\n");
846        }
847        indent(out, depth + 1);
848        out.push_str("}\n");
849    }
850    indent(out, depth);
851    out.push_str("}\n");
852}
853
854fn write_recipe_param(param: &RecipeParam, out: &mut String, depth: usize) {
855    indent(out, depth);
856    out.push_str("param name=\"");
857    out.push_str(&param.name);
858    out.push_str("\" value=");
859    out.push_str(&fmt_property_value(&param.value));
860    // Unknown props on the param node, in sorted key order.
861    for (key, prop) in &param.unknown_props {
862        out.push(' ');
863        out.push_str(key);
864        out.push('=');
865        out.push_str(&fmt_unknown_property(prop));
866    }
867    out.push('\n');
868}
869
870// ---------------------------------------------------------------------------
871// Actions
872// ---------------------------------------------------------------------------
873
874/// Emit the `actions { … }` block.
875///
876/// Stable position: after `provenance`, before `document`. Emitted ONLY when at
877/// least one action is declared, so documents without actions keep their
878/// existing canonical form (and round-trip) unchanged. Each action emits:
879///
880/// ```text
881/// action id="…" label="…" version="…" {
882///   tx "…"
883/// }
884/// ```
885///
886/// Optional attributes are omitted when `None`. Unknown props follow known
887/// ones in BTreeMap key order. The `tx` payload is emitted as a single escaped
888/// string child node (same encoding as `content` in a `code` node), so
889/// characters that require escaping (`"`, `\`, `\n`, etc.) survive
890/// round-trips. Mirrors [`write_provenance_block`].
891fn write_action_block(actions: &[ActionDef], out: &mut String, depth: usize) {
892    if actions.is_empty() {
893        return;
894    }
895    indent(out, depth);
896    out.push_str("actions {\n");
897    for def in actions {
898        indent(out, depth + 1);
899        out.push_str("action id=\"");
900        out.push_str(&def.id);
901        out.push('"');
902        if let Some(label) = &def.label {
903            out.push_str(" label=\"");
904            out.push_str(&escape_kdl_string(label));
905            out.push('"');
906        }
907        if let Some(version) = &def.version {
908            out.push_str(" version=\"");
909            out.push_str(version);
910            out.push('"');
911        }
912        // Unknown properties in sorted key order (BTreeMap iteration is sorted).
913        for (key, prop) in &def.unknown_props {
914            out.push(' ');
915            out.push_str(key);
916            out.push('=');
917            out.push_str(&fmt_unknown_property(prop));
918        }
919        out.push_str(" {\n");
920        // Emit the tx payload as a single escaped-string child node. This
921        // mirrors how `code` nodes emit their `content` child: the JSON is
922        // stored decoded and re-encoded here so quotes and backslashes survive
923        // round-trips.
924        indent(out, depth + 2);
925        out.push_str("tx \"");
926        out.push_str(&escape_kdl_string(&def.tx_json));
927        out.push_str("\"\n");
928        indent(out, depth + 1);
929        out.push_str("}\n");
930    }
931    indent(out, depth);
932    out.push_str("}\n");
933}