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            for child in &element.children {
96                match child {
97                    Node::Interpolation(interpolation) => {
98                        return Err(semantic_error(
99                            "html.semantic.raw_text_interpolation",
100                            format!("Interpolations are not allowed inside <{}>.", element.name),
101                            interpolation.span.clone(),
102                        ));
103                    }
104                    Node::Text(_) => {}
105                    _ => {
106                        return Err(semantic_error(
107                            "html.semantic.raw_text_content",
108                            format!("Only text is allowed inside <{}>.", element.name),
109                            element.span.clone(),
110                        ));
111                    }
112                }
113            }
114            Ok(())
115        }
116        Node::ComponentTag(component) => {
117            validate_attributes(&component.attributes)?;
118            for child in &component.children {
119                validate_thtml_node(child)?;
120            }
121            Ok(())
122        }
123        Node::Fragment(fragment) => {
124            for child in &fragment.children {
125                validate_thtml_node(child)?;
126            }
127            Ok(())
128        }
129        _ => Ok(()),
130    }
131}
132
133fn validate_attributes(attributes: &[AttributeLike]) -> BackendResult<()> {
134    for attribute in attributes {
135        match attribute {
136            AttributeLike::Attribute(attribute) => {
137                if let Some(value) = &attribute.value {
138                    if !value.quoted
139                        && value
140                            .parts
141                            .iter()
142                            .any(|part| matches!(part, ValuePart::Interpolation(_)))
143                    {
144                        return Err(semantic_error(
145                            "html.semantic.unquoted_dynamic_attr",
146                            format!(
147                                "Dynamic attribute value for '{}' must be quoted.",
148                                attribute.name
149                            ),
150                            attribute.span.clone(),
151                        ));
152                    }
153                }
154            }
155            AttributeLike::SpreadAttribute(_) => {}
156        }
157    }
158    Ok(())
159}
160
161fn semantic_error(
162    code: impl Into<String>,
163    message: impl Into<String>,
164    span: Option<SourceSpan>,
165) -> BackendError {
166    BackendError::semantic_at(code, message, span)
167}
168
169fn contains_components(document: &Document) -> bool {
170    document.children.iter().any(node_contains_component)
171}
172
173fn node_contains_component(node: &Node) -> bool {
174    match node {
175        Node::ComponentTag(_) => true,
176        Node::Element(element) => element.children.iter().any(node_contains_component),
177        Node::RawTextElement(element) => element.children.iter().any(node_contains_component),
178        Node::Fragment(fragment) => fragment.children.iter().any(node_contains_component),
179        _ => false,
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use tstring_syntax::TemplateSegment;
187
188    #[test]
189    fn thtml_accepts_component_tags() {
190        let input = TemplateInput::from_segments(vec![TemplateSegment::StaticText(
191            "<Button disabled />".to_string(),
192        )]);
193        check_template(&input).expect("thtml should allow component tags");
194    }
195
196    #[test]
197    fn thtml_runtime_without_bindings_rejects_components() {
198        let input = TemplateInput::from_segments(vec![TemplateSegment::StaticText(
199            "<Button />".to_string(),
200        )]);
201        let compiled = compile_template(&input).expect("compile thtml");
202        let err = render_html(&compiled, &RuntimeContext::default()).expect_err("must fail");
203        assert_eq!(
204            err.message,
205            "Component rendering requires the bindings layer runtime context."
206        );
207    }
208}