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///   deny  "token.unused"
277///   warn  "node.unknown_property"
278/// }
279/// ```
280///
281/// A child whose name is not a recognized verb is silently ignored
282/// (forward-compat — same posture as every other document block). A recognized
283/// verb whose code argument is missing or non-string is a hard [`ParseError`]
284/// (the entry is meaningless without a code). Declaration order is preserved;
285/// last-wins resolution happens at consult time (see [`DiagnosticPolicy::verb_for`]).
286pub(crate) fn transform_diagnostic_policy(node: &KdlNode) -> Result<DiagnosticPolicy, ParseError> {
287    let mut entries: Vec<PolicyEntry> = Vec::new();
288    if let Some(children) = node.children() {
289        for child in children.nodes() {
290            let (verb, verb_name) = match child.name().value() {
291                "allow" => (PolicyVerb::Allow, "allow"),
292                "deny" => (PolicyVerb::Deny, "deny"),
293                "warn" => (PolicyVerb::Warn, "warn"),
294                // Unknown verb → ignore (forward-compat).
295                _ => continue,
296            };
297            let code = match child.get(0) {
298                Some(KdlValue::String(s)) => s.clone(),
299                _ => {
300                    return Err(ParseError::spanless(
301                        ParseErrorCode::InvalidPropertyValue,
302                        format!(
303                            "diagnostics `{verb_name}` entry requires a quoted diagnostic-code \
304                             string as its first argument, e.g. `{verb_name} \"layout.off_canvas\"`"
305                        ),
306                    ));
307                }
308            };
309            entries.push(PolicyEntry {
310                verb,
311                code,
312                source_span: node_span(child),
313            });
314        }
315    }
316    Ok(DiagnosticPolicy { entries })
317}
318
319// ---------------------------------------------------------------------------
320// Brand contract
321// ---------------------------------------------------------------------------
322
323/// Transform the document-level `brand { … }` block into a [`BrandContract`].
324///
325/// Each child node is one category constraint:
326/// - `colors "#hex1" "#hex2" …` — approved color hex strings.
327/// - `fonts  "Family One" "Family Two" …` — approved font-family names.
328/// - `weights 400 700 …` — approved font weight integers (100–900).
329///
330/// Absent child = unconstrained for that category. Unknown children are
331/// silently ignored (forward-compat). Declaration order within each child
332/// node's arguments is preserved.
333///
334/// Errors:
335/// - A `colors` or `fonts` argument that is not a KDL string → [`ParseError`].
336/// - A `weights` argument that is not an integer → [`ParseError`].
337/// - A `weights` argument that is out of the 100–900 range → [`ParseError`].
338pub(crate) fn transform_brand_contract(node: &KdlNode) -> Result<BrandContract, ParseError> {
339    let source_span = node_span(node);
340    let mut allowed_colors: Option<Vec<String>> = None;
341    let mut allowed_fonts: Option<Vec<String>> = None;
342    let mut allowed_weights: Option<Vec<u32>> = None;
343
344    if let Some(children) = node.children() {
345        for child in children.nodes() {
346            match child.name().value() {
347                "colors" => {
348                    let mut colors: Vec<String> = Vec::new();
349                    // Only positional arguments (name is None) are color values.
350                    let positional: Vec<_> = child
351                        .entries()
352                        .iter()
353                        .filter(|e| e.name().is_none())
354                        .collect();
355                    for (idx, entry) in positional.iter().enumerate() {
356                        match entry.value() {
357                            KdlValue::String(s) => {
358                                // Store hex strings in lowercase so comparisons
359                                // are case-insensitive without repeated conversion.
360                                colors.push(s.to_lowercase());
361                            }
362                            _ => {
363                                return Err(ParseError::spanless(
364                                    ParseErrorCode::InvalidPropertyValue,
365                                    format!(
366                                        "brand `colors` argument {idx} must be a quoted string \
367                                         (hex color), e.g. `colors \"#0b1f33\" \"#ffffff\"`"
368                                    ),
369                                ));
370                            }
371                        }
372                    }
373                    allowed_colors = Some(colors);
374                }
375                "fonts" => {
376                    let mut fonts: Vec<String> = Vec::new();
377                    let positional: Vec<_> = child
378                        .entries()
379                        .iter()
380                        .filter(|e| e.name().is_none())
381                        .collect();
382                    for (idx, entry) in positional.iter().enumerate() {
383                        match entry.value() {
384                            KdlValue::String(s) => {
385                                fonts.push(s.clone());
386                            }
387                            _ => {
388                                return Err(ParseError::spanless(
389                                    ParseErrorCode::InvalidPropertyValue,
390                                    format!(
391                                        "brand `fonts` argument {idx} must be a quoted string \
392                                         (font-family name), e.g. `fonts \"Noto Sans\"`"
393                                    ),
394                                ));
395                            }
396                        }
397                    }
398                    allowed_fonts = Some(fonts);
399                }
400                "weights" => {
401                    let mut weights: Vec<u32> = Vec::new();
402                    let positional: Vec<_> = child
403                        .entries()
404                        .iter()
405                        .filter(|e| e.name().is_none())
406                        .collect();
407                    for (idx, entry) in positional.iter().enumerate() {
408                        match entry.value() {
409                            KdlValue::Integer(n) => {
410                                // KDL integers are i128; we need a u32 in 100..=900.
411                                let n_val = *n;
412                                if !(100..=900).contains(&n_val) {
413                                    return Err(ParseError::spanless(
414                                        ParseErrorCode::InvalidPropertyValue,
415                                        format!(
416                                            "brand `weights` argument {idx} must be an integer \
417                                             in the range 100-900 (got {n_val})"
418                                        ),
419                                    ));
420                                }
421                                // Infallible: 100..=900 fits in u32 and is positive.
422                                let w = u32::try_from(n_val).map_err(|_| {
423                                    ParseError::spanless(
424                                        ParseErrorCode::InvalidPropertyValue,
425                                        format!(
426                                            "brand `weights` argument {idx} is out of range \
427                                             for a u32 weight (got {n_val})"
428                                        ),
429                                    )
430                                })?;
431                                weights.push(w);
432                            }
433                            _ => {
434                                return Err(ParseError::spanless(
435                                    ParseErrorCode::InvalidPropertyValue,
436                                    format!(
437                                        "brand `weights` argument {idx} must be an integer \
438                                         (font weight), e.g. `weights 400 700`"
439                                    ),
440                                ));
441                            }
442                        }
443                    }
444                    allowed_weights = Some(weights);
445                }
446                // Unknown children are silently ignored (forward-compat).
447                _ => {}
448            }
449        }
450    }
451
452    Ok(BrandContract {
453        allowed_colors,
454        allowed_fonts,
455        allowed_weights,
456        source_span,
457    })
458}
459
460// ---------------------------------------------------------------------------
461// Masters
462// ---------------------------------------------------------------------------
463
464/// Transform the document-level `masters { … }` block into a list of
465/// [`MasterDef`]. Each `master id="..." { <child nodes> }` becomes one
466/// definition whose children are parsed exactly like page/group children (via
467/// [`transform_node`]). Non-`master` children inside the block are silently
468/// ignored (forward-compat). Mirrors [`transform_components`].
469fn transform_masters(node: &KdlNode) -> Result<Vec<MasterDef>, ParseError> {
470    let mut defs: Vec<MasterDef> = Vec::new();
471    if let Some(children) = node.children() {
472        for child in children.nodes() {
473            if child.name().value() == "master" {
474                defs.push(transform_master_def(child)?);
475            }
476        }
477    }
478    Ok(defs)
479}
480
481fn transform_master_def(node: &KdlNode) -> Result<MasterDef, ParseError> {
482    let id = required_string_prop(node, "id")?.to_owned();
483    let children = transform_children(node)?;
484    Ok(MasterDef {
485        id,
486        children,
487        source_span: node_span(node),
488    })
489}
490
491// ---------------------------------------------------------------------------
492// Sections
493// ---------------------------------------------------------------------------
494
495/// Transform the document-level `sections { … }` block into a list of
496/// [`SectionDef`]. Each `section id="…" name="…" start-page="…" …` is a leaf
497/// marker (it takes no children); non-`section` children inside the block are
498/// silently ignored (forward-compat). Mirrors [`transform_masters`].
499fn transform_sections(node: &KdlNode) -> Result<Vec<SectionDef>, ParseError> {
500    let mut defs: Vec<SectionDef> = Vec::new();
501    if let Some(children) = node.children() {
502        for child in children.nodes() {
503            if child.name().value() == "section" {
504                defs.push(transform_section_def(child)?);
505            }
506        }
507    }
508    Ok(defs)
509}
510
511fn transform_section_def(node: &KdlNode) -> Result<SectionDef, ParseError> {
512    let id = required_string_prop(node, "id")?.to_owned();
513    let name = required_string_prop(node, "name")?.to_owned();
514    let start_page = required_string_prop_aliased(node, "start-page", "start_page")?.to_owned();
515
516    // `folio-start` / `folio_start`: optional non-negative integer.
517    // `optional_u32_prop` silently drops negative or non-integer values, which
518    // is the same forward-compat posture used for other optional integer props
519    // (e.g. `tab-width`, `page-parity-start`).
520    let folio_start = optional_u32_prop(node, "folio-start")
521        .or_else(|| optional_u32_prop(node, "folio_start"))
522        .map(|n| n as usize);
523
524    // `folio-style` / `folio_style`: optional string.
525    let folio_style =
526        optional_string_prop_aliased(node, "folio-style", "folio_style").map(str::to_owned);
527
528    Ok(SectionDef {
529        id,
530        name,
531        folio_start,
532        folio_style,
533        start_page,
534        source_span: node_span(node),
535    })
536}
537
538// ---------------------------------------------------------------------------
539// Libraries
540// ---------------------------------------------------------------------------
541
542const LIBRARY_KNOWN_PROPS: &[&str] = &["id", "version", "hash"];
543
544/// Transform the document-level `libraries { … }` block into a list of
545/// [`LibraryDef`]. Each `library id="…" version="…" hash="…"` is a leaf marker
546/// (it takes no children); non-`library` children inside the block are silently
547/// ignored (forward-compat). Mirrors [`transform_sections`].
548fn transform_libraries(node: &KdlNode) -> Result<Vec<LibraryDef>, ParseError> {
549    let mut defs: Vec<LibraryDef> = Vec::new();
550    if let Some(children) = node.children() {
551        for child in children.nodes() {
552            if child.name().value() == "library" {
553                defs.push(transform_library_def(child)?);
554            }
555        }
556    }
557    Ok(defs)
558}
559
560fn transform_library_def(node: &KdlNode) -> Result<LibraryDef, ParseError> {
561    let id = required_string_prop(node, "id")?.to_owned();
562    let version = optional_string_prop(node, "version").map(str::to_owned);
563    let hash = optional_string_prop(node, "hash").map(str::to_owned);
564    let unknown_props = collect_unknown_props(node, LIBRARY_KNOWN_PROPS);
565    let source_span = node_span(node);
566
567    Ok(LibraryDef {
568        id,
569        version,
570        hash,
571        source_span,
572        unknown_props,
573    })
574}
575
576// ---------------------------------------------------------------------------
577// Actions
578// ---------------------------------------------------------------------------
579
580const ACTION_KNOWN_PROPS: &[&str] = &["id", "label", "version"];
581
582/// Transform the document-level `actions { … }` block into a list of
583/// [`ActionDef`]. Each `action id="…" label="…" version="…" { tx "…" }` is a
584/// block node whose `tx` child carries the opaque JSON payload as a positional
585/// string argument; non-`action` children inside the block are silently ignored
586/// (forward-compat). Mirrors [`transform_libraries`].
587fn transform_actions(node: &KdlNode) -> Result<Vec<ActionDef>, ParseError> {
588    let mut defs: Vec<ActionDef> = Vec::new();
589    if let Some(children) = node.children() {
590        for child in children.nodes() {
591            if child.name().value() == "action" {
592                defs.push(transform_action_def(child)?);
593            }
594        }
595    }
596    Ok(defs)
597}
598
599fn transform_action_def(node: &KdlNode) -> Result<ActionDef, ParseError> {
600    let id = required_string_prop(node, "id")?.to_owned();
601    let label = optional_string_prop(node, "label").map(str::to_owned);
602    let version = optional_string_prop(node, "version").map(str::to_owned);
603    let unknown_props = collect_unknown_props(node, ACTION_KNOWN_PROPS);
604    let source_span = node_span(node);
605
606    // The `tx_json` payload lives in a `tx` child node whose first positional
607    // argument is the decoded string. Exactly like `content` in CodeNode, the
608    // value is stored decoded; the writer re-encodes it on format.
609    let tx_json = node
610        .children()
611        .and_then(|doc| {
612            doc.nodes().iter().find_map(|child| {
613                if child.name().value() != "tx" {
614                    return None;
615                }
616                child.get(0).and_then(|v| match v {
617                    KdlValue::String(s) => Some(s.clone()),
618                    _ => None,
619                })
620            })
621        })
622        .ok_or_else(|| {
623            ParseError::spanless(
624                ParseErrorCode::InvalidPropertyValue,
625                format!("node `action` id=\"{id}\" is missing required `tx` child node"),
626            )
627        })?;
628
629    Ok(ActionDef {
630        id,
631        label,
632        version,
633        tx_json,
634        source_span,
635        unknown_props,
636    })
637}
638
639// ---------------------------------------------------------------------------
640// Provenance
641// ---------------------------------------------------------------------------
642
643const PROVENANCE_KNOWN_PROPS: &[&str] = &["id", "node", "library", "item", "linked"];
644
645/// Transform the document-level `provenance { … }` block into a list of
646/// [`ProvenanceDef`]. Each `origin id="…" node="…" library="…" …` is a leaf
647/// marker (it takes no children); non-`origin` children inside the block are
648/// silently ignored (forward-compat). Mirrors [`transform_libraries`].
649fn transform_provenance(node: &KdlNode) -> Result<Vec<ProvenanceDef>, ParseError> {
650    let mut defs: Vec<ProvenanceDef> = Vec::new();
651    if let Some(children) = node.children() {
652        for child in children.nodes() {
653            if child.name().value() == "origin" {
654                defs.push(transform_provenance_def(child)?);
655            }
656        }
657    }
658    Ok(defs)
659}
660
661fn transform_provenance_def(node: &KdlNode) -> Result<ProvenanceDef, ParseError> {
662    let id = required_string_prop(node, "id")?.to_owned();
663    let document_node = required_string_prop(node, "node")?.to_owned();
664    let library = required_string_prop(node, "library")?.to_owned();
665    let item = optional_string_prop(node, "item").map(str::to_owned);
666    let linked = optional_bool_prop(node, "linked");
667    let unknown_props = collect_unknown_props(node, PROVENANCE_KNOWN_PROPS);
668    let source_span = node_span(node);
669
670    Ok(ProvenanceDef {
671        id,
672        node: document_node,
673        library,
674        item,
675        linked,
676        source_span,
677        unknown_props,
678    })
679}
680
681// ---------------------------------------------------------------------------
682// Variants
683// ---------------------------------------------------------------------------
684
685const VARIANT_KNOWN_PROPS: &[&str] = &["id", "source", "w", "h"];
686const VARIANT_OVERRIDE_KNOWN_PROPS: &[&str] =
687    &["node", "visible", "x", "y", "w", "h", "fill", "text"];
688
689/// Transform the document-level `variants { … }` block into a list of
690/// [`VariantDef`]. Each `variant id="…" source="…" w=(px)N h=(px)N { … }` is
691/// a block node; non-`variant` children inside the block are silently ignored
692/// (forward-compat). Mirrors [`transform_provenance`].
693fn transform_variants(node: &KdlNode) -> Result<Vec<VariantDef>, ParseError> {
694    let mut defs: Vec<VariantDef> = Vec::new();
695    if let Some(children) = node.children() {
696        for child in children.nodes() {
697            if child.name().value() == "variant" {
698                defs.push(transform_variant_def(child)?);
699            }
700        }
701    }
702    Ok(defs)
703}
704
705fn transform_variant_def(node: &KdlNode) -> Result<VariantDef, ParseError> {
706    let id = required_string_prop(node, "id")?.to_owned();
707    let source = required_string_prop(node, "source")?.to_owned();
708
709    let w = node
710        .entry("w")
711        .ok_or_else(|| {
712            ParseError::spanless(
713                ParseErrorCode::InvalidPropertyValue,
714                format!("variant `{id}` is missing required property `w`"),
715            )
716        })
717        .and_then(|e| entry_to_dimension(e, "w"))?;
718
719    let h = node
720        .entry("h")
721        .ok_or_else(|| {
722            ParseError::spanless(
723                ParseErrorCode::InvalidPropertyValue,
724                format!("variant `{id}` is missing required property `h`"),
725            )
726        })
727        .and_then(|e| entry_to_dimension(e, "h"))?;
728
729    let unknown_props = collect_unknown_props(node, VARIANT_KNOWN_PROPS);
730    let source_span = node_span(node);
731
732    let mut overrides: Vec<VariantOverride> = Vec::new();
733    if let Some(children) = node.children() {
734        for child in children.nodes() {
735            if child.name().value() == "override" {
736                overrides.push(transform_variant_override(child)?);
737            }
738        }
739    }
740
741    Ok(VariantDef {
742        id,
743        source,
744        w,
745        h,
746        overrides,
747        source_span,
748        unknown_props,
749    })
750}
751
752fn transform_variant_override(node: &KdlNode) -> Result<VariantOverride, ParseError> {
753    let target_node = required_string_prop(node, "node")?.to_owned();
754    let visible = optional_bool_prop(node, "visible");
755    let x = optional_dimension_prop(node, "x");
756    let y = optional_dimension_prop(node, "y");
757    let w = optional_dimension_prop(node, "w");
758    let h = optional_dimension_prop(node, "h");
759    let fill = node
760        .entry("fill")
761        .and_then(|e| entry_to_property_value(e).ok());
762    let text = optional_string_prop(node, "text").map(str::to_owned);
763    let unknown_props = collect_unknown_props(node, VARIANT_OVERRIDE_KNOWN_PROPS);
764    let source_span = node_span(node);
765
766    Ok(VariantOverride {
767        node: target_node,
768        visible,
769        x,
770        y,
771        w,
772        h,
773        fill,
774        text,
775        source_span,
776        unknown_props,
777    })
778}
779
780// ---------------------------------------------------------------------------
781// Recipes
782// ---------------------------------------------------------------------------
783
784const RECIPE_KNOWN_PROPS: &[&str] = &["id", "kind", "seed", "generator", "bounds", "detached"];
785const RECIPE_PARAM_KNOWN_PROPS: &[&str] = &["name", "value"];
786
787/// Transform the document-level `recipes { … }` block into a list of
788/// [`RecipeDef`]. Each `recipe id="…" kind="…" …` is a block node; non-`recipe`
789/// children inside the block are silently ignored (forward-compat). Mirrors
790/// [`transform_variants`].
791fn transform_recipes(node: &KdlNode) -> Result<Vec<RecipeDef>, ParseError> {
792    let mut defs: Vec<RecipeDef> = Vec::new();
793    if let Some(children) = node.children() {
794        for child in children.nodes() {
795            if child.name().value() == "recipe" {
796                defs.push(transform_recipe_def(child)?);
797            }
798        }
799    }
800    Ok(defs)
801}
802
803fn transform_recipe_def(node: &KdlNode) -> Result<RecipeDef, ParseError> {
804    let id = required_string_prop(node, "id")?.to_owned();
805    let kind = required_string_prop(node, "kind")?.to_owned();
806
807    // Optional integer seed: negative seeds are valid, so read as i64 not u32.
808    let seed = optional_i64_prop(node, "seed");
809
810    let generator = optional_string_prop(node, "generator").map(str::to_owned);
811    let bounds = optional_string_prop(node, "bounds").map(str::to_owned);
812    let detached = optional_bool_prop(node, "detached");
813
814    let unknown_props = collect_unknown_props(node, RECIPE_KNOWN_PROPS);
815    let source_span = node_span(node);
816
817    let mut params: Vec<RecipeParam> = Vec::new();
818    let mut palette: Vec<String> = Vec::new();
819    let mut expanded: Vec<String> = Vec::new();
820
821    if let Some(children) = node.children() {
822        for child in children.nodes() {
823            match child.name().value() {
824                "param" => {
825                    params.push(transform_recipe_param(child)?);
826                }
827                "palette" => {
828                    palette.push(required_string_prop(child, "token")?.to_owned());
829                }
830                "expanded" => {
831                    expanded.push(required_string_prop(child, "node")?.to_owned());
832                }
833                _ => {}
834            }
835        }
836    }
837
838    Ok(RecipeDef {
839        id,
840        kind,
841        seed,
842        generator,
843        bounds,
844        detached,
845        params,
846        palette,
847        expanded,
848        source_span,
849        unknown_props,
850    })
851}
852
853fn transform_recipe_param(node: &KdlNode) -> Result<RecipeParam, ParseError> {
854    let name = required_string_prop(node, "name")?.to_owned();
855    let value = node
856        .entry("value")
857        .ok_or_else(|| {
858            ParseError::spanless(
859                ParseErrorCode::InvalidPropertyValue,
860                format!("recipe `param` `{name}` is missing required property `value`"),
861            )
862        })
863        .and_then(entry_to_property_value)?;
864    let unknown_props = collect_unknown_props(node, RECIPE_PARAM_KNOWN_PROPS);
865    let source_span = node_span(node);
866
867    Ok(RecipeParam {
868        name,
869        value,
870        source_span,
871        unknown_props,
872    })
873}
874
875// ---------------------------------------------------------------------------
876// Components
877// ---------------------------------------------------------------------------
878
879/// Transform the document-level `components { … }` block into a list of
880/// [`ComponentDef`]. Each `component id="..." { <child nodes> }` becomes one
881/// definition whose children are parsed exactly like page/group children (via
882/// [`transform_node`]). Non-`component` children inside the block are silently
883/// ignored (forward-compat).
884fn transform_components(node: &KdlNode) -> Result<Vec<ComponentDef>, ParseError> {
885    let mut defs: Vec<ComponentDef> = Vec::new();
886    if let Some(children) = node.children() {
887        for child in children.nodes() {
888            if child.name().value() == "component" {
889                defs.push(transform_component_def(child)?);
890            }
891        }
892    }
893    Ok(defs)
894}
895
896fn transform_component_def(node: &KdlNode) -> Result<ComponentDef, ParseError> {
897    let id = required_string_prop(node, "id")?.to_owned();
898    let children = transform_children(node)?;
899    Ok(ComponentDef {
900        id,
901        children,
902        source_span: node_span(node),
903    })
904}
905
906// ---------------------------------------------------------------------------
907// Project
908// ---------------------------------------------------------------------------
909
910fn transform_project(node: &KdlNode) -> Result<Project, ParseError> {
911    let id = required_string_prop(node, "id")?.to_owned();
912    let name = required_string_prop(node, "name")?.to_owned();
913    let author = node.children().and_then(|doc| {
914        doc.nodes()
915            .iter()
916            .find(|n| n.name().value() == "author")
917            .and_then(|n| n.get(0))
918            .and_then(|v| {
919                if let KdlValue::String(s) = v {
920                    Some(s.clone())
921                } else {
922                    None
923                }
924            })
925    });
926    Ok(Project { id, name, author })
927}
928
929// ---------------------------------------------------------------------------
930// Assets
931// ---------------------------------------------------------------------------
932
933/// Canonical set of property names recognised on an `asset` declaration node.
934///
935/// Used by `zenith-core::schema` to surface the authorable attribute list for
936/// the `zenith schema asset` subcommand.
937pub(crate) const ASSET_KNOWN_PROPS: &[&str] = &[
938    "id",
939    "kind",
940    "src",
941    "sha256",
942    "ai-prompt",
943    "ai-model",
944    "ai-provider",
945    "ai-seed",
946    "ai-generation-date",
947    "ai-license",
948    "ai-source-rights",
949    "ai-safety-status",
950    "ai-reuse-policy",
951];
952
953fn transform_assets(node: &KdlNode) -> Result<AssetBlock, ParseError> {
954    let source_span = node_span(node);
955    let mut asset_list: Vec<AssetDecl> = Vec::new();
956
957    if let Some(children) = node.children() {
958        for child in children.nodes() {
959            if child.name().value() == "asset" {
960                asset_list.push(transform_asset_decl(child)?);
961            }
962            // Non-`asset` child nodes inside assets block are silently ignored
963            // (forward-compat).
964        }
965    }
966
967    Ok(AssetBlock {
968        assets: asset_list,
969        source_span,
970    })
971}
972
973fn transform_asset_decl(node: &KdlNode) -> Result<AssetDecl, ParseError> {
974    let id = required_string_prop(node, "id")?.to_owned();
975    let kind_str = required_string_prop(node, "kind")?;
976    let kind = AssetKind::from_kind_str(kind_str);
977    let src = required_string_prop(node, "src")?.to_owned();
978    let sha256 = optional_string_prop(node, "sha256").map(str::to_owned);
979    let ai_prompt = optional_string_prop(node, "ai-prompt").map(str::to_owned);
980    let ai_model = optional_string_prop(node, "ai-model").map(str::to_owned);
981    let ai_provider = optional_string_prop(node, "ai-provider").map(str::to_owned);
982    let ai_seed = optional_i64_prop(node, "ai-seed");
983    let ai_generation_date = optional_string_prop(node, "ai-generation-date").map(str::to_owned);
984    let ai_license = optional_string_prop(node, "ai-license").map(str::to_owned);
985    let ai_source_rights = optional_string_prop(node, "ai-source-rights").map(str::to_owned);
986    let ai_safety_status = optional_string_prop(node, "ai-safety-status").map(str::to_owned);
987    let ai_reuse_policy = optional_string_prop(node, "ai-reuse-policy").map(str::to_owned);
988    let unknown_props = collect_unknown_props(node, ASSET_KNOWN_PROPS);
989    let source_span = node_span(node);
990
991    Ok(AssetDecl {
992        id,
993        kind,
994        src,
995        sha256,
996        ai_prompt,
997        ai_model,
998        ai_provider,
999        ai_seed,
1000        ai_generation_date,
1001        ai_license,
1002        ai_source_rights,
1003        ai_safety_status,
1004        ai_reuse_policy,
1005        source_span,
1006        unknown_props,
1007    })
1008}
1009
1010// ---------------------------------------------------------------------------
1011// Document body / pages
1012// ---------------------------------------------------------------------------
1013
1014fn transform_document_body(node: &KdlNode) -> Result<DocumentBody, ParseError> {
1015    let id = required_string_prop(node, "id")?.to_owned();
1016    let title = optional_string_prop(node, "title").map(str::to_owned);
1017
1018    let mut block_styles: Vec<BlockStyle> = Vec::new();
1019    let mut pages: Vec<Page> = Vec::new();
1020    if let Some(children) = node.children() {
1021        for child in children.nodes() {
1022            match child.name().value() {
1023                "block" => block_styles.push(transform_block_style(child)?),
1024                "page" => pages.push(transform_page(child)?),
1025                _ => {}
1026            }
1027        }
1028    }
1029
1030    Ok(DocumentBody {
1031        id,
1032        title,
1033        block_styles,
1034        pages,
1035    })
1036}
1037
1038/// Iterate a KDL node's children block and transform each child into a
1039/// [`Node`].  Returns an empty `Vec` when the node has no children block.
1040///
1041/// Both `transform_page` and `transform_group` use this helper to avoid
1042/// duplicating the child-iteration logic.
1043///
1044/// # Known limitation
1045/// Groups nest recursively via `transform_node` → `transform_group` →
1046/// `transform_children` with no depth guard.  This is an accepted v0
1047/// limitation; stack overflow is only possible with pathologically deep trees.
1048pub(super) fn transform_children(node: &KdlNode) -> Result<Vec<Node>, ParseError> {
1049    let mut children: Vec<Node> = Vec::new();
1050    if let Some(doc) = node.children() {
1051        for child in doc.nodes() {
1052            children.push(transform_node(child)?);
1053        }
1054    }
1055    Ok(children)
1056}