Skip to main content

tstring_html/
lib.rs

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 validate_html_document(document: &Document) -> BackendResult<()> {
833    for child in &document.children {
834        validate_html_node(child)?;
835    }
836    Ok(())
837}
838
839fn rebind_node_interpolations(node: &mut Node, template: &TemplateInput) {
840    match node {
841        Node::Fragment(fragment) => {
842            for child in &mut fragment.children {
843                rebind_node_interpolations(child, template);
844            }
845        }
846        Node::Element(element) => {
847            rebind_attributes(&mut element.attributes, template);
848            for child in &mut element.children {
849                rebind_node_interpolations(child, template);
850            }
851        }
852        Node::ComponentTag(component) => {
853            rebind_attributes(&mut component.attributes, template);
854            for child in &mut component.children {
855                rebind_node_interpolations(child, template);
856            }
857        }
858        Node::RawTextElement(element) => {
859            rebind_attributes(&mut element.attributes, template);
860            for child in &mut element.children {
861                rebind_node_interpolations(child, template);
862            }
863        }
864        Node::Interpolation(interpolation) => {
865            rebind_interpolation(interpolation, template);
866        }
867        Node::Text(_) | Node::Comment(_) | Node::Doctype(_) => {}
868    }
869}
870
871fn rebind_attributes(attributes: &mut [AttributeLike], template: &TemplateInput) {
872    for attribute in attributes {
873        match attribute {
874            AttributeLike::Attribute(attribute) => {
875                if let Some(value) = &mut attribute.value {
876                    for part in &mut value.parts {
877                        if let ValuePart::Interpolation(interpolation) = part {
878                            rebind_interpolation(interpolation, template);
879                        }
880                    }
881                }
882            }
883            AttributeLike::SpreadAttribute(attribute) => {
884                rebind_interpolation(&mut attribute.interpolation, template);
885            }
886        }
887    }
888}
889
890fn rebind_interpolation(interpolation: &mut InterpolationNode, template: &TemplateInput) {
891    if let Some(source) = template.interpolation(interpolation.interpolation_index) {
892        interpolation.expression = source.expression.clone();
893        interpolation.raw_source = source.raw_source.clone();
894        interpolation.conversion = source.conversion.clone();
895        interpolation.format_spec = source.format_spec.clone();
896    }
897}
898
899fn validate_html_node(node: &Node) -> BackendResult<()> {
900    match node {
901        Node::ComponentTag(component) => Err(semantic_error(
902            "html.semantic.component_tag",
903            format!(
904                "Component tag <{}> is only valid in the T-HTML backend.",
905                component.name
906            ),
907            component.span.clone(),
908        )),
909        Node::Element(element) => {
910            validate_children(&element.children)?;
911            validate_attributes(&element.attributes)?;
912            Ok(())
913        }
914        Node::RawTextElement(element) => {
915            validate_attributes(&element.attributes)?;
916            for child in &element.children {
917                match child {
918                    Node::Interpolation(interpolation) => {
919                        return Err(semantic_error(
920                            "html.semantic.raw_text_interpolation",
921                            format!("Interpolations are not allowed inside <{}>.", element.name),
922                            interpolation.span.clone(),
923                        ));
924                    }
925                    Node::Text(_) => {}
926                    _ => {
927                        return Err(semantic_error(
928                            "html.semantic.raw_text_content",
929                            format!("Only text is allowed inside <{}>.", element.name),
930                            element.span.clone(),
931                        ));
932                    }
933                }
934            }
935            Ok(())
936        }
937        Node::Fragment(fragment) => validate_children(&fragment.children),
938        _ => Ok(()),
939    }
940}
941
942fn validate_children(children: &[Node]) -> BackendResult<()> {
943    for child in children {
944        validate_html_node(child)?;
945    }
946    Ok(())
947}
948
949fn validate_attributes(attributes: &[AttributeLike]) -> BackendResult<()> {
950    for attribute in attributes {
951        match attribute {
952            AttributeLike::Attribute(attribute) => {
953                if let Some(value) = &attribute.value {
954                    if !value.quoted
955                        && value
956                            .parts
957                            .iter()
958                            .any(|part| matches!(part, ValuePart::Interpolation(_)))
959                    {
960                        return Err(semantic_error(
961                            "html.semantic.unquoted_dynamic_attr",
962                            format!(
963                                "Dynamic attribute value for '{}' must be quoted.",
964                                attribute.name
965                            ),
966                            attribute.span.clone(),
967                        ));
968                    }
969                }
970            }
971            AttributeLike::SpreadAttribute(_) => {}
972        }
973    }
974    Ok(())
975}
976
977fn require_raw_source(template: &TemplateInput) -> BackendResult<()> {
978    for segment in &template.segments {
979        if let TemplateSegment::Interpolation(interpolation) = segment {
980            if interpolation.raw_source.is_none() {
981                return Err(semantic_error(
982                    "html.format.raw_source_required",
983                    format!(
984                        "Formatting requires raw_source for interpolation '{}'.",
985                        interpolation.expression_label()
986                    ),
987                    None,
988                ));
989            }
990        }
991    }
992    Ok(())
993}
994
995fn render_document(document: &Document, context: &RuntimeContext) -> BackendResult<String> {
996    let mut out = String::new();
997    for child in &document.children {
998        render_node(child, context, &mut out)?;
999    }
1000    Ok(out)
1001}
1002
1003fn render_node(node: &Node, context: &RuntimeContext, out: &mut String) -> BackendResult<()> {
1004    match node {
1005        Node::Text(text) => out.push_str(&escape_html_text(&text.value)),
1006        Node::Interpolation(interpolation) => {
1007            render_child_value(value_for_interpolation(context, interpolation)?, out)?
1008        }
1009        Node::Comment(comment) => {
1010            out.push_str("<!--");
1011            out.push_str(&comment.value);
1012            out.push_str("-->");
1013        }
1014        Node::Doctype(doctype) => {
1015            out.push('<');
1016            out.push('!');
1017            out.push_str(&doctype.value);
1018            out.push('>');
1019        }
1020        Node::Fragment(fragment) => {
1021            for child in &fragment.children {
1022                render_node(child, context, out)?;
1023            }
1024        }
1025        Node::Element(element) => render_html_element(
1026            &element.name,
1027            &element.attributes,
1028            &element.children,
1029            element.self_closing,
1030            context,
1031            out,
1032        )?,
1033        Node::RawTextElement(element) => {
1034            out.push('<');
1035            out.push_str(&element.name);
1036            let normalized = normalize_attributes(&element.attributes, context)?;
1037            write_attributes(&normalized, out);
1038            out.push('>');
1039            for child in &element.children {
1040                match child {
1041                    Node::Text(text) => out.push_str(&text.value),
1042                    _ => {
1043                        return Err(semantic_error(
1044                            "html.semantic.raw_text_render",
1045                            format!("Only text can be rendered inside <{}>.", element.name),
1046                            element.span.clone(),
1047                        ));
1048                    }
1049                }
1050            }
1051            out.push_str("</");
1052            out.push_str(&element.name);
1053            out.push('>');
1054        }
1055        Node::ComponentTag(component) => {
1056            return Err(semantic_error(
1057                "html.semantic.component_render",
1058                format!(
1059                    "Component tag <{}> is only valid in the T-HTML backend.",
1060                    component.name
1061                ),
1062                component.span.clone(),
1063            ));
1064        }
1065    }
1066    Ok(())
1067}
1068
1069fn render_html_element(
1070    name: &str,
1071    attributes: &[AttributeLike],
1072    children: &[Node],
1073    self_closing: bool,
1074    context: &RuntimeContext,
1075    out: &mut String,
1076) -> BackendResult<()> {
1077    out.push('<');
1078    out.push_str(name);
1079    let normalized = normalize_attributes(attributes, context)?;
1080    write_attributes(&normalized, out);
1081    if self_closing {
1082        out.push_str("/>");
1083        return Ok(());
1084    }
1085    out.push('>');
1086    for child in children {
1087        render_node(child, context, out)?;
1088    }
1089    out.push_str("</");
1090    out.push_str(name);
1091    out.push('>');
1092    Ok(())
1093}
1094
1095#[derive(Default)]
1096struct NormalizedAttributes {
1097    order: Vec<String>,
1098    attrs: BTreeMap<String, Option<String>>,
1099    class_values: Vec<String>,
1100    saw_class: bool,
1101}
1102
1103fn normalize_attributes(
1104    attributes: &[AttributeLike],
1105    context: &RuntimeContext,
1106) -> BackendResult<NormalizedAttributes> {
1107    let mut normalized = NormalizedAttributes::default();
1108    for attribute in attributes {
1109        match attribute {
1110            AttributeLike::Attribute(attribute) => {
1111                if attribute.name == "class" {
1112                    normalized.saw_class = true;
1113                    if !normalized.order.iter().any(|value| value == "class") {
1114                        normalized.order.push("class".to_string());
1115                    }
1116                    if let Some(value) = &attribute.value {
1117                        let rendered = render_attribute_value_parts(value, context, "class")?;
1118                        normalized.class_values.extend(rendered);
1119                    }
1120                    continue;
1121                }
1122
1123                let rendered = render_attribute(attribute, context)?;
1124                if let Some(value) = rendered {
1125                    if !normalized
1126                        .order
1127                        .iter()
1128                        .any(|entry| entry == &attribute.name)
1129                    {
1130                        normalized.order.push(attribute.name.clone());
1131                    }
1132                    normalized.attrs.insert(attribute.name.clone(), value);
1133                }
1134            }
1135            AttributeLike::SpreadAttribute(attribute) => {
1136                apply_spread_attribute(&mut normalized, attribute, context)?
1137            }
1138        }
1139    }
1140    Ok(normalized)
1141}
1142
1143fn render_attribute(
1144    attribute: &Attribute,
1145    context: &RuntimeContext,
1146) -> BackendResult<Option<Option<String>>> {
1147    match &attribute.value {
1148        None => Ok(Some(None)),
1149        Some(value) => {
1150            if value.parts.len() == 1
1151                && matches!(value.parts.first(), Some(ValuePart::Interpolation(_)))
1152            {
1153                let interpolation = match value.parts.first() {
1154                    Some(ValuePart::Interpolation(interpolation)) => interpolation,
1155                    _ => unreachable!(),
1156                };
1157                return match value_for_interpolation(context, interpolation)? {
1158                    RuntimeValue::Null => Ok(None),
1159                    RuntimeValue::Bool(false) => Ok(None),
1160                    RuntimeValue::Bool(true) => Ok(Some(None)),
1161                    other => Ok(Some(Some(escape_html_attribute(&stringify_runtime_value(
1162                        &other,
1163                    )?)))),
1164                };
1165            }
1166            let rendered = render_attribute_value_string(value, context, &attribute.name)?;
1167            Ok(Some(Some(escape_html_attribute(&rendered))))
1168        }
1169    }
1170}
1171
1172fn apply_spread_attribute(
1173    normalized: &mut NormalizedAttributes,
1174    attribute: &SpreadAttribute,
1175    context: &RuntimeContext,
1176) -> BackendResult<()> {
1177    match value_for_interpolation(context, &attribute.interpolation)? {
1178        RuntimeValue::Attributes(entries) => {
1179            for (name, value) in entries {
1180                if name == "class" {
1181                    normalized.saw_class = true;
1182                    if !normalized.order.iter().any(|entry| entry == "class") {
1183                        normalized.order.push("class".to_string());
1184                    }
1185                    normalized
1186                        .class_values
1187                        .extend(normalize_class_value(&value)?);
1188                    continue;
1189                }
1190                match value {
1191                    RuntimeValue::Null | RuntimeValue::Bool(false) => {
1192                        normalized.attrs.remove(name.as_str());
1193                    }
1194                    RuntimeValue::Bool(true) => {
1195                        if !normalized.order.iter().any(|entry| entry == name) {
1196                            normalized.order.push(name.clone());
1197                        }
1198                        normalized.attrs.insert(name.clone(), None);
1199                    }
1200                    other => {
1201                        if !normalized.order.iter().any(|entry| entry == name) {
1202                            normalized.order.push(name.clone());
1203                        }
1204                        normalized.attrs.insert(
1205                            name.clone(),
1206                            Some(escape_html_attribute(&stringify_runtime_value_impl(
1207                                &other,
1208                            )?)),
1209                        );
1210                    }
1211                }
1212            }
1213            Ok(())
1214        }
1215        _ => Err(runtime_error(
1216            "html.runtime.spread_type",
1217            "Spread attributes require a mapping-like value.",
1218            attribute.span.clone(),
1219        )),
1220    }
1221}
1222
1223fn write_attributes(normalized: &NormalizedAttributes, out: &mut String) {
1224    for name in &normalized.order {
1225        if name == "class" {
1226            if !normalized.class_values.is_empty() {
1227                out.push(' ');
1228                out.push_str("class=\"");
1229                out.push_str(&escape_html_attribute(&normalized.class_values.join(" ")));
1230                out.push('"');
1231            }
1232            continue;
1233        }
1234        if let Some(value) = normalized.attrs.get(name) {
1235            out.push(' ');
1236            out.push_str(name);
1237            if let Some(value) = value {
1238                out.push_str("=\"");
1239                out.push_str(value);
1240                out.push('"');
1241            }
1242        }
1243    }
1244}
1245
1246pub fn render_child_value(value: &RuntimeValue, out: &mut String) -> BackendResult<()> {
1247    match value {
1248        RuntimeValue::Null => {}
1249        RuntimeValue::Bool(value) => out.push_str(&escape_html_text(&value.to_string())),
1250        RuntimeValue::Int(value) => out.push_str(&escape_html_text(&value.to_string())),
1251        RuntimeValue::Float(value) => out.push_str(&escape_html_text(&value.to_string())),
1252        RuntimeValue::String(value) => out.push_str(&escape_html_text(value)),
1253        RuntimeValue::RawHtml(value) => out.push_str(value),
1254        RuntimeValue::Fragment(values) | RuntimeValue::Sequence(values) => {
1255            for value in values {
1256                render_child_value(value, out)?;
1257            }
1258        }
1259        RuntimeValue::Attributes(_) => {
1260            return Err(runtime_error(
1261                "html.runtime.child_type",
1262                "Mapping-like values cannot be rendered as children.",
1263                None,
1264            ));
1265        }
1266    }
1267    Ok(())
1268}
1269
1270fn render_attribute_value_string(
1271    value: &AttributeValue,
1272    context: &RuntimeContext,
1273    name: &str,
1274) -> BackendResult<String> {
1275    let mut rendered = String::new();
1276    for part in &value.parts {
1277        match part {
1278            ValuePart::Text(text) => rendered.push_str(text),
1279            ValuePart::Interpolation(interpolation) => {
1280                if name == "class" {
1281                    let normalized =
1282                        normalize_class_value(value_for_interpolation(context, interpolation)?)?;
1283                    if !normalized.is_empty() {
1284                        if !rendered.is_empty() {
1285                            rendered.push(' ');
1286                        }
1287                        rendered.push_str(&normalized.join(" "));
1288                    }
1289                } else {
1290                    rendered.push_str(&stringify_runtime_value_impl(value_for_interpolation(
1291                        context,
1292                        interpolation,
1293                    )?)?);
1294                }
1295            }
1296        }
1297    }
1298    Ok(rendered)
1299}
1300
1301fn render_attribute_value_parts(
1302    value: &AttributeValue,
1303    context: &RuntimeContext,
1304    name: &str,
1305) -> BackendResult<Vec<String>> {
1306    if name != "class" {
1307        return Ok(vec![render_attribute_value_string(value, context, name)?]);
1308    }
1309
1310    let mut class_values = Vec::new();
1311    for part in &value.parts {
1312        match part {
1313            ValuePart::Text(text) => {
1314                class_values.extend(
1315                    text.split_ascii_whitespace()
1316                        .filter(|part| !part.is_empty())
1317                        .map(str::to_string),
1318                );
1319            }
1320            ValuePart::Interpolation(interpolation) => {
1321                class_values.extend(normalize_class_value(value_for_interpolation(
1322                    context,
1323                    interpolation,
1324                )?)?);
1325            }
1326        }
1327    }
1328    Ok(class_values)
1329}
1330
1331fn normalize_class_value(value: &RuntimeValue) -> BackendResult<Vec<String>> {
1332    match value {
1333        RuntimeValue::Null => Ok(Vec::new()),
1334        RuntimeValue::Bool(false) => Ok(Vec::new()),
1335        RuntimeValue::Bool(true) => Err(runtime_error(
1336            "html.runtime.class_bool",
1337            "True is not a supported scalar class value.",
1338            None,
1339        )),
1340        RuntimeValue::String(value) => Ok(value
1341            .split_ascii_whitespace()
1342            .filter(|part| !part.is_empty())
1343            .map(str::to_string)
1344            .collect()),
1345        RuntimeValue::Sequence(values) | RuntimeValue::Fragment(values) => {
1346            let mut normalized = Vec::new();
1347            for value in values {
1348                normalized.extend(normalize_class_value(value)?);
1349            }
1350            Ok(normalized)
1351        }
1352        RuntimeValue::Attributes(entries) => Ok(entries
1353            .iter()
1354            .filter_map(|(name, value)| truthy_runtime_value(value).then_some(name.clone()))
1355            .collect()),
1356        RuntimeValue::Int(_) | RuntimeValue::Float(_) | RuntimeValue::RawHtml(_) => {
1357            Err(runtime_error(
1358                "html.runtime.class_type",
1359                "Unsupported class value type.",
1360                None,
1361            ))
1362        }
1363    }
1364}
1365
1366fn truthy_runtime_value(value: &RuntimeValue) -> bool {
1367    match value {
1368        RuntimeValue::Null => false,
1369        RuntimeValue::Bool(value) => *value,
1370        RuntimeValue::Int(value) => *value != 0,
1371        RuntimeValue::Float(value) => *value != 0.0,
1372        RuntimeValue::String(value) => !value.is_empty(),
1373        RuntimeValue::Fragment(value) | RuntimeValue::Sequence(value) => !value.is_empty(),
1374        RuntimeValue::RawHtml(value) => !value.is_empty(),
1375        RuntimeValue::Attributes(value) => !value.is_empty(),
1376    }
1377}
1378
1379fn value_for_interpolation<'a>(
1380    context: &'a RuntimeContext,
1381    interpolation: &InterpolationNode,
1382) -> BackendResult<&'a RuntimeValue> {
1383    context
1384        .values
1385        .get(interpolation.interpolation_index)
1386        .ok_or_else(|| {
1387            runtime_error(
1388                "html.runtime.missing_value",
1389                format!(
1390                    "Missing runtime value for interpolation '{}'.",
1391                    interpolation.expression
1392                ),
1393                interpolation.span.clone(),
1394            )
1395        })
1396}
1397
1398fn stringify_runtime_value_impl(value: &RuntimeValue) -> BackendResult<String> {
1399    match value {
1400        RuntimeValue::Null => Ok(String::new()),
1401        RuntimeValue::Bool(value) => Ok(value.to_string()),
1402        RuntimeValue::Int(value) => Ok(value.to_string()),
1403        RuntimeValue::Float(value) => Ok(value.to_string()),
1404        RuntimeValue::String(value) => Ok(value.clone()),
1405        RuntimeValue::RawHtml(value) => Ok(value.clone()),
1406        RuntimeValue::Fragment(_) | RuntimeValue::Sequence(_) | RuntimeValue::Attributes(_) => {
1407            Err(runtime_error(
1408                "html.runtime.scalar_type",
1409                "Value cannot be stringified in this position.",
1410                None,
1411            ))
1412        }
1413    }
1414}
1415
1416fn escape_html_text(value: &str) -> String {
1417    value
1418        .replace('&', "&amp;")
1419        .replace('<', "&lt;")
1420        .replace('>', "&gt;")
1421}
1422
1423fn escape_html_attribute(value: &str) -> String {
1424    let mut out = String::new();
1425    let mut index = 0usize;
1426
1427    while index < value.len() {
1428        let ch = value[index..]
1429            .chars()
1430            .next()
1431            .expect("valid character boundary");
1432        match ch {
1433            '&' => {
1434                if let Some(entity_len) = html_entity_len(&value[index..]) {
1435                    out.push_str(&value[index..index + entity_len]);
1436                    index += entity_len;
1437                } else {
1438                    out.push_str("&amp;");
1439                    index += 1;
1440                }
1441            }
1442            '<' => {
1443                out.push_str("&lt;");
1444                index += 1;
1445            }
1446            '>' => {
1447                out.push_str("&gt;");
1448                index += 1;
1449            }
1450            '"' => {
1451                out.push_str("&quot;");
1452                index += 1;
1453            }
1454            _ => {
1455                out.push(ch);
1456                index += ch.len_utf8();
1457            }
1458        }
1459    }
1460
1461    out
1462}
1463
1464fn html_entity_len(input: &str) -> Option<usize> {
1465    let bytes = input.as_bytes();
1466    if !bytes.starts_with(b"&") {
1467        return None;
1468    }
1469
1470    let mut index = 1usize;
1471    if bytes.get(index) == Some(&b'#') {
1472        index += 1;
1473        if matches!(bytes.get(index), Some(b'x' | b'X')) {
1474            index += 1;
1475            let start = index;
1476            while bytes.get(index).is_some_and(u8::is_ascii_hexdigit) {
1477                index += 1;
1478            }
1479            if index == start || bytes.get(index) != Some(&b';') {
1480                return None;
1481            }
1482            return Some(index + 1);
1483        }
1484
1485        let start = index;
1486        while bytes.get(index).is_some_and(u8::is_ascii_digit) {
1487            index += 1;
1488        }
1489        if index == start || bytes.get(index) != Some(&b';') {
1490            return None;
1491        }
1492        return Some(index + 1);
1493    }
1494
1495    let start = index;
1496    while bytes.get(index).is_some_and(u8::is_ascii_alphanumeric) {
1497        index += 1;
1498    }
1499    if index == start || bytes.get(index) != Some(&b';') {
1500        return None;
1501    }
1502    Some(index + 1)
1503}
1504
1505fn flatten_input(template: &TemplateInput) -> Vec<StreamItem> {
1506    template.flatten()
1507}
1508
1509fn merge_children_span(children: &[Node]) -> Option<SourceSpan> {
1510    let mut iter = children.iter().filter_map(node_span);
1511    let first = iter.next()?;
1512    Some(iter.fold(first, merge_span))
1513}
1514
1515fn node_span(node: &Node) -> Option<SourceSpan> {
1516    match node {
1517        Node::Fragment(node) => node.span.clone(),
1518        Node::Element(node) => node.span.clone(),
1519        Node::ComponentTag(node) => node.span.clone(),
1520        Node::Text(node) => node.span.clone(),
1521        Node::Interpolation(node) => node.span.clone(),
1522        Node::Comment(node) => node.span.clone(),
1523        Node::Doctype(node) => node.span.clone(),
1524        Node::RawTextElement(node) => node.span.clone(),
1525    }
1526}
1527
1528fn merge_span(left: SourceSpan, right: SourceSpan) -> SourceSpan {
1529    left.merge(&right)
1530}
1531
1532fn merge_span_opt(left: Option<SourceSpan>, right: Option<SourceSpan>) -> Option<SourceSpan> {
1533    match (left, right) {
1534        (Some(left), Some(right)) => Some(merge_span(left, right)),
1535        (Some(left), None) => Some(left),
1536        (None, Some(right)) => Some(right),
1537        (None, None) => None,
1538    }
1539}
1540
1541fn is_name_char(value: char, is_start: bool) -> bool {
1542    if is_start {
1543        value.is_ascii_alphabetic() || value == '_'
1544    } else {
1545        value.is_ascii_alphanumeric() || matches!(value, '_' | '-' | ':' | '.')
1546    }
1547}
1548
1549fn parse_error(
1550    code: impl Into<String>,
1551    message: impl Into<String>,
1552    span: Option<SourceSpan>,
1553) -> BackendError {
1554    BackendError::parse_at(code, message, span)
1555}
1556
1557fn semantic_error(
1558    code: impl Into<String>,
1559    message: impl Into<String>,
1560    span: Option<SourceSpan>,
1561) -> BackendError {
1562    BackendError::semantic_at(code, message, span)
1563}
1564
1565pub fn runtime_error(
1566    code: impl Into<String>,
1567    message: impl Into<String>,
1568    span: Option<SourceSpan>,
1569) -> BackendError {
1570    let message = message.into();
1571    BackendError {
1572        kind: ErrorKind::Semantic,
1573        message: message.clone(),
1574        diagnostics: vec![Diagnostic::error(code, message, span)],
1575    }
1576}
1577
1578impl CompiledHtmlTemplate {
1579    #[must_use]
1580    pub fn from_document(document: Document) -> Self {
1581        Self { document }
1582    }
1583}
1584
1585pub fn stringify_runtime_value(value: &RuntimeValue) -> BackendResult<String> {
1586    stringify_runtime_value_impl(value)
1587}
1588
1589#[cfg(test)]
1590mod tests {
1591    use super::*;
1592
1593    fn interpolation(index: usize, expression: &str, raw_source: Option<&str>) -> TemplateSegment {
1594        TemplateSegment::Interpolation(TemplateInterpolation {
1595            expression: expression.to_string(),
1596            conversion: None,
1597            format_spec: String::new(),
1598            interpolation_index: index,
1599            raw_source: raw_source.map(str::to_string),
1600        })
1601    }
1602
1603    #[test]
1604    fn static_key_parts_preserve_empty_boundaries() {
1605        let input = TemplateInput::from_segments(vec![
1606            interpolation(0, "a", Some("{a}")),
1607            interpolation(1, "b", Some("{b}")),
1608        ]);
1609        assert_eq!(static_key_parts(&input), vec!["", "", ""]);
1610    }
1611
1612    #[test]
1613    fn parse_and_render_html() {
1614        let input = TemplateInput::from_segments(vec![
1615            TemplateSegment::StaticText("<div class=\"hello ".to_string()),
1616            interpolation(0, "name", Some("{name}")),
1617            TemplateSegment::StaticText("\">".to_string()),
1618            interpolation(1, "content", Some("{content}")),
1619            TemplateSegment::StaticText("</div>".to_string()),
1620        ]);
1621        let compiled = compile_template(&input).expect("compile html template");
1622        let rendered = render_html(
1623            &compiled,
1624            &RuntimeContext {
1625                values: vec![
1626                    RuntimeValue::String("world".to_string()),
1627                    RuntimeValue::String("<safe>".to_string()),
1628                ],
1629            },
1630        )
1631        .expect("render html");
1632        assert_eq!(rendered, "<div class=\"hello world\">&lt;safe&gt;</div>");
1633    }
1634
1635    #[test]
1636    fn html_backend_rejects_component_tags() {
1637        let input = TemplateInput::from_segments(vec![TemplateSegment::StaticText(
1638            "<Button />".to_string(),
1639        )]);
1640        let err = check_template(&input).expect_err("component tags must fail");
1641        assert_eq!(err.kind, ErrorKind::Semantic);
1642    }
1643
1644    #[test]
1645    fn format_requires_raw_source() {
1646        let input = TemplateInput::from_segments(vec![
1647            TemplateSegment::StaticText("<div>".to_string()),
1648            interpolation(0, "value", None),
1649            TemplateSegment::StaticText("</div>".to_string()),
1650        ]);
1651        let err = format_template(&input).expect_err("format requires raw source");
1652        assert_eq!(err.kind, ErrorKind::Semantic);
1653    }
1654
1655    #[test]
1656    fn class_normalization_supports_mappings_and_sequences() {
1657        let values = normalize_class_value(&RuntimeValue::Sequence(vec![
1658            RuntimeValue::String("foo bar".to_string()),
1659            RuntimeValue::Attributes(vec![
1660                ("baz".to_string(), RuntimeValue::Bool(true)),
1661                ("skip".to_string(), RuntimeValue::Bool(false)),
1662            ]),
1663        ]))
1664        .expect("normalize class");
1665        assert_eq!(values, vec!["foo", "bar", "baz"]);
1666    }
1667}