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