1mod component_registry;
8mod condition_parser;
9mod css_parser;
10mod error;
11mod handlebars_parser;
12pub mod plugin;
13mod route_parser;
14
15pub use component_registry::{Component, ComponentRegistry};
16pub use condition_parser::ConditionParser;
17pub use css_parser::CssParser;
18pub use error::{ParserError, Result};
19pub use handlebars_parser::HandlebarsParser;
20
21use crate::plugin::ParserPlugin;
22use std::collections::{HashMap, HashSet};
23use tree_sitter::{Node, Parser, Query, QueryCursor, StreamingIteratorMut};
24use tree_sitter_html::LANGUAGE;
25use webui_protocol::{
26 web_ui_fragment, web_ui_fragment::Fragment, ConditionExpr, FragmentList, WebUIFragment,
27 WebUIFragmentAttribute, WebUIFragmentRecords, WebUiFragmentRoute,
28};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
33pub enum CssStrategy {
34 #[default]
36 Link,
37 Style,
39 Module,
43}
44
45struct FragmentIdCounter {
47 counters: HashMap<String, usize>,
49}
50
51impl FragmentIdCounter {
52 fn new() -> Self {
54 Self {
55 counters: HashMap::new(),
56 }
57 }
58
59 fn next_id(&mut self, prefix: &str) -> String {
61 let count = self.counters.entry(prefix.to_string()).or_insert(0);
62 *count += 1;
63 format!("{}-{}", prefix, count)
64 }
65}
66
67impl Default for HtmlParser {
68 fn default() -> Self {
69 Self::new()
70 }
71}
72
73pub struct HtmlParser {
75 css_parser: CssParser,
77
78 parser: Parser,
80
81 id_counter: FragmentIdCounter,
83
84 condition_parser: ConditionParser,
86
87 handlebars_parser: HandlebarsParser,
89
90 component_registry: ComponentRegistry,
92
93 fragment_records: WebUIFragmentRecords,
95
96 raw_buffer: String,
98
99 css_strategy: CssStrategy,
101
102 plugin: Option<Box<dyn ParserPlugin>>,
104
105 token_store: HashSet<String>,
108
109 token_definitions: HashSet<String>,
113}
114
115impl HtmlParser {
116 pub fn new() -> Self {
118 let mut parser = Parser::new();
119 parser
120 .set_language(&LANGUAGE.into())
121 .expect("Error loading HTML grammar");
122
123 Self {
124 component_registry: ComponentRegistry::new(),
125 css_parser: CssParser::new(),
126 id_counter: FragmentIdCounter::new(),
127 condition_parser: ConditionParser::new(),
128 handlebars_parser: HandlebarsParser::new(),
129 raw_buffer: String::new(),
130 fragment_records: WebUIFragmentRecords::new(),
131 css_strategy: CssStrategy::default(),
132 plugin: None,
133 token_store: HashSet::new(),
134 token_definitions: HashSet::new(),
135 parser,
136 }
137 }
138
139 pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
141 let mut p = Self::new();
142 p.plugin = Some(plugin);
143 p
144 }
145
146 pub fn set_css_strategy(&mut self, strategy: CssStrategy) -> &mut Self {
148 self.css_strategy = strategy;
149 self
150 }
151
152 pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
154 &mut self.component_registry
155 }
156
157 pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
158 std::mem::take(&mut self.fragment_records)
159 }
160
161 pub fn has_fragment(&self, fragment_id: &str) -> bool {
163 self.fragment_records.contains_key(fragment_id)
164 }
165
166 pub fn take_plugin(&mut self) -> Option<Box<dyn ParserPlugin>> {
169 self.plugin.take()
170 }
171
172 pub fn plugin_mut(&mut self) -> Option<&mut dyn ParserPlugin> {
174 self.plugin.as_deref_mut()
175 }
176
177 #[must_use]
185 pub fn take_tokens(&mut self) -> Vec<String> {
186 let definitions = std::mem::take(&mut self.token_definitions);
187 let mut tokens: Vec<String> = std::mem::take(&mut self.token_store)
188 .into_iter()
189 .filter(|t| !definitions.contains(t))
190 .collect();
191 tokens.sort();
192 tokens
193 }
194
195 pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
197 self.raw_buffer.clear();
199
200 let tree = self
202 .parser
203 .parse(html_content, None)
204 .ok_or_else(|| ParserError::Html("Failed to parse HTML".to_string()))?;
205
206 let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
207
208 self.process_html_node(tree.root_node(), html_content, &mut entry_fragment)?;
210
211 self.flush_raw_buffer(&mut entry_fragment);
212
213 self.fragment_records.insert(
215 fragment_id.to_string(),
216 FragmentList {
217 fragments: entry_fragment,
218 },
219 );
220
221 Ok(())
223 }
224
225 fn add_raw_fragment(&mut self, content: &str) {
227 if !content.is_empty() {
228 self.raw_buffer.push_str(content);
229 }
230 }
231
232 fn add_for_fragment(
234 &mut self,
235 item: String,
236 collection: String,
237 fragment_id: String,
238 fragments: &mut Vec<WebUIFragment>,
239 ) {
240 self.flush_raw_buffer(fragments);
241 fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
242 }
243
244 fn add_if_fragment(
246 &mut self,
247 condition: ConditionExpr,
248 fragment_id: String,
249 fragments: &mut Vec<WebUIFragment>,
250 ) {
251 self.flush_raw_buffer(fragments);
252 fragments.push(WebUIFragment::if_cond(condition, fragment_id));
253 }
254
255 fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
257 self.flush_raw_buffer(fragments);
258 fragments.push(fragment);
259 }
260
261 fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
263 if !self.raw_buffer.is_empty() {
264 fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
265 }
266 }
267
268 fn process_html_node(
270 &mut self,
271 node: Node,
272 source: &str,
273 fragments: &mut Vec<WebUIFragment>,
274 ) -> Result<()> {
275 let mut cursor = node.walk();
276
277 if node.kind() == "document" || node.kind() == "fragment" || node.kind() == "element" {
278 if cursor.goto_first_child() {
280 loop {
281 let child = cursor.node();
282
283 self.process_child_node(child, source, fragments)?;
285
286 if !cursor.goto_next_sibling() {
287 break;
288 }
289 }
290 cursor.goto_parent();
291 }
292 } else {
293 let content = &source[node.start_byte()..node.end_byte()];
295 if !content.trim().is_empty() {
296 self.add_raw_fragment(content);
297 }
298 }
299
300 Ok(())
301 }
302
303 fn process_child_node(
305 &mut self,
306 node: Node,
307 source: &str,
308 fragments: &mut Vec<WebUIFragment>,
309 ) -> Result<()> {
310 match node.kind() {
311 "element" => {
312 let tag_name = self.get_element_tag_name(node, source)?;
314
315 match tag_name.as_str() {
317 "for" => return self.process_for_directive(node, source, fragments),
318 "if" => return self.process_if_directive(node, source, fragments),
319 "body" => return self.process_body_element(node, source, fragments),
320 "head" => return self.process_head_element(node, source, fragments),
321 "route" => return self.process_route_directive(node, source, fragments),
322 "outlet" => {
323 self.flush_raw_buffer(fragments);
324 fragments.push(WebUIFragment::outlet());
325 return Ok(());
326 }
327 _ => {
328 if self.component_registry.contains(tag_name.as_str()) {
329 return self.process_component_directive(
330 node,
331 source,
332 fragments,
333 tag_name.as_str(),
334 );
335 }
336
337 self.process_regular_element(node, source, fragments, &tag_name)?;
339
340 return Ok(());
341 }
342 }
343 }
344 "style_element" => {
345 for child in node.named_children(&mut node.walk()) {
347 if child.kind() == "raw_text" {
348 let style_content = &source[child.start_byte()..child.end_byte()];
349 let processed_css = self.css_parser.parse_inline_css(style_content)?;
350
351 if let Ok((tokens, defs)) = self
353 .css_parser
354 .extract_tokens_and_definitions(style_content)
355 {
356 self.token_store.extend(tokens);
357 self.token_definitions.extend(defs);
358 }
359
360 let style_tag = format!("<style>{}</style>", processed_css);
362 self.add_raw_fragment(&style_tag);
363 }
364 }
365 }
366 "text" | "raw_text" => {
367 let content = &source[node.start_byte()..node.end_byte()];
368 if !content.trim().is_empty() {
369 let handlebars_result = self.handlebars_parser.parse(content);
370 match handlebars_result {
371 Ok(parsed_fragments) => {
372 for fragment in parsed_fragments {
373 if matches!(fragment.fragment.as_ref(), Some(Fragment::Raw(_))) {
374 if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
375 self.add_raw_fragment(&raw.value);
376 }
377 } else {
378 self.add_fragment(fragment, fragments);
379 }
380 }
381 }
382 Err(error) => return Err(error),
383 }
384 }
385 }
386 "doctype" => {
391 let content = &source[node.start_byte()..node.end_byte()];
392 self.add_raw_fragment(content);
393 }
394 "comment" => {
396 let content = &source[node.start_byte()..node.end_byte()];
397 self.process_comment(content, fragments)?;
398 }
399 _ => {
401 self.process_html_node(node, source, fragments)?;
402 }
403 }
404
405 Ok(())
406 }
407
408 fn get_element_tag_name(&self, node: Node, source: &str) -> Result<String> {
410 let mut cursor = node.walk();
411 for child in node.named_children(&mut cursor) {
412 if child.kind() == "start_tag" || child.kind() == "self_closing_tag" {
413 let mut tag_cursor = child.walk();
414 for tag_name_node in child.named_children(&mut tag_cursor) {
415 if tag_name_node.kind() == "tag_name" {
416 let tag_name = source[tag_name_node.start_byte()..tag_name_node.end_byte()]
417 .to_string();
418 return Ok(tag_name);
419 }
420 }
421 }
422 }
423
424 Err(ParserError::Html("Failed to extract tag name".to_string()))
425 }
426
427 fn get_element_attribute(
428 &self,
429 node: Node,
430 attr_name: &str,
431 source: &str,
432 ) -> Result<Option<String>> {
433 let query_str = r#"
434 (element
435 [
436 (start_tag
437 (attribute
438 (attribute_name) @name
439 [(quoted_attribute_value (attribute_value) @value)
440 (attribute_value) @value]))
441 (self_closing_tag
442 (attribute
443 (attribute_name) @name
444 [(quoted_attribute_value (attribute_value) @value)
445 (attribute_value) @value]))
446 ]
447 )
448 "#;
449
450 let query = Query::new(&LANGUAGE.into(), query_str)
451 .map_err(|e| ParserError::Html(format!("Failed to create attribute query: {:?}", e)))?;
452
453 let mut cursor = QueryCursor::new();
454 let mut matches = cursor.matches(&query, node, source.as_bytes());
455 while let Some(m) = matches.next_mut() {
456 let mut found_name = false;
457 let mut found_value: Option<String> = None;
458
459 for capture in m.captures.iter() {
460 let node = capture.node;
461 let capture_name = query.capture_names()[capture.index as usize];
462
463 if capture_name == "name" {
464 let name_text = node.utf8_text(source.as_bytes()).map_err(|_| {
465 ParserError::Html("Invalid UTF-8 for attribute name".to_string())
466 })?;
467 if name_text == attr_name {
468 found_name = true;
469 }
470 } else if capture_name == "value" {
471 let value_text = node.utf8_text(source.as_bytes()).map_err(|_| {
472 ParserError::Html("Invalid UTF-8 for attribute value".to_string())
473 })?;
474 found_value = Some(value_text.to_string());
475 }
476 }
477
478 if found_name {
479 return Ok(found_value);
480 }
481 }
482
483 Ok(None)
484 }
485
486 fn has_element_attribute(&self, node: Node, attr_name: &str, source: &str) -> Result<bool> {
489 if self
491 .get_element_attribute(node, attr_name, source)?
492 .is_some()
493 {
494 return Ok(true);
495 }
496
497 let query_str = r#"
501 (element
502 [
503 (start_tag
504 (attribute
505 (attribute_name) @name))
506 (self_closing_tag
507 (attribute
508 (attribute_name) @name))
509 ]
510 )
511 "#;
512
513 let query = Query::new(&LANGUAGE.into(), query_str)
514 .map_err(|e| ParserError::Html(format!("Failed to create attribute query: {:?}", e)))?;
515
516 let tag_node_id = node
518 .named_children(&mut node.walk())
519 .find(|c| c.kind() == "start_tag" || c.kind() == "self_closing_tag")
520 .map(|c| c.id());
521
522 let mut cursor = QueryCursor::new();
523 let mut matches = cursor.matches(&query, node, source.as_bytes());
524 while let Some(m) = matches.next_mut() {
525 for capture in m.captures.iter() {
526 let capture_name = query.capture_names()[capture.index as usize];
527 if capture_name == "name" {
528 let attr_parent = capture.node.parent().and_then(|a| a.parent());
530 if attr_parent
531 .map(|p| Some(p.id()) == tag_node_id)
532 .unwrap_or(false)
533 {
534 let name_text =
535 capture.node.utf8_text(source.as_bytes()).map_err(|_| {
536 ParserError::Html("Invalid UTF-8 for attribute name".to_string())
537 })?;
538 if name_text == attr_name {
539 return Ok(true);
540 }
541 }
542 }
543 }
544 }
545
546 Ok(false)
547 }
548
549 fn find_tag_node<'a>(&self, node: Node<'a>) -> Option<Node<'a>> {
551 let mut cursor = node.walk();
552 let result = node
553 .named_children(&mut cursor)
554 .find(|child| child.kind() == "start_tag" || child.kind() == "self_closing_tag");
555 result
556 }
557
558 fn extract_single_handlebars(value: &str) -> Option<String> {
561 let trimmed = value.trim();
562 if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
563 let inner = trimmed[2..trimmed.len() - 2].trim();
564 if !inner.contains("{{") && !inner.is_empty() {
566 return Some(inner.to_string());
567 }
568 }
569 None
570 }
571
572 fn contains_handlebars(value: &str) -> bool {
574 value.contains("{{")
575 }
576
577 fn has_end_tag(&self, node: Node) -> bool {
579 let mut cursor = node.walk();
580 let result = node
581 .named_children(&mut cursor)
582 .any(|child| child.kind() == "end_tag");
583 result
584 }
585
586 fn process_regular_element(
588 &mut self,
589 node: Node,
590 source: &str,
591 fragments: &mut Vec<WebUIFragment>,
592 tag_name: &str,
593 ) -> Result<()> {
594 let tag_node = self.find_tag_node(node);
595 let is_self_closing = tag_node
596 .map(|n| n.kind() == "self_closing_tag")
597 .unwrap_or(false);
598 let has_end = self.has_end_tag(node);
599
600 if let Some(tag_node) = tag_node {
601 self.add_raw_fragment(&format!("<{}", tag_name));
603
604 let binding_count = self.process_tag_attributes(tag_node, source, fragments, false)?;
606 if let Some(ref mut p) = self.plugin {
607 if let Some(data) = p.on_element_parsed(binding_count) {
608 self.add_fragment(WebUIFragment::plugin(data), fragments);
609 }
610 }
611
612 if is_self_closing {
613 let tag_text = &source[tag_node.start_byte()..tag_node.end_byte()];
615 if tag_text.ends_with("/>") {
616 self.add_raw_fragment("/>");
617 } else {
618 self.add_raw_fragment(">");
619 }
620 } else if !has_end {
621 self.add_raw_fragment(">");
623 } else {
624 self.add_raw_fragment(">");
625
626 for child in node.named_children(&mut node.walk()) {
628 let kind = child.kind();
629 if kind != "start_tag" && kind != "end_tag" && kind != "self_closing_tag" {
630 self.process_child_node(child, source, fragments)?;
631 }
632 }
633
634 self.add_raw_fragment(&format!("</{}>", tag_name));
636 }
637 }
638
639 Ok(())
640 }
641
642 fn process_tag_attributes(
649 &mut self,
650 tag_node: Node,
651 source: &str,
652 fragments: &mut Vec<WebUIFragment>,
653 is_component: bool,
654 ) -> Result<u32> {
655 let mut first_dynamic_emitted = false;
656 let mut binding_count: u32 = 0;
657 let mut cursor = tag_node.walk();
658
659 for child in tag_node.named_children(&mut cursor) {
660 if child.kind() != "attribute" {
661 continue;
662 }
663
664 let attr_name = self.get_attr_name(child, source)?;
665
666 if let Some(ref p) = self.plugin {
669 if p.should_skip_attribute(&attr_name) {
670 binding_count += 1;
671 continue;
672 }
673 }
674
675 let attr_value = self.get_attr_value(child, source);
676
677 if let Some(bool_name) = attr_name.strip_prefix('?') {
678 if is_component {
680 if let Some(val) = &attr_value {
681 if let Some(signal_name) = Self::extract_single_handlebars(val) {
682 let condition = ConditionExpr::identifier(&signal_name);
683 let frag = Self::maybe_mark_attr_start(
684 WebUIFragment::attribute_boolean(bool_name, condition),
685 &mut first_dynamic_emitted,
686 );
687 self.add_fragment(frag, fragments);
688 binding_count += 1;
689 }
690 }
691 } else {
692 self.process_boolean_attribute(bool_name, attr_value.as_deref(), fragments)?;
693 binding_count += 1;
694 }
695 } else if attr_name.starts_with(':') {
696 if is_component {
698 if let Some(val) = &attr_value {
699 if let Some(signal_name) = Self::extract_single_handlebars(val) {
700 let frag = Self::maybe_mark_attr_start(
701 WebUIFragment::attribute_complex(&attr_name, signal_name),
702 &mut first_dynamic_emitted,
703 );
704 self.add_fragment(frag, fragments);
705 binding_count += 1;
706 }
707 }
708 } else {
709 self.process_complex_attribute(&attr_name, attr_value.as_deref(), fragments)?;
710 binding_count += 1;
711 }
712 } else if is_component && Self::is_skipped_attribute(&attr_name) {
713 if let Some(val) = &attr_value {
715 if let Some(signal_name) = Self::extract_single_handlebars(val) {
716 let frag = WebUIFragment {
717 fragment: Some(web_ui_fragment::Fragment::Attribute(
718 WebUIFragmentAttribute {
719 name: attr_name,
720 value: signal_name,
721 attr_skip: true,
722 ..Default::default()
723 },
724 )),
725 };
726 self.add_fragment(frag, fragments);
727 binding_count += 1;
728 }
729 }
730 } else if let Some(ref val) = attr_value {
731 if Self::contains_handlebars(val) {
732 if is_component {
733 if let Some(signal_name) = Self::extract_single_handlebars(val) {
734 let frag = Self::maybe_mark_attr_start(
735 WebUIFragment::attribute(&attr_name, signal_name),
736 &mut first_dynamic_emitted,
737 );
738 self.add_fragment(frag, fragments);
739 binding_count += 1;
740 } else {
741 let template_id = self.id_counter.next_id("attr");
742 let parsed = self.handlebars_parser.parse(val)?;
743 self.fragment_records
744 .insert(template_id.clone(), FragmentList { fragments: parsed });
745 let frag = Self::maybe_mark_attr_start(
746 WebUIFragment::attribute_template(&attr_name, template_id),
747 &mut first_dynamic_emitted,
748 );
749 self.add_fragment(frag, fragments);
750 binding_count += 1;
751 }
752 } else {
753 self.process_dynamic_attribute(&attr_name, val, fragments)?;
754 binding_count += 1;
755 }
756 } else if is_component {
757 let frag = Self::maybe_mark_attr_start(
760 WebUIFragment {
761 fragment: Some(web_ui_fragment::Fragment::Attribute(
762 WebUIFragmentAttribute {
763 name: attr_name,
764 value: val.clone(),
765 raw_value: true,
766 ..Default::default()
767 },
768 )),
769 },
770 &mut first_dynamic_emitted,
771 );
772 self.add_fragment(frag, fragments);
773 } else {
774 let attr_text = &source[child.start_byte()..child.end_byte()];
776 self.add_raw_fragment(&format!(" {}", attr_text));
777 }
778 } else {
779 self.add_raw_fragment(&format!(" {}", attr_name));
781 }
782 }
783 Ok(binding_count)
784 }
785
786 fn maybe_mark_attr_start(
789 mut frag: WebUIFragment,
790 first_dynamic_emitted: &mut bool,
791 ) -> WebUIFragment {
792 if !*first_dynamic_emitted {
793 if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
794 a.attr_start = true;
795 }
796 *first_dynamic_emitted = true;
797 }
798 frag
799 }
800
801 fn get_attr_name(&self, attr_node: Node, source: &str) -> Result<String> {
803 let mut cursor = attr_node.walk();
804 for child in attr_node.children(&mut cursor) {
805 if child.kind() == "attribute_name" {
806 return Ok(source[child.start_byte()..child.end_byte()].to_string());
807 }
808 }
809 Err(ParserError::Html(
810 "Attribute node missing attribute_name".to_string(),
811 ))
812 }
813
814 fn get_attr_value(&self, attr_node: Node, source: &str) -> Option<String> {
817 let mut cursor = attr_node.walk();
818 for child in attr_node.children(&mut cursor) {
819 match child.kind() {
820 "quoted_attribute_value" => {
821 let mut inner_cursor = child.walk();
823 for inner in child.children(&mut inner_cursor) {
824 if inner.kind() == "attribute_value" {
825 return Some(source[inner.start_byte()..inner.end_byte()].to_string());
826 }
827 }
828 return Some(String::new());
830 }
831 "attribute_value" => {
832 return Some(source[child.start_byte()..child.end_byte()].to_string());
833 }
834 _ => {}
835 }
836 }
837 None
838 }
839
840 fn process_boolean_attribute(
843 &mut self,
844 name: &str,
845 value: Option<&str>,
846 fragments: &mut Vec<WebUIFragment>,
847 ) -> Result<()> {
848 if let Some(val) = value {
849 if let Some(expr_str) = Self::extract_single_handlebars(val) {
850 let condition = self
852 .condition_parser
853 .parse(&expr_str)
854 .unwrap_or_else(|_| ConditionExpr::identifier(&expr_str));
855 self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
856 return Ok(());
857 }
858 }
859 Ok(())
861 }
862
863 fn process_complex_attribute(
865 &mut self,
866 name: &str,
867 value: Option<&str>,
868 fragments: &mut Vec<WebUIFragment>,
869 ) -> Result<()> {
870 if let Some(val) = value {
871 if let Some(signal_name) = Self::extract_single_handlebars(val) {
872 self.add_fragment(
873 WebUIFragment::attribute_complex(name, signal_name),
874 fragments,
875 );
876 return Ok(());
877 }
878 }
879 self.add_raw_fragment(&format!(" {}=\"{}\"", name, value.unwrap_or("")));
881 Ok(())
882 }
883
884 fn process_dynamic_attribute(
886 &mut self,
887 name: &str,
888 value: &str,
889 fragments: &mut Vec<WebUIFragment>,
890 ) -> Result<()> {
891 if let Some(signal_name) = Self::extract_single_handlebars(value) {
892 self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
894 } else {
895 let template_id = self.id_counter.next_id("attr");
897 let parsed = self.handlebars_parser.parse(value)?;
898
899 self.fragment_records
900 .insert(template_id.clone(), FragmentList { fragments: parsed });
901
902 self.add_fragment(
903 WebUIFragment::attribute_template(name, template_id),
904 fragments,
905 );
906 }
907 Ok(())
908 }
909
910 fn process_head_element(
912 &mut self,
913 node: Node,
914 source: &str,
915 fragments: &mut Vec<WebUIFragment>,
916 ) -> Result<()> {
917 self.add_raw_fragment("<head>");
918 for child in node.named_children(&mut node.walk()) {
919 let kind = child.kind();
920 if kind != "start_tag" && kind != "end_tag" {
921 self.process_child_node(child, source, fragments)?;
922 }
923 }
924 self.flush_raw_buffer(fragments);
925 fragments.push(WebUIFragment::signal("head_end", true));
926 self.add_raw_fragment("</head>");
927 Ok(())
928 }
929
930 fn process_body_element(
932 &mut self,
933 node: Node,
934 source: &str,
935 fragments: &mut Vec<WebUIFragment>,
936 ) -> Result<()> {
937 self.add_raw_fragment("<body>");
938 self.flush_raw_buffer(fragments);
939 fragments.push(WebUIFragment::signal("body_start", true));
940 for child in node.named_children(&mut node.walk()) {
941 let kind = child.kind();
942 if kind != "start_tag" && kind != "end_tag" {
943 self.process_child_node(child, source, fragments)?;
944 }
945 }
946 self.flush_raw_buffer(fragments);
947 if let Some(ref mut p) = self.plugin {
949 if let Some(body_end_content) = p.on_body_end() {
950 fragments.push(WebUIFragment::raw(body_end_content));
951 }
952 }
953 fragments.push(WebUIFragment::signal("body_end", true));
954 self.add_raw_fragment("</body>");
955 Ok(())
956 }
957
958 fn process_comment(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
963 let inner = content
965 .strip_prefix("<!--")
966 .and_then(|s| s.strip_suffix("-->"))
967 .unwrap_or("");
968
969 let trimmed = inner.trim();
970
971 if !trimmed.contains("{{") {
973 self.add_raw_fragment(content);
974 return Ok(());
975 }
976
977 let parsed = self.handlebars_parser.parse(trimmed)?;
979
980 let all_signals = parsed
984 .iter()
985 .all(|f| matches!(f.fragment.as_ref(), Some(Fragment::Signal(_))));
986
987 if all_signals && !parsed.is_empty() {
988 for fragment in parsed {
989 self.add_fragment(fragment, fragments);
990 }
991 } else {
992 self.add_raw_fragment(content);
994 }
995
996 Ok(())
997 }
998
999 fn process_for_directive(
1001 &mut self,
1002 node: Node,
1003 source: &str,
1004 fragments: &mut Vec<WebUIFragment>,
1005 ) -> Result<()> {
1006 let each = self
1008 .get_element_attribute(node, "each", source)?
1009 .ok_or_else(|| {
1010 ParserError::Directive("Missing 'each' attribute on <for>".to_string())
1011 })?;
1012
1013 let parts: Vec<&str> = each.split_whitespace().collect();
1015 if parts.len() != 3 || parts[1] != "in" {
1016 return Err(ParserError::Directive(format!(
1017 "Invalid for each: {}",
1018 each
1019 )));
1020 }
1021
1022 let allowed = |s: &str| {
1024 s.chars()
1025 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1026 };
1027 if !allowed(parts[0]) || !allowed(parts[2]) {
1028 return Err(ParserError::Directive(format!(
1029 "Invalid identifier in for each: {}",
1030 each
1031 )));
1032 }
1033
1034 let item = parts[0];
1035 let collection = parts[2];
1036
1037 let fragment_id = self
1039 .get_element_attribute(node, "template", source)?
1040 .unwrap_or_else(|| self.id_counter.next_id("for"));
1041 let mut for_fragment: Vec<WebUIFragment> = Vec::new();
1042
1043 let mut temp_buffer = String::new();
1045 std::mem::swap(&mut self.raw_buffer, &mut temp_buffer);
1046
1047 for child in node.named_children(&mut node.walk()) {
1049 if child.kind() != "start_tag" && child.kind() != "end_tag" {
1050 self.process_child_node(child, source, &mut for_fragment)?;
1051 }
1052 }
1053
1054 self.flush_raw_buffer(&mut for_fragment);
1056
1057 std::mem::swap(&mut self.raw_buffer, &mut temp_buffer);
1059
1060 if for_fragment.is_empty() {
1062 return Ok(());
1063 }
1064
1065 self.fragment_records.insert(
1067 fragment_id.clone(),
1068 FragmentList {
1069 fragments: for_fragment,
1070 },
1071 );
1072
1073 self.add_for_fragment(
1075 item.to_string(),
1076 collection.to_string(),
1077 fragment_id,
1078 fragments,
1079 );
1080
1081 Ok(())
1082 }
1083
1084 fn process_if_directive(
1086 &mut self,
1087 node: Node,
1088 source: &str,
1089 fragments: &mut Vec<WebUIFragment>,
1090 ) -> Result<()> {
1091 let condition_str = self
1093 .get_element_attribute(node, "condition", source)?
1094 .ok_or_else(|| {
1095 ParserError::Directive("Missing 'condition' attribute on <if>".to_string())
1096 })?;
1097
1098 let condition_result = self.condition_parser.parse(&condition_str);
1100 let condition = match condition_result {
1101 Ok(cond) => cond,
1102 Err(_) => {
1103 return Err(ParserError::Directive(format!(
1104 "Invalid condition expression: {}",
1105 condition_str
1106 )))
1107 }
1108 };
1109
1110 let fragment_id = self.id_counter.next_id("if");
1112
1113 self.flush_raw_buffer(fragments);
1115
1116 let mut if_fragment: Vec<WebUIFragment> = Vec::new();
1118
1119 let parent_buffer = std::mem::take(&mut self.raw_buffer);
1121
1122 for child in node.named_children(&mut node.walk()) {
1124 if child.kind() != "start_tag" && child.kind() != "end_tag" {
1125 self.process_child_node(child, source, &mut if_fragment)?;
1126 }
1127 }
1128
1129 self.flush_raw_buffer(&mut if_fragment);
1131
1132 self.fragment_records.insert(
1134 fragment_id.clone(),
1135 FragmentList {
1136 fragments: if_fragment,
1137 },
1138 );
1139
1140 self.raw_buffer = parent_buffer;
1142
1143 self.add_if_fragment(condition, fragment_id, fragments);
1145
1146 Ok(())
1147 }
1148
1149 fn process_route_directive(
1155 &mut self,
1156 node: Node,
1157 source: &str,
1158 fragments: &mut Vec<WebUIFragment>,
1159 ) -> Result<()> {
1160 let path = self
1162 .get_element_attribute(node, "path", source)?
1163 .unwrap_or_default();
1164
1165 let component = self
1166 .get_element_attribute(node, "component", source)?
1167 .unwrap_or_default();
1168
1169 let exact = self.has_element_attribute(node, "exact", source)?;
1170
1171 let attrs = route_parser::RouteAttributes {
1172 path: path.clone(),
1173 component: component.clone(),
1174 exact,
1175 };
1176
1177 route_parser::validate_attributes(&attrs)?;
1179
1180 route_parser::extract_params(&path)?;
1182
1183 self.ensure_route_component_parsed(&component)?;
1185
1186 let children = self.parse_child_routes(node, source)?;
1188
1189 self.flush_raw_buffer(fragments);
1191
1192 let route_fragment =
1194 route_parser::build_route_fragment(&attrs, component.clone(), children);
1195
1196 fragments.push(WebUIFragment::route_from(route_fragment));
1198
1199 Ok(())
1200 }
1201
1202 fn parse_child_routes(&mut self, node: Node, source: &str) -> Result<Vec<WebUiFragmentRoute>> {
1204 let mut children = Vec::new();
1205 let mut cursor = node.walk();
1206
1207 for child in node.named_children(&mut cursor) {
1208 if child.kind() == "element" {
1209 if let Ok(tag) = self.get_element_tag_name(child, source) {
1210 if tag == "route" {
1211 let child_route = self.parse_route_as_fragment(child, source)?;
1212 children.push(child_route);
1213 }
1214 }
1215 }
1216 }
1217
1218 Ok(children)
1219 }
1220
1221 fn parse_route_as_fragment(&mut self, node: Node, source: &str) -> Result<WebUiFragmentRoute> {
1223 let path = self
1224 .get_element_attribute(node, "path", source)?
1225 .unwrap_or_default();
1226 let component = self
1227 .get_element_attribute(node, "component", source)?
1228 .unwrap_or_default();
1229 let exact = self.has_element_attribute(node, "exact", source)?;
1230
1231 let attrs = route_parser::RouteAttributes {
1232 path: path.clone(),
1233 component: component.clone(),
1234 exact,
1235 };
1236
1237 route_parser::validate_attributes(&attrs)?;
1238 route_parser::extract_params(&path)?;
1239
1240 self.ensure_route_component_parsed(&component)?;
1242
1243 let children = self.parse_child_routes(node, source)?;
1245
1246 Ok(route_parser::build_route_fragment(
1247 &attrs, component, children,
1248 ))
1249 }
1250
1251 fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
1253 if component.is_empty()
1254 || !self.component_registry.contains(component)
1255 || self.fragment_records.contains_key(component)
1256 {
1257 return Ok(());
1258 }
1259
1260 if let Some(ref mut p) = self.plugin {
1261 if let Some(comp) = self.component_registry.get(component) {
1262 p.on_parse_component(component, comp)?;
1263 }
1264 }
1265 let (html_content, css_content, css_tokens) = {
1266 let comp = self.component_registry.get(component).ok_or_else(|| {
1267 crate::error::ParserError::Directive(format!("Component not found: {component}"))
1268 })?;
1269 (
1270 comp.html_content.clone(),
1271 comp.css_content.clone(),
1272 comp.css_tokens.clone(),
1273 )
1274 };
1275 self.token_store.extend(css_tokens);
1276 let adopted_specifier = match self.css_strategy {
1277 CssStrategy::Module if css_content.is_some() => Some(component.to_string()),
1278 _ => None,
1279 };
1280 let css_injection = match self.css_strategy {
1281 CssStrategy::Link => {
1282 if css_content.is_some() {
1283 Some(format!(
1284 "<link rel=\"stylesheet\" href=\"/{component}.css\">"
1285 ))
1286 } else {
1287 None
1288 }
1289 }
1290 CssStrategy::Style => css_content
1291 .as_ref()
1292 .map(|css| format!("<style>{}</style>", css.trim())),
1293 CssStrategy::Module => None,
1294 };
1295 let processed = self.process_component_template(
1296 &html_content,
1297 css_injection.as_deref(),
1298 adopted_specifier.as_deref(),
1299 );
1300 let saved_buffer = std::mem::take(&mut self.raw_buffer);
1301 self.parse(component, &processed)?;
1302 self.raw_buffer = saved_buffer;
1303
1304 Ok(())
1305 }
1306
1307 const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
1309 const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-", "aria-"];
1311
1312 fn is_skipped_attribute(name: &str) -> bool {
1313 Self::SKIPPED_ATTRIBUTES.contains(&name)
1314 || Self::SKIPPED_ATTRIBUTE_PREFIXES
1315 .iter()
1316 .any(|prefix| name.starts_with(prefix))
1317 }
1318
1319 fn process_component_directive(
1320 &mut self,
1321 node: Node,
1322 source: &str,
1323 fragments: &mut Vec<WebUIFragment>,
1324 tag_name: &str,
1325 ) -> Result<()> {
1326 let tag_node = self.find_tag_node(node);
1327 let is_self_closing = tag_node
1328 .map(|n| n.kind() == "self_closing_tag")
1329 .unwrap_or(false);
1330
1331 self.add_raw_fragment(&format!("<{}", tag_name));
1333
1334 if let Some(tag_node) = tag_node {
1336 let binding_count = self.process_tag_attributes(tag_node, source, fragments, true)?;
1337 if let Some(ref mut p) = self.plugin {
1338 if let Some(data) = p.on_element_parsed(binding_count) {
1339 self.add_fragment(WebUIFragment::plugin(data), fragments);
1340 }
1341 }
1342 }
1343
1344 if is_self_closing {
1345 let tag_text = tag_node
1346 .map(|n| &source[n.start_byte()..n.end_byte()])
1347 .unwrap_or("");
1348 if tag_text.ends_with("/>") {
1349 self.add_raw_fragment("/>");
1350 } else {
1351 self.add_raw_fragment(">");
1352 }
1353 } else {
1354 self.add_raw_fragment(">");
1355 }
1356
1357 self.flush_raw_buffer(fragments);
1359
1360 let (html_content, css_content, css_tokens) = {
1362 let component = self.component_registry.get(tag_name).ok_or_else(|| {
1363 ParserError::Directive(format!("Component not found: {}", tag_name))
1364 })?;
1365 (
1366 component.html_content.clone(),
1367 component.css_content.clone(),
1368 component.css_tokens.clone(),
1369 )
1370 };
1371
1372 self.token_store.extend(css_tokens);
1374
1375 if !self.fragment_records.contains_key(tag_name) {
1377 if let Some(ref mut p) = self.plugin {
1379 if let Some(component) = self.component_registry.get(tag_name) {
1380 p.on_parse_component(tag_name, component)?;
1381 }
1382 }
1383
1384 let adopted_specifier = match self.css_strategy {
1385 CssStrategy::Module if css_content.is_some() => Some(tag_name.to_string()),
1386 _ => None,
1387 };
1388 let css_injection = match self.css_strategy {
1389 CssStrategy::Link => {
1390 if css_content.is_some() {
1391 Some(format!(
1392 "<link rel=\"stylesheet\" href=\"/{}.css\">",
1393 tag_name
1394 ))
1395 } else {
1396 None
1397 }
1398 }
1399 CssStrategy::Style => css_content
1400 .as_ref()
1401 .map(|css| format!("<style>{}</style>", css.trim())),
1402 CssStrategy::Module => None,
1403 };
1404 let processed = self.process_component_template(
1405 &html_content,
1406 css_injection.as_deref(),
1407 adopted_specifier.as_deref(),
1408 );
1409 self.parse(tag_name, &processed)?;
1410 }
1411
1412 fragments.push(WebUIFragment::component(tag_name.to_string()));
1414
1415 if !is_self_closing {
1417 for child in node.named_children(&mut node.walk()) {
1418 let kind = child.kind();
1419 if kind != "start_tag" && kind != "end_tag" && kind != "self_closing_tag" {
1420 self.process_child_node(child, source, fragments)?;
1421 }
1422 }
1423 }
1424
1425 if !is_self_closing {
1427 self.add_raw_fragment(&format!("</{}>", tag_name));
1428 }
1429
1430 Ok(())
1431 }
1432
1433 fn process_component_template(
1438 &mut self,
1439 html: &str,
1440 css_snippet: Option<&str>,
1441 adopted_specifier: Option<&str>,
1442 ) -> String {
1443 let trimmed = html.trim();
1444 let has_template = trimmed.starts_with("<template");
1445
1446 let adopted_attr = adopted_specifier.map(|spec| {
1448 let mut s = String::with_capacity(35 + spec.len());
1449 s.push_str(" shadowrootadoptedstylesheets=\"");
1450 s.push_str(spec);
1451 s.push('"');
1452 s
1453 });
1454 let adopted_ref = adopted_attr.as_deref().unwrap_or_default();
1455
1456 if has_template {
1457 let stripped = self.strip_runtime_attrs_from_template(trimmed);
1459 if let Some(pos) = stripped.find('>') {
1460 let snippet = css_snippet.unwrap_or_default();
1461 if adopted_ref.is_empty() && snippet.is_empty() {
1462 return stripped;
1463 }
1464 let mut result =
1465 String::with_capacity(stripped.len() + snippet.len() + adopted_ref.len() + 16);
1466 result.push_str(&stripped[..pos]);
1467 result.push_str(adopted_ref);
1468 result.push('>');
1469 result.push_str(snippet);
1470 result.push_str(&stripped[pos + 1..]);
1471 return result;
1472 }
1473 stripped
1474 } else {
1475 let snippet = css_snippet.unwrap_or_default();
1476 let mut result =
1477 String::with_capacity(45 + adopted_ref.len() + snippet.len() + trimmed.len());
1478 result.push_str("<template shadowrootmode=\"open\"");
1479 result.push_str(adopted_ref);
1480 result.push('>');
1481 result.push_str(snippet);
1482 result.push_str(trimmed);
1483 result.push_str("</template>");
1484 result
1485 }
1486 }
1487
1488 fn strip_runtime_attrs_from_template(&mut self, html: &str) -> String {
1491 let tree = match self.parser.parse(html, None) {
1492 Some(t) => t,
1493 None => return html.to_string(),
1494 };
1495
1496 let root = tree.root_node();
1498 let start_tag = Self::find_first_node(root, "start_tag");
1499 let Some(tag) = start_tag else {
1500 return html.to_string();
1501 };
1502
1503 let mut removals: Vec<(usize, usize)> = Vec::new();
1505 let mut cursor = tag.walk();
1506 for child in tag.named_children(&mut cursor) {
1507 if child.kind() == "attribute" {
1508 let name_node = child.child(0);
1509 if let Some(name) = name_node {
1510 let attr_name = &html[name.start_byte()..name.end_byte()];
1511 if attr_name.starts_with('@')
1512 || attr_name.starts_with(':')
1513 || attr_name.starts_with('?')
1514 {
1515 let mut start = child.start_byte();
1517 while start > 0 && html.as_bytes()[start - 1] == b' ' {
1518 start -= 1;
1519 }
1520 removals.push((start, child.end_byte()));
1521 }
1522 }
1523 }
1524 }
1525
1526 if removals.is_empty() {
1527 return html.to_string();
1528 }
1529
1530 let mut result = String::with_capacity(html.len());
1532 let mut pos = 0;
1533 for (start, end) in &removals {
1534 result.push_str(&html[pos..*start]);
1535 pos = *end;
1536 }
1537 result.push_str(&html[pos..]);
1538 result
1539 }
1540
1541 fn find_first_node<'a>(root: Node<'a>, kind: &str) -> Option<Node<'a>> {
1543 let mut stack = vec![root];
1544 while let Some(node) = stack.pop() {
1545 if node.kind() == kind {
1546 return Some(node);
1547 }
1548 let mut cursor = node.walk();
1549 let children: Vec<_> = node.children(&mut cursor).collect();
1551 for child in children.into_iter().rev() {
1552 stack.push(child);
1553 }
1554 }
1555 None
1556 }
1557}
1558
1559#[cfg(test)]
1560mod tests {
1561 use webui_test_utils::*;
1562
1563 use super::*;
1564
1565 #[test]
1566 fn test_parse_signal() {
1567 let mut parser = HtmlParser::new();
1568 let html = "Hello, {{name}}!";
1569 let result = parser.parse("test.html", html);
1570
1571 assert!(result.is_ok());
1572 let fragment_records = parser.into_fragment_records();
1573
1574 assert_stream!(
1575 fragment_records,
1576 "test.html",
1577 [raw("Hello, "), signal("name"), raw("!"),]
1578 );
1579 }
1580
1581 #[test]
1582 fn test_parse_raw_signal() {
1583 let mut parser = HtmlParser::new();
1584 let html = "Hello, {{{html_content}}}!";
1585 let result = parser.parse("test.html", html);
1586
1587 assert!(result.is_ok());
1588 let fragment_records = parser.into_fragment_records();
1589
1590 assert_stream!(
1591 fragment_records,
1592 "test.html",
1593 [raw("Hello, "), signal_raw("html_content"), raw("!"),]
1594 );
1595 }
1596
1597 #[test]
1598 fn test_parse_for_directive() {
1599 let mut parser = HtmlParser::new();
1600 let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
1601
1602 let result = parser.parse("test.html", html);
1603 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1604 let fragment_records = parser.into_fragment_records();
1605 println!("Fragment records: {:#?}", fragment_records);
1606
1607 assert_stream!(
1608 fragment_records,
1609 "test.html",
1610 [for_loop("item", "items", "for-1"),]
1611 );
1612
1613 assert_stream!(
1615 fragment_records,
1616 "for-1",
1617 [
1618 raw("<div class=\"item\">"),
1619 signal("item.name"),
1620 raw("</div>"),
1621 ]
1622 );
1623 }
1624
1625 #[test]
1626 fn test_parse_if_directive() {
1627 let mut parser = HtmlParser::new();
1628 let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
1629
1630 let result = parser.parse("test.html", html);
1631
1632 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1633 let fragment_records = parser.into_fragment_records();
1634 println!("Fragment records: {:#?}", fragment_records);
1635
1636 assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
1637
1638 assert_stream!(
1640 fragment_records,
1641 "if-1",
1642 [
1643 raw("<div>Welcome back, "),
1644 signal("username"),
1645 raw("!</div>"),
1646 ]
1647 );
1648 }
1649
1650 #[test]
1651 fn test_component_directive() {
1652 let mut parser = HtmlParser::new();
1653 parser
1654 .component_registry
1655 .register_component(
1656 "my-component",
1657 "<div>My Component</div>",
1658 Some("div { color: blue; }"),
1659 )
1660 .expect("Failed to register component");
1661
1662 let result = parser.parse("test.html", "<my-component></my-component>");
1663 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1664 let records = parser.into_fragment_records();
1665
1666 assert_stream!(
1667 records,
1668 "test.html",
1669 [
1670 raw("<my-component>"),
1671 component("my-component"),
1672 raw("</my-component>"),
1673 ]
1674 );
1675
1676 let comp = &records["my-component"].fragments;
1678 assert_eq!(comp.len(), 1);
1679 assert!(
1680 matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
1681 raw.value.contains("<template shadowrootmode=\"open\">") && raw.value.contains("<div>My Component</div>"))
1682 );
1683 }
1684
1685 #[test]
1686 fn test_component_directive_with_slots() {
1687 let mut parser = HtmlParser::new();
1688 parser
1689 .component_registry
1690 .register_component(
1691 "my-component",
1692 "<div>My Component</div>",
1693 Some("div { color: blue; }"),
1694 )
1695 .expect("Failed to register component");
1696
1697 let result = parser.parse(
1698 "test.html",
1699 "Hello<my-component><p>World</p></my-component>",
1700 );
1701 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1702 let records = parser.into_fragment_records();
1703 let fragments = &records["test.html"].fragments;
1704
1705 assert!(fragments.len() >= 3);
1707 assert!(
1709 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component>"))
1710 );
1711 assert!(fragments.iter().any(|f| matches!(
1713 f.fragment.as_ref(),
1714 Some(Fragment::Component(c)) if c.fragment_id == "my-component"
1715 )));
1716 let last = fragments.last().unwrap();
1718 assert!(
1719 matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
1720 );
1721 }
1722
1723 #[test]
1726 fn test_component_no_double_wrap_template() {
1727 let mut parser = HtmlParser::new();
1728 parser
1729 .component_registry
1730 .register_component(
1731 "custom-element",
1732 r#"<template foo="bar"><slot></slot></template>"#,
1733 None,
1734 )
1735 .expect("register");
1736 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
1737 assert!(result.is_ok());
1738 let records = parser.into_fragment_records();
1739
1740 assert_fragments!(
1741 records["index.html"].fragments,
1742 [
1743 raw("<custom-element>"),
1744 component("custom-element"),
1745 raw("Hello</custom-element>"),
1746 ]
1747 );
1748
1749 assert_stream!(
1750 records,
1751 "custom-element",
1752 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
1753 );
1754 }
1755
1756 #[test]
1757 fn test_component_styled_no_double_wrap() {
1758 let mut parser = HtmlParser::new();
1759 parser
1760 .component_registry
1761 .register_component(
1762 "custom-element",
1763 r#"<template foo="bar"><slot></slot></template>"#,
1764 Some("div { color: red; }"),
1765 )
1766 .expect("register");
1767 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
1768 assert!(result.is_ok());
1769 let records = parser.into_fragment_records();
1770
1771 assert_stream!(
1772 records,
1773 "custom-element",
1774 [raw(
1775 r#"<template foo="bar"><link rel="stylesheet" href="/custom-element.css"><slot></slot></template>"#
1776 ),]
1777 );
1778 }
1779
1780 #[test]
1781 fn test_component_strip_runtime_attrs() {
1782 let mut parser = HtmlParser::new();
1783 parser
1784 .component_registry
1785 .register_component(
1786 "custom-element",
1787 r#"<template @click={foo} :bar="baz" ?bool="true"><slot></slot></template>"#,
1788 None,
1789 )
1790 .expect("register");
1791 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
1792 assert!(result.is_ok());
1793 let records = parser.into_fragment_records();
1794
1795 assert_stream!(
1796 records,
1797 "custom-element",
1798 [raw("<template><slot></slot></template>"),]
1799 );
1800 }
1801
1802 #[test]
1803 fn test_component_with_slots_and_attrs() {
1804 let mut parser = HtmlParser::new();
1805 parser
1806 .component_registry
1807 .register_component("custom-element", "<slot></slot>", None)
1808 .expect("register");
1809 let result = parser.parse(
1810 "index.html",
1811 r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
1812 );
1813 assert!(result.is_ok());
1814 let records = parser.into_fragment_records();
1815 assert_fragments!(
1816 records["index.html"].fragments,
1817 [
1818 raw("<custom-element"),
1819 attr_raw_start("appearance", "subtle"),
1820 raw(">"),
1821 component("custom-element"),
1822 raw("Hello World</custom-element>"),
1823 ]
1824 );
1825 }
1826
1827 #[test]
1828 fn test_component_legacy_no_styles() {
1829 let mut parser = HtmlParser::new();
1830 parser
1831 .component_registry
1832 .register_component("custom-element", "<div>Custom Element</div>", None)
1833 .expect("register");
1834 let result = parser.parse("index.html", "<custom-element></custom-element>");
1835 assert!(result.is_ok());
1836 let records = parser.into_fragment_records();
1837
1838 assert_fragments!(
1839 records["index.html"].fragments,
1840 [
1841 raw("<custom-element>"),
1842 component("custom-element"),
1843 raw("</custom-element>"),
1844 ]
1845 );
1846
1847 assert_stream!(
1848 records,
1849 "custom-element",
1850 [raw(
1851 "<template shadowrootmode=\"open\"><div>Custom Element</div></template>"
1852 ),]
1853 );
1854 }
1855
1856 #[test]
1857 fn test_component_self_closing() {
1858 let mut parser = HtmlParser::new();
1859 parser
1860 .component_registry
1861 .register_component("custom-widget", "<div>Widget Content</div>", None)
1862 .expect("register");
1863 let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
1864 assert!(result.is_ok());
1865 let records = parser.into_fragment_records();
1866
1867 assert_fragments!(
1868 records["index.html"].fragments,
1869 [
1870 raw("<custom-widget"),
1871 attr_start("config", "settings"),
1872 raw("/>"),
1873 component("custom-widget"),
1874 ]
1875 );
1876 }
1877
1878 #[test]
1879 fn test_component_nested_self_closing_in_slot() {
1880 let mut parser = HtmlParser::new();
1881 parser
1882 .component_registry
1883 .register_component("custom-icon", "<svg><slot></slot></svg>", None)
1884 .expect("register");
1885 let result = parser.parse(
1886 "index.html",
1887 r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
1888 );
1889 assert!(result.is_ok());
1890 let records = parser.into_fragment_records();
1891
1892 assert_fragments!(
1893 records["index.html"].fragments,
1894 [
1895 raw("<custom-icon>"),
1896 component("custom-icon"),
1897 raw("<use"),
1898 attr_template("href", "attr-1"),
1899 raw("/></custom-icon>"),
1900 ]
1901 );
1902
1903 assert_stream!(
1904 records,
1905 "custom-icon",
1906 [raw(
1907 "<template shadowrootmode=\"open\"><svg><slot></slot></svg></template>"
1908 ),]
1909 );
1910 }
1911
1912 #[test]
1913 fn test_component_leading_boolean_attr_start() {
1914 let mut parser = HtmlParser::new();
1915 parser
1916 .component_registry
1917 .register_component("custom-element", "<slot></slot>", None)
1918 .expect("register");
1919 let result = parser.parse(
1920 "index.html",
1921 r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
1922 );
1923 assert!(result.is_ok());
1924 let records = parser.into_fragment_records();
1925
1926 assert_fragments!(
1927 records["index.html"].fragments,
1928 [
1929 raw("<custom-element"),
1930 bool_attr_start("disabled", "isDisabled"),
1932 attr_raw("title", "Hello"),
1934 raw(">"),
1935 component("custom-element"),
1936 raw("</custom-element>"),
1937 ]
1938 );
1939 }
1940
1941 #[test]
1942 fn test_component_meta_link_tags() {
1943 let (fragments, _) = parse_and_get_fragments(
1944 r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
1945 );
1946 assert!(fragments.len() >= 5);
1947 assert!(
1948 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<head><meta charset=\"utf-8\"") && raw.value.contains("<link"))
1949 );
1950 assert!(
1951 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
1952 );
1953 assert!(
1954 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
1955 );
1956 assert!(
1957 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if s.value == "head_end" && s.raw)
1958 );
1959 assert!(
1960 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
1961 );
1962 }
1963
1964 #[test]
1965 fn test_nested_directives() {
1966 let mut parser = HtmlParser::new();
1967 let html = r#"<for each="category in categories">
1968 <if condition="category.hasItems">
1969 <for each="item in category.items">
1970 {{item.title}}
1971 </for>
1972 </if>
1973 </for>"#;
1974
1975 let result = parser.parse("test.html", html);
1976
1977 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1978 let fragment_records = parser.into_fragment_records();
1979
1980 assert_stream!(
1981 fragment_records,
1982 "test.html",
1983 [for_loop("category", "categories", "for-1"),]
1984 );
1985
1986 assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
1987
1988 assert_stream!(
1989 fragment_records,
1990 "if-1",
1991 [for_loop("item", "category.items", "for-2"),]
1992 );
1993
1994 assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
1995 }
1996
1997 #[test]
1998 fn test_complex_directives() {
1999 let mut parser = HtmlParser::new();
2000 let html = r#"<for each="category in categories">
2001 <div class="category">
2002 <h2>{{category.name}}</h2>
2003 <if condition="category.hasItems">
2004 <ul>
2005 <for each="item in category.items">
2006 <li>{{item.title}}</li>
2007 </for>
2008 </ul>
2009 </if>
2010 </div>
2011 </for>"#;
2012
2013 let result = parser.parse("test.html", html);
2014
2015 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2016 let fragment_records = parser.into_fragment_records();
2017
2018 assert_stream!(
2019 fragment_records,
2020 "test.html",
2021 [for_loop("category", "categories", "for-1"),]
2022 );
2023
2024 assert_stream!(
2026 fragment_records,
2027 "for-1",
2028 [
2029 raw("<div class=\"category\"><h2>"),
2030 signal("category.name"),
2031 raw("</h2>"),
2032 if_cond("if-1"),
2033 raw("</div>"),
2034 ]
2035 );
2036
2037 assert_stream!(
2039 fragment_records,
2040 "if-1",
2041 [
2042 raw("<ul>"),
2043 for_loop("item", "category.items", "for-2"),
2044 raw("</ul>"),
2045 ]
2046 );
2047
2048 assert_stream!(
2050 fragment_records,
2051 "for-2",
2052 [raw("<li>"), signal("item.title"), raw("</li>"),]
2053 );
2054 }
2055
2056 fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
2060 let mut parser = HtmlParser::new();
2061 let result = parser.parse("index.html", html);
2062 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2063 let records = parser.into_fragment_records();
2064 let fragments = records
2065 .get("index.html")
2066 .expect("Failed to get index.html fragment")
2067 .fragments
2068 .clone();
2069 (fragments, records)
2070 }
2071
2072 #[test]
2073 fn test_attribute_handlebars_in_href() {
2074 let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
2076 assert_fragments!(
2077 fragments,
2078 [
2079 raw("<a"),
2080 attr("href", "url"),
2081 raw(">"),
2082 signal("name"),
2083 raw("</a>"),
2084 ]
2085 );
2086 }
2087
2088 #[test]
2089 fn test_attribute_boolean_with_handlebars() {
2090 let (fragments, _) =
2092 parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
2093 assert_fragments!(
2094 fragments,
2095 [
2096 raw("<button"),
2097 bool_attr("disabled", "isDisabled"),
2098 raw(">Click</button>"),
2099 ]
2100 );
2101 }
2102
2103 #[test]
2104 fn test_attribute_multiple_boolean() {
2105 let (fragments, _) =
2108 parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
2109
2110 assert_fragments!(
2111 fragments,
2112 [
2113 raw("<input"),
2114 bool_attr("checked", "isChecked"),
2115 bool_attr("disabled", "isDisabled"),
2116 raw("/>"),
2117 ]
2118 );
2119 }
2120
2121 #[test]
2122 fn test_attribute_boolean_and_regular_together() {
2123 let (fragments, _) = parse_and_get_fragments(
2126 r#"<input ?checked="{{isChecked}}" type="checkbox">Hi</input>"#,
2127 );
2128
2129 assert_fragments!(
2130 fragments,
2131 [
2132 raw("<input"),
2133 bool_attr("checked", "isChecked"),
2134 raw(" type=\"checkbox\">Hi</input>"),
2135 ]
2136 );
2137 }
2138
2139 #[test]
2140 fn test_attribute_boolean_sandwiched() {
2141 let (fragments, _) = parse_and_get_fragments(
2144 r#"<input version={{edition}} ?checked="{{isChecked}}" type="checkbox">Hi</input>"#,
2145 );
2146
2147 assert_fragments!(
2148 fragments,
2149 [
2150 raw("<input"),
2151 attr("version", "edition"),
2152 bool_attr("checked", "isChecked"),
2153 raw(" type=\"checkbox\">Hi</input>"),
2154 ]
2155 );
2156 }
2157
2158 #[test]
2159 fn test_attribute_boolean_ending() {
2160 let (fragments, _) = parse_and_get_fragments(
2163 r#"<input version={{edition}} ?checked="{{isChecked}}">Hi</input>"#,
2164 );
2165
2166 assert_fragments!(
2167 fragments,
2168 [
2169 raw("<input"),
2170 attr("version", "edition"),
2171 bool_attr("checked", "isChecked"),
2172 raw(">Hi</input>"),
2173 ]
2174 );
2175 }
2176
2177 #[test]
2178 fn test_attribute_boolean_dotted_path() {
2179 let (fragments, _) =
2182 parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
2183
2184 assert_fragments!(
2185 fragments,
2186 [
2187 raw("<div"),
2188 bool_attr("checked", "layout.isPinned"),
2189 raw(">Content</div>"),
2190 ]
2191 );
2192 }
2193
2194 #[test]
2195 fn test_attribute_colon_prefixed_complex() {
2196 let (fragments, _) =
2199 parse_and_get_fragments(r#"<my-component :config="{{settings}}"></my-component>"#);
2200
2201 assert_fragments!(
2202 fragments,
2203 [
2204 raw("<my-component"),
2205 attr_complex(":config", "settings"),
2206 raw("></my-component>"),
2207 ]
2208 );
2209 }
2210
2211 #[test]
2212 fn test_attribute_multiple_colon_prefixed() {
2213 let (fragments, _) = parse_and_get_fragments(
2216 r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
2217 );
2218
2219 assert_fragments!(
2220 fragments,
2221 [
2222 raw("<my-component"),
2223 attr_complex(":prop1", "val1"),
2224 attr_complex(":prop2", "val2"),
2225 raw("></my-component>"),
2226 ]
2227 );
2228 }
2229
2230 #[test]
2231 fn test_attribute_mixed_normal_boolean_colon() {
2232 let (fragments, _) = parse_and_get_fragments(
2235 r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
2236 );
2237
2238 assert_fragments!(
2239 fragments,
2240 [
2241 raw("<my-component id=\"comp\""),
2242 attr_complex(":config", "settings"),
2243 bool_attr("enabled", "isEnabled"),
2244 raw("></my-component>"),
2245 ]
2246 );
2247 }
2248
2249 #[test]
2250 fn test_attribute_reject_boolean_without_handlebars() {
2251 let (fragments, _) = parse_and_get_fragments(r#"<input ?checked="name"></input>"#);
2254
2255 assert_fragments!(fragments, [raw("<input></input>"),]);
2257 }
2258
2259 #[test]
2260 fn test_attribute_reject_boolean_with_partial_handlebars() {
2261 let (fragments, _) =
2264 parse_and_get_fragments(r#"<input ?checked="Hello {{name}}"></input>"#);
2265
2266 assert_fragments!(fragments, [raw("<input></input>"),]);
2268 }
2269
2270 #[test]
2271 fn test_attribute_reject_boolean_with_plain_value() {
2272 let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
2275
2276 assert_fragments!(fragments, [raw("<button>Click</button>"),]);
2278 }
2279
2280 #[test]
2281 fn test_attribute_boolean_predicate_equal() {
2282 let (fragments, _) =
2284 parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
2285 assert_fragments!(
2286 fragments,
2287 [
2288 raw("<div"),
2289 bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
2290 raw(">X</div>"),
2291 ]
2292 );
2293 }
2294
2295 #[test]
2296 fn test_attribute_boolean_predicate_greater_than() {
2297 let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
2299 assert_fragments!(
2300 fragments,
2301 [
2302 raw("<span"),
2303 bool_attr_predicate("hidden", "num", 1, "9"),
2304 raw(">X</span>"),
2305 ]
2306 );
2307 }
2308
2309 #[test]
2310 fn test_attribute_boolean_predicate_not_equal() {
2311 let (fragments, _) =
2313 parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
2314 assert_fragments!(
2315 fragments,
2316 [
2317 raw("<a"),
2318 bool_attr_predicate("data-active", "status", 4, "'inactive'"),
2319 raw(">X</a>"),
2320 ]
2321 );
2322 }
2323
2324 #[test]
2325 fn test_attribute_boolean_negation() {
2326 let (fragments, _) =
2328 parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
2329 assert_fragments!(
2330 fragments,
2331 [
2332 raw("<button"),
2333 bool_attr_not("disabled", "isReady"),
2334 raw(">X</button>"),
2335 ]
2336 );
2337 }
2338
2339 #[test]
2340 fn test_attribute_mixed_static_dynamic() {
2341 let (fragments, records) =
2344 parse_and_get_fragments(r#"<input value="hello {{world}}">Hi</input>"#);
2345
2346 assert_fragments!(
2347 fragments,
2348 [
2349 raw("<input"),
2350 attr_template("value", "attr-1"),
2351 raw(">Hi</input>"),
2352 ]
2353 );
2354
2355 assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
2357 }
2358
2359 #[test]
2362 fn test_body_signals() {
2363 let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
2364 assert_fragments!(
2365 fragments,
2366 [
2367 raw("<body>"),
2368 signal_raw("body_start"),
2369 raw("<app-shell></app-shell>"),
2370 signal_raw("body_end"),
2371 raw("</body>"),
2372 ]
2373 );
2374 }
2375
2376 #[test]
2379 fn test_empty_for_produces_nothing() {
2380 let (fragments, records) =
2381 parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
2382 assert_fragments!(fragments, [raw("<div></div>"),]);
2383 assert!(!records.contains_key("for-1"));
2384 }
2385
2386 #[test]
2389 fn test_self_closing_svg_path() {
2390 let (fragments, _) =
2391 parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
2392 assert_fragments!(
2393 fragments,
2394 [raw(
2395 r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
2396 ),]
2397 );
2398 }
2399
2400 #[test]
2401 fn test_html5_void_elements() {
2402 let (fragments, _) = parse_and_get_fragments(
2403 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
2404 );
2405 assert_fragments!(
2406 fragments,
2407 [raw(
2408 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
2409 ),]
2410 );
2411 }
2412
2413 #[test]
2414 fn test_self_closing_with_dynamic_attributes() {
2415 let (fragments, _) =
2416 parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
2417 assert_fragments!(
2418 fragments,
2419 [
2420 raw("<img"),
2421 attr("src", "imageUrl"),
2422 attr("alt", "imageAlt"),
2423 raw("/>"),
2424 ]
2425 );
2426 }
2427
2428 #[test]
2429 fn test_self_closing_with_boolean_attributes() {
2430 let (fragments, _) = parse_and_get_fragments(
2431 r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
2432 );
2433 assert_fragments!(
2434 fragments,
2435 [
2436 raw("<input type=\"checkbox\""),
2437 bool_attr("checked", "isSelected"),
2438 bool_attr("disabled", "isDisabled"),
2439 raw("/>"),
2440 ]
2441 );
2442 }
2443
2444 #[test]
2445 fn test_multiple_self_closing_in_sequence() {
2446 let (fragments, _) =
2447 parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
2448 assert_fragments!(
2449 fragments,
2450 [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
2451 );
2452 }
2453
2454 #[test]
2455 fn test_self_closing_with_mixed_content() {
2456 let (fragments, _) =
2457 parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
2458 assert_fragments!(
2459 fragments,
2460 [
2461 raw("<div>Text before<img"),
2462 attr("src", "url"),
2463 raw("/>Text after</div>"),
2464 ]
2465 );
2466 }
2467
2468 #[test]
2469 fn test_self_closing_svg_elements() {
2470 let (fragments, _) = parse_and_get_fragments(
2471 r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
2472 );
2473 assert_fragments!(
2474 fragments,
2475 [
2476 raw("<svg><circle"),
2477 attr("cx", "x"),
2478 attr("cy", "y"),
2479 raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
2480 ]
2481 );
2482 }
2483
2484 #[test]
2485 fn test_self_closing_inside_for_loop() {
2486 let (fragments, records) = parse_and_get_fragments(
2487 r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
2488 );
2489 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
2490 assert_stream!(
2491 records,
2492 "for-1",
2493 [raw("<img"), attr("src", "item.url"), raw("/>"),]
2494 );
2495 }
2496
2497 #[test]
2498 fn test_self_closing_whitespace_variations() {
2499 let (fragments, _) =
2500 parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
2501 assert_fragments!(
2502 fragments,
2503 [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
2504 );
2505 }
2506
2507 #[test]
2508 fn test_deeply_nested_self_closing() {
2509 let (fragments, _) = parse_and_get_fragments(
2510 r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
2511 );
2512 assert_fragments!(
2513 fragments,
2514 [raw(
2515 r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
2516 ),]
2517 );
2518 }
2519
2520 #[test]
2521 fn test_self_closing_vs_empty_regular_tags() {
2522 let (fragments, _) =
2523 parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
2524 assert_fragments!(
2525 fragments,
2526 [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
2527 );
2528 }
2529
2530 #[test]
2533 fn test_for_custom_template_attribute() {
2534 let (fragments, records) = parse_and_get_fragments(
2536 r#"<for each="item in items" template="static"><span>Item</span></for>"#,
2537 );
2538 assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
2539 assert_stream!(records, "static", [raw("<span>Item</span>"),]);
2540 }
2541
2542 #[test]
2543 fn test_for_recursive_template() {
2544 let mut parser = HtmlParser::new();
2546 let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
2547 let result = parser.parse("index.html", html);
2548 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2549 let records = parser.into_fragment_records();
2550
2551 assert_fragments!(
2552 records["index.html"].fragments,
2553 [for_loop("outerItem", "outerItems", "static"),]
2554 );
2555
2556 assert_stream!(
2557 records,
2558 "static",
2559 [
2560 raw("<div><span>"),
2561 signal("outerItem.name"),
2562 raw("</span>"),
2563 for_loop("innerItem", "innerItems", "static"),
2564 raw("</div>"),
2565 ]
2566 );
2567 }
2568
2569 #[test]
2572 fn test_if_multiple_children() {
2573 let (fragments, records) =
2575 parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
2576 assert_fragments!(fragments, [if_cond("if-1"),]);
2577 assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
2578 }
2579
2580 #[test]
2581 fn test_for_multiple_children() {
2582 let (fragments, records) =
2584 parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
2585 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
2586 assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
2587 }
2588
2589 #[test]
2592 fn test_handlebars_at_beginning() {
2593 let (fragments, _) = parse_and_get_fragments("{{first}}");
2595 assert_fragments!(fragments, [signal("first"),]);
2596 }
2597
2598 #[test]
2599 fn test_handlebars_at_beginning_and_raw() {
2600 let (fragments, _) = parse_and_get_fragments("{{first}}test");
2602 assert_fragments!(fragments, [signal("first"), raw("test"),]);
2603 }
2604
2605 #[test]
2606 fn test_handlebars_raw_and_end() {
2607 let (fragments, _) = parse_and_get_fragments("test{{first}}");
2609 assert_fragments!(fragments, [raw("test"), signal("first"),]);
2610 }
2611
2612 #[test]
2615 fn test_handlebars_invalid_triple_open() {
2616 let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
2618 assert_fragments!(fragments, [raw("{{{invalid}}"),]);
2619 }
2620
2621 #[test]
2622 fn test_handlebars_four_open_braces() {
2623 let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
2625 assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
2626 }
2627
2628 #[test]
2629 fn test_handlebars_five_open_with_valid_double() {
2630 let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
2632 assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
2633 }
2634
2635 #[test]
2636 fn test_entities_preserved() {
2637 let (fragments, _) = parse_and_get_fragments("<p>Hello}World</p>");
2639 assert_fragments!(fragments, [raw("<p>Hello}World</p>"),]);
2640 }
2641
2642 #[test]
2645 fn test_doctype_preserved() {
2646 let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
2648 assert!(fragments.len() >= 1);
2649 assert!(
2650 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
2651 );
2652 }
2653
2654 #[test]
2657 fn test_component_attr_skip() {
2658 let mut parser = HtmlParser::new();
2660 parser
2661 .component_registry
2662 .register_component("custom-element", "<slot></slot>", None)
2663 .expect("register");
2664 let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
2665 let result = parser.parse("index.html", html);
2666 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2667 let records = parser.into_fragment_records();
2668
2669 assert_fragments!(
2672 records["index.html"].fragments,
2673 [
2674 raw("<custom-element"),
2675 attr_complex_start(":config", "config"),
2677 attr_skip("class", "value0"),
2679 attr_skip("style", "value1"),
2680 attr_skip("role", "value2"),
2681 attr_skip("data-test", "value3"),
2682 attr_skip("aria-test", "value4"),
2683 raw(">"),
2684 component("custom-element"),
2685 raw("</custom-element>"),
2686 ]
2687 );
2688 }
2689
2690 #[test]
2691 fn test_component_multiple_nested() {
2692 let mut parser = HtmlParser::new();
2694 parser
2695 .component_registry
2696 .register_component(
2697 "custom-element",
2698 "<custom-child></custom-child><slot></slot>",
2699 None,
2700 )
2701 .expect("register");
2702 parser
2703 .component_registry
2704 .register_component("custom-button", "<slot></slot>", None)
2705 .expect("register");
2706 parser
2707 .component_registry
2708 .register_component("custom-child", "<h1>Hello World!</h1>", None)
2709 .expect("register");
2710
2711 let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
2712 let result = parser.parse("index.html", html);
2713 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2714 let records = parser.into_fragment_records();
2715
2716 assert_fragments!(
2718 records["index.html"].fragments,
2719 [for_loop("item", "items", "for-1"),]
2720 );
2721
2722 assert_stream!(
2724 records,
2725 "for-1",
2726 [
2727 raw("<custom-element>"),
2728 component("custom-element"),
2729 raw("<custom-button>"),
2730 component("custom-button"),
2731 raw("Ok</custom-button></custom-element>"),
2732 ]
2733 );
2734
2735 let ce = &records["custom-element"].fragments;
2737 assert_eq!(ce.len(), 3);
2738 assert!(
2739 matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<template shadowrootmode=\"open\"><custom-child>"))
2740 );
2741 assert!(
2742 matches!(ce[1].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
2743 );
2744 assert!(
2745 matches!(ce[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><slot></slot></template>"))
2746 );
2747
2748 assert_stream!(
2749 records,
2750 "custom-button",
2751 [raw(
2752 "<template shadowrootmode=\"open\"><slot></slot></template>"
2753 ),]
2754 );
2755
2756 assert_stream!(
2757 records,
2758 "custom-child",
2759 [raw(
2760 "<template shadowrootmode=\"open\"><h1>Hello World!</h1></template>"
2761 ),]
2762 );
2763 }
2764
2765 #[test]
2768 fn test_invalid_markup_returns_error() {
2769 let mut parser = HtmlParser::new();
2772 let result = parser.parse("index.html", "<div><span>Unclosed div");
2773 assert!(result.is_ok());
2774 }
2775
2776 #[test]
2779 fn test_complex_raw_text_full_page() {
2780 let html = r#"<!DOCTYPE HTML><html dir="auto" lang="en"><head><meta charset="utf-8"><title>Test</title><style>html { margin: 0; }</style></head><body><app-shell></app-shell><script type="module" src="./index.js"></script></body></html>"#;
2782 let (fragments, _) = parse_and_get_fragments(html);
2783
2784 assert!(fragments.len() >= 7);
2786 assert!(
2787 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
2788 raw.value.contains("<!DOCTYPE HTML>") && raw.value.contains("<title>Test</title>"))
2789 );
2790 assert!(
2791 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
2792 s.value == "head_end" && s.raw)
2793 );
2794 assert!(
2795 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
2796 raw.value.contains("</head>") && raw.value.ends_with("<body>"))
2797 );
2798 assert!(
2799 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
2800 s.value == "body_start" && s.raw)
2801 );
2802 assert!(
2803 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
2804 raw.value.contains("<app-shell>"))
2805 );
2806 assert!(
2807 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
2808 s.value == "body_end" && s.raw)
2809 );
2810 assert!(
2811 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
2812 raw.value.contains("</body>") && raw.value.contains("</html>"))
2813 );
2814 }
2815
2816 #[test]
2817 fn test_css_strategy_external_emits_link_tag() {
2818 let mut parser = HtmlParser::new();
2819 parser
2820 .component_registry_mut()
2821 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
2822 .ok();
2823 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
2824 let records = parser.into_fragment_records();
2825 let my_card = &records["my-card"].fragments;
2826 let raw_text: String = my_card
2827 .iter()
2828 .filter_map(|f| match &f.fragment {
2829 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
2830 _ => None,
2831 })
2832 .collect();
2833 assert!(
2834 raw_text.contains(r#"<link rel="stylesheet" href="/my-card.css">"#),
2835 "Expected external <link> tag in: {}",
2836 raw_text
2837 );
2838 }
2839
2840 #[test]
2841 fn test_css_strategy_inline_emits_style_tag() {
2842 let mut parser = HtmlParser::new();
2843 parser.set_css_strategy(CssStrategy::Style);
2844 parser
2845 .component_registry_mut()
2846 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
2847 .ok();
2848 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
2849 let records = parser.into_fragment_records();
2850 let my_card = &records["my-card"].fragments;
2851 let raw_text: String = my_card
2852 .iter()
2853 .filter_map(|f| match &f.fragment {
2854 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
2855 _ => None,
2856 })
2857 .collect();
2858 assert!(
2859 raw_text.contains("<style>p { color: red; }</style>"),
2860 "Expected inline <style> tag in: {}",
2861 raw_text
2862 );
2863 assert!(
2864 !raw_text.contains("<link"),
2865 "Should not have <link> tag in inline mode: {}",
2866 raw_text
2867 );
2868 }
2869
2870 #[test]
2871 fn test_css_strategy_module_emits_adopted_stylesheets() {
2872 let mut parser = HtmlParser::new();
2873 parser.set_css_strategy(CssStrategy::Module);
2874 parser
2875 .component_registry_mut()
2876 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
2877 .ok();
2878 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
2879 let records = parser.into_fragment_records();
2880
2881 let my_card = &records["my-card"].fragments;
2884 let template_text: String = my_card
2885 .iter()
2886 .filter_map(|f| match &f.fragment {
2887 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
2888 _ => None,
2889 })
2890 .collect();
2891 assert!(
2892 template_text.contains("shadowrootadoptedstylesheets=\"my-card\""),
2893 "Expected shadowrootadoptedstylesheets attr in: {template_text}"
2894 );
2895 assert!(
2896 !template_text.contains("<link"),
2897 "Should not have <link> in module mode: {template_text}"
2898 );
2899 assert!(
2902 !template_text.contains(r#"<style type="module""#),
2903 "CSS module should NOT be in raw fragments: {template_text}"
2904 );
2905 }
2906
2907 #[test]
2908 fn test_css_strategy_module_no_css_no_adopted_attr() {
2909 let mut parser = HtmlParser::new();
2910 parser.set_css_strategy(CssStrategy::Module);
2911 parser
2912 .component_registry_mut()
2913 .register_component("my-card", "<p><slot></slot></p>", None)
2914 .ok();
2915 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
2916 let records = parser.into_fragment_records();
2917 let my_card = &records["my-card"].fragments;
2918 let template_text: String = my_card
2919 .iter()
2920 .filter_map(|f| match &f.fragment {
2921 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
2922 _ => None,
2923 })
2924 .collect();
2925 assert!(
2927 !template_text.contains("shadowrootadoptedstylesheets"),
2928 "Should not have adopted attr without CSS: {template_text}"
2929 );
2930 }
2931
2932 #[test]
2946 fn test_body_start_end_injection() {
2947 let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
2951 let (fragments, _) = parse_and_get_fragments(html);
2952
2953 assert_fragments!(
2954 fragments,
2955 [
2956 raw("<html><head><title>Test</title>"),
2957 signal_raw("head_end"),
2958 raw("</head><body>"),
2959 signal_raw("body_start"),
2960 raw("<div>Content</div><p>More</p>"),
2961 signal_raw("body_end"),
2962 raw("</body></html>"),
2963 ]
2964 );
2965 }
2966
2967 #[test]
2968 fn test_fail_with_invalid_markup() {
2969 let mut parser = HtmlParser::new();
2975 let result = parser.parse("index.html", "<div><span></div></span>");
2976
2977 assert!(
2979 result.is_ok(),
2980 "tree-sitter is lenient and recovers from overlapping tags"
2981 );
2982 }
2983
2984 #[test]
2985 fn test_complex_raw_text_page() {
2986 let html = concat!(
2989 "<!DOCTYPE html>",
2990 "<html lang=\"en\">",
2991 "<head>",
2992 "<meta charset=\"utf-8\">",
2993 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
2994 "<title>Complex Page</title>",
2995 "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
2996 "<link rel=\"stylesheet\" href=\"styles.css\">",
2997 "</head>",
2998 "<body>",
2999 "<h1>Hello World</h1>",
3000 "<script type=\"module\" src=\"./app.js\"></script>",
3001 "</body>",
3002 "</html>",
3003 );
3004 let (fragments, _) = parse_and_get_fragments(html);
3005
3006 assert!(
3009 fragments.len() >= 7,
3010 "Expected at least 7 fragments, got {}",
3011 fragments.len()
3012 );
3013
3014 assert!(
3016 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3017 raw.value.contains("<!DOCTYPE html>") &&
3018 raw.value.contains("<meta charset=\"utf-8\">") &&
3019 raw.value.contains("<meta name=\"viewport\"") &&
3020 raw.value.contains("<title>Complex Page</title>") &&
3021 raw.value.contains("<style>") &&
3022 raw.value.contains("body { margin: 0; padding: 0; }")),
3023 "First fragment should contain all head content, got: {:?}",
3024 fragments[0]
3025 );
3026
3027 assert!(
3029 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
3030 s.value == "head_end" && s.raw),
3031 "Second fragment should be head_end signal"
3032 );
3033
3034 assert!(
3036 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3037 raw.value.contains("</head>") && raw.value.ends_with("<body>")),
3038 "Third fragment should contain </head><body>"
3039 );
3040
3041 assert!(
3043 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
3044 s.value == "body_start" && s.raw),
3045 "Fourth fragment should be body_start signal"
3046 );
3047
3048 assert!(
3050 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3051 raw.value.contains("<h1>Hello World</h1>") &&
3052 raw.value.contains("<script")),
3053 "Fifth fragment should contain body content"
3054 );
3055
3056 assert!(
3058 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
3059 s.value == "body_end" && s.raw),
3060 "Sixth fragment should be body_end signal"
3061 );
3062
3063 assert!(
3065 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3066 raw.value.contains("</body>") && raw.value.contains("</html>")),
3067 "Seventh fragment should contain closing tags"
3068 );
3069 }
3070
3071 struct BindingCountPlugin {
3075 counts: Vec<u32>,
3076 }
3077
3078 impl BindingCountPlugin {
3079 fn new() -> Self {
3080 Self { counts: Vec::new() }
3081 }
3082 }
3083
3084 impl crate::plugin::ParserPlugin for BindingCountPlugin {
3085 fn on_parse_component(&mut self, _tag_name: &str, _component: &Component) -> Result<()> {
3086 Ok(())
3087 }
3088
3089 fn should_skip_attribute(&self, attr_name: &str) -> bool {
3090 attr_name.starts_with('@') || attr_name == "f-ref"
3091 }
3092
3093 fn on_body_end(&mut self) -> Option<String> {
3094 None
3095 }
3096
3097 fn on_element_parsed(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
3098 self.counts.push(binding_attribute_count);
3099 if binding_attribute_count > 0 {
3100 Some(binding_attribute_count.to_le_bytes().to_vec())
3101 } else {
3102 None
3103 }
3104 }
3105
3106 fn as_any(&self) -> &dyn std::any::Any {
3107 self
3108 }
3109
3110 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
3111 self
3112 }
3113 }
3114
3115 #[test]
3116 fn test_component_static_attrs_not_counted_as_bindings() {
3117 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3118 parser
3119 .component_registry
3120 .register_component("my-btn", "<button><slot></slot></button>", None)
3121 .expect("register");
3122
3123 let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
3125 let result = parser.parse("index.html", html);
3126 assert!(result.is_ok());
3127
3128 let records = parser.into_fragment_records();
3129
3130 let frags = &records["index.html"].fragments;
3132 let plugin_count = frags
3133 .iter()
3134 .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
3135 .count();
3136 assert_eq!(
3137 plugin_count, 0,
3138 "Static-only component attributes should not emit a Plugin fragment"
3139 );
3140 }
3141
3142 #[test]
3143 fn test_component_dynamic_attr_counted_as_binding() {
3144 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3145 parser
3146 .component_registry
3147 .register_component("my-btn", "<button><slot></slot></button>", None)
3148 .expect("register");
3149
3150 let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
3152 let result = parser.parse("index.html", html);
3153 assert!(result.is_ok());
3154
3155 let records = parser.into_fragment_records();
3156 let frags = &records["index.html"].fragments;
3157
3158 let plugin_frags: Vec<_> = frags
3160 .iter()
3161 .filter_map(|f| match f.fragment.as_ref() {
3162 Some(Fragment::Plugin(p)) => Some(&p.data),
3163 _ => None,
3164 })
3165 .collect();
3166 assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
3167 let count = u32::from_le_bytes([
3168 plugin_frags[0][0],
3169 plugin_frags[0][1],
3170 plugin_frags[0][2],
3171 plugin_frags[0][3],
3172 ]);
3173 assert_eq!(count, 1, "Binding attribute count should be 1");
3174 }
3175
3176 #[test]
3177 fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
3178 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3179 parser
3180 .component_registry
3181 .register_component("my-btn", "<button><slot></slot></button>", None)
3182 .expect("register");
3183
3184 let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
3186 let result = parser.parse("index.html", html);
3187 assert!(result.is_ok());
3188
3189 let records = parser.into_fragment_records();
3190 let frags = &records["index.html"].fragments;
3191
3192 let plugin_frags: Vec<_> = frags
3193 .iter()
3194 .filter_map(|f| match f.fragment.as_ref() {
3195 Some(Fragment::Plugin(p)) => Some(&p.data),
3196 _ => None,
3197 })
3198 .collect();
3199 assert_eq!(plugin_frags.len(), 1);
3200 let count = u32::from_le_bytes([
3201 plugin_frags[0][0],
3202 plugin_frags[0][1],
3203 plugin_frags[0][2],
3204 plugin_frags[0][3],
3205 ]);
3206 assert_eq!(
3208 count, 2,
3209 "Binding count should include dynamic + plugin-skipped attrs, not static"
3210 );
3211 }
3212
3213 #[test]
3214 fn test_component_only_skipped_plugin_attrs_counted() {
3215 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3216 parser
3217 .component_registry
3218 .register_component("my-btn", "<button><slot></slot></button>", None)
3219 .expect("register");
3220
3221 let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
3223 let result = parser.parse("index.html", html);
3224 assert!(result.is_ok());
3225
3226 let records = parser.into_fragment_records();
3227 let frags = &records["index.html"].fragments;
3228
3229 let plugin_frags: Vec<_> = frags
3230 .iter()
3231 .filter_map(|f| match f.fragment.as_ref() {
3232 Some(Fragment::Plugin(p)) => Some(&p.data),
3233 _ => None,
3234 })
3235 .collect();
3236 assert_eq!(plugin_frags.len(), 1);
3237 let count = u32::from_le_bytes([
3238 plugin_frags[0][0],
3239 plugin_frags[0][1],
3240 plugin_frags[0][2],
3241 plugin_frags[0][3],
3242 ]);
3243 assert_eq!(
3245 count, 2,
3246 "Only plugin-skipped attrs should be counted, not static title"
3247 );
3248 }
3249
3250 #[test]
3253 fn test_comment_handlebars_signal() {
3254 let mut parser = HtmlParser::new();
3255 let html = "<!--{{tokens}}-->";
3256 parser.parse("test.html", html).expect("parse failed");
3257 let records = parser.into_fragment_records();
3258
3259 assert_stream!(records, "test.html", [signal("tokens")]);
3260 }
3261
3262 #[test]
3263 fn test_comment_triple_brace_raw_signal() {
3264 let mut parser = HtmlParser::new();
3265 let html = "<!--{{{tokens}}}-->";
3266 parser.parse("test.html", html).expect("parse failed");
3267 let records = parser.into_fragment_records();
3268
3269 assert_stream!(records, "test.html", [signal_raw("tokens")]);
3270 }
3271
3272 #[test]
3273 fn test_comment_regular_preserved() {
3274 let mut parser = HtmlParser::new();
3275 let html = "<!-- regular comment -->";
3276 parser.parse("test.html", html).expect("parse failed");
3277 let records = parser.into_fragment_records();
3278
3279 assert_stream!(records, "test.html", [raw("<!-- regular comment -->")]);
3280 }
3281
3282 #[test]
3283 fn test_comment_dotted_signal() {
3284 let mut parser = HtmlParser::new();
3285 let html = "<!--{{tokens.light}}-->";
3286 parser.parse("test.html", html).expect("parse failed");
3287 let records = parser.into_fragment_records();
3288
3289 assert_stream!(records, "test.html", [signal("tokens.light")]);
3290 }
3291
3292 #[test]
3293 fn test_comment_arbitrary_identifier() {
3294 let mut parser = HtmlParser::new();
3295 let html = "<!--{{someOtherBinding}}-->";
3296 parser.parse("test.html", html).expect("parse failed");
3297 let records = parser.into_fragment_records();
3298
3299 assert_stream!(records, "test.html", [signal("someOtherBinding")]);
3300 }
3301
3302 #[test]
3303 fn test_comment_with_surrounding_content() {
3304 let mut parser = HtmlParser::new();
3305 let html = "<div><!--{{tokens}}--></div>";
3306 parser.parse("test.html", html).expect("parse failed");
3307 let records = parser.into_fragment_records();
3308
3309 assert_stream!(
3310 records,
3311 "test.html",
3312 [raw("<div>"), signal("tokens"), raw("</div>")]
3313 );
3314 }
3315
3316 #[test]
3319 fn test_tokens_from_style_tag() {
3320 let mut parser = HtmlParser::new();
3321 let html = r#"<style>
3322 .btn { color: var(--colorPrimary); background: var(--bgColor); }
3323 </style>"#;
3324 parser.parse("test.html", html).expect("parse failed");
3325 let tokens = parser.take_tokens();
3326
3327 assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
3328 }
3329
3330 #[test]
3331 fn test_tokens_from_component_css() {
3332 let mut parser = HtmlParser::new();
3333 parser
3334 .component_registry_mut()
3335 .register_component(
3336 "my-button",
3337 "<button>Click</button>",
3338 Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
3339 )
3340 .expect("register failed");
3341
3342 let html = "<my-button></my-button>";
3343 parser.parse("test.html", html).expect("parse failed");
3344 let tokens = parser.take_tokens();
3345
3346 assert_eq!(tokens, vec!["borderWidth", "textColor"]);
3347 }
3348
3349 #[test]
3350 fn test_tokens_merged_from_style_and_components() {
3351 let mut parser = HtmlParser::new();
3352 parser
3353 .component_registry_mut()
3354 .register_component(
3355 "my-widget",
3356 "<div>Widget</div>",
3357 Some(".w { padding: var(--spacingM); }"),
3358 )
3359 .expect("register failed");
3360
3361 let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
3362 parser.parse("test.html", html).expect("parse failed");
3363 let tokens = parser.take_tokens();
3364
3365 assert_eq!(tokens, vec!["spacingM", "textColor"]);
3366 }
3367
3368 #[test]
3369 fn test_tokens_deduplicated() {
3370 let mut parser = HtmlParser::new();
3371 parser
3372 .component_registry_mut()
3373 .register_component(
3374 "my-btn",
3375 "<button>B</button>",
3376 Some(".b { color: var(--shared); }"),
3377 )
3378 .expect("register failed");
3379
3380 let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
3381 parser.parse("test.html", html).expect("parse failed");
3382 let tokens = parser.take_tokens();
3383
3384 assert_eq!(tokens, vec!["shared"]);
3385 }
3386
3387 #[test]
3388 fn test_tokens_empty_when_no_vars() {
3389 let mut parser = HtmlParser::new();
3390 let html = "<div>Hello</div>";
3391 parser.parse("test.html", html).expect("parse failed");
3392 let tokens = parser.take_tokens();
3393
3394 assert!(tokens.is_empty());
3395 }
3396
3397 #[test]
3398 fn test_tokens_exclude_locally_defined_in_component() {
3399 let mut parser = HtmlParser::new();
3400 parser
3401 .component_registry_mut()
3402 .register_component(
3403 "my-card",
3404 "<div>Card</div>",
3405 Some(":host { --local: 5px; width: var(--external); }"),
3406 )
3407 .expect("register failed");
3408
3409 let html = "<my-card></my-card>";
3410 parser.parse("test.html", html).expect("parse failed");
3411 let tokens = parser.take_tokens();
3412
3413 assert_eq!(tokens, vec!["external"]);
3414 }
3415
3416 #[test]
3417 fn test_tokens_exclude_entry_root_definitions() {
3418 let mut parser = HtmlParser::new();
3419 parser
3420 .component_registry_mut()
3421 .register_component(
3422 "my-btn",
3423 "<button>B</button>",
3424 Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
3425 )
3426 .expect("register failed");
3427
3428 let html = r#"<style>
3431 :root {
3432 --color-primary: #0078d4;
3433 --radius-m: 6px;
3434 }
3435 body { color: var(--color-primary); }
3436 </style>
3437 <my-btn></my-btn>"#;
3438 parser.parse("test.html", html).expect("parse failed");
3439 let tokens = parser.take_tokens();
3440
3441 assert!(
3443 tokens.is_empty(),
3444 "Tokens defined in entry :root should be excluded: {tokens:?}"
3445 );
3446 }
3447
3448 #[test]
3449 fn test_tokens_entry_defs_exclude_but_external_kept() {
3450 let mut parser = HtmlParser::new();
3451 parser
3452 .component_registry_mut()
3453 .register_component(
3454 "my-card",
3455 "<div>Card</div>",
3456 Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
3457 )
3458 .expect("register failed");
3459
3460 let html = r#"<style>
3462 :root { --color-primary: #0078d4; }
3463 </style>
3464 <my-card></my-card>"#;
3465 parser.parse("test.html", html).expect("parse failed");
3466 let tokens = parser.take_tokens();
3467
3468 assert_eq!(tokens, vec!["external-spacing"]);
3470 }
3471
3472 #[test]
3475 fn test_parse_simple_route() {
3476 let mut parser = HtmlParser::new();
3477 let html = r#"<route path="/profile" component="profile-page" exact />"#;
3478 parser.parse("test.html", html).expect("parse failed");
3479
3480 let records = parser.into_fragment_records();
3482 let frags = &records["test.html"].fragments;
3483 assert_eq!(frags.len(), 1);
3484 match frags[0].fragment.as_ref() {
3485 Some(web_ui_fragment::Fragment::Route(r)) => {
3486 assert_eq!(r.path, "/profile");
3487 assert_eq!(r.fragment_id, "profile-page");
3488 assert!(r.exact);
3489 }
3490 other => panic!("expected Fragment::Route, got {:?}", other),
3491 }
3492 }
3493
3494 #[test]
3495 fn test_parse_route_with_params() {
3496 let mut parser = HtmlParser::new();
3497 let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
3498 parser.parse("test.html", html).expect("parse failed");
3499
3500 let records = parser.into_fragment_records();
3502 let frags = &records["test.html"].fragments;
3503 match frags[0].fragment.as_ref() {
3504 Some(web_ui_fragment::Fragment::Route(r)) => {
3505 assert_eq!(r.path, "/profile/:id/view/:section");
3506 }
3507 other => panic!("expected Fragment::Route, got {:?}", other),
3508 }
3509 }
3510
3511 #[test]
3512 fn test_parse_route_requires_component() {
3513 let mut parser = HtmlParser::new();
3514 let html = r#"<route path="/old" />"#;
3515 let result = parser.parse("test.html", html);
3516 assert!(result.is_err());
3517 }
3518
3519 #[test]
3520 fn test_parse_multiple_routes() {
3521 let mut parser = HtmlParser::new();
3522 let html = r#"<route path="/" component="app-layout" />
3523 <route path="/dashboard" component="dash-page" exact />
3524 <route path="/contacts" component="contacts-page" exact />"#;
3525 parser.parse("test.html", html).expect("parse failed");
3526
3527 let records = parser.into_fragment_records();
3528 let frags = &records["test.html"].fragments;
3529 assert_eq!(frags.len(), 3);
3530 for frag in frags {
3532 assert!(matches!(
3533 frag.fragment.as_ref(),
3534 Some(web_ui_fragment::Fragment::Route(_))
3535 ));
3536 }
3537 }
3538
3539 #[test]
3540 fn test_parse_route_requires_component_with_body() {
3541 let mut parser = HtmlParser::new();
3542 let html = r#"<route path="/error">
3543 <div class="error"><h1>Not Found</h1></div>
3544 </route>"#;
3545 let result = parser.parse("test.html", html);
3546 assert!(
3547 result.is_err(),
3548 "Route without component attribute should fail"
3549 );
3550 }
3551
3552 #[test]
3553 fn test_parse_multiple_routes_with_fragments() {
3554 let mut parser = HtmlParser::new();
3555 let html = r#"<route path="/" component="home-page" exact />
3556 <route path="/about" component="about-page" exact />
3557 <route path="/contact/:id" component="contact-page" />"#;
3558 parser.parse("test.html", html).expect("parse failed");
3559
3560 let records = parser.into_fragment_records();
3561 let frags = &records["test.html"].fragments;
3562 assert_eq!(frags.len(), 3);
3563
3564 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
3566 assert_eq!(r.path, "/");
3567 }
3568 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
3569 assert_eq!(r.path, "/about");
3570 }
3571 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
3572 assert_eq!(r.path, "/contact/:id");
3573 }
3574 }
3575
3576 #[test]
3577 fn test_outlet_not_captured_by_for_loop() {
3578 let mut parser = HtmlParser::new();
3579 let html = r#"<template shadowrootmode="open">
3580 <ul>
3581 <for each="item in items">
3582 <li>{{item.name}}</li>
3583 </for>
3584 </ul>
3585 <main>
3586 <outlet />
3587 </main>
3588</template>"#;
3589 parser.parse("comp.html", html).expect("parse failed");
3590
3591 let records = parser.into_fragment_records();
3592 let frags = &records["comp.html"].fragments;
3593
3594 let frag_types: Vec<String> = frags
3596 .iter()
3597 .map(|f| match f.fragment.as_ref() {
3598 Some(web_ui_fragment::Fragment::Raw(r)) => {
3599 format!("Raw({:?})", &r.value[..r.value.len().min(40)])
3600 }
3601 Some(web_ui_fragment::Fragment::ForLoop(fl)) => {
3602 format!("ForLoop({})", fl.fragment_id)
3603 }
3604 Some(web_ui_fragment::Fragment::Outlet(_)) => "Outlet".to_string(),
3605 other => format!("{:?}", other),
3606 })
3607 .collect();
3608 eprintln!("Fragment order: {:#?}", frag_types);
3609
3610 let outlet_count = frags
3612 .iter()
3613 .filter(|f| {
3614 matches!(
3615 f.fragment.as_ref(),
3616 Some(web_ui_fragment::Fragment::Outlet(_))
3617 )
3618 })
3619 .count();
3620 assert_eq!(
3621 outlet_count, 1,
3622 "expected exactly 1 outlet in top-level fragments, got {outlet_count}. Fragments: {frag_types:?}"
3623 );
3624
3625 let outlet_idx = frags
3627 .iter()
3628 .position(|f| {
3629 matches!(
3630 f.fragment.as_ref(),
3631 Some(web_ui_fragment::Fragment::Outlet(_))
3632 )
3633 })
3634 .expect("no outlet found");
3635 let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
3636 Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
3637 _ => false,
3638 });
3639 if let Some(ul_idx) = close_ul_idx {
3640 assert!(
3641 outlet_idx > ul_idx,
3642 "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx}). Fragments: {frag_types:?}"
3643 );
3644 }
3645 }
3646
3647 #[test]
3648 fn test_outlet_position_after_for_not_inside() {
3649 let mut parser = HtmlParser::new();
3650 let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
3651 parser.parse("test.html", html).expect("parse failed");
3652
3653 let records = parser.into_fragment_records();
3654 let frags = &records["test.html"].fragments;
3655
3656 let has_outlet = frags.iter().any(|f| {
3658 matches!(
3659 f.fragment.as_ref(),
3660 Some(web_ui_fragment::Fragment::Outlet(_))
3661 )
3662 });
3663 assert!(
3664 has_outlet,
3665 "outlet should be in top-level fragments: {frags:?}"
3666 );
3667
3668 let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
3670 Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
3671 _ => None,
3672 });
3673 if let Some(id) = for_id {
3674 let for_frags = &records[&id].fragments;
3675 let outlet_in_for = for_frags.iter().any(|f| {
3676 matches!(
3677 f.fragment.as_ref(),
3678 Some(web_ui_fragment::Fragment::Outlet(_))
3679 )
3680 });
3681 assert!(
3682 !outlet_in_for,
3683 "outlet should NOT be inside for-loop body: {for_frags:?}"
3684 );
3685 }
3686 }
3687}