Skip to main content

coil_template/
parser.rs

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="pageTitle='Shoppr'">
733  <head>
734    <title coil:text="${pageTitle}">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}