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("&"),
397 '"' => output.push_str("""),
398 '<' => output.push_str("<"),
399 '>' => output.push_str(">"),
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("&"),
413 '<' => output.push_str("<"),
414 '>' => output.push_str(">"),
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<&">"
435 );
436 }
437}