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>"`.
326fn write_diagnostics_block(policy: &DiagnosticPolicy, out: &mut String, depth: usize) {
327    if policy.entries.is_empty() {
328        return;
329    }
330    indent(out, depth);
331    out.push_str("diagnostics {\n");
332    for entry in &policy.entries {
333        indent(out, depth + 1);
334        let verb = match entry.verb {
335            PolicyVerb::Allow => "allow",
336            PolicyVerb::Deny => "deny",
337            PolicyVerb::Warn => "warn",
338        };
339        out.push_str(verb);
340        out.push_str(" \"");
341        out.push_str(&escape_kdl_string(&entry.code));
342        out.push_str("\"\n");
343    }
344    indent(out, depth);
345    out.push_str("}\n");
346}
347
348// ---------------------------------------------------------------------------
349// Brand contract
350// ---------------------------------------------------------------------------
351
352/// Emit the `brand { … }` block.
353///
354/// Stable position: right after `diagnostics`, before `project`. Emitted ONLY
355/// when the contract has at least one constrained category (`!is_empty()`), so
356/// documents without a brand contract keep their existing canonical form and
357/// round-trip byte-identically. Canonical child order: `colors`, `fonts`,
358/// `weights` (matching the declaration order in the KDL syntax docs).
359///
360/// Each value is emitted as a positional argument on its child node:
361/// `colors "#0b1f33" "#ffffff"`, `fonts "Noto Sans"`, `weights 400 700`.
362/// Absent categories are not emitted (a `None` field → no line).
363fn write_brand_block(contract: &BrandContract, out: &mut String, depth: usize) {
364    if contract.is_empty() {
365        return;
366    }
367    indent(out, depth);
368    out.push_str("brand {\n");
369
370    if let Some(colors) = &contract.allowed_colors {
371        indent(out, depth + 1);
372        out.push_str("colors");
373        for color in colors {
374            out.push_str(" \"");
375            out.push_str(color);
376            out.push('"');
377        }
378        out.push('\n');
379    }
380
381    if let Some(fonts) = &contract.allowed_fonts {
382        indent(out, depth + 1);
383        out.push_str("fonts");
384        for font in fonts {
385            out.push_str(" \"");
386            out.push_str(&escape_kdl_string(font));
387            out.push('"');
388        }
389        out.push('\n');
390    }
391
392    if let Some(weights) = &contract.allowed_weights {
393        indent(out, depth + 1);
394        out.push_str("weights");
395        for weight in weights {
396            out.push(' ');
397            let _ = write!(out, "{weight}");
398        }
399        out.push('\n');
400    }
401
402    indent(out, depth);
403    out.push_str("}\n");
404}
405
406// ---------------------------------------------------------------------------
407// Masters
408// ---------------------------------------------------------------------------
409
410/// Emit the `masters { … }` block.
411///
412/// Stable position: after `components`, before `document`. Emitted ONLY when at
413/// least one master is declared, so documents without masters keep their
414/// existing canonical form (and round-trip) unchanged. Each master emits
415/// `master id="…" { <child nodes> }`. Mirrors [`write_component_block`].
416fn write_master_block(masters: &[MasterDef], out: &mut String, depth: usize) {
417    if masters.is_empty() {
418        return;
419    }
420    indent(out, depth);
421    out.push_str("masters {\n");
422    for def in masters {
423        indent(out, depth + 1);
424        out.push_str("master id=\"");
425        out.push_str(&def.id);
426        out.push_str("\" {\n");
427        write_component_children(&def.children, out, depth + 1);
428        indent(out, depth + 1);
429        out.push_str("}\n");
430    }
431    indent(out, depth);
432    out.push_str("}\n");
433}
434
435// ---------------------------------------------------------------------------
436// Sections
437// ---------------------------------------------------------------------------
438
439/// Emit the `sections { … }` block.
440///
441/// Stable position: after `masters`, before `document`. Emitted ONLY when at
442/// least one section is declared, so documents without sections keep their
443/// existing canonical form (and round-trip) unchanged. Each section emits a
444/// single leaf line: `section id="…" name="…" folio-start=N folio-style="…"
445/// start-page="…"`. Optional attributes are omitted when `None`. Mirrors
446/// [`write_master_block`].
447fn write_section_block(sections: &[SectionDef], out: &mut String, depth: usize) {
448    if sections.is_empty() {
449        return;
450    }
451    indent(out, depth);
452    out.push_str("sections {\n");
453    for def in sections {
454        indent(out, depth + 1);
455        out.push_str("section id=\"");
456        out.push_str(&def.id);
457        out.push_str("\" name=\"");
458        out.push_str(&escape_kdl_string(&def.name));
459        out.push('"');
460        if let Some(fs) = def.folio_start {
461            out.push_str(" folio-start=");
462            // Writing to a String via fmt::Write is infallible; the Err variant
463            // is unreachable but we must handle it.
464            let _ = write!(out, "{fs}");
465        }
466        write_opt_str(out, "folio-style", &def.folio_style);
467        out.push_str(" start-page=\"");
468        out.push_str(&def.start_page);
469        out.push_str("\"\n");
470    }
471    indent(out, depth);
472    out.push_str("}\n");
473}
474
475// ---------------------------------------------------------------------------
476// Components
477// ---------------------------------------------------------------------------
478
479/// Emit the `components { … }` block.
480///
481/// Stable position: after `styles`, before `document`. The block is emitted ONLY
482/// when at least one component is declared, so documents without components keep
483/// their existing canonical form (and round-trip) unchanged. Each component emits
484/// `component id="…" { <child nodes> }`.
485fn write_component_block(components: &[ComponentDef], out: &mut String, depth: usize) {
486    if components.is_empty() {
487        return;
488    }
489    indent(out, depth);
490    out.push_str("components {\n");
491    for def in components {
492        indent(out, depth + 1);
493        out.push_str("component id=\"");
494        out.push_str(&def.id);
495        out.push_str("\" {\n");
496        write_component_children(&def.children, out, depth + 1);
497        indent(out, depth + 1);
498        out.push_str("}\n");
499    }
500    indent(out, depth);
501    out.push_str("}\n");
502}
503
504// ---------------------------------------------------------------------------
505// Project
506// ---------------------------------------------------------------------------
507
508fn write_project(proj: &Project, out: &mut String, depth: usize) {
509    indent(out, depth);
510    out.push_str("project");
511    // Canonical order: id, name
512    out.push_str(" id=\"");
513    out.push_str(&proj.id);
514    out.push('"');
515    out.push_str(" name=\"");
516    out.push_str(&proj.name);
517    out.push('"');
518    // author: if present, emit as a block child
519    if let Some(author) = &proj.author {
520        out.push_str(" {\n");
521        indent(out, depth + 1);
522        out.push_str("author \"");
523        out.push_str(author);
524        out.push_str("\"\n");
525        indent(out, depth);
526        out.push_str("}\n");
527    } else {
528        out.push('\n');
529    }
530}
531
532// ---------------------------------------------------------------------------
533// Assets
534// ---------------------------------------------------------------------------
535
536/// Emit the `assets { … }` block.
537///
538/// Mirrors `write_token_block`: always emits the block (even when empty),
539/// consistent with how `tokens` and `styles` always emit their brace blocks.
540fn write_asset_block(block: &AssetBlock, out: &mut String, depth: usize) {
541    indent(out, depth);
542    out.push_str("assets {\n");
543
544    for decl in &block.assets {
545        write_asset_decl(decl, out, depth + 1);
546    }
547
548    indent(out, depth);
549    out.push_str("}\n");
550}
551
552fn write_asset_decl(decl: &AssetDecl, out: &mut String, depth: usize) {
553    indent(out, depth);
554    out.push_str("asset");
555
556    // Canonical property order: id, kind, src, sha256, ai-* provenance fields
557    // (in the order below), then unknown_props (sorted).
558    out.push_str(" id=\"");
559    out.push_str(&decl.id);
560    out.push('"');
561
562    out.push_str(" kind=\"");
563    out.push_str(decl.kind.kind_str());
564    out.push('"');
565
566    out.push_str(" src=\"");
567    out.push_str(&decl.src);
568    out.push('"');
569
570    if let Some(sha256) = &decl.sha256 {
571        out.push_str(" sha256=\"");
572        out.push_str(sha256);
573        out.push('"');
574    }
575
576    // AI-generation provenance fields — all optional, emitted only when Some.
577    // Free-form string fields pass through escape_kdl_string so quotes and
578    // newlines (common in prompts) survive as single-line KDL strings.
579    write_opt_str_escaped(out, "ai-prompt", &decl.ai_prompt);
580    write_opt_str_escaped(out, "ai-model", &decl.ai_model);
581    write_opt_str_escaped(out, "ai-provider", &decl.ai_provider);
582    if let Some(seed) = decl.ai_seed {
583        out.push_str(" ai-seed=");
584        let _ = write!(out, "{seed}");
585    }
586    write_opt_str_escaped(out, "ai-generation-date", &decl.ai_generation_date);
587    write_opt_str_escaped(out, "ai-license", &decl.ai_license);
588    write_opt_str_escaped(out, "ai-source-rights", &decl.ai_source_rights);
589    write_opt_str_escaped(out, "ai-safety-status", &decl.ai_safety_status);
590    write_opt_str_escaped(out, "ai-reuse-policy", &decl.ai_reuse_policy);
591
592    // Unknown properties in sorted key order (BTreeMap iteration is sorted).
593    for (key, prop) in &decl.unknown_props {
594        out.push(' ');
595        out.push_str(key);
596        out.push('=');
597        out.push_str(&fmt_unknown_property(prop));
598    }
599
600    out.push('\n');
601}
602
603// ---------------------------------------------------------------------------
604// Libraries
605// ---------------------------------------------------------------------------
606
607/// Emit the `libraries { … }` block.
608///
609/// Stable position: after `assets`, before `tokens`. Emitted ONLY when at least
610/// one library is declared, so documents without imported packages keep their
611/// existing canonical form (and round-trip) unchanged. Each library emits a
612/// single leaf line: `library id="…" version="…" hash="…"`, with optional
613/// attributes omitted when `None`, then any unknown props in BTreeMap key order.
614/// Mirrors [`write_section_block`].
615fn write_library_block(libraries: &[LibraryDef], out: &mut String, depth: usize) {
616    if libraries.is_empty() {
617        return;
618    }
619    indent(out, depth);
620    out.push_str("libraries {\n");
621    for def in libraries {
622        indent(out, depth + 1);
623        out.push_str("library id=\"");
624        out.push_str(&def.id);
625        out.push('"');
626        if let Some(version) = &def.version {
627            out.push_str(" version=\"");
628            out.push_str(version);
629            out.push('"');
630        }
631        if let Some(hash) = &def.hash {
632            out.push_str(" hash=\"");
633            out.push_str(hash);
634            out.push('"');
635        }
636        // Unknown properties in sorted key order (BTreeMap iteration is sorted).
637        for (key, prop) in &def.unknown_props {
638            out.push(' ');
639            out.push_str(key);
640            out.push('=');
641            out.push_str(&fmt_unknown_property(prop));
642        }
643        out.push('\n');
644    }
645    indent(out, depth);
646    out.push_str("}\n");
647}
648
649// ---------------------------------------------------------------------------
650// Provenance
651// ---------------------------------------------------------------------------
652
653/// Emit the `provenance { … }` block.
654///
655/// Stable position: after `sections`, before `document`. Emitted ONLY when at
656/// least one origin record is declared, so documents without provenance keep
657/// their existing canonical form (and round-trip) unchanged. Each record emits a
658/// single leaf line: `origin id="…" node="…" library="…"`, then optional
659/// `item="…"` and `linked=#true`/`#false` when set, then any unknown props in
660/// BTreeMap key order. Mirrors [`write_library_block`].
661fn write_provenance_block(provenance: &[ProvenanceDef], out: &mut String, depth: usize) {
662    if provenance.is_empty() {
663        return;
664    }
665    indent(out, depth);
666    out.push_str("provenance {\n");
667    for def in provenance {
668        indent(out, depth + 1);
669        out.push_str("origin id=\"");
670        out.push_str(&def.id);
671        out.push_str("\" node=\"");
672        out.push_str(&def.node);
673        out.push_str("\" library=\"");
674        out.push_str(&def.library);
675        out.push('"');
676        if let Some(item) = &def.item {
677            out.push_str(" item=\"");
678            out.push_str(item);
679            out.push('"');
680        }
681        write_opt_bool(out, "linked", &def.linked);
682        // Unknown properties in sorted key order (BTreeMap iteration is sorted).
683        for (key, prop) in &def.unknown_props {
684            out.push(' ');
685            out.push_str(key);
686            out.push('=');
687            out.push_str(&fmt_unknown_property(prop));
688        }
689        out.push('\n');
690    }
691    indent(out, depth);
692    out.push_str("}\n");
693}
694
695// ---------------------------------------------------------------------------
696// Variants
697// ---------------------------------------------------------------------------
698
699/// Emit the `variants { … }` block.
700///
701/// Stable position: after `provenance`, before `actions`. Emitted ONLY when at
702/// least one variant is declared, so documents without variants keep their
703/// existing canonical form (and round-trip) unchanged. Each variant emits:
704///
705/// ```text
706/// variant id="…" source="…" w=(px)N h=(px)N {
707///   override node="…" visible=#false x=(px)N y=(px)N w=(px)N h=(px)N fill=… text="…"
708/// }
709/// ```
710///
711/// Optional override props (`visible`, `x`, `y`, `w`, `h`, `fill`, `text`) are omitted when `None`.
712/// Unknown props follow known ones in BTreeMap key order. Variants with no
713/// overrides still emit a brace block (consistent with other block nodes).
714/// Mirrors [`write_provenance_block`].
715fn write_variants_block(variants: &[VariantDef], out: &mut String, depth: usize) {
716    if variants.is_empty() {
717        return;
718    }
719    indent(out, depth);
720    out.push_str("variants {\n");
721    for def in variants {
722        indent(out, depth + 1);
723        out.push_str("variant id=\"");
724        out.push_str(&def.id);
725        out.push_str("\" source=\"");
726        out.push_str(&def.source);
727        out.push_str("\" w=");
728        out.push_str(&fmt_dimension(&def.w));
729        out.push_str(" h=");
730        out.push_str(&fmt_dimension(&def.h));
731        // Unknown props on the variant node itself, in sorted key order.
732        for (key, prop) in &def.unknown_props {
733            out.push(' ');
734            out.push_str(key);
735            out.push('=');
736            out.push_str(&fmt_unknown_property(prop));
737        }
738        out.push_str(" {\n");
739        for ov in &def.overrides {
740            indent(out, depth + 2);
741            out.push_str("override node=\"");
742            out.push_str(&ov.node);
743            out.push('"');
744            write_opt_bool(out, "visible", &ov.visible);
745            write_opt_dimension(out, "x", &ov.x);
746            write_opt_dimension(out, "y", &ov.y);
747            write_opt_dimension(out, "w", &ov.w);
748            write_opt_dimension(out, "h", &ov.h);
749            write_opt_property_value(out, "fill", &ov.fill);
750            write_opt_str_escaped(out, "text", &ov.text);
751            // Unknown props on the override node, in sorted key order.
752            for (key, prop) in &ov.unknown_props {
753                out.push(' ');
754                out.push_str(key);
755                out.push('=');
756                out.push_str(&fmt_unknown_property(prop));
757            }
758            out.push('\n');
759        }
760        indent(out, depth + 1);
761        out.push_str("}\n");
762    }
763    indent(out, depth);
764    out.push_str("}\n");
765}
766
767// ---------------------------------------------------------------------------
768// Recipes
769// ---------------------------------------------------------------------------
770
771/// Emit the `recipes { … }` block.
772///
773/// Stable position: after `variants`, before `actions`. Emitted ONLY when at
774/// least one recipe is declared, so documents without recipes keep their
775/// existing canonical form (and round-trip) unchanged. Each recipe emits:
776///
777/// ```text
778/// recipe id="…" kind="…" seed=N generator="…" bounds="…" detached=#false {
779///   param name="…" value=…
780///   palette token="…"
781///   expanded node="…"
782/// }
783/// ```
784///
785/// Optional props (`seed`, `generator`, `bounds`, `detached`) are omitted when
786/// `None`. Unknown props follow known ones in BTreeMap key order. Free-form
787/// string fields (`generator`, `bounds`) pass through the same `escape_kdl_string`
788/// guard as `variants` uses for `text`. Mirrors [`write_variants_block`].
789fn write_recipes_block(recipes: &[RecipeDef], out: &mut String, depth: usize) {
790    if recipes.is_empty() {
791        return;
792    }
793    indent(out, depth);
794    out.push_str("recipes {\n");
795    for def in recipes {
796        indent(out, depth + 1);
797        out.push_str("recipe id=\"");
798        out.push_str(&def.id);
799        out.push_str("\" kind=\"");
800        out.push_str(&escape_kdl_string(&def.kind));
801        out.push('"');
802        if let Some(seed) = def.seed {
803            out.push_str(" seed=");
804            let _ = write!(out, "{seed}");
805        }
806        if let Some(generator) = &def.generator {
807            out.push_str(" generator=\"");
808            out.push_str(&escape_kdl_string(generator));
809            out.push('"');
810        }
811        if let Some(bounds) = &def.bounds {
812            out.push_str(" bounds=\"");
813            out.push_str(&escape_kdl_string(bounds));
814            out.push('"');
815        }
816        write_opt_bool(out, "detached", &def.detached);
817        // Unknown props on the recipe node itself, in sorted key order.
818        for (key, prop) in &def.unknown_props {
819            out.push(' ');
820            out.push_str(key);
821            out.push('=');
822            out.push_str(&fmt_unknown_property(prop));
823        }
824        out.push_str(" {\n");
825        for param in &def.params {
826            write_recipe_param(param, out, depth + 2);
827        }
828        for token_id in &def.palette {
829            indent(out, depth + 2);
830            out.push_str("palette token=\"");
831            out.push_str(token_id);
832            out.push_str("\"\n");
833        }
834        for node_id in &def.expanded {
835            indent(out, depth + 2);
836            out.push_str("expanded node=\"");
837            out.push_str(node_id);
838            out.push_str("\"\n");
839        }
840        indent(out, depth + 1);
841        out.push_str("}\n");
842    }
843    indent(out, depth);
844    out.push_str("}\n");
845}
846
847fn write_recipe_param(param: &RecipeParam, out: &mut String, depth: usize) {
848    indent(out, depth);
849    out.push_str("param name=\"");
850    out.push_str(&param.name);
851    out.push_str("\" value=");
852    out.push_str(&fmt_property_value(&param.value));
853    // Unknown props on the param node, in sorted key order.
854    for (key, prop) in &param.unknown_props {
855        out.push(' ');
856        out.push_str(key);
857        out.push('=');
858        out.push_str(&fmt_unknown_property(prop));
859    }
860    out.push('\n');
861}
862
863// ---------------------------------------------------------------------------
864// Actions
865// ---------------------------------------------------------------------------
866
867/// Emit the `actions { … }` block.
868///
869/// Stable position: after `provenance`, before `document`. Emitted ONLY when at
870/// least one action is declared, so documents without actions keep their
871/// existing canonical form (and round-trip) unchanged. Each action emits:
872///
873/// ```text
874/// action id="…" label="…" version="…" {
875///   tx "…"
876/// }
877/// ```
878///
879/// Optional attributes are omitted when `None`. Unknown props follow known
880/// ones in BTreeMap key order. The `tx` payload is emitted as a single escaped
881/// string child node (same encoding as `content` in a `code` node), so
882/// characters that require escaping (`"`, `\`, `\n`, etc.) survive
883/// round-trips. Mirrors [`write_provenance_block`].
884fn write_action_block(actions: &[ActionDef], out: &mut String, depth: usize) {
885    if actions.is_empty() {
886        return;
887    }
888    indent(out, depth);
889    out.push_str("actions {\n");
890    for def in actions {
891        indent(out, depth + 1);
892        out.push_str("action id=\"");
893        out.push_str(&def.id);
894        out.push('"');
895        if let Some(label) = &def.label {
896            out.push_str(" label=\"");
897            out.push_str(&escape_kdl_string(label));
898            out.push('"');
899        }
900        if let Some(version) = &def.version {
901            out.push_str(" version=\"");
902            out.push_str(version);
903            out.push('"');
904        }
905        // Unknown properties in sorted key order (BTreeMap iteration is sorted).
906        for (key, prop) in &def.unknown_props {
907            out.push(' ');
908            out.push_str(key);
909            out.push('=');
910            out.push_str(&fmt_unknown_property(prop));
911        }
912        out.push_str(" {\n");
913        // Emit the tx payload as a single escaped-string child node. This
914        // mirrors how `code` nodes emit their `content` child: the JSON is
915        // stored decoded and re-encoded here so quotes and backslashes survive
916        // round-trips.
917        indent(out, depth + 2);
918        out.push_str("tx \"");
919        out.push_str(&escape_kdl_string(&def.tx_json));
920        out.push_str("\"\n");
921        indent(out, depth + 1);
922        out.push_str("}\n");
923    }
924    indent(out, depth);
925    out.push_str("}\n");
926}