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