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