Skip to main content

tstring_html/
lib.rs

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