Skip to main content

zenith_core/parse/transform/
document.rs

1//! Top-level `transform` entry point and the document-level structural blocks
2//! (project, assets, libraries, actions, masters, sections, provenance,
3//! components, document body, pages, folds, safe-zones).
4
5use kdl::{KdlDocument, KdlNode, KdlValue};
6
7use crate::ast::{
8    action::ActionDef,
9    asset::{AssetBlock, AssetDecl, AssetKind},
10    block_style::BlockStyle,
11    brand::BrandContract,
12    document::{ComponentDef, Document, DocumentBody, MasterDef, Page, Project, SectionDef},
13    library::LibraryDef,
14    node::Node,
15    policy::{DiagnosticPolicy, PolicyEntry, PolicyVerb},
16    provenance::ProvenanceDef,
17    recipe::{RecipeDef, RecipeParam},
18    style::StyleBlock,
19    token::TokenBlock,
20    variant::{VariantDef, VariantOverride},
21};
22use crate::error::{ParseError, ParseErrorCode};
23
24use super::block_style::transform_block_style;
25use super::helpers::{
26    collect_unknown_props, entry_to_dimension, entry_to_property_value, node_span,
27    optional_bool_prop, optional_dimension_prop, optional_i64_prop, optional_string_prop,
28    optional_string_prop_aliased, optional_u32_prop, required_string_prop,
29    required_string_prop_aliased, required_u32_prop,
30};
31use super::node::transform_node;
32use super::page::transform_page;
33use super::tokens::{transform_styles, transform_tokens};
34
35/// Canonical set of property names recognised on the document-level surface.
36///
37/// Covers both the root `zenith` node attributes (version, colorspace,
38/// doc-id, mirror-margins, page-progression, page-parity-start,
39/// facing-pages, spread-gutter, margin-*) and the required `document`
40/// child-block attributes (id, title).
41///
42/// Both the hyphenated spelling (canonical) and the underscored alias are
43/// included for each attribute that accepts either form, matching the lenient
44/// parser behaviour. Used by `zenith-core::schema` to surface the authorable
45/// attribute list for the `zenith schema document` subcommand.
46pub(crate) const DOCUMENT_KNOWN_PROPS: &[&str] = &[
47    // root `zenith` node
48    "version",
49    "colorspace",
50    "doc-id",
51    "doc_id",
52    "mirror-margins",
53    "mirror_margins",
54    "page-progression",
55    "page_progression",
56    "page-parity-start",
57    "page_parity_start",
58    "facing-pages",
59    "facing_pages",
60    "spread-gutter",
61    "spread_gutter",
62    "margin-inner",
63    "margin_inner",
64    "margin-outer",
65    "margin_outer",
66    "margin-top",
67    "margin_top",
68    "margin-bottom",
69    "margin_bottom",
70    // `document { … }` child block
71    "id",
72    "title",
73];
74
75/// Transform a parsed `KdlDocument` into a Zenith `Document` AST.
76pub fn transform(doc: &KdlDocument) -> Result<Document, ParseError> {
77    // Find the single top-level `zenith` node.
78    let zenith_node = doc
79        .nodes()
80        .iter()
81        .find(|n| n.name().value() == "zenith")
82        .ok_or_else(|| {
83            ParseError::spanless(
84                ParseErrorCode::MissingZenithRoot,
85                "no top-level `zenith` node found",
86            )
87        })?;
88
89    let version = required_u32_prop(zenith_node, "version")?;
90    // Optional export color space attribute on the root `zenith` node. Value
91    // validity ("srgb"|"cmyk") is checked by the validator, not the parser, so
92    // an unrecognized value is preserved verbatim for a precise warning.
93    let colorspace = optional_string_prop(zenith_node, "colorspace").map(str::to_owned);
94
95    // Optional stable document identity (`doc-id="01ARZ3NDEKTSV4RRFFQ69G5FAV"`).
96    // The value is a ULID (Crockford base-32) minted at document creation; it
97    // is preserved verbatim without validation — the parser accepts whatever
98    // string the author wrote and lets the validator decide. Both the hyphenated
99    // and underscored spellings are accepted for forward-compat.
100    let doc_id = optional_string_prop(zenith_node, "doc-id")
101        .or_else(|| optional_string_prop(zenith_node, "doc_id"))
102        .map(str::to_owned);
103
104    // Optional mirrored-margins toggle (`mirror-margins=#true`). Forward-compat:
105    // both the hyphenated and underscored spellings are accepted.
106    let mirror_margins = optional_bool_prop(zenith_node, "mirror-margins")
107        .or_else(|| optional_bool_prop(zenith_node, "mirror_margins"));
108
109    // Optional page-progression attribute (`page-progression="rtl"`). Value
110    // validity ("ltr"|"rtl") is checked by the validator, not the parser, so an
111    // unrecognized value is preserved verbatim for a precise warning.
112    let page_progression = optional_string_prop(zenith_node, "page-progression")
113        .or_else(|| optional_string_prop(zenith_node, "page_progression"))
114        .map(str::to_owned);
115
116    // Optional starting-parity attribute (`page-parity-start="verso"`). Value
117    // validity ("recto"|"verso") is checked by the validator, not the parser, so
118    // an unrecognized value is preserved verbatim for a precise warning. Both the
119    // hyphenated and underscored spellings are accepted for forward-compat.
120    let page_parity_start = optional_string_prop(zenith_node, "page-parity-start")
121        .or_else(|| optional_string_prop(zenith_node, "page_parity_start"))
122        .map(str::to_owned);
123
124    // Optional facing-pages toggle (`facing-pages=#true`). Forward-compat:
125    // both the hyphenated and underscored spellings are accepted. This is
126    // informational metadata only; pages still render independently.
127    let facing_pages = optional_bool_prop(zenith_node, "facing-pages")
128        .or_else(|| optional_bool_prop(zenith_node, "facing_pages"));
129
130    // Optional spread-gutter dimension (`spread-gutter=(px)40`). Drives the
131    // transparent gap between the two pages of a `--spread` composite.
132    // Both hyphenated and underscored spellings are accepted for forward-compat.
133    let spread_gutter = optional_dimension_prop(zenith_node, "spread-gutter")
134        .or_else(|| optional_dimension_prop(zenith_node, "spread_gutter"));
135
136    // Optional DOCUMENT-LEVEL default book live-area margins. Same KDL syntax as
137    // on a page (`margin-inner=(px)225`); a page that omits its own margin
138    // inherits these via `Document::effective_margins`. Both hyphenated and
139    // underscored spellings are accepted for forward-compat.
140    let margin_inner = optional_dimension_prop(zenith_node, "margin-inner")
141        .or_else(|| optional_dimension_prop(zenith_node, "margin_inner"));
142    let margin_outer = optional_dimension_prop(zenith_node, "margin-outer")
143        .or_else(|| optional_dimension_prop(zenith_node, "margin_outer"));
144    let margin_top = optional_dimension_prop(zenith_node, "margin-top")
145        .or_else(|| optional_dimension_prop(zenith_node, "margin_top"));
146    let margin_bottom = optional_dimension_prop(zenith_node, "margin-bottom")
147        .or_else(|| optional_dimension_prop(zenith_node, "margin_bottom"));
148
149    let children_doc = zenith_node.children().ok_or_else(|| {
150        ParseError::spanless(
151            ParseErrorCode::MissingZenithRoot,
152            "`zenith` node has no children block",
153        )
154    })?;
155
156    let mut project: Option<Project> = None;
157    let mut assets = AssetBlock::default();
158    let mut libraries: Vec<LibraryDef> = Vec::new();
159    let mut actions: Vec<ActionDef> = Vec::new();
160    let mut tokens = TokenBlock::default();
161    let mut styles = StyleBlock::default();
162    let mut components: Vec<ComponentDef> = Vec::new();
163    let mut masters: Vec<MasterDef> = Vec::new();
164    let mut sections: Vec<SectionDef> = Vec::new();
165    let mut provenance: Vec<ProvenanceDef> = Vec::new();
166    let mut variants: Vec<VariantDef> = Vec::new();
167    let mut recipes: Vec<RecipeDef> = Vec::new();
168    let mut diagnostic_policy = DiagnosticPolicy::default();
169    let mut brand_contract = BrandContract::default();
170    let mut body: Option<DocumentBody> = None;
171
172    for child in children_doc.nodes() {
173        match child.name().value() {
174            "project" => {
175                project = Some(transform_project(child)?);
176            }
177            "assets" => {
178                assets = transform_assets(child)?;
179            }
180            "libraries" => {
181                libraries = transform_libraries(child)?;
182            }
183            "actions" => {
184                actions = transform_actions(child)?;
185            }
186            "tokens" => {
187                tokens = transform_tokens(child)?;
188            }
189            "styles" => {
190                styles = transform_styles(child)?;
191            }
192            "components" => {
193                components = transform_components(child)?;
194            }
195            "masters" => {
196                masters = transform_masters(child)?;
197            }
198            "sections" => {
199                sections = transform_sections(child)?;
200            }
201            "provenance" => {
202                provenance = transform_provenance(child)?;
203            }
204            "variants" => {
205                variants = transform_variants(child)?;
206            }
207            "recipes" => {
208                recipes = transform_recipes(child)?;
209            }
210            "diagnostics" => {
211                diagnostic_policy = transform_diagnostic_policy(child)?;
212            }
213            "brand" => {
214                brand_contract = transform_brand_contract(child)?;
215            }
216            "document" => {
217                body = Some(transform_document_body(child)?);
218            }
219            // Any other unknown top-level children are accepted without error
220            // (forward-compat); they simply are not represented in the v0 AST.
221            _ => {}
222        }
223    }
224
225    let body = body.ok_or_else(|| {
226        ParseError::spanless(
227            ParseErrorCode::MissingZenithRoot,
228            "`zenith` node is missing a `document` child",
229        )
230    })?;
231
232    Ok(Document {
233        version,
234        colorspace,
235        doc_id,
236        mirror_margins,
237        facing_pages,
238        spread_gutter,
239        page_progression,
240        page_parity_start,
241        margin_inner,
242        margin_outer,
243        margin_top,
244        margin_bottom,
245        project,
246        assets,
247        libraries,
248        actions,
249        tokens,
250        styles,
251        components,
252        masters,
253        sections,
254        provenance,
255        variants,
256        recipes,
257        diagnostic_policy,
258        brand_contract,
259        body,
260    })
261}
262
263// ---------------------------------------------------------------------------
264// Diagnostics policy
265// ---------------------------------------------------------------------------
266
267/// Transform the document-level `diagnostics { … }` block into a
268/// [`DiagnosticPolicy`].
269///
270/// Each child node is one policy entry whose NAME is the verb (`allow`, `deny`,
271/// or `warn`) and whose FIRST positional argument is the diagnostic code string:
272///
273/// ```text
274/// diagnostics {
275///   allow "layout.off_canvas"
276///   allow "layout.off_canvas" "bg.glow" "bg.rim"
277///   deny  "token.unused"
278///   warn  "node.unknown_property"
279/// }
280/// ```
281///
282/// A child whose name is not a recognized verb is silently ignored
283/// (forward-compat — same posture as every other document block). A recognized
284/// verb whose code argument is missing or non-string is a hard [`ParseError`]
285/// (the entry is meaningless without a code). Declaration order is preserved;
286/// last-wins resolution happens at consult time (see [`DiagnosticPolicy::verb_for`]).
287pub(crate) fn transform_diagnostic_policy(node: &KdlNode) -> Result<DiagnosticPolicy, ParseError> {
288    let mut entries: Vec<PolicyEntry> = Vec::new();
289    if let Some(children) = node.children() {
290        for child in children.nodes() {
291            let (verb, verb_name) = match child.name().value() {
292                "allow" => (PolicyVerb::Allow, "allow"),
293                "deny" => (PolicyVerb::Deny, "deny"),
294                "warn" => (PolicyVerb::Warn, "warn"),
295                // Unknown verb → ignore (forward-compat).
296                _ => continue,
297            };
298            let mut positional = child
299                .entries()
300                .iter()
301                .filter(|entry| entry.name().is_none());
302            let code = match positional.next().map(|entry| entry.value()) {
303                Some(KdlValue::String(s)) => s.clone(),
304                _ => {
305                    return Err(ParseError::spanless(
306                        ParseErrorCode::InvalidPropertyValue,
307                        format!(
308                            "diagnostics `{verb_name}` entry requires a quoted diagnostic-code \
309                             string as its first argument, e.g. `{verb_name} \"layout.off_canvas\"`"
310                        ),
311                    ));
312                }
313            };
314            let mut subjects: Vec<String> = Vec::new();
315            for (idx, entry) in positional.enumerate() {
316                match entry.value() {
317                    KdlValue::String(s) => subjects.push(s.clone()),
318                    _ => {
319                        let subject_index = idx + 1;
320                        return Err(ParseError::spanless(
321                            ParseErrorCode::InvalidPropertyValue,
322                            format!(
323                                "diagnostics `{verb_name}` subject argument {subject_index} must \
324                                 be a quoted subject-id string, e.g. `{verb_name} \
325                                 \"layout.off_canvas\" \"bg.glow\"`"
326                            ),
327                        ));
328                    }
329                }
330            }
331            if child.entries().iter().any(|entry| {
332                entry
333                    .name()
334                    .map(|name| name.value() == "subject" || name.value() == "subjects")
335                    .unwrap_or(false)
336            }) {
337                return Err(ParseError::spanless(
338                    ParseErrorCode::InvalidPropertyValue,
339                    "diagnostics scoped subjects must be positional strings after the \
340                     diagnostic code, e.g. `allow \"layout.off_canvas\" \"bg.glow\"`",
341                ));
342            }
343            entries.push(PolicyEntry {
344                verb,
345                code,
346                subjects,
347                source_span: node_span(child),
348            });
349        }
350    }
351    Ok(DiagnosticPolicy { entries })
352}
353
354// ---------------------------------------------------------------------------
355// Brand contract
356// ---------------------------------------------------------------------------
357
358/// Transform the document-level `brand { … }` block into a [`BrandContract`].
359///
360/// Each child node is one category constraint:
361/// - `colors "#hex1" "#hex2" …` — approved color hex strings.
362/// - `fonts  "Family One" "Family Two" …` — approved font-family names.
363/// - `weights 400 700 …` — approved font weight integers (100–900).
364///
365/// Absent child = unconstrained for that category. Unknown children are
366/// silently ignored (forward-compat). Declaration order within each child
367/// node's arguments is preserved.
368///
369/// Errors:
370/// - A `colors` or `fonts` argument that is not a KDL string → [`ParseError`].
371/// - A `weights` argument that is not an integer → [`ParseError`].
372/// - A `weights` argument that is out of the 100–900 range → [`ParseError`].
373pub(crate) fn transform_brand_contract(node: &KdlNode) -> Result<BrandContract, ParseError> {
374    let source_span = node_span(node);
375    let mut allowed_colors: Option<Vec<String>> = None;
376    let mut allowed_fonts: Option<Vec<String>> = None;
377    let mut allowed_weights: Option<Vec<u32>> = None;
378
379    if let Some(children) = node.children() {
380        for child in children.nodes() {
381            match child.name().value() {
382                "colors" => {
383                    let mut colors: Vec<String> = Vec::new();
384                    // Only positional arguments (name is None) are color values.
385                    let positional: Vec<_> = child
386                        .entries()
387                        .iter()
388                        .filter(|e| e.name().is_none())
389                        .collect();
390                    for (idx, entry) in positional.iter().enumerate() {
391                        match entry.value() {
392                            KdlValue::String(s) => {
393                                // Store hex strings in lowercase so comparisons
394                                // are case-insensitive without repeated conversion.
395                                colors.push(s.to_lowercase());
396                            }
397                            _ => {
398                                return Err(ParseError::spanless(
399                                    ParseErrorCode::InvalidPropertyValue,
400                                    format!(
401                                        "brand `colors` argument {idx} must be a quoted string \
402                                         (hex color), e.g. `colors \"#0b1f33\" \"#ffffff\"`"
403                                    ),
404                                ));
405                            }
406                        }
407                    }
408                    allowed_colors = Some(colors);
409                }
410                "fonts" => {
411                    let mut fonts: Vec<String> = Vec::new();
412                    let positional: Vec<_> = child
413                        .entries()
414                        .iter()
415                        .filter(|e| e.name().is_none())
416                        .collect();
417                    for (idx, entry) in positional.iter().enumerate() {
418                        match entry.value() {
419                            KdlValue::String(s) => {
420                                fonts.push(s.clone());
421                            }
422                            _ => {
423                                return Err(ParseError::spanless(
424                                    ParseErrorCode::InvalidPropertyValue,
425                                    format!(
426                                        "brand `fonts` argument {idx} must be a quoted string \
427                                         (font-family name), e.g. `fonts \"Noto Sans\"`"
428                                    ),
429                                ));
430                            }
431                        }
432                    }
433                    allowed_fonts = Some(fonts);
434                }
435                "weights" => {
436                    let mut weights: Vec<u32> = Vec::new();
437                    let positional: Vec<_> = child
438                        .entries()
439                        .iter()
440                        .filter(|e| e.name().is_none())
441                        .collect();
442                    for (idx, entry) in positional.iter().enumerate() {
443                        match entry.value() {
444                            KdlValue::Integer(n) => {
445                                // KDL integers are i128; we need a u32 in 100..=900.
446                                let n_val = *n;
447                                if !(100..=900).contains(&n_val) {
448                                    return Err(ParseError::spanless(
449                                        ParseErrorCode::InvalidPropertyValue,
450                                        format!(
451                                            "brand `weights` argument {idx} must be an integer \
452                                             in the range 100-900 (got {n_val})"
453                                        ),
454                                    ));
455                                }
456                                // Infallible: 100..=900 fits in u32 and is positive.
457                                let w = u32::try_from(n_val).map_err(|_| {
458                                    ParseError::spanless(
459                                        ParseErrorCode::InvalidPropertyValue,
460                                        format!(
461                                            "brand `weights` argument {idx} is out of range \
462                                             for a u32 weight (got {n_val})"
463                                        ),
464                                    )
465                                })?;
466                                weights.push(w);
467                            }
468                            _ => {
469                                return Err(ParseError::spanless(
470                                    ParseErrorCode::InvalidPropertyValue,
471                                    format!(
472                                        "brand `weights` argument {idx} must be an integer \
473                                         (font weight), e.g. `weights 400 700`"
474                                    ),
475                                ));
476                            }
477                        }
478                    }
479                    allowed_weights = Some(weights);
480                }
481                // Unknown children are silently ignored (forward-compat).
482                _ => {}
483            }
484        }
485    }
486
487    Ok(BrandContract {
488        allowed_colors,
489        allowed_fonts,
490        allowed_weights,
491        source_span,
492    })
493}
494
495// ---------------------------------------------------------------------------
496// Masters
497// ---------------------------------------------------------------------------
498
499/// Transform the document-level `masters { … }` block into a list of
500/// [`MasterDef`]. Each `master id="..." { <child nodes> }` becomes one
501/// definition whose children are parsed exactly like page/group children (via
502/// [`transform_node`]). Non-`master` children inside the block are silently
503/// ignored (forward-compat). Mirrors [`transform_components`].
504fn transform_masters(node: &KdlNode) -> Result<Vec<MasterDef>, ParseError> {
505    let mut defs: Vec<MasterDef> = Vec::new();
506    if let Some(children) = node.children() {
507        for child in children.nodes() {
508            if child.name().value() == "master" {
509                defs.push(transform_master_def(child)?);
510            }
511        }
512    }
513    Ok(defs)
514}
515
516fn transform_master_def(node: &KdlNode) -> Result<MasterDef, ParseError> {
517    let id = required_string_prop(node, "id")?.to_owned();
518    let children = transform_children(node)?;
519    Ok(MasterDef {
520        id,
521        children,
522        source_span: node_span(node),
523    })
524}
525
526// ---------------------------------------------------------------------------
527// Sections
528// ---------------------------------------------------------------------------
529
530/// Transform the document-level `sections { … }` block into a list of
531/// [`SectionDef`]. Each `section id="…" name="…" start-page="…" …` is a leaf
532/// marker (it takes no children); non-`section` children inside the block are
533/// silently ignored (forward-compat). Mirrors [`transform_masters`].
534fn transform_sections(node: &KdlNode) -> Result<Vec<SectionDef>, ParseError> {
535    let mut defs: Vec<SectionDef> = Vec::new();
536    if let Some(children) = node.children() {
537        for child in children.nodes() {
538            if child.name().value() == "section" {
539                defs.push(transform_section_def(child)?);
540            }
541        }
542    }
543    Ok(defs)
544}
545
546fn transform_section_def(node: &KdlNode) -> Result<SectionDef, ParseError> {
547    let id = required_string_prop(node, "id")?.to_owned();
548    let name = required_string_prop(node, "name")?.to_owned();
549    let start_page = required_string_prop_aliased(node, "start-page", "start_page")?.to_owned();
550
551    // `folio-start` / `folio_start`: optional non-negative integer.
552    // `optional_u32_prop` silently drops negative or non-integer values, which
553    // is the same forward-compat posture used for other optional integer props
554    // (e.g. `tab-width`, `page-parity-start`).
555    let folio_start = optional_u32_prop(node, "folio-start")
556        .or_else(|| optional_u32_prop(node, "folio_start"))
557        .map(|n| n as usize);
558
559    // `folio-style` / `folio_style`: optional string.
560    let folio_style =
561        optional_string_prop_aliased(node, "folio-style", "folio_style").map(str::to_owned);
562
563    Ok(SectionDef {
564        id,
565        name,
566        folio_start,
567        folio_style,
568        start_page,
569        source_span: node_span(node),
570    })
571}
572
573// ---------------------------------------------------------------------------
574// Libraries
575// ---------------------------------------------------------------------------
576
577const LIBRARY_KNOWN_PROPS: &[&str] = &["id", "version", "hash"];
578
579/// Transform the document-level `libraries { … }` block into a list of
580/// [`LibraryDef`]. Each `library id="…" version="…" hash="…"` is a leaf marker
581/// (it takes no children); non-`library` children inside the block are silently
582/// ignored (forward-compat). Mirrors [`transform_sections`].
583fn transform_libraries(node: &KdlNode) -> Result<Vec<LibraryDef>, ParseError> {
584    let mut defs: Vec<LibraryDef> = Vec::new();
585    if let Some(children) = node.children() {
586        for child in children.nodes() {
587            if child.name().value() == "library" {
588                defs.push(transform_library_def(child)?);
589            }
590        }
591    }
592    Ok(defs)
593}
594
595fn transform_library_def(node: &KdlNode) -> Result<LibraryDef, ParseError> {
596    let id = required_string_prop(node, "id")?.to_owned();
597    let version = optional_string_prop(node, "version").map(str::to_owned);
598    let hash = optional_string_prop(node, "hash").map(str::to_owned);
599    let unknown_props = collect_unknown_props(node, LIBRARY_KNOWN_PROPS);
600    let source_span = node_span(node);
601
602    Ok(LibraryDef {
603        id,
604        version,
605        hash,
606        source_span,
607        unknown_props,
608    })
609}
610
611// ---------------------------------------------------------------------------
612// Actions
613// ---------------------------------------------------------------------------
614
615const ACTION_KNOWN_PROPS: &[&str] = &["id", "label", "version"];
616
617/// Transform the document-level `actions { … }` block into a list of
618/// [`ActionDef`]. Each `action id="…" label="…" version="…" { tx "…" }` is a
619/// block node whose `tx` child carries the opaque JSON payload as a positional
620/// string argument; non-`action` children inside the block are silently ignored
621/// (forward-compat). Mirrors [`transform_libraries`].
622fn transform_actions(node: &KdlNode) -> Result<Vec<ActionDef>, ParseError> {
623    let mut defs: Vec<ActionDef> = Vec::new();
624    if let Some(children) = node.children() {
625        for child in children.nodes() {
626            if child.name().value() == "action" {
627                defs.push(transform_action_def(child)?);
628            }
629        }
630    }
631    Ok(defs)
632}
633
634fn transform_action_def(node: &KdlNode) -> Result<ActionDef, ParseError> {
635    let id = required_string_prop(node, "id")?.to_owned();
636    let label = optional_string_prop(node, "label").map(str::to_owned);
637    let version = optional_string_prop(node, "version").map(str::to_owned);
638    let unknown_props = collect_unknown_props(node, ACTION_KNOWN_PROPS);
639    let source_span = node_span(node);
640
641    // The `tx_json` payload lives in a `tx` child node whose first positional
642    // argument is the decoded string. Exactly like `content` in CodeNode, the
643    // value is stored decoded; the writer re-encodes it on format.
644    let tx_json = node
645        .children()
646        .and_then(|doc| {
647            doc.nodes().iter().find_map(|child| {
648                if child.name().value() != "tx" {
649                    return None;
650                }
651                child.get(0).and_then(|v| match v {
652                    KdlValue::String(s) => Some(s.clone()),
653                    _ => None,
654                })
655            })
656        })
657        .ok_or_else(|| {
658            ParseError::spanless(
659                ParseErrorCode::InvalidPropertyValue,
660                format!("node `action` id=\"{id}\" is missing required `tx` child node"),
661            )
662        })?;
663
664    Ok(ActionDef {
665        id,
666        label,
667        version,
668        tx_json,
669        source_span,
670        unknown_props,
671    })
672}
673
674// ---------------------------------------------------------------------------
675// Provenance
676// ---------------------------------------------------------------------------
677
678const PROVENANCE_KNOWN_PROPS: &[&str] = &["id", "node", "library", "item", "linked"];
679
680/// Transform the document-level `provenance { … }` block into a list of
681/// [`ProvenanceDef`]. Each `origin id="…" node="…" library="…" …` is a leaf
682/// marker (it takes no children); non-`origin` children inside the block are
683/// silently ignored (forward-compat). Mirrors [`transform_libraries`].
684fn transform_provenance(node: &KdlNode) -> Result<Vec<ProvenanceDef>, ParseError> {
685    let mut defs: Vec<ProvenanceDef> = Vec::new();
686    if let Some(children) = node.children() {
687        for child in children.nodes() {
688            if child.name().value() == "origin" {
689                defs.push(transform_provenance_def(child)?);
690            }
691        }
692    }
693    Ok(defs)
694}
695
696fn transform_provenance_def(node: &KdlNode) -> Result<ProvenanceDef, ParseError> {
697    let id = required_string_prop(node, "id")?.to_owned();
698    let document_node = required_string_prop(node, "node")?.to_owned();
699    let library = required_string_prop(node, "library")?.to_owned();
700    let item = optional_string_prop(node, "item").map(str::to_owned);
701    let linked = optional_bool_prop(node, "linked");
702    let unknown_props = collect_unknown_props(node, PROVENANCE_KNOWN_PROPS);
703    let source_span = node_span(node);
704
705    Ok(ProvenanceDef {
706        id,
707        node: document_node,
708        library,
709        item,
710        linked,
711        source_span,
712        unknown_props,
713    })
714}
715
716// ---------------------------------------------------------------------------
717// Variants
718// ---------------------------------------------------------------------------
719
720const VARIANT_KNOWN_PROPS: &[&str] = &["id", "source", "w", "h"];
721const VARIANT_OVERRIDE_KNOWN_PROPS: &[&str] =
722    &["node", "visible", "x", "y", "w", "h", "fill", "text"];
723
724/// Transform the document-level `variants { … }` block into a list of
725/// [`VariantDef`]. Each `variant id="…" source="…" w=(px)N h=(px)N { … }` is
726/// a block node; non-`variant` children inside the block are silently ignored
727/// (forward-compat). Mirrors [`transform_provenance`].
728fn transform_variants(node: &KdlNode) -> Result<Vec<VariantDef>, ParseError> {
729    let mut defs: Vec<VariantDef> = Vec::new();
730    if let Some(children) = node.children() {
731        for child in children.nodes() {
732            if child.name().value() == "variant" {
733                defs.push(transform_variant_def(child)?);
734            }
735        }
736    }
737    Ok(defs)
738}
739
740fn transform_variant_def(node: &KdlNode) -> Result<VariantDef, ParseError> {
741    let id = required_string_prop(node, "id")?.to_owned();
742    let source = required_string_prop(node, "source")?.to_owned();
743
744    let w = node
745        .entry("w")
746        .ok_or_else(|| {
747            ParseError::spanless(
748                ParseErrorCode::InvalidPropertyValue,
749                format!("variant `{id}` is missing required property `w`"),
750            )
751        })
752        .and_then(|e| entry_to_dimension(e, "w"))?;
753
754    let h = node
755        .entry("h")
756        .ok_or_else(|| {
757            ParseError::spanless(
758                ParseErrorCode::InvalidPropertyValue,
759                format!("variant `{id}` is missing required property `h`"),
760            )
761        })
762        .and_then(|e| entry_to_dimension(e, "h"))?;
763
764    let unknown_props = collect_unknown_props(node, VARIANT_KNOWN_PROPS);
765    let source_span = node_span(node);
766
767    let mut overrides: Vec<VariantOverride> = Vec::new();
768    if let Some(children) = node.children() {
769        for child in children.nodes() {
770            if child.name().value() == "override" {
771                overrides.push(transform_variant_override(child)?);
772            }
773        }
774    }
775
776    Ok(VariantDef {
777        id,
778        source,
779        w,
780        h,
781        overrides,
782        source_span,
783        unknown_props,
784    })
785}
786
787fn transform_variant_override(node: &KdlNode) -> Result<VariantOverride, ParseError> {
788    let target_node = required_string_prop(node, "node")?.to_owned();
789    let visible = optional_bool_prop(node, "visible");
790    let x = optional_dimension_prop(node, "x");
791    let y = optional_dimension_prop(node, "y");
792    let w = optional_dimension_prop(node, "w");
793    let h = optional_dimension_prop(node, "h");
794    let fill = node
795        .entry("fill")
796        .and_then(|e| entry_to_property_value(e).ok());
797    let text = optional_string_prop(node, "text").map(str::to_owned);
798    let unknown_props = collect_unknown_props(node, VARIANT_OVERRIDE_KNOWN_PROPS);
799    let source_span = node_span(node);
800
801    Ok(VariantOverride {
802        node: target_node,
803        visible,
804        x,
805        y,
806        w,
807        h,
808        fill,
809        text,
810        source_span,
811        unknown_props,
812    })
813}
814
815// ---------------------------------------------------------------------------
816// Recipes
817// ---------------------------------------------------------------------------
818
819const RECIPE_KNOWN_PROPS: &[&str] = &["id", "kind", "seed", "generator", "bounds", "detached"];
820const RECIPE_PARAM_KNOWN_PROPS: &[&str] = &["name", "value"];
821
822/// Transform the document-level `recipes { … }` block into a list of
823/// [`RecipeDef`]. Each `recipe id="…" kind="…" …` is a block node; non-`recipe`
824/// children inside the block are silently ignored (forward-compat). Mirrors
825/// [`transform_variants`].
826fn transform_recipes(node: &KdlNode) -> Result<Vec<RecipeDef>, ParseError> {
827    let mut defs: Vec<RecipeDef> = Vec::new();
828    if let Some(children) = node.children() {
829        for child in children.nodes() {
830            if child.name().value() == "recipe" {
831                defs.push(transform_recipe_def(child)?);
832            }
833        }
834    }
835    Ok(defs)
836}
837
838fn transform_recipe_def(node: &KdlNode) -> Result<RecipeDef, ParseError> {
839    let id = required_string_prop(node, "id")?.to_owned();
840    let kind = required_string_prop(node, "kind")?.to_owned();
841
842    // Optional integer seed: negative seeds are valid, so read as i64 not u32.
843    let seed = optional_i64_prop(node, "seed");
844
845    let generator = optional_string_prop(node, "generator").map(str::to_owned);
846    let bounds = optional_string_prop(node, "bounds").map(str::to_owned);
847    let detached = optional_bool_prop(node, "detached");
848
849    let unknown_props = collect_unknown_props(node, RECIPE_KNOWN_PROPS);
850    let source_span = node_span(node);
851
852    let mut params: Vec<RecipeParam> = Vec::new();
853    let mut palette: Vec<String> = Vec::new();
854    let mut expanded: Vec<String> = Vec::new();
855
856    if let Some(children) = node.children() {
857        for child in children.nodes() {
858            match child.name().value() {
859                "param" => {
860                    params.push(transform_recipe_param(child)?);
861                }
862                "palette" => {
863                    palette.push(required_string_prop(child, "token")?.to_owned());
864                }
865                "expanded" => {
866                    expanded.push(required_string_prop(child, "node")?.to_owned());
867                }
868                _ => {}
869            }
870        }
871    }
872
873    Ok(RecipeDef {
874        id,
875        kind,
876        seed,
877        generator,
878        bounds,
879        detached,
880        params,
881        palette,
882        expanded,
883        source_span,
884        unknown_props,
885    })
886}
887
888fn transform_recipe_param(node: &KdlNode) -> Result<RecipeParam, ParseError> {
889    let name = required_string_prop(node, "name")?.to_owned();
890    let value = node
891        .entry("value")
892        .ok_or_else(|| {
893            ParseError::spanless(
894                ParseErrorCode::InvalidPropertyValue,
895                format!("recipe `param` `{name}` is missing required property `value`"),
896            )
897        })
898        .and_then(entry_to_property_value)?;
899    let unknown_props = collect_unknown_props(node, RECIPE_PARAM_KNOWN_PROPS);
900    let source_span = node_span(node);
901
902    Ok(RecipeParam {
903        name,
904        value,
905        source_span,
906        unknown_props,
907    })
908}
909
910// ---------------------------------------------------------------------------
911// Components
912// ---------------------------------------------------------------------------
913
914/// Transform the document-level `components { … }` block into a list of
915/// [`ComponentDef`]. Each `component id="..." { <child nodes> }` becomes one
916/// definition whose children are parsed exactly like page/group children (via
917/// [`transform_node`]). Non-`component` children inside the block are silently
918/// ignored (forward-compat).
919fn transform_components(node: &KdlNode) -> Result<Vec<ComponentDef>, ParseError> {
920    let mut defs: Vec<ComponentDef> = Vec::new();
921    if let Some(children) = node.children() {
922        for child in children.nodes() {
923            if child.name().value() == "component" {
924                defs.push(transform_component_def(child)?);
925            }
926        }
927    }
928    Ok(defs)
929}
930
931fn transform_component_def(node: &KdlNode) -> Result<ComponentDef, ParseError> {
932    let id = required_string_prop(node, "id")?.to_owned();
933    let children = transform_children(node)?;
934    Ok(ComponentDef {
935        id,
936        children,
937        source_span: node_span(node),
938    })
939}
940
941// ---------------------------------------------------------------------------
942// Project
943// ---------------------------------------------------------------------------
944
945fn transform_project(node: &KdlNode) -> Result<Project, ParseError> {
946    let id = required_string_prop(node, "id")?.to_owned();
947    let name = required_string_prop(node, "name")?.to_owned();
948    let author = node.children().and_then(|doc| {
949        doc.nodes()
950            .iter()
951            .find(|n| n.name().value() == "author")
952            .and_then(|n| n.get(0))
953            .and_then(|v| {
954                if let KdlValue::String(s) = v {
955                    Some(s.clone())
956                } else {
957                    None
958                }
959            })
960    });
961    Ok(Project { id, name, author })
962}
963
964// ---------------------------------------------------------------------------
965// Assets
966// ---------------------------------------------------------------------------
967
968/// Canonical set of property names recognised on an `asset` declaration node.
969///
970/// Used by `zenith-core::schema` to surface the authorable attribute list for
971/// the `zenith schema asset` subcommand.
972pub(crate) const ASSET_KNOWN_PROPS: &[&str] = &[
973    "id",
974    "kind",
975    "src",
976    "sha256",
977    "ai-prompt",
978    "ai-model",
979    "ai-provider",
980    "ai-seed",
981    "ai-generation-date",
982    "ai-license",
983    "ai-source-rights",
984    "ai-safety-status",
985    "ai-reuse-policy",
986];
987
988fn transform_assets(node: &KdlNode) -> Result<AssetBlock, ParseError> {
989    let source_span = node_span(node);
990    let mut asset_list: Vec<AssetDecl> = Vec::new();
991
992    if let Some(children) = node.children() {
993        for child in children.nodes() {
994            if child.name().value() == "asset" {
995                asset_list.push(transform_asset_decl(child)?);
996            }
997            // Non-`asset` child nodes inside assets block are silently ignored
998            // (forward-compat).
999        }
1000    }
1001
1002    Ok(AssetBlock {
1003        assets: asset_list,
1004        source_span,
1005    })
1006}
1007
1008fn transform_asset_decl(node: &KdlNode) -> Result<AssetDecl, ParseError> {
1009    let id = required_string_prop(node, "id")?.to_owned();
1010    let kind_str = required_string_prop(node, "kind")?;
1011    let kind = AssetKind::from_kind_str(kind_str);
1012    let src = required_string_prop(node, "src")?.to_owned();
1013    let sha256 = optional_string_prop(node, "sha256").map(str::to_owned);
1014    let ai_prompt = optional_string_prop(node, "ai-prompt").map(str::to_owned);
1015    let ai_model = optional_string_prop(node, "ai-model").map(str::to_owned);
1016    let ai_provider = optional_string_prop(node, "ai-provider").map(str::to_owned);
1017    let ai_seed = optional_i64_prop(node, "ai-seed");
1018    let ai_generation_date = optional_string_prop(node, "ai-generation-date").map(str::to_owned);
1019    let ai_license = optional_string_prop(node, "ai-license").map(str::to_owned);
1020    let ai_source_rights = optional_string_prop(node, "ai-source-rights").map(str::to_owned);
1021    let ai_safety_status = optional_string_prop(node, "ai-safety-status").map(str::to_owned);
1022    let ai_reuse_policy = optional_string_prop(node, "ai-reuse-policy").map(str::to_owned);
1023    let unknown_props = collect_unknown_props(node, ASSET_KNOWN_PROPS);
1024    let source_span = node_span(node);
1025
1026    Ok(AssetDecl {
1027        id,
1028        kind,
1029        src,
1030        sha256,
1031        ai_prompt,
1032        ai_model,
1033        ai_provider,
1034        ai_seed,
1035        ai_generation_date,
1036        ai_license,
1037        ai_source_rights,
1038        ai_safety_status,
1039        ai_reuse_policy,
1040        source_span,
1041        unknown_props,
1042    })
1043}
1044
1045// ---------------------------------------------------------------------------
1046// Document body / pages
1047// ---------------------------------------------------------------------------
1048
1049fn transform_document_body(node: &KdlNode) -> Result<DocumentBody, ParseError> {
1050    let id = required_string_prop(node, "id")?.to_owned();
1051    let title = optional_string_prop(node, "title").map(str::to_owned);
1052
1053    let mut block_styles: Vec<BlockStyle> = Vec::new();
1054    let mut pages: Vec<Page> = Vec::new();
1055    if let Some(children) = node.children() {
1056        for child in children.nodes() {
1057            match child.name().value() {
1058                "block" => block_styles.push(transform_block_style(child)?),
1059                "page" => pages.push(transform_page(child)?),
1060                _ => {}
1061            }
1062        }
1063    }
1064
1065    Ok(DocumentBody {
1066        id,
1067        title,
1068        block_styles,
1069        pages,
1070    })
1071}
1072
1073/// Iterate a KDL node's children block and transform each child into a
1074/// [`Node`].  Returns an empty `Vec` when the node has no children block.
1075///
1076/// Both `transform_page` and `transform_group` use this helper to avoid
1077/// duplicating the child-iteration logic.
1078///
1079/// # Known limitation
1080/// Groups nest recursively via `transform_node` → `transform_group` →
1081/// `transform_children` with no depth guard.  This is an accepted v0
1082/// limitation; stack overflow is only possible with pathologically deep trees.
1083pub(super) fn transform_children(node: &KdlNode) -> Result<Vec<Node>, ParseError> {
1084    let mut children: Vec<Node> = Vec::new();
1085    if let Some(doc) = node.children() {
1086        for child in doc.nodes() {
1087            children.push(transform_node(child)?);
1088        }
1089    }
1090    Ok(children)
1091}