1use super::*;
2use html5ever::tendril::TendrilSink;
3use markup5ever_rcdom::{Handle, NodeData, RcDom};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Default, Clone, Copy)]
8pub struct TemplateSourceParser;
9
10impl TemplateSourceParser {
11 pub fn new() -> Self {
12 Self
13 }
14
15 pub fn parse_layout(
16 &self,
17 namespace: TemplateNamespace,
18 name: TemplateName,
19 source: &str,
20 ) -> Result<TemplateDefinition, TemplateModelError> {
21 self.parse_definition(namespace, name, source, TemplateKind::Layout)
22 }
23
24 pub fn parse_fragment(
25 &self,
26 namespace: TemplateNamespace,
27 name: TemplateName,
28 source: &str,
29 ) -> Result<TemplateDefinition, TemplateModelError> {
30 self.parse_definition(namespace, name, source, TemplateKind::Fragment)
31 }
32
33 pub fn parse_auto(
34 &self,
35 namespace: TemplateNamespace,
36 name: TemplateName,
37 source: &str,
38 ) -> Result<TemplateDefinition, TemplateModelError> {
39 let kind = if source.contains("coil:fragment=") {
40 TemplateKind::Fragment
41 } else {
42 TemplateKind::Layout
43 };
44 self.parse_definition(namespace, name, source, kind)
45 }
46
47 pub fn load_directory<P>(
48 &self,
49 root: P,
50 namespace: TemplateNamespace,
51 ) -> Result<Vec<TemplateDefinition>, TemplateModelError>
52 where
53 P: AsRef<Path>,
54 {
55 let root = root.as_ref();
56 if !root.exists() {
57 return Ok(Vec::new());
58 }
59
60 let mut files = Vec::new();
61 collect_template_files(root, &mut files)?;
62 files.sort();
63
64 let mut templates = Vec::with_capacity(files.len());
65 for path in files {
66 templates.push(self.parse_file(root, path, namespace.clone())?);
67 }
68
69 Ok(templates)
70 }
71
72 pub fn parse_file<R, P>(
73 &self,
74 root: R,
75 path: P,
76 namespace: TemplateNamespace,
77 ) -> Result<TemplateDefinition, TemplateModelError>
78 where
79 R: AsRef<Path>,
80 P: AsRef<Path>,
81 {
82 let root = root.as_ref();
83 let path = path.as_ref();
84 let source =
85 fs::read_to_string(path).map_err(|error| TemplateModelError::TemplateRead {
86 path: path.display().to_string(),
87 message: error.to_string(),
88 })?;
89
90 let kind = template_kind_for_path(root, path);
91 self.parse_source(root, path, &source, namespace, kind)
92 }
93
94 pub fn parse_source<R, P>(
95 &self,
96 root: R,
97 path: P,
98 source: &str,
99 namespace: TemplateNamespace,
100 kind: TemplateKind,
101 ) -> Result<TemplateDefinition, TemplateModelError>
102 where
103 R: AsRef<Path>,
104 P: AsRef<Path>,
105 {
106 let root = root.as_ref();
107 let path = path.as_ref();
108 let relative = path.strip_prefix(root).unwrap_or(path).with_extension("");
109 let name = TemplateName::new(relative.to_string_lossy().replace('\\', "/"))?;
110 let dom = html5ever::parse_document(RcDom::default(), Default::default()).one(source);
111
112 let nodes = match kind {
113 TemplateKind::Layout => render_document_nodes(&dom, path)?,
114 TemplateKind::Fragment => render_fragment_nodes(&dom, path)?,
115 };
116
117 Ok(match kind {
118 TemplateKind::Layout => TemplateDefinition::layout(namespace, name, nodes),
119 TemplateKind::Fragment => TemplateDefinition::fragment(namespace, name, nodes),
120 })
121 }
122
123 fn parse_definition(
124 &self,
125 namespace: TemplateNamespace,
126 name: TemplateName,
127 source: &str,
128 kind: TemplateKind,
129 ) -> Result<TemplateDefinition, TemplateModelError> {
130 let dom = html5ever::parse_document(RcDom::default(), Default::default()).one(source);
131 let nodes = match kind {
132 TemplateKind::Layout => render_document_nodes(&dom, Path::new("<template>"))?,
133 TemplateKind::Fragment => render_fragment_nodes(&dom, Path::new("<template>"))?,
134 };
135
136 Ok(match kind {
137 TemplateKind::Layout => TemplateDefinition::layout(namespace, name, nodes),
138 TemplateKind::Fragment => TemplateDefinition::fragment(namespace, name, nodes),
139 })
140 }
141}
142
143fn collect_template_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), TemplateModelError> {
144 for entry in fs::read_dir(dir).map_err(|error| TemplateModelError::TemplateRead {
145 path: dir.display().to_string(),
146 message: error.to_string(),
147 })? {
148 let entry = entry.map_err(|error| TemplateModelError::TemplateRead {
149 path: dir.display().to_string(),
150 message: error.to_string(),
151 })?;
152 let path = entry.path();
153 if path.is_dir() {
154 collect_template_files(&path, files)?;
155 continue;
156 }
157
158 if path
159 .extension()
160 .and_then(|ext| ext.to_str())
161 .map(|ext| ext.eq_ignore_ascii_case("html"))
162 .unwrap_or(false)
163 {
164 files.push(path);
165 }
166 }
167
168 Ok(())
169}
170
171fn template_kind_for_path(root: &Path, path: &Path) -> TemplateKind {
172 let relative = path.strip_prefix(root).unwrap_or(path);
173 match relative
174 .components()
175 .next()
176 .and_then(|component| component.as_os_str().to_str())
177 {
178 Some("components") | Some("fragments") => TemplateKind::Fragment,
179 _ => TemplateKind::Layout,
180 }
181}
182
183fn render_document_nodes(dom: &RcDom, path: &Path) -> Result<Vec<Node>, TemplateModelError> {
184 let mut rendered = Vec::new();
185 for child in dom.document.children.borrow().iter() {
186 rendered.extend(render_node(child, path)?);
187 }
188 Ok(rendered)
189}
190
191fn render_fragment_nodes(dom: &RcDom, path: &Path) -> Result<Vec<Node>, TemplateModelError> {
192 if let Some(body) = find_body(dom.document.clone()) {
193 let mut rendered = Vec::new();
194 for child in body.children.borrow().iter() {
195 rendered.extend(render_node(child, path)?);
196 }
197 return Ok(rendered);
198 }
199
200 render_document_nodes(dom, path)
201}
202
203fn find_body(handle: Handle) -> Option<Handle> {
204 for child in handle.children.borrow().iter() {
205 if let NodeData::Element { name, .. } = &child.data {
206 if name.local.as_ref().eq_ignore_ascii_case("html") {
207 for grandchild in child.children.borrow().iter() {
208 if let NodeData::Element { name, .. } = &grandchild.data {
209 if name.local.as_ref().eq_ignore_ascii_case("body") {
210 return Some(grandchild.clone());
211 }
212 }
213 }
214 }
215 }
216
217 if let Some(body) = find_body(child.clone()) {
218 return Some(body);
219 }
220 }
221
222 None
223}
224
225fn render_node(handle: &Handle, path: &Path) -> Result<Vec<Node>, TemplateModelError> {
226 match &handle.data {
227 NodeData::Document => {
228 let mut rendered = Vec::new();
229 for child in handle.children.borrow().iter() {
230 rendered.extend(render_node(child, path)?);
231 }
232 Ok(rendered)
233 }
234 NodeData::Doctype { name, .. } => Ok(vec![Node::static_text(format!("<!DOCTYPE {name}>"))]),
235 NodeData::Text { contents } => {
236 let text = contents.borrow().to_string();
237 if text.trim().is_empty() {
238 return Ok(Vec::new());
239 }
240 Ok(vec![Node::static_text(text)])
241 }
242 NodeData::Comment { .. } => Ok(Vec::new()),
243 NodeData::Element { name, attrs, .. } => render_element(
244 name.local.as_ref(),
245 attrs.borrow().iter(),
246 handle.children.borrow().iter(),
247 path,
248 ),
249 _ => Ok(Vec::new()),
250 }
251}
252
253fn render_element<'a>(
254 tag: &str,
255 attrs: impl Iterator<Item = &'a markup5ever::Attribute>,
256 children: impl Iterator<Item = &'a Handle>,
257 path: &Path,
258) -> Result<Vec<Node>, TemplateModelError> {
259 let mut static_attrs = Vec::new();
260 let mut dynamic_attrs = Vec::new();
261 let mut include_selector: Option<IncludeTarget> = None;
262 let mut slot_name: Option<String> = None;
263 let mut text_expression: Option<TemplateExpression> = None;
264 let mut raw_text_expression: Option<TemplateExpression> = None;
265 let mut with_bindings: Vec<TemplateBinding> = Vec::new();
266 let mut each_binding: Option<(String, String)> = None;
267 let mut condition: Option<(ConditionExpression, bool)> = None;
268
269 for attr in attrs {
270 let name = attr.name.local.to_string();
271 if name.starts_with("xmlns:") {
272 continue;
273 }
274
275 let value = attr.value.to_string();
276 if let Some(directive) = name.strip_prefix("coil:") {
277 match directive {
278 "fragment" => {}
279 "text" => text_expression = Some(parse_template_expression(&value)?),
280 "t" => text_expression = Some(parse_translation_expression(&value)?),
281 "utext" => raw_text_expression = Some(parse_template_expression(&value)?),
282 "replace" => {
283 include_selector = Some(IncludeTarget::Replace(parse_selector_ref(&value)?))
284 }
285 "include" => {
286 include_selector = Some(IncludeTarget::Insert(parse_selector_ref(&value)?))
287 }
288 "insert" => {
289 let selector = parse_selector_ref(&value)?;
290 if selector.template.is_none() {
291 if let Some(fragment) = selector.fragment {
292 slot_name = Some(fragment);
293 }
294 } else {
295 include_selector = Some(IncludeTarget::Insert(selector));
296 }
297 }
298 "slot" => slot_name = Some(parse_slot_name(&value)),
299 "attr" => dynamic_attrs.extend(parse_attr_bindings(&value)?),
300 "with" => with_bindings = parse_with_bindings(&value)?,
301 "if" => condition = Some((parse_condition(&value)?, false)),
302 "unless" => condition = Some((parse_condition(&value)?, true)),
303 "each" => each_binding = Some(parse_each_expression(&value)?),
304 other => dynamic_attrs.push(AttributeNode::dynamic_expression(
305 other,
306 parse_template_expression(&value)?,
307 )?),
308 }
309 continue;
310 }
311
312 static_attrs.push(AttributeNode::static_value(name, value)?);
313 }
314
315 let mut rendered_children = render_children(children, path)?;
316 if let Some(expression) = text_expression {
317 rendered_children = vec![Node::expression(expression)];
318 } else if let Some(expression) = raw_text_expression {
319 rendered_children = vec![Node::raw_expression(expression)];
320 }
321
322 let mut element = if tag.eq_ignore_ascii_case("coil:block") {
323 None
324 } else {
325 Some(build_element_node(
326 tag,
327 rendered_children.clone(),
328 static_attrs,
329 dynamic_attrs,
330 )?)
331 };
332
333 let mut nodes = match (slot_name, include_selector, element.take()) {
334 (Some(slot), _, Some(mut element)) => {
335 element.children = vec![Node::Slot(
336 SlotNode::new(SlotName::new(slot)?).with_fallback(rendered_children),
337 )];
338 vec![Node::Element(element)]
339 }
340 (Some(slot), _, None) => vec![Node::Slot(
341 SlotNode::new(SlotName::new(slot)?).with_fallback(rendered_children),
342 )],
343 (None, Some(IncludeTarget::Replace(selector)), _) => {
344 vec![Node::include(selector_to_template_selector(selector)?)]
345 }
346 (None, Some(IncludeTarget::Insert(selector)), Some(mut element)) => {
347 element.children = vec![Node::include(selector_to_template_selector(selector)?)];
348 vec![Node::Element(element)]
349 }
350 (None, Some(IncludeTarget::Insert(selector)), None) => {
351 vec![Node::include(selector_to_template_selector(selector)?)]
352 }
353 (None, None, Some(element)) => vec![Node::Element(element)],
354 (None, None, None) => rendered_children,
355 };
356
357 if let Some((condition, negated)) = condition {
358 nodes = vec![match (condition, negated) {
359 (ConditionExpression::Key(key), false) => Node::conditional(key, nodes)?,
360 (ConditionExpression::Key(key), true) => Node::conditional_not(key, nodes)?,
361 (ConditionExpression::Literal(value), false) => Node::conditional_literal(value, nodes),
362 (ConditionExpression::Literal(value), true) => Node::conditional_literal(!value, nodes),
363 }];
364 }
365
366 if let Some((item, collection)) = each_binding {
367 nodes = vec![Node::each(item, collection, nodes)?];
368 }
369
370 if !with_bindings.is_empty() {
371 nodes = vec![Node::with(with_bindings, nodes)];
372 }
373
374 Ok(nodes)
375}
376
377fn build_element_node(
378 tag: &str,
379 children: Vec<Node>,
380 static_attrs: Vec<AttributeNode>,
381 dynamic_attrs: Vec<AttributeNode>,
382) -> Result<ElementNode, TemplateModelError> {
383 let mut element = ElementNode::new(tag, children)?;
384 element.attributes = Vec::with_capacity(static_attrs.len() + dynamic_attrs.len());
385 for attribute in static_attrs {
386 push_attribute(&mut element.attributes, attribute);
387 }
388 for attribute in dynamic_attrs {
389 push_attribute(&mut element.attributes, attribute);
390 }
391 Ok(element)
392}
393
394fn push_attribute(attributes: &mut Vec<AttributeNode>, attribute: AttributeNode) {
395 if let Some(existing) = attributes
396 .iter_mut()
397 .find(|existing| existing.name == attribute.name)
398 {
399 *existing = attribute;
400 return;
401 }
402
403 attributes.push(attribute);
404}
405
406fn render_children<'a>(
407 children: impl Iterator<Item = &'a Handle>,
408 path: &Path,
409) -> Result<Vec<Node>, TemplateModelError> {
410 let mut rendered = Vec::new();
411 for child in children {
412 rendered.extend(render_node(child, path)?);
413 }
414 Ok(rendered)
415}
416
417#[derive(Debug, Clone)]
418enum IncludeTarget {
419 Replace(TemplateSelectorParts),
420 Insert(TemplateSelectorParts),
421}
422
423#[derive(Debug, Clone)]
424struct TemplateSelectorParts {
425 template: Option<String>,
426 fragment: Option<String>,
427}
428
429fn selector_to_template_selector(
430 selector: TemplateSelectorParts,
431) -> Result<TemplateSelector, TemplateModelError> {
432 let template = selector
433 .template
434 .ok_or_else(|| TemplateModelError::ParseError {
435 line: 0,
436 column: 0,
437 message: match selector.fragment {
438 Some(fragment) => {
439 format!("selector is missing a template name before `::{fragment}`")
440 }
441 None => "selector is missing a template name".to_string(),
442 },
443 })?;
444 Ok(TemplateSelector::new(TemplateName::new(template)?))
445}
446
447fn parse_selector_ref(value: &str) -> Result<TemplateSelectorParts, TemplateModelError> {
448 let trimmed = value.trim();
449 let trimmed = trimmed
450 .strip_prefix("~{")
451 .and_then(|value| value.strip_suffix('}'))
452 .unwrap_or(trimmed);
453 let (template, fragment) = trimmed.split_once("::").unwrap_or((trimmed, ""));
454 let template = template.trim();
455 let fragment = fragment.trim();
456
457 Ok(TemplateSelectorParts {
458 template: (!template.is_empty()).then(|| template.to_string()),
459 fragment: (!fragment.is_empty()).then(|| fragment.to_string()),
460 })
461}
462
463fn parse_render_key(value: &str) -> String {
464 let trimmed = value.trim();
465 trimmed
466 .strip_prefix("${")
467 .and_then(|value| value.strip_suffix('}'))
468 .or_else(|| {
469 trimmed
470 .strip_prefix("#{")
471 .and_then(|value| value.strip_suffix('}'))
472 })
473 .or_else(|| {
474 trimmed
475 .strip_prefix("*{")
476 .and_then(|value| value.strip_suffix('}'))
477 })
478 .unwrap_or(trimmed)
479 .trim()
480 .to_string()
481}
482
483fn parse_slot_name(value: &str) -> String {
484 parse_render_key(value)
485}
486
487fn parse_condition(value: &str) -> Result<ConditionExpression, TemplateModelError> {
488 let value = value.trim();
489 match parse_template_expression(value)? {
490 TemplateExpression::LiteralBool(value) => Ok(ConditionExpression::Literal(value)),
491 TemplateExpression::LiteralText(value) => match value.to_ascii_lowercase().as_str() {
492 "true" => Ok(ConditionExpression::Literal(true)),
493 "false" => Ok(ConditionExpression::Literal(false)),
494 _ => Ok(ConditionExpression::Key(value)),
495 },
496 TemplateExpression::ModelKey(value) | TemplateExpression::AssetPath(value) => {
497 Ok(ConditionExpression::Key(value))
498 }
499 TemplateExpression::TranslationKey(_) => Err(TemplateModelError::ParseError {
500 line: 0,
501 column: 0,
502 message: "translation expressions are not valid in coil:if or coil:unless".to_string(),
503 }),
504 }
505}
506
507fn parse_each_expression(value: &str) -> Result<(String, String), TemplateModelError> {
508 let (item, collection) =
509 value
510 .split_once(':')
511 .ok_or_else(|| TemplateModelError::ParseError {
512 line: 0,
513 column: 0,
514 message: format!("invalid coil:each expression `{value}`"),
515 })?;
516 Ok((
517 validate_token("render_key", item.trim().to_string())?,
518 parse_render_key(collection),
519 ))
520}
521
522fn parse_with_bindings(value: &str) -> Result<Vec<TemplateBinding>, TemplateModelError> {
523 let mut bindings = Vec::new();
524 for assignment in value.split(',') {
525 let assignment = assignment.trim();
526 if assignment.is_empty() {
527 continue;
528 }
529
530 let (key, raw_value) =
531 assignment
532 .split_once('=')
533 .ok_or_else(|| TemplateModelError::ParseError {
534 line: 0,
535 column: 0,
536 message: format!("invalid coil:with binding `{assignment}`"),
537 })?;
538 bindings.push(TemplateBinding::new(
539 key.trim(),
540 parse_template_expression(raw_value.trim())?,
541 )?);
542 }
543 Ok(bindings)
544}
545
546fn parse_attr_bindings(value: &str) -> Result<Vec<AttributeNode>, TemplateModelError> {
547 let mut attributes = Vec::new();
548 for assignment in value.split(',') {
549 let assignment = assignment.trim();
550 if assignment.is_empty() {
551 continue;
552 }
553
554 let (name, raw_value) =
555 assignment
556 .split_once('=')
557 .ok_or_else(|| TemplateModelError::ParseError {
558 line: 0,
559 column: 0,
560 message: format!("invalid coil:attr binding `{assignment}`"),
561 })?;
562 attributes.push(AttributeNode::dynamic_expression(
563 name.trim(),
564 parse_template_expression(raw_value.trim())?,
565 )?);
566 }
567 Ok(attributes)
568}
569
570fn parse_template_expression(value: &str) -> Result<TemplateExpression, TemplateModelError> {
571 let trimmed = value.trim();
572
573 if let Some(inner) = trimmed
574 .strip_prefix("${")
575 .and_then(|value| value.strip_suffix('}'))
576 .or_else(|| {
577 trimmed
578 .strip_prefix("#{")
579 .and_then(|value| value.strip_suffix('}'))
580 })
581 .or_else(|| {
582 trimmed
583 .strip_prefix("*{")
584 .and_then(|value| value.strip_suffix('}'))
585 })
586 {
587 return parse_template_expression(inner.trim());
588 }
589
590 if let Some(inner) = trimmed
591 .strip_prefix("@{")
592 .and_then(|value| value.strip_suffix('}'))
593 {
594 let inner = inner.trim();
595 return Ok(TemplateExpression::AssetPath(inner.to_string()));
596 }
597
598 if let Some(inner) = trimmed
599 .strip_prefix("asset(")
600 .and_then(|value| value.strip_suffix(')'))
601 {
602 let inner = inner.trim().trim_matches('"').trim_matches('\'');
603 return Ok(TemplateExpression::AssetPath(inner.to_string()));
604 }
605
606 if let Some(inner) = trimmed
607 .strip_prefix("t(")
608 .and_then(|value| value.strip_suffix(')'))
609 {
610 let inner = inner.trim();
611 let key = inner
612 .strip_prefix('"')
613 .and_then(|value| value.strip_suffix('"'))
614 .or_else(|| inner.strip_prefix('\'').and_then(|value| value.strip_suffix('\'')))
615 .ok_or_else(|| TemplateModelError::ParseError {
616 line: 0,
617 column: 0,
618 message: format!(
619 "translation helper expects a quoted key like t('checkout.title'), got `{trimmed}`"
620 ),
621 })?;
622 return Ok(TemplateExpression::TranslationKey(validate_token(
623 "translation_key",
624 key.to_string(),
625 )?));
626 }
627
628 if let Some(inner) = trimmed
629 .strip_prefix('"')
630 .and_then(|value| value.strip_suffix('"'))
631 {
632 return Ok(TemplateExpression::LiteralText(inner.to_string()));
633 }
634 if let Some(inner) = trimmed
635 .strip_prefix('\'')
636 .and_then(|value| value.strip_suffix('\''))
637 {
638 return Ok(TemplateExpression::LiteralText(inner.to_string()));
639 }
640
641 match trimmed {
642 "true" => Ok(TemplateExpression::LiteralBool(true)),
643 "false" => Ok(TemplateExpression::LiteralBool(false)),
644 other => Ok(TemplateExpression::ModelKey(other.to_string())),
645 }
646}
647
648fn parse_translation_expression(value: &str) -> Result<TemplateExpression, TemplateModelError> {
649 let trimmed = value.trim();
650 let is_wrapped_expression =
651 trimmed.starts_with("${") || trimmed.starts_with("#{") || trimmed.starts_with("*{");
652 match parse_template_expression(trimmed)? {
653 TemplateExpression::TranslationKey(key) => Ok(TemplateExpression::TranslationKey(key)),
654 TemplateExpression::ModelKey(key) if !is_wrapped_expression => Ok(
655 TemplateExpression::TranslationKey(validate_token("translation_key", key)?),
656 ),
657 _ => Err(TemplateModelError::ParseError {
658 line: 0,
659 column: 0,
660 message: format!(
661 "coil:t expects a translation key like `home.title` or `t('home.title')`, got `{trimmed}`"
662 ),
663 }),
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use std::time::{SystemTime, UNIX_EPOCH};
671
672 fn unique_root(label: &str) -> PathBuf {
673 let unique = SystemTime::now()
674 .duration_since(UNIX_EPOCH)
675 .unwrap_or_default()
676 .as_nanos();
677 std::env::temp_dir().join(format!("coil-template-parser-{label}-{unique}"))
678 }
679
680 fn write_file(path: &Path, contents: &str) {
681 if let Some(parent) = path.parent() {
682 fs::create_dir_all(parent).unwrap();
683 }
684 fs::write(path, contents).unwrap();
685 }
686
687 #[test]
688 fn loads_templates_from_app_template_tree() {
689 let root = unique_root("load");
690 write_file(
691 &root.join("templates/layouts/base.html"),
692 r#"<!doctype html>
693<html xmlns:coil="https://coil.rs" coil:fragment="shell">
694 <body>
695 <main coil:insert="~{::content}">
696 <section coil:fragment="content"><p>Fallback</p></section>
697 </main>
698 </body>
699</html>"#,
700 );
701 write_file(
702 &root.join("templates/components/hero.html"),
703 r#"<section class="hero" xmlns:coil="https://coil.rs" coil:fragment="hero">Hero</section>"#,
704 );
705
706 let namespace = TemplateNamespace::new("customer-app").unwrap();
707 let parser = TemplateSourceParser::new();
708 let templates = parser
709 .load_directory(root.join("templates"), namespace)
710 .unwrap();
711
712 assert_eq!(templates.len(), 2);
713 assert!(
714 templates
715 .iter()
716 .any(|template| template.key.name.as_str() == "layouts/base")
717 );
718 assert!(
719 templates
720 .iter()
721 .any(|template| template.key.name.as_str() == "components/hero")
722 );
723 }
724
725 #[test]
726 fn parses_thymeleaf_directives_into_template_nodes() {
727 let root = unique_root("parse");
728 let path = root.join("templates/pages/home.html");
729 write_file(
730 &path,
731 r#"<!doctype html>
732<html xmlns:coil="https://coil.rs" coil:with="page_title='Shoppr'">
733 <head>
734 <title coil:text="${page_title}">Fallback</title>
735 <link rel="stylesheet" href="/theme/assets/site.css" coil:href="${asset('theme/assets/site.css')}" />
736 </head>
737 <body>
738 <section class="home-page" coil:fragment="content">
739 <div coil:replace="~{components/hero :: hero}"></div>
740 <div coil:replace="~{commerce/collection-grid :: grid}"></div>
741 </section>
742 </body>
743</html>"#,
744 );
745
746 let parser = TemplateSourceParser::new();
747 let template = parser
748 .parse_file(
749 root.join("templates"),
750 &path,
751 TemplateNamespace::new("customer-app").unwrap(),
752 )
753 .unwrap();
754
755 assert_eq!(template.kind, TemplateKind::Layout);
756 assert_eq!(template.nodes.len(), 2);
757 assert!(matches!(template.nodes.first(), Some(Node::StaticText(_))));
758 match template.nodes.get(1) {
759 Some(Node::With { children, .. }) => {
760 assert!(matches!(children.first(), Some(Node::Element(_))));
761 }
762 other => panic!("expected a `coil:with` wrapper, got {other:?}"),
763 }
764 }
765
766 #[test]
767 fn rejects_invalid_each_expressions() {
768 let error = parse_each_expression("collection").unwrap_err();
769 assert!(matches!(error, TemplateModelError::ParseError { .. }));
770 }
771
772 #[test]
773 fn parses_translation_expressions_in_text_and_attributes() {
774 let root = unique_root("translations");
775 let path = root.join("templates/pages/home.html");
776 write_file(
777 &path,
778 r#"<section xmlns:coil="https://coil.rs" coil:fragment="home">
779 <h1 coil:text="t('home.title')">Fallback</h1>
780 <a coil:title="${t('home.cta')}">Link</a>
781</section>"#,
782 );
783
784 let parser = TemplateSourceParser::new();
785 let template = parser
786 .parse_file(
787 root.join("templates"),
788 &path,
789 TemplateNamespace::new("customer-app").unwrap(),
790 )
791 .unwrap();
792
793 fn contains_translation_node(nodes: &[Node], key: &str) -> bool {
794 nodes.iter().any(|node| match node {
795 Node::Expression(TemplateExpression::TranslationKey(found)) => found == key,
796 Node::RawExpression(TemplateExpression::TranslationKey(found)) => found == key,
797 Node::Expression(_) | Node::RawExpression(_) => false,
798 Node::Element(element) => {
799 element.attributes.iter().any(|attribute| {
800 attribute.value
801 == AttributeValue::DynamicExpression(
802 TemplateExpression::TranslationKey(key.to_string()),
803 )
804 }) || contains_translation_node(&element.children, key)
805 }
806 Node::With { children, .. }
807 | Node::Conditional { children, .. }
808 | Node::Each { children, .. } => contains_translation_node(children, key),
809 Node::Slot(slot) => slot
810 .fallback
811 .as_ref()
812 .is_some_and(|children| contains_translation_node(children, key)),
813 Node::StaticText(_) | Node::Value(_) | Node::RawValue(_) | Node::Include(_) => {
814 false
815 }
816 })
817 }
818
819 assert!(contains_translation_node(&template.nodes, "home.title"));
820 assert!(contains_translation_node(&template.nodes, "home.cta"));
821 }
822
823 #[test]
824 fn parses_dv_t_as_a_first_class_translation_directive() {
825 let root = unique_root("directive-translations");
826 let path = root.join("templates/pages/home.html");
827 write_file(
828 &path,
829 r#"<section xmlns:coil="https://coil.rs" coil:fragment="home">
830 <h1 coil:t="home.title">Fallback</h1>
831 <p coil:t="${t('home.summary')}">Fallback</p>
832</section>"#,
833 );
834
835 let parser = TemplateSourceParser::new();
836 let template = parser
837 .parse_file(
838 root.join("templates"),
839 &path,
840 TemplateNamespace::new("customer-app").unwrap(),
841 )
842 .unwrap();
843
844 fn contains_translation_node(nodes: &[Node], key: &str) -> bool {
845 nodes.iter().any(|node| match node {
846 Node::Expression(TemplateExpression::TranslationKey(found)) => found == key,
847 Node::RawExpression(TemplateExpression::TranslationKey(found)) => found == key,
848 Node::Expression(_) | Node::RawExpression(_) => false,
849 Node::Element(element) => contains_translation_node(&element.children, key),
850 Node::With { children, .. }
851 | Node::Conditional { children, .. }
852 | Node::Each { children, .. } => contains_translation_node(children, key),
853 Node::Slot(slot) => slot
854 .fallback
855 .as_ref()
856 .is_some_and(|children| contains_translation_node(children, key)),
857 Node::StaticText(_) | Node::Value(_) | Node::RawValue(_) | Node::Include(_) => {
858 false
859 }
860 })
861 }
862
863 assert!(contains_translation_node(&template.nodes, "home.title"));
864 assert!(contains_translation_node(&template.nodes, "home.summary"));
865 }
866
867 #[test]
868 fn rejects_non_translation_expressions_in_dv_t() {
869 let error = parse_translation_expression("${headline}").unwrap_err();
870 assert_eq!(
871 error,
872 TemplateModelError::ParseError {
873 line: 0,
874 column: 0,
875 message:
876 "coil:t expects a translation key like `home.title` or `t('home.title')`, got `${headline}`"
877 .to_string(),
878 }
879 );
880 }
881}