Skip to main content

tstring_thtml/
lib.rs

1use tstring_html::{
2    AttributeLike, CompiledHtmlTemplate, Document, FormatOptions, Node, RenderedFragment,
3    RuntimeContext, ValuePart, format_document_as_thtml_with_options, format_template_syntax,
4    parse_template, runtime_error,
5};
6use tstring_syntax::{BackendError, BackendResult, SourceSpan, TemplateInput};
7
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct CompiledThtmlTemplate {
10    document: Document,
11}
12
13pub fn check_template(template: &TemplateInput) -> BackendResult<()> {
14    prepare_template(template).map(|_| ())
15}
16
17pub fn format_template(template: &TemplateInput) -> BackendResult<String> {
18    format_template_with_options(template, &FormatOptions::default())
19}
20
21pub fn format_template_with_options(
22    template: &TemplateInput,
23    options: &FormatOptions,
24) -> BackendResult<String> {
25    let document = format_template_syntax(template)?;
26    validate_thtml_document(&document)?;
27    Ok(format_document_as_thtml_with_options(&document, options))
28}
29
30pub fn compile_template(template: &TemplateInput) -> BackendResult<CompiledThtmlTemplate> {
31    let document = prepare_template(template)?;
32    Ok(CompiledThtmlTemplate { document })
33}
34
35pub fn render_html(
36    compiled: &CompiledThtmlTemplate,
37    context: &RuntimeContext,
38) -> BackendResult<String> {
39    if contains_components(&compiled.document) {
40        return Err(runtime_error(
41            "thtml.runtime.component_resolution",
42            "Component rendering requires the bindings layer runtime context.",
43            None,
44        ));
45    }
46    let html_compiled = CompiledHtmlTemplate::from_document(compiled.document.clone());
47    tstring_html::render_html(&html_compiled, context)
48}
49
50pub fn render_fragment(
51    compiled: &CompiledThtmlTemplate,
52    context: &RuntimeContext,
53) -> BackendResult<RenderedFragment> {
54    Ok(RenderedFragment {
55        html: render_html(compiled, context)?,
56    })
57}
58
59impl CompiledThtmlTemplate {
60    #[must_use]
61    pub fn document(&self) -> &Document {
62        &self.document
63    }
64
65    #[must_use]
66    pub fn from_document(document: Document) -> Self {
67        Self { document }
68    }
69}
70
71pub fn prepare_template(template: &TemplateInput) -> BackendResult<Document> {
72    let document = parse_template(template)?;
73    validate_thtml_document(&document)?;
74    Ok(document)
75}
76
77fn validate_thtml_document(document: &Document) -> BackendResult<()> {
78    for child in &document.children {
79        validate_thtml_node(child)?;
80    }
81    Ok(())
82}
83
84fn validate_thtml_node(node: &Node) -> BackendResult<()> {
85    match node {
86        Node::Element(element) => {
87            validate_attributes(&element.attributes)?;
88            for child in &element.children {
89                validate_thtml_node(child)?;
90            }
91            Ok(())
92        }
93        Node::RawTextElement(element) => {
94            validate_attributes(&element.attributes)?;
95            validate_raw_text_children(element)
96        }
97        Node::ComponentTag(component) => {
98            validate_attributes(&component.attributes)?;
99            for child in &component.children {
100                validate_thtml_node(child)?;
101            }
102            Ok(())
103        }
104        Node::Fragment(fragment) => {
105            for child in &fragment.children {
106                validate_thtml_node(child)?;
107            }
108            Ok(())
109        }
110        _ => Ok(()),
111    }
112}
113
114fn validate_raw_text_children(element: &tstring_html::RawTextElementNode) -> BackendResult<()> {
115    for child in &element.children {
116        match child {
117            Node::Text(_) => {}
118            Node::Interpolation(_) if element.name.eq_ignore_ascii_case("title") => {}
119            Node::Interpolation(interpolation) => {
120                return Err(semantic_error(
121                    "html.semantic.raw_text_interpolation",
122                    format!("Interpolations are not allowed inside <{}>.", element.name),
123                    interpolation.span.clone(),
124                ));
125            }
126            _ => {
127                let message = if element.name.eq_ignore_ascii_case("title") {
128                    format!(
129                        "Only text and interpolations are allowed inside <{}>.",
130                        element.name
131                    )
132                } else {
133                    format!("Only text is allowed inside <{}>.", element.name)
134                };
135                return Err(semantic_error(
136                    "html.semantic.raw_text_content",
137                    message,
138                    element.span.clone(),
139                ));
140            }
141        }
142    }
143    Ok(())
144}
145
146fn validate_attributes(attributes: &[AttributeLike]) -> BackendResult<()> {
147    for attribute in attributes {
148        match attribute {
149            AttributeLike::Attribute(attribute) => {
150                if let Some(value) = &attribute.value {
151                    if !value.quoted
152                        && value
153                            .parts
154                            .iter()
155                            .any(|part| matches!(part, ValuePart::Interpolation(_)))
156                    {
157                        return Err(semantic_error(
158                            "html.semantic.unquoted_dynamic_attr",
159                            format!(
160                                "Dynamic attribute value for '{}' must be quoted.",
161                                attribute.name
162                            ),
163                            attribute.span.clone(),
164                        ));
165                    }
166                }
167            }
168            AttributeLike::SpreadAttribute(_) => {}
169        }
170    }
171    Ok(())
172}
173
174fn semantic_error(
175    code: impl Into<String>,
176    message: impl Into<String>,
177    span: Option<SourceSpan>,
178) -> BackendError {
179    BackendError::semantic_at(code, message, span)
180}
181
182fn contains_components(document: &Document) -> bool {
183    document.children.iter().any(node_contains_component)
184}
185
186fn node_contains_component(node: &Node) -> bool {
187    match node {
188        Node::ComponentTag(_) => true,
189        Node::Element(element) => element.children.iter().any(node_contains_component),
190        Node::RawTextElement(element) => element.children.iter().any(node_contains_component),
191        Node::Fragment(fragment) => fragment.children.iter().any(node_contains_component),
192        _ => false,
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use tstring_syntax::TemplateSegment;
200
201    #[test]
202    fn thtml_accepts_component_tags() {
203        let input = TemplateInput::from_segments(vec![TemplateSegment::StaticText(
204            "<Button disabled />".to_string(),
205        )]);
206        check_template(&input).expect("thtml should allow component tags");
207    }
208
209    #[test]
210    fn thtml_runtime_without_bindings_rejects_components() {
211        let input = TemplateInput::from_segments(vec![TemplateSegment::StaticText(
212            "<Button />".to_string(),
213        )]);
214        let compiled = compile_template(&input).expect("compile thtml");
215        let err = render_html(&compiled, &RuntimeContext::default()).expect_err("must fail");
216        assert_eq!(
217            err.message,
218            "Component rendering requires the bindings layer runtime context."
219        );
220    }
221
222    #[test]
223    fn thtml_accepts_title_interpolation() {
224        let input = TemplateInput::from_segments(vec![
225            TemplateSegment::StaticText("<title>".to_string()),
226            TemplateSegment::Interpolation(tstring_syntax::TemplateInterpolation {
227                expression: "title".to_string(),
228                conversion: None,
229                format_spec: String::new(),
230                interpolation_index: 0,
231                raw_source: Some("{title}".to_string()),
232            }),
233            TemplateSegment::StaticText("</title>".to_string()),
234        ]);
235        check_template(&input).expect("title interpolation should be allowed");
236        assert_eq!(
237            format_template(&input).expect("format title"),
238            "<title>{title}</title>"
239        );
240    }
241}