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::{AttributeAction, ParserPlugin, ParserPluginArtifacts};
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,
42}
43
44impl std::fmt::Display for CssStrategy {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 match self {
47 CssStrategy::Link => write!(f, "link"),
48 CssStrategy::Style => write!(f, "style"),
49 CssStrategy::Module => write!(f, "module"),
50 }
51 }
52}
53
54impl std::str::FromStr for CssStrategy {
55 type Err = String;
56
57 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
58 match s {
59 "link" => Ok(CssStrategy::Link),
60 "style" => Ok(CssStrategy::Style),
61 "module" => Ok(CssStrategy::Module),
62 other => Err(format!(
63 "Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
64 )),
65 }
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
72pub enum DomStrategy {
73 #[default]
75 Shadow,
76 Light,
78}
79
80impl std::fmt::Display for DomStrategy {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 match self {
83 DomStrategy::Shadow => write!(f, "shadow"),
84 DomStrategy::Light => write!(f, "light"),
85 }
86 }
87}
88
89impl std::str::FromStr for DomStrategy {
90 type Err = String;
91
92 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
93 match s {
94 "shadow" => Ok(DomStrategy::Shadow),
95 "light" => Ok(DomStrategy::Light),
96 other => Err(format!(
97 "Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
98 )),
99 }
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
106pub enum Plugin {
107 Fast,
109 #[cfg_attr(feature = "cli", value(name = "webui"))]
111 WebUI,
112}
113
114impl std::fmt::Display for Plugin {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 match self {
117 Plugin::Fast => write!(f, "fast"),
118 Plugin::WebUI => write!(f, "webui"),
119 }
120 }
121}
122
123impl std::str::FromStr for Plugin {
124 type Err = String;
125
126 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
127 match s {
128 "fast" => Ok(Plugin::Fast),
129 "webui" => Ok(Plugin::WebUI),
130 other => Err(format!("Unknown plugin: {other}")),
131 }
132 }
133}
134
135struct FragmentIdCounter {
137 counters: HashMap<String, usize>,
139}
140
141impl FragmentIdCounter {
142 fn new() -> Self {
144 Self {
145 counters: HashMap::new(),
146 }
147 }
148
149 fn next_id(&mut self, prefix: &str) -> String {
151 let count = self.counters.entry(prefix.to_string()).or_insert(0);
152 *count += 1;
153 format!("{}-{}", prefix, count)
154 }
155}
156
157impl Default for HtmlParser {
158 fn default() -> Self {
159 Self::new()
160 }
161}
162
163pub struct HtmlParser {
165 css_parser: CssParser,
167
168 parser: Parser,
170
171 id_counter: FragmentIdCounter,
173
174 condition_parser: ConditionParser,
176
177 handlebars_parser: HandlebarsParser,
179
180 component_registry: ComponentRegistry,
182
183 fragment_records: WebUIFragmentRecords,
185
186 raw_buffer: String,
188
189 css_strategy: CssStrategy,
191
192 dom_strategy: DomStrategy,
194
195 plugin: Option<Box<dyn ParserPlugin>>,
197
198 token_store: HashSet<String>,
201
202 token_definitions: HashSet<String>,
206}
207
208impl HtmlParser {
209 pub fn new() -> Self {
211 let mut parser = Parser::new();
212 #[allow(clippy::disallowed_methods)]
214 parser
215 .set_language(&LANGUAGE.into())
216 .expect("Error loading HTML grammar");
217
218 Self {
219 component_registry: ComponentRegistry::new(),
220 css_parser: CssParser::new(),
221 id_counter: FragmentIdCounter::new(),
222 condition_parser: ConditionParser::new(),
223 handlebars_parser: HandlebarsParser::new(),
224 raw_buffer: String::new(),
225 fragment_records: WebUIFragmentRecords::new(),
226 css_strategy: CssStrategy::default(),
227 dom_strategy: DomStrategy::default(),
228 plugin: None,
229 token_store: HashSet::new(),
230 token_definitions: HashSet::new(),
231 parser,
232 }
233 }
234
235 pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
237 let mut p = Self::new();
238 p.plugin = Some(plugin);
239 p
240 }
241
242 pub fn set_css_strategy(&mut self, strategy: CssStrategy) -> &mut Self {
244 self.css_strategy = strategy;
245 self
246 }
247
248 pub fn set_dom_strategy(&mut self, strategy: DomStrategy) -> &mut Self {
250 self.dom_strategy = strategy;
251 self
252 }
253
254 pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
256 &mut self.component_registry
257 }
258
259 pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
260 std::mem::take(&mut self.fragment_records)
261 }
262
263 pub fn has_fragment(&self, fragment_id: &str) -> bool {
265 self.fragment_records.contains_key(fragment_id)
266 }
267
268 #[must_use]
270 pub fn take_plugin_artifacts(&mut self) -> ParserPluginArtifacts {
271 self.plugin
272 .take()
273 .map_or(ParserPluginArtifacts::None, |plugin| {
274 plugin.into_artifacts()
275 })
276 }
277
278 #[must_use]
286 pub fn take_tokens(&mut self) -> Vec<String> {
287 let definitions = std::mem::take(&mut self.token_definitions);
288 let mut tokens: Vec<String> = std::mem::take(&mut self.token_store)
289 .into_iter()
290 .filter(|t| !definitions.contains(t))
291 .collect();
292 tokens.sort();
293 tokens
294 }
295
296 pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
298 self.raw_buffer.clear();
300 if let Some(ref mut plugin) = self.plugin {
301 plugin.start_fragment(fragment_id);
302 }
303
304 let tree = self
306 .parser
307 .parse(html_content, None)
308 .ok_or_else(|| ParserError::Html("Failed to parse HTML".to_string()))?;
309
310 let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
311
312 self.process_html_node(tree.root_node(), html_content, &mut entry_fragment)?;
314
315 self.flush_raw_buffer(&mut entry_fragment);
316
317 self.fragment_records.insert(
319 fragment_id.to_string(),
320 FragmentList {
321 fragments: entry_fragment,
322 },
323 );
324
325 Ok(())
327 }
328
329 fn add_raw_fragment(&mut self, content: &str) {
331 if !content.is_empty() {
332 self.raw_buffer.push_str(content);
333 }
334 }
335
336 fn add_for_fragment(
338 &mut self,
339 item: String,
340 collection: String,
341 fragment_id: String,
342 fragments: &mut Vec<WebUIFragment>,
343 ) {
344 self.flush_raw_buffer(fragments);
345 fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
346 }
347
348 fn add_if_fragment(
350 &mut self,
351 condition: ConditionExpr,
352 fragment_id: String,
353 fragments: &mut Vec<WebUIFragment>,
354 ) {
355 self.flush_raw_buffer(fragments);
356 fragments.push(WebUIFragment::if_cond(condition, fragment_id));
357 }
358
359 fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
361 self.flush_raw_buffer(fragments);
362 fragments.push(fragment);
363 }
364
365 fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
367 if !self.raw_buffer.is_empty() {
368 fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
369 }
370 }
371
372 fn should_emit_text_content(content: &str) -> bool {
379 if content.is_empty() {
380 return false;
381 }
382
383 if !content.trim().is_empty() {
384 return true;
385 }
386
387 content.chars().all(char::is_whitespace)
388 && !content.contains('\n')
389 && !content.contains('\r')
390 }
391
392 fn process_children_with_source_gaps(
398 &mut self,
399 node: Node,
400 source: &str,
401 fragments: &mut Vec<WebUIFragment>,
402 skip_structural_tags: bool,
403 ) -> Result<()> {
404 let mut cursor = node.walk();
405 let mut last_end = node.start_byte();
406
407 for child in node.children(&mut cursor) {
408 if child.start_byte() > last_end {
409 let gap = &source[last_end..child.start_byte()];
410 if Self::should_emit_text_content(gap) {
411 self.add_raw_fragment(gap);
412 }
413 }
414
415 let kind = child.kind();
416 if !skip_structural_tags || (kind != "start_tag" && kind != "end_tag") {
417 self.process_child_node(child, source, fragments)?;
418 }
419
420 last_end = child.end_byte();
421 }
422
423 if node.end_byte() > last_end {
424 let gap = &source[last_end..node.end_byte()];
425 if Self::should_emit_text_content(gap) {
426 self.add_raw_fragment(gap);
427 }
428 }
429
430 Ok(())
431 }
432
433 fn extract_style_comment_signals(
444 &mut self,
445 css: &str,
446 fragments: &mut Vec<WebUIFragment>,
447 ) -> Result<()> {
448 let bytes = css.as_bytes();
449 let len = bytes.len();
450 let mut pos = 0;
451
452 while pos < len {
453 let Some(open) = css[pos..].find("/*") else {
455 self.add_raw_fragment(&css[pos..]);
457 break;
458 };
459 let open = pos + open;
460
461 let Some(close_offset) = css[open + 2..].find("*/") else {
463 self.add_raw_fragment(&css[pos..]);
465 break;
466 };
467 let close = open + 2 + close_offset;
468 let after_close = close + 2;
469
470 let inner = css[open + 2..close].trim();
472
473 let is_signal = if !inner.is_empty() {
476 match self.handlebars_parser.parse(inner) {
477 Ok(ref parsed) if parsed.len() == 1 => {
478 matches!(parsed[0].fragment.as_ref(), Some(Fragment::Signal(_)))
479 }
480 _ => false,
481 }
482 } else {
483 false
484 };
485
486 if is_signal {
487 self.add_raw_fragment(&css[pos..open]);
489
490 let parsed = self.handlebars_parser.parse(inner)?;
492 self.add_fragment(parsed.into_iter().next().unwrap_or_default(), fragments);
493 } else {
494 self.add_raw_fragment(&css[pos..after_close]);
497 }
498
499 pos = after_close;
500 }
501
502 Ok(())
503 }
504
505 fn process_content_children(
507 &mut self,
508 node: Node,
509 source: &str,
510 fragments: &mut Vec<WebUIFragment>,
511 ) -> Result<()> {
512 self.process_children_with_source_gaps(node, source, fragments, true)
513 }
514 fn process_html_node(
516 &mut self,
517 node: Node,
518 source: &str,
519 fragments: &mut Vec<WebUIFragment>,
520 ) -> Result<()> {
521 if node.kind() == "document" || node.kind() == "fragment" || node.kind() == "element" {
522 self.process_children_with_source_gaps(node, source, fragments, false)?;
523 } else {
524 let content = &source[node.start_byte()..node.end_byte()];
526 if Self::should_emit_text_content(content) {
527 self.add_raw_fragment(content);
528 }
529 }
530
531 Ok(())
532 }
533
534 fn process_child_node(
536 &mut self,
537 node: Node,
538 source: &str,
539 fragments: &mut Vec<WebUIFragment>,
540 ) -> Result<()> {
541 match node.kind() {
542 "element" => {
543 let tag_name = self.get_element_tag_name(node, source)?;
545
546 match tag_name.as_str() {
548 "for" => return self.process_for_directive(node, source, fragments),
549 "if" => return self.process_if_directive(node, source, fragments),
550 "body" => return self.process_body_element(node, source, fragments),
551 "head" => return self.process_head_element(node, source, fragments),
552 "route" => return self.process_route_directive(node, source, fragments),
553 "outlet" => {
554 self.flush_raw_buffer(fragments);
555 fragments.push(WebUIFragment::outlet());
556 return Ok(());
557 }
558 _ => {
559 if self.component_registry.contains(tag_name.as_str()) {
560 return self.process_component_directive(
561 node,
562 source,
563 fragments,
564 tag_name.as_str(),
565 );
566 }
567
568 self.process_regular_element(node, source, fragments, &tag_name)?;
570
571 return Ok(());
572 }
573 }
574 }
575 "style_element" => {
576 self.add_raw_fragment("<style>");
579 for child in node.named_children(&mut node.walk()) {
580 if child.kind() == "raw_text" {
581 let style_content = &source[child.start_byte()..child.end_byte()];
582
583 if let Ok((tokens, defs)) = self
586 .css_parser
587 .extract_tokens_and_definitions(style_content)
588 {
589 self.token_store.extend(tokens);
590 self.token_definitions.extend(defs);
591 }
592
593 self.extract_style_comment_signals(style_content, fragments)?;
597 }
598 }
599 self.add_raw_fragment("</style>");
600 }
601 "text" | "raw_text" => {
602 let content = &source[node.start_byte()..node.end_byte()];
603 if Self::should_emit_text_content(content) {
604 let handlebars_result = self.handlebars_parser.parse(content);
605 match handlebars_result {
606 Ok(parsed_fragments) => {
607 for fragment in parsed_fragments {
608 if matches!(fragment.fragment.as_ref(), Some(Fragment::Raw(_))) {
609 if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
610 self.add_raw_fragment(&raw.value);
611 }
612 } else {
613 self.add_fragment(fragment, fragments);
614 }
615 }
616 }
617 Err(error) => return Err(error),
618 }
619 }
620 }
621 "doctype" => {
626 let content = &source[node.start_byte()..node.end_byte()];
627 self.add_raw_fragment(content);
628 }
629 "comment" => {
631 let content = &source[node.start_byte()..node.end_byte()];
632 self.process_comment(content, fragments)?;
633 }
634 _ => {
636 self.process_html_node(node, source, fragments)?;
637 }
638 }
639
640 Ok(())
641 }
642
643 fn get_element_tag_name(&self, node: Node, source: &str) -> Result<String> {
645 let mut cursor = node.walk();
646 for child in node.named_children(&mut cursor) {
647 if child.kind() == "start_tag" || child.kind() == "self_closing_tag" {
648 let mut tag_cursor = child.walk();
649 for tag_name_node in child.named_children(&mut tag_cursor) {
650 if tag_name_node.kind() == "tag_name" {
651 let tag_name = source[tag_name_node.start_byte()..tag_name_node.end_byte()]
652 .to_string();
653 return Ok(tag_name);
654 }
655 }
656 }
657 }
658
659 Err(ParserError::Html("Failed to extract tag name".to_string()))
660 }
661
662 fn get_element_attribute(
663 &self,
664 node: Node,
665 attr_name: &str,
666 source: &str,
667 ) -> Result<Option<String>> {
668 let query_str = r#"
669 (element
670 [
671 (start_tag
672 (attribute
673 (attribute_name) @name
674 [(quoted_attribute_value (attribute_value) @value)
675 (attribute_value) @value]))
676 (self_closing_tag
677 (attribute
678 (attribute_name) @name
679 [(quoted_attribute_value (attribute_value) @value)
680 (attribute_value) @value]))
681 ]
682 )
683 "#;
684
685 let query = Query::new(&LANGUAGE.into(), query_str)
686 .map_err(|e| ParserError::Html(format!("Failed to create attribute query: {:?}", e)))?;
687
688 let mut cursor = QueryCursor::new();
689 let mut matches = cursor.matches(&query, node, source.as_bytes());
690 while let Some(m) = matches.next_mut() {
691 let mut found_name = false;
692 let mut found_value: Option<String> = None;
693
694 for capture in m.captures.iter() {
695 let node = capture.node;
696 let capture_name = query.capture_names()[capture.index as usize];
697
698 if capture_name == "name" {
699 let name_text = node.utf8_text(source.as_bytes()).map_err(|_| {
700 ParserError::Html("Invalid UTF-8 for attribute name".to_string())
701 })?;
702 if name_text == attr_name {
703 found_name = true;
704 }
705 } else if capture_name == "value" {
706 let value_text = node.utf8_text(source.as_bytes()).map_err(|_| {
707 ParserError::Html("Invalid UTF-8 for attribute value".to_string())
708 })?;
709 found_value = Some(value_text.to_string());
710 }
711 }
712
713 if found_name {
714 return Ok(found_value);
715 }
716 }
717
718 Ok(None)
719 }
720
721 fn has_element_attribute(&self, node: Node, attr_name: &str, source: &str) -> Result<bool> {
724 if self
726 .get_element_attribute(node, attr_name, source)?
727 .is_some()
728 {
729 return Ok(true);
730 }
731
732 let query_str = r#"
736 (element
737 [
738 (start_tag
739 (attribute
740 (attribute_name) @name))
741 (self_closing_tag
742 (attribute
743 (attribute_name) @name))
744 ]
745 )
746 "#;
747
748 let query = Query::new(&LANGUAGE.into(), query_str)
749 .map_err(|e| ParserError::Html(format!("Failed to create attribute query: {:?}", e)))?;
750
751 let tag_node_id = node
753 .named_children(&mut node.walk())
754 .find(|c| c.kind() == "start_tag" || c.kind() == "self_closing_tag")
755 .map(|c| c.id());
756
757 let mut cursor = QueryCursor::new();
758 let mut matches = cursor.matches(&query, node, source.as_bytes());
759 while let Some(m) = matches.next_mut() {
760 for capture in m.captures.iter() {
761 let capture_name = query.capture_names()[capture.index as usize];
762 if capture_name == "name" {
763 let attr_parent = capture.node.parent().and_then(|a| a.parent());
765 if attr_parent
766 .map(|p| Some(p.id()) == tag_node_id)
767 .unwrap_or(false)
768 {
769 let name_text =
770 capture.node.utf8_text(source.as_bytes()).map_err(|_| {
771 ParserError::Html("Invalid UTF-8 for attribute name".to_string())
772 })?;
773 if name_text == attr_name {
774 return Ok(true);
775 }
776 }
777 }
778 }
779 }
780
781 Ok(false)
782 }
783
784 fn find_tag_node<'a>(&self, node: Node<'a>) -> Option<Node<'a>> {
786 let mut cursor = node.walk();
787 let result = node
788 .named_children(&mut cursor)
789 .find(|child| child.kind() == "start_tag" || child.kind() == "self_closing_tag");
790 result
791 }
792
793 fn extract_single_handlebars(value: &str) -> Option<String> {
796 let trimmed = value.trim();
797 if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
798 let inner = trimmed[2..trimmed.len() - 2].trim();
799 if !inner.contains("{{") && !inner.is_empty() {
801 return Some(inner.to_string());
802 }
803 }
804 None
805 }
806
807 fn contains_handlebars(value: &str) -> bool {
809 value.contains("{{")
810 }
811
812 fn has_end_tag(&self, node: Node) -> bool {
814 let mut cursor = node.walk();
815 let result = node
816 .named_children(&mut cursor)
817 .any(|child| child.kind() == "end_tag");
818 result
819 }
820
821 fn process_regular_element(
823 &mut self,
824 node: Node,
825 source: &str,
826 fragments: &mut Vec<WebUIFragment>,
827 tag_name: &str,
828 ) -> Result<()> {
829 let tag_node = self.find_tag_node(node);
830 let is_self_closing = tag_node
831 .map(|n| n.kind() == "self_closing_tag")
832 .unwrap_or(false);
833 let has_end = self.has_end_tag(node);
834
835 if let Some(tag_node) = tag_node {
836 self.add_raw_fragment(&format!("<{}", tag_name));
838
839 let binding_count = self.process_tag_attributes(tag_node, source, fragments, false)?;
841 if let Some(ref mut p) = self.plugin {
842 if let Some(data) = p.finish_element(binding_count) {
843 self.add_fragment(WebUIFragment::plugin(data), fragments);
844 }
845 }
846
847 if is_self_closing {
848 let tag_text = &source[tag_node.start_byte()..tag_node.end_byte()];
850 if tag_text.ends_with("/>") {
851 self.add_raw_fragment("/>");
852 } else {
853 self.add_raw_fragment(">");
854 }
855 } else if !has_end {
856 self.add_raw_fragment(">");
861 self.process_content_children(node, source, fragments)?;
862 } else {
863 self.add_raw_fragment(">");
864
865 self.process_content_children(node, source, fragments)?;
866
867 self.add_raw_fragment(&format!("</{}>", tag_name));
869 }
870 }
871
872 Ok(())
873 }
874
875 fn process_tag_attributes(
882 &mut self,
883 tag_node: Node,
884 source: &str,
885 fragments: &mut Vec<WebUIFragment>,
886 is_component: bool,
887 ) -> Result<u32> {
888 let mut first_dynamic_emitted = false;
889 let mut binding_count: u32 = 0;
890 let mut cursor = tag_node.walk();
891
892 for child in tag_node.named_children(&mut cursor) {
893 if child.kind() != "attribute" {
894 continue;
895 }
896
897 let attr_name = self.get_attr_name(child, source)?;
898
899 if let Some(ref mut p) = self.plugin {
900 match p.classify_attribute(&attr_name) {
901 AttributeAction::Keep => {}
902 AttributeAction::Skip => continue,
903 AttributeAction::SkipAndCountBinding => {
904 binding_count += 1;
905 continue;
906 }
907 }
908 }
909
910 let attr_value = self.get_attr_value(child, source);
911
912 if let Some(bool_name) = attr_name.strip_prefix('?') {
913 if is_component {
915 if let Some(condition) = self.parse_boolean_condition(attr_value.as_deref()) {
916 let frag = Self::maybe_mark_attr_start(
917 WebUIFragment::attribute_boolean(bool_name, condition),
918 &mut first_dynamic_emitted,
919 );
920 self.add_fragment(frag, fragments);
921 binding_count += 1;
922 }
923 } else if self.process_boolean_attribute(
924 bool_name,
925 attr_value.as_deref(),
926 fragments,
927 )? {
928 binding_count += 1;
929 }
930 } else if let Some(prop_name) = attr_name.strip_prefix(':') {
931 if !is_component {
937 return Err(ParserError::Parse(format!(
938 ":{prop_name} complex binding is only allowed on custom elements. \
939 Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
940 )));
941 }
942 if Self::is_blocked_complex_property(prop_name) {
943 return Err(ParserError::Parse(format!(
944 ":{prop_name} is not allowed as a complex attribute binding \
945 because it enables arbitrary HTML injection. \
946 Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
947 )));
948 }
949 if let Some(val) = &attr_value {
950 if let Some(signal_name) = Self::extract_single_handlebars(val) {
951 let frag = Self::maybe_mark_attr_start(
952 WebUIFragment::attribute_complex(&attr_name, signal_name),
953 &mut first_dynamic_emitted,
954 );
955 self.add_fragment(frag, fragments);
956 binding_count += 1;
957 }
958 }
959 } else if is_component && Self::is_skipped_attribute(&attr_name) {
960 if let Some(val) = &attr_value {
962 if let Some(signal_name) = Self::extract_single_handlebars(val) {
963 let frag = WebUIFragment {
965 fragment: Some(web_ui_fragment::Fragment::Attribute(
966 WebUIFragmentAttribute {
967 name: attr_name,
968 value: signal_name,
969 attr_skip: true,
970 ..Default::default()
971 },
972 )),
973 };
974 self.add_fragment(frag, fragments);
975 binding_count += 1;
976 } else if Self::contains_handlebars(val) {
977 let template_id = self.id_counter.next_id("attr");
979 let parsed = self.handlebars_parser.parse(val)?;
980 self.fragment_records
981 .insert(template_id.clone(), FragmentList { fragments: parsed });
982 let frag = WebUIFragment {
983 fragment: Some(web_ui_fragment::Fragment::Attribute(
984 WebUIFragmentAttribute {
985 name: attr_name,
986 template: template_id,
987 attr_skip: true,
988 ..Default::default()
989 },
990 )),
991 };
992 self.add_fragment(frag, fragments);
993 binding_count += 1;
994 } else {
995 let frag = WebUIFragment {
997 fragment: Some(web_ui_fragment::Fragment::Attribute(
998 WebUIFragmentAttribute {
999 name: attr_name,
1000 value: val.clone(),
1001 raw_value: true,
1002 attr_skip: true,
1003 ..Default::default()
1004 },
1005 )),
1006 };
1007 self.add_fragment(frag, fragments);
1008 }
1009 }
1010 } else if let Some(ref val) = attr_value {
1011 if Self::contains_handlebars(val) {
1012 if is_component {
1013 if let Some(signal_name) = Self::extract_single_handlebars(val) {
1014 let frag = Self::maybe_mark_attr_start(
1015 WebUIFragment::attribute(&attr_name, signal_name),
1016 &mut first_dynamic_emitted,
1017 );
1018 self.add_fragment(frag, fragments);
1019 binding_count += 1;
1020 } else {
1021 let template_id = self.id_counter.next_id("attr");
1022 let parsed = self.handlebars_parser.parse(val)?;
1023 self.fragment_records
1024 .insert(template_id.clone(), FragmentList { fragments: parsed });
1025 let frag = Self::maybe_mark_attr_start(
1026 WebUIFragment::attribute_template(&attr_name, template_id),
1027 &mut first_dynamic_emitted,
1028 );
1029 self.add_fragment(frag, fragments);
1030 binding_count += 1;
1031 }
1032 } else {
1033 self.process_dynamic_attribute(&attr_name, val, fragments)?;
1034 binding_count += 1;
1035 }
1036 } else if is_component {
1037 let frag = Self::maybe_mark_attr_start(
1040 WebUIFragment {
1041 fragment: Some(web_ui_fragment::Fragment::Attribute(
1042 WebUIFragmentAttribute {
1043 name: attr_name,
1044 value: val.clone(),
1045 raw_value: true,
1046 ..Default::default()
1047 },
1048 )),
1049 },
1050 &mut first_dynamic_emitted,
1051 );
1052 self.add_fragment(frag, fragments);
1053 } else {
1054 let attr_text = &source[child.start_byte()..child.end_byte()];
1056 self.add_raw_fragment(&format!(" {}", attr_text));
1057 }
1058 } else {
1059 self.add_raw_fragment(&format!(" {}", attr_name));
1061 }
1062 }
1063 Ok(binding_count)
1064 }
1065
1066 fn maybe_mark_attr_start(
1069 mut frag: WebUIFragment,
1070 first_dynamic_emitted: &mut bool,
1071 ) -> WebUIFragment {
1072 if !*first_dynamic_emitted {
1073 if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
1074 a.attr_start = true;
1075 }
1076 *first_dynamic_emitted = true;
1077 }
1078 frag
1079 }
1080
1081 fn get_attr_name(&self, attr_node: Node, source: &str) -> Result<String> {
1083 let mut cursor = attr_node.walk();
1084 for child in attr_node.children(&mut cursor) {
1085 if child.kind() == "attribute_name" {
1086 return Ok(source[child.start_byte()..child.end_byte()].to_string());
1087 }
1088 }
1089 Err(ParserError::Html(
1090 "Attribute node missing attribute_name".to_string(),
1091 ))
1092 }
1093
1094 fn get_attr_value(&self, attr_node: Node, source: &str) -> Option<String> {
1097 let mut cursor = attr_node.walk();
1098 for child in attr_node.children(&mut cursor) {
1099 match child.kind() {
1100 "quoted_attribute_value" => {
1101 let mut inner_cursor = child.walk();
1103 for inner in child.children(&mut inner_cursor) {
1104 if inner.kind() == "attribute_value" {
1105 return Some(source[inner.start_byte()..inner.end_byte()].to_string());
1106 }
1107 }
1108 return Some(String::new());
1110 }
1111 "attribute_value" => {
1112 return Some(source[child.start_byte()..child.end_byte()].to_string());
1113 }
1114 _ => {}
1115 }
1116 }
1117 None
1118 }
1119
1120 fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
1123 if let Some(val) = value {
1124 if let Some(expr_str) = Self::extract_single_handlebars(val) {
1125 return Some(
1126 self.condition_parser
1127 .parse(&expr_str)
1128 .unwrap_or_else(|_| ConditionExpr::identifier(&expr_str)),
1129 );
1130 }
1131 }
1132
1133 None
1134 }
1135
1136 fn process_boolean_attribute(
1137 &mut self,
1138 name: &str,
1139 value: Option<&str>,
1140 fragments: &mut Vec<WebUIFragment>,
1141 ) -> Result<bool> {
1142 if let Some(condition) = self.parse_boolean_condition(value) {
1143 self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
1144 return Ok(true);
1145 }
1146 Ok(false)
1148 }
1149
1150 fn process_dynamic_attribute(
1152 &mut self,
1153 name: &str,
1154 value: &str,
1155 fragments: &mut Vec<WebUIFragment>,
1156 ) -> Result<()> {
1157 if let Some(signal_name) = Self::extract_single_handlebars(value) {
1158 self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
1160 } else {
1161 let template_id = self.id_counter.next_id("attr");
1163 let parsed = self.handlebars_parser.parse(value)?;
1164
1165 self.fragment_records
1166 .insert(template_id.clone(), FragmentList { fragments: parsed });
1167
1168 self.add_fragment(
1169 WebUIFragment::attribute_template(name, template_id),
1170 fragments,
1171 );
1172 }
1173 Ok(())
1174 }
1175
1176 fn process_head_element(
1178 &mut self,
1179 node: Node,
1180 source: &str,
1181 fragments: &mut Vec<WebUIFragment>,
1182 ) -> Result<()> {
1183 self.add_raw_fragment("<head>");
1184 self.process_content_children(node, source, fragments)?;
1185 self.flush_raw_buffer(fragments);
1186 fragments.push(WebUIFragment::signal("head_end", true));
1187 self.add_raw_fragment("</head>");
1188 Ok(())
1189 }
1190
1191 fn process_body_element(
1193 &mut self,
1194 node: Node,
1195 source: &str,
1196 fragments: &mut Vec<WebUIFragment>,
1197 ) -> Result<()> {
1198 self.add_raw_fragment("<body>");
1199 self.flush_raw_buffer(fragments);
1200 fragments.push(WebUIFragment::signal("body_start", true));
1201 self.process_content_children(node, source, fragments)?;
1202 self.flush_raw_buffer(fragments);
1203 fragments.push(WebUIFragment::signal("body_end", true));
1204 self.add_raw_fragment("</body>");
1205 Ok(())
1206 }
1207
1208 fn process_comment(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
1213 let inner = content
1215 .strip_prefix("<!--")
1216 .and_then(|s| s.strip_suffix("-->"))
1217 .unwrap_or("");
1218
1219 let trimmed = inner.trim();
1220
1221 if !trimmed.contains("{{") {
1223 self.add_raw_fragment(content);
1224 return Ok(());
1225 }
1226
1227 let parsed = self.handlebars_parser.parse(trimmed)?;
1229
1230 let all_signals = parsed
1234 .iter()
1235 .all(|f| matches!(f.fragment.as_ref(), Some(Fragment::Signal(_))));
1236
1237 if all_signals && !parsed.is_empty() {
1238 for fragment in parsed {
1239 self.add_fragment(fragment, fragments);
1240 }
1241 } else {
1242 self.add_raw_fragment(content);
1244 }
1245
1246 Ok(())
1247 }
1248
1249 fn process_for_directive(
1251 &mut self,
1252 node: Node,
1253 source: &str,
1254 fragments: &mut Vec<WebUIFragment>,
1255 ) -> Result<()> {
1256 let each = self
1258 .get_element_attribute(node, "each", source)?
1259 .ok_or_else(|| {
1260 ParserError::Directive("Missing 'each' attribute on <for>".to_string())
1261 })?;
1262
1263 let parts: Vec<&str> = each.split_whitespace().collect();
1265 if parts.len() != 3 || parts[1] != "in" {
1266 return Err(ParserError::Directive(format!(
1267 "Invalid for each: {}",
1268 each
1269 )));
1270 }
1271
1272 let allowed = |s: &str| {
1274 s.chars()
1275 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1276 };
1277 if !allowed(parts[0]) || !allowed(parts[2]) {
1278 return Err(ParserError::Directive(format!(
1279 "Invalid identifier in for each: {}",
1280 each
1281 )));
1282 }
1283
1284 let item = parts[0];
1285 let collection = parts[2];
1286
1287 let fragment_id = self
1289 .get_element_attribute(node, "template", source)?
1290 .unwrap_or_else(|| self.id_counter.next_id("for"));
1291 let mut for_fragment: Vec<WebUIFragment> = Vec::new();
1292
1293 let mut temp_buffer = String::new();
1295 std::mem::swap(&mut self.raw_buffer, &mut temp_buffer);
1296
1297 self.process_content_children(node, source, &mut for_fragment)?;
1299
1300 self.flush_raw_buffer(&mut for_fragment);
1302
1303 std::mem::swap(&mut self.raw_buffer, &mut temp_buffer);
1305
1306 if for_fragment.is_empty() {
1308 return Ok(());
1309 }
1310
1311 self.fragment_records.insert(
1313 fragment_id.clone(),
1314 FragmentList {
1315 fragments: for_fragment,
1316 },
1317 );
1318
1319 self.add_for_fragment(
1321 item.to_string(),
1322 collection.to_string(),
1323 fragment_id,
1324 fragments,
1325 );
1326
1327 Ok(())
1328 }
1329
1330 fn process_if_directive(
1332 &mut self,
1333 node: Node,
1334 source: &str,
1335 fragments: &mut Vec<WebUIFragment>,
1336 ) -> Result<()> {
1337 let condition_str = self
1339 .get_element_attribute(node, "condition", source)?
1340 .ok_or_else(|| {
1341 ParserError::Directive("Missing 'condition' attribute on <if>".to_string())
1342 })?;
1343
1344 let condition_result = self.condition_parser.parse(&condition_str);
1346 let condition = match condition_result {
1347 Ok(cond) => cond,
1348 Err(_) => {
1349 return Err(ParserError::Directive(format!(
1350 "Invalid condition expression: {}",
1351 condition_str
1352 )))
1353 }
1354 };
1355
1356 let fragment_id = self.id_counter.next_id("if");
1358
1359 self.flush_raw_buffer(fragments);
1361
1362 let mut if_fragment: Vec<WebUIFragment> = Vec::new();
1364
1365 let parent_buffer = std::mem::take(&mut self.raw_buffer);
1367
1368 self.process_content_children(node, source, &mut if_fragment)?;
1370
1371 self.flush_raw_buffer(&mut if_fragment);
1373
1374 self.fragment_records.insert(
1376 fragment_id.clone(),
1377 FragmentList {
1378 fragments: if_fragment,
1379 },
1380 );
1381
1382 self.raw_buffer = parent_buffer;
1384
1385 self.add_if_fragment(condition, fragment_id, fragments);
1387
1388 Ok(())
1389 }
1390
1391 fn process_route_directive(
1397 &mut self,
1398 node: Node,
1399 source: &str,
1400 fragments: &mut Vec<WebUIFragment>,
1401 ) -> Result<()> {
1402 let path = self
1404 .get_element_attribute(node, "path", source)?
1405 .unwrap_or_default();
1406
1407 let component = self
1408 .get_element_attribute(node, "component", source)?
1409 .unwrap_or_default();
1410
1411 let exact = self.has_element_attribute(node, "exact", source)?;
1412
1413 let query = self
1414 .get_element_attribute(node, "query", source)?
1415 .unwrap_or_default();
1416
1417 let attrs = route_parser::RouteAttributes {
1418 path: path.clone(),
1419 component: component.clone(),
1420 exact,
1421 query,
1422 };
1423
1424 route_parser::validate_attributes(&attrs)?;
1426
1427 route_parser::extract_params(&path)?;
1429
1430 self.ensure_route_component_parsed(&component)?;
1432
1433 let children = self.parse_child_routes(node, source)?;
1435
1436 self.flush_raw_buffer(fragments);
1438
1439 let route_fragment =
1441 route_parser::build_route_fragment(&attrs, component.clone(), children);
1442
1443 fragments.push(WebUIFragment::route_from(route_fragment));
1445
1446 Ok(())
1447 }
1448
1449 fn parse_child_routes(&mut self, node: Node, source: &str) -> Result<Vec<WebUiFragmentRoute>> {
1451 let mut children = Vec::new();
1452 let mut cursor = node.walk();
1453
1454 for child in node.named_children(&mut cursor) {
1455 if child.kind() == "element" {
1456 if let Ok(tag) = self.get_element_tag_name(child, source) {
1457 if tag == "route" {
1458 let child_route = self.parse_route_as_fragment(child, source)?;
1459 children.push(child_route);
1460 }
1461 }
1462 }
1463 }
1464
1465 Ok(children)
1466 }
1467
1468 fn parse_route_as_fragment(&mut self, node: Node, source: &str) -> Result<WebUiFragmentRoute> {
1470 let path = self
1471 .get_element_attribute(node, "path", source)?
1472 .unwrap_or_default();
1473 let component = self
1474 .get_element_attribute(node, "component", source)?
1475 .unwrap_or_default();
1476 let exact = self.has_element_attribute(node, "exact", source)?;
1477
1478 let query = self
1479 .get_element_attribute(node, "query", source)?
1480 .unwrap_or_default();
1481
1482 let attrs = route_parser::RouteAttributes {
1483 path: path.clone(),
1484 component: component.clone(),
1485 exact,
1486 query,
1487 };
1488
1489 route_parser::validate_attributes(&attrs)?;
1490 route_parser::extract_params(&path)?;
1491
1492 self.ensure_route_component_parsed(&component)?;
1494
1495 let children = self.parse_child_routes(node, source)?;
1497
1498 Ok(route_parser::build_route_fragment(
1499 &attrs, component, children,
1500 ))
1501 }
1502
1503 fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
1505 if component.is_empty()
1506 || !self.component_registry.contains(component)
1507 || self.fragment_records.contains_key(component)
1508 {
1509 return Ok(());
1510 }
1511
1512 let component_data = self
1513 .component_registry
1514 .get(component)
1515 .ok_or_else(|| {
1516 crate::error::ParserError::Directive(format!("Component not found: {component}"))
1517 })?
1518 .clone();
1519
1520 self.token_store
1521 .extend(component_data.css_tokens.iter().cloned());
1522
1523 let processed = self.build_component_template(
1524 component,
1525 &component_data.html_content,
1526 component_data.css_content.as_deref(),
1527 );
1528
1529 if let Some(ref mut p) = self.plugin {
1530 p.register_component_template(component, &component_data, &processed)?;
1531 }
1532
1533 let saved_buffer = std::mem::take(&mut self.raw_buffer);
1534 self.parse(component, &processed)?;
1535 self.raw_buffer = saved_buffer;
1536
1537 Ok(())
1538 }
1539
1540 const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
1542 const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-", "aria-"];
1544
1545 fn is_skipped_attribute(name: &str) -> bool {
1546 Self::SKIPPED_ATTRIBUTES.contains(&name)
1547 || Self::SKIPPED_ATTRIBUTE_PREFIXES
1548 .iter()
1549 .any(|prefix| name.starts_with(prefix))
1550 }
1551
1552 fn is_blocked_complex_property(name: &str) -> bool {
1555 matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
1556 }
1557
1558 fn process_component_directive(
1559 &mut self,
1560 node: Node,
1561 source: &str,
1562 fragments: &mut Vec<WebUIFragment>,
1563 tag_name: &str,
1564 ) -> Result<()> {
1565 let tag_node = self.find_tag_node(node);
1566 let is_self_closing = tag_node
1567 .map(|n| n.kind() == "self_closing_tag")
1568 .unwrap_or(false);
1569
1570 self.add_raw_fragment(&format!("<{}", tag_name));
1572
1573 if let Some(tag_node) = tag_node {
1575 let binding_count = self.process_tag_attributes(tag_node, source, fragments, true)?;
1576 if let Some(ref mut p) = self.plugin {
1577 if let Some(data) = p.finish_element(binding_count) {
1578 self.add_fragment(WebUIFragment::plugin(data), fragments);
1579 }
1580 }
1581 }
1582
1583 if is_self_closing {
1584 let tag_text = tag_node
1585 .map(|n| &source[n.start_byte()..n.end_byte()])
1586 .unwrap_or("");
1587 if tag_text.ends_with("/>") {
1588 self.add_raw_fragment("/>");
1589 } else {
1590 self.add_raw_fragment(">");
1591 }
1592 } else {
1593 self.add_raw_fragment(">");
1594 }
1595
1596 self.flush_raw_buffer(fragments);
1598
1599 let (html_content, css_content, css_tokens) = {
1601 let component = self.component_registry.get(tag_name).ok_or_else(|| {
1602 ParserError::Directive(format!("Component not found: {}", tag_name))
1603 })?;
1604 (
1605 component.html_content.clone(),
1606 component.css_content.clone(),
1607 component.css_tokens.clone(),
1608 )
1609 };
1610
1611 self.token_store.extend(css_tokens);
1613
1614 if !self.fragment_records.contains_key(tag_name) {
1616 let component_data = self
1617 .component_registry
1618 .get(tag_name)
1619 .ok_or_else(|| {
1620 ParserError::Directive(format!("Component not found: {}", tag_name))
1621 })?
1622 .clone();
1623 let processed =
1624 self.build_component_template(tag_name, &html_content, css_content.as_deref());
1625
1626 if let Some(ref mut p) = self.plugin {
1628 p.register_component_template(tag_name, &component_data, &processed)?;
1629 }
1630
1631 self.parse(tag_name, &processed)?;
1632 }
1633
1634 fragments.push(WebUIFragment::component(tag_name.to_string()));
1636
1637 if !is_self_closing {
1639 self.process_content_children(node, source, fragments)?;
1640 }
1641
1642 if !is_self_closing {
1644 self.add_raw_fragment(&format!("</{}>", tag_name));
1645 }
1646
1647 Ok(())
1648 }
1649
1650 fn build_component_template(
1655 &mut self,
1656 tag_name: &str,
1657 html: &str,
1658 css_content: Option<&str>,
1659 ) -> String {
1660 let adopted_specifier = match self.css_strategy {
1661 CssStrategy::Module if css_content.is_some() => Some(tag_name.to_string()),
1662 _ => None,
1663 };
1664 let css_injection = match self.css_strategy {
1665 CssStrategy::Link => {
1666 if css_content.is_some() && self.dom_strategy == DomStrategy::Shadow {
1669 Some(format!(
1670 "<link rel=\"stylesheet\" href=\"/{tag_name}.css\">"
1671 ))
1672 } else {
1673 None
1674 }
1675 }
1676 CssStrategy::Style => css_content.map(|css| format!("<style>{}</style>", css.trim())),
1677 CssStrategy::Module => None,
1678 };
1679
1680 self.process_component_template(
1681 html,
1682 css_injection.as_deref(),
1683 adopted_specifier.as_deref(),
1684 )
1685 }
1686
1687 fn process_component_template(
1698 &mut self,
1699 html: &str,
1700 css_snippet: Option<&str>,
1701 adopted_specifier: Option<&str>,
1702 ) -> String {
1703 let trimmed = html.trim();
1704 let snippet = css_snippet.unwrap_or_default();
1705
1706 let has_template = trimmed.starts_with("<template");
1708 let inner = if has_template {
1709 let stripped = self.strip_runtime_attrs_from_template(trimmed);
1710 if let Some(open_end) = stripped.find('>') {
1711 let inner_start = open_end + 1;
1712 let inner_end = stripped.rfind("</template>").unwrap_or(stripped.len());
1713 if inner_start < inner_end {
1714 stripped[inner_start..inner_end].to_string()
1715 } else {
1716 String::new()
1717 }
1718 } else {
1719 trimmed.to_string()
1720 }
1721 } else {
1722 trimmed.to_string()
1723 };
1724
1725 match self.dom_strategy {
1726 DomStrategy::Shadow => {
1727 let adopted_attr = adopted_specifier.map(|spec| {
1729 let mut s = String::with_capacity(35 + spec.len());
1730 s.push_str(" shadowrootadoptedstylesheets=\"");
1731 s.push_str(spec);
1732 s.push('"');
1733 s
1734 });
1735 let adopted_ref = adopted_attr.as_deref().unwrap_or_default();
1736
1737 let mut result =
1738 String::with_capacity(45 + adopted_ref.len() + snippet.len() + inner.len());
1739 result.push_str("<template shadowrootmode=\"open\"");
1740 result.push_str(adopted_ref);
1741 result.push('>');
1742 result.push_str(snippet);
1743 result.push_str(&inner);
1744 result.push_str("</template>");
1745 result
1746 }
1747 DomStrategy::Light => {
1748 if snippet.is_empty() && adopted_specifier.is_none() {
1750 return inner;
1751 }
1752 let mut result = String::with_capacity(snippet.len() + inner.len() + 16);
1753 result.push_str(snippet);
1754 result.push_str(&inner);
1755 result
1756 }
1757 }
1758 }
1759
1760 fn strip_runtime_attrs_from_template(&mut self, html: &str) -> String {
1763 let tree = match self.parser.parse(html, None) {
1764 Some(t) => t,
1765 None => return html.to_string(),
1766 };
1767
1768 let root = tree.root_node();
1770 let start_tag = Self::find_first_node(root, "start_tag");
1771 let Some(tag) = start_tag else {
1772 return html.to_string();
1773 };
1774
1775 let mut removals: Vec<(usize, usize)> = Vec::new();
1777 let mut cursor = tag.walk();
1778 for child in tag.named_children(&mut cursor) {
1779 if child.kind() == "attribute" {
1780 let name_node = child.child(0);
1781 if let Some(name) = name_node {
1782 let attr_name = &html[name.start_byte()..name.end_byte()];
1783 if attr_name.starts_with('@')
1784 || attr_name.starts_with(':')
1785 || attr_name.starts_with('?')
1786 {
1787 let mut start = child.start_byte();
1789 while start > 0 && html.as_bytes()[start - 1] == b' ' {
1790 start -= 1;
1791 }
1792 removals.push((start, child.end_byte()));
1793 }
1794 }
1795 }
1796 }
1797
1798 if removals.is_empty() {
1799 return html.to_string();
1800 }
1801
1802 let mut result = String::with_capacity(html.len());
1804 let mut pos = 0;
1805 for (start, end) in &removals {
1806 result.push_str(&html[pos..*start]);
1807 pos = *end;
1808 }
1809 result.push_str(&html[pos..]);
1810 result
1811 }
1812
1813 fn find_first_node<'a>(root: Node<'a>, kind: &str) -> Option<Node<'a>> {
1815 let mut stack = vec![root];
1816 while let Some(node) = stack.pop() {
1817 if node.kind() == kind {
1818 return Some(node);
1819 }
1820 let mut cursor = node.walk();
1821 #[allow(clippy::needless_collect)]
1824 let children: Vec<_> = node.children(&mut cursor).collect();
1825 for child in children.into_iter().rev() {
1826 stack.push(child);
1827 }
1828 }
1829 None
1830 }
1831}
1832
1833#[cfg(test)]
1834mod tests {
1835 use webui_test_utils::*;
1836
1837 use super::*;
1838
1839 #[test]
1840 fn test_parse_signal() {
1841 let mut parser = HtmlParser::new();
1842 let html = "Hello, {{name}}!";
1843 let result = parser.parse("test.html", html);
1844
1845 assert!(result.is_ok());
1846 let fragment_records = parser.into_fragment_records();
1847
1848 assert_stream!(
1849 fragment_records,
1850 "test.html",
1851 [raw("Hello, "), signal("name"), raw("!"),]
1852 );
1853 }
1854
1855 #[test]
1856 fn test_parse_raw_signal() {
1857 let mut parser = HtmlParser::new();
1858 let html = "Hello, {{{html_content}}}!";
1859 let result = parser.parse("test.html", html);
1860
1861 assert!(result.is_ok());
1862 let fragment_records = parser.into_fragment_records();
1863
1864 assert_stream!(
1865 fragment_records,
1866 "test.html",
1867 [raw("Hello, "), signal_raw("html_content"), raw("!"),]
1868 );
1869 }
1870
1871 #[test]
1872 fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
1873 let mut parser = HtmlParser::new();
1874 let html = "<nav>{{sectionName}} > {{topicName}}</nav>";
1875 let result = parser.parse("test.html", html);
1876
1877 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1878 let fragment_records = parser.into_fragment_records();
1879
1880 assert_stream!(
1881 fragment_records,
1882 "test.html",
1883 [
1884 raw("<nav>"),
1885 signal("sectionName"),
1886 raw(" > "),
1887 signal("topicName"),
1888 raw("</nav>"),
1889 ]
1890 );
1891 }
1892
1893 #[test]
1894 fn test_parse_for_directive() {
1895 let mut parser = HtmlParser::new();
1896 let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
1897
1898 let result = parser.parse("test.html", html);
1899 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1900 let fragment_records = parser.into_fragment_records();
1901 println!("Fragment records: {:#?}", fragment_records);
1902
1903 assert_stream!(
1904 fragment_records,
1905 "test.html",
1906 [for_loop("item", "items", "for-1"),]
1907 );
1908
1909 assert_stream!(
1911 fragment_records,
1912 "for-1",
1913 [
1914 raw("<div class=\"item\">"),
1915 signal("item.name"),
1916 raw("</div>"),
1917 ]
1918 );
1919 }
1920
1921 #[test]
1922 fn test_parse_if_directive() {
1923 let mut parser = HtmlParser::new();
1924 let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
1925
1926 let result = parser.parse("test.html", html);
1927
1928 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1929 let fragment_records = parser.into_fragment_records();
1930 println!("Fragment records: {:#?}", fragment_records);
1931
1932 assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
1933
1934 assert_stream!(
1936 fragment_records,
1937 "if-1",
1938 [
1939 raw("<div>Welcome back, "),
1940 signal("username"),
1941 raw("!</div>"),
1942 ]
1943 );
1944 }
1945
1946 #[test]
1947 fn test_component_directive() {
1948 let mut parser = HtmlParser::new();
1949 parser.set_dom_strategy(DomStrategy::Light);
1950 parser
1951 .component_registry
1952 .register_component(
1953 "my-component",
1954 "<div>My Component</div>",
1955 Some("div { color: blue; }"),
1956 )
1957 .expect("Failed to register component");
1958
1959 let result = parser.parse("test.html", "<my-component></my-component>");
1960 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1961 let records = parser.into_fragment_records();
1962
1963 assert_stream!(
1964 records,
1965 "test.html",
1966 [
1967 raw("<my-component>"),
1968 component("my-component"),
1969 raw("</my-component>"),
1970 ]
1971 );
1972
1973 let comp = &records["my-component"].fragments;
1975 assert_eq!(comp.len(), 1);
1976 assert!(
1977 matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
1978 !raw.value.contains("<template shadowrootmode") && raw.value.contains("<div>My Component</div>"))
1979 );
1980 }
1981
1982 #[test]
1983 fn test_component_directive_with_slots() {
1984 let mut parser = HtmlParser::new();
1985 parser
1986 .component_registry
1987 .register_component(
1988 "my-component",
1989 "<div>My Component</div>",
1990 Some("div { color: blue; }"),
1991 )
1992 .expect("Failed to register component");
1993
1994 let result = parser.parse(
1995 "test.html",
1996 "Hello<my-component><p>World</p></my-component>",
1997 );
1998 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1999 let records = parser.into_fragment_records();
2000 let fragments = &records["test.html"].fragments;
2001
2002 assert!(fragments.len() >= 3);
2004 assert!(
2006 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component>"))
2007 );
2008 assert!(fragments.iter().any(|f| matches!(
2010 f.fragment.as_ref(),
2011 Some(Fragment::Component(c)) if c.fragment_id == "my-component"
2012 )));
2013 let last = fragments.last().unwrap();
2015 assert!(
2016 matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
2017 );
2018 }
2019
2020 #[test]
2021 fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
2022 let mut parser = HtmlParser::new();
2027 parser.set_dom_strategy(DomStrategy::Shadow);
2028
2029 parser
2030 .component_registry
2031 .register_component(
2032 "app-shell",
2033 r#"<template shadowrootmode="open">
2034 <my-navbar></my-navbar>
2035 <main><outlet /></main>
2036 <cart-panel></cart-panel>
2037 <my-footer></my-footer>
2038 </template>"#,
2039 Some(":host{display:flex}"),
2040 )
2041 .expect("register app-shell");
2042 parser
2043 .component_registry
2044 .register_component("my-navbar", "<nav>Nav</nav>", Some("nav{color:red}"))
2045 .expect("register my-navbar");
2046 parser
2047 .component_registry
2048 .register_component(
2049 "cart-panel",
2050 "<aside>Cart</aside>",
2051 Some("aside{color:green}"),
2052 )
2053 .expect("register cart-panel");
2054 parser
2055 .component_registry
2056 .register_component(
2057 "my-footer",
2058 "<footer>Footer</footer>",
2059 Some("footer{color:blue}"),
2060 )
2061 .expect("register my-footer");
2062
2063 parser
2064 .parse("index.html", "<app-shell></app-shell>")
2065 .expect("parse index.html");
2066 let records = parser.into_fragment_records();
2067
2068 let app_frags = &records["app-shell"].fragments;
2070 let component_ids: Vec<&str> = app_frags
2071 .iter()
2072 .filter_map(|f| match f.fragment.as_ref() {
2073 Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
2074 _ => None,
2075 })
2076 .collect();
2077
2078 assert!(
2079 component_ids.contains(&"my-navbar"),
2080 "app-shell fragments should contain my-navbar: {component_ids:?}"
2081 );
2082 assert!(
2083 component_ids.contains(&"cart-panel"),
2084 "app-shell fragments should contain cart-panel: {component_ids:?}"
2085 );
2086 assert!(
2087 component_ids.contains(&"my-footer"),
2088 "app-shell fragments should contain my-footer: {component_ids:?}"
2089 );
2090 }
2091
2092 #[test]
2095 fn test_component_no_double_wrap_template() {
2096 let mut parser = HtmlParser::new();
2097 parser.set_dom_strategy(DomStrategy::Light);
2098 parser
2099 .component_registry
2100 .register_component(
2101 "custom-element",
2102 r#"<template foo="bar"><slot></slot></template>"#,
2103 None,
2104 )
2105 .expect("register");
2106 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2107 assert!(result.is_ok());
2108 let records = parser.into_fragment_records();
2109
2110 assert_fragments!(
2111 records["index.html"].fragments,
2112 [
2113 raw("<custom-element>"),
2114 component("custom-element"),
2115 raw("Hello</custom-element>"),
2116 ]
2117 );
2118
2119 assert_stream!(records, "custom-element", [raw("<slot></slot>"),]);
2120 }
2121
2122 #[test]
2123 fn test_component_styled_no_double_wrap() {
2124 let mut parser = HtmlParser::new();
2125 parser.set_dom_strategy(DomStrategy::Light);
2126 parser
2127 .component_registry
2128 .register_component(
2129 "custom-element",
2130 r#"<template foo="bar"><slot></slot></template>"#,
2131 Some("div { color: red; }"),
2132 )
2133 .expect("register");
2134 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2135 assert!(result.is_ok());
2136 let records = parser.into_fragment_records();
2137
2138 assert_stream!(records, "custom-element", [raw(r#"<slot></slot>"#),]);
2139 }
2140
2141 #[test]
2142 fn test_component_strip_runtime_attrs() {
2143 let mut parser = HtmlParser::new();
2144 parser.set_dom_strategy(DomStrategy::Light);
2145 parser
2146 .component_registry
2147 .register_component(
2148 "custom-element",
2149 r#"<template @click={foo} :bar="baz" ?bool="true"><slot></slot></template>"#,
2150 None,
2151 )
2152 .expect("register");
2153 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2154 assert!(result.is_ok());
2155 let records = parser.into_fragment_records();
2156
2157 assert_stream!(records, "custom-element", [raw("<slot></slot>"),]);
2158 }
2159
2160 #[test]
2161 fn test_component_with_slots_and_attrs() {
2162 let mut parser = HtmlParser::new();
2163 parser
2164 .component_registry
2165 .register_component("custom-element", "<slot></slot>", None)
2166 .expect("register");
2167 let result = parser.parse(
2168 "index.html",
2169 r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
2170 );
2171 assert!(result.is_ok());
2172 let records = parser.into_fragment_records();
2173 assert_fragments!(
2174 records["index.html"].fragments,
2175 [
2176 raw("<custom-element"),
2177 attr_raw_start("appearance", "subtle"),
2178 raw(">"),
2179 component("custom-element"),
2180 raw("Hello World</custom-element>"),
2181 ]
2182 );
2183 }
2184
2185 #[test]
2186 fn test_component_legacy_no_styles() {
2187 let mut parser = HtmlParser::new();
2188 parser.set_dom_strategy(DomStrategy::Light);
2189 parser
2190 .component_registry
2191 .register_component("custom-element", "<div>Custom Element</div>", None)
2192 .expect("register");
2193 let result = parser.parse("index.html", "<custom-element></custom-element>");
2194 assert!(result.is_ok());
2195 let records = parser.into_fragment_records();
2196
2197 assert_fragments!(
2198 records["index.html"].fragments,
2199 [
2200 raw("<custom-element>"),
2201 component("custom-element"),
2202 raw("</custom-element>"),
2203 ]
2204 );
2205
2206 assert_stream!(
2207 records,
2208 "custom-element",
2209 [raw("<div>Custom Element</div>"),]
2210 );
2211 }
2212
2213 #[test]
2214 fn test_component_self_closing() {
2215 let mut parser = HtmlParser::new();
2216 parser
2217 .component_registry
2218 .register_component("custom-widget", "<div>Widget Content</div>", None)
2219 .expect("register");
2220 let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
2221 assert!(result.is_ok());
2222 let records = parser.into_fragment_records();
2223
2224 assert_fragments!(
2225 records["index.html"].fragments,
2226 [
2227 raw("<custom-widget"),
2228 attr_start("config", "settings"),
2229 raw("/>"),
2230 component("custom-widget"),
2231 ]
2232 );
2233 }
2234
2235 #[test]
2236 fn test_component_nested_self_closing_in_slot() {
2237 let mut parser = HtmlParser::new();
2238 parser.set_dom_strategy(DomStrategy::Light);
2239 parser
2240 .component_registry
2241 .register_component("custom-icon", "<svg><slot></slot></svg>", None)
2242 .expect("register");
2243 let result = parser.parse(
2244 "index.html",
2245 r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
2246 );
2247 assert!(result.is_ok());
2248 let records = parser.into_fragment_records();
2249
2250 assert_fragments!(
2251 records["index.html"].fragments,
2252 [
2253 raw("<custom-icon>"),
2254 component("custom-icon"),
2255 raw("<use"),
2256 attr_template("href", "attr-1"),
2257 raw("/></custom-icon>"),
2258 ]
2259 );
2260
2261 assert_stream!(records, "custom-icon", [raw("<svg><slot></slot></svg>"),]);
2262 }
2263
2264 #[test]
2265 fn test_component_leading_boolean_attr_start() {
2266 let mut parser = HtmlParser::new();
2267 parser
2268 .component_registry
2269 .register_component("custom-element", "<slot></slot>", None)
2270 .expect("register");
2271 let result = parser.parse(
2272 "index.html",
2273 r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
2274 );
2275 assert!(result.is_ok());
2276 let records = parser.into_fragment_records();
2277
2278 assert_fragments!(
2279 records["index.html"].fragments,
2280 [
2281 raw("<custom-element"),
2282 bool_attr_start("disabled", "isDisabled"),
2284 attr_raw("title", "Hello"),
2286 raw(">"),
2287 component("custom-element"),
2288 raw("</custom-element>"),
2289 ]
2290 );
2291 }
2292
2293 #[test]
2294 fn test_component_boolean_predicate_preserves_condition_tree() {
2295 let mut parser = HtmlParser::new();
2296 parser
2297 .component_registry
2298 .register_component("custom-element", "<slot></slot>", None)
2299 .expect("register");
2300 let result = parser.parse(
2301 "index.html",
2302 r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
2303 );
2304 assert!(result.is_ok());
2305
2306 let records = parser.into_fragment_records();
2307 let fragments = &records["index.html"].fragments;
2308 match fragments
2309 .get(1)
2310 .and_then(|fragment| fragment.fragment.as_ref())
2311 {
2312 Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
2313 assert_eq!(attr.name, "disabled");
2314 assert!(attr.attr_start);
2315 match attr
2316 .condition_tree
2317 .as_ref()
2318 .and_then(|condition| condition.expr.as_ref())
2319 {
2320 Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
2321 assert_eq!(pred.left, "page");
2322 assert_eq!(pred.operator, 3);
2323 assert_eq!(pred.right, "'dashboard'");
2324 }
2325 other => panic!("expected predicate condition tree, got {:?}", other),
2326 }
2327 }
2328 other => panic!("expected attribute fragment, got {:?}", other),
2329 }
2330 }
2331
2332 #[test]
2333 fn test_component_meta_link_tags() {
2334 let (fragments, _) = parse_and_get_fragments(
2335 r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
2336 );
2337 assert!(fragments.len() >= 5);
2338 assert!(
2339 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<head><meta charset=\"utf-8\"") && raw.value.contains("<link"))
2340 );
2341 assert!(
2342 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
2343 );
2344 assert!(
2345 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
2346 );
2347 assert!(
2348 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if s.value == "head_end" && s.raw)
2349 );
2350 assert!(
2351 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
2352 );
2353 }
2354
2355 #[test]
2356 fn test_nested_directives() {
2357 let mut parser = HtmlParser::new();
2358 let html = r#"<for each="category in categories">
2359 <if condition="category.hasItems">
2360 <for each="item in category.items">
2361 {{item.title}}
2362 </for>
2363 </if>
2364 </for>"#;
2365
2366 let result = parser.parse("test.html", html);
2367
2368 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2369 let fragment_records = parser.into_fragment_records();
2370
2371 assert_stream!(
2372 fragment_records,
2373 "test.html",
2374 [for_loop("category", "categories", "for-1"),]
2375 );
2376
2377 assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
2378
2379 assert_stream!(
2380 fragment_records,
2381 "if-1",
2382 [for_loop("item", "category.items", "for-2"),]
2383 );
2384
2385 assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
2386 }
2387
2388 #[test]
2389 fn test_complex_directives() {
2390 let mut parser = HtmlParser::new();
2391 let html = r#"<for each="category in categories">
2392 <div class="category">
2393 <h2>{{category.name}}</h2>
2394 <if condition="category.hasItems">
2395 <ul>
2396 <for each="item in category.items">
2397 <li>{{item.title}}</li>
2398 </for>
2399 </ul>
2400 </if>
2401 </div>
2402 </for>"#;
2403
2404 let result = parser.parse("test.html", html);
2405
2406 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2407 let fragment_records = parser.into_fragment_records();
2408
2409 assert_stream!(
2410 fragment_records,
2411 "test.html",
2412 [for_loop("category", "categories", "for-1"),]
2413 );
2414
2415 assert_stream!(
2417 fragment_records,
2418 "for-1",
2419 [
2420 raw("<div class=\"category\"><h2>"),
2421 signal("category.name"),
2422 raw("</h2>"),
2423 if_cond("if-1"),
2424 raw("</div>"),
2425 ]
2426 );
2427
2428 assert_stream!(
2430 fragment_records,
2431 "if-1",
2432 [
2433 raw("<ul>"),
2434 for_loop("item", "category.items", "for-2"),
2435 raw("</ul>"),
2436 ]
2437 );
2438
2439 assert_stream!(
2441 fragment_records,
2442 "for-2",
2443 [raw("<li>"), signal("item.title"), raw("</li>"),]
2444 );
2445 }
2446
2447 fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
2451 let mut parser = HtmlParser::new();
2452 let result = parser.parse("index.html", html);
2453 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2454 let records = parser.into_fragment_records();
2455 let fragments = records
2456 .get("index.html")
2457 .expect("Failed to get index.html fragment")
2458 .fragments
2459 .clone();
2460 (fragments, records)
2461 }
2462
2463 fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
2465 let mut parser = HtmlParser::new();
2466 parser
2467 .component_registry
2468 .register_component(tag, "<div></div>", None)
2469 .expect("register");
2470 let result = parser.parse("index.html", html);
2471 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2472 let records = parser.into_fragment_records();
2473 let fragments = records
2474 .get("index.html")
2475 .expect("Failed to get index.html fragment")
2476 .fragments
2477 .clone();
2478 (fragments, records)
2479 }
2480
2481 #[test]
2482 fn test_attribute_handlebars_in_href() {
2483 let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
2485 assert_fragments!(
2486 fragments,
2487 [
2488 raw("<a"),
2489 attr("href", "url"),
2490 raw(">"),
2491 signal("name"),
2492 raw("</a>"),
2493 ]
2494 );
2495 }
2496
2497 #[test]
2498 fn test_attribute_boolean_with_handlebars() {
2499 let (fragments, _) =
2501 parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
2502 assert_fragments!(
2503 fragments,
2504 [
2505 raw("<button"),
2506 bool_attr("disabled", "isDisabled"),
2507 raw(">Click</button>"),
2508 ]
2509 );
2510 }
2511
2512 #[test]
2513 fn test_attribute_multiple_boolean() {
2514 let (fragments, _) =
2517 parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
2518
2519 assert_fragments!(
2520 fragments,
2521 [
2522 raw("<input"),
2523 bool_attr("checked", "isChecked"),
2524 bool_attr("disabled", "isDisabled"),
2525 raw("/>"),
2526 ]
2527 );
2528 }
2529
2530 #[test]
2531 fn test_attribute_boolean_and_regular_together() {
2532 let (fragments, _) = parse_and_get_fragments(
2535 r#"<input ?checked="{{isChecked}}" type="checkbox">Hi</input>"#,
2536 );
2537
2538 assert_fragments!(
2539 fragments,
2540 [
2541 raw("<input"),
2542 bool_attr("checked", "isChecked"),
2543 raw(" type=\"checkbox\">Hi</input>"),
2544 ]
2545 );
2546 }
2547
2548 #[test]
2549 fn test_attribute_boolean_sandwiched() {
2550 let (fragments, _) = parse_and_get_fragments(
2553 r#"<input version={{edition}} ?checked="{{isChecked}}" type="checkbox">Hi</input>"#,
2554 );
2555
2556 assert_fragments!(
2557 fragments,
2558 [
2559 raw("<input"),
2560 attr("version", "edition"),
2561 bool_attr("checked", "isChecked"),
2562 raw(" type=\"checkbox\">Hi</input>"),
2563 ]
2564 );
2565 }
2566
2567 #[test]
2568 fn test_attribute_boolean_ending() {
2569 let (fragments, _) = parse_and_get_fragments(
2572 r#"<input version={{edition}} ?checked="{{isChecked}}">Hi</input>"#,
2573 );
2574
2575 assert_fragments!(
2576 fragments,
2577 [
2578 raw("<input"),
2579 attr("version", "edition"),
2580 bool_attr("checked", "isChecked"),
2581 raw(">Hi</input>"),
2582 ]
2583 );
2584 }
2585
2586 #[test]
2587 fn test_attribute_boolean_dotted_path() {
2588 let (fragments, _) =
2591 parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
2592
2593 assert_fragments!(
2594 fragments,
2595 [
2596 raw("<div"),
2597 bool_attr("checked", "layout.isPinned"),
2598 raw(">Content</div>"),
2599 ]
2600 );
2601 }
2602
2603 #[test]
2604 fn test_attribute_colon_prefixed_complex() {
2605 let (fragments, _) = parse_with_component(
2608 "my-component",
2609 r#"<my-component :config="{{settings}}"></my-component>"#,
2610 );
2611
2612 assert_fragments!(
2613 fragments,
2614 [
2615 raw("<my-component"),
2616 attr_complex_start(":config", "settings"),
2617 raw(">"),
2618 component("my-component"),
2619 raw("</my-component>"),
2620 ]
2621 );
2622 }
2623
2624 #[test]
2625 fn test_attribute_multiple_colon_prefixed() {
2626 let (fragments, _) = parse_with_component(
2629 "my-component",
2630 r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
2631 );
2632
2633 assert_fragments!(
2634 fragments,
2635 [
2636 raw("<my-component"),
2637 attr_complex_start(":prop1", "val1"),
2638 attr_complex(":prop2", "val2"),
2639 raw(">"),
2640 component("my-component"),
2641 raw("</my-component>"),
2642 ]
2643 );
2644 }
2645
2646 #[test]
2647 fn test_blocked_complex_property_innerhtml() {
2648 let mut parser = HtmlParser::new();
2649 let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
2650 assert!(
2651 result.is_err(),
2652 "Expected error for :innerHTML on native element"
2653 );
2654 let err = result.unwrap_err().to_string();
2655 assert!(
2656 err.contains("only allowed on custom elements"),
2657 "Error: {err}"
2658 );
2659 }
2660
2661 #[test]
2662 fn test_blocked_complex_on_native_element() {
2663 let mut parser = HtmlParser::new();
2664 let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
2665 assert!(
2666 result.is_err(),
2667 "Expected error for :data on native element"
2668 );
2669 let err = result.unwrap_err().to_string();
2670 assert!(
2671 err.contains("only allowed on custom elements"),
2672 "Error: {err}"
2673 );
2674 }
2675
2676 #[test]
2677 fn test_blocked_complex_property_on_component() {
2678 let mut parser = HtmlParser::new();
2679 parser
2680 .component_registry
2681 .register_component("my-widget", "<div></div>", None)
2682 .expect("register");
2683 let result = parser.parse(
2684 "index.html",
2685 r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
2686 );
2687 assert!(
2688 result.is_err(),
2689 "Expected error for :innerHTML on component"
2690 );
2691 let err = result.unwrap_err().to_string();
2692 assert!(err.contains("HTML injection"), "Error: {err}");
2693 }
2694
2695 #[test]
2696 fn test_allowed_complex_property() {
2697 let (fragments, _) = parse_with_component(
2699 "my-component",
2700 r#"<my-component :config="{{settings}}"></my-component>"#,
2701 );
2702 assert_fragments!(
2703 fragments,
2704 [
2705 raw("<my-component"),
2706 attr_complex_start(":config", "settings"),
2707 raw(">"),
2708 component("my-component"),
2709 raw("</my-component>"),
2710 ]
2711 );
2712 }
2713
2714 #[test]
2715 fn test_attribute_mixed_normal_boolean_colon() {
2716 let (fragments, _) = parse_with_component(
2719 "my-component",
2720 r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
2721 );
2722
2723 assert_fragments!(
2724 fragments,
2725 [
2726 raw("<my-component"),
2727 attr_raw_start("id", "comp"),
2728 attr_complex(":config", "settings"),
2729 bool_attr("enabled", "isEnabled"),
2730 raw(">"),
2731 component("my-component"),
2732 raw("</my-component>"),
2733 ]
2734 );
2735 }
2736
2737 #[test]
2738 fn test_attribute_reject_boolean_without_handlebars() {
2739 let (fragments, _) = parse_and_get_fragments(r#"<input ?checked="name"></input>"#);
2742
2743 assert_fragments!(fragments, [raw("<input></input>"),]);
2745 }
2746
2747 #[test]
2748 fn test_attribute_reject_boolean_with_partial_handlebars() {
2749 let (fragments, _) =
2752 parse_and_get_fragments(r#"<input ?checked="Hello {{name}}"></input>"#);
2753
2754 assert_fragments!(fragments, [raw("<input></input>"),]);
2756 }
2757
2758 #[test]
2759 fn test_attribute_reject_boolean_with_plain_value() {
2760 let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
2763
2764 assert_fragments!(fragments, [raw("<button>Click</button>"),]);
2766 }
2767
2768 #[test]
2769 fn test_attribute_boolean_predicate_equal() {
2770 let (fragments, _) =
2772 parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
2773 assert_fragments!(
2774 fragments,
2775 [
2776 raw("<div"),
2777 bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
2778 raw(">X</div>"),
2779 ]
2780 );
2781 }
2782
2783 #[test]
2784 fn test_attribute_boolean_predicate_greater_than() {
2785 let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
2787 assert_fragments!(
2788 fragments,
2789 [
2790 raw("<span"),
2791 bool_attr_predicate("hidden", "num", 1, "9"),
2792 raw(">X</span>"),
2793 ]
2794 );
2795 }
2796
2797 #[test]
2798 fn test_attribute_boolean_predicate_not_equal() {
2799 let (fragments, _) =
2801 parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
2802 assert_fragments!(
2803 fragments,
2804 [
2805 raw("<a"),
2806 bool_attr_predicate("data-active", "status", 4, "'inactive'"),
2807 raw(">X</a>"),
2808 ]
2809 );
2810 }
2811
2812 #[test]
2813 fn test_attribute_boolean_negation() {
2814 let (fragments, _) =
2816 parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
2817 assert_fragments!(
2818 fragments,
2819 [
2820 raw("<button"),
2821 bool_attr_not("disabled", "isReady"),
2822 raw(">X</button>"),
2823 ]
2824 );
2825 }
2826
2827 #[test]
2828 fn test_attribute_mixed_static_dynamic() {
2829 let (fragments, records) =
2832 parse_and_get_fragments(r#"<input value="hello {{world}}">Hi</input>"#);
2833
2834 assert_fragments!(
2835 fragments,
2836 [
2837 raw("<input"),
2838 attr_template("value", "attr-1"),
2839 raw(">Hi</input>"),
2840 ]
2841 );
2842
2843 assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
2845 }
2846
2847 #[test]
2850 fn test_body_signals() {
2851 let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
2852 assert_fragments!(
2853 fragments,
2854 [
2855 raw("<body>"),
2856 signal_raw("body_start"),
2857 raw("<app-shell></app-shell>"),
2858 signal_raw("body_end"),
2859 raw("</body>"),
2860 ]
2861 );
2862 }
2863
2864 #[test]
2867 fn test_empty_for_produces_nothing() {
2868 let (fragments, records) =
2869 parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
2870 assert_fragments!(fragments, [raw("<div></div>"),]);
2871 assert!(!records.contains_key("for-1"));
2872 }
2873
2874 #[test]
2877 fn test_self_closing_svg_path() {
2878 let (fragments, _) =
2879 parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
2880 assert_fragments!(
2881 fragments,
2882 [raw(
2883 r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
2884 ),]
2885 );
2886 }
2887
2888 #[test]
2889 fn test_html5_void_elements() {
2890 let (fragments, _) = parse_and_get_fragments(
2891 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
2892 );
2893 assert_fragments!(
2894 fragments,
2895 [raw(
2896 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
2897 ),]
2898 );
2899 }
2900
2901 #[test]
2902 fn test_self_closing_with_dynamic_attributes() {
2903 let (fragments, _) =
2904 parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
2905 assert_fragments!(
2906 fragments,
2907 [
2908 raw("<img"),
2909 attr("src", "imageUrl"),
2910 attr("alt", "imageAlt"),
2911 raw("/>"),
2912 ]
2913 );
2914 }
2915
2916 #[test]
2917 fn test_self_closing_with_boolean_attributes() {
2918 let (fragments, _) = parse_and_get_fragments(
2919 r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
2920 );
2921 assert_fragments!(
2922 fragments,
2923 [
2924 raw("<input type=\"checkbox\""),
2925 bool_attr("checked", "isSelected"),
2926 bool_attr("disabled", "isDisabled"),
2927 raw("/>"),
2928 ]
2929 );
2930 }
2931
2932 #[test]
2933 fn test_multiple_self_closing_in_sequence() {
2934 let (fragments, _) =
2935 parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
2936 assert_fragments!(
2937 fragments,
2938 [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
2939 );
2940 }
2941
2942 #[test]
2943 fn test_self_closing_with_mixed_content() {
2944 let (fragments, _) =
2945 parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
2946 assert_fragments!(
2947 fragments,
2948 [
2949 raw("<div>Text before<img"),
2950 attr("src", "url"),
2951 raw("/>Text after</div>"),
2952 ]
2953 );
2954 }
2955
2956 #[test]
2957 fn test_self_closing_svg_elements() {
2958 let (fragments, _) = parse_and_get_fragments(
2959 r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
2960 );
2961 assert_fragments!(
2962 fragments,
2963 [
2964 raw("<svg><circle"),
2965 attr("cx", "x"),
2966 attr("cy", "y"),
2967 raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
2968 ]
2969 );
2970 }
2971
2972 #[test]
2973 fn test_self_closing_inside_for_loop() {
2974 let (fragments, records) = parse_and_get_fragments(
2975 r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
2976 );
2977 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
2978 assert_stream!(
2979 records,
2980 "for-1",
2981 [raw("<img"), attr("src", "item.url"), raw("/>"),]
2982 );
2983 }
2984
2985 #[test]
2986 fn test_self_closing_whitespace_variations() {
2987 let (fragments, _) =
2988 parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
2989 assert_fragments!(
2990 fragments,
2991 [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
2992 );
2993 }
2994
2995 #[test]
2996 fn test_deeply_nested_self_closing() {
2997 let (fragments, _) = parse_and_get_fragments(
2998 r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
2999 );
3000 assert_fragments!(
3001 fragments,
3002 [raw(
3003 r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
3004 ),]
3005 );
3006 }
3007
3008 #[test]
3009 fn test_self_closing_vs_empty_regular_tags() {
3010 let (fragments, _) =
3011 parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
3012 assert_fragments!(
3013 fragments,
3014 [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
3015 );
3016 }
3017
3018 #[test]
3021 fn test_for_custom_template_attribute() {
3022 let (fragments, records) = parse_and_get_fragments(
3024 r#"<for each="item in items" template="static"><span>Item</span></for>"#,
3025 );
3026 assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
3027 assert_stream!(records, "static", [raw("<span>Item</span>"),]);
3028 }
3029
3030 #[test]
3031 fn test_for_recursive_template() {
3032 let mut parser = HtmlParser::new();
3034 let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
3035 let result = parser.parse("index.html", html);
3036 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3037 let records = parser.into_fragment_records();
3038
3039 assert_fragments!(
3040 records["index.html"].fragments,
3041 [for_loop("outerItem", "outerItems", "static"),]
3042 );
3043
3044 assert_stream!(
3045 records,
3046 "static",
3047 [
3048 raw("<div><span>"),
3049 signal("outerItem.name"),
3050 raw("</span>"),
3051 for_loop("innerItem", "innerItems", "static"),
3052 raw("</div>"),
3053 ]
3054 );
3055 }
3056
3057 #[test]
3060 fn test_if_multiple_children() {
3061 let (fragments, records) =
3063 parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
3064 assert_fragments!(fragments, [if_cond("if-1"),]);
3065 assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
3066 }
3067
3068 #[test]
3069 fn test_for_multiple_children() {
3070 let (fragments, records) =
3072 parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
3073 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
3074 assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
3075 }
3076
3077 #[test]
3080 fn test_handlebars_at_beginning() {
3081 let (fragments, _) = parse_and_get_fragments("{{first}}");
3083 assert_fragments!(fragments, [signal("first"),]);
3084 }
3085
3086 #[test]
3087 fn test_handlebars_at_beginning_and_raw() {
3088 let (fragments, _) = parse_and_get_fragments("{{first}}test");
3090 assert_fragments!(fragments, [signal("first"), raw("test"),]);
3091 }
3092
3093 #[test]
3094 fn test_handlebars_raw_and_end() {
3095 let (fragments, _) = parse_and_get_fragments("test{{first}}");
3097 assert_fragments!(fragments, [raw("test"), signal("first"),]);
3098 }
3099
3100 #[test]
3103 fn test_handlebars_invalid_triple_open() {
3104 let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
3106 assert_fragments!(fragments, [raw("{{{invalid}}"),]);
3107 }
3108
3109 #[test]
3110 fn test_handlebars_four_open_braces() {
3111 let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
3113 assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
3114 }
3115
3116 #[test]
3117 fn test_handlebars_five_open_with_valid_double() {
3118 let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
3120 assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
3121 }
3122
3123 #[test]
3124 fn test_entities_preserved() {
3125 let (fragments, _) = parse_and_get_fragments("<p>Hello}World</p>");
3127 assert_fragments!(fragments, [raw("<p>Hello}World</p>"),]);
3128 }
3129
3130 #[test]
3133 fn test_doctype_preserved() {
3134 let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
3136 assert!(!fragments.is_empty());
3137 assert!(
3138 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
3139 );
3140 }
3141
3142 #[test]
3145 fn test_component_attr_skip() {
3146 let mut parser = HtmlParser::new();
3148 parser
3149 .component_registry
3150 .register_component("custom-element", "<slot></slot>", None)
3151 .expect("register");
3152 let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
3153 let result = parser.parse("index.html", html);
3154 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3155 let records = parser.into_fragment_records();
3156
3157 assert_fragments!(
3160 records["index.html"].fragments,
3161 [
3162 raw("<custom-element"),
3163 attr_complex_start(":config", "config"),
3165 attr_skip("class", "value0"),
3167 attr_skip("style", "value1"),
3168 attr_skip("role", "value2"),
3169 attr_skip("data-test", "value3"),
3170 attr_skip("aria-test", "value4"),
3171 raw(">"),
3172 component("custom-element"),
3173 raw("</custom-element>"),
3174 ]
3175 );
3176 }
3177
3178 #[test]
3179 fn test_component_attr_skip_static_and_embedded() {
3180 let mut parser = HtmlParser::new();
3183 parser
3184 .component_registry
3185 .register_component("item-group", "<slot></slot>", None)
3186 .expect("register");
3187
3188 let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
3189 let result = parser.parse("index.html", html);
3190 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3191 let records = parser.into_fragment_records();
3192
3193 assert_fragments!(
3194 records["index.html"].fragments,
3195 [
3196 raw("<item-group"),
3197 attr_skip_raw("role", "list"),
3198 attr_skip_template("aria-labelledby", "attr-1"),
3199 attr_skip_template("data-testid", "attr-2"),
3200 attr_skip_raw("class", "fixed-class"),
3201 raw(">"),
3202 component("item-group"),
3203 raw("</item-group>"),
3204 ]
3205 );
3206
3207 assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
3210 assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
3211 }
3212
3213 #[test]
3214 fn test_component_multiple_nested() {
3215 let mut parser = HtmlParser::new();
3217 parser.set_dom_strategy(DomStrategy::Light);
3218 parser
3219 .component_registry
3220 .register_component(
3221 "custom-element",
3222 "<custom-child></custom-child><slot></slot>",
3223 None,
3224 )
3225 .expect("register");
3226 parser
3227 .component_registry
3228 .register_component("custom-button", "<slot></slot>", None)
3229 .expect("register");
3230 parser
3231 .component_registry
3232 .register_component("custom-child", "<h1>Hello World!</h1>", None)
3233 .expect("register");
3234
3235 let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
3236 let result = parser.parse("index.html", html);
3237 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3238 let records = parser.into_fragment_records();
3239
3240 assert_fragments!(
3242 records["index.html"].fragments,
3243 [for_loop("item", "items", "for-1"),]
3244 );
3245
3246 assert_stream!(
3248 records,
3249 "for-1",
3250 [
3251 raw("<custom-element>"),
3252 component("custom-element"),
3253 raw("<custom-button>"),
3254 component("custom-button"),
3255 raw("Ok</custom-button></custom-element>"),
3256 ]
3257 );
3258
3259 let ce = &records["custom-element"].fragments;
3261 assert_eq!(ce.len(), 3);
3262 assert!(
3263 matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child>"))
3264 );
3265 assert!(
3266 matches!(ce[1].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
3267 );
3268 assert!(
3269 matches!(ce[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><slot></slot>"))
3270 );
3271
3272 assert_stream!(records, "custom-button", [raw("<slot></slot>"),]);
3273
3274 assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
3275 }
3276
3277 #[test]
3280 fn test_invalid_markup_returns_error() {
3281 let mut parser = HtmlParser::new();
3284 let result = parser.parse("index.html", "<div><span>Unclosed div");
3285 assert!(result.is_ok());
3286 }
3287
3288 #[test]
3291 fn test_complex_raw_text_full_page() {
3292 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>"#;
3294 let (fragments, _) = parse_and_get_fragments(html);
3295
3296 assert!(fragments.len() >= 7);
3298 assert!(
3299 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3300 raw.value.contains("<!DOCTYPE HTML>") && raw.value.contains("<title>Test</title>"))
3301 );
3302 assert!(
3303 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
3304 s.value == "head_end" && s.raw)
3305 );
3306 assert!(
3307 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3308 raw.value.contains("</head>") && raw.value.ends_with("<body>"))
3309 );
3310 assert!(
3311 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
3312 s.value == "body_start" && s.raw)
3313 );
3314 assert!(
3315 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3316 raw.value.contains("<app-shell>"))
3317 );
3318 assert!(
3319 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
3320 s.value == "body_end" && s.raw)
3321 );
3322 assert!(
3323 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3324 raw.value.contains("</body>") && raw.value.contains("</html>"))
3325 );
3326 }
3327
3328 #[test]
3329 fn test_css_strategy_external_emits_link_tag() {
3330 let mut parser = HtmlParser::new();
3331 parser
3332 .component_registry_mut()
3333 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3334 .ok();
3335 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3336 let records = parser.into_fragment_records();
3337 let my_card = &records["my-card"].fragments;
3338 let raw_text: String = my_card
3339 .iter()
3340 .filter_map(|f| match &f.fragment {
3341 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3342 _ => None,
3343 })
3344 .collect();
3345 assert!(
3346 raw_text.contains(r#"<link rel="stylesheet" href="/my-card.css">"#),
3347 "Expected external <link> tag in: {}",
3348 raw_text
3349 );
3350 }
3351
3352 #[test]
3353 fn test_css_strategy_inline_emits_style_tag() {
3354 let mut parser = HtmlParser::new();
3355 parser.set_css_strategy(CssStrategy::Style);
3356 parser
3357 .component_registry_mut()
3358 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3359 .ok();
3360 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3361 let records = parser.into_fragment_records();
3362 let my_card = &records["my-card"].fragments;
3363 let raw_text: String = my_card
3364 .iter()
3365 .filter_map(|f| match &f.fragment {
3366 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3367 _ => None,
3368 })
3369 .collect();
3370 assert!(
3371 raw_text.contains("<style>p { color: red; }</style>"),
3372 "Expected inline <style> tag in: {}",
3373 raw_text
3374 );
3375 assert!(
3376 !raw_text.contains("<link"),
3377 "Should not have <link> tag in inline mode: {}",
3378 raw_text
3379 );
3380 }
3381
3382 #[test]
3383 fn test_css_strategy_module_emits_adopted_stylesheets() {
3384 let mut parser = HtmlParser::new();
3385 parser.set_dom_strategy(DomStrategy::Light);
3386 parser.set_css_strategy(CssStrategy::Module);
3387 parser
3388 .component_registry_mut()
3389 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3390 .ok();
3391 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3392 let records = parser.into_fragment_records();
3393
3394 let my_card = &records["my-card"].fragments;
3397 let template_text: String = my_card
3398 .iter()
3399 .filter_map(|f| match &f.fragment {
3400 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3401 _ => None,
3402 })
3403 .collect();
3404 assert!(
3405 !template_text.contains("shadowrootadoptedstylesheets"),
3406 "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
3407 );
3408 assert!(
3409 !template_text.contains("<link"),
3410 "Should not have <link> in module mode: {template_text}"
3411 );
3412 assert!(
3415 !template_text.contains(r#"<style type="module""#),
3416 "CSS module should NOT be in raw fragments: {template_text}"
3417 );
3418 }
3419
3420 #[test]
3421 fn test_css_strategy_module_no_css_no_adopted_attr() {
3422 let mut parser = HtmlParser::new();
3423 parser.set_css_strategy(CssStrategy::Module);
3424 parser
3425 .component_registry_mut()
3426 .register_component("my-card", "<p><slot></slot></p>", None)
3427 .ok();
3428 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3429 let records = parser.into_fragment_records();
3430 let my_card = &records["my-card"].fragments;
3431 let template_text: String = my_card
3432 .iter()
3433 .filter_map(|f| match &f.fragment {
3434 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3435 _ => None,
3436 })
3437 .collect();
3438 assert!(
3440 !template_text.contains("shadowrootadoptedstylesheets"),
3441 "Should not have adopted attr without CSS: {template_text}"
3442 );
3443 }
3444
3445 #[test]
3459 fn test_body_start_end_injection() {
3460 let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
3464 let (fragments, _) = parse_and_get_fragments(html);
3465
3466 assert_fragments!(
3467 fragments,
3468 [
3469 raw("<html><head><title>Test</title>"),
3470 signal_raw("head_end"),
3471 raw("</head><body>"),
3472 signal_raw("body_start"),
3473 raw("<div>Content</div><p>More</p>"),
3474 signal_raw("body_end"),
3475 raw("</body></html>"),
3476 ]
3477 );
3478 }
3479
3480 #[test]
3481 fn test_fail_with_invalid_markup() {
3482 let mut parser = HtmlParser::new();
3488 let result = parser.parse("index.html", "<div><span></div></span>");
3489
3490 assert!(
3492 result.is_ok(),
3493 "tree-sitter is lenient and recovers from overlapping tags"
3494 );
3495 }
3496
3497 #[test]
3498 fn test_complex_raw_text_page() {
3499 let html = concat!(
3502 "<!DOCTYPE html>",
3503 "<html lang=\"en\">",
3504 "<head>",
3505 "<meta charset=\"utf-8\">",
3506 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
3507 "<title>Complex Page</title>",
3508 "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
3509 "<link rel=\"stylesheet\" href=\"styles.css\">",
3510 "</head>",
3511 "<body>",
3512 "<h1>Hello World</h1>",
3513 "<script type=\"module\" src=\"./app.js\"></script>",
3514 "</body>",
3515 "</html>",
3516 );
3517 let (fragments, _) = parse_and_get_fragments(html);
3518
3519 assert!(
3522 fragments.len() >= 7,
3523 "Expected at least 7 fragments, got {}",
3524 fragments.len()
3525 );
3526
3527 assert!(
3529 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3530 raw.value.contains("<!DOCTYPE html>") &&
3531 raw.value.contains("<meta charset=\"utf-8\">") &&
3532 raw.value.contains("<meta name=\"viewport\"") &&
3533 raw.value.contains("<title>Complex Page</title>") &&
3534 raw.value.contains("<style>") &&
3535 raw.value.contains("body { margin: 0; padding: 0; }")),
3536 "First fragment should contain all head content, got: {:?}",
3537 fragments[0]
3538 );
3539
3540 assert!(
3542 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
3543 s.value == "head_end" && s.raw),
3544 "Second fragment should be head_end signal"
3545 );
3546
3547 assert!(
3549 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3550 raw.value.contains("</head>") && raw.value.ends_with("<body>")),
3551 "Third fragment should contain </head><body>"
3552 );
3553
3554 assert!(
3556 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
3557 s.value == "body_start" && s.raw),
3558 "Fourth fragment should be body_start signal"
3559 );
3560
3561 assert!(
3563 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3564 raw.value.contains("<h1>Hello World</h1>") &&
3565 raw.value.contains("<script")),
3566 "Fifth fragment should contain body content"
3567 );
3568
3569 assert!(
3571 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
3572 s.value == "body_end" && s.raw),
3573 "Sixth fragment should be body_end signal"
3574 );
3575
3576 assert!(
3578 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3579 raw.value.contains("</body>") && raw.value.contains("</html>")),
3580 "Seventh fragment should contain closing tags"
3581 );
3582 }
3583
3584 struct BindingCountPlugin {
3588 counts: Vec<u32>,
3589 }
3590
3591 impl BindingCountPlugin {
3592 fn new() -> Self {
3593 Self { counts: Vec::new() }
3594 }
3595 }
3596
3597 impl crate::plugin::ParserPlugin for BindingCountPlugin {
3598 fn register_component_template(
3599 &mut self,
3600 _tag_name: &str,
3601 _component: &Component,
3602 _processed_template: &str,
3603 ) -> Result<()> {
3604 Ok(())
3605 }
3606
3607 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
3608 if attr_name.starts_with('@') || attr_name == "f-ref" {
3609 AttributeAction::SkipAndCountBinding
3610 } else {
3611 AttributeAction::Keep
3612 }
3613 }
3614
3615 fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
3616 self.counts.push(binding_attribute_count);
3617 if binding_attribute_count > 0 {
3618 Some(binding_attribute_count.to_le_bytes().to_vec())
3619 } else {
3620 None
3621 }
3622 }
3623 }
3624
3625 #[test]
3626 fn test_component_static_attrs_not_counted_as_bindings() {
3627 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3628 parser
3629 .component_registry
3630 .register_component("my-btn", "<button><slot></slot></button>", None)
3631 .expect("register");
3632
3633 let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
3635 let result = parser.parse("index.html", html);
3636 assert!(result.is_ok());
3637
3638 let records = parser.into_fragment_records();
3639
3640 let frags = &records["index.html"].fragments;
3642 let plugin_count = frags
3643 .iter()
3644 .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
3645 .count();
3646 assert_eq!(
3647 plugin_count, 0,
3648 "Static-only component attributes should not emit a Plugin fragment"
3649 );
3650 }
3651
3652 #[test]
3653 fn test_component_dynamic_attr_counted_as_binding() {
3654 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3655 parser
3656 .component_registry
3657 .register_component("my-btn", "<button><slot></slot></button>", None)
3658 .expect("register");
3659
3660 let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
3662 let result = parser.parse("index.html", html);
3663 assert!(result.is_ok());
3664
3665 let records = parser.into_fragment_records();
3666 let frags = &records["index.html"].fragments;
3667
3668 let plugin_frags: Vec<_> = frags
3670 .iter()
3671 .filter_map(|f| match f.fragment.as_ref() {
3672 Some(Fragment::Plugin(p)) => Some(&p.data),
3673 _ => None,
3674 })
3675 .collect();
3676 assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
3677 let count = u32::from_le_bytes([
3678 plugin_frags[0][0],
3679 plugin_frags[0][1],
3680 plugin_frags[0][2],
3681 plugin_frags[0][3],
3682 ]);
3683 assert_eq!(count, 1, "Binding attribute count should be 1");
3684 }
3685
3686 #[test]
3687 fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
3688 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3689 parser
3690 .component_registry
3691 .register_component("my-btn", "<button><slot></slot></button>", None)
3692 .expect("register");
3693
3694 let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
3696 let result = parser.parse("index.html", html);
3697 assert!(result.is_ok());
3698
3699 let records = parser.into_fragment_records();
3700 let frags = &records["index.html"].fragments;
3701
3702 let plugin_frags: Vec<_> = frags
3703 .iter()
3704 .filter_map(|f| match f.fragment.as_ref() {
3705 Some(Fragment::Plugin(p)) => Some(&p.data),
3706 _ => None,
3707 })
3708 .collect();
3709 assert_eq!(plugin_frags.len(), 1);
3710 let count = u32::from_le_bytes([
3711 plugin_frags[0][0],
3712 plugin_frags[0][1],
3713 plugin_frags[0][2],
3714 plugin_frags[0][3],
3715 ]);
3716 assert_eq!(
3718 count, 2,
3719 "Binding count should include dynamic + plugin-skipped attrs, not static"
3720 );
3721 }
3722
3723 #[test]
3724 fn test_component_only_skipped_plugin_attrs_counted() {
3725 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3726 parser
3727 .component_registry
3728 .register_component("my-btn", "<button><slot></slot></button>", None)
3729 .expect("register");
3730
3731 let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
3733 let result = parser.parse("index.html", html);
3734 assert!(result.is_ok());
3735
3736 let records = parser.into_fragment_records();
3737 let frags = &records["index.html"].fragments;
3738
3739 let plugin_frags: Vec<_> = frags
3740 .iter()
3741 .filter_map(|f| match f.fragment.as_ref() {
3742 Some(Fragment::Plugin(p)) => Some(&p.data),
3743 _ => None,
3744 })
3745 .collect();
3746 assert_eq!(plugin_frags.len(), 1);
3747 let count = u32::from_le_bytes([
3748 plugin_frags[0][0],
3749 plugin_frags[0][1],
3750 plugin_frags[0][2],
3751 plugin_frags[0][3],
3752 ]);
3753 assert_eq!(
3755 count, 2,
3756 "Only plugin-skipped attrs should be counted, not static title"
3757 );
3758 }
3759
3760 #[test]
3763 fn test_comment_handlebars_signal() {
3764 let mut parser = HtmlParser::new();
3765 let html = "<!--{{tokens}}-->";
3766 parser.parse("test.html", html).expect("parse failed");
3767 let records = parser.into_fragment_records();
3768
3769 assert_stream!(records, "test.html", [signal("tokens")]);
3770 }
3771
3772 #[test]
3773 fn test_comment_triple_brace_raw_signal() {
3774 let mut parser = HtmlParser::new();
3775 let html = "<!--{{{tokens}}}-->";
3776 parser.parse("test.html", html).expect("parse failed");
3777 let records = parser.into_fragment_records();
3778
3779 assert_stream!(records, "test.html", [signal_raw("tokens")]);
3780 }
3781
3782 #[test]
3783 fn test_comment_regular_preserved() {
3784 let mut parser = HtmlParser::new();
3785 let html = "<!-- regular comment -->";
3786 parser.parse("test.html", html).expect("parse failed");
3787 let records = parser.into_fragment_records();
3788
3789 assert_stream!(records, "test.html", [raw("<!-- regular comment -->")]);
3790 }
3791
3792 #[test]
3793 fn test_comment_dotted_signal() {
3794 let mut parser = HtmlParser::new();
3795 let html = "<!--{{tokens.light}}-->";
3796 parser.parse("test.html", html).expect("parse failed");
3797 let records = parser.into_fragment_records();
3798
3799 assert_stream!(records, "test.html", [signal("tokens.light")]);
3800 }
3801
3802 #[test]
3803 fn test_comment_arbitrary_identifier() {
3804 let mut parser = HtmlParser::new();
3805 let html = "<!--{{someOtherBinding}}-->";
3806 parser.parse("test.html", html).expect("parse failed");
3807 let records = parser.into_fragment_records();
3808
3809 assert_stream!(records, "test.html", [signal("someOtherBinding")]);
3810 }
3811
3812 #[test]
3813 fn test_comment_with_surrounding_content() {
3814 let mut parser = HtmlParser::new();
3815 let html = "<div><!--{{tokens}}--></div>";
3816 parser.parse("test.html", html).expect("parse failed");
3817 let records = parser.into_fragment_records();
3818
3819 assert_stream!(
3820 records,
3821 "test.html",
3822 [raw("<div>"), signal("tokens"), raw("</div>")]
3823 );
3824 }
3825
3826 #[test]
3829 fn test_tokens_from_style_tag() {
3830 let mut parser = HtmlParser::new();
3831 let html = r#"<style>
3832 .btn { color: var(--colorPrimary); background: var(--bgColor); }
3833 </style>"#;
3834 parser.parse("test.html", html).expect("parse failed");
3835 let tokens = parser.take_tokens();
3836
3837 assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
3838 }
3839
3840 #[test]
3841 fn test_tokens_from_component_css() {
3842 let mut parser = HtmlParser::new();
3843 parser
3844 .component_registry_mut()
3845 .register_component(
3846 "my-button",
3847 "<button>Click</button>",
3848 Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
3849 )
3850 .expect("register failed");
3851
3852 let html = "<my-button></my-button>";
3853 parser.parse("test.html", html).expect("parse failed");
3854 let tokens = parser.take_tokens();
3855
3856 assert_eq!(tokens, vec!["borderWidth", "textColor"]);
3857 }
3858
3859 #[test]
3860 fn test_tokens_merged_from_style_and_components() {
3861 let mut parser = HtmlParser::new();
3862 parser
3863 .component_registry_mut()
3864 .register_component(
3865 "my-widget",
3866 "<div>Widget</div>",
3867 Some(".w { padding: var(--spacingM); }"),
3868 )
3869 .expect("register failed");
3870
3871 let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
3872 parser.parse("test.html", html).expect("parse failed");
3873 let tokens = parser.take_tokens();
3874
3875 assert_eq!(tokens, vec!["spacingM", "textColor"]);
3876 }
3877
3878 #[test]
3879 fn test_tokens_deduplicated() {
3880 let mut parser = HtmlParser::new();
3881 parser
3882 .component_registry_mut()
3883 .register_component(
3884 "my-btn",
3885 "<button>B</button>",
3886 Some(".b { color: var(--shared); }"),
3887 )
3888 .expect("register failed");
3889
3890 let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
3891 parser.parse("test.html", html).expect("parse failed");
3892 let tokens = parser.take_tokens();
3893
3894 assert_eq!(tokens, vec!["shared"]);
3895 }
3896
3897 #[test]
3898 fn test_tokens_empty_when_no_vars() {
3899 let mut parser = HtmlParser::new();
3900 let html = "<div>Hello</div>";
3901 parser.parse("test.html", html).expect("parse failed");
3902 let tokens = parser.take_tokens();
3903
3904 assert!(tokens.is_empty());
3905 }
3906
3907 #[test]
3908 fn test_tokens_exclude_locally_defined_in_component() {
3909 let mut parser = HtmlParser::new();
3910 parser
3911 .component_registry_mut()
3912 .register_component(
3913 "my-card",
3914 "<div>Card</div>",
3915 Some(":host { --local: 5px; width: var(--external); }"),
3916 )
3917 .expect("register failed");
3918
3919 let html = "<my-card></my-card>";
3920 parser.parse("test.html", html).expect("parse failed");
3921 let tokens = parser.take_tokens();
3922
3923 assert_eq!(tokens, vec!["external"]);
3924 }
3925
3926 #[test]
3927 fn test_tokens_exclude_entry_root_definitions() {
3928 let mut parser = HtmlParser::new();
3929 parser
3930 .component_registry_mut()
3931 .register_component(
3932 "my-btn",
3933 "<button>B</button>",
3934 Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
3935 )
3936 .expect("register failed");
3937
3938 let html = r#"<style>
3941 :root {
3942 --color-primary: #0078d4;
3943 --radius-m: 6px;
3944 }
3945 body { color: var(--color-primary); }
3946 </style>
3947 <my-btn></my-btn>"#;
3948 parser.parse("test.html", html).expect("parse failed");
3949 let tokens = parser.take_tokens();
3950
3951 assert!(
3953 tokens.is_empty(),
3954 "Tokens defined in entry :root should be excluded: {tokens:?}"
3955 );
3956 }
3957
3958 #[test]
3959 fn test_tokens_entry_defs_exclude_but_external_kept() {
3960 let mut parser = HtmlParser::new();
3961 parser
3962 .component_registry_mut()
3963 .register_component(
3964 "my-card",
3965 "<div>Card</div>",
3966 Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
3967 )
3968 .expect("register failed");
3969
3970 let html = r#"<style>
3972 :root { --color-primary: #0078d4; }
3973 </style>
3974 <my-card></my-card>"#;
3975 parser.parse("test.html", html).expect("parse failed");
3976 let tokens = parser.take_tokens();
3977
3978 assert_eq!(tokens, vec!["external-spacing"]);
3980 }
3981
3982 #[test]
3985 fn test_parse_simple_route() {
3986 let mut parser = HtmlParser::new();
3987 let html = r#"<route path="/profile" component="profile-page" exact />"#;
3988 parser.parse("test.html", html).expect("parse failed");
3989
3990 let records = parser.into_fragment_records();
3992 let frags = &records["test.html"].fragments;
3993 assert_eq!(frags.len(), 1);
3994 match frags[0].fragment.as_ref() {
3995 Some(web_ui_fragment::Fragment::Route(r)) => {
3996 assert_eq!(r.path, "/profile");
3997 assert_eq!(r.fragment_id, "profile-page");
3998 assert!(r.exact);
3999 }
4000 other => panic!("expected Fragment::Route, got {:?}", other),
4001 }
4002 }
4003
4004 #[test]
4005 fn test_parse_route_with_params() {
4006 let mut parser = HtmlParser::new();
4007 let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
4008 parser.parse("test.html", html).expect("parse failed");
4009
4010 let records = parser.into_fragment_records();
4012 let frags = &records["test.html"].fragments;
4013 match frags[0].fragment.as_ref() {
4014 Some(web_ui_fragment::Fragment::Route(r)) => {
4015 assert_eq!(r.path, "/profile/:id/view/:section");
4016 }
4017 other => panic!("expected Fragment::Route, got {:?}", other),
4018 }
4019 }
4020
4021 #[test]
4022 fn test_parse_route_requires_component() {
4023 let mut parser = HtmlParser::new();
4024 let html = r#"<route path="/old" />"#;
4025 let result = parser.parse("test.html", html);
4026 assert!(result.is_err());
4027 }
4028
4029 #[test]
4030 fn test_parse_multiple_routes() {
4031 let mut parser = HtmlParser::new();
4032 let html = r#"<route path="/" component="app-layout" />
4033 <route path="/dashboard" component="dash-page" exact />
4034 <route path="/contacts" component="contacts-page" exact />"#;
4035 parser.parse("test.html", html).expect("parse failed");
4036
4037 let records = parser.into_fragment_records();
4038 let frags = &records["test.html"].fragments;
4039 assert_eq!(frags.len(), 3);
4040 for frag in frags {
4042 assert!(matches!(
4043 frag.fragment.as_ref(),
4044 Some(web_ui_fragment::Fragment::Route(_))
4045 ));
4046 }
4047 }
4048
4049 #[test]
4050 fn test_parse_route_requires_component_with_body() {
4051 let mut parser = HtmlParser::new();
4052 let html = r#"<route path="/error">
4053 <div class="error"><h1>Not Found</h1></div>
4054 </route>"#;
4055 let result = parser.parse("test.html", html);
4056 assert!(
4057 result.is_err(),
4058 "Route without component attribute should fail"
4059 );
4060 }
4061
4062 #[test]
4063 fn test_parse_multiple_routes_with_fragments() {
4064 let mut parser = HtmlParser::new();
4065 let html = r#"<route path="/" component="home-page" exact />
4066 <route path="/about" component="about-page" exact />
4067 <route path="/contact/:id" component="contact-page" />"#;
4068 parser.parse("test.html", html).expect("parse failed");
4069
4070 let records = parser.into_fragment_records();
4071 let frags = &records["test.html"].fragments;
4072 assert_eq!(frags.len(), 3);
4073
4074 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
4076 assert_eq!(r.path, "/");
4077 }
4078 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
4079 assert_eq!(r.path, "/about");
4080 }
4081 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
4082 assert_eq!(r.path, "/contact/:id");
4083 }
4084 }
4085
4086 #[test]
4087 fn test_parse_route_with_query_allowlist() {
4088 let mut parser = HtmlParser::new();
4089 let html =
4090 r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
4091 parser.parse("test.html", html).expect("parse failed");
4092
4093 let records = parser.into_fragment_records();
4094 let frags = &records["test.html"].fragments;
4095 assert_eq!(frags.len(), 1);
4096 match frags[0].fragment.as_ref() {
4097 Some(web_ui_fragment::Fragment::Route(r)) => {
4098 assert_eq!(r.path, "/compose");
4099 assert_eq!(r.fragment_id, "compose-page");
4100 assert!(r.exact);
4101 assert_eq!(r.allowed_query, "action,to,subject");
4102 }
4103 other => panic!("expected Fragment::Route, got {:?}", other),
4104 }
4105 }
4106
4107 #[test]
4108 fn test_parse_route_without_query_has_empty_allowed_query() {
4109 let mut parser = HtmlParser::new();
4110 let html = r#"<route path="/profile" component="profile-page" exact />"#;
4111 parser.parse("test.html", html).expect("parse failed");
4112
4113 let records = parser.into_fragment_records();
4114 let frags = &records["test.html"].fragments;
4115 match frags[0].fragment.as_ref() {
4116 Some(web_ui_fragment::Fragment::Route(r)) => {
4117 assert!(r.allowed_query.is_empty());
4118 }
4119 other => panic!("expected Fragment::Route, got {:?}", other),
4120 }
4121 }
4122
4123 #[test]
4124 fn test_outlet_not_captured_by_for_loop() {
4125 let mut parser = HtmlParser::new();
4126 let html = r#"<template shadowrootmode="open">
4127 <ul>
4128 <for each="item in items">
4129 <li>{{item.name}}</li>
4130 </for>
4131 </ul>
4132 <main>
4133 <outlet />
4134 </main>
4135</template>"#;
4136 parser.parse("comp.html", html).expect("parse failed");
4137
4138 let records = parser.into_fragment_records();
4139 let frags = &records["comp.html"].fragments;
4140
4141 let frag_types: Vec<String> = frags
4143 .iter()
4144 .map(|f| match f.fragment.as_ref() {
4145 Some(web_ui_fragment::Fragment::Raw(r)) => {
4146 format!("Raw({:?})", &r.value[..r.value.len().min(40)])
4147 }
4148 Some(web_ui_fragment::Fragment::ForLoop(fl)) => {
4149 format!("ForLoop({})", fl.fragment_id)
4150 }
4151 Some(web_ui_fragment::Fragment::Outlet(_)) => "Outlet".to_string(),
4152 other => format!("{:?}", other),
4153 })
4154 .collect();
4155 eprintln!("Fragment order: {:#?}", frag_types);
4156
4157 let outlet_count = frags
4159 .iter()
4160 .filter(|f| {
4161 matches!(
4162 f.fragment.as_ref(),
4163 Some(web_ui_fragment::Fragment::Outlet(_))
4164 )
4165 })
4166 .count();
4167 assert_eq!(
4168 outlet_count, 1,
4169 "expected exactly 1 outlet in top-level fragments, got {outlet_count}. Fragments: {frag_types:?}"
4170 );
4171
4172 let outlet_idx = frags
4174 .iter()
4175 .position(|f| {
4176 matches!(
4177 f.fragment.as_ref(),
4178 Some(web_ui_fragment::Fragment::Outlet(_))
4179 )
4180 })
4181 .expect("no outlet found");
4182 let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
4183 Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
4184 _ => false,
4185 });
4186 if let Some(ul_idx) = close_ul_idx {
4187 assert!(
4188 outlet_idx > ul_idx,
4189 "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx}). Fragments: {frag_types:?}"
4190 );
4191 }
4192 }
4193
4194 #[test]
4195 fn test_outlet_position_after_for_not_inside() {
4196 let mut parser = HtmlParser::new();
4197 let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
4198 parser.parse("test.html", html).expect("parse failed");
4199
4200 let records = parser.into_fragment_records();
4201 let frags = &records["test.html"].fragments;
4202
4203 let has_outlet = frags.iter().any(|f| {
4205 matches!(
4206 f.fragment.as_ref(),
4207 Some(web_ui_fragment::Fragment::Outlet(_))
4208 )
4209 });
4210 assert!(
4211 has_outlet,
4212 "outlet should be in top-level fragments: {frags:?}"
4213 );
4214
4215 let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
4217 Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
4218 _ => None,
4219 });
4220 if let Some(id) = for_id {
4221 let for_frags = &records[&id].fragments;
4222 let outlet_in_for = for_frags.iter().any(|f| {
4223 matches!(
4224 f.fragment.as_ref(),
4225 Some(web_ui_fragment::Fragment::Outlet(_))
4226 )
4227 });
4228 assert!(
4229 !outlet_in_for,
4230 "outlet should NOT be inside for-loop body: {for_frags:?}"
4231 );
4232 }
4233 }
4234
4235 #[test]
4236 fn test_style_element_with_handlebars_signal() {
4237 let mut parser = HtmlParser::new();
4238 let html = r#"<html><head><style>
4239:root {
4240 /*{{{tokens.light}}}*/
4241}
4242</style></head><body></body></html>"#;
4243
4244 let result = parser.parse("test.html", html);
4245 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4246 let fragment_records = parser.into_fragment_records();
4247
4248 assert_stream!(
4250 fragment_records,
4251 "test.html",
4252 [
4253 raw("<html><head><style>\n:root {\n "),
4254 signal_raw("tokens.light"),
4255 raw("\n}\n</style>"),
4256 signal_raw("head_end"),
4257 raw("</head><body>"),
4258 signal_raw("body_start"),
4259 signal_raw("body_end"),
4260 raw("</body></html>"),
4261 ]
4262 );
4263 }
4264
4265 #[test]
4266 fn test_style_comment_signal_with_spaces() {
4267 let mut parser = HtmlParser::new();
4268 let html = r#"<html><head><style>
4269:root {
4270 /* {{{tokens.light}}} */
4271}
4272</style></head><body></body></html>"#;
4273
4274 let result = parser.parse("test.html", html);
4275 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4276 let fragment_records = parser.into_fragment_records();
4277
4278 assert_stream!(
4280 fragment_records,
4281 "test.html",
4282 [
4283 raw("<html><head><style>\n:root {\n "),
4284 signal_raw("tokens.light"),
4285 raw("\n}\n</style>"),
4286 signal_raw("head_end"),
4287 raw("</head><body>"),
4288 signal_raw("body_start"),
4289 signal_raw("body_end"),
4290 raw("</body></html>"),
4291 ]
4292 );
4293 }
4294
4295 #[test]
4296 fn test_style_comment_signal_double_brace() {
4297 let mut parser = HtmlParser::new();
4298 let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
4299
4300 let result = parser.parse("test.html", html);
4301 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4302 let fragment_records = parser.into_fragment_records();
4303
4304 assert_stream!(
4306 fragment_records,
4307 "test.html",
4308 [
4309 raw("<html><head><style>"),
4310 signal("themeCss"),
4311 raw("</style>"),
4312 signal_raw("head_end"),
4313 raw("</head><body>"),
4314 signal_raw("body_start"),
4315 signal_raw("body_end"),
4316 raw("</body></html>"),
4317 ]
4318 );
4319 }
4320
4321 #[test]
4322 fn test_style_comment_with_extra_text_stays_raw() {
4323 let mut parser = HtmlParser::new();
4324 let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
4326
4327 let result = parser.parse("test.html", html);
4328 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4329 let fragment_records = parser.into_fragment_records();
4330
4331 assert_stream!(
4332 fragment_records,
4333 "test.html",
4334 [
4335 raw("<html><head><style>/* theme: {{token}} */</style>"),
4336 signal_raw("head_end"),
4337 raw("</head><body>"),
4338 signal_raw("body_start"),
4339 signal_raw("body_end"),
4340 raw("</body></html>"),
4341 ]
4342 );
4343 }
4344
4345 #[test]
4346 fn test_style_comment_with_multiple_signals_stays_raw() {
4347 let mut parser = HtmlParser::new();
4348 let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
4350
4351 let result = parser.parse("test.html", html);
4352 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4353 let fragment_records = parser.into_fragment_records();
4354
4355 assert_stream!(
4356 fragment_records,
4357 "test.html",
4358 [
4359 raw("<html><head><style>/*{{a}}{{b}}*/</style>"),
4360 signal_raw("head_end"),
4361 raw("</head><body>"),
4362 signal_raw("body_start"),
4363 signal_raw("body_end"),
4364 raw("</body></html>"),
4365 ]
4366 );
4367 }
4368
4369 #[test]
4370 fn test_style_bare_handlebars_stays_raw() {
4371 let mut parser = HtmlParser::new();
4372 let html =
4374 "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
4375
4376 let result = parser.parse("test.html", html);
4377 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4378 let fragment_records = parser.into_fragment_records();
4379
4380 assert_stream!(
4381 fragment_records,
4382 "test.html",
4383 [
4384 raw("<html><head><style>body { color: {{textColor}}; }</style>"),
4385 signal_raw("head_end"),
4386 raw("</head><body>"),
4387 signal_raw("body_start"),
4388 signal_raw("body_end"),
4389 raw("</body></html>"),
4390 ]
4391 );
4392 }
4393
4394 #[test]
4395 fn test_style_mixed_css_and_comment_signal() {
4396 let mut parser = HtmlParser::new();
4397 let html = r#"<html><head><style>
4398 .a { color: red; }
4399 /*{{themeCss}}*/
4400 .b { color: blue; }
4401</style></head><body></body></html>"#;
4402
4403 let result = parser.parse("test.html", html);
4404 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4405 let fragment_records = parser.into_fragment_records();
4406
4407 assert_stream!(
4408 fragment_records,
4409 "test.html",
4410 [
4411 raw("<html><head><style>\n .a { color: red; }\n "),
4412 signal("themeCss"),
4413 raw("\n .b { color: blue; }\n</style>"),
4414 signal_raw("head_end"),
4415 raw("</head><body>"),
4416 signal_raw("body_start"),
4417 signal_raw("body_end"),
4418 raw("</body></html>"),
4419 ]
4420 );
4421 }
4422
4423 #[test]
4424 fn test_style_element_plain_css_unchanged() {
4425 let mut parser = HtmlParser::new();
4426 let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
4427
4428 let result = parser.parse("test.html", html);
4429 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4430 let fragment_records = parser.into_fragment_records();
4431
4432 assert_stream!(
4434 fragment_records,
4435 "test.html",
4436 [
4437 raw("<html><head><style>body { margin: 0; }</style>"),
4438 signal_raw("head_end"),
4439 raw("</head><body>"),
4440 signal_raw("body_start"),
4441 signal_raw("body_end"),
4442 raw("</body></html>"),
4443 ]
4444 );
4445 }
4446
4447 #[test]
4448 fn test_text_with_binding_after_void_element() {
4449 let (fragments, _) = parse_and_get_fragments(
4452 "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
4453 );
4454 assert_fragments!(
4455 fragments,
4456 [
4457 raw("Part A: "),
4458 signal("value1"),
4459 raw("<button>Click</button><br>Part B: "),
4460 signal("value2"),
4461 raw("<button>Click2</button>"),
4462 ]
4463 );
4464 }
4465}