Skip to main content

presolve_compiler/
html_codegen.rs

1use crate::component_graph::SerializableValue;
2use crate::template_graph::{
3    AttributeValue, ConditionalNode, ElementNode, FragmentNode, ListNode, TemplateAttribute,
4    TemplateChild, TemplateGraph,
5};
6
7struct ListRenderScope<'a> {
8    item_variable: &'a str,
9    item: Option<&'a SerializableValue>,
10    index_variable: Option<&'a str>,
11    index: Option<usize>,
12    instance_key: &'a str,
13}
14
15const LIST_INDEX_TOKEN: &str = "__ez_list_index__";
16const LIST_ITEM_TOKEN: &str = "__ez_list_item__";
17const LIST_KEY_TOKEN: &str = "__ez_list_key__";
18
19#[must_use]
20pub fn generate_static_html(template_graph: &TemplateGraph) -> String {
21    let mut output = String::new();
22
23    for template in &template_graph.templates {
24        if let Some(root) = &template.root {
25            output.push_str(&generate_element_html(root, None));
26            output.push('\n');
27        } else if let Some(fragment) = &template.root_fragment {
28            output.push_str(&generate_fragment_html(fragment, None));
29            output.push('\n');
30        }
31    }
32
33    output
34}
35
36fn generate_fragment_html(fragment: &FragmentNode, scope: Option<&ListRenderScope<'_>>) -> String {
37    generate_children_html_with_scope(&fragment.children, scope)
38}
39
40pub(crate) fn generate_children_html(children: &[TemplateChild]) -> String {
41    generate_children_html_with_scope(children, None)
42}
43
44fn generate_children_html_with_scope(
45    children: &[TemplateChild],
46    scope: Option<&ListRenderScope<'_>>,
47) -> String {
48    let mut html = String::new();
49
50    for child in children {
51        html.push_str(&generate_child_html(child, scope));
52    }
53
54    html
55}
56
57fn generate_element_html(element: &ElementNode, scope: Option<&ListRenderScope<'_>>) -> String {
58    let mut html = String::new();
59
60    html.push('<');
61    html.push_str(&element.tag_name);
62
63    html.push_str(" data-presolve-node=\"");
64    html.push_str(&escape_attr(&node_id_for_scope(&element.id.0, scope)));
65    html.push('"');
66
67    for attribute in &element.attributes {
68        if let Some(attribute_html) = generate_attribute_html(attribute, scope) {
69            html.push(' ');
70            html.push_str(&attribute_html);
71        }
72    }
73
74    if let Some(list_attribute_bindings) = list_attribute_bindings(&element.attributes, scope) {
75        html.push_str(" data-presolve-list-bindings=\"");
76        html.push_str(&escape_attr(&list_attribute_bindings));
77        html.push('"');
78    }
79
80    html.push('>');
81
82    for child in &element.children {
83        html.push_str(&generate_child_html(child, scope));
84    }
85
86    html.push_str("</");
87    html.push_str(&element.tag_name);
88    html.push('>');
89
90    html
91}
92
93fn generate_child_html(child: &TemplateChild, scope: Option<&ListRenderScope<'_>>) -> String {
94    match child {
95        TemplateChild::Text { value, .. } => escape_text(value),
96        TemplateChild::Binding {
97            id,
98            expression,
99            initial_value,
100            ..
101        } => {
102            let mut html = String::new();
103
104            html.push_str("<!-- presolve-binding:");
105            html.push_str(&escape_comment(&node_id_for_scope(&id.0, scope)));
106            html.push(':');
107            html.push_str(&escape_comment(expression));
108            html.push_str(" -->");
109
110            html.push_str(&escape_text(&binding_render_text(
111                expression,
112                initial_value.as_ref(),
113                scope,
114            )));
115
116            if scope.is_some() {
117                html.push_str("<!-- presolve-list-binding-end:");
118                html.push_str(&escape_comment(&node_id_for_scope(&id.0, scope)));
119                html.push_str(" -->");
120            }
121
122            html
123        }
124        TemplateChild::Element(element) => generate_element_html(element, scope),
125        TemplateChild::Fragment(fragment) => generate_fragment_html(fragment, scope),
126        TemplateChild::Conditional(conditional) => generate_conditional_html(conditional, scope),
127        TemplateChild::List(list) => generate_list_html(list),
128    }
129}
130
131fn generate_conditional_html(
132    conditional: &ConditionalNode,
133    scope: Option<&ListRenderScope<'_>>,
134) -> String {
135    let mut html = String::new();
136
137    html.push_str("<!-- presolve-conditional-start:");
138    html.push_str(&escape_comment(&node_id_for_scope(
139        &conditional.start_id.0,
140        scope,
141    )));
142    html.push(':');
143    html.push_str(&escape_comment(&conditional.condition));
144    html.push_str(" -->");
145
146    let children = match conditional.initial_value {
147        Some(SerializableValue::Boolean(true)) => &conditional.when_true,
148        _ => &conditional.when_false,
149    };
150
151    html.push_str(&generate_children_html_with_scope(children, scope));
152    html.push_str("<!-- presolve-conditional-end:");
153    html.push_str(&escape_comment(&node_id_for_scope(
154        &conditional.end_id.0,
155        scope,
156    )));
157    html.push_str(" -->");
158
159    html
160}
161
162pub(crate) fn generate_list_html(list: &ListNode) -> String {
163    let mut html = String::new();
164    html.push_str("<!-- presolve-list-start:");
165    html.push_str(&escape_comment(&list.start_id.0));
166    html.push(':');
167    html.push_str(&escape_comment(&list.iterable));
168    html.push_str(" -->");
169
170    if let Some(SerializableValue::Array(items)) = &list.initial_value {
171        for (index, item) in items.iter().enumerate() {
172            let instance_key = list_instance_key(list, item, index);
173            let scope = ListRenderScope {
174                item_variable: &list.item_variable,
175                item: Some(item),
176                index_variable: list.index_variable.as_deref(),
177                index: Some(index),
178                instance_key: &instance_key,
179            };
180            html.push_str(&generate_children_html_with_scope(
181                &list.item_template,
182                Some(&scope),
183            ));
184        }
185    }
186
187    html.push_str("<!-- presolve-list-end:");
188    html.push_str(&escape_comment(&list.end_id.0));
189    html.push_str(" -->");
190    html
191}
192
193pub(crate) fn generate_list_item_template_html(list: &ListNode) -> String {
194    let scope = ListRenderScope {
195        item_variable: &list.item_variable,
196        item: None,
197        index_variable: list.index_variable.as_deref(),
198        index: None,
199        instance_key: LIST_KEY_TOKEN,
200    };
201
202    generate_children_html_with_scope(&list.item_template, Some(&scope))
203}
204
205fn node_id_for_scope(id: &str, scope: Option<&ListRenderScope<'_>>) -> String {
206    scope.map_or_else(
207        || id.to_string(),
208        |scope| format!("{id}:{}", scope.instance_key),
209    )
210}
211
212fn binding_render_text(
213    expression: &str,
214    initial_value: Option<&SerializableValue>,
215    scope: Option<&ListRenderScope<'_>>,
216) -> String {
217    binding_render_value(expression, initial_value, scope)
218}
219
220fn binding_render_value(
221    expression: &str,
222    initial_value: Option<&SerializableValue>,
223    scope: Option<&ListRenderScope<'_>>,
224) -> String {
225    if let Some(scope) = scope {
226        if expression == scope.item_variable {
227            return scope.item.map_or_else(
228                || LIST_ITEM_TOKEN.to_string(),
229                SerializableValue::render_text,
230            );
231        }
232        if let Some(value) = list_item_member_value(expression, scope) {
233            return value.render_text();
234        }
235        if scope.index_variable == Some(expression) {
236            return scope
237                .index
238                .map_or_else(|| LIST_INDEX_TOKEN.to_string(), |index| index.to_string());
239        }
240    }
241
242    initial_value.map_or_else(String::new, SerializableValue::render_text)
243}
244
245fn list_instance_key(list: &ListNode, item: &SerializableValue, index: usize) -> String {
246    if list.key_expression == list.item_variable {
247        return serializable_list_key(item).unwrap_or_else(|| index.to_string());
248    }
249
250    if let Some(path) = list_member_key_path(list) {
251        return item
252            .member_path_value(path)
253            .and_then(serializable_list_key)
254            .unwrap_or_else(|| index.to_string());
255    }
256
257    if list.index_variable.as_deref() == Some(list.key_expression.as_str()) {
258        return index.to_string();
259    }
260
261    index.to_string()
262}
263
264fn list_item_member_value<'a>(
265    expression: &str,
266    scope: &'a ListRenderScope<'a>,
267) -> Option<&'a SerializableValue> {
268    let path = expression
269        .strip_prefix(scope.item_variable)?
270        .strip_prefix('.')?;
271
272    scope.item?.member_path_value(path)
273}
274
275fn list_member_key_path(list: &ListNode) -> Option<&str> {
276    list.key_expression
277        .strip_prefix(&list.item_variable)
278        .and_then(|suffix| suffix.strip_prefix('.'))
279        .filter(|path| !path.is_empty() && !path.split('.').any(str::is_empty))
280}
281
282fn serializable_list_key(value: &SerializableValue) -> Option<String> {
283    match value {
284        SerializableValue::Null => Some("null".to_string()),
285        SerializableValue::Number(value) | SerializableValue::String(value) => Some(value.clone()),
286        SerializableValue::Boolean(value) => Some(value.to_string()),
287        SerializableValue::Array(_) | SerializableValue::Object(_) => None,
288    }
289}
290
291fn generate_attribute_html(
292    attribute: &TemplateAttribute,
293    scope: Option<&ListRenderScope<'_>>,
294) -> Option<String> {
295    match &attribute.value {
296        AttributeValue::Boolean => Some(attribute.name.clone()),
297        AttributeValue::Binding {
298            expression,
299            initial_value,
300            ..
301        } if is_boolean_attribute(&attribute.name)
302            && binding_render_value(expression, initial_value.as_ref(), scope) != "true" =>
303        {
304            None
305        }
306        _ => {
307            let mut html = String::new();
308            html.push_str(&attribute.name);
309            html.push_str("=\"");
310            html.push_str(&escape_attr(&attribute_value_string(attribute, scope)));
311            html.push('"');
312            Some(html)
313        }
314    }
315}
316
317fn attribute_value_string(
318    attribute: &TemplateAttribute,
319    scope: Option<&ListRenderScope<'_>>,
320) -> String {
321    match &attribute.value {
322        AttributeValue::Boolean => String::new(),
323        AttributeValue::Static(value) => value.clone(),
324        AttributeValue::Binding {
325            expression,
326            initial_value,
327            ..
328        } => binding_render_value(expression, initial_value.as_ref(), scope),
329        AttributeValue::EventHandler { handler, .. } => handler.clone(),
330        AttributeValue::BindingList(bindings) => bindings.join(","),
331    }
332}
333
334fn list_attribute_bindings(
335    attributes: &[TemplateAttribute],
336    scope: Option<&ListRenderScope<'_>>,
337) -> Option<String> {
338    let scope = scope?;
339    let bindings = attributes
340        .iter()
341        .filter_map(|attribute| {
342            let AttributeValue::Binding { expression, .. } = &attribute.value else {
343                return None;
344            };
345
346            list_item_binding_expression(expression, scope)
347                .then(|| format!("{}={expression}", attribute.name))
348        })
349        .collect::<Vec<_>>();
350
351    (!bindings.is_empty()).then(|| bindings.join(";"))
352}
353
354fn list_item_binding_expression(expression: &str, scope: &ListRenderScope<'_>) -> bool {
355    expression == scope.item_variable
356        || scope.index_variable == Some(expression)
357        || expression
358            .strip_prefix(scope.item_variable)
359            .and_then(|suffix| suffix.strip_prefix('.'))
360            .is_some_and(|path| !path.is_empty() && !path.split('.').any(str::is_empty))
361}
362
363fn is_boolean_attribute(name: &str) -> bool {
364    matches!(
365        name,
366        "allowfullscreen"
367            | "async"
368            | "autofocus"
369            | "autoplay"
370            | "checked"
371            | "controls"
372            | "default"
373            | "defer"
374            | "disabled"
375            | "formnovalidate"
376            | "hidden"
377            | "inert"
378            | "loop"
379            | "multiple"
380            | "muted"
381            | "nomodule"
382            | "novalidate"
383            | "open"
384            | "readonly"
385            | "required"
386            | "reversed"
387            | "selected"
388    )
389}
390
391fn escape_attr(value: &str) -> String {
392    let mut output = String::new();
393
394    for ch in value.chars() {
395        match ch {
396            '&' => output.push_str("&amp;"),
397            '"' => output.push_str("&quot;"),
398            '<' => output.push_str("&lt;"),
399            '>' => output.push_str("&gt;"),
400            ch => output.push(ch),
401        }
402    }
403
404    output
405}
406
407fn escape_text(value: &str) -> String {
408    let mut output = String::new();
409
410    for ch in value.chars() {
411        match ch {
412            '&' => output.push_str("&amp;"),
413            '<' => output.push_str("&lt;"),
414            '>' => output.push_str("&gt;"),
415            ch => output.push(ch),
416        }
417    }
418
419    output
420}
421
422fn escape_comment(value: &str) -> String {
423    value.replace("--", "—")
424}
425
426#[cfg(test)]
427mod tests {
428    use super::escape_attr;
429
430    #[test]
431    fn escapes_html_attributes() {
432        assert_eq!(
433            escape_attr(r#"this.value<&">"#),
434            "this.value&lt;&amp;&quot;&gt;"
435        );
436    }
437}