Skip to main content

polyspec_crudui_generator/
template.rs

1use crate::condition_editor::{check_condition_editor, fields_error};
2use crate::{FormError, FormResult};
3use polyspec_crudui_validator::compose::{
4    compose_properties, member_ordered, member_ordered_map, ComposeOptions, FileLoader,
5    MemoryLoader,
6};
7use polyspec_crudui_validator::validate::group_source::{group_source_failure, scalar_child};
8use polyspec_crudui_validator::validate::numeric;
9use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value};
11
12/// One data-independent field definition.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct FieldTemplate {
15    /// Property name relative to its parent group.
16    pub name: String,
17    /// Composed field settings without child properties.
18    pub spec: Map<String, Value>,
19    /// Child field definitions, stored once for repeated groups.
20    pub children: Vec<FieldTemplate>,
21}
22
23/// A JSON-serializable form structure without record data.
24///
25/// Reading one from JSON accepts exactly the shape `compile_form` produces; see
26/// [`FormTemplate::from_json`].
27#[derive(Debug, Clone, Serialize, PartialEq)]
28#[serde(rename_all = "camelCase")]
29pub struct FormTemplate {
30    /// Template format identifier.
31    pub kind: String,
32    /// Optional root prefix for input names.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub key_prefix: Option<String>,
35    /// Top-level field definitions.
36    pub fields: Vec<FieldTemplate>,
37    /// Form buttons in declaration order; one submit button when the spec declares none.
38    pub buttons: Vec<Map<String, Value>>,
39    /// Submission target declared by the spec, kept unchanged in the template.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub action: Option<Map<String, Value>>,
42    /// Root description content as declared by the spec (any value, `null` included), translated
43    /// when the form is rendered; `None` when the root declares no description.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub description: Option<Value>,
46}
47
48impl FormTemplate {
49    /// Read a template that is exactly what `compile_form` produces: an object whose only
50    /// members are `kind` (`crudui/form-template`), `fields`, `buttons` (objects), an optional
51    /// string `keyPrefix`, an optional object `action` and an optional `description` of any
52    /// value; each field has exactly a string
53    /// `name`, an object `spec` and a field list `children`. Any other value fails with
54    /// `Unsupported form template`.
55    pub fn from_json(value: &Value) -> FormResult<FormTemplate> {
56        let shape = || FormError::input("Unsupported form template");
57        let object = value.as_object().ok_or_else(shape)?;
58        if !only_members(
59            object,
60            &[
61                "kind",
62                "keyPrefix",
63                "fields",
64                "buttons",
65                "action",
66                "description",
67            ],
68        ) || object.get("kind").and_then(Value::as_str) != Some("crudui/form-template")
69        {
70            return Err(shape());
71        }
72        let key_prefix = match object.get("keyPrefix") {
73            None => None,
74            Some(Value::String(prefix)) => Some(prefix.clone()),
75            Some(_) => return Err(shape()),
76        };
77        let action = match object.get("action") {
78            None => None,
79            Some(Value::Object(action)) => Some(action.clone()),
80            Some(_) => return Err(shape()),
81        };
82        let fields = field_templates(object.get("fields")).ok_or_else(shape)?;
83        let buttons = object
84            .get("buttons")
85            .and_then(Value::as_array)
86            .ok_or_else(shape)?
87            .iter()
88            .map(|button| button.as_object().cloned())
89            .collect::<Option<Vec<_>>>()
90            .ok_or_else(shape)?;
91        Ok(FormTemplate {
92            kind: "crudui/form-template".into(),
93            key_prefix,
94            fields,
95            buttons,
96            action,
97            description: object.get("description").cloned(),
98        })
99    }
100}
101
102impl<'de> Deserialize<'de> for FormTemplate {
103    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
104        let value = Value::deserialize(deserializer)?;
105        FormTemplate::from_json(&value).map_err(serde::de::Error::custom)
106    }
107}
108
109fn only_members(object: &Map<String, Value>, allowed: &[&str]) -> bool {
110    object.keys().all(|key| allowed.contains(&key.as_str()))
111}
112
113/// A field template list with exactly `name`, `spec` and `children` in every field.
114fn field_templates(value: Option<&Value>) -> Option<Vec<FieldTemplate>> {
115    value?
116        .as_array()?
117        .iter()
118        .map(|field| {
119            let field = field.as_object()?;
120            if field.len() != 3 || !only_members(field, &["name", "spec", "children"]) {
121                return None;
122            }
123            Some(FieldTemplate {
124                name: field.get("name")?.as_str()?.to_owned(),
125                spec: field.get("spec")?.as_object()?.clone(),
126                children: field_templates(field.get("children"))?,
127            })
128        })
129        .collect()
130}
131
132/// Inputs used only during structure compilation.
133#[derive(Default)]
134pub struct CompileOptions<'a> {
135    /// Parsed documents indexed by their composition paths.
136    pub files: Map<String, Value>,
137    /// Optional explicit document loader.
138    pub loader: Option<&'a dyn FileLoader>,
139    /// Directory for resolving relative references.
140    pub basepath: String,
141    /// Root prefix for input names.
142    pub key_prefix: Option<String>,
143}
144
145/// A string, or a condition map: a non-empty object.
146fn condition_value(value: &Value) -> bool {
147    value.is_string() || value.as_object().is_some_and(|map| !map.is_empty())
148}
149
150/// Allowed keys of the closed `multiple` bucket.
151const MULTIPLE_KEYS: &[&str] = &[
152    "only", "min", "max", "copy", "sortable", "title", "controls", "header", "onclick", "keys",
153];
154/// Keys `multiple` accepts beside `only: true`.
155const ONLY_MULTIPLE_KEYS: &[&str] = &["only", "title", "header", "keys"];
156/// Allowed keys of the closed `lang` bucket.
157const LANG_KEYS: &[&str] = &[
158    "mode",
159    "only",
160    "name",
161    "key",
162    "frame",
163    "title",
164    "group_class",
165];
166/// Allowed keys of the closed `design` bucket.
167const DESIGN_KEYS: &[&str] = &[
168    "show", "class", "style", "label", "wrapper", "group", "prepend",
169];
170/// Allowed keys of a closed design node.
171const DESIGN_NODE_KEYS: &[&str] = &["class", "style"];
172/// Allowed keys of the closed `design` bucket of a form field.
173/// A condition field also declares its editor (docs/spec/schema.md, Structured condition editor).
174const CONDITION_DESIGN_KEYS: &[&str] = &[
175    "show",
176    "class",
177    "style",
178    "attributes",
179    "editor",
180    "label",
181    "wrapper",
182    "group",
183    "prepend",
184];
185const FIELD_DESIGN_KEYS: &[&str] = &[
186    "show",
187    "class",
188    "style",
189    "attributes",
190    "label",
191    "wrapper",
192    "group",
193    "prepend",
194];
195/// Allowed keys of the closed `design` bucket of a group field.
196const GROUP_DESIGN_KEYS: &[&str] = &[
197    "show",
198    "class",
199    "style",
200    "attributes",
201    "layout",
202    "label",
203    "wrapper",
204    "group",
205    "prepend",
206];
207/// Allowed keys of the `wrapper` design node of a form field.
208const FIELD_WRAPPER_KEYS: &[&str] = &["class", "style", "attributes"];
209/// Prefixes of attribute names crudui writes on a control or a node root.
210const OWNED_ATTRIBUTE_PREFIXES: &[&str] = &["data-crudui-", "data-source-"];
211/// Attribute names crudui writes on a control or a node root.
212const OWNED_ATTRIBUTE_NAMES: &[&str] = &[
213    "data-field-path",
214    "data-lang",
215    "data-name",
216    "data-rule-name",
217    "data-default",
218    "data-is-default",
219    "data-type",
220    "data-height",
221    "data-upload-server",
222    "data-fileserver",
223    "data-server",
224    "data-max-tags",
225    "data-keyword-min-length",
226    "data-delay",
227    "data-api-server",
228    "data-max-width",
229    "data-min-width",
230    "data-max-height",
231    "data-min-height",
232    "data-preview-max-width",
233    "data-preview-max-height",
234    "data-unsupported-type",
235];
236
237/// Whether `name` is a `data-*` or `aria-*` name crudui does not write: lowercase letters,
238/// digits, `-`, `_` and `.` after the prefix, starting with a letter or a digit.
239fn declared_attribute_name(name: &str) -> bool {
240    let Some(rest) = name
241        .strip_prefix("data-")
242        .or_else(|| name.strip_prefix("aria-"))
243    else {
244        return false;
245    };
246    let valid = !rest.is_empty()
247        && rest.bytes().enumerate().all(|(index, c)| {
248            c.is_ascii_lowercase()
249                || c.is_ascii_digit()
250                || (index > 0 && (c == b'-' || c == b'_' || c == b'.'))
251        });
252    valid
253        && !OWNED_ATTRIBUTE_NAMES.contains(&name)
254        && !OWNED_ATTRIBUTE_PREFIXES
255            .iter()
256            .any(|prefix| name.starts_with(prefix))
257}
258
259/// Reject declared attributes at `key` that are not an object of permitted names to strings.
260/// Every name is checked before any value.
261pub(crate) fn check_declared_attributes(
262    attributes: &Value,
263    key: &str,
264    path: &str,
265) -> FormResult<()> {
266    let Some(attributes) = attributes.as_object() else {
267        return Err(FormError::input(format!(
268            "Invalid {key} at {path}: expected an object"
269        )));
270    };
271    if let Some(name) = attributes
272        .keys()
273        .find(|name| !declared_attribute_name(name))
274    {
275        return Err(FormError::input(format!(
276            "Invalid {key}.{name} at {path}: expected a data-* or aria-* name that crudui does not write"
277        )));
278    }
279    if let Some((name, _)) = attributes.iter().find(|(_, value)| !value.is_string()) {
280        return Err(FormError::input(format!(
281            "Invalid {key}.{name} at {path}: expected a string"
282        )));
283    }
284    Ok(())
285}
286/// Allowed keys of the closed `behavior` bucket.
287const BEHAVIOR_KEYS: &[&str] = &["onchange", "onclick", "onload"];
288
289/// Reject the first key of a closed bucket that the bucket does not allow.
290pub(crate) fn check_known_keys(
291    bucket: &str,
292    settings: &Map<String, Value>,
293    allowed: &[&str],
294    path: &str,
295) -> FormResult<()> {
296    match settings.keys().find(|key| !allowed.contains(&key.as_str())) {
297        Some(key) => Err(FormError::input(format!(
298            "Invalid {bucket}.{key} at {path}: unknown key"
299        ))),
300        None => Ok(()),
301    }
302}
303
304/// Reject an unknown key or a wrong value type in one `design` declaration at `path`. Form
305/// fields, form buttons, list and detail specifications, their columns and fields share this
306/// rule; only a form field (`field`) accepts `attributes` and `wrapper.attributes`, and only a
307/// group field accepts `layout`, one of `layouts`.
308pub(crate) fn check_design_declaration(
309    design: &Value,
310    path: &str,
311    field: bool,
312    layouts: Option<&[&str]>,
313    condition: bool,
314) -> FormResult<()> {
315    let fail = |key: &str, expected: &str| -> FormResult<()> {
316        Err(FormError::input(format!(
317            "Invalid {key} at {path}: expected {expected}"
318        )))
319    };
320    if !design.is_boolean() && !design.is_object() {
321        return fail("design", "a boolean or an object");
322    }
323    let Some(design) = design.as_object() else {
324        return Ok(());
325    };
326    let design_keys = if layouts.is_some() {
327        GROUP_DESIGN_KEYS
328    } else if condition {
329        CONDITION_DESIGN_KEYS
330    } else if field {
331        FIELD_DESIGN_KEYS
332    } else {
333        DESIGN_KEYS
334    };
335    check_known_keys("design", design, design_keys, path)?;
336    if design
337        .get("show")
338        .is_some_and(|v| !v.is_boolean() && !condition_value(v))
339    {
340        return fail("design.show", "an expression, a boolean or a condition map");
341    }
342    for key in ["class", "style"] {
343        if design.get(key).is_some_and(|v| !condition_value(v)) {
344            return fail(&format!("design.{key}"), "a string or a condition map");
345        }
346    }
347    if let Some(attributes) = design.get("attributes") {
348        check_declared_attributes(attributes, "design.attributes", path)?;
349    }
350    if let (Some(layouts), Some(layout)) = (layouts, design.get("layout")) {
351        if !layout
352            .as_str()
353            .is_some_and(|layout| layouts.contains(&layout))
354        {
355            return fail(
356                "design.layout",
357                if layouts.len() == 3 {
358                    "stacked, inline or line"
359                } else {
360                    "stacked or inline"
361                },
362            );
363        }
364    }
365    for node in ["label", "wrapper", "group", "prepend"] {
366        let Some(value) = design.get(node) else {
367            continue;
368        };
369        let Some(value) = value.as_object() else {
370            return fail(&format!("design.{node}"), "an object");
371        };
372        let node_keys = if field && node == "wrapper" {
373            FIELD_WRAPPER_KEYS
374        } else {
375            DESIGN_NODE_KEYS
376        };
377        check_known_keys(&format!("design.{node}"), value, node_keys, path)?;
378        for key in ["class", "style"] {
379            if value.get(key).is_some_and(|v| !condition_value(v)) {
380                return fail(
381                    &format!("design.{node}.{key}"),
382                    "a string or a condition map",
383                );
384            }
385        }
386        if let Some(attributes) = value.get("attributes") {
387            check_declared_attributes(attributes, &format!("design.{node}.attributes"), path)?;
388        }
389    }
390    Ok(())
391}
392
393/// Reject a wrong value type or an unknown key in one field's `multiple`, `lang`,
394/// `design` and `behavior` declarations. Only a form field (`field`) accepts declared attributes.
395fn check_declarations(spec: &Map<String, Value>, path: &str, field: bool) -> FormResult<()> {
396    let fail = |key: &str, expected: &str| -> FormResult<()> {
397        Err(FormError::input(format!(
398            "Invalid {key} at {path}: expected {expected}"
399        )))
400    };
401    // Buttons and the submission target belong to the form, not to a field.
402    for key in ["buttons", "action"] {
403        if spec.contains_key(key) {
404            return fail(key, "the form root");
405        }
406    }
407    if let Some(multiple) = spec.get("multiple") {
408        if !multiple.is_boolean() && multiple != "only" && !multiple.is_object() {
409            return fail("multiple", "a boolean, only or an object");
410        }
411        if let Some(settings) = multiple.as_object() {
412            check_known_keys("multiple", settings, MULTIPLE_KEYS, path)?;
413            if settings.get("only").is_some_and(|v| !v.is_boolean()) {
414                return fail("multiple.only", "a boolean");
415            }
416            // Rows of a data-only collection come from the data: row limits and row
417            // controls do not apply.
418            if settings.get("only").is_some_and(|v| *v == true) {
419                check_known_keys("multiple", settings, ONLY_MULTIPLE_KEYS, path)?;
420            }
421            // A keyed map is a data-only collection; validation resolves its reference.
422            if let Some(keys) = settings.get("keys") {
423                if settings.get("only").is_none_or(|v| *v != true) {
424                    return fail("multiple.keys", "multiple.only: true");
425                }
426                if !keys.is_string() {
427                    return fail("multiple.keys", "a reference to a repeated group");
428                }
429            }
430            for key in ["min", "max"] {
431                if settings.get(key).is_some_and(|v| !v.is_number()) {
432                    return fail(&format!("multiple.{key}"), "a number");
433                }
434            }
435            for key in ["copy", "sortable"] {
436                if settings.get(key).is_some_and(|v| !v.is_boolean()) {
437                    return fail(&format!("multiple.{key}"), "a boolean");
438                }
439            }
440            if let Some(title) = settings.get("title") {
441                if spec.get("type").is_none_or(|t| t != "group") {
442                    return fail("multiple.title", "a repeated group");
443                }
444                let child = title.as_str().and_then(|name| {
445                    spec.get("properties")
446                        .and_then(Value::as_object)
447                        .and_then(|properties| properties.get(name))
448                });
449                if !child.is_some_and(scalar_child) {
450                    return fail(
451                        "multiple.title",
452                        "the name of a direct child field without multiple, properties or lang",
453                    );
454                }
455            }
456            if settings
457                .get("controls")
458                .is_some_and(|v| !["header", "footer", "outline"].iter().any(|p| v == p))
459            {
460                return fail("multiple.controls", "header, footer or outline");
461            }
462            if settings
463                .get("header")
464                .is_some_and(|v| !["static", "sticky"].iter().any(|p| v == p))
465            {
466                return fail("multiple.header", "static or sticky");
467            }
468        }
469    }
470    if spec
471        .get("lang")
472        .is_some_and(|v| !v.is_boolean() && !v.is_object())
473    {
474        return fail("lang", "a boolean or an object");
475    }
476    if let Some(lang) = spec.get("lang").and_then(Value::as_object) {
477        check_known_keys("lang", lang, LANG_KEYS, path)?;
478    }
479    if let Some(only) = spec
480        .get("lang")
481        .and_then(Value::as_object)
482        .and_then(|lang| lang.get("only"))
483    {
484        let codes = only
485            .as_array()
486            .is_some_and(|codes| codes.iter().all(Value::is_string));
487        if !codes && !only.is_object() {
488            return fail("lang.only", "a list of language codes or an object");
489        }
490    }
491    if let Some(design) = spec.get("design") {
492        let layouts = if field { group_layouts(spec) } else { None };
493        check_design_declaration(design, path, field, layouts, field && is_condition(spec))?;
494    }
495    if let Some(behavior) = spec.get("behavior").and_then(Value::as_object) {
496        check_known_keys("behavior", behavior, BEHAVIOR_KEYS, path)?;
497    }
498    if field
499        && spec
500            .get("type")
501            .and_then(Value::as_str)
502            .is_some_and(|t| t.to_lowercase() == "range")
503    {
504        check_range_declaration(spec, path)?;
505    }
506    if field && is_condition(spec) {
507        check_condition_declaration(spec, path)?;
508        check_condition_editor(spec, path)?;
509    } else if field
510        && spec
511            .get("options")
512            .and_then(Value::as_object)
513            .is_some_and(|options| options.contains_key("fields"))
514    {
515        return Err(fields_error(path));
516    }
517    Ok(())
518}
519
520/// Whether a field declares the condition type.
521fn is_condition(spec: &Map<String, Value>) -> bool {
522    spec.get("type")
523        .and_then(Value::as_str)
524        .is_some_and(|t| t.to_lowercase() == "condition")
525}
526
527/// Reject a condition field whose `validate.conditionTree` is not the literal `true` or `judge`:
528/// every value of the field is checked as a condition tree.
529fn check_condition_declaration(spec: &Map<String, Value>, path: &str) -> FormResult<()> {
530    let parameter = spec
531        .get("validate")
532        .and_then(|v| v.get("conditionTree"))
533        .unwrap_or(&Value::Null);
534    if *parameter == Value::Bool(true) || parameter.as_str() == Some("judge") {
535        return Ok(());
536    }
537    Err(FormError::input(format!(
538        "Invalid validate.conditionTree at {path}: expected true or judge"
539    )))
540}
541
542/// Reject a range field without literal bounds and a literal step whose multiple the minimum
543/// is: the slider moves from the minimum in steps.
544fn check_range_declaration(spec: &Map<String, Value>, path: &str) -> FormResult<()> {
545    let validate = spec.get("validate").filter(|v| v.is_object());
546    let rule = |key: &str| validate.and_then(|v| v.get(key)).unwrap_or(&Value::Null);
547    let Some((minimum, _)) = numeric::number_range(rule("range")) else {
548        return Err(FormError::input(format!(
549            "Invalid validate.range at {path}: expected [minimum, maximum] finite numbers with minimum not above maximum"
550        )));
551    };
552    let Some(step) = numeric::step(rule("step")) else {
553        return Err(FormError::input(format!(
554            "Invalid validate.step at {path}: expected a finite number above 0"
555        )));
556    };
557    if !numeric::is_multiple(minimum, step) {
558        return Err(FormError::input(format!(
559            "Invalid validate.range at {path}: expected a minimum that is a multiple of validate.step"
560        )));
561    }
562    Ok(())
563}
564
565/// The `design.layout` values a group field accepts: a repeated group has no line, and a field
566/// that is not a group has no layout.
567fn group_layouts(spec: &Map<String, Value>) -> Option<&'static [&'static str]> {
568    if spec.get("type").is_none_or(|t| t != "group") {
569        return None;
570    }
571    let repeated = spec
572        .get("multiple")
573        .is_some_and(|v| *v == true || *v == "only" || v.is_object());
574    Some(if repeated {
575        &["stacked", "inline"]
576    } else {
577        &["stacked", "inline", "line"]
578    })
579}
580
581/// Reject a wrong root `action` or `buttons` declaration.
582fn check_form_declarations(spec: &Map<String, Value>) -> FormResult<()> {
583    let fail = |key: &str, expected: &str| -> FormResult<()> {
584        Err(FormError::input(format!(
585            "Invalid {key} at form: expected {expected}"
586        )))
587    };
588    if let Some(action) = spec.get("action") {
589        let Some(action) = action.as_object() else {
590            return fail("action", "an object");
591        };
592        for key in ["method", "url", "enctype"] {
593            if action.get(key).is_some_and(|v| !v.is_string()) {
594                return fail(&format!("action.{key}"), "a string");
595            }
596        }
597    }
598    let Some(buttons) = spec.get("buttons") else {
599        return Ok(());
600    };
601    let Some(buttons) = buttons.as_array() else {
602        return fail("buttons", "a list of buttons");
603    };
604    for (index, button) in buttons.iter().enumerate() {
605        let key = format!("buttons.{index}");
606        let Some(button) = button.as_object() else {
607            return fail(&key, "an object");
608        };
609        let kind = button.get("type").and_then(Value::as_str).unwrap_or("");
610        if !crate::buttons::FORM_BUTTON_TYPES.contains(&kind) {
611            return fail(&format!("{key}.type"), "submit, reset, button or link");
612        }
613        for name in ["name", "value", "href"] {
614            if button.get(name).is_some_and(|v| !v.is_string()) {
615                return fail(&format!("{key}.{name}"), "a string");
616            }
617        }
618        // A button type without interface text needs declared text.
619        if crate::buttons::button_text(crate::messages::form_messages("ko")?, kind).is_empty()
620            && !button.contains_key("text")
621        {
622            return fail(&format!("{key}.text"), "content for this button type");
623        }
624        if kind == "link" && !button.contains_key("href") {
625            return fail(&format!("{key}.href"), "a link target");
626        }
627        check_declarations(button, &format!("form.{key}"), false)?;
628    }
629    Ok(())
630}
631
632fn fields(
633    root: &Map<String, Value>,
634    properties: &Map<String, Value>,
635    parent: &str,
636) -> FormResult<Vec<FieldTemplate>> {
637    let mut out = Vec::new();
638    for (name, value) in properties {
639        let Some(raw) = value.as_object() else {
640            continue;
641        };
642        let path = if parent.is_empty() {
643            name.clone()
644        } else {
645            format!("{parent}.{name}")
646        };
647        check_declarations(raw, &path, true)?;
648        // A group source is checked after the other declarations of the field.
649        if let Some(message) = group_source_failure(value, &path, root) {
650            return Err(FormError::input(message));
651        }
652        let mut spec = raw.clone();
653        let children = match spec.shift_remove("properties") {
654            Some(Value::Object(children)) => fields(root, &children, &path)?,
655            _ => Vec::new(),
656        };
657        out.push(FieldTemplate {
658            name: name.clone(),
659            spec,
660            children,
661        });
662    }
663    Ok(out)
664}
665
666/// A field template whose specification maps, at every depth, are in specification member order.
667fn member_ordered_field(field: &FieldTemplate) -> FieldTemplate {
668    FieldTemplate {
669        name: field.name.clone(),
670        spec: member_ordered_map(&field.spec),
671        children: field.children.iter().map(member_ordered_field).collect(),
672    }
673}
674
675/// A form template whose field specifications, buttons, action and description are in
676/// specification member order.
677pub(crate) fn member_ordered_template(template: &FormTemplate) -> FormTemplate {
678    FormTemplate {
679        kind: template.kind.clone(),
680        key_prefix: template.key_prefix.clone(),
681        fields: template.fields.iter().map(member_ordered_field).collect(),
682        buttons: template.buttons.iter().map(member_ordered_map).collect(),
683        action: template.action.as_ref().map(member_ordered_map),
684        description: template.description.as_ref().map(member_ordered),
685    }
686}
687
688/// Compile a complete form structure before record data is available.
689pub fn compile_form(spec: &Value, options: &CompileOptions<'_>) -> FormResult<FormTemplate> {
690    // The specification is read in member order; composition orders every loaded document.
691    let spec = &member_ordered(spec);
692    if spec["type"] != "group" || !spec["properties"].is_object() {
693        return Err(FormError::input(
694            "A form spec must be a group with properties",
695        ));
696    }
697    check_form_declarations(spec.as_object().expect("checked group"))?;
698    // The form root takes no layout; a group field declares it.
699    if spec["design"].get("layout").is_some() {
700        return Err(FormError::input(
701            "Invalid design.layout at form: unknown key",
702        ));
703    }
704    let memory = MemoryLoader::new(options.files.clone());
705    let properties = compose_properties(
706        spec["properties"]
707            .as_object()
708            .expect("checked properties")
709            .clone(),
710        options.loader.unwrap_or(&memory),
711        &ComposeOptions::with_basepath(&options.basepath),
712    )?;
713    Ok(FormTemplate {
714        kind: "crudui/form-template".into(),
715        key_prefix: options.key_prefix.clone(),
716        fields: fields(&properties, &properties, "")?,
717        buttons: match spec.get("buttons").and_then(Value::as_array) {
718            Some(declared) => declared
719                .iter()
720                .filter_map(|b| b.as_object().cloned())
721                .collect(),
722            None => vec![Map::from_iter([(
723                "type".to_string(),
724                Value::from("submit"),
725            )])],
726        },
727        action: spec.get("action").and_then(Value::as_object).cloned(),
728        description: spec.get("description").cloned(),
729    })
730}
731
732pub(crate) fn repeats(field: &FieldTemplate) -> bool {
733    field
734        .spec
735        .get("multiple")
736        .is_some_and(|v| *v == true || *v == "only" || v.is_object())
737}
738
739/// A `multiple: only` collection: its rows come only from the data.
740pub(crate) fn data_only(field: &FieldTemplate) -> bool {
741    field
742        .spec
743        .get("multiple")
744        .is_some_and(|v| *v == "only" || v.get("only").is_some_and(|only| *only == true))
745}