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('&', "&")
402 .replace('<', "<")
403 .replace('>', ">")
404}
405
406pub(crate) fn escape_html_attribute(value: &str) -> String {
407 escape_html_text(value)
408 .replace('"', """)
409 .replace('\'', "'")
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}