Skip to main content

tstring_thtml/
lib.rs

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