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