1use std::collections::BTreeMap;
2
3mod formatter;
4
5use tstring_syntax::{
6 BackendError, BackendResult, Diagnostic, ErrorKind, SourceSpan, StreamItem, TemplateInput,
7 TemplateInterpolation, TemplateSegment,
8};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub struct FormatOptions {
12 pub line_length: usize,
13}
14
15impl Default for FormatOptions {
16 fn default() -> Self {
17 Self { line_length: 80 }
18 }
19}
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum FormatFlavor {
23 Html,
24 Thtml,
25}
26
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct Document {
29 pub children: Vec<Node>,
30 pub span: Option<SourceSpan>,
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub enum Node {
35 Fragment(FragmentNode),
36 Element(ElementNode),
37 ComponentTag(ComponentTagNode),
38 Text(TextNode),
39 Interpolation(InterpolationNode),
40 Comment(CommentNode),
41 Doctype(DoctypeNode),
42 RawTextElement(RawTextElementNode),
43}
44
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct FragmentNode {
47 pub children: Vec<Node>,
48 pub span: Option<SourceSpan>,
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct ElementNode {
53 pub name: String,
54 pub attributes: Vec<AttributeLike>,
55 pub children: Vec<Node>,
56 pub self_closing: bool,
57 pub span: Option<SourceSpan>,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct ComponentTagNode {
62 pub name: String,
63 pub attributes: Vec<AttributeLike>,
64 pub children: Vec<Node>,
65 pub self_closing: bool,
66 pub span: Option<SourceSpan>,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct RawTextElementNode {
71 pub name: String,
72 pub attributes: Vec<AttributeLike>,
73 pub children: Vec<Node>,
74 pub span: Option<SourceSpan>,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub enum AttributeLike {
79 Attribute(Attribute),
80 SpreadAttribute(SpreadAttribute),
81}
82
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct Attribute {
85 pub name: String,
86 pub value: Option<AttributeValue>,
87 pub span: Option<SourceSpan>,
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct AttributeValue {
92 pub quoted: bool,
93 pub parts: Vec<ValuePart>,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub enum ValuePart {
98 Text(String),
99 Interpolation(InterpolationNode),
100}
101
102#[derive(Clone, Debug, PartialEq, Eq)]
103pub struct SpreadAttribute {
104 pub interpolation: InterpolationNode,
105 pub span: Option<SourceSpan>,
106}
107
108#[derive(Clone, Debug, PartialEq, Eq)]
109pub struct TextNode {
110 pub value: String,
111 pub span: Option<SourceSpan>,
112}
113
114#[derive(Clone, Debug, PartialEq, Eq)]
115pub struct InterpolationNode {
116 pub interpolation_index: usize,
117 pub expression: String,
118 pub raw_source: Option<String>,
119 pub conversion: Option<String>,
120 pub format_spec: String,
121 pub span: Option<SourceSpan>,
122}
123
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct CommentNode {
126 pub value: String,
127 pub span: Option<SourceSpan>,
128}
129
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct DoctypeNode {
132 pub value: String,
133 pub span: Option<SourceSpan>,
134}
135
136#[derive(Clone, Debug, PartialEq, Eq)]
137pub struct CompiledHtmlTemplate {
138 document: Document,
139}
140
141#[derive(Clone, Debug, PartialEq)]
142pub enum RuntimeValue {
143 Null,
144 Bool(bool),
145 Int(i64),
146 Float(f64),
147 String(String),
148 Fragment(Vec<RuntimeValue>),
149 RawHtml(String),
150 Sequence(Vec<RuntimeValue>),
151 Attributes(Vec<(String, RuntimeValue)>),
152}
153
154#[derive(Clone, Debug, Default, PartialEq)]
155pub struct RuntimeContext {
156 pub values: Vec<RuntimeValue>,
157}
158
159#[derive(Clone, Debug, PartialEq, Eq)]
160pub struct RenderedFragment {
161 pub html: String,
162}
163
164#[derive(Clone, Debug)]
165enum Token {
166 Char(char, Option<SourceSpan>),
167 Interpolation(TemplateInterpolation, Option<SourceSpan>),
168 Eof,
169}
170
171struct Parser {
172 tokens: Vec<Token>,
173 index: usize,
174}
175
176impl Parser {
177 fn new(input: &TemplateInput) -> Self {
178 let mut tokens = Vec::new();
179 for item in flatten_input(input) {
180 match item {
181 StreamItem::Char { ch, span } => tokens.push(Token::Char(ch, Some(span))),
182 StreamItem::Interpolation {
183 interpolation,
184 span,
185 ..
186 } => tokens.push(Token::Interpolation(interpolation, Some(span))),
187 StreamItem::Eof { .. } => tokens.push(Token::Eof),
188 }
189 }
190 if tokens.is_empty() || !matches!(tokens.last(), Some(Token::Eof)) {
191 tokens.push(Token::Eof);
192 }
193 Self { tokens, index: 0 }
194 }
195
196 fn parse_document(&mut self) -> BackendResult<Document> {
197 let children = self.parse_nodes(None, false)?;
198 Ok(Document {
199 span: merge_children_span(&children),
200 children,
201 })
202 }
203
204 fn parse_nodes(
205 &mut self,
206 closing_tag: Option<&str>,
207 raw_text_mode: bool,
208 ) -> BackendResult<Vec<Node>> {
209 let mut children = Vec::new();
210 loop {
211 if self.is_eof() {
212 if let Some(name) = closing_tag {
213 return Err(parse_error(
214 "html.parse.unclosed_tag",
215 format!("Unclosed tag <{name}>."),
216 self.current_span(),
217 ));
218 }
219 break;
220 }
221
222 if let Some(name) = closing_tag {
223 if self.starts_with_literal("</") {
224 let close_span = self.current_span();
225 self.consume_literal("</");
226 self.skip_whitespace();
227 let close_name = self.parse_name()?;
228 self.skip_whitespace();
229 self.expect_char('>')?;
230 if close_name != name {
231 return Err(parse_error(
232 "html.parse.mismatched_tag",
233 format!("Mismatched closing tag </{close_name}>. Expected </{name}>."),
234 close_span,
235 ));
236 }
237 break;
238 }
239 if raw_text_mode {
240 if let Some(text) = self.parse_raw_text_chunk(name)? {
241 children.push(text);
242 continue;
243 }
244 }
245 }
246
247 if self.is_eof() {
248 break;
249 }
250
251 if self.starts_with_literal("<!--") {
252 children.push(Node::Comment(self.parse_comment()?));
253 continue;
254 }
255 if self.starts_with_doctype() {
256 children.push(Node::Doctype(self.parse_doctype()?));
257 continue;
258 }
259 if self.current_is_char('<') {
260 children.push(self.parse_tag()?);
261 continue;
262 }
263 if let Some(interpolation) = self.take_interpolation() {
264 children.push(Node::Interpolation(interpolation));
265 continue;
266 }
267 children.push(Node::Text(self.parse_text()?));
268 }
269 Ok(children)
270 }
271
272 fn parse_raw_text_chunk(&mut self, closing_tag: &str) -> BackendResult<Option<Node>> {
273 let mut text = String::new();
274 let mut span = None;
275 while !self.is_eof() {
276 if self.starts_with_close_tag(closing_tag) {
277 break;
278 }
279 match self.current() {
280 Token::Interpolation(_, _) => {
281 if !text.is_empty() {
282 return Ok(Some(Node::Text(TextNode { value: text, span })));
283 }
284 if let Some(interpolation) = self.take_interpolation() {
285 return Ok(Some(Node::Interpolation(interpolation)));
286 }
287 }
288 Token::Char(ch, node_span) => {
289 span = merge_span_opt(span, node_span.clone());
290 text.push(*ch);
291 self.index += 1;
292 }
293 Token::Eof => break,
294 }
295 }
296 if text.is_empty() {
297 Ok(None)
298 } else {
299 Ok(Some(Node::Text(TextNode { value: text, span })))
300 }
301 }
302
303 fn parse_comment(&mut self) -> BackendResult<CommentNode> {
304 let start = self.current_span();
305 self.consume_literal("<!--");
306 let mut value = String::new();
307 while !self.is_eof() && !self.starts_with_literal("-->") {
308 match self.current() {
309 Token::Char(ch, _) => {
310 value.push(*ch);
311 self.index += 1;
312 }
313 Token::Interpolation(_, span) => {
314 return Err(parse_error(
315 "html.parse.comment_interpolation",
316 "Interpolations are not allowed inside HTML comments.",
317 span.clone(),
318 ));
319 }
320 Token::Eof => break,
321 }
322 }
323 if !self.starts_with_literal("-->") {
324 return Err(parse_error(
325 "html.parse.comment_unclosed",
326 "Unclosed HTML comment.",
327 start,
328 ));
329 }
330 self.consume_literal("-->");
331 Ok(CommentNode { value, span: start })
332 }
333
334 fn parse_doctype(&mut self) -> BackendResult<DoctypeNode> {
335 let start = self.current_span();
336 self.consume_char('<')?;
337 self.consume_char('!')?;
338 let mut value = String::new();
339 while !self.is_eof() {
340 if self.current_is_char('>') {
341 self.index += 1;
342 break;
343 }
344 match self.current() {
345 Token::Char(ch, _) => {
346 value.push(*ch);
347 self.index += 1;
348 }
349 Token::Interpolation(_, span) => {
350 return Err(parse_error(
351 "html.parse.doctype_interpolation",
352 "Interpolations are not allowed inside a doctype.",
353 span.clone(),
354 ));
355 }
356 Token::Eof => break,
357 }
358 }
359 Ok(DoctypeNode {
360 value: value.trim().to_string(),
361 span: start,
362 })
363 }
364
365 fn parse_tag(&mut self) -> BackendResult<Node> {
366 let start = self.current_span();
367 self.expect_char('<')?;
368 let name = self.parse_name()?;
369 let mut attributes = Vec::new();
370 loop {
371 self.skip_whitespace();
372 if self.starts_with_literal("/>") {
373 self.consume_literal("/>");
374 let kind = classify_tag_name(&name);
375 let span = start;
376 return Ok(match kind {
377 TagKind::Html => {
378 if is_raw_text_tag(&name) {
379 Node::RawTextElement(RawTextElementNode {
380 name,
381 attributes,
382 children: Vec::new(),
383 span,
384 })
385 } else {
386 Node::Element(ElementNode {
387 name,
388 attributes,
389 children: Vec::new(),
390 self_closing: true,
391 span,
392 })
393 }
394 }
395 TagKind::Component => Node::ComponentTag(ComponentTagNode {
396 name,
397 attributes,
398 children: Vec::new(),
399 self_closing: true,
400 span,
401 }),
402 });
403 }
404 if self.current_is_char('>') {
405 self.index += 1;
406 break;
407 }
408 if self.is_eof() {
409 return Err(parse_error(
410 "html.parse.unclosed_start_tag",
411 format!("Unclosed start tag <{name}>."),
412 start,
413 ));
414 }
415 if let Some(interpolation) = self.take_interpolation() {
416 attributes.push(AttributeLike::SpreadAttribute(SpreadAttribute {
417 span: interpolation.span.clone(),
418 interpolation,
419 }));
420 continue;
421 }
422 attributes.push(AttributeLike::Attribute(self.parse_attribute()?));
423 }
424
425 let kind = classify_tag_name(&name);
426 let children = if is_raw_text_tag(&name) {
427 self.parse_nodes(Some(&name), true)?
428 } else {
429 self.parse_nodes(Some(&name), false)?
430 };
431 let span = merge_span_opt(start, merge_children_span(&children));
432 Ok(match kind {
433 TagKind::Html => {
434 if is_raw_text_tag(&name) {
435 Node::RawTextElement(RawTextElementNode {
436 name,
437 attributes,
438 children,
439 span,
440 })
441 } else {
442 Node::Element(ElementNode {
443 name,
444 attributes,
445 children,
446 self_closing: false,
447 span,
448 })
449 }
450 }
451 TagKind::Component => Node::ComponentTag(ComponentTagNode {
452 name,
453 attributes,
454 children,
455 self_closing: false,
456 span,
457 }),
458 })
459 }
460
461 fn parse_attribute(&mut self) -> BackendResult<Attribute> {
462 let span = self.current_span();
463 let name = self.parse_name()?;
464 self.skip_whitespace();
465 if !self.current_is_char('=') {
466 return Ok(Attribute {
467 name,
468 value: None,
469 span,
470 });
471 }
472 self.index += 1;
473 self.skip_whitespace();
474 let value = self.parse_attribute_value()?;
475 Ok(Attribute {
476 name,
477 value: Some(value),
478 span,
479 })
480 }
481
482 fn parse_attribute_value(&mut self) -> BackendResult<AttributeValue> {
483 if self.current_is_char('"') || self.current_is_char('\'') {
484 let quote = self.current_char().unwrap_or('"');
485 self.index += 1;
486 let mut parts = Vec::new();
487 let mut text = String::new();
488 while !self.is_eof() {
489 if self.current_is_char(quote) {
490 self.index += 1;
491 break;
492 }
493 if let Some(interpolation) = self.take_interpolation() {
494 if !text.is_empty() {
495 parts.push(ValuePart::Text(std::mem::take(&mut text)));
496 }
497 parts.push(ValuePart::Interpolation(interpolation));
498 continue;
499 }
500 match self.current() {
501 Token::Char(ch, _) => {
502 text.push(*ch);
503 self.index += 1;
504 }
505 Token::Eof => break,
506 Token::Interpolation(_, _) => {}
507 }
508 }
509 if !text.is_empty() {
510 parts.push(ValuePart::Text(text));
511 }
512 return Ok(AttributeValue {
513 quoted: true,
514 parts,
515 });
516 }
517
518 if let Some(interpolation) = self.take_interpolation() {
519 return Ok(AttributeValue {
520 quoted: false,
521 parts: vec![ValuePart::Interpolation(interpolation)],
522 });
523 }
524
525 let mut text = String::new();
526 while !self.is_eof() {
527 if self.current_is_whitespace()
528 || self.current_is_char('>')
529 || self.starts_with_literal("/>")
530 {
531 break;
532 }
533 match self.current() {
534 Token::Char(ch, _) => {
535 text.push(*ch);
536 self.index += 1;
537 }
538 Token::Interpolation(_, _) | Token::Eof => break,
539 }
540 }
541 Ok(AttributeValue {
542 quoted: false,
543 parts: vec![ValuePart::Text(text)],
544 })
545 }
546
547 fn parse_text(&mut self) -> BackendResult<TextNode> {
548 let mut value = String::new();
549 let mut span = self.current_span();
550 while !self.is_eof() && !self.current_is_char('<') {
551 if matches!(self.current(), Token::Interpolation(_, _)) {
552 break;
553 }
554 match self.current() {
555 Token::Char(ch, node_span) => {
556 span = merge_span_opt(span, node_span.clone());
557 value.push(*ch);
558 self.index += 1;
559 }
560 Token::Interpolation(_, _) | Token::Eof => break,
561 }
562 }
563 Ok(TextNode { value, span })
564 }
565
566 fn parse_name(&mut self) -> BackendResult<String> {
567 let mut name = String::new();
568 while !self.is_eof() {
569 match self.current() {
570 Token::Char(ch, _) if is_name_char(*ch, name.is_empty()) => {
571 name.push(*ch);
572 self.index += 1;
573 }
574 _ => break,
575 }
576 }
577 if name.is_empty() {
578 Err(parse_error(
579 "html.parse.expected_name",
580 "Expected a tag or attribute name.",
581 self.current_span(),
582 ))
583 } else {
584 Ok(name)
585 }
586 }
587
588 fn take_interpolation(&mut self) -> Option<InterpolationNode> {
589 match self.current().clone() {
590 Token::Interpolation(interpolation, span) => {
591 self.index += 1;
592 Some(InterpolationNode {
593 interpolation_index: interpolation.interpolation_index,
594 expression: interpolation.expression,
595 raw_source: interpolation.raw_source,
596 conversion: interpolation.conversion,
597 format_spec: interpolation.format_spec,
598 span,
599 })
600 }
601 _ => None,
602 }
603 }
604
605 fn skip_whitespace(&mut self) {
606 while self.current_is_whitespace() {
607 self.index += 1;
608 }
609 }
610
611 fn starts_with_literal(&self, value: &str) -> bool {
612 for (offset, expected) in value.chars().enumerate() {
613 match self.tokens.get(self.index + offset) {
614 Some(Token::Char(ch, _)) if *ch == expected => {}
615 _ => return false,
616 }
617 }
618 true
619 }
620
621 fn starts_with_close_tag(&self, name: &str) -> bool {
622 let literal = format!("</{name}");
623 self.starts_with_literal(&literal)
624 }
625
626 fn starts_with_doctype(&self) -> bool {
627 let literal = "<!DOCTYPE";
628 for (offset, expected) in literal.chars().enumerate() {
629 match self.tokens.get(self.index + offset) {
630 Some(Token::Char(ch, _)) if ch.eq_ignore_ascii_case(&expected) => {}
631 _ => return false,
632 }
633 }
634 true
635 }
636
637 fn consume_literal(&mut self, literal: &str) {
638 for _ in literal.chars() {
639 self.index += 1;
640 }
641 }
642
643 fn consume_char(&mut self, expected: char) -> BackendResult<()> {
644 self.expect_char(expected)
645 }
646
647 fn expect_char(&mut self, expected: char) -> BackendResult<()> {
648 match self.current() {
649 Token::Char(ch, _) if *ch == expected => {
650 self.index += 1;
651 Ok(())
652 }
653 _ => Err(parse_error(
654 "html.parse.expected_char",
655 format!("Expected '{expected}'."),
656 self.current_span(),
657 )),
658 }
659 }
660
661 fn current(&self) -> &Token {
662 self.tokens.get(self.index).unwrap_or(&Token::Eof)
663 }
664
665 fn current_char(&self) -> Option<char> {
666 match self.current() {
667 Token::Char(ch, _) => Some(*ch),
668 _ => None,
669 }
670 }
671
672 fn current_is_char(&self, expected: char) -> bool {
673 self.current_char() == Some(expected)
674 }
675
676 fn current_is_whitespace(&self) -> bool {
677 self.current_char().is_some_and(char::is_whitespace)
678 }
679
680 fn current_span(&self) -> Option<SourceSpan> {
681 match self.current() {
682 Token::Char(_, span) | Token::Interpolation(_, span) => span.clone(),
683 Token::Eof => None,
684 }
685 }
686
687 fn is_eof(&self) -> bool {
688 matches!(self.current(), Token::Eof)
689 }
690}
691
692#[derive(Clone, Copy, Debug, PartialEq, Eq)]
693enum TagKind {
694 Html,
695 Component,
696}
697
698pub fn parse_template(template: &TemplateInput) -> BackendResult<Document> {
699 Parser::new(template).parse_document()
700}
701
702pub fn check_template(template: &TemplateInput) -> BackendResult<()> {
703 prepare_template(template).map(|_| ())
704}
705
706pub fn format_template(template: &TemplateInput) -> BackendResult<String> {
707 format_template_with_options(template, &FormatOptions::default())
708}
709
710pub fn format_template_with_options(
711 template: &TemplateInput,
712 options: &FormatOptions,
713) -> BackendResult<String> {
714 let document = format_template_syntax(template)?;
715 validate_html_document(&document)?;
716 Ok(format_document_with_options(&document, options))
717}
718
719pub fn compile_template(template: &TemplateInput) -> BackendResult<CompiledHtmlTemplate> {
720 let document = prepare_template(template)?;
721 Ok(CompiledHtmlTemplate { document })
722}
723
724pub fn render_html(
725 compiled: &CompiledHtmlTemplate,
726 context: &RuntimeContext,
727) -> BackendResult<String> {
728 render_document(&compiled.document, context)
729}
730
731pub fn render_fragment(
732 compiled: &CompiledHtmlTemplate,
733 context: &RuntimeContext,
734) -> BackendResult<RenderedFragment> {
735 Ok(RenderedFragment {
736 html: render_document(&compiled.document, context)?,
737 })
738}
739
740pub fn format_template_syntax(template: &TemplateInput) -> BackendResult<Document> {
741 require_raw_source(template)?;
742 parse_template(template)
743}
744
745#[must_use]
746pub fn format_document_with_options(document: &Document, options: &FormatOptions) -> String {
747 formatter::format_document(document, options, FormatFlavor::Html)
748}
749
750#[must_use]
751pub fn format_document_as_thtml_with_options(
752 document: &Document,
753 options: &FormatOptions,
754) -> String {
755 formatter::format_document(document, options, FormatFlavor::Thtml)
756}
757
758pub fn prepare_template(template: &TemplateInput) -> BackendResult<Document> {
759 let document = parse_template(template)?;
760 validate_html_document(&document)?;
761 Ok(document)
762}
763
764pub fn rebind_document_interpolations(document: &mut Document, template: &TemplateInput) {
765 for child in &mut document.children {
766 rebind_node_interpolations(child, template);
767 }
768}
769
770pub fn render_attributes_fragment(
771 attributes: &[AttributeLike],
772 context: &RuntimeContext,
773) -> BackendResult<String> {
774 let normalized = normalize_attributes(attributes, context)?;
775 let mut out = String::new();
776 write_attributes(&normalized, &mut out);
777 Ok(out)
778}
779
780impl CompiledHtmlTemplate {
781 #[must_use]
782 pub fn document(&self) -> &Document {
783 &self.document
784 }
785}
786
787pub fn static_key_parts(template: &TemplateInput) -> Vec<String> {
788 let interpolation_count = template
789 .segments
790 .iter()
791 .filter(|segment| matches!(segment, TemplateSegment::Interpolation(_)))
792 .count();
793 let mut parts = Vec::with_capacity(interpolation_count + 1);
794 let mut current = String::new();
795 let mut seen_any = false;
796
797 for segment in &template.segments {
798 match segment {
799 TemplateSegment::StaticText(text) => {
800 current.push_str(text);
801 seen_any = true;
802 }
803 TemplateSegment::Interpolation(_) => {
804 parts.push(std::mem::take(&mut current));
805 seen_any = true;
806 }
807 }
808 }
809 if !seen_any {
810 parts.push(String::new());
811 } else {
812 parts.push(current);
813 }
814 while parts.len() < interpolation_count + 1 {
815 parts.push(String::new());
816 }
817 parts
818}
819
820fn classify_tag_name(name: &str) -> TagKind {
821 if name.chars().next().is_some_and(char::is_uppercase) {
822 TagKind::Component
823 } else {
824 TagKind::Html
825 }
826}
827
828pub fn is_raw_text_tag(name: &str) -> bool {
829 matches!(name, "script" | "style" | "title" | "textarea")
830}
831
832fn raw_text_allows_interpolation(name: &str) -> bool {
833 name.eq_ignore_ascii_case("title")
834}
835
836fn validate_html_document(document: &Document) -> BackendResult<()> {
837 for child in &document.children {
838 validate_html_node(child)?;
839 }
840 Ok(())
841}
842
843fn rebind_node_interpolations(node: &mut Node, template: &TemplateInput) {
844 match node {
845 Node::Fragment(fragment) => {
846 for child in &mut fragment.children {
847 rebind_node_interpolations(child, template);
848 }
849 }
850 Node::Element(element) => {
851 rebind_attributes(&mut element.attributes, template);
852 for child in &mut element.children {
853 rebind_node_interpolations(child, template);
854 }
855 }
856 Node::ComponentTag(component) => {
857 rebind_attributes(&mut component.attributes, template);
858 for child in &mut component.children {
859 rebind_node_interpolations(child, template);
860 }
861 }
862 Node::RawTextElement(element) => {
863 rebind_attributes(&mut element.attributes, template);
864 for child in &mut element.children {
865 rebind_node_interpolations(child, template);
866 }
867 }
868 Node::Interpolation(interpolation) => {
869 rebind_interpolation(interpolation, template);
870 }
871 Node::Text(_) | Node::Comment(_) | Node::Doctype(_) => {}
872 }
873}
874
875fn rebind_attributes(attributes: &mut [AttributeLike], template: &TemplateInput) {
876 for attribute in attributes {
877 match attribute {
878 AttributeLike::Attribute(attribute) => {
879 if let Some(value) = &mut attribute.value {
880 for part in &mut value.parts {
881 if let ValuePart::Interpolation(interpolation) = part {
882 rebind_interpolation(interpolation, template);
883 }
884 }
885 }
886 }
887 AttributeLike::SpreadAttribute(attribute) => {
888 rebind_interpolation(&mut attribute.interpolation, template);
889 }
890 }
891 }
892}
893
894fn rebind_interpolation(interpolation: &mut InterpolationNode, template: &TemplateInput) {
895 if let Some(source) = template.interpolation(interpolation.interpolation_index) {
896 interpolation.expression = source.expression.clone();
897 interpolation.raw_source = source.raw_source.clone();
898 interpolation.conversion = source.conversion.clone();
899 interpolation.format_spec = source.format_spec.clone();
900 }
901}
902
903fn validate_html_node(node: &Node) -> BackendResult<()> {
904 match node {
905 Node::ComponentTag(component) => Err(semantic_error(
906 "html.semantic.component_tag",
907 format!(
908 "Component tag <{}> is only valid in the T-HTML backend.",
909 component.name
910 ),
911 component.span.clone(),
912 )),
913 Node::Element(element) => {
914 validate_children(&element.children)?;
915 validate_attributes(&element.attributes)?;
916 Ok(())
917 }
918 Node::RawTextElement(element) => {
919 validate_attributes(&element.attributes)?;
920 validate_raw_text_children(element)
921 }
922 Node::Fragment(fragment) => validate_children(&fragment.children),
923 _ => Ok(()),
924 }
925}
926
927fn validate_raw_text_children(element: &RawTextElementNode) -> BackendResult<()> {
928 for child in &element.children {
929 match child {
930 Node::Text(_) => {}
931 Node::Interpolation(_) if raw_text_allows_interpolation(&element.name) => {}
932 Node::Interpolation(interpolation) => {
933 return Err(semantic_error(
934 "html.semantic.raw_text_interpolation",
935 format!("Interpolations are not allowed inside <{}>.", element.name),
936 interpolation.span.clone(),
937 ));
938 }
939 _ => {
940 let message = if raw_text_allows_interpolation(&element.name) {
941 format!(
942 "Only text and interpolations are allowed inside <{}>.",
943 element.name
944 )
945 } else {
946 format!("Only text is allowed inside <{}>.", element.name)
947 };
948 return Err(semantic_error(
949 "html.semantic.raw_text_content",
950 message,
951 element.span.clone(),
952 ));
953 }
954 }
955 }
956 Ok(())
957}
958
959fn validate_children(children: &[Node]) -> BackendResult<()> {
960 for child in children {
961 validate_html_node(child)?;
962 }
963 Ok(())
964}
965
966fn validate_attributes(attributes: &[AttributeLike]) -> BackendResult<()> {
967 for attribute in attributes {
968 match attribute {
969 AttributeLike::Attribute(attribute) => {
970 if let Some(value) = &attribute.value {
971 if !value.quoted
972 && value
973 .parts
974 .iter()
975 .any(|part| matches!(part, ValuePart::Interpolation(_)))
976 {
977 return Err(semantic_error(
978 "html.semantic.unquoted_dynamic_attr",
979 format!(
980 "Dynamic attribute value for '{}' must be quoted.",
981 attribute.name
982 ),
983 attribute.span.clone(),
984 ));
985 }
986 }
987 }
988 AttributeLike::SpreadAttribute(_) => {}
989 }
990 }
991 Ok(())
992}
993
994fn require_raw_source(template: &TemplateInput) -> BackendResult<()> {
995 for segment in &template.segments {
996 if let TemplateSegment::Interpolation(interpolation) = segment {
997 if interpolation.raw_source.is_none() {
998 return Err(semantic_error(
999 "html.format.raw_source_required",
1000 format!(
1001 "Formatting requires raw_source for interpolation '{}'.",
1002 interpolation.expression_label()
1003 ),
1004 None,
1005 ));
1006 }
1007 }
1008 }
1009 Ok(())
1010}
1011
1012fn render_document(document: &Document, context: &RuntimeContext) -> BackendResult<String> {
1013 let mut out = String::new();
1014 for child in &document.children {
1015 render_node(child, context, &mut out)?;
1016 }
1017 Ok(out)
1018}
1019
1020fn render_node(node: &Node, context: &RuntimeContext, out: &mut String) -> BackendResult<()> {
1021 match node {
1022 Node::Text(text) => out.push_str(&escape_html_text(&text.value)),
1023 Node::Interpolation(interpolation) => {
1024 render_child_value(value_for_interpolation(context, interpolation)?, out)?
1025 }
1026 Node::Comment(comment) => {
1027 out.push_str("<!--");
1028 out.push_str(&comment.value);
1029 out.push_str("-->");
1030 }
1031 Node::Doctype(doctype) => {
1032 out.push('<');
1033 out.push('!');
1034 out.push_str(&doctype.value);
1035 out.push('>');
1036 }
1037 Node::Fragment(fragment) => {
1038 for child in &fragment.children {
1039 render_node(child, context, out)?;
1040 }
1041 }
1042 Node::Element(element) => render_html_element(
1043 &element.name,
1044 &element.attributes,
1045 &element.children,
1046 element.self_closing,
1047 context,
1048 out,
1049 )?,
1050 Node::RawTextElement(element) => render_raw_text_element(element, context, out)?,
1051 Node::ComponentTag(component) => {
1052 return Err(semantic_error(
1053 "html.semantic.component_render",
1054 format!(
1055 "Component tag <{}> is only valid in the T-HTML backend.",
1056 component.name
1057 ),
1058 component.span.clone(),
1059 ));
1060 }
1061 }
1062 Ok(())
1063}
1064
1065fn render_raw_text_element(
1066 element: &RawTextElementNode,
1067 context: &RuntimeContext,
1068 out: &mut String,
1069) -> BackendResult<()> {
1070 out.push('<');
1071 out.push_str(&element.name);
1072 let normalized = normalize_attributes(&element.attributes, context)?;
1073 write_attributes(&normalized, out);
1074 out.push('>');
1075 for child in &element.children {
1076 match child {
1077 Node::Text(text) => out.push_str(&text.value),
1078 Node::Interpolation(interpolation) if raw_text_allows_interpolation(&element.name) => {
1079 render_escaped_text_value(value_for_interpolation(context, interpolation)?, out)?;
1080 }
1081 _ => {
1082 let message = if raw_text_allows_interpolation(&element.name) {
1083 format!(
1084 "Only text and interpolations can be rendered inside <{}>.",
1085 element.name
1086 )
1087 } else {
1088 format!("Only text can be rendered inside <{}>.", element.name)
1089 };
1090 return Err(semantic_error(
1091 "html.semantic.raw_text_render",
1092 message,
1093 element.span.clone(),
1094 ));
1095 }
1096 }
1097 }
1098 out.push_str("</");
1099 out.push_str(&element.name);
1100 out.push('>');
1101 Ok(())
1102}
1103
1104fn render_html_element(
1105 name: &str,
1106 attributes: &[AttributeLike],
1107 children: &[Node],
1108 self_closing: bool,
1109 context: &RuntimeContext,
1110 out: &mut String,
1111) -> BackendResult<()> {
1112 out.push('<');
1113 out.push_str(name);
1114 let normalized = normalize_attributes(attributes, context)?;
1115 write_attributes(&normalized, out);
1116 if self_closing {
1117 out.push_str("/>");
1118 return Ok(());
1119 }
1120 out.push('>');
1121 for child in children {
1122 render_node(child, context, out)?;
1123 }
1124 out.push_str("</");
1125 out.push_str(name);
1126 out.push('>');
1127 Ok(())
1128}
1129
1130#[derive(Default)]
1131struct NormalizedAttributes {
1132 order: Vec<String>,
1133 attrs: BTreeMap<String, Option<String>>,
1134 class_values: Vec<String>,
1135 saw_class: bool,
1136}
1137
1138fn normalize_attributes(
1139 attributes: &[AttributeLike],
1140 context: &RuntimeContext,
1141) -> BackendResult<NormalizedAttributes> {
1142 let mut normalized = NormalizedAttributes::default();
1143 for attribute in attributes {
1144 match attribute {
1145 AttributeLike::Attribute(attribute) => {
1146 if attribute.name == "class" {
1147 normalized.saw_class = true;
1148 if !normalized.order.iter().any(|value| value == "class") {
1149 normalized.order.push("class".to_string());
1150 }
1151 if let Some(value) = &attribute.value {
1152 let rendered = render_attribute_value_parts(value, context, "class")?;
1153 normalized.class_values.extend(rendered);
1154 }
1155 continue;
1156 }
1157
1158 let rendered = render_attribute(attribute, context)?;
1159 if let Some(value) = rendered {
1160 if !normalized
1161 .order
1162 .iter()
1163 .any(|entry| entry == &attribute.name)
1164 {
1165 normalized.order.push(attribute.name.clone());
1166 }
1167 normalized.attrs.insert(attribute.name.clone(), value);
1168 }
1169 }
1170 AttributeLike::SpreadAttribute(attribute) => {
1171 apply_spread_attribute(&mut normalized, attribute, context)?
1172 }
1173 }
1174 }
1175 Ok(normalized)
1176}
1177
1178fn render_attribute(
1179 attribute: &Attribute,
1180 context: &RuntimeContext,
1181) -> BackendResult<Option<Option<String>>> {
1182 match &attribute.value {
1183 None => Ok(Some(None)),
1184 Some(value) => {
1185 if value.parts.len() == 1
1186 && matches!(value.parts.first(), Some(ValuePart::Interpolation(_)))
1187 {
1188 let interpolation = match value.parts.first() {
1189 Some(ValuePart::Interpolation(interpolation)) => interpolation,
1190 _ => unreachable!(),
1191 };
1192 return match value_for_interpolation(context, interpolation)? {
1193 RuntimeValue::Null => Ok(None),
1194 RuntimeValue::Bool(false) => Ok(None),
1195 RuntimeValue::Bool(true) => Ok(Some(None)),
1196 other => Ok(Some(Some(escape_html_attribute(&stringify_runtime_value(
1197 &other,
1198 )?)))),
1199 };
1200 }
1201 let rendered = render_attribute_value_string(value, context, &attribute.name)?;
1202 Ok(Some(Some(escape_html_attribute(&rendered))))
1203 }
1204 }
1205}
1206
1207fn apply_spread_attribute(
1208 normalized: &mut NormalizedAttributes,
1209 attribute: &SpreadAttribute,
1210 context: &RuntimeContext,
1211) -> BackendResult<()> {
1212 match value_for_interpolation(context, &attribute.interpolation)? {
1213 RuntimeValue::Attributes(entries) => {
1214 for (name, value) in entries {
1215 if name == "class" {
1216 normalized.saw_class = true;
1217 if !normalized.order.iter().any(|entry| entry == "class") {
1218 normalized.order.push("class".to_string());
1219 }
1220 normalized
1221 .class_values
1222 .extend(normalize_class_value(&value)?);
1223 continue;
1224 }
1225 match value {
1226 RuntimeValue::Null | RuntimeValue::Bool(false) => {
1227 normalized.attrs.remove(name.as_str());
1228 }
1229 RuntimeValue::Bool(true) => {
1230 if !normalized.order.iter().any(|entry| entry == name) {
1231 normalized.order.push(name.clone());
1232 }
1233 normalized.attrs.insert(name.clone(), None);
1234 }
1235 other => {
1236 if !normalized.order.iter().any(|entry| entry == name) {
1237 normalized.order.push(name.clone());
1238 }
1239 normalized.attrs.insert(
1240 name.clone(),
1241 Some(escape_html_attribute(&stringify_runtime_value_impl(
1242 &other,
1243 )?)),
1244 );
1245 }
1246 }
1247 }
1248 Ok(())
1249 }
1250 _ => Err(runtime_error(
1251 "html.runtime.spread_type",
1252 "Spread attributes require a mapping-like value.",
1253 attribute.span.clone(),
1254 )),
1255 }
1256}
1257
1258fn write_attributes(normalized: &NormalizedAttributes, out: &mut String) {
1259 for name in &normalized.order {
1260 if name == "class" {
1261 if !normalized.class_values.is_empty() {
1262 out.push(' ');
1263 out.push_str("class=\"");
1264 out.push_str(&escape_html_attribute(&normalized.class_values.join(" ")));
1265 out.push('"');
1266 }
1267 continue;
1268 }
1269 if let Some(value) = normalized.attrs.get(name) {
1270 out.push(' ');
1271 out.push_str(name);
1272 if let Some(value) = value {
1273 out.push_str("=\"");
1274 out.push_str(value);
1275 out.push('"');
1276 }
1277 }
1278 }
1279}
1280
1281pub fn render_child_value(value: &RuntimeValue, out: &mut String) -> BackendResult<()> {
1282 match value {
1283 RuntimeValue::Null => {}
1284 RuntimeValue::Bool(value) => out.push_str(&escape_html_text(&value.to_string())),
1285 RuntimeValue::Int(value) => out.push_str(&escape_html_text(&value.to_string())),
1286 RuntimeValue::Float(value) => out.push_str(&escape_html_text(&value.to_string())),
1287 RuntimeValue::String(value) => out.push_str(&escape_html_text(value)),
1288 RuntimeValue::RawHtml(value) => out.push_str(value),
1289 RuntimeValue::Fragment(values) | RuntimeValue::Sequence(values) => {
1290 for value in values {
1291 render_child_value(value, out)?;
1292 }
1293 }
1294 RuntimeValue::Attributes(_) => {
1295 return Err(runtime_error(
1296 "html.runtime.child_type",
1297 "Mapping-like values cannot be rendered as children.",
1298 None,
1299 ));
1300 }
1301 }
1302 Ok(())
1303}
1304
1305fn render_escaped_text_value(value: &RuntimeValue, out: &mut String) -> BackendResult<()> {
1306 match value {
1307 RuntimeValue::Null => {}
1308 RuntimeValue::Bool(value) => out.push_str(&escape_html_text(&value.to_string())),
1309 RuntimeValue::Int(value) => out.push_str(&escape_html_text(&value.to_string())),
1310 RuntimeValue::Float(value) => out.push_str(&escape_html_text(&value.to_string())),
1311 RuntimeValue::String(value) => out.push_str(&escape_html_text(value)),
1312 RuntimeValue::RawHtml(value) => out.push_str(&escape_html_text(value)),
1313 RuntimeValue::Fragment(values) | RuntimeValue::Sequence(values) => {
1314 for value in values {
1315 render_escaped_text_value(value, out)?;
1316 }
1317 }
1318 RuntimeValue::Attributes(_) => {
1319 return Err(runtime_error(
1320 "html.runtime.child_type",
1321 "Mapping-like values cannot be rendered as children.",
1322 None,
1323 ));
1324 }
1325 }
1326 Ok(())
1327}
1328
1329fn render_attribute_value_string(
1330 value: &AttributeValue,
1331 context: &RuntimeContext,
1332 name: &str,
1333) -> BackendResult<String> {
1334 let mut rendered = String::new();
1335 for part in &value.parts {
1336 match part {
1337 ValuePart::Text(text) => rendered.push_str(text),
1338 ValuePart::Interpolation(interpolation) => {
1339 if name == "class" {
1340 let normalized =
1341 normalize_class_value(value_for_interpolation(context, interpolation)?)?;
1342 if !normalized.is_empty() {
1343 if !rendered.is_empty() {
1344 rendered.push(' ');
1345 }
1346 rendered.push_str(&normalized.join(" "));
1347 }
1348 } else {
1349 rendered.push_str(&stringify_runtime_value_impl(value_for_interpolation(
1350 context,
1351 interpolation,
1352 )?)?);
1353 }
1354 }
1355 }
1356 }
1357 Ok(rendered)
1358}
1359
1360fn render_attribute_value_parts(
1361 value: &AttributeValue,
1362 context: &RuntimeContext,
1363 name: &str,
1364) -> BackendResult<Vec<String>> {
1365 if name != "class" {
1366 return Ok(vec![render_attribute_value_string(value, context, name)?]);
1367 }
1368
1369 let mut class_values = Vec::new();
1370 for part in &value.parts {
1371 match part {
1372 ValuePart::Text(text) => {
1373 class_values.extend(
1374 text.split_ascii_whitespace()
1375 .filter(|part| !part.is_empty())
1376 .map(str::to_string),
1377 );
1378 }
1379 ValuePart::Interpolation(interpolation) => {
1380 class_values.extend(normalize_class_value(value_for_interpolation(
1381 context,
1382 interpolation,
1383 )?)?);
1384 }
1385 }
1386 }
1387 Ok(class_values)
1388}
1389
1390fn normalize_class_value(value: &RuntimeValue) -> BackendResult<Vec<String>> {
1391 match value {
1392 RuntimeValue::Null => Ok(Vec::new()),
1393 RuntimeValue::Bool(false) => Ok(Vec::new()),
1394 RuntimeValue::Bool(true) => Err(runtime_error(
1395 "html.runtime.class_bool",
1396 "True is not a supported scalar class value.",
1397 None,
1398 )),
1399 RuntimeValue::String(value) => Ok(value
1400 .split_ascii_whitespace()
1401 .filter(|part| !part.is_empty())
1402 .map(str::to_string)
1403 .collect()),
1404 RuntimeValue::Sequence(values) | RuntimeValue::Fragment(values) => {
1405 let mut normalized = Vec::new();
1406 for value in values {
1407 normalized.extend(normalize_class_value(value)?);
1408 }
1409 Ok(normalized)
1410 }
1411 RuntimeValue::Attributes(entries) => Ok(entries
1412 .iter()
1413 .filter_map(|(name, value)| truthy_runtime_value(value).then_some(name.clone()))
1414 .collect()),
1415 RuntimeValue::Int(_) | RuntimeValue::Float(_) | RuntimeValue::RawHtml(_) => {
1416 Err(runtime_error(
1417 "html.runtime.class_type",
1418 "Unsupported class value type.",
1419 None,
1420 ))
1421 }
1422 }
1423}
1424
1425fn truthy_runtime_value(value: &RuntimeValue) -> bool {
1426 match value {
1427 RuntimeValue::Null => false,
1428 RuntimeValue::Bool(value) => *value,
1429 RuntimeValue::Int(value) => *value != 0,
1430 RuntimeValue::Float(value) => *value != 0.0,
1431 RuntimeValue::String(value) => !value.is_empty(),
1432 RuntimeValue::Fragment(value) | RuntimeValue::Sequence(value) => !value.is_empty(),
1433 RuntimeValue::RawHtml(value) => !value.is_empty(),
1434 RuntimeValue::Attributes(value) => !value.is_empty(),
1435 }
1436}
1437
1438fn value_for_interpolation<'a>(
1439 context: &'a RuntimeContext,
1440 interpolation: &InterpolationNode,
1441) -> BackendResult<&'a RuntimeValue> {
1442 context
1443 .values
1444 .get(interpolation.interpolation_index)
1445 .ok_or_else(|| {
1446 runtime_error(
1447 "html.runtime.missing_value",
1448 format!(
1449 "Missing runtime value for interpolation '{}'.",
1450 interpolation.expression
1451 ),
1452 interpolation.span.clone(),
1453 )
1454 })
1455}
1456
1457fn stringify_runtime_value_impl(value: &RuntimeValue) -> BackendResult<String> {
1458 match value {
1459 RuntimeValue::Null => Ok(String::new()),
1460 RuntimeValue::Bool(value) => Ok(value.to_string()),
1461 RuntimeValue::Int(value) => Ok(value.to_string()),
1462 RuntimeValue::Float(value) => Ok(value.to_string()),
1463 RuntimeValue::String(value) => Ok(value.clone()),
1464 RuntimeValue::RawHtml(value) => Ok(value.clone()),
1465 RuntimeValue::Fragment(_) | RuntimeValue::Sequence(_) | RuntimeValue::Attributes(_) => {
1466 Err(runtime_error(
1467 "html.runtime.scalar_type",
1468 "Value cannot be stringified in this position.",
1469 None,
1470 ))
1471 }
1472 }
1473}
1474
1475fn escape_html_text(value: &str) -> String {
1476 value
1477 .replace('&', "&")
1478 .replace('<', "<")
1479 .replace('>', ">")
1480}
1481
1482fn escape_html_attribute(value: &str) -> String {
1483 let mut out = String::new();
1484 let mut index = 0usize;
1485
1486 while index < value.len() {
1487 let ch = value[index..]
1488 .chars()
1489 .next()
1490 .expect("valid character boundary");
1491 match ch {
1492 '&' => {
1493 if let Some(entity_len) = html_entity_len(&value[index..]) {
1494 out.push_str(&value[index..index + entity_len]);
1495 index += entity_len;
1496 } else {
1497 out.push_str("&");
1498 index += 1;
1499 }
1500 }
1501 '<' => {
1502 out.push_str("<");
1503 index += 1;
1504 }
1505 '>' => {
1506 out.push_str(">");
1507 index += 1;
1508 }
1509 '"' => {
1510 out.push_str(""");
1511 index += 1;
1512 }
1513 _ => {
1514 out.push(ch);
1515 index += ch.len_utf8();
1516 }
1517 }
1518 }
1519
1520 out
1521}
1522
1523fn html_entity_len(input: &str) -> Option<usize> {
1524 let bytes = input.as_bytes();
1525 if !bytes.starts_with(b"&") {
1526 return None;
1527 }
1528
1529 let mut index = 1usize;
1530 if bytes.get(index) == Some(&b'#') {
1531 index += 1;
1532 if matches!(bytes.get(index), Some(b'x' | b'X')) {
1533 index += 1;
1534 let start = index;
1535 while bytes.get(index).is_some_and(u8::is_ascii_hexdigit) {
1536 index += 1;
1537 }
1538 if index == start || bytes.get(index) != Some(&b';') {
1539 return None;
1540 }
1541 return Some(index + 1);
1542 }
1543
1544 let start = index;
1545 while bytes.get(index).is_some_and(u8::is_ascii_digit) {
1546 index += 1;
1547 }
1548 if index == start || bytes.get(index) != Some(&b';') {
1549 return None;
1550 }
1551 return Some(index + 1);
1552 }
1553
1554 let start = index;
1555 while bytes.get(index).is_some_and(u8::is_ascii_alphanumeric) {
1556 index += 1;
1557 }
1558 if index == start || bytes.get(index) != Some(&b';') {
1559 return None;
1560 }
1561 Some(index + 1)
1562}
1563
1564fn flatten_input(template: &TemplateInput) -> Vec<StreamItem> {
1565 template.flatten()
1566}
1567
1568fn merge_children_span(children: &[Node]) -> Option<SourceSpan> {
1569 let mut iter = children.iter().filter_map(node_span);
1570 let first = iter.next()?;
1571 Some(iter.fold(first, merge_span))
1572}
1573
1574fn node_span(node: &Node) -> Option<SourceSpan> {
1575 match node {
1576 Node::Fragment(node) => node.span.clone(),
1577 Node::Element(node) => node.span.clone(),
1578 Node::ComponentTag(node) => node.span.clone(),
1579 Node::Text(node) => node.span.clone(),
1580 Node::Interpolation(node) => node.span.clone(),
1581 Node::Comment(node) => node.span.clone(),
1582 Node::Doctype(node) => node.span.clone(),
1583 Node::RawTextElement(node) => node.span.clone(),
1584 }
1585}
1586
1587fn merge_span(left: SourceSpan, right: SourceSpan) -> SourceSpan {
1588 left.merge(&right)
1589}
1590
1591fn merge_span_opt(left: Option<SourceSpan>, right: Option<SourceSpan>) -> Option<SourceSpan> {
1592 match (left, right) {
1593 (Some(left), Some(right)) => Some(merge_span(left, right)),
1594 (Some(left), None) => Some(left),
1595 (None, Some(right)) => Some(right),
1596 (None, None) => None,
1597 }
1598}
1599
1600fn is_name_char(value: char, is_start: bool) -> bool {
1601 if is_start {
1602 value.is_ascii_alphabetic() || value == '_'
1603 } else {
1604 value.is_ascii_alphanumeric() || matches!(value, '_' | '-' | ':' | '.')
1605 }
1606}
1607
1608fn parse_error(
1609 code: impl Into<String>,
1610 message: impl Into<String>,
1611 span: Option<SourceSpan>,
1612) -> BackendError {
1613 BackendError::parse_at(code, message, span)
1614}
1615
1616fn semantic_error(
1617 code: impl Into<String>,
1618 message: impl Into<String>,
1619 span: Option<SourceSpan>,
1620) -> BackendError {
1621 BackendError::semantic_at(code, message, span)
1622}
1623
1624pub fn runtime_error(
1625 code: impl Into<String>,
1626 message: impl Into<String>,
1627 span: Option<SourceSpan>,
1628) -> BackendError {
1629 let message = message.into();
1630 BackendError {
1631 kind: ErrorKind::Semantic,
1632 message: message.clone(),
1633 diagnostics: vec![Diagnostic::error(code, message, span)],
1634 }
1635}
1636
1637impl CompiledHtmlTemplate {
1638 #[must_use]
1639 pub fn from_document(document: Document) -> Self {
1640 Self { document }
1641 }
1642}
1643
1644pub fn stringify_runtime_value(value: &RuntimeValue) -> BackendResult<String> {
1645 stringify_runtime_value_impl(value)
1646}
1647
1648#[cfg(test)]
1649mod tests {
1650 use super::*;
1651
1652 fn interpolation(index: usize, expression: &str, raw_source: Option<&str>) -> TemplateSegment {
1653 TemplateSegment::Interpolation(TemplateInterpolation {
1654 expression: expression.to_string(),
1655 conversion: None,
1656 format_spec: String::new(),
1657 interpolation_index: index,
1658 raw_source: raw_source.map(str::to_string),
1659 })
1660 }
1661
1662 #[test]
1663 fn static_key_parts_preserve_empty_boundaries() {
1664 let input = TemplateInput::from_segments(vec![
1665 interpolation(0, "a", Some("{a}")),
1666 interpolation(1, "b", Some("{b}")),
1667 ]);
1668 assert_eq!(static_key_parts(&input), vec!["", "", ""]);
1669 }
1670
1671 #[test]
1672 fn parse_and_render_html() {
1673 let input = TemplateInput::from_segments(vec![
1674 TemplateSegment::StaticText("<div class=\"hello ".to_string()),
1675 interpolation(0, "name", Some("{name}")),
1676 TemplateSegment::StaticText("\">".to_string()),
1677 interpolation(1, "content", Some("{content}")),
1678 TemplateSegment::StaticText("</div>".to_string()),
1679 ]);
1680 let compiled = compile_template(&input).expect("compile html template");
1681 let rendered = render_html(
1682 &compiled,
1683 &RuntimeContext {
1684 values: vec![
1685 RuntimeValue::String("world".to_string()),
1686 RuntimeValue::String("<safe>".to_string()),
1687 ],
1688 },
1689 )
1690 .expect("render html");
1691 assert_eq!(rendered, "<div class=\"hello world\"><safe></div>");
1692 }
1693
1694 #[test]
1695 fn html_backend_rejects_component_tags() {
1696 let input = TemplateInput::from_segments(vec![TemplateSegment::StaticText(
1697 "<Button />".to_string(),
1698 )]);
1699 let err = check_template(&input).expect_err("component tags must fail");
1700 assert_eq!(err.kind, ErrorKind::Semantic);
1701 }
1702
1703 #[test]
1704 fn format_requires_raw_source() {
1705 let input = TemplateInput::from_segments(vec![
1706 TemplateSegment::StaticText("<div>".to_string()),
1707 interpolation(0, "value", None),
1708 TemplateSegment::StaticText("</div>".to_string()),
1709 ]);
1710 let err = format_template(&input).expect_err("format requires raw source");
1711 assert_eq!(err.kind, ErrorKind::Semantic);
1712 }
1713
1714 #[test]
1715 fn class_normalization_supports_mappings_and_sequences() {
1716 let values = normalize_class_value(&RuntimeValue::Sequence(vec![
1717 RuntimeValue::String("foo bar".to_string()),
1718 RuntimeValue::Attributes(vec![
1719 ("baz".to_string(), RuntimeValue::Bool(true)),
1720 ("skip".to_string(), RuntimeValue::Bool(false)),
1721 ]),
1722 ]))
1723 .expect("normalize class");
1724 assert_eq!(values, vec!["foo", "bar", "baz"]);
1725 }
1726
1727 #[test]
1728 fn title_interpolation_is_allowed_and_escaped_on_render() {
1729 let input = TemplateInput::from_segments(vec![
1730 TemplateSegment::StaticText("<title>".to_string()),
1731 interpolation(0, "title", Some("{title}")),
1732 TemplateSegment::StaticText("</title>".to_string()),
1733 ]);
1734 let compiled = compile_template(&input).expect("compile title template");
1735 let rendered = render_html(
1736 &compiled,
1737 &RuntimeContext {
1738 values: vec![RuntimeValue::RawHtml("<safe>".to_string())],
1739 },
1740 )
1741 .expect("render title");
1742 assert_eq!(rendered, "<title><safe></title>");
1743 }
1744
1745 #[test]
1746 fn script_interpolation_is_still_rejected() {
1747 let input = TemplateInput::from_segments(vec![
1748 TemplateSegment::StaticText("<script>".to_string()),
1749 interpolation(0, "script", Some("{script}")),
1750 TemplateSegment::StaticText("</script>".to_string()),
1751 ]);
1752 let err = check_template(&input).expect_err("script must still fail");
1753 assert_eq!(err.kind, ErrorKind::Semantic);
1754 assert!(err.message.contains("<script>"));
1755 }
1756}