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}