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(">");
856 } else {
857 self.add_raw_fragment(">");
858
859 self.process_content_children(node, source, fragments)?;
860
861 self.add_raw_fragment(&format!("</{}>", tag_name));
863 }
864 }
865
866 Ok(())
867 }
868
869 fn process_tag_attributes(
876 &mut self,
877 tag_node: Node,
878 source: &str,
879 fragments: &mut Vec<WebUIFragment>,
880 is_component: bool,
881 ) -> Result<u32> {
882 let mut first_dynamic_emitted = false;
883 let mut binding_count: u32 = 0;
884 let mut cursor = tag_node.walk();
885
886 for child in tag_node.named_children(&mut cursor) {
887 if child.kind() != "attribute" {
888 continue;
889 }
890
891 let attr_name = self.get_attr_name(child, source)?;
892
893 if let Some(ref mut p) = self.plugin {
894 match p.classify_attribute(&attr_name) {
895 AttributeAction::Keep => {}
896 AttributeAction::Skip => continue,
897 AttributeAction::SkipAndCountBinding => {
898 binding_count += 1;
899 continue;
900 }
901 }
902 }
903
904 let attr_value = self.get_attr_value(child, source);
905
906 if let Some(bool_name) = attr_name.strip_prefix('?') {
907 if is_component {
909 if let Some(condition) = self.parse_boolean_condition(attr_value.as_deref()) {
910 let frag = Self::maybe_mark_attr_start(
911 WebUIFragment::attribute_boolean(bool_name, condition),
912 &mut first_dynamic_emitted,
913 );
914 self.add_fragment(frag, fragments);
915 binding_count += 1;
916 }
917 } else if self.process_boolean_attribute(
918 bool_name,
919 attr_value.as_deref(),
920 fragments,
921 )? {
922 binding_count += 1;
923 }
924 } else if let Some(prop_name) = attr_name.strip_prefix(':') {
925 if !is_component {
931 return Err(ParserError::Parse(format!(
932 ":{prop_name} complex binding is only allowed on custom elements. \
933 Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
934 )));
935 }
936 if Self::is_blocked_complex_property(prop_name) {
937 return Err(ParserError::Parse(format!(
938 ":{prop_name} is not allowed as a complex attribute binding \
939 because it enables arbitrary HTML injection. \
940 Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
941 )));
942 }
943 if let Some(val) = &attr_value {
944 if let Some(signal_name) = Self::extract_single_handlebars(val) {
945 let frag = Self::maybe_mark_attr_start(
946 WebUIFragment::attribute_complex(&attr_name, signal_name),
947 &mut first_dynamic_emitted,
948 );
949 self.add_fragment(frag, fragments);
950 binding_count += 1;
951 }
952 }
953 } else if is_component && Self::is_skipped_attribute(&attr_name) {
954 if let Some(val) = &attr_value {
956 if let Some(signal_name) = Self::extract_single_handlebars(val) {
957 let frag = WebUIFragment {
959 fragment: Some(web_ui_fragment::Fragment::Attribute(
960 WebUIFragmentAttribute {
961 name: attr_name,
962 value: signal_name,
963 attr_skip: true,
964 ..Default::default()
965 },
966 )),
967 };
968 self.add_fragment(frag, fragments);
969 binding_count += 1;
970 } else if Self::contains_handlebars(val) {
971 let template_id = self.id_counter.next_id("attr");
973 let parsed = self.handlebars_parser.parse(val)?;
974 self.fragment_records
975 .insert(template_id.clone(), FragmentList { fragments: parsed });
976 let frag = WebUIFragment {
977 fragment: Some(web_ui_fragment::Fragment::Attribute(
978 WebUIFragmentAttribute {
979 name: attr_name,
980 template: template_id,
981 attr_skip: true,
982 ..Default::default()
983 },
984 )),
985 };
986 self.add_fragment(frag, fragments);
987 binding_count += 1;
988 } else {
989 let frag = WebUIFragment {
991 fragment: Some(web_ui_fragment::Fragment::Attribute(
992 WebUIFragmentAttribute {
993 name: attr_name,
994 value: val.clone(),
995 raw_value: true,
996 attr_skip: true,
997 ..Default::default()
998 },
999 )),
1000 };
1001 self.add_fragment(frag, fragments);
1002 }
1003 }
1004 } else if let Some(ref val) = attr_value {
1005 if Self::contains_handlebars(val) {
1006 if is_component {
1007 if let Some(signal_name) = Self::extract_single_handlebars(val) {
1008 let frag = Self::maybe_mark_attr_start(
1009 WebUIFragment::attribute(&attr_name, signal_name),
1010 &mut first_dynamic_emitted,
1011 );
1012 self.add_fragment(frag, fragments);
1013 binding_count += 1;
1014 } else {
1015 let template_id = self.id_counter.next_id("attr");
1016 let parsed = self.handlebars_parser.parse(val)?;
1017 self.fragment_records
1018 .insert(template_id.clone(), FragmentList { fragments: parsed });
1019 let frag = Self::maybe_mark_attr_start(
1020 WebUIFragment::attribute_template(&attr_name, template_id),
1021 &mut first_dynamic_emitted,
1022 );
1023 self.add_fragment(frag, fragments);
1024 binding_count += 1;
1025 }
1026 } else {
1027 self.process_dynamic_attribute(&attr_name, val, fragments)?;
1028 binding_count += 1;
1029 }
1030 } else if is_component {
1031 let frag = Self::maybe_mark_attr_start(
1034 WebUIFragment {
1035 fragment: Some(web_ui_fragment::Fragment::Attribute(
1036 WebUIFragmentAttribute {
1037 name: attr_name,
1038 value: val.clone(),
1039 raw_value: true,
1040 ..Default::default()
1041 },
1042 )),
1043 },
1044 &mut first_dynamic_emitted,
1045 );
1046 self.add_fragment(frag, fragments);
1047 } else {
1048 let attr_text = &source[child.start_byte()..child.end_byte()];
1050 self.add_raw_fragment(&format!(" {}", attr_text));
1051 }
1052 } else {
1053 self.add_raw_fragment(&format!(" {}", attr_name));
1055 }
1056 }
1057 Ok(binding_count)
1058 }
1059
1060 fn maybe_mark_attr_start(
1063 mut frag: WebUIFragment,
1064 first_dynamic_emitted: &mut bool,
1065 ) -> WebUIFragment {
1066 if !*first_dynamic_emitted {
1067 if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
1068 a.attr_start = true;
1069 }
1070 *first_dynamic_emitted = true;
1071 }
1072 frag
1073 }
1074
1075 fn get_attr_name(&self, attr_node: Node, source: &str) -> Result<String> {
1077 let mut cursor = attr_node.walk();
1078 for child in attr_node.children(&mut cursor) {
1079 if child.kind() == "attribute_name" {
1080 return Ok(source[child.start_byte()..child.end_byte()].to_string());
1081 }
1082 }
1083 Err(ParserError::Html(
1084 "Attribute node missing attribute_name".to_string(),
1085 ))
1086 }
1087
1088 fn get_attr_value(&self, attr_node: Node, source: &str) -> Option<String> {
1091 let mut cursor = attr_node.walk();
1092 for child in attr_node.children(&mut cursor) {
1093 match child.kind() {
1094 "quoted_attribute_value" => {
1095 let mut inner_cursor = child.walk();
1097 for inner in child.children(&mut inner_cursor) {
1098 if inner.kind() == "attribute_value" {
1099 return Some(source[inner.start_byte()..inner.end_byte()].to_string());
1100 }
1101 }
1102 return Some(String::new());
1104 }
1105 "attribute_value" => {
1106 return Some(source[child.start_byte()..child.end_byte()].to_string());
1107 }
1108 _ => {}
1109 }
1110 }
1111 None
1112 }
1113
1114 fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
1117 if let Some(val) = value {
1118 if let Some(expr_str) = Self::extract_single_handlebars(val) {
1119 return Some(
1120 self.condition_parser
1121 .parse(&expr_str)
1122 .unwrap_or_else(|_| ConditionExpr::identifier(&expr_str)),
1123 );
1124 }
1125 }
1126
1127 None
1128 }
1129
1130 fn process_boolean_attribute(
1131 &mut self,
1132 name: &str,
1133 value: Option<&str>,
1134 fragments: &mut Vec<WebUIFragment>,
1135 ) -> Result<bool> {
1136 if let Some(condition) = self.parse_boolean_condition(value) {
1137 self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
1138 return Ok(true);
1139 }
1140 Ok(false)
1142 }
1143
1144 fn process_dynamic_attribute(
1146 &mut self,
1147 name: &str,
1148 value: &str,
1149 fragments: &mut Vec<WebUIFragment>,
1150 ) -> Result<()> {
1151 if let Some(signal_name) = Self::extract_single_handlebars(value) {
1152 self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
1154 } else {
1155 let template_id = self.id_counter.next_id("attr");
1157 let parsed = self.handlebars_parser.parse(value)?;
1158
1159 self.fragment_records
1160 .insert(template_id.clone(), FragmentList { fragments: parsed });
1161
1162 self.add_fragment(
1163 WebUIFragment::attribute_template(name, template_id),
1164 fragments,
1165 );
1166 }
1167 Ok(())
1168 }
1169
1170 fn process_head_element(
1172 &mut self,
1173 node: Node,
1174 source: &str,
1175 fragments: &mut Vec<WebUIFragment>,
1176 ) -> Result<()> {
1177 self.add_raw_fragment("<head>");
1178 self.process_content_children(node, source, fragments)?;
1179 self.flush_raw_buffer(fragments);
1180 fragments.push(WebUIFragment::signal("head_end", true));
1181 self.add_raw_fragment("</head>");
1182 Ok(())
1183 }
1184
1185 fn process_body_element(
1187 &mut self,
1188 node: Node,
1189 source: &str,
1190 fragments: &mut Vec<WebUIFragment>,
1191 ) -> Result<()> {
1192 self.add_raw_fragment("<body>");
1193 self.flush_raw_buffer(fragments);
1194 fragments.push(WebUIFragment::signal("body_start", true));
1195 self.process_content_children(node, source, fragments)?;
1196 self.flush_raw_buffer(fragments);
1197 fragments.push(WebUIFragment::signal("body_end", true));
1198 self.add_raw_fragment("</body>");
1199 Ok(())
1200 }
1201
1202 fn process_comment(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
1207 let inner = content
1209 .strip_prefix("<!--")
1210 .and_then(|s| s.strip_suffix("-->"))
1211 .unwrap_or("");
1212
1213 let trimmed = inner.trim();
1214
1215 if !trimmed.contains("{{") {
1217 self.add_raw_fragment(content);
1218 return Ok(());
1219 }
1220
1221 let parsed = self.handlebars_parser.parse(trimmed)?;
1223
1224 let all_signals = parsed
1228 .iter()
1229 .all(|f| matches!(f.fragment.as_ref(), Some(Fragment::Signal(_))));
1230
1231 if all_signals && !parsed.is_empty() {
1232 for fragment in parsed {
1233 self.add_fragment(fragment, fragments);
1234 }
1235 } else {
1236 self.add_raw_fragment(content);
1238 }
1239
1240 Ok(())
1241 }
1242
1243 fn process_for_directive(
1245 &mut self,
1246 node: Node,
1247 source: &str,
1248 fragments: &mut Vec<WebUIFragment>,
1249 ) -> Result<()> {
1250 let each = self
1252 .get_element_attribute(node, "each", source)?
1253 .ok_or_else(|| {
1254 ParserError::Directive("Missing 'each' attribute on <for>".to_string())
1255 })?;
1256
1257 let parts: Vec<&str> = each.split_whitespace().collect();
1259 if parts.len() != 3 || parts[1] != "in" {
1260 return Err(ParserError::Directive(format!(
1261 "Invalid for each: {}",
1262 each
1263 )));
1264 }
1265
1266 let allowed = |s: &str| {
1268 s.chars()
1269 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1270 };
1271 if !allowed(parts[0]) || !allowed(parts[2]) {
1272 return Err(ParserError::Directive(format!(
1273 "Invalid identifier in for each: {}",
1274 each
1275 )));
1276 }
1277
1278 let item = parts[0];
1279 let collection = parts[2];
1280
1281 let fragment_id = self
1283 .get_element_attribute(node, "template", source)?
1284 .unwrap_or_else(|| self.id_counter.next_id("for"));
1285 let mut for_fragment: Vec<WebUIFragment> = Vec::new();
1286
1287 let mut temp_buffer = String::new();
1289 std::mem::swap(&mut self.raw_buffer, &mut temp_buffer);
1290
1291 self.process_content_children(node, source, &mut for_fragment)?;
1293
1294 self.flush_raw_buffer(&mut for_fragment);
1296
1297 std::mem::swap(&mut self.raw_buffer, &mut temp_buffer);
1299
1300 if for_fragment.is_empty() {
1302 return Ok(());
1303 }
1304
1305 self.fragment_records.insert(
1307 fragment_id.clone(),
1308 FragmentList {
1309 fragments: for_fragment,
1310 },
1311 );
1312
1313 self.add_for_fragment(
1315 item.to_string(),
1316 collection.to_string(),
1317 fragment_id,
1318 fragments,
1319 );
1320
1321 Ok(())
1322 }
1323
1324 fn process_if_directive(
1326 &mut self,
1327 node: Node,
1328 source: &str,
1329 fragments: &mut Vec<WebUIFragment>,
1330 ) -> Result<()> {
1331 let condition_str = self
1333 .get_element_attribute(node, "condition", source)?
1334 .ok_or_else(|| {
1335 ParserError::Directive("Missing 'condition' attribute on <if>".to_string())
1336 })?;
1337
1338 let condition_result = self.condition_parser.parse(&condition_str);
1340 let condition = match condition_result {
1341 Ok(cond) => cond,
1342 Err(_) => {
1343 return Err(ParserError::Directive(format!(
1344 "Invalid condition expression: {}",
1345 condition_str
1346 )))
1347 }
1348 };
1349
1350 let fragment_id = self.id_counter.next_id("if");
1352
1353 self.flush_raw_buffer(fragments);
1355
1356 let mut if_fragment: Vec<WebUIFragment> = Vec::new();
1358
1359 let parent_buffer = std::mem::take(&mut self.raw_buffer);
1361
1362 self.process_content_children(node, source, &mut if_fragment)?;
1364
1365 self.flush_raw_buffer(&mut if_fragment);
1367
1368 self.fragment_records.insert(
1370 fragment_id.clone(),
1371 FragmentList {
1372 fragments: if_fragment,
1373 },
1374 );
1375
1376 self.raw_buffer = parent_buffer;
1378
1379 self.add_if_fragment(condition, fragment_id, fragments);
1381
1382 Ok(())
1383 }
1384
1385 fn process_route_directive(
1391 &mut self,
1392 node: Node,
1393 source: &str,
1394 fragments: &mut Vec<WebUIFragment>,
1395 ) -> Result<()> {
1396 let path = self
1398 .get_element_attribute(node, "path", source)?
1399 .unwrap_or_default();
1400
1401 let component = self
1402 .get_element_attribute(node, "component", source)?
1403 .unwrap_or_default();
1404
1405 let exact = self.has_element_attribute(node, "exact", source)?;
1406
1407 let attrs = route_parser::RouteAttributes {
1408 path: path.clone(),
1409 component: component.clone(),
1410 exact,
1411 };
1412
1413 route_parser::validate_attributes(&attrs)?;
1415
1416 route_parser::extract_params(&path)?;
1418
1419 self.ensure_route_component_parsed(&component)?;
1421
1422 let children = self.parse_child_routes(node, source)?;
1424
1425 self.flush_raw_buffer(fragments);
1427
1428 let route_fragment =
1430 route_parser::build_route_fragment(&attrs, component.clone(), children);
1431
1432 fragments.push(WebUIFragment::route_from(route_fragment));
1434
1435 Ok(())
1436 }
1437
1438 fn parse_child_routes(&mut self, node: Node, source: &str) -> Result<Vec<WebUiFragmentRoute>> {
1440 let mut children = Vec::new();
1441 let mut cursor = node.walk();
1442
1443 for child in node.named_children(&mut cursor) {
1444 if child.kind() == "element" {
1445 if let Ok(tag) = self.get_element_tag_name(child, source) {
1446 if tag == "route" {
1447 let child_route = self.parse_route_as_fragment(child, source)?;
1448 children.push(child_route);
1449 }
1450 }
1451 }
1452 }
1453
1454 Ok(children)
1455 }
1456
1457 fn parse_route_as_fragment(&mut self, node: Node, source: &str) -> Result<WebUiFragmentRoute> {
1459 let path = self
1460 .get_element_attribute(node, "path", source)?
1461 .unwrap_or_default();
1462 let component = self
1463 .get_element_attribute(node, "component", source)?
1464 .unwrap_or_default();
1465 let exact = self.has_element_attribute(node, "exact", source)?;
1466
1467 let attrs = route_parser::RouteAttributes {
1468 path: path.clone(),
1469 component: component.clone(),
1470 exact,
1471 };
1472
1473 route_parser::validate_attributes(&attrs)?;
1474 route_parser::extract_params(&path)?;
1475
1476 self.ensure_route_component_parsed(&component)?;
1478
1479 let children = self.parse_child_routes(node, source)?;
1481
1482 Ok(route_parser::build_route_fragment(
1483 &attrs, component, children,
1484 ))
1485 }
1486
1487 fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
1489 if component.is_empty()
1490 || !self.component_registry.contains(component)
1491 || self.fragment_records.contains_key(component)
1492 {
1493 return Ok(());
1494 }
1495
1496 let component_data = self
1497 .component_registry
1498 .get(component)
1499 .ok_or_else(|| {
1500 crate::error::ParserError::Directive(format!("Component not found: {component}"))
1501 })?
1502 .clone();
1503
1504 self.token_store
1505 .extend(component_data.css_tokens.iter().cloned());
1506
1507 let processed = self.build_component_template(
1508 component,
1509 &component_data.html_content,
1510 component_data.css_content.as_deref(),
1511 );
1512
1513 if let Some(ref mut p) = self.plugin {
1514 p.register_component_template(component, &component_data, &processed)?;
1515 }
1516
1517 let saved_buffer = std::mem::take(&mut self.raw_buffer);
1518 self.parse(component, &processed)?;
1519 self.raw_buffer = saved_buffer;
1520
1521 Ok(())
1522 }
1523
1524 const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
1526 const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-", "aria-"];
1528
1529 fn is_skipped_attribute(name: &str) -> bool {
1530 Self::SKIPPED_ATTRIBUTES.contains(&name)
1531 || Self::SKIPPED_ATTRIBUTE_PREFIXES
1532 .iter()
1533 .any(|prefix| name.starts_with(prefix))
1534 }
1535
1536 fn is_blocked_complex_property(name: &str) -> bool {
1539 matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
1540 }
1541
1542 fn process_component_directive(
1543 &mut self,
1544 node: Node,
1545 source: &str,
1546 fragments: &mut Vec<WebUIFragment>,
1547 tag_name: &str,
1548 ) -> Result<()> {
1549 let tag_node = self.find_tag_node(node);
1550 let is_self_closing = tag_node
1551 .map(|n| n.kind() == "self_closing_tag")
1552 .unwrap_or(false);
1553
1554 self.add_raw_fragment(&format!("<{}", tag_name));
1556
1557 if let Some(tag_node) = tag_node {
1559 let binding_count = self.process_tag_attributes(tag_node, source, fragments, true)?;
1560 if let Some(ref mut p) = self.plugin {
1561 if let Some(data) = p.finish_element(binding_count) {
1562 self.add_fragment(WebUIFragment::plugin(data), fragments);
1563 }
1564 }
1565 }
1566
1567 if is_self_closing {
1568 let tag_text = tag_node
1569 .map(|n| &source[n.start_byte()..n.end_byte()])
1570 .unwrap_or("");
1571 if tag_text.ends_with("/>") {
1572 self.add_raw_fragment("/>");
1573 } else {
1574 self.add_raw_fragment(">");
1575 }
1576 } else {
1577 self.add_raw_fragment(">");
1578 }
1579
1580 self.flush_raw_buffer(fragments);
1582
1583 let (html_content, css_content, css_tokens) = {
1585 let component = self.component_registry.get(tag_name).ok_or_else(|| {
1586 ParserError::Directive(format!("Component not found: {}", tag_name))
1587 })?;
1588 (
1589 component.html_content.clone(),
1590 component.css_content.clone(),
1591 component.css_tokens.clone(),
1592 )
1593 };
1594
1595 self.token_store.extend(css_tokens);
1597
1598 if !self.fragment_records.contains_key(tag_name) {
1600 let component_data = self
1601 .component_registry
1602 .get(tag_name)
1603 .ok_or_else(|| {
1604 ParserError::Directive(format!("Component not found: {}", tag_name))
1605 })?
1606 .clone();
1607 let processed =
1608 self.build_component_template(tag_name, &html_content, css_content.as_deref());
1609
1610 if let Some(ref mut p) = self.plugin {
1612 p.register_component_template(tag_name, &component_data, &processed)?;
1613 }
1614
1615 self.parse(tag_name, &processed)?;
1616 }
1617
1618 fragments.push(WebUIFragment::component(tag_name.to_string()));
1620
1621 if !is_self_closing {
1623 self.process_content_children(node, source, fragments)?;
1624 }
1625
1626 if !is_self_closing {
1628 self.add_raw_fragment(&format!("</{}>", tag_name));
1629 }
1630
1631 Ok(())
1632 }
1633
1634 fn build_component_template(
1639 &mut self,
1640 tag_name: &str,
1641 html: &str,
1642 css_content: Option<&str>,
1643 ) -> String {
1644 let adopted_specifier = match self.css_strategy {
1645 CssStrategy::Module if css_content.is_some() => Some(tag_name.to_string()),
1646 _ => None,
1647 };
1648 let css_injection = match self.css_strategy {
1649 CssStrategy::Link => {
1650 if css_content.is_some() && self.dom_strategy == DomStrategy::Shadow {
1653 Some(format!(
1654 "<link rel=\"stylesheet\" href=\"/{tag_name}.css\">"
1655 ))
1656 } else {
1657 None
1658 }
1659 }
1660 CssStrategy::Style => css_content.map(|css| format!("<style>{}</style>", css.trim())),
1661 CssStrategy::Module => None,
1662 };
1663
1664 self.process_component_template(
1665 html,
1666 css_injection.as_deref(),
1667 adopted_specifier.as_deref(),
1668 )
1669 }
1670
1671 fn process_component_template(
1682 &mut self,
1683 html: &str,
1684 css_snippet: Option<&str>,
1685 adopted_specifier: Option<&str>,
1686 ) -> String {
1687 let trimmed = html.trim();
1688 let snippet = css_snippet.unwrap_or_default();
1689
1690 let has_template = trimmed.starts_with("<template");
1692 let inner = if has_template {
1693 let stripped = self.strip_runtime_attrs_from_template(trimmed);
1694 if let Some(open_end) = stripped.find('>') {
1695 let inner_start = open_end + 1;
1696 let inner_end = stripped.rfind("</template>").unwrap_or(stripped.len());
1697 if inner_start < inner_end {
1698 stripped[inner_start..inner_end].to_string()
1699 } else {
1700 String::new()
1701 }
1702 } else {
1703 trimmed.to_string()
1704 }
1705 } else {
1706 trimmed.to_string()
1707 };
1708
1709 match self.dom_strategy {
1710 DomStrategy::Shadow => {
1711 let adopted_attr = adopted_specifier.map(|spec| {
1713 let mut s = String::with_capacity(35 + spec.len());
1714 s.push_str(" shadowrootadoptedstylesheets=\"");
1715 s.push_str(spec);
1716 s.push('"');
1717 s
1718 });
1719 let adopted_ref = adopted_attr.as_deref().unwrap_or_default();
1720
1721 let mut result =
1722 String::with_capacity(45 + adopted_ref.len() + snippet.len() + inner.len());
1723 result.push_str("<template shadowrootmode=\"open\"");
1724 result.push_str(adopted_ref);
1725 result.push('>');
1726 result.push_str(snippet);
1727 result.push_str(&inner);
1728 result.push_str("</template>");
1729 result
1730 }
1731 DomStrategy::Light => {
1732 if snippet.is_empty() && adopted_specifier.is_none() {
1734 return inner;
1735 }
1736 let mut result = String::with_capacity(snippet.len() + inner.len() + 16);
1737 result.push_str(snippet);
1738 result.push_str(&inner);
1739 result
1740 }
1741 }
1742 }
1743
1744 fn strip_runtime_attrs_from_template(&mut self, html: &str) -> String {
1747 let tree = match self.parser.parse(html, None) {
1748 Some(t) => t,
1749 None => return html.to_string(),
1750 };
1751
1752 let root = tree.root_node();
1754 let start_tag = Self::find_first_node(root, "start_tag");
1755 let Some(tag) = start_tag else {
1756 return html.to_string();
1757 };
1758
1759 let mut removals: Vec<(usize, usize)> = Vec::new();
1761 let mut cursor = tag.walk();
1762 for child in tag.named_children(&mut cursor) {
1763 if child.kind() == "attribute" {
1764 let name_node = child.child(0);
1765 if let Some(name) = name_node {
1766 let attr_name = &html[name.start_byte()..name.end_byte()];
1767 if attr_name.starts_with('@')
1768 || attr_name.starts_with(':')
1769 || attr_name.starts_with('?')
1770 {
1771 let mut start = child.start_byte();
1773 while start > 0 && html.as_bytes()[start - 1] == b' ' {
1774 start -= 1;
1775 }
1776 removals.push((start, child.end_byte()));
1777 }
1778 }
1779 }
1780 }
1781
1782 if removals.is_empty() {
1783 return html.to_string();
1784 }
1785
1786 let mut result = String::with_capacity(html.len());
1788 let mut pos = 0;
1789 for (start, end) in &removals {
1790 result.push_str(&html[pos..*start]);
1791 pos = *end;
1792 }
1793 result.push_str(&html[pos..]);
1794 result
1795 }
1796
1797 fn find_first_node<'a>(root: Node<'a>, kind: &str) -> Option<Node<'a>> {
1799 let mut stack = vec![root];
1800 while let Some(node) = stack.pop() {
1801 if node.kind() == kind {
1802 return Some(node);
1803 }
1804 let mut cursor = node.walk();
1805 let children: Vec<_> = node.children(&mut cursor).collect();
1807 for child in children.into_iter().rev() {
1808 stack.push(child);
1809 }
1810 }
1811 None
1812 }
1813}
1814
1815#[cfg(test)]
1816mod tests {
1817 use webui_test_utils::*;
1818
1819 use super::*;
1820
1821 #[test]
1822 fn test_parse_signal() {
1823 let mut parser = HtmlParser::new();
1824 let html = "Hello, {{name}}!";
1825 let result = parser.parse("test.html", html);
1826
1827 assert!(result.is_ok());
1828 let fragment_records = parser.into_fragment_records();
1829
1830 assert_stream!(
1831 fragment_records,
1832 "test.html",
1833 [raw("Hello, "), signal("name"), raw("!"),]
1834 );
1835 }
1836
1837 #[test]
1838 fn test_parse_raw_signal() {
1839 let mut parser = HtmlParser::new();
1840 let html = "Hello, {{{html_content}}}!";
1841 let result = parser.parse("test.html", html);
1842
1843 assert!(result.is_ok());
1844 let fragment_records = parser.into_fragment_records();
1845
1846 assert_stream!(
1847 fragment_records,
1848 "test.html",
1849 [raw("Hello, "), signal_raw("html_content"), raw("!"),]
1850 );
1851 }
1852
1853 #[test]
1854 fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
1855 let mut parser = HtmlParser::new();
1856 let html = "<nav>{{sectionName}} > {{topicName}}</nav>";
1857 let result = parser.parse("test.html", html);
1858
1859 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1860 let fragment_records = parser.into_fragment_records();
1861
1862 assert_stream!(
1863 fragment_records,
1864 "test.html",
1865 [
1866 raw("<nav>"),
1867 signal("sectionName"),
1868 raw(" > "),
1869 signal("topicName"),
1870 raw("</nav>"),
1871 ]
1872 );
1873 }
1874
1875 #[test]
1876 fn test_parse_for_directive() {
1877 let mut parser = HtmlParser::new();
1878 let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
1879
1880 let result = parser.parse("test.html", html);
1881 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1882 let fragment_records = parser.into_fragment_records();
1883 println!("Fragment records: {:#?}", fragment_records);
1884
1885 assert_stream!(
1886 fragment_records,
1887 "test.html",
1888 [for_loop("item", "items", "for-1"),]
1889 );
1890
1891 assert_stream!(
1893 fragment_records,
1894 "for-1",
1895 [
1896 raw("<div class=\"item\">"),
1897 signal("item.name"),
1898 raw("</div>"),
1899 ]
1900 );
1901 }
1902
1903 #[test]
1904 fn test_parse_if_directive() {
1905 let mut parser = HtmlParser::new();
1906 let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
1907
1908 let result = parser.parse("test.html", html);
1909
1910 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1911 let fragment_records = parser.into_fragment_records();
1912 println!("Fragment records: {:#?}", fragment_records);
1913
1914 assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
1915
1916 assert_stream!(
1918 fragment_records,
1919 "if-1",
1920 [
1921 raw("<div>Welcome back, "),
1922 signal("username"),
1923 raw("!</div>"),
1924 ]
1925 );
1926 }
1927
1928 #[test]
1929 fn test_component_directive() {
1930 let mut parser = HtmlParser::new();
1931 parser.set_dom_strategy(DomStrategy::Light);
1932 parser
1933 .component_registry
1934 .register_component(
1935 "my-component",
1936 "<div>My Component</div>",
1937 Some("div { color: blue; }"),
1938 )
1939 .expect("Failed to register component");
1940
1941 let result = parser.parse("test.html", "<my-component></my-component>");
1942 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1943 let records = parser.into_fragment_records();
1944
1945 assert_stream!(
1946 records,
1947 "test.html",
1948 [
1949 raw("<my-component>"),
1950 component("my-component"),
1951 raw("</my-component>"),
1952 ]
1953 );
1954
1955 let comp = &records["my-component"].fragments;
1957 assert_eq!(comp.len(), 1);
1958 assert!(
1959 matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
1960 !raw.value.contains("<template shadowrootmode") && raw.value.contains("<div>My Component</div>"))
1961 );
1962 }
1963
1964 #[test]
1965 fn test_component_directive_with_slots() {
1966 let mut parser = HtmlParser::new();
1967 parser
1968 .component_registry
1969 .register_component(
1970 "my-component",
1971 "<div>My Component</div>",
1972 Some("div { color: blue; }"),
1973 )
1974 .expect("Failed to register component");
1975
1976 let result = parser.parse(
1977 "test.html",
1978 "Hello<my-component><p>World</p></my-component>",
1979 );
1980 assert!(result.is_ok(), "Parse error: {:?}", result.err());
1981 let records = parser.into_fragment_records();
1982 let fragments = &records["test.html"].fragments;
1983
1984 assert!(fragments.len() >= 3);
1986 assert!(
1988 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component>"))
1989 );
1990 assert!(fragments.iter().any(|f| matches!(
1992 f.fragment.as_ref(),
1993 Some(Fragment::Component(c)) if c.fragment_id == "my-component"
1994 )));
1995 let last = fragments.last().unwrap();
1997 assert!(
1998 matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
1999 );
2000 }
2001
2002 #[test]
2003 fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
2004 let mut parser = HtmlParser::new();
2009 parser.set_dom_strategy(DomStrategy::Shadow);
2010
2011 parser
2012 .component_registry
2013 .register_component(
2014 "app-shell",
2015 r#"<template shadowrootmode="open">
2016 <my-navbar></my-navbar>
2017 <main><outlet /></main>
2018 <cart-panel></cart-panel>
2019 <my-footer></my-footer>
2020 </template>"#,
2021 Some(":host{display:flex}"),
2022 )
2023 .expect("register app-shell");
2024 parser
2025 .component_registry
2026 .register_component("my-navbar", "<nav>Nav</nav>", Some("nav{color:red}"))
2027 .expect("register my-navbar");
2028 parser
2029 .component_registry
2030 .register_component(
2031 "cart-panel",
2032 "<aside>Cart</aside>",
2033 Some("aside{color:green}"),
2034 )
2035 .expect("register cart-panel");
2036 parser
2037 .component_registry
2038 .register_component(
2039 "my-footer",
2040 "<footer>Footer</footer>",
2041 Some("footer{color:blue}"),
2042 )
2043 .expect("register my-footer");
2044
2045 parser
2046 .parse("index.html", "<app-shell></app-shell>")
2047 .expect("parse index.html");
2048 let records = parser.into_fragment_records();
2049
2050 let app_frags = &records["app-shell"].fragments;
2052 let component_ids: Vec<&str> = app_frags
2053 .iter()
2054 .filter_map(|f| match f.fragment.as_ref() {
2055 Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
2056 _ => None,
2057 })
2058 .collect();
2059
2060 assert!(
2061 component_ids.contains(&"my-navbar"),
2062 "app-shell fragments should contain my-navbar: {component_ids:?}"
2063 );
2064 assert!(
2065 component_ids.contains(&"cart-panel"),
2066 "app-shell fragments should contain cart-panel: {component_ids:?}"
2067 );
2068 assert!(
2069 component_ids.contains(&"my-footer"),
2070 "app-shell fragments should contain my-footer: {component_ids:?}"
2071 );
2072 }
2073
2074 #[test]
2077 fn test_component_no_double_wrap_template() {
2078 let mut parser = HtmlParser::new();
2079 parser.set_dom_strategy(DomStrategy::Light);
2080 parser
2081 .component_registry
2082 .register_component(
2083 "custom-element",
2084 r#"<template foo="bar"><slot></slot></template>"#,
2085 None,
2086 )
2087 .expect("register");
2088 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2089 assert!(result.is_ok());
2090 let records = parser.into_fragment_records();
2091
2092 assert_fragments!(
2093 records["index.html"].fragments,
2094 [
2095 raw("<custom-element>"),
2096 component("custom-element"),
2097 raw("Hello</custom-element>"),
2098 ]
2099 );
2100
2101 assert_stream!(records, "custom-element", [raw("<slot></slot>"),]);
2102 }
2103
2104 #[test]
2105 fn test_component_styled_no_double_wrap() {
2106 let mut parser = HtmlParser::new();
2107 parser.set_dom_strategy(DomStrategy::Light);
2108 parser
2109 .component_registry
2110 .register_component(
2111 "custom-element",
2112 r#"<template foo="bar"><slot></slot></template>"#,
2113 Some("div { color: red; }"),
2114 )
2115 .expect("register");
2116 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2117 assert!(result.is_ok());
2118 let records = parser.into_fragment_records();
2119
2120 assert_stream!(records, "custom-element", [raw(r#"<slot></slot>"#),]);
2121 }
2122
2123 #[test]
2124 fn test_component_strip_runtime_attrs() {
2125 let mut parser = HtmlParser::new();
2126 parser.set_dom_strategy(DomStrategy::Light);
2127 parser
2128 .component_registry
2129 .register_component(
2130 "custom-element",
2131 r#"<template @click={foo} :bar="baz" ?bool="true"><slot></slot></template>"#,
2132 None,
2133 )
2134 .expect("register");
2135 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2136 assert!(result.is_ok());
2137 let records = parser.into_fragment_records();
2138
2139 assert_stream!(records, "custom-element", [raw("<slot></slot>"),]);
2140 }
2141
2142 #[test]
2143 fn test_component_with_slots_and_attrs() {
2144 let mut parser = HtmlParser::new();
2145 parser
2146 .component_registry
2147 .register_component("custom-element", "<slot></slot>", None)
2148 .expect("register");
2149 let result = parser.parse(
2150 "index.html",
2151 r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
2152 );
2153 assert!(result.is_ok());
2154 let records = parser.into_fragment_records();
2155 assert_fragments!(
2156 records["index.html"].fragments,
2157 [
2158 raw("<custom-element"),
2159 attr_raw_start("appearance", "subtle"),
2160 raw(">"),
2161 component("custom-element"),
2162 raw("Hello World</custom-element>"),
2163 ]
2164 );
2165 }
2166
2167 #[test]
2168 fn test_component_legacy_no_styles() {
2169 let mut parser = HtmlParser::new();
2170 parser.set_dom_strategy(DomStrategy::Light);
2171 parser
2172 .component_registry
2173 .register_component("custom-element", "<div>Custom Element</div>", None)
2174 .expect("register");
2175 let result = parser.parse("index.html", "<custom-element></custom-element>");
2176 assert!(result.is_ok());
2177 let records = parser.into_fragment_records();
2178
2179 assert_fragments!(
2180 records["index.html"].fragments,
2181 [
2182 raw("<custom-element>"),
2183 component("custom-element"),
2184 raw("</custom-element>"),
2185 ]
2186 );
2187
2188 assert_stream!(
2189 records,
2190 "custom-element",
2191 [raw("<div>Custom Element</div>"),]
2192 );
2193 }
2194
2195 #[test]
2196 fn test_component_self_closing() {
2197 let mut parser = HtmlParser::new();
2198 parser
2199 .component_registry
2200 .register_component("custom-widget", "<div>Widget Content</div>", None)
2201 .expect("register");
2202 let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
2203 assert!(result.is_ok());
2204 let records = parser.into_fragment_records();
2205
2206 assert_fragments!(
2207 records["index.html"].fragments,
2208 [
2209 raw("<custom-widget"),
2210 attr_start("config", "settings"),
2211 raw("/>"),
2212 component("custom-widget"),
2213 ]
2214 );
2215 }
2216
2217 #[test]
2218 fn test_component_nested_self_closing_in_slot() {
2219 let mut parser = HtmlParser::new();
2220 parser.set_dom_strategy(DomStrategy::Light);
2221 parser
2222 .component_registry
2223 .register_component("custom-icon", "<svg><slot></slot></svg>", None)
2224 .expect("register");
2225 let result = parser.parse(
2226 "index.html",
2227 r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
2228 );
2229 assert!(result.is_ok());
2230 let records = parser.into_fragment_records();
2231
2232 assert_fragments!(
2233 records["index.html"].fragments,
2234 [
2235 raw("<custom-icon>"),
2236 component("custom-icon"),
2237 raw("<use"),
2238 attr_template("href", "attr-1"),
2239 raw("/></custom-icon>"),
2240 ]
2241 );
2242
2243 assert_stream!(records, "custom-icon", [raw("<svg><slot></slot></svg>"),]);
2244 }
2245
2246 #[test]
2247 fn test_component_leading_boolean_attr_start() {
2248 let mut parser = HtmlParser::new();
2249 parser
2250 .component_registry
2251 .register_component("custom-element", "<slot></slot>", None)
2252 .expect("register");
2253 let result = parser.parse(
2254 "index.html",
2255 r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
2256 );
2257 assert!(result.is_ok());
2258 let records = parser.into_fragment_records();
2259
2260 assert_fragments!(
2261 records["index.html"].fragments,
2262 [
2263 raw("<custom-element"),
2264 bool_attr_start("disabled", "isDisabled"),
2266 attr_raw("title", "Hello"),
2268 raw(">"),
2269 component("custom-element"),
2270 raw("</custom-element>"),
2271 ]
2272 );
2273 }
2274
2275 #[test]
2276 fn test_component_boolean_predicate_preserves_condition_tree() {
2277 let mut parser = HtmlParser::new();
2278 parser
2279 .component_registry
2280 .register_component("custom-element", "<slot></slot>", None)
2281 .expect("register");
2282 let result = parser.parse(
2283 "index.html",
2284 r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
2285 );
2286 assert!(result.is_ok());
2287
2288 let records = parser.into_fragment_records();
2289 let fragments = &records["index.html"].fragments;
2290 match fragments
2291 .get(1)
2292 .and_then(|fragment| fragment.fragment.as_ref())
2293 {
2294 Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
2295 assert_eq!(attr.name, "disabled");
2296 assert!(attr.attr_start);
2297 match attr
2298 .condition_tree
2299 .as_ref()
2300 .and_then(|condition| condition.expr.as_ref())
2301 {
2302 Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
2303 assert_eq!(pred.left, "page");
2304 assert_eq!(pred.operator, 3);
2305 assert_eq!(pred.right, "'dashboard'");
2306 }
2307 other => panic!("expected predicate condition tree, got {:?}", other),
2308 }
2309 }
2310 other => panic!("expected attribute fragment, got {:?}", other),
2311 }
2312 }
2313
2314 #[test]
2315 fn test_component_meta_link_tags() {
2316 let (fragments, _) = parse_and_get_fragments(
2317 r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
2318 );
2319 assert!(fragments.len() >= 5);
2320 assert!(
2321 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<head><meta charset=\"utf-8\"") && raw.value.contains("<link"))
2322 );
2323 assert!(
2324 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
2325 );
2326 assert!(
2327 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
2328 );
2329 assert!(
2330 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if s.value == "head_end" && s.raw)
2331 );
2332 assert!(
2333 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
2334 );
2335 }
2336
2337 #[test]
2338 fn test_nested_directives() {
2339 let mut parser = HtmlParser::new();
2340 let html = r#"<for each="category in categories">
2341 <if condition="category.hasItems">
2342 <for each="item in category.items">
2343 {{item.title}}
2344 </for>
2345 </if>
2346 </for>"#;
2347
2348 let result = parser.parse("test.html", html);
2349
2350 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2351 let fragment_records = parser.into_fragment_records();
2352
2353 assert_stream!(
2354 fragment_records,
2355 "test.html",
2356 [for_loop("category", "categories", "for-1"),]
2357 );
2358
2359 assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
2360
2361 assert_stream!(
2362 fragment_records,
2363 "if-1",
2364 [for_loop("item", "category.items", "for-2"),]
2365 );
2366
2367 assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
2368 }
2369
2370 #[test]
2371 fn test_complex_directives() {
2372 let mut parser = HtmlParser::new();
2373 let html = r#"<for each="category in categories">
2374 <div class="category">
2375 <h2>{{category.name}}</h2>
2376 <if condition="category.hasItems">
2377 <ul>
2378 <for each="item in category.items">
2379 <li>{{item.title}}</li>
2380 </for>
2381 </ul>
2382 </if>
2383 </div>
2384 </for>"#;
2385
2386 let result = parser.parse("test.html", html);
2387
2388 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2389 let fragment_records = parser.into_fragment_records();
2390
2391 assert_stream!(
2392 fragment_records,
2393 "test.html",
2394 [for_loop("category", "categories", "for-1"),]
2395 );
2396
2397 assert_stream!(
2399 fragment_records,
2400 "for-1",
2401 [
2402 raw("<div class=\"category\"><h2>"),
2403 signal("category.name"),
2404 raw("</h2>"),
2405 if_cond("if-1"),
2406 raw("</div>"),
2407 ]
2408 );
2409
2410 assert_stream!(
2412 fragment_records,
2413 "if-1",
2414 [
2415 raw("<ul>"),
2416 for_loop("item", "category.items", "for-2"),
2417 raw("</ul>"),
2418 ]
2419 );
2420
2421 assert_stream!(
2423 fragment_records,
2424 "for-2",
2425 [raw("<li>"), signal("item.title"), raw("</li>"),]
2426 );
2427 }
2428
2429 fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
2433 let mut parser = HtmlParser::new();
2434 let result = parser.parse("index.html", html);
2435 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2436 let records = parser.into_fragment_records();
2437 let fragments = records
2438 .get("index.html")
2439 .expect("Failed to get index.html fragment")
2440 .fragments
2441 .clone();
2442 (fragments, records)
2443 }
2444
2445 fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
2447 let mut parser = HtmlParser::new();
2448 parser
2449 .component_registry
2450 .register_component(tag, "<div></div>", None)
2451 .expect("register");
2452 let result = parser.parse("index.html", html);
2453 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2454 let records = parser.into_fragment_records();
2455 let fragments = records
2456 .get("index.html")
2457 .expect("Failed to get index.html fragment")
2458 .fragments
2459 .clone();
2460 (fragments, records)
2461 }
2462
2463 #[test]
2464 fn test_attribute_handlebars_in_href() {
2465 let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
2467 assert_fragments!(
2468 fragments,
2469 [
2470 raw("<a"),
2471 attr("href", "url"),
2472 raw(">"),
2473 signal("name"),
2474 raw("</a>"),
2475 ]
2476 );
2477 }
2478
2479 #[test]
2480 fn test_attribute_boolean_with_handlebars() {
2481 let (fragments, _) =
2483 parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
2484 assert_fragments!(
2485 fragments,
2486 [
2487 raw("<button"),
2488 bool_attr("disabled", "isDisabled"),
2489 raw(">Click</button>"),
2490 ]
2491 );
2492 }
2493
2494 #[test]
2495 fn test_attribute_multiple_boolean() {
2496 let (fragments, _) =
2499 parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
2500
2501 assert_fragments!(
2502 fragments,
2503 [
2504 raw("<input"),
2505 bool_attr("checked", "isChecked"),
2506 bool_attr("disabled", "isDisabled"),
2507 raw("/>"),
2508 ]
2509 );
2510 }
2511
2512 #[test]
2513 fn test_attribute_boolean_and_regular_together() {
2514 let (fragments, _) = parse_and_get_fragments(
2517 r#"<input ?checked="{{isChecked}}" type="checkbox">Hi</input>"#,
2518 );
2519
2520 assert_fragments!(
2521 fragments,
2522 [
2523 raw("<input"),
2524 bool_attr("checked", "isChecked"),
2525 raw(" type=\"checkbox\">Hi</input>"),
2526 ]
2527 );
2528 }
2529
2530 #[test]
2531 fn test_attribute_boolean_sandwiched() {
2532 let (fragments, _) = parse_and_get_fragments(
2535 r#"<input version={{edition}} ?checked="{{isChecked}}" type="checkbox">Hi</input>"#,
2536 );
2537
2538 assert_fragments!(
2539 fragments,
2540 [
2541 raw("<input"),
2542 attr("version", "edition"),
2543 bool_attr("checked", "isChecked"),
2544 raw(" type=\"checkbox\">Hi</input>"),
2545 ]
2546 );
2547 }
2548
2549 #[test]
2550 fn test_attribute_boolean_ending() {
2551 let (fragments, _) = parse_and_get_fragments(
2554 r#"<input version={{edition}} ?checked="{{isChecked}}">Hi</input>"#,
2555 );
2556
2557 assert_fragments!(
2558 fragments,
2559 [
2560 raw("<input"),
2561 attr("version", "edition"),
2562 bool_attr("checked", "isChecked"),
2563 raw(">Hi</input>"),
2564 ]
2565 );
2566 }
2567
2568 #[test]
2569 fn test_attribute_boolean_dotted_path() {
2570 let (fragments, _) =
2573 parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
2574
2575 assert_fragments!(
2576 fragments,
2577 [
2578 raw("<div"),
2579 bool_attr("checked", "layout.isPinned"),
2580 raw(">Content</div>"),
2581 ]
2582 );
2583 }
2584
2585 #[test]
2586 fn test_attribute_colon_prefixed_complex() {
2587 let (fragments, _) = parse_with_component(
2590 "my-component",
2591 r#"<my-component :config="{{settings}}"></my-component>"#,
2592 );
2593
2594 assert_fragments!(
2595 fragments,
2596 [
2597 raw("<my-component"),
2598 attr_complex_start(":config", "settings"),
2599 raw(">"),
2600 component("my-component"),
2601 raw("</my-component>"),
2602 ]
2603 );
2604 }
2605
2606 #[test]
2607 fn test_attribute_multiple_colon_prefixed() {
2608 let (fragments, _) = parse_with_component(
2611 "my-component",
2612 r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
2613 );
2614
2615 assert_fragments!(
2616 fragments,
2617 [
2618 raw("<my-component"),
2619 attr_complex_start(":prop1", "val1"),
2620 attr_complex(":prop2", "val2"),
2621 raw(">"),
2622 component("my-component"),
2623 raw("</my-component>"),
2624 ]
2625 );
2626 }
2627
2628 #[test]
2629 fn test_blocked_complex_property_innerhtml() {
2630 let mut parser = HtmlParser::new();
2631 let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
2632 assert!(
2633 result.is_err(),
2634 "Expected error for :innerHTML on native element"
2635 );
2636 let err = result.unwrap_err().to_string();
2637 assert!(
2638 err.contains("only allowed on custom elements"),
2639 "Error: {err}"
2640 );
2641 }
2642
2643 #[test]
2644 fn test_blocked_complex_on_native_element() {
2645 let mut parser = HtmlParser::new();
2646 let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
2647 assert!(
2648 result.is_err(),
2649 "Expected error for :data on native element"
2650 );
2651 let err = result.unwrap_err().to_string();
2652 assert!(
2653 err.contains("only allowed on custom elements"),
2654 "Error: {err}"
2655 );
2656 }
2657
2658 #[test]
2659 fn test_blocked_complex_property_on_component() {
2660 let mut parser = HtmlParser::new();
2661 parser
2662 .component_registry
2663 .register_component("my-widget", "<div></div>", None)
2664 .expect("register");
2665 let result = parser.parse(
2666 "index.html",
2667 r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
2668 );
2669 assert!(
2670 result.is_err(),
2671 "Expected error for :innerHTML on component"
2672 );
2673 let err = result.unwrap_err().to_string();
2674 assert!(err.contains("HTML injection"), "Error: {err}");
2675 }
2676
2677 #[test]
2678 fn test_allowed_complex_property() {
2679 let (fragments, _) = parse_with_component(
2681 "my-component",
2682 r#"<my-component :config="{{settings}}"></my-component>"#,
2683 );
2684 assert_fragments!(
2685 fragments,
2686 [
2687 raw("<my-component"),
2688 attr_complex_start(":config", "settings"),
2689 raw(">"),
2690 component("my-component"),
2691 raw("</my-component>"),
2692 ]
2693 );
2694 }
2695
2696 #[test]
2697 fn test_attribute_mixed_normal_boolean_colon() {
2698 let (fragments, _) = parse_with_component(
2701 "my-component",
2702 r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
2703 );
2704
2705 assert_fragments!(
2706 fragments,
2707 [
2708 raw("<my-component"),
2709 attr_raw_start("id", "comp"),
2710 attr_complex(":config", "settings"),
2711 bool_attr("enabled", "isEnabled"),
2712 raw(">"),
2713 component("my-component"),
2714 raw("</my-component>"),
2715 ]
2716 );
2717 }
2718
2719 #[test]
2720 fn test_attribute_reject_boolean_without_handlebars() {
2721 let (fragments, _) = parse_and_get_fragments(r#"<input ?checked="name"></input>"#);
2724
2725 assert_fragments!(fragments, [raw("<input></input>"),]);
2727 }
2728
2729 #[test]
2730 fn test_attribute_reject_boolean_with_partial_handlebars() {
2731 let (fragments, _) =
2734 parse_and_get_fragments(r#"<input ?checked="Hello {{name}}"></input>"#);
2735
2736 assert_fragments!(fragments, [raw("<input></input>"),]);
2738 }
2739
2740 #[test]
2741 fn test_attribute_reject_boolean_with_plain_value() {
2742 let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
2745
2746 assert_fragments!(fragments, [raw("<button>Click</button>"),]);
2748 }
2749
2750 #[test]
2751 fn test_attribute_boolean_predicate_equal() {
2752 let (fragments, _) =
2754 parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
2755 assert_fragments!(
2756 fragments,
2757 [
2758 raw("<div"),
2759 bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
2760 raw(">X</div>"),
2761 ]
2762 );
2763 }
2764
2765 #[test]
2766 fn test_attribute_boolean_predicate_greater_than() {
2767 let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
2769 assert_fragments!(
2770 fragments,
2771 [
2772 raw("<span"),
2773 bool_attr_predicate("hidden", "num", 1, "9"),
2774 raw(">X</span>"),
2775 ]
2776 );
2777 }
2778
2779 #[test]
2780 fn test_attribute_boolean_predicate_not_equal() {
2781 let (fragments, _) =
2783 parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
2784 assert_fragments!(
2785 fragments,
2786 [
2787 raw("<a"),
2788 bool_attr_predicate("data-active", "status", 4, "'inactive'"),
2789 raw(">X</a>"),
2790 ]
2791 );
2792 }
2793
2794 #[test]
2795 fn test_attribute_boolean_negation() {
2796 let (fragments, _) =
2798 parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
2799 assert_fragments!(
2800 fragments,
2801 [
2802 raw("<button"),
2803 bool_attr_not("disabled", "isReady"),
2804 raw(">X</button>"),
2805 ]
2806 );
2807 }
2808
2809 #[test]
2810 fn test_attribute_mixed_static_dynamic() {
2811 let (fragments, records) =
2814 parse_and_get_fragments(r#"<input value="hello {{world}}">Hi</input>"#);
2815
2816 assert_fragments!(
2817 fragments,
2818 [
2819 raw("<input"),
2820 attr_template("value", "attr-1"),
2821 raw(">Hi</input>"),
2822 ]
2823 );
2824
2825 assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
2827 }
2828
2829 #[test]
2832 fn test_body_signals() {
2833 let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
2834 assert_fragments!(
2835 fragments,
2836 [
2837 raw("<body>"),
2838 signal_raw("body_start"),
2839 raw("<app-shell></app-shell>"),
2840 signal_raw("body_end"),
2841 raw("</body>"),
2842 ]
2843 );
2844 }
2845
2846 #[test]
2849 fn test_empty_for_produces_nothing() {
2850 let (fragments, records) =
2851 parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
2852 assert_fragments!(fragments, [raw("<div></div>"),]);
2853 assert!(!records.contains_key("for-1"));
2854 }
2855
2856 #[test]
2859 fn test_self_closing_svg_path() {
2860 let (fragments, _) =
2861 parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
2862 assert_fragments!(
2863 fragments,
2864 [raw(
2865 r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
2866 ),]
2867 );
2868 }
2869
2870 #[test]
2871 fn test_html5_void_elements() {
2872 let (fragments, _) = parse_and_get_fragments(
2873 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
2874 );
2875 assert_fragments!(
2876 fragments,
2877 [raw(
2878 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
2879 ),]
2880 );
2881 }
2882
2883 #[test]
2884 fn test_self_closing_with_dynamic_attributes() {
2885 let (fragments, _) =
2886 parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
2887 assert_fragments!(
2888 fragments,
2889 [
2890 raw("<img"),
2891 attr("src", "imageUrl"),
2892 attr("alt", "imageAlt"),
2893 raw("/>"),
2894 ]
2895 );
2896 }
2897
2898 #[test]
2899 fn test_self_closing_with_boolean_attributes() {
2900 let (fragments, _) = parse_and_get_fragments(
2901 r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
2902 );
2903 assert_fragments!(
2904 fragments,
2905 [
2906 raw("<input type=\"checkbox\""),
2907 bool_attr("checked", "isSelected"),
2908 bool_attr("disabled", "isDisabled"),
2909 raw("/>"),
2910 ]
2911 );
2912 }
2913
2914 #[test]
2915 fn test_multiple_self_closing_in_sequence() {
2916 let (fragments, _) =
2917 parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
2918 assert_fragments!(
2919 fragments,
2920 [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
2921 );
2922 }
2923
2924 #[test]
2925 fn test_self_closing_with_mixed_content() {
2926 let (fragments, _) =
2927 parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
2928 assert_fragments!(
2929 fragments,
2930 [
2931 raw("<div>Text before<img"),
2932 attr("src", "url"),
2933 raw("/>Text after</div>"),
2934 ]
2935 );
2936 }
2937
2938 #[test]
2939 fn test_self_closing_svg_elements() {
2940 let (fragments, _) = parse_and_get_fragments(
2941 r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
2942 );
2943 assert_fragments!(
2944 fragments,
2945 [
2946 raw("<svg><circle"),
2947 attr("cx", "x"),
2948 attr("cy", "y"),
2949 raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
2950 ]
2951 );
2952 }
2953
2954 #[test]
2955 fn test_self_closing_inside_for_loop() {
2956 let (fragments, records) = parse_and_get_fragments(
2957 r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
2958 );
2959 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
2960 assert_stream!(
2961 records,
2962 "for-1",
2963 [raw("<img"), attr("src", "item.url"), raw("/>"),]
2964 );
2965 }
2966
2967 #[test]
2968 fn test_self_closing_whitespace_variations() {
2969 let (fragments, _) =
2970 parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
2971 assert_fragments!(
2972 fragments,
2973 [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
2974 );
2975 }
2976
2977 #[test]
2978 fn test_deeply_nested_self_closing() {
2979 let (fragments, _) = parse_and_get_fragments(
2980 r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
2981 );
2982 assert_fragments!(
2983 fragments,
2984 [raw(
2985 r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
2986 ),]
2987 );
2988 }
2989
2990 #[test]
2991 fn test_self_closing_vs_empty_regular_tags() {
2992 let (fragments, _) =
2993 parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
2994 assert_fragments!(
2995 fragments,
2996 [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
2997 );
2998 }
2999
3000 #[test]
3003 fn test_for_custom_template_attribute() {
3004 let (fragments, records) = parse_and_get_fragments(
3006 r#"<for each="item in items" template="static"><span>Item</span></for>"#,
3007 );
3008 assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
3009 assert_stream!(records, "static", [raw("<span>Item</span>"),]);
3010 }
3011
3012 #[test]
3013 fn test_for_recursive_template() {
3014 let mut parser = HtmlParser::new();
3016 let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
3017 let result = parser.parse("index.html", html);
3018 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3019 let records = parser.into_fragment_records();
3020
3021 assert_fragments!(
3022 records["index.html"].fragments,
3023 [for_loop("outerItem", "outerItems", "static"),]
3024 );
3025
3026 assert_stream!(
3027 records,
3028 "static",
3029 [
3030 raw("<div><span>"),
3031 signal("outerItem.name"),
3032 raw("</span>"),
3033 for_loop("innerItem", "innerItems", "static"),
3034 raw("</div>"),
3035 ]
3036 );
3037 }
3038
3039 #[test]
3042 fn test_if_multiple_children() {
3043 let (fragments, records) =
3045 parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
3046 assert_fragments!(fragments, [if_cond("if-1"),]);
3047 assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
3048 }
3049
3050 #[test]
3051 fn test_for_multiple_children() {
3052 let (fragments, records) =
3054 parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
3055 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
3056 assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
3057 }
3058
3059 #[test]
3062 fn test_handlebars_at_beginning() {
3063 let (fragments, _) = parse_and_get_fragments("{{first}}");
3065 assert_fragments!(fragments, [signal("first"),]);
3066 }
3067
3068 #[test]
3069 fn test_handlebars_at_beginning_and_raw() {
3070 let (fragments, _) = parse_and_get_fragments("{{first}}test");
3072 assert_fragments!(fragments, [signal("first"), raw("test"),]);
3073 }
3074
3075 #[test]
3076 fn test_handlebars_raw_and_end() {
3077 let (fragments, _) = parse_and_get_fragments("test{{first}}");
3079 assert_fragments!(fragments, [raw("test"), signal("first"),]);
3080 }
3081
3082 #[test]
3085 fn test_handlebars_invalid_triple_open() {
3086 let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
3088 assert_fragments!(fragments, [raw("{{{invalid}}"),]);
3089 }
3090
3091 #[test]
3092 fn test_handlebars_four_open_braces() {
3093 let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
3095 assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
3096 }
3097
3098 #[test]
3099 fn test_handlebars_five_open_with_valid_double() {
3100 let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
3102 assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
3103 }
3104
3105 #[test]
3106 fn test_entities_preserved() {
3107 let (fragments, _) = parse_and_get_fragments("<p>Hello}World</p>");
3109 assert_fragments!(fragments, [raw("<p>Hello}World</p>"),]);
3110 }
3111
3112 #[test]
3115 fn test_doctype_preserved() {
3116 let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
3118 assert!(fragments.len() >= 1);
3119 assert!(
3120 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
3121 );
3122 }
3123
3124 #[test]
3127 fn test_component_attr_skip() {
3128 let mut parser = HtmlParser::new();
3130 parser
3131 .component_registry
3132 .register_component("custom-element", "<slot></slot>", None)
3133 .expect("register");
3134 let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
3135 let result = parser.parse("index.html", html);
3136 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3137 let records = parser.into_fragment_records();
3138
3139 assert_fragments!(
3142 records["index.html"].fragments,
3143 [
3144 raw("<custom-element"),
3145 attr_complex_start(":config", "config"),
3147 attr_skip("class", "value0"),
3149 attr_skip("style", "value1"),
3150 attr_skip("role", "value2"),
3151 attr_skip("data-test", "value3"),
3152 attr_skip("aria-test", "value4"),
3153 raw(">"),
3154 component("custom-element"),
3155 raw("</custom-element>"),
3156 ]
3157 );
3158 }
3159
3160 #[test]
3161 fn test_component_attr_skip_static_and_embedded() {
3162 let mut parser = HtmlParser::new();
3165 parser
3166 .component_registry
3167 .register_component("item-group", "<slot></slot>", None)
3168 .expect("register");
3169
3170 let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
3171 let result = parser.parse("index.html", html);
3172 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3173 let records = parser.into_fragment_records();
3174
3175 assert_fragments!(
3176 records["index.html"].fragments,
3177 [
3178 raw("<item-group"),
3179 attr_skip_raw("role", "list"),
3180 attr_skip_template("aria-labelledby", "attr-1"),
3181 attr_skip_template("data-testid", "attr-2"),
3182 attr_skip_raw("class", "fixed-class"),
3183 raw(">"),
3184 component("item-group"),
3185 raw("</item-group>"),
3186 ]
3187 );
3188
3189 assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
3192 assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
3193 }
3194
3195 #[test]
3196 fn test_component_multiple_nested() {
3197 let mut parser = HtmlParser::new();
3199 parser.set_dom_strategy(DomStrategy::Light);
3200 parser
3201 .component_registry
3202 .register_component(
3203 "custom-element",
3204 "<custom-child></custom-child><slot></slot>",
3205 None,
3206 )
3207 .expect("register");
3208 parser
3209 .component_registry
3210 .register_component("custom-button", "<slot></slot>", None)
3211 .expect("register");
3212 parser
3213 .component_registry
3214 .register_component("custom-child", "<h1>Hello World!</h1>", None)
3215 .expect("register");
3216
3217 let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
3218 let result = parser.parse("index.html", html);
3219 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3220 let records = parser.into_fragment_records();
3221
3222 assert_fragments!(
3224 records["index.html"].fragments,
3225 [for_loop("item", "items", "for-1"),]
3226 );
3227
3228 assert_stream!(
3230 records,
3231 "for-1",
3232 [
3233 raw("<custom-element>"),
3234 component("custom-element"),
3235 raw("<custom-button>"),
3236 component("custom-button"),
3237 raw("Ok</custom-button></custom-element>"),
3238 ]
3239 );
3240
3241 let ce = &records["custom-element"].fragments;
3243 assert_eq!(ce.len(), 3);
3244 assert!(
3245 matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child>"))
3246 );
3247 assert!(
3248 matches!(ce[1].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
3249 );
3250 assert!(
3251 matches!(ce[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><slot></slot>"))
3252 );
3253
3254 assert_stream!(records, "custom-button", [raw("<slot></slot>"),]);
3255
3256 assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
3257 }
3258
3259 #[test]
3262 fn test_invalid_markup_returns_error() {
3263 let mut parser = HtmlParser::new();
3266 let result = parser.parse("index.html", "<div><span>Unclosed div");
3267 assert!(result.is_ok());
3268 }
3269
3270 #[test]
3273 fn test_complex_raw_text_full_page() {
3274 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>"#;
3276 let (fragments, _) = parse_and_get_fragments(html);
3277
3278 assert!(fragments.len() >= 7);
3280 assert!(
3281 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3282 raw.value.contains("<!DOCTYPE HTML>") && raw.value.contains("<title>Test</title>"))
3283 );
3284 assert!(
3285 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
3286 s.value == "head_end" && s.raw)
3287 );
3288 assert!(
3289 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3290 raw.value.contains("</head>") && raw.value.ends_with("<body>"))
3291 );
3292 assert!(
3293 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
3294 s.value == "body_start" && s.raw)
3295 );
3296 assert!(
3297 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3298 raw.value.contains("<app-shell>"))
3299 );
3300 assert!(
3301 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
3302 s.value == "body_end" && s.raw)
3303 );
3304 assert!(
3305 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3306 raw.value.contains("</body>") && raw.value.contains("</html>"))
3307 );
3308 }
3309
3310 #[test]
3311 fn test_css_strategy_external_emits_link_tag() {
3312 let mut parser = HtmlParser::new();
3313 parser
3314 .component_registry_mut()
3315 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3316 .ok();
3317 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3318 let records = parser.into_fragment_records();
3319 let my_card = &records["my-card"].fragments;
3320 let raw_text: String = my_card
3321 .iter()
3322 .filter_map(|f| match &f.fragment {
3323 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3324 _ => None,
3325 })
3326 .collect();
3327 assert!(
3328 raw_text.contains(r#"<link rel="stylesheet" href="/my-card.css">"#),
3329 "Expected external <link> tag in: {}",
3330 raw_text
3331 );
3332 }
3333
3334 #[test]
3335 fn test_css_strategy_inline_emits_style_tag() {
3336 let mut parser = HtmlParser::new();
3337 parser.set_css_strategy(CssStrategy::Style);
3338 parser
3339 .component_registry_mut()
3340 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3341 .ok();
3342 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3343 let records = parser.into_fragment_records();
3344 let my_card = &records["my-card"].fragments;
3345 let raw_text: String = my_card
3346 .iter()
3347 .filter_map(|f| match &f.fragment {
3348 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3349 _ => None,
3350 })
3351 .collect();
3352 assert!(
3353 raw_text.contains("<style>p { color: red; }</style>"),
3354 "Expected inline <style> tag in: {}",
3355 raw_text
3356 );
3357 assert!(
3358 !raw_text.contains("<link"),
3359 "Should not have <link> tag in inline mode: {}",
3360 raw_text
3361 );
3362 }
3363
3364 #[test]
3365 fn test_css_strategy_module_emits_adopted_stylesheets() {
3366 let mut parser = HtmlParser::new();
3367 parser.set_dom_strategy(DomStrategy::Light);
3368 parser.set_css_strategy(CssStrategy::Module);
3369 parser
3370 .component_registry_mut()
3371 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3372 .ok();
3373 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3374 let records = parser.into_fragment_records();
3375
3376 let my_card = &records["my-card"].fragments;
3379 let template_text: String = my_card
3380 .iter()
3381 .filter_map(|f| match &f.fragment {
3382 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3383 _ => None,
3384 })
3385 .collect();
3386 assert!(
3387 !template_text.contains("shadowrootadoptedstylesheets"),
3388 "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
3389 );
3390 assert!(
3391 !template_text.contains("<link"),
3392 "Should not have <link> in module mode: {template_text}"
3393 );
3394 assert!(
3397 !template_text.contains(r#"<style type="module""#),
3398 "CSS module should NOT be in raw fragments: {template_text}"
3399 );
3400 }
3401
3402 #[test]
3403 fn test_css_strategy_module_no_css_no_adopted_attr() {
3404 let mut parser = HtmlParser::new();
3405 parser.set_css_strategy(CssStrategy::Module);
3406 parser
3407 .component_registry_mut()
3408 .register_component("my-card", "<p><slot></slot></p>", None)
3409 .ok();
3410 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3411 let records = parser.into_fragment_records();
3412 let my_card = &records["my-card"].fragments;
3413 let template_text: String = my_card
3414 .iter()
3415 .filter_map(|f| match &f.fragment {
3416 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3417 _ => None,
3418 })
3419 .collect();
3420 assert!(
3422 !template_text.contains("shadowrootadoptedstylesheets"),
3423 "Should not have adopted attr without CSS: {template_text}"
3424 );
3425 }
3426
3427 #[test]
3441 fn test_body_start_end_injection() {
3442 let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
3446 let (fragments, _) = parse_and_get_fragments(html);
3447
3448 assert_fragments!(
3449 fragments,
3450 [
3451 raw("<html><head><title>Test</title>"),
3452 signal_raw("head_end"),
3453 raw("</head><body>"),
3454 signal_raw("body_start"),
3455 raw("<div>Content</div><p>More</p>"),
3456 signal_raw("body_end"),
3457 raw("</body></html>"),
3458 ]
3459 );
3460 }
3461
3462 #[test]
3463 fn test_fail_with_invalid_markup() {
3464 let mut parser = HtmlParser::new();
3470 let result = parser.parse("index.html", "<div><span></div></span>");
3471
3472 assert!(
3474 result.is_ok(),
3475 "tree-sitter is lenient and recovers from overlapping tags"
3476 );
3477 }
3478
3479 #[test]
3480 fn test_complex_raw_text_page() {
3481 let html = concat!(
3484 "<!DOCTYPE html>",
3485 "<html lang=\"en\">",
3486 "<head>",
3487 "<meta charset=\"utf-8\">",
3488 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
3489 "<title>Complex Page</title>",
3490 "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
3491 "<link rel=\"stylesheet\" href=\"styles.css\">",
3492 "</head>",
3493 "<body>",
3494 "<h1>Hello World</h1>",
3495 "<script type=\"module\" src=\"./app.js\"></script>",
3496 "</body>",
3497 "</html>",
3498 );
3499 let (fragments, _) = parse_and_get_fragments(html);
3500
3501 assert!(
3504 fragments.len() >= 7,
3505 "Expected at least 7 fragments, got {}",
3506 fragments.len()
3507 );
3508
3509 assert!(
3511 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3512 raw.value.contains("<!DOCTYPE html>") &&
3513 raw.value.contains("<meta charset=\"utf-8\">") &&
3514 raw.value.contains("<meta name=\"viewport\"") &&
3515 raw.value.contains("<title>Complex Page</title>") &&
3516 raw.value.contains("<style>") &&
3517 raw.value.contains("body { margin: 0; padding: 0; }")),
3518 "First fragment should contain all head content, got: {:?}",
3519 fragments[0]
3520 );
3521
3522 assert!(
3524 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
3525 s.value == "head_end" && s.raw),
3526 "Second fragment should be head_end signal"
3527 );
3528
3529 assert!(
3531 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3532 raw.value.contains("</head>") && raw.value.ends_with("<body>")),
3533 "Third fragment should contain </head><body>"
3534 );
3535
3536 assert!(
3538 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
3539 s.value == "body_start" && s.raw),
3540 "Fourth fragment should be body_start signal"
3541 );
3542
3543 assert!(
3545 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3546 raw.value.contains("<h1>Hello World</h1>") &&
3547 raw.value.contains("<script")),
3548 "Fifth fragment should contain body content"
3549 );
3550
3551 assert!(
3553 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
3554 s.value == "body_end" && s.raw),
3555 "Sixth fragment should be body_end signal"
3556 );
3557
3558 assert!(
3560 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3561 raw.value.contains("</body>") && raw.value.contains("</html>")),
3562 "Seventh fragment should contain closing tags"
3563 );
3564 }
3565
3566 struct BindingCountPlugin {
3570 counts: Vec<u32>,
3571 }
3572
3573 impl BindingCountPlugin {
3574 fn new() -> Self {
3575 Self { counts: Vec::new() }
3576 }
3577 }
3578
3579 impl crate::plugin::ParserPlugin for BindingCountPlugin {
3580 fn register_component_template(
3581 &mut self,
3582 _tag_name: &str,
3583 _component: &Component,
3584 _processed_template: &str,
3585 ) -> Result<()> {
3586 Ok(())
3587 }
3588
3589 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
3590 if attr_name.starts_with('@') || attr_name == "f-ref" {
3591 AttributeAction::SkipAndCountBinding
3592 } else {
3593 AttributeAction::Keep
3594 }
3595 }
3596
3597 fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
3598 self.counts.push(binding_attribute_count);
3599 if binding_attribute_count > 0 {
3600 Some(binding_attribute_count.to_le_bytes().to_vec())
3601 } else {
3602 None
3603 }
3604 }
3605 }
3606
3607 #[test]
3608 fn test_component_static_attrs_not_counted_as_bindings() {
3609 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3610 parser
3611 .component_registry
3612 .register_component("my-btn", "<button><slot></slot></button>", None)
3613 .expect("register");
3614
3615 let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
3617 let result = parser.parse("index.html", html);
3618 assert!(result.is_ok());
3619
3620 let records = parser.into_fragment_records();
3621
3622 let frags = &records["index.html"].fragments;
3624 let plugin_count = frags
3625 .iter()
3626 .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
3627 .count();
3628 assert_eq!(
3629 plugin_count, 0,
3630 "Static-only component attributes should not emit a Plugin fragment"
3631 );
3632 }
3633
3634 #[test]
3635 fn test_component_dynamic_attr_counted_as_binding() {
3636 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3637 parser
3638 .component_registry
3639 .register_component("my-btn", "<button><slot></slot></button>", None)
3640 .expect("register");
3641
3642 let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
3644 let result = parser.parse("index.html", html);
3645 assert!(result.is_ok());
3646
3647 let records = parser.into_fragment_records();
3648 let frags = &records["index.html"].fragments;
3649
3650 let plugin_frags: Vec<_> = frags
3652 .iter()
3653 .filter_map(|f| match f.fragment.as_ref() {
3654 Some(Fragment::Plugin(p)) => Some(&p.data),
3655 _ => None,
3656 })
3657 .collect();
3658 assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
3659 let count = u32::from_le_bytes([
3660 plugin_frags[0][0],
3661 plugin_frags[0][1],
3662 plugin_frags[0][2],
3663 plugin_frags[0][3],
3664 ]);
3665 assert_eq!(count, 1, "Binding attribute count should be 1");
3666 }
3667
3668 #[test]
3669 fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
3670 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3671 parser
3672 .component_registry
3673 .register_component("my-btn", "<button><slot></slot></button>", None)
3674 .expect("register");
3675
3676 let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
3678 let result = parser.parse("index.html", html);
3679 assert!(result.is_ok());
3680
3681 let records = parser.into_fragment_records();
3682 let frags = &records["index.html"].fragments;
3683
3684 let plugin_frags: Vec<_> = frags
3685 .iter()
3686 .filter_map(|f| match f.fragment.as_ref() {
3687 Some(Fragment::Plugin(p)) => Some(&p.data),
3688 _ => None,
3689 })
3690 .collect();
3691 assert_eq!(plugin_frags.len(), 1);
3692 let count = u32::from_le_bytes([
3693 plugin_frags[0][0],
3694 plugin_frags[0][1],
3695 plugin_frags[0][2],
3696 plugin_frags[0][3],
3697 ]);
3698 assert_eq!(
3700 count, 2,
3701 "Binding count should include dynamic + plugin-skipped attrs, not static"
3702 );
3703 }
3704
3705 #[test]
3706 fn test_component_only_skipped_plugin_attrs_counted() {
3707 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
3708 parser
3709 .component_registry
3710 .register_component("my-btn", "<button><slot></slot></button>", None)
3711 .expect("register");
3712
3713 let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
3715 let result = parser.parse("index.html", html);
3716 assert!(result.is_ok());
3717
3718 let records = parser.into_fragment_records();
3719 let frags = &records["index.html"].fragments;
3720
3721 let plugin_frags: Vec<_> = frags
3722 .iter()
3723 .filter_map(|f| match f.fragment.as_ref() {
3724 Some(Fragment::Plugin(p)) => Some(&p.data),
3725 _ => None,
3726 })
3727 .collect();
3728 assert_eq!(plugin_frags.len(), 1);
3729 let count = u32::from_le_bytes([
3730 plugin_frags[0][0],
3731 plugin_frags[0][1],
3732 plugin_frags[0][2],
3733 plugin_frags[0][3],
3734 ]);
3735 assert_eq!(
3737 count, 2,
3738 "Only plugin-skipped attrs should be counted, not static title"
3739 );
3740 }
3741
3742 #[test]
3745 fn test_comment_handlebars_signal() {
3746 let mut parser = HtmlParser::new();
3747 let html = "<!--{{tokens}}-->";
3748 parser.parse("test.html", html).expect("parse failed");
3749 let records = parser.into_fragment_records();
3750
3751 assert_stream!(records, "test.html", [signal("tokens")]);
3752 }
3753
3754 #[test]
3755 fn test_comment_triple_brace_raw_signal() {
3756 let mut parser = HtmlParser::new();
3757 let html = "<!--{{{tokens}}}-->";
3758 parser.parse("test.html", html).expect("parse failed");
3759 let records = parser.into_fragment_records();
3760
3761 assert_stream!(records, "test.html", [signal_raw("tokens")]);
3762 }
3763
3764 #[test]
3765 fn test_comment_regular_preserved() {
3766 let mut parser = HtmlParser::new();
3767 let html = "<!-- regular comment -->";
3768 parser.parse("test.html", html).expect("parse failed");
3769 let records = parser.into_fragment_records();
3770
3771 assert_stream!(records, "test.html", [raw("<!-- regular comment -->")]);
3772 }
3773
3774 #[test]
3775 fn test_comment_dotted_signal() {
3776 let mut parser = HtmlParser::new();
3777 let html = "<!--{{tokens.light}}-->";
3778 parser.parse("test.html", html).expect("parse failed");
3779 let records = parser.into_fragment_records();
3780
3781 assert_stream!(records, "test.html", [signal("tokens.light")]);
3782 }
3783
3784 #[test]
3785 fn test_comment_arbitrary_identifier() {
3786 let mut parser = HtmlParser::new();
3787 let html = "<!--{{someOtherBinding}}-->";
3788 parser.parse("test.html", html).expect("parse failed");
3789 let records = parser.into_fragment_records();
3790
3791 assert_stream!(records, "test.html", [signal("someOtherBinding")]);
3792 }
3793
3794 #[test]
3795 fn test_comment_with_surrounding_content() {
3796 let mut parser = HtmlParser::new();
3797 let html = "<div><!--{{tokens}}--></div>";
3798 parser.parse("test.html", html).expect("parse failed");
3799 let records = parser.into_fragment_records();
3800
3801 assert_stream!(
3802 records,
3803 "test.html",
3804 [raw("<div>"), signal("tokens"), raw("</div>")]
3805 );
3806 }
3807
3808 #[test]
3811 fn test_tokens_from_style_tag() {
3812 let mut parser = HtmlParser::new();
3813 let html = r#"<style>
3814 .btn { color: var(--colorPrimary); background: var(--bgColor); }
3815 </style>"#;
3816 parser.parse("test.html", html).expect("parse failed");
3817 let tokens = parser.take_tokens();
3818
3819 assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
3820 }
3821
3822 #[test]
3823 fn test_tokens_from_component_css() {
3824 let mut parser = HtmlParser::new();
3825 parser
3826 .component_registry_mut()
3827 .register_component(
3828 "my-button",
3829 "<button>Click</button>",
3830 Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
3831 )
3832 .expect("register failed");
3833
3834 let html = "<my-button></my-button>";
3835 parser.parse("test.html", html).expect("parse failed");
3836 let tokens = parser.take_tokens();
3837
3838 assert_eq!(tokens, vec!["borderWidth", "textColor"]);
3839 }
3840
3841 #[test]
3842 fn test_tokens_merged_from_style_and_components() {
3843 let mut parser = HtmlParser::new();
3844 parser
3845 .component_registry_mut()
3846 .register_component(
3847 "my-widget",
3848 "<div>Widget</div>",
3849 Some(".w { padding: var(--spacingM); }"),
3850 )
3851 .expect("register failed");
3852
3853 let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
3854 parser.parse("test.html", html).expect("parse failed");
3855 let tokens = parser.take_tokens();
3856
3857 assert_eq!(tokens, vec!["spacingM", "textColor"]);
3858 }
3859
3860 #[test]
3861 fn test_tokens_deduplicated() {
3862 let mut parser = HtmlParser::new();
3863 parser
3864 .component_registry_mut()
3865 .register_component(
3866 "my-btn",
3867 "<button>B</button>",
3868 Some(".b { color: var(--shared); }"),
3869 )
3870 .expect("register failed");
3871
3872 let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
3873 parser.parse("test.html", html).expect("parse failed");
3874 let tokens = parser.take_tokens();
3875
3876 assert_eq!(tokens, vec!["shared"]);
3877 }
3878
3879 #[test]
3880 fn test_tokens_empty_when_no_vars() {
3881 let mut parser = HtmlParser::new();
3882 let html = "<div>Hello</div>";
3883 parser.parse("test.html", html).expect("parse failed");
3884 let tokens = parser.take_tokens();
3885
3886 assert!(tokens.is_empty());
3887 }
3888
3889 #[test]
3890 fn test_tokens_exclude_locally_defined_in_component() {
3891 let mut parser = HtmlParser::new();
3892 parser
3893 .component_registry_mut()
3894 .register_component(
3895 "my-card",
3896 "<div>Card</div>",
3897 Some(":host { --local: 5px; width: var(--external); }"),
3898 )
3899 .expect("register failed");
3900
3901 let html = "<my-card></my-card>";
3902 parser.parse("test.html", html).expect("parse failed");
3903 let tokens = parser.take_tokens();
3904
3905 assert_eq!(tokens, vec!["external"]);
3906 }
3907
3908 #[test]
3909 fn test_tokens_exclude_entry_root_definitions() {
3910 let mut parser = HtmlParser::new();
3911 parser
3912 .component_registry_mut()
3913 .register_component(
3914 "my-btn",
3915 "<button>B</button>",
3916 Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
3917 )
3918 .expect("register failed");
3919
3920 let html = r#"<style>
3923 :root {
3924 --color-primary: #0078d4;
3925 --radius-m: 6px;
3926 }
3927 body { color: var(--color-primary); }
3928 </style>
3929 <my-btn></my-btn>"#;
3930 parser.parse("test.html", html).expect("parse failed");
3931 let tokens = parser.take_tokens();
3932
3933 assert!(
3935 tokens.is_empty(),
3936 "Tokens defined in entry :root should be excluded: {tokens:?}"
3937 );
3938 }
3939
3940 #[test]
3941 fn test_tokens_entry_defs_exclude_but_external_kept() {
3942 let mut parser = HtmlParser::new();
3943 parser
3944 .component_registry_mut()
3945 .register_component(
3946 "my-card",
3947 "<div>Card</div>",
3948 Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
3949 )
3950 .expect("register failed");
3951
3952 let html = r#"<style>
3954 :root { --color-primary: #0078d4; }
3955 </style>
3956 <my-card></my-card>"#;
3957 parser.parse("test.html", html).expect("parse failed");
3958 let tokens = parser.take_tokens();
3959
3960 assert_eq!(tokens, vec!["external-spacing"]);
3962 }
3963
3964 #[test]
3967 fn test_parse_simple_route() {
3968 let mut parser = HtmlParser::new();
3969 let html = r#"<route path="/profile" component="profile-page" exact />"#;
3970 parser.parse("test.html", html).expect("parse failed");
3971
3972 let records = parser.into_fragment_records();
3974 let frags = &records["test.html"].fragments;
3975 assert_eq!(frags.len(), 1);
3976 match frags[0].fragment.as_ref() {
3977 Some(web_ui_fragment::Fragment::Route(r)) => {
3978 assert_eq!(r.path, "/profile");
3979 assert_eq!(r.fragment_id, "profile-page");
3980 assert!(r.exact);
3981 }
3982 other => panic!("expected Fragment::Route, got {:?}", other),
3983 }
3984 }
3985
3986 #[test]
3987 fn test_parse_route_with_params() {
3988 let mut parser = HtmlParser::new();
3989 let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
3990 parser.parse("test.html", html).expect("parse failed");
3991
3992 let records = parser.into_fragment_records();
3994 let frags = &records["test.html"].fragments;
3995 match frags[0].fragment.as_ref() {
3996 Some(web_ui_fragment::Fragment::Route(r)) => {
3997 assert_eq!(r.path, "/profile/:id/view/:section");
3998 }
3999 other => panic!("expected Fragment::Route, got {:?}", other),
4000 }
4001 }
4002
4003 #[test]
4004 fn test_parse_route_requires_component() {
4005 let mut parser = HtmlParser::new();
4006 let html = r#"<route path="/old" />"#;
4007 let result = parser.parse("test.html", html);
4008 assert!(result.is_err());
4009 }
4010
4011 #[test]
4012 fn test_parse_multiple_routes() {
4013 let mut parser = HtmlParser::new();
4014 let html = r#"<route path="/" component="app-layout" />
4015 <route path="/dashboard" component="dash-page" exact />
4016 <route path="/contacts" component="contacts-page" exact />"#;
4017 parser.parse("test.html", html).expect("parse failed");
4018
4019 let records = parser.into_fragment_records();
4020 let frags = &records["test.html"].fragments;
4021 assert_eq!(frags.len(), 3);
4022 for frag in frags {
4024 assert!(matches!(
4025 frag.fragment.as_ref(),
4026 Some(web_ui_fragment::Fragment::Route(_))
4027 ));
4028 }
4029 }
4030
4031 #[test]
4032 fn test_parse_route_requires_component_with_body() {
4033 let mut parser = HtmlParser::new();
4034 let html = r#"<route path="/error">
4035 <div class="error"><h1>Not Found</h1></div>
4036 </route>"#;
4037 let result = parser.parse("test.html", html);
4038 assert!(
4039 result.is_err(),
4040 "Route without component attribute should fail"
4041 );
4042 }
4043
4044 #[test]
4045 fn test_parse_multiple_routes_with_fragments() {
4046 let mut parser = HtmlParser::new();
4047 let html = r#"<route path="/" component="home-page" exact />
4048 <route path="/about" component="about-page" exact />
4049 <route path="/contact/:id" component="contact-page" />"#;
4050 parser.parse("test.html", html).expect("parse failed");
4051
4052 let records = parser.into_fragment_records();
4053 let frags = &records["test.html"].fragments;
4054 assert_eq!(frags.len(), 3);
4055
4056 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
4058 assert_eq!(r.path, "/");
4059 }
4060 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
4061 assert_eq!(r.path, "/about");
4062 }
4063 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
4064 assert_eq!(r.path, "/contact/:id");
4065 }
4066 }
4067
4068 #[test]
4069 fn test_outlet_not_captured_by_for_loop() {
4070 let mut parser = HtmlParser::new();
4071 let html = r#"<template shadowrootmode="open">
4072 <ul>
4073 <for each="item in items">
4074 <li>{{item.name}}</li>
4075 </for>
4076 </ul>
4077 <main>
4078 <outlet />
4079 </main>
4080</template>"#;
4081 parser.parse("comp.html", html).expect("parse failed");
4082
4083 let records = parser.into_fragment_records();
4084 let frags = &records["comp.html"].fragments;
4085
4086 let frag_types: Vec<String> = frags
4088 .iter()
4089 .map(|f| match f.fragment.as_ref() {
4090 Some(web_ui_fragment::Fragment::Raw(r)) => {
4091 format!("Raw({:?})", &r.value[..r.value.len().min(40)])
4092 }
4093 Some(web_ui_fragment::Fragment::ForLoop(fl)) => {
4094 format!("ForLoop({})", fl.fragment_id)
4095 }
4096 Some(web_ui_fragment::Fragment::Outlet(_)) => "Outlet".to_string(),
4097 other => format!("{:?}", other),
4098 })
4099 .collect();
4100 eprintln!("Fragment order: {:#?}", frag_types);
4101
4102 let outlet_count = frags
4104 .iter()
4105 .filter(|f| {
4106 matches!(
4107 f.fragment.as_ref(),
4108 Some(web_ui_fragment::Fragment::Outlet(_))
4109 )
4110 })
4111 .count();
4112 assert_eq!(
4113 outlet_count, 1,
4114 "expected exactly 1 outlet in top-level fragments, got {outlet_count}. Fragments: {frag_types:?}"
4115 );
4116
4117 let outlet_idx = frags
4119 .iter()
4120 .position(|f| {
4121 matches!(
4122 f.fragment.as_ref(),
4123 Some(web_ui_fragment::Fragment::Outlet(_))
4124 )
4125 })
4126 .expect("no outlet found");
4127 let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
4128 Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
4129 _ => false,
4130 });
4131 if let Some(ul_idx) = close_ul_idx {
4132 assert!(
4133 outlet_idx > ul_idx,
4134 "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx}). Fragments: {frag_types:?}"
4135 );
4136 }
4137 }
4138
4139 #[test]
4140 fn test_outlet_position_after_for_not_inside() {
4141 let mut parser = HtmlParser::new();
4142 let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
4143 parser.parse("test.html", html).expect("parse failed");
4144
4145 let records = parser.into_fragment_records();
4146 let frags = &records["test.html"].fragments;
4147
4148 let has_outlet = frags.iter().any(|f| {
4150 matches!(
4151 f.fragment.as_ref(),
4152 Some(web_ui_fragment::Fragment::Outlet(_))
4153 )
4154 });
4155 assert!(
4156 has_outlet,
4157 "outlet should be in top-level fragments: {frags:?}"
4158 );
4159
4160 let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
4162 Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
4163 _ => None,
4164 });
4165 if let Some(id) = for_id {
4166 let for_frags = &records[&id].fragments;
4167 let outlet_in_for = for_frags.iter().any(|f| {
4168 matches!(
4169 f.fragment.as_ref(),
4170 Some(web_ui_fragment::Fragment::Outlet(_))
4171 )
4172 });
4173 assert!(
4174 !outlet_in_for,
4175 "outlet should NOT be inside for-loop body: {for_frags:?}"
4176 );
4177 }
4178 }
4179
4180 #[test]
4181 fn test_style_element_with_handlebars_signal() {
4182 let mut parser = HtmlParser::new();
4183 let html = r#"<html><head><style>
4184:root {
4185 /*{{{tokens.light}}}*/
4186}
4187</style></head><body></body></html>"#;
4188
4189 let result = parser.parse("test.html", html);
4190 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4191 let fragment_records = parser.into_fragment_records();
4192
4193 assert_stream!(
4195 fragment_records,
4196 "test.html",
4197 [
4198 raw("<html><head><style>\n:root {\n "),
4199 signal_raw("tokens.light"),
4200 raw("\n}\n</style>"),
4201 signal_raw("head_end"),
4202 raw("</head><body>"),
4203 signal_raw("body_start"),
4204 signal_raw("body_end"),
4205 raw("</body></html>"),
4206 ]
4207 );
4208 }
4209
4210 #[test]
4211 fn test_style_comment_signal_with_spaces() {
4212 let mut parser = HtmlParser::new();
4213 let html = r#"<html><head><style>
4214:root {
4215 /* {{{tokens.light}}} */
4216}
4217</style></head><body></body></html>"#;
4218
4219 let result = parser.parse("test.html", html);
4220 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4221 let fragment_records = parser.into_fragment_records();
4222
4223 assert_stream!(
4225 fragment_records,
4226 "test.html",
4227 [
4228 raw("<html><head><style>\n:root {\n "),
4229 signal_raw("tokens.light"),
4230 raw("\n}\n</style>"),
4231 signal_raw("head_end"),
4232 raw("</head><body>"),
4233 signal_raw("body_start"),
4234 signal_raw("body_end"),
4235 raw("</body></html>"),
4236 ]
4237 );
4238 }
4239
4240 #[test]
4241 fn test_style_comment_signal_double_brace() {
4242 let mut parser = HtmlParser::new();
4243 let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
4244
4245 let result = parser.parse("test.html", html);
4246 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4247 let fragment_records = parser.into_fragment_records();
4248
4249 assert_stream!(
4251 fragment_records,
4252 "test.html",
4253 [
4254 raw("<html><head><style>"),
4255 signal("themeCss"),
4256 raw("</style>"),
4257 signal_raw("head_end"),
4258 raw("</head><body>"),
4259 signal_raw("body_start"),
4260 signal_raw("body_end"),
4261 raw("</body></html>"),
4262 ]
4263 );
4264 }
4265
4266 #[test]
4267 fn test_style_comment_with_extra_text_stays_raw() {
4268 let mut parser = HtmlParser::new();
4269 let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
4271
4272 let result = parser.parse("test.html", html);
4273 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4274 let fragment_records = parser.into_fragment_records();
4275
4276 assert_stream!(
4277 fragment_records,
4278 "test.html",
4279 [
4280 raw("<html><head><style>/* theme: {{token}} */</style>"),
4281 signal_raw("head_end"),
4282 raw("</head><body>"),
4283 signal_raw("body_start"),
4284 signal_raw("body_end"),
4285 raw("</body></html>"),
4286 ]
4287 );
4288 }
4289
4290 #[test]
4291 fn test_style_comment_with_multiple_signals_stays_raw() {
4292 let mut parser = HtmlParser::new();
4293 let html = "<html><head><style>/*{{a}}{{b}}*/</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!(
4301 fragment_records,
4302 "test.html",
4303 [
4304 raw("<html><head><style>/*{{a}}{{b}}*/</style>"),
4305 signal_raw("head_end"),
4306 raw("</head><body>"),
4307 signal_raw("body_start"),
4308 signal_raw("body_end"),
4309 raw("</body></html>"),
4310 ]
4311 );
4312 }
4313
4314 #[test]
4315 fn test_style_bare_handlebars_stays_raw() {
4316 let mut parser = HtmlParser::new();
4317 let html =
4319 "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
4320
4321 let result = parser.parse("test.html", html);
4322 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4323 let fragment_records = parser.into_fragment_records();
4324
4325 assert_stream!(
4326 fragment_records,
4327 "test.html",
4328 [
4329 raw("<html><head><style>body { color: {{textColor}}; }</style>"),
4330 signal_raw("head_end"),
4331 raw("</head><body>"),
4332 signal_raw("body_start"),
4333 signal_raw("body_end"),
4334 raw("</body></html>"),
4335 ]
4336 );
4337 }
4338
4339 #[test]
4340 fn test_style_mixed_css_and_comment_signal() {
4341 let mut parser = HtmlParser::new();
4342 let html = r#"<html><head><style>
4343 .a { color: red; }
4344 /*{{themeCss}}*/
4345 .b { color: blue; }
4346</style></head><body></body></html>"#;
4347
4348 let result = parser.parse("test.html", html);
4349 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4350 let fragment_records = parser.into_fragment_records();
4351
4352 assert_stream!(
4353 fragment_records,
4354 "test.html",
4355 [
4356 raw("<html><head><style>\n .a { color: red; }\n "),
4357 signal("themeCss"),
4358 raw("\n .b { color: blue; }\n</style>"),
4359 signal_raw("head_end"),
4360 raw("</head><body>"),
4361 signal_raw("body_start"),
4362 signal_raw("body_end"),
4363 raw("</body></html>"),
4364 ]
4365 );
4366 }
4367
4368 #[test]
4369 fn test_style_element_plain_css_unchanged() {
4370 let mut parser = HtmlParser::new();
4371 let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
4372
4373 let result = parser.parse("test.html", html);
4374 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4375 let fragment_records = parser.into_fragment_records();
4376
4377 assert_stream!(
4379 fragment_records,
4380 "test.html",
4381 [
4382 raw("<html><head><style>body { margin: 0; }</style>"),
4383 signal_raw("head_end"),
4384 raw("</head><body>"),
4385 signal_raw("body_start"),
4386 signal_raw("body_end"),
4387 raw("</body></html>"),
4388 ]
4389 );
4390 }
4391}