Skip to main content

coil_template/
runtime.rs

1use super::*;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct TemplateRuntime {
5    registry: TemplateRegistry,
6}
7
8impl TemplateRuntime {
9    pub fn new(registry: TemplateRegistry) -> Self {
10        Self { registry }
11    }
12
13    pub fn render_document(
14        &self,
15        namespaces: &[TemplateNamespace],
16        request: DocumentRenderRequest,
17    ) -> Result<RenderOutput, TemplateModelError> {
18        let layout = self.registry.resolve(namespaces, &request.layout)?;
19        if layout.kind != TemplateKind::Layout {
20            return Err(TemplateModelError::TemplateKindMismatch {
21                name: request.layout.name().clone(),
22                expected: TemplateKind::Layout,
23                actual: layout.kind,
24            });
25        }
26
27        let html = self.render_nodes(
28            namespaces,
29            &request.model,
30            &request.slots,
31            &layout.nodes,
32            RenderSurface::Document,
33        )?;
34
35        Ok(RenderOutput { html })
36    }
37
38    pub fn render_fragment(
39        &self,
40        namespaces: &[TemplateNamespace],
41        request: FragmentRenderRequest,
42    ) -> Result<RenderOutput, TemplateModelError> {
43        let fragment = self.registry.resolve(namespaces, &request.fragment)?;
44        if fragment.kind != TemplateKind::Fragment {
45            return Err(TemplateModelError::FragmentCannotRenderLayout {
46                name: request.fragment.name().clone(),
47            });
48        }
49
50        let html = self.render_nodes(
51            namespaces,
52            &request.model,
53            &BTreeMap::new(),
54            &fragment.nodes,
55            RenderSurface::Fragment,
56        )?;
57
58        Ok(RenderOutput { html })
59    }
60
61    fn render_nodes(
62        &self,
63        namespaces: &[TemplateNamespace],
64        model: &RenderModel,
65        slots: &BTreeMap<SlotName, SlotFill>,
66        nodes: &[Node],
67        surface: RenderSurface,
68    ) -> Result<String, TemplateModelError> {
69        let mut rendered = String::new();
70        for node in nodes {
71            match node {
72                Node::StaticText(value) => rendered.push_str(value),
73                Node::Value(key) => {
74                    let value = model
75                        .get_path(key)
76                        .ok_or_else(|| TemplateModelError::MissingValue { key: key.clone() })?;
77                    rendered.push_str(&escape_html_text(value.as_text(key)?));
78                }
79                Node::RawValue(key) => {
80                    let value = model
81                        .get_path(key)
82                        .ok_or_else(|| TemplateModelError::MissingValue { key: key.clone() })?;
83                    match value {
84                        RenderValue::TrustedHtml(value) => rendered.push_str(value.as_str()),
85                        RenderValue::Text(_)
86                        | RenderValue::Bool(_)
87                        | RenderValue::List(_)
88                        | RenderValue::Object(_) => {
89                            return Err(TemplateModelError::ValueTypeMismatch {
90                                key: key.clone(),
91                                expected: "trusted_html",
92                            });
93                        }
94                    }
95                }
96                Node::Expression(expression) => {
97                    let value = self.evaluate_expression(model, expression)?;
98                    rendered.push_str(&escape_html_text(&render_expression_as_text(
99                        expression, value,
100                    )?));
101                }
102                Node::RawExpression(expression) => {
103                    let value = self.evaluate_expression(model, expression)?;
104                    match value {
105                        RenderValue::TrustedHtml(value) => rendered.push_str(value.as_str()),
106                        RenderValue::Text(_)
107                        | RenderValue::Bool(_)
108                        | RenderValue::List(_)
109                        | RenderValue::Object(_) => {
110                            return Err(TemplateModelError::ValueTypeMismatch {
111                                key: expression_label(expression),
112                                expected: "trusted_html",
113                            });
114                        }
115                    }
116                }
117                Node::Element(element) => {
118                    if element.tag == "coil:block" {
119                        rendered.push_str(&self.render_nodes(
120                            namespaces,
121                            model,
122                            slots,
123                            &element.children,
124                            surface,
125                        )?);
126                        continue;
127                    }
128                    rendered.push('<');
129                    rendered.push_str(&element.tag);
130                    for attribute in &element.attributes {
131                        rendered.push(' ');
132                        rendered.push_str(&attribute.name);
133                        rendered.push_str("=\"");
134                        match &attribute.value {
135                            AttributeValue::Static(value) => {
136                                rendered.push_str(&escape_html_attribute(value))
137                            }
138                            AttributeValue::DynamicText(key) => {
139                                let value = model.get_path(key).ok_or_else(|| {
140                                    TemplateModelError::MissingValue { key: key.clone() }
141                                })?;
142                                rendered.push_str(&escape_html_attribute(value.as_text(key)?));
143                            }
144                            AttributeValue::DynamicExpression(expression) => {
145                                let value = self.evaluate_expression(model, expression)?;
146                                match value {
147                                    RenderValue::Text(value) => {
148                                        rendered.push_str(&escape_html_attribute(&value));
149                                    }
150                                    RenderValue::TrustedHtml(value) => {
151                                        rendered.push_str(&escape_html_attribute(value.as_str()));
152                                    }
153                                    RenderValue::Bool(value) => {
154                                        rendered
155                                            .push_str(&escape_html_attribute(&value.to_string()));
156                                    }
157                                    RenderValue::List(_) | RenderValue::Object(_) => {
158                                        return Err(TemplateModelError::ValueTypeMismatch {
159                                            key: attribute.name.clone(),
160                                            expected: "text",
161                                        });
162                                    }
163                                }
164                            }
165                        }
166                        rendered.push('"');
167                    }
168                    rendered.push('>');
169                    rendered.push_str(&self.render_nodes(
170                        namespaces,
171                        model,
172                        slots,
173                        &element.children,
174                        surface,
175                    )?);
176                    rendered.push_str("</");
177                    rendered.push_str(&element.tag);
178                    rendered.push('>');
179                }
180                Node::Slot(slot) => {
181                    if let Some(fill) = slots.get(&slot.name) {
182                        rendered
183                            .push_str(&self.render_slot_fill(namespaces, model, fill, surface)?);
184                    } else if let Some(fallback) = &slot.fallback {
185                        rendered.push_str(
186                            &self.render_nodes(namespaces, model, slots, fallback, surface)?,
187                        );
188                    } else {
189                        return Err(TemplateModelError::MissingSlotFill {
190                            slot: slot.name.clone(),
191                        });
192                    }
193                }
194                Node::With { bindings, children } => {
195                    let mut extended = model.clone();
196                    for binding in bindings {
197                        let value = self.evaluate_expression(model, &binding.expression)?;
198                        extended = extended.with_value(binding.key.clone(), value)?;
199                    }
200                    rendered.push_str(
201                        &self.render_nodes(namespaces, &extended, slots, children, surface)?,
202                    );
203                }
204                Node::Conditional {
205                    condition,
206                    negated,
207                    children,
208                } => {
209                    let enabled = self.evaluate_condition(model, condition)?;
210                    let enabled = if *negated { !enabled } else { enabled };
211
212                    if enabled {
213                        rendered.push_str(
214                            &self.render_nodes(namespaces, model, slots, children, surface)?,
215                        );
216                    }
217                }
218                Node::Each {
219                    item,
220                    collection,
221                    children,
222                } => {
223                    let value = model.get_path(collection).ok_or_else(|| {
224                        TemplateModelError::MissingValue {
225                            key: collection.clone(),
226                        }
227                    })?;
228                    for entry in value.as_list(collection)? {
229                        let loop_model = model
230                            .merged_with(entry)
231                            .with_object(item.clone(), entry.clone())?;
232                        rendered.push_str(&self.render_nodes(
233                            namespaces,
234                            &loop_model,
235                            slots,
236                            children,
237                            surface,
238                        )?);
239                    }
240                }
241                Node::Include(selector) => {
242                    let template = self.registry.resolve(namespaces, selector)?;
243                    if template.kind != TemplateKind::Fragment {
244                        return Err(TemplateModelError::LayoutCannotBeIncludedAsFragment {
245                            name: selector.name().clone(),
246                        });
247                    }
248                    rendered.push_str(&self.render_nodes(
249                        namespaces,
250                        model,
251                        slots,
252                        &template.nodes,
253                        surface,
254                    )?);
255                }
256            }
257        }
258
259        if surface == RenderSurface::Fragment && rendered.starts_with("<!DOCTYPE") {
260            return Err(TemplateModelError::FragmentCannotRenderLayout {
261                name: TemplateName::new("document").expect("constant token is valid"),
262            });
263        }
264
265        Ok(rendered)
266    }
267
268    fn render_slot_fill(
269        &self,
270        namespaces: &[TemplateNamespace],
271        model: &RenderModel,
272        fill: &SlotFill,
273        surface: RenderSurface,
274    ) -> Result<String, TemplateModelError> {
275        match fill {
276            SlotFill::Template(selector) => {
277                let template = self.registry.resolve(namespaces, selector)?;
278                if template.kind != TemplateKind::Fragment {
279                    return Err(TemplateModelError::LayoutCannotBeIncludedAsFragment {
280                        name: selector.name().clone(),
281                    });
282                }
283                self.render_nodes(
284                    namespaces,
285                    model,
286                    &BTreeMap::new(),
287                    &template.nodes,
288                    surface,
289                )
290            }
291            SlotFill::Nodes(nodes) => {
292                self.render_nodes(namespaces, model, &BTreeMap::new(), nodes, surface)
293            }
294        }
295    }
296
297    fn evaluate_expression(
298        &self,
299        model: &RenderModel,
300        expression: &TemplateExpression,
301    ) -> Result<RenderValue, TemplateModelError> {
302        match expression {
303            TemplateExpression::ModelKey(key) => model
304                .get_path(key)
305                .cloned()
306                .ok_or_else(|| TemplateModelError::MissingValue { key: key.clone() }),
307            TemplateExpression::LiteralText(value) => Ok(RenderValue::text(value.clone())),
308            TemplateExpression::LiteralBool(value) => Ok(RenderValue::bool(*value)),
309            TemplateExpression::AssetPath(value) => Ok(RenderValue::text(
310                model
311                    .get_asset_path(value)
312                    .unwrap_or(value.as_str())
313                    .to_string(),
314            )),
315            TemplateExpression::TranslationKey(key) => model
316                .get_translation(key)
317                .map(|value| RenderValue::text(value.to_string()))
318                .ok_or_else(|| TemplateModelError::MissingTranslation { key: key.clone() }),
319        }
320    }
321
322    fn evaluate_condition(
323        &self,
324        model: &RenderModel,
325        condition: &ConditionExpression,
326    ) -> Result<bool, TemplateModelError> {
327        match condition {
328            ConditionExpression::Literal(value) => Ok(*value),
329            ConditionExpression::Key(key) => {
330                let value = model
331                    .get_path(key)
332                    .ok_or_else(|| TemplateModelError::MissingValue { key: key.clone() })?;
333                value.as_bool(key)
334            }
335        }
336    }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340enum RenderSurface {
341    Document,
342    Fragment,
343}
344
345pub(crate) fn require_non_empty(
346    field: &'static str,
347    value: String,
348) -> Result<String, TemplateModelError> {
349    let trimmed = value.trim();
350    if trimmed.is_empty() {
351        Err(TemplateModelError::EmptyField { field })
352    } else {
353        Ok(trimmed.to_string())
354    }
355}
356
357pub(crate) fn validate_token(
358    field: &'static str,
359    value: String,
360) -> Result<String, TemplateModelError> {
361    let trimmed = require_non_empty(field, value)?;
362    if trimmed
363        .chars()
364        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))
365    {
366        Ok(trimmed)
367    } else {
368        Err(TemplateModelError::InvalidToken {
369            field,
370            value: trimmed,
371        })
372    }
373}
374
375pub(crate) fn validate_element_name(value: String) -> Result<String, TemplateModelError> {
376    let tag = require_non_empty("element_tag", value)?;
377    if tag
378        .chars()
379        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | ':'))
380    {
381        Ok(tag)
382    } else {
383        Err(TemplateModelError::InvalidElementName { tag })
384    }
385}
386
387pub(crate) fn validate_attribute_name(value: String) -> Result<String, TemplateModelError> {
388    let name = require_non_empty("attribute_name", value)?;
389    if name
390        .chars()
391        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | ':' | '_'))
392    {
393        Ok(name)
394    } else {
395        Err(TemplateModelError::InvalidAttributeName { name })
396    }
397}
398
399pub(crate) fn escape_html_text(value: &str) -> String {
400    value
401        .replace('&', "&amp;")
402        .replace('<', "&lt;")
403        .replace('>', "&gt;")
404}
405
406pub(crate) fn escape_html_attribute(value: &str) -> String {
407    escape_html_text(value)
408        .replace('"', "&quot;")
409        .replace('\'', "&#39;")
410}
411
412fn render_expression_as_text(
413    expression: &TemplateExpression,
414    value: RenderValue,
415) -> Result<String, TemplateModelError> {
416    match value {
417        RenderValue::Text(value) => Ok(value),
418        RenderValue::TrustedHtml(value) => Ok(value.as_str().to_string()),
419        RenderValue::Bool(value) => Ok(value.to_string()),
420        RenderValue::List(_) | RenderValue::Object(_) => {
421            Err(TemplateModelError::ValueTypeMismatch {
422                key: expression_label(expression),
423                expected: "text",
424            })
425        }
426    }
427}
428
429fn expression_label(expression: &TemplateExpression) -> String {
430    match expression {
431        TemplateExpression::ModelKey(key) => key.clone(),
432        TemplateExpression::LiteralText(value) => value.clone(),
433        TemplateExpression::LiteralBool(value) => value.to_string(),
434        TemplateExpression::AssetPath(path) => format!("asset({path})"),
435        TemplateExpression::TranslationKey(key) => format!("t('{key}')"),
436    }
437}