Skip to main content

polyspec_crudui_generator/
render.rs

1use crate::form_render::{render_model, NodeErrors, RenderModel};
2use serde_json::{json, Map, Value};
3
4use crate::util::{join_class, js_string, scalar, style};
5use crate::{Form, FormResult};
6
7pub(crate) fn escape(value: &str) -> String {
8    value
9        .replace('&', "&")
10        .replace('<', "&lt;")
11        .replace('>', "&gt;")
12        .replace('"', "&quot;")
13        .replace('\'', "&#x27;")
14}
15
16pub(crate) fn raw_text(value: &str) -> String {
17    value
18        .replace('&', "&amp;")
19        .replace('<', "&lt;")
20        .replace('>', "&gt;")
21}
22
23pub(crate) fn raw_attribute(value: &str) -> String {
24    value
25        .replace('&', "&amp;")
26        .replace('"', "&quot;")
27        .replace('<', "&lt;")
28}
29
30fn attributes(attrs: &Value, raw: bool) -> String {
31    let Some(attrs) = attrs.as_object() else {
32        return String::new();
33    };
34    attrs
35        .iter()
36        .map(|(key, value)| {
37            let value = scalar(Some(value));
38            let value = if raw {
39                raw_attribute(&value)
40            } else {
41                escape(&value)
42            };
43            let key = if raw {
44                key.as_str()
45            } else {
46                match key.as_str() {
47                    "autocomplete" => "autoComplete",
48                    "readonly" => "readOnly",
49                    "maxlength" => "maxLength",
50                    "minlength" => "minLength",
51                    "colspan" => "colSpan",
52                    "rowspan" => "rowSpan",
53                    _ => key.as_str(),
54                }
55            };
56            format!(" {key}=\"{value}\"")
57        })
58        .collect()
59}
60
61pub(crate) fn sanitize_url(url: &str) -> String {
62    let pattern=regex::RegexBuilder::new(r"^[\x00-\x1f ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:")
63        .case_insensitive(true).unicode(false).build().expect("URL scheme pattern");
64    if pattern.is_match(url) {
65        "javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')".into()
66    } else {
67        url.into()
68    }
69}
70
71pub(crate) fn element(tag: &str, attrs: &Value, content: &str) -> String {
72    let mut attrs = attrs.as_object().cloned().unwrap_or_default();
73    if let Some(value) = attrs.get("style").and_then(Value::as_str) {
74        let value = crate::css::render_style(value);
75        if value.is_empty() {
76            attrs.shift_remove("style");
77        } else {
78            attrs.insert("style".into(), value.into());
79        }
80    }
81    for key in ["href", "src"] {
82        if let Some(value) = attrs.get(key).and_then(Value::as_str) {
83            if key == "src" && value.is_empty() {
84                attrs.shift_remove(key);
85            } else {
86                attrs.insert(key.into(), sanitize_url(value).into());
87            }
88        }
89    }
90    for key in ["readonly", "disabled", "required", "multiple", "autofocus"] {
91        if attrs.contains_key(key) {
92            attrs.insert(key.into(), "".into());
93        }
94    }
95    if tag == "input" {
96        for key in ["name", "checked", "value"] {
97            if let Some(value) = attrs.shift_remove(key) {
98                attrs.insert(key.into(), value);
99            }
100        }
101    } else if matches!(tag, "select" | "textarea") {
102        attrs.shift_remove("value");
103    }
104    let attributes = attributes(&attrs.into(), false);
105    if matches!(tag, "input" | "img" | "br" | "hr" | "link") {
106        format!("<{tag}{attributes}/>")
107    } else {
108        let leading_newline = if tag == "textarea" && content.starts_with('\n') {
109            "\n"
110        } else {
111            ""
112        };
113        format!("<{tag}{attributes}>{leading_newline}{content}</{tag}>")
114    }
115}
116
117pub(crate) fn raw_element(tag: &str, attrs: &Value, content: &str) -> String {
118    let attributes = attributes(attrs, true);
119    if matches!(tag, "input" | "img" | "br" | "hr" | "link") {
120        format!("<{tag}{attributes}>")
121    } else {
122        format!("<{tag}{attributes}>{content}</{tag}>")
123    }
124}
125
126fn has_events(attrs: &Value) -> bool {
127    attrs.as_object().into_iter().flatten().any(|(key, _)| {
128        key.strip_prefix("on")
129            .and_then(|rest| rest.bytes().next())
130            .is_some_and(|byte| byte.is_ascii_lowercase())
131    })
132}
133
134fn str_at<'a>(value: &'a Value, key: &str) -> &'a str {
135    value[key].as_str().unwrap_or("")
136}
137
138pub(crate) fn appearance_attrs(class: String, inline: &str) -> Value {
139    let mut attrs = Map::new();
140    if !class.is_empty() {
141        attrs.insert("class".into(), class.into());
142    }
143    let inline = style(inline);
144    if !inline.is_empty() {
145        attrs.insert("style".into(), inline.into());
146    }
147    attrs.into()
148}
149
150fn affix(affix: &Value, raw: bool) -> String {
151    if !affix.is_object() {
152        return String::new();
153    }
154    let element = if raw { raw_element } else { element };
155    let text = if raw { raw_text } else { escape };
156    element(
157        "span",
158        &appearance_attrs(str_at(affix, "class").into(), str_at(affix, "style")),
159        &text(str_at(affix, "text")),
160    )
161}
162
163fn control(widget: &Value, search: bool) -> String {
164    let tag = widget["tag"].as_str().unwrap_or("input");
165    let raw = search || widget["layout"] == "condition" || has_events(&widget["attrs"]);
166    let text = if raw { raw_text } else { escape };
167    let render = if raw { raw_element } else { element };
168    let content = if tag == "select" {
169        let option = |option: &Value| {
170            let mut attrs = json!({"value":option["value"]});
171            if option["selected"] == true {
172                attrs["selected"] = if search { "selected" } else { "" }.into();
173            }
174            render("option", &attrs, &text(str_at(option, "label")))
175        };
176        // The options of one group run inside one optgroup element.
177        let mut content = String::new();
178        let options = widget["options"].as_array().map_or(&[][..], Vec::as_slice);
179        let mut start = 0;
180        while start < options.len() {
181            let group = options[start].get("group");
182            let end = start
183                + options[start..]
184                    .iter()
185                    .take_while(|o| {
186                        o.get("group").map(|g| &g["index"]) == group.map(|g| &g["index"])
187                    })
188                    .count();
189            let run: String = options[start..end].iter().map(option).collect();
190            content += &match group {
191                Some(group) => render("optgroup", &json!({"label":group["label"]}), &run),
192                None => run,
193            };
194            start = end;
195        }
196        content
197    } else {
198        text(str_at(widget, "text"))
199    };
200    let attrs = if raw {
201        widget["attrs"].clone()
202    } else {
203        style_last(&widget["attrs"])
204    };
205    render(tag, &attrs, &content)
206}
207
208/// The attributes with `style` moved last, as an ordinary control writes them.
209fn style_last(attrs: &Value) -> Value {
210    let mut attrs = attrs.clone();
211    if let Some(style) = attrs
212        .as_object_mut()
213        .and_then(|attrs| attrs.shift_remove("style"))
214    {
215        attrs["style"] = style;
216    }
217    attrs
218}
219
220fn script(text: &str) -> String {
221    element("script", &json!({"nonce":""}), text)
222}
223
224fn widget(model: &Value) -> String {
225    if model["unsupported"] == true {
226        return element(
227            "div",
228            &json!({"class":"crudui-widget crudui-widget--unsupported","data-unsupported-type":model["type"]}),
229            "",
230        );
231    }
232    match str_at(model, "layout") {
233        "widget" => element(
234            "div",
235            &json!({"class":"crudui-widget"}),
236            &(affix(&model["prepend"], has_events(&model["attrs"]))
237                + &control(model, false)
238                + &affix(&model["append"], has_events(&model["attrs"]))),
239        ),
240        "bare" => control(model, false),
241        "range" => {
242            let raw = has_events(&model["attrs"]);
243            let mut attrs = json!({"class":"crudui-widget__output"});
244            if let Some(id) = model["attrs"].get("id") {
245                attrs["for"] = id.clone();
246            }
247            let label = str_at(model, "text");
248            let output = if raw {
249                raw_element("output", &attrs, &raw_text(label))
250            } else {
251                element("output", &attrs, &escape(label))
252            };
253            element(
254                "div",
255                &json!({"class":"crudui-widget crudui-widget--range"}),
256                &(affix(&model["prepend"], raw)
257                    + &control(model, false)
258                    + &output
259                    + &affix(&model["append"], raw)),
260            )
261        }
262        "host-script" => control(model, false) + &script(str_at(model, "script")),
263        "choices" => {
264            let radio = model["kind"] == "choice";
265            let content = model["options"]
266                .as_array()
267                .into_iter()
268                .flatten()
269                .map(|option| {
270                    let mut attrs = model["extra"]["input"]
271                        .as_object()
272                        .cloned()
273                        .unwrap_or_default();
274                    attrs.insert(
275                        "type".into(),
276                        if radio { "radio" } else { "checkbox" }.into(),
277                    );
278                    attrs.insert("value".into(), option["value"].clone());
279                    attrs.insert("autocomplete".into(), "off".into());
280                    attrs.insert("class".into(), "valid-target crudui-choices__input".into());
281                    if option["id"].is_string() {
282                        attrs.insert("id".into(), option["id"].clone());
283                    }
284                    if radio {
285                        attrs.insert(
286                            "data-is-default".into(),
287                            if option["isDefault"] == true { "1" } else { "" }.into(),
288                        );
289                    }
290                    if option["selected"] == true {
291                        attrs.insert("checked".into(), "".into());
292                    }
293                    if let Some(declared) = model["extra"]["option"].as_object() {
294                        attrs.extend(declared.clone());
295                    }
296                    // A choice attribute named like a control attribute keeps that position.
297                    if let Some(declared) = option["attributes"].as_object() {
298                        attrs.extend(declared.clone());
299                    }
300                    let mut label = json!({"for":option["id"],"class":join_class(&[
301                        str_at(model, "itemLabelClass"),
302                        str_at(option, "className"),
303                    ])});
304                    if let Some(inline) = option.get("style") {
305                        label["style"] = inline.clone();
306                    }
307                    let raw = has_events(&model["extra"]["input"]);
308                    let input = if raw { raw_element } else { element };
309                    let text = if raw { raw_text } else { escape };
310                    input("input", &attrs.into(), "")
311                        + &input(
312                            "label",
313                            &label,
314                            &input("span", &json!({}), &text(str_at(option, "label"))),
315                        )
316                })
317                .collect::<String>();
318            element("div", &model["attrs"], &content)
319        }
320        "file" => {
321            let raw = has_events(&model["extra"]["file"]);
322            let input = if raw { raw_element } else { element };
323            let mut content = affix(&model["prepend"], raw);
324            if model["extra"]["display"].is_object() {
325                let display = if raw {
326                    json!({"class":model["extra"]["display"]["class"],"readonly":"","type":"text","value":""})
327                } else {
328                    model["extra"]["display"].clone()
329                };
330                content += &input("input", &display, "");
331            }
332            content += &input("input", &model["extra"]["file"], "");
333            if model["extra"]["display"].is_object() {
334                content += &element(
335                    "button",
336                    &json!({"class":"crudui-widget__button","type":"button"}),
337                    "&nbsp;",
338                );
339            }
340            element("div", &json!({"class":"crudui-widget"}), &content)
341        }
342        "display" => element(
343            "div",
344            &style_last(&model["attrs"]),
345            str_at(model, "rawHtml"),
346        ),
347        "search" => {
348            let mut content = String::new();
349            if !str_at(model, "styleChrome").is_empty() {
350                content += &element("style", &json!({"nonce":""}), str_at(model, "styleChrome"));
351            }
352            content += &script(str_at(model, "script"));
353            content
354                + &element(
355                    "div",
356                    &json!({"class":"crudui-widget crudui-widget--search"}),
357                    &(affix(&model["prepend"], true)
358                        + &control(model, true)
359                        + &affix(&model["append"], true)),
360                )
361        }
362        "button" => {
363            if has_events(&model["attrs"]) {
364                raw_element("button", &model["attrs"], &raw_text(str_at(model, "text")))
365            } else {
366                element(
367                    "button",
368                    &style_last(&model["attrs"]),
369                    &escape(str_at(model, "text")),
370                )
371            }
372        }
373        "condition" => condition_widget(model),
374        _ => String::new(),
375    }
376}
377
378/// A structured condition widget (docs/spec/form-markup.md, Structured condition editor): the prepend affix, the
379/// named error of a value that the editor does not show, the textarea, the editor of a value that it shows and the
380/// append affix.
381fn condition_widget(model: &Value) -> String {
382    let raw = true;
383    let editor = &model["condition"];
384    let mut content = affix(&model["prepend"], raw);
385    if let Some(error) = editor.get("error") {
386        content += &raw_element(
387            "p",
388            &json!({"class":"crudui-condition__error","role":"alert"}),
389            &raw_text(error.as_str().unwrap_or("")),
390        );
391    }
392    content += &control(model, false);
393    if editor.get("error").is_none() {
394        content += &raw_element(
395            "div",
396            &json!({"class":"crudui-condition","data-condition-for":editor["for"]}),
397            &condition_node(&editor["root"], editor),
398        );
399    }
400    raw_element(
401        "div",
402        &json!({"class":"crudui-widget crudui-widget--condition"}),
403        &(content + &affix(&model["append"], raw)),
404    )
405}
406
407fn condition_button(action: &str, label: &Value) -> String {
408    raw_element(
409        "button",
410        &json!({"type":"button","class":"crudui-action crudui-action--text","data-condition-action":action}),
411        &raw_text(label.as_str().unwrap_or("")),
412    )
413}
414
415fn condition_select(attrs: &Value, choices: &[&str], selected: &str) -> String {
416    let options: String = choices
417        .iter()
418        .map(|choice| {
419            let mut option = json!({"value":choice});
420            if *choice == selected {
421                option["selected"] = "".into();
422            }
423            raw_element("option", &option, &raw_text(choice))
424        })
425        .collect();
426    raw_element("select", attrs, &options)
427}
428
429/// One condition of the editor: a group, a field comparison or a judged text (CUMK-067).
430fn condition_node(node: &Value, editor: &Value) -> String {
431    let labels = &editor["labels"];
432    let path = str_at(node, "path");
433    let remove = if path.is_empty() {
434        String::new()
435    } else {
436        condition_button("remove", &labels["remove"])
437    };
438    match str_at(node, "kind") {
439        "group" => {
440            let items: String = node["items"]
441                .as_array()
442                .map_or(&[][..], Vec::as_slice)
443                .iter()
444                .map(|item| {
445                    raw_element(
446                        "li",
447                        &json!({"class":"crudui-condition__item"}),
448                        &condition_node(item, editor),
449                    )
450                })
451                .collect();
452            let judge = if editor["judge"] == true {
453                condition_button("add-judge", &labels["addJudge"])
454            } else {
455                String::new()
456            };
457            raw_element(
458                "fieldset",
459                &json!({"class":"crudui-condition__group","data-condition-path":path}),
460                &(condition_select(
461                    &json!({"class":"crudui-condition__mode","data-condition-part":"mode","aria-label":labels["mode"]}),
462                    &["all", "any"],
463                    str_at(node, "mode"),
464                ) + &raw_element("ol", &json!({"class":"crudui-condition__list"}), &items)
465                    + &condition_button("add-comparison", &labels["addComparison"])
466                    + &condition_button("add-group", &labels["addGroup"])
467                    + &judge
468                    + &remove),
469            )
470        }
471        "judge" => raw_element(
472            "div",
473            &json!({"class":"crudui-condition__judge","data-condition-path":path}),
474            &(raw_element(
475                "input",
476                &json!({"class":"crudui-condition__text","data-condition-part":"judge","value":node["text"]}),
477                "",
478            ) + &remove),
479        ),
480        _ => {
481            let field = str_at(node, "field");
482            let control = match editor["fields"].as_array() {
483                Some(names) => {
484                    let mut choices: Vec<&str> = names.iter().filter_map(Value::as_str).collect();
485                    if !choices.contains(&field) {
486                        choices.insert(0, field);
487                    }
488                    condition_select(
489                        &json!({"class":"crudui-condition__field","data-condition-part":"field"}),
490                        &choices,
491                        field,
492                    )
493                }
494                None => raw_element(
495                    "input",
496                    &json!({"class":"crudui-condition__field","data-condition-part":"field","value":field}),
497                    "",
498                ),
499            };
500            raw_element(
501                "div",
502                &json!({"class":"crudui-condition__comparison","data-condition-path":path}),
503                &(control
504                    + &condition_select(
505                        &json!({"class":"crudui-condition__op","data-condition-part":"op"}),
506                        &crate::condition_editor::CONDITION_OPS,
507                        str_at(node, "op"),
508                    )
509                    + &raw_element(
510                        "input",
511                        &json!({"class":"crudui-condition__value","data-condition-part":"value","value":node["value"]}),
512                        "",
513                    )
514                    + &remove),
515            )
516        }
517    }
518}
519
520fn classes(parts: &[&str]) -> String {
521    parts
522        .iter()
523        .filter(|part| !part.is_empty())
524        .copied()
525        .collect::<Vec<_>>()
526        .join(" ")
527}
528
529/// A present model part as text: strings verbatim, other values as JavaScript strings.
530fn part_text(value: &Value) -> String {
531    escape(&js_string(value))
532}
533
534/// One control group of `data-crudui-action` buttons.
535fn controls(controls: &Value) -> String {
536    let buttons = controls["actions"]
537        .as_array()
538        .into_iter()
539        .flatten()
540        .map(|action| {
541            let mut attrs = json!({"type":"button","class":"crudui-action","data-crudui-action":action["name"],"aria-label":action["label"]});
542            if action["disabled"] == true {
543                attrs["aria-disabled"] = "true".into();
544            }
545            element("button", &attrs, "")
546        })
547        .collect::<String>();
548    element(
549        "div",
550        &json!({"class":"crudui-controls","role":"group","aria-label":controls["label"]}),
551        &buttons,
552    )
553}
554
555fn header(node: &Value) -> String {
556    let header = &node["header"];
557    let mut parts = String::new();
558    if node["collapsible"] == true {
559        let mut attrs = json!({"type":"button","class":"crudui-action","data-crudui-action":"toggle-row",
560            "aria-expanded": (node["expanded"] == true).to_string()});
561        if let Some(id) = node["body"]["id"].as_str() {
562            attrs["aria-controls"] = id.into();
563        }
564        if let Some(label) = node["toggleLabel"].as_str() {
565            attrs["aria-label"] = label.into();
566        }
567        parts += &element("button", &attrs, "");
568    }
569    if let Some(label) = header.get("label") {
570        parts += &match header.get("labelFor").and_then(Value::as_str) {
571            Some(target) if !target.is_empty() => element(
572                "label",
573                &json!({"class":"crudui-node__label","for":target}),
574                &part_text(label),
575            ),
576            _ => element(
577                "span",
578                &json!({"class":"crudui-node__label"}),
579                &part_text(label),
580            ),
581        };
582    }
583    if let Some(description) = header.get("description") {
584        parts += &element(
585            "p",
586            &json!({"class":"crudui-node__description"}),
587            &part_text(description),
588        );
589    }
590    for key in ["number", "title"] {
591        if let Some(value) = header.get(key) {
592            parts += &element(
593                "span",
594                &json!({"class":format!("crudui-node__{key}")}),
595                &part_text(value),
596            );
597        }
598    }
599    if let Some(summary) = header.get("summary") {
600        let mut attrs = json!({"class":"crudui-node__summary"});
601        if node["expanded"] == true {
602            attrs["hidden"] = "".into();
603        }
604        parts += &element("span", &attrs, &part_text(summary));
605    }
606    if let Some(count) = header.get("count") {
607        parts += &element(
608            "span",
609            &json!({"class":"crudui-node__count"}),
610            &part_text(count),
611        );
612    }
613    if node["controls"]["placement"] == "header" {
614        parts += &controls(&node["controls"]);
615    }
616    if parts.is_empty() {
617        return String::new();
618    }
619    element(
620        "div",
621        &json!({"class":classes(&["crudui-node__header", str_at(header, "className")]),"style":str_at(header, "style")}),
622        &parts,
623    )
624}
625
626fn body(node: &Value, errors: &NodeErrors) -> String {
627    let body = &node["body"];
628    let mut attrs = json!({"class":classes(&["crudui-node__body", str_at(body, "className")]),"style":str_at(body, "style")});
629    if let Some(id) = body["id"].as_str().filter(|id| !id.is_empty()) {
630        attrs["id"] = id.into();
631    }
632    if node["collapsible"] == true && node["expanded"] != true {
633        attrs["hidden"] = "".into();
634    }
635    let content = if node["checkbox"].is_object() {
636        let checkbox = &node["checkbox"];
637        let mut input = json!({"class":checkbox["className"],"id":checkbox["id"],"name":checkbox["name"],"type":"checkbox"});
638        if let Some(role) = checkbox.get("role") {
639            input["role"] = role.clone();
640        }
641        input["value"] = "1".into();
642        if checkbox["checked"] == true {
643            input["checked"] = "".into();
644        }
645        if let (Some(input), Some(declared)) =
646            (input.as_object_mut(), checkbox["attributes"].as_object())
647        {
648            input.extend(declared.clone());
649        }
650        // An inline layout writes the label in the header, so the checkbox has no caption.
651        let caption = match checkbox.get("caption") {
652            Some(caption) => element(
653                "label",
654                &json!({"for":checkbox["id"]}),
655                &escape(caption.as_str().unwrap_or("")),
656            ),
657            None => String::new(),
658        };
659        element("input", &input, "") + &caption
660    } else if node.get("widget").is_some() {
661        widget(&node["widget"])
662    } else {
663        nodes(&node["children"], errors)
664    };
665    element("div", &attrs, &content)
666}
667
668/// The errors slot of a block: one paragraph per message, present only with messages.
669fn errors_html(block: &str, messages: Option<&Vec<String>>) -> String {
670    let Some(messages) = messages.filter(|messages| !messages.is_empty()) else {
671        return String::new();
672    };
673    let paragraphs: String = messages
674        .iter()
675        .map(|text| {
676            element(
677                "p",
678                &json!({"class": format!("{block}__error")}),
679                &escape(text),
680            )
681        })
682        .collect();
683    element(
684        "div",
685        &json!({"class": format!("{block}__errors")}),
686        &paragraphs,
687    )
688}
689
690/// Render one node of the recursive form grammar; `errors` holds the node errors.
691fn node(node: &Value, errors: &NodeErrors) -> String {
692    let kind = str_at(node, "kind");
693    let modifier = format!("crudui-node--{kind}");
694    let sticky = if node["sticky"] == true {
695        "crudui-node--sticky"
696    } else {
697        ""
698    };
699    // A sticky row carries its depth on the root; the stylesheet derives its sticky line from it.
700    let depth = if node["sticky"] == true {
701        format!(
702            "--crudui-sticky-depth: {}",
703            node["stickyDepth"].as_u64().unwrap_or(0)
704        )
705    } else {
706        String::new()
707    };
708    let style = [str_at(node, "style"), depth.as_str()]
709        .into_iter()
710        .filter(|s| !s.is_empty())
711        .collect::<Vec<_>>()
712        .join("; ");
713    let mut attrs = json!({"class":classes(&["crudui-node", &modifier, sticky, str_at(node, "className")]),"style":style});
714    if kind != "row" && kind != "lang-item" {
715        if let Some(path) = node.get("path") {
716            attrs["data-field-path"] = js_string(path).into();
717        }
718    }
719    if let Some(key) = node.get("key") {
720        attrs["data-crudui-row-key"] = js_string(key).into();
721    }
722    if let Some(lang) = node.get("lang") {
723        attrs["data-lang"] = js_string(lang).into();
724    }
725    if node["hidden"] == true {
726        attrs["hidden"] = "".into();
727    }
728    if let (Some(attrs), Some(declared)) = (attrs.as_object_mut(), node["attributes"].as_object()) {
729        attrs.extend(declared.clone());
730    }
731    let footer = if node["controls"]["placement"] == "footer" {
732        element(
733            "div",
734            &json!({"class":"crudui-node__footer"}),
735            &controls(&node["controls"]),
736        )
737    } else {
738        String::new()
739    };
740    let header_html = header(node);
741    let header_slot = if node["sticky"] == true && !header_html.is_empty() {
742        element(
743            "div",
744            &json!({"class":"crudui-node__header-container"}),
745            &header_html,
746        )
747    } else {
748        header_html
749    };
750    let errors_slot = errors_html("crudui-node", errors.get(&(node as *const Value)));
751    element(
752        "div",
753        &attrs,
754        &(header_slot + &body(node, errors) + &errors_slot + &footer),
755    )
756}
757
758fn nodes(nodes: &Value, errors: &NodeErrors) -> String {
759    nodes
760        .as_array()
761        .into_iter()
762        .flatten()
763        .map(|child| node(child, errors))
764        .collect()
765}
766
767/// Render the `crudui-form` block without its footer: the translated root description when it
768/// is not empty, form errors and the top-level nodes.
769pub(crate) fn render_fields(fields: &[Value], model: &RenderModel, description: &str) -> String {
770    let description = if description.is_empty() {
771        String::new()
772    } else {
773        element(
774            "p",
775            &json!({"class":"crudui-form__description"}),
776            &escape(description),
777        )
778    };
779    element(
780        "div",
781        &json!({"class":"crudui-form"}),
782        &(description
783            + &errors_html("crudui-form", Some(&model.form_errors))
784            + &element(
785                "div",
786                &json!({"class":"crudui-form__body"}),
787                &fields
788                    .iter()
789                    .map(|field| node(field, &model.node_errors))
790                    .collect::<String>(),
791            )),
792    )
793}
794
795/// Render fields and the form buttons for a record as the complete form of `model`.
796pub(crate) fn render_form_html(
797    fields: &[Value],
798    template: &crate::FormTemplate,
799    data: &Value,
800    language: &str,
801    model: &RenderModel,
802) -> FormResult<String> {
803    let messages = crate::messages::form_messages(language)?;
804    let description = crate::util::translate(template.description.as_ref(), language);
805    let body = render_fields(fields, model, &description);
806    let footer = element(
807        "div",
808        &json!({"class":"crudui-form__footer"}),
809        &element(
810            "div",
811            &json!({"class":"crudui-controls","role":"group","aria-label":messages.form_actions}),
812            &crate::buttons::form_buttons_html(&template.buttons, data, language, messages),
813        ),
814    );
815    let block = format!(
816        "{}{footer}</div>",
817        body.strip_suffix("</div>")
818            .expect("form markup ends with its closing tag")
819    );
820    let Some(attrs) = &model.form else {
821        return Ok(block);
822    };
823    let hidden: String = model
824        .hidden
825        .iter()
826        .map(|(name, value)| {
827            element(
828                "input",
829                &json!({"type":"hidden","name":name,"value":value}),
830                "",
831            )
832        })
833        .collect();
834    Ok(element(
835        "form",
836        &Value::Object(attrs.clone()),
837        &(hidden + &block),
838    ))
839}
840
841/// Render an instance as the complete form without executing scripts or browser operations
842/// (form-runtime.md, "Complete form"). `options` is `None` or an object with the optional members
843/// `action`, `hidden`, `formErrors` and `errors`; `None` writes the `crudui-form` block alone.
844pub fn render_form(form: &Form, options: Option<&Value>) -> FormResult<String> {
845    let model = render_model(form.fields(), form.template().action.as_ref(), options)?;
846    render_form_html(
847        form.fields(),
848        form.template(),
849        &form.get_data(),
850        form.language(),
851        &model,
852    )
853}