1mod component_registry;
8mod condition_parser;
9mod css_parser;
10mod error;
11mod handlebars_parser;
12pub mod plugin;
13mod route_parser;
14mod tag_scan;
15
16pub use component_registry::{Component, ComponentRegistry};
17pub use condition_parser::ConditionParser;
18pub use css_parser::CssParser;
19pub use error::{ParserError, Result};
20pub use handlebars_parser::HandlebarsParser;
21
22use crate::plugin::{AttributeAction, ParserPlugin, ParserPluginArtifacts};
23use std::collections::{HashMap, HashSet};
24use tree_sitter::{Node, Parser, Query, QueryCursor, StreamingIteratorMut};
25use tree_sitter_html::LANGUAGE;
26use webui_protocol::{
27 web_ui_fragment, web_ui_fragment::Fragment, ConditionExpr, FragmentList, WebUIFragment,
28 WebUIFragmentAttribute, WebUIFragmentRecords, WebUiFragmentRoute,
29};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
34pub enum CssStrategy {
35 #[default]
37 Link,
38 Style,
40 Module,
43}
44
45impl std::fmt::Display for CssStrategy {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 match self {
48 CssStrategy::Link => write!(f, "link"),
49 CssStrategy::Style => write!(f, "style"),
50 CssStrategy::Module => write!(f, "module"),
51 }
52 }
53}
54
55impl std::str::FromStr for CssStrategy {
56 type Err = String;
57
58 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
59 match s {
60 "link" => Ok(CssStrategy::Link),
61 "style" => Ok(CssStrategy::Style),
62 "module" => Ok(CssStrategy::Module),
63 other => Err(format!(
64 "Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
65 )),
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
73pub enum DomStrategy {
74 #[default]
76 Shadow,
77 Light,
79}
80
81impl std::fmt::Display for DomStrategy {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 DomStrategy::Shadow => write!(f, "shadow"),
85 DomStrategy::Light => write!(f, "light"),
86 }
87 }
88}
89
90impl std::str::FromStr for DomStrategy {
91 type Err = String;
92
93 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
94 match s {
95 "shadow" => Ok(DomStrategy::Shadow),
96 "light" => Ok(DomStrategy::Light),
97 other => Err(format!(
98 "Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
99 )),
100 }
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
107pub enum Plugin {
108 #[cfg_attr(feature = "cli", value(name = "fast"))]
110 Fast,
111 #[cfg_attr(feature = "cli", value(name = "fast-v2"))]
113 FastV2,
114 #[cfg_attr(feature = "cli", value(name = "fast-v3"))]
116 FastV3,
117 #[cfg_attr(feature = "cli", value(name = "webui"))]
119 WebUI,
120}
121
122impl std::fmt::Display for Plugin {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 match self {
125 Plugin::Fast => write!(f, "fast"),
126 Plugin::FastV2 => write!(f, "fast-v2"),
127 Plugin::FastV3 => write!(f, "fast-v3"),
128 Plugin::WebUI => write!(f, "webui"),
129 }
130 }
131}
132
133impl std::str::FromStr for Plugin {
134 type Err = String;
135
136 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
137 match s {
138 "fast" => Ok(Plugin::Fast),
139 "fast-v2" => Ok(Plugin::FastV2),
140 "fast-v3" => Ok(Plugin::FastV3),
141 "webui" => Ok(Plugin::WebUI),
142 other => Err(format!(
143 "Unknown plugin: {other}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
144 )),
145 }
146 }
147}
148
149struct FragmentIdCounter {
151 counters: HashMap<String, usize>,
153}
154
155impl FragmentIdCounter {
156 fn new() -> Self {
158 Self {
159 counters: HashMap::new(),
160 }
161 }
162
163 fn next_id(&mut self, prefix: &str) -> String {
165 let count = self.counters.entry(prefix.to_string()).or_insert(0);
166 *count += 1;
167 format!("{}-{}", prefix, count)
168 }
169}
170
171impl Default for HtmlParser {
172 fn default() -> Self {
173 Self::new()
174 }
175}
176
177pub struct HtmlParser {
179 css_parser: CssParser,
181
182 parser: Parser,
184
185 id_counter: FragmentIdCounter,
187
188 condition_parser: ConditionParser,
190
191 handlebars_parser: HandlebarsParser,
193
194 component_registry: ComponentRegistry,
196
197 fragment_records: WebUIFragmentRecords,
199
200 raw_buffer: String,
202
203 css_strategy: CssStrategy,
205
206 dom_strategy: DomStrategy,
208
209 plugin: Option<Box<dyn ParserPlugin>>,
211
212 token_store: HashSet<String>,
215
216 token_definitions: HashSet<String>,
220}
221
222impl HtmlParser {
223 pub fn new() -> Self {
225 let mut parser = Parser::new();
226 #[allow(clippy::disallowed_methods)]
228 parser
229 .set_language(&LANGUAGE.into())
230 .expect("Error loading HTML grammar");
231
232 Self {
233 component_registry: ComponentRegistry::new(),
234 css_parser: CssParser::new(),
235 id_counter: FragmentIdCounter::new(),
236 condition_parser: ConditionParser::new(),
237 handlebars_parser: HandlebarsParser::new(),
238 raw_buffer: String::new(),
239 fragment_records: WebUIFragmentRecords::new(),
240 css_strategy: CssStrategy::default(),
241 dom_strategy: DomStrategy::default(),
242 plugin: None,
243 token_store: HashSet::new(),
244 token_definitions: HashSet::new(),
245 parser,
246 }
247 }
248
249 pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
251 let mut p = Self::new();
252 p.plugin = Some(plugin);
253 p
254 }
255
256 pub fn set_css_strategy(&mut self, strategy: CssStrategy) -> &mut Self {
258 self.css_strategy = strategy;
259 self
260 }
261
262 pub fn set_dom_strategy(&mut self, strategy: DomStrategy) -> &mut Self {
264 self.dom_strategy = strategy;
265 self
266 }
267
268 pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
270 &mut self.component_registry
271 }
272
273 pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
274 std::mem::take(&mut self.fragment_records)
275 }
276
277 pub fn has_fragment(&self, fragment_id: &str) -> bool {
279 self.fragment_records.contains_key(fragment_id)
280 }
281
282 #[must_use]
284 pub fn take_plugin_artifacts(&mut self) -> ParserPluginArtifacts {
285 self.plugin
286 .take()
287 .map_or(ParserPluginArtifacts::None, |plugin| {
288 plugin.into_artifacts()
289 })
290 }
291
292 #[must_use]
300 pub fn take_tokens(&mut self) -> Vec<String> {
301 let definitions = std::mem::take(&mut self.token_definitions);
302 let mut tokens: Vec<String> = std::mem::take(&mut self.token_store)
303 .into_iter()
304 .filter(|t| !definitions.contains(t))
305 .collect();
306 tokens.sort();
307 tokens
308 }
309
310 pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
312 self.raw_buffer.clear();
314 if let Some(ref mut plugin) = self.plugin {
315 plugin.start_fragment(fragment_id);
316 }
317
318 let tree = self
320 .parser
321 .parse(html_content, None)
322 .ok_or_else(|| ParserError::Html("Failed to parse HTML".to_string()))?;
323
324 let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
325
326 self.process_html_node(tree.root_node(), html_content, &mut entry_fragment)?;
328
329 self.flush_raw_buffer(&mut entry_fragment);
330
331 self.fragment_records.insert(
333 fragment_id.to_string(),
334 FragmentList {
335 fragments: entry_fragment,
336 },
337 );
338
339 Ok(())
341 }
342
343 fn add_raw_fragment(&mut self, content: &str) {
345 if !content.is_empty() {
346 self.raw_buffer.push_str(content);
347 }
348 }
349
350 fn add_for_fragment(
352 &mut self,
353 item: String,
354 collection: String,
355 fragment_id: String,
356 fragments: &mut Vec<WebUIFragment>,
357 ) {
358 self.flush_raw_buffer(fragments);
359 fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
360 }
361
362 fn add_if_fragment(
364 &mut self,
365 condition: ConditionExpr,
366 fragment_id: String,
367 fragments: &mut Vec<WebUIFragment>,
368 ) {
369 self.flush_raw_buffer(fragments);
370 fragments.push(WebUIFragment::if_cond(condition, fragment_id));
371 }
372
373 fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
375 self.flush_raw_buffer(fragments);
376 fragments.push(fragment);
377 }
378
379 fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
381 if !self.raw_buffer.is_empty() {
382 fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
383 }
384 }
385
386 fn should_emit_text_content(content: &str) -> bool {
393 if content.is_empty() {
394 return false;
395 }
396
397 if !content.trim().is_empty() {
398 return true;
399 }
400
401 content.chars().all(char::is_whitespace)
402 && !content.contains('\n')
403 && !content.contains('\r')
404 }
405
406 fn process_children_with_source_gaps(
412 &mut self,
413 node: Node,
414 source: &str,
415 fragments: &mut Vec<WebUIFragment>,
416 skip_structural_tags: bool,
417 ) -> Result<()> {
418 let mut cursor = node.walk();
419 let mut last_end = node.start_byte();
420
421 for child in node.children(&mut cursor) {
422 if child.start_byte() > last_end {
423 let gap = &source[last_end..child.start_byte()];
424 if Self::should_emit_text_content(gap) {
425 self.add_raw_fragment(gap);
426 }
427 }
428
429 let kind = child.kind();
430 if !skip_structural_tags || (kind != "start_tag" && kind != "end_tag") {
431 self.process_child_node(child, source, fragments)?;
432 }
433
434 last_end = child.end_byte();
435 }
436
437 if node.end_byte() > last_end {
438 let gap = &source[last_end..node.end_byte()];
439 if Self::should_emit_text_content(gap) {
440 self.add_raw_fragment(gap);
441 }
442 }
443
444 Ok(())
445 }
446
447 fn extract_style_comment_signals(
458 &mut self,
459 css: &str,
460 fragments: &mut Vec<WebUIFragment>,
461 ) -> Result<()> {
462 let bytes = css.as_bytes();
463 let len = bytes.len();
464 let mut pos = 0;
465
466 while pos < len {
467 let Some(open) = css[pos..].find("/*") else {
469 self.add_raw_fragment(&css[pos..]);
471 break;
472 };
473 let open = pos + open;
474
475 let Some(close_offset) = css[open + 2..].find("*/") else {
477 self.add_raw_fragment(&css[pos..]);
479 break;
480 };
481 let close = open + 2 + close_offset;
482 let after_close = close + 2;
483
484 let inner = css[open + 2..close].trim();
486
487 let is_signal = if !inner.is_empty() {
490 match self.handlebars_parser.parse(inner) {
491 Ok(ref parsed) if parsed.len() == 1 => {
492 matches!(parsed[0].fragment.as_ref(), Some(Fragment::Signal(_)))
493 }
494 _ => false,
495 }
496 } else {
497 false
498 };
499
500 if is_signal {
501 self.add_raw_fragment(&css[pos..open]);
503
504 let parsed = self.handlebars_parser.parse(inner)?;
506 self.add_fragment(parsed.into_iter().next().unwrap_or_default(), fragments);
507 } else {
508 self.add_raw_fragment(&css[pos..after_close]);
511 }
512
513 pos = after_close;
514 }
515
516 Ok(())
517 }
518
519 fn process_content_children(
521 &mut self,
522 node: Node,
523 source: &str,
524 fragments: &mut Vec<WebUIFragment>,
525 ) -> Result<()> {
526 self.process_children_with_source_gaps(node, source, fragments, true)
527 }
528 fn process_html_node(
530 &mut self,
531 node: Node,
532 source: &str,
533 fragments: &mut Vec<WebUIFragment>,
534 ) -> Result<()> {
535 if node.kind() == "document" || node.kind() == "fragment" || node.kind() == "element" {
536 self.process_children_with_source_gaps(node, source, fragments, false)?;
537 } else {
538 let content = &source[node.start_byte()..node.end_byte()];
540 if Self::should_emit_text_content(content) {
541 self.add_raw_fragment(content);
542 }
543 }
544
545 Ok(())
546 }
547
548 fn process_child_node(
550 &mut self,
551 node: Node,
552 source: &str,
553 fragments: &mut Vec<WebUIFragment>,
554 ) -> Result<()> {
555 match node.kind() {
556 "element" => {
557 let tag_name = self.get_element_tag_name(node, source)?;
559
560 match tag_name.as_str() {
562 "for" => return self.process_for_directive(node, source, fragments),
563 "if" => return self.process_if_directive(node, source, fragments),
564 "body" => return self.process_body_element(node, source, fragments),
565 "head" => return self.process_head_element(node, source, fragments),
566 "route" => return self.process_route_directive(node, source, fragments),
567 "outlet" => {
568 self.flush_raw_buffer(fragments);
569 fragments.push(WebUIFragment::outlet());
570 return Ok(());
571 }
572 _ => {
573 if self.component_registry.contains(tag_name.as_str()) {
574 return self.process_component_directive(
575 node,
576 source,
577 fragments,
578 tag_name.as_str(),
579 );
580 }
581
582 self.process_regular_element(node, source, fragments, &tag_name)?;
584
585 return Ok(());
586 }
587 }
588 }
589 "style_element" => {
590 self.add_raw_fragment("<style>");
593 for child in node.named_children(&mut node.walk()) {
594 if child.kind() == "raw_text" {
595 let style_content = &source[child.start_byte()..child.end_byte()];
596
597 if let Ok((tokens, defs)) = self
600 .css_parser
601 .extract_tokens_and_definitions(style_content)
602 {
603 self.token_store.extend(tokens);
604 self.token_definitions.extend(defs);
605 }
606
607 self.extract_style_comment_signals(style_content, fragments)?;
611 }
612 }
613 self.add_raw_fragment("</style>");
614 }
615 "text" | "raw_text" => {
616 let content = &source[node.start_byte()..node.end_byte()];
617 if Self::should_emit_text_content(content) {
618 let handlebars_result = self.handlebars_parser.parse(content);
619 match handlebars_result {
620 Ok(parsed_fragments) => {
621 for fragment in parsed_fragments {
622 if matches!(fragment.fragment.as_ref(), Some(Fragment::Raw(_))) {
623 if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
624 self.add_raw_fragment(&raw.value);
625 }
626 } else {
627 self.add_fragment(fragment, fragments);
628 }
629 }
630 }
631 Err(error) => return Err(error),
632 }
633 }
634 }
635 "doctype" => {
640 let content = &source[node.start_byte()..node.end_byte()];
641 self.add_raw_fragment(content);
642 }
643 "comment" => {
645 let content = &source[node.start_byte()..node.end_byte()];
646 self.process_comment(content, fragments)?;
647 }
648 _ => {
650 self.process_html_node(node, source, fragments)?;
651 }
652 }
653
654 Ok(())
655 }
656
657 fn get_element_tag_name(&self, node: Node, source: &str) -> Result<String> {
659 let mut cursor = node.walk();
660 for child in node.named_children(&mut cursor) {
661 if child.kind() == "start_tag" || child.kind() == "self_closing_tag" {
662 let mut tag_cursor = child.walk();
663 for tag_name_node in child.named_children(&mut tag_cursor) {
664 if tag_name_node.kind() == "tag_name" {
665 let tag_name = source[tag_name_node.start_byte()..tag_name_node.end_byte()]
666 .to_string();
667 return Ok(tag_name);
668 }
669 }
670 }
671 }
672
673 Err(ParserError::Html("Failed to extract tag name".to_string()))
674 }
675
676 fn get_element_attribute(
677 &self,
678 node: Node,
679 attr_name: &str,
680 source: &str,
681 ) -> Result<Option<String>> {
682 let query_str = r#"
683 (element
684 [
685 (start_tag
686 (attribute
687 (attribute_name) @name
688 [(quoted_attribute_value (attribute_value) @value)
689 (attribute_value) @value]))
690 (self_closing_tag
691 (attribute
692 (attribute_name) @name
693 [(quoted_attribute_value (attribute_value) @value)
694 (attribute_value) @value]))
695 ]
696 )
697 "#;
698
699 let query = Query::new(&LANGUAGE.into(), query_str)
700 .map_err(|e| ParserError::Html(format!("Failed to create attribute query: {:?}", e)))?;
701
702 let mut cursor = QueryCursor::new();
703 let mut matches = cursor.matches(&query, node, source.as_bytes());
704 while let Some(m) = matches.next_mut() {
705 let mut found_name = false;
706 let mut found_value: Option<String> = None;
707
708 for capture in m.captures.iter() {
709 let node = capture.node;
710 let capture_name = query.capture_names()[capture.index as usize];
711
712 if capture_name == "name" {
713 let name_text = node.utf8_text(source.as_bytes()).map_err(|_| {
714 ParserError::Html("Invalid UTF-8 for attribute name".to_string())
715 })?;
716 if name_text == attr_name {
717 found_name = true;
718 }
719 } else if capture_name == "value" {
720 let value_text = node.utf8_text(source.as_bytes()).map_err(|_| {
721 ParserError::Html("Invalid UTF-8 for attribute value".to_string())
722 })?;
723 found_value = Some(value_text.to_string());
724 }
725 }
726
727 if found_name {
728 return Ok(found_value);
729 }
730 }
731
732 Ok(None)
733 }
734
735 fn has_element_attribute(&self, node: Node, attr_name: &str, source: &str) -> Result<bool> {
738 if self
740 .get_element_attribute(node, attr_name, source)?
741 .is_some()
742 {
743 return Ok(true);
744 }
745
746 let query_str = r#"
750 (element
751 [
752 (start_tag
753 (attribute
754 (attribute_name) @name))
755 (self_closing_tag
756 (attribute
757 (attribute_name) @name))
758 ]
759 )
760 "#;
761
762 let query = Query::new(&LANGUAGE.into(), query_str)
763 .map_err(|e| ParserError::Html(format!("Failed to create attribute query: {:?}", e)))?;
764
765 let tag_node_id = node
767 .named_children(&mut node.walk())
768 .find(|c| c.kind() == "start_tag" || c.kind() == "self_closing_tag")
769 .map(|c| c.id());
770
771 let mut cursor = QueryCursor::new();
772 let mut matches = cursor.matches(&query, node, source.as_bytes());
773 while let Some(m) = matches.next_mut() {
774 for capture in m.captures.iter() {
775 let capture_name = query.capture_names()[capture.index as usize];
776 if capture_name == "name" {
777 let attr_parent = capture.node.parent().and_then(|a| a.parent());
779 if attr_parent
780 .map(|p| Some(p.id()) == tag_node_id)
781 .unwrap_or(false)
782 {
783 let name_text =
784 capture.node.utf8_text(source.as_bytes()).map_err(|_| {
785 ParserError::Html("Invalid UTF-8 for attribute name".to_string())
786 })?;
787 if name_text == attr_name {
788 return Ok(true);
789 }
790 }
791 }
792 }
793 }
794
795 Ok(false)
796 }
797
798 fn find_tag_node<'a>(&self, node: Node<'a>) -> Option<Node<'a>> {
800 let mut cursor = node.walk();
801 let result = node
802 .named_children(&mut cursor)
803 .find(|child| child.kind() == "start_tag" || child.kind() == "self_closing_tag");
804 result
805 }
806
807 fn extract_single_handlebars(value: &str) -> Option<String> {
810 let trimmed = value.trim();
811 if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
812 let inner = trimmed[2..trimmed.len() - 2].trim();
813 if !inner.contains("{{") && !inner.is_empty() {
815 return Some(inner.to_string());
816 }
817 }
818 None
819 }
820
821 fn contains_handlebars(value: &str) -> bool {
823 value.contains("{{")
824 }
825
826 fn has_end_tag(&self, node: Node) -> bool {
828 let mut cursor = node.walk();
829 let result = node
830 .named_children(&mut cursor)
831 .any(|child| child.kind() == "end_tag");
832 result
833 }
834
835 fn process_regular_element(
837 &mut self,
838 node: Node,
839 source: &str,
840 fragments: &mut Vec<WebUIFragment>,
841 tag_name: &str,
842 ) -> Result<()> {
843 let tag_node = self.find_tag_node(node);
844 let is_self_closing = tag_node
845 .map(|n| n.kind() == "self_closing_tag")
846 .unwrap_or(false);
847 let has_end = self.has_end_tag(node);
848
849 if let Some(tag_node) = tag_node {
850 self.add_raw_fragment(&format!("<{}", tag_name));
852
853 let binding_count = self.process_tag_attributes(tag_node, source, fragments, false)?;
855 if let Some(ref mut p) = self.plugin {
856 if let Some(data) = p.finish_element(binding_count) {
857 self.add_fragment(WebUIFragment::plugin(data), fragments);
858 }
859 }
860
861 if is_self_closing {
862 let tag_text = &source[tag_node.start_byte()..tag_node.end_byte()];
864 if tag_text.ends_with("/>") {
865 self.add_raw_fragment("/>");
866 } else {
867 self.add_raw_fragment(">");
868 }
869 } else if !has_end {
870 self.add_raw_fragment(">");
875 self.process_content_children(node, source, fragments)?;
876 } else {
877 self.add_raw_fragment(">");
878
879 self.process_content_children(node, source, fragments)?;
880
881 self.add_raw_fragment(&format!("</{}>", tag_name));
883 }
884 }
885
886 Ok(())
887 }
888
889 fn process_tag_attributes(
896 &mut self,
897 tag_node: Node,
898 source: &str,
899 fragments: &mut Vec<WebUIFragment>,
900 is_component: bool,
901 ) -> Result<u32> {
902 let mut first_dynamic_emitted = false;
903 let mut binding_count: u32 = 0;
904 let mut cursor = tag_node.walk();
905
906 for child in tag_node.named_children(&mut cursor) {
907 if child.kind() != "attribute" {
908 continue;
909 }
910
911 let attr_name = self.get_attr_name(child, source)?;
912
913 if let Some(ref mut p) = self.plugin {
914 match p.classify_attribute(&attr_name) {
915 AttributeAction::Keep => {}
916 AttributeAction::Skip => continue,
917 AttributeAction::SkipAndCountBinding => {
918 binding_count += 1;
919 continue;
920 }
921 }
922 }
923
924 let attr_value = self.get_attr_value(child, source);
925
926 if let Some(bool_name) = attr_name.strip_prefix('?') {
927 if is_component {
929 if let Some(condition) = self.parse_boolean_condition(attr_value.as_deref()) {
930 let frag = Self::maybe_mark_attr_start(
931 WebUIFragment::attribute_boolean(bool_name, condition),
932 &mut first_dynamic_emitted,
933 );
934 self.add_fragment(frag, fragments);
935 binding_count += 1;
936 }
937 } else if self.process_boolean_attribute(
938 bool_name,
939 attr_value.as_deref(),
940 fragments,
941 )? {
942 binding_count += 1;
943 }
944 } else if let Some(prop_name) = attr_name.strip_prefix(':') {
945 if !is_component {
951 return Err(ParserError::Parse(format!(
952 ":{prop_name} complex binding is only allowed on custom elements. \
953 Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
954 )));
955 }
956 if Self::is_blocked_complex_property(prop_name) {
957 return Err(ParserError::Parse(format!(
958 ":{prop_name} is not allowed as a complex attribute binding \
959 because it enables arbitrary HTML injection. \
960 Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
961 )));
962 }
963 if let Some(val) = &attr_value {
964 if let Some(signal_name) = Self::extract_single_handlebars(val) {
965 let frag = Self::maybe_mark_attr_start(
966 WebUIFragment::attribute_complex(&attr_name, signal_name),
967 &mut first_dynamic_emitted,
968 );
969 self.add_fragment(frag, fragments);
970 binding_count += 1;
971 }
972 }
973 } else if is_component && Self::is_skipped_attribute(&attr_name) {
974 if let Some(val) = &attr_value {
976 if let Some(signal_name) = Self::extract_single_handlebars(val) {
977 let frag = WebUIFragment {
979 fragment: Some(web_ui_fragment::Fragment::Attribute(
980 WebUIFragmentAttribute {
981 name: attr_name,
982 value: signal_name,
983 attr_skip: true,
984 ..Default::default()
985 },
986 )),
987 };
988 self.add_fragment(frag, fragments);
989 binding_count += 1;
990 } else if Self::contains_handlebars(val) {
991 let template_id = self.id_counter.next_id("attr");
993 let parsed = self.handlebars_parser.parse(val)?;
994 self.fragment_records
995 .insert(template_id.clone(), FragmentList { fragments: parsed });
996 let frag = WebUIFragment {
997 fragment: Some(web_ui_fragment::Fragment::Attribute(
998 WebUIFragmentAttribute {
999 name: attr_name,
1000 template: template_id,
1001 attr_skip: true,
1002 ..Default::default()
1003 },
1004 )),
1005 };
1006 self.add_fragment(frag, fragments);
1007 binding_count += 1;
1008 } else {
1009 let frag = WebUIFragment {
1011 fragment: Some(web_ui_fragment::Fragment::Attribute(
1012 WebUIFragmentAttribute {
1013 name: attr_name,
1014 value: val.clone(),
1015 raw_value: true,
1016 attr_skip: true,
1017 ..Default::default()
1018 },
1019 )),
1020 };
1021 self.add_fragment(frag, fragments);
1022 }
1023 }
1024 } else if let Some(ref val) = attr_value {
1025 if Self::contains_handlebars(val) {
1026 if is_component {
1027 if let Some(signal_name) = Self::extract_single_handlebars(val) {
1028 let frag = Self::maybe_mark_attr_start(
1029 WebUIFragment::attribute(&attr_name, signal_name),
1030 &mut first_dynamic_emitted,
1031 );
1032 self.add_fragment(frag, fragments);
1033 binding_count += 1;
1034 } else {
1035 let template_id = self.id_counter.next_id("attr");
1036 let parsed = self.handlebars_parser.parse(val)?;
1037 self.fragment_records
1038 .insert(template_id.clone(), FragmentList { fragments: parsed });
1039 let frag = Self::maybe_mark_attr_start(
1040 WebUIFragment::attribute_template(&attr_name, template_id),
1041 &mut first_dynamic_emitted,
1042 );
1043 self.add_fragment(frag, fragments);
1044 binding_count += 1;
1045 }
1046 } else {
1047 self.process_dynamic_attribute(&attr_name, val, fragments)?;
1048 binding_count += 1;
1049 }
1050 } else if is_component {
1051 let frag = Self::maybe_mark_attr_start(
1054 WebUIFragment {
1055 fragment: Some(web_ui_fragment::Fragment::Attribute(
1056 WebUIFragmentAttribute {
1057 name: attr_name,
1058 value: val.clone(),
1059 raw_value: true,
1060 ..Default::default()
1061 },
1062 )),
1063 },
1064 &mut first_dynamic_emitted,
1065 );
1066 self.add_fragment(frag, fragments);
1067 } else {
1068 let attr_text = &source[child.start_byte()..child.end_byte()];
1070 self.add_raw_fragment(&format!(" {}", attr_text));
1071 }
1072 } else {
1073 self.add_raw_fragment(&format!(" {}", attr_name));
1075 }
1076 }
1077 Ok(binding_count)
1078 }
1079
1080 fn maybe_mark_attr_start(
1083 mut frag: WebUIFragment,
1084 first_dynamic_emitted: &mut bool,
1085 ) -> WebUIFragment {
1086 if !*first_dynamic_emitted {
1087 if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
1088 a.attr_start = true;
1089 }
1090 *first_dynamic_emitted = true;
1091 }
1092 frag
1093 }
1094
1095 fn get_attr_name(&self, attr_node: Node, source: &str) -> Result<String> {
1097 let mut cursor = attr_node.walk();
1098 for child in attr_node.children(&mut cursor) {
1099 if child.kind() == "attribute_name" {
1100 return Ok(source[child.start_byte()..child.end_byte()].to_string());
1101 }
1102 }
1103 Err(ParserError::Html(
1104 "Attribute node missing attribute_name".to_string(),
1105 ))
1106 }
1107
1108 fn get_attr_value(&self, attr_node: Node, source: &str) -> Option<String> {
1111 let mut cursor = attr_node.walk();
1112 for child in attr_node.children(&mut cursor) {
1113 match child.kind() {
1114 "quoted_attribute_value" => {
1115 let mut inner_cursor = child.walk();
1117 for inner in child.children(&mut inner_cursor) {
1118 if inner.kind() == "attribute_value" {
1119 return Some(source[inner.start_byte()..inner.end_byte()].to_string());
1120 }
1121 }
1122 return Some(String::new());
1124 }
1125 "attribute_value" => {
1126 return Some(source[child.start_byte()..child.end_byte()].to_string());
1127 }
1128 _ => {}
1129 }
1130 }
1131 None
1132 }
1133
1134 fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
1137 if let Some(val) = value {
1138 if let Some(expr_str) = Self::extract_single_handlebars(val) {
1139 return Some(
1140 self.condition_parser
1141 .parse(&expr_str)
1142 .unwrap_or_else(|_| ConditionExpr::identifier(&expr_str)),
1143 );
1144 }
1145 }
1146
1147 None
1148 }
1149
1150 fn process_boolean_attribute(
1151 &mut self,
1152 name: &str,
1153 value: Option<&str>,
1154 fragments: &mut Vec<WebUIFragment>,
1155 ) -> Result<bool> {
1156 if let Some(condition) = self.parse_boolean_condition(value) {
1157 self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
1158 return Ok(true);
1159 }
1160 Ok(false)
1162 }
1163
1164 fn process_dynamic_attribute(
1166 &mut self,
1167 name: &str,
1168 value: &str,
1169 fragments: &mut Vec<WebUIFragment>,
1170 ) -> Result<()> {
1171 if let Some(signal_name) = Self::extract_single_handlebars(value) {
1172 self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
1174 } else {
1175 let template_id = self.id_counter.next_id("attr");
1177 let parsed = self.handlebars_parser.parse(value)?;
1178
1179 self.fragment_records
1180 .insert(template_id.clone(), FragmentList { fragments: parsed });
1181
1182 self.add_fragment(
1183 WebUIFragment::attribute_template(name, template_id),
1184 fragments,
1185 );
1186 }
1187 Ok(())
1188 }
1189
1190 fn process_head_element(
1192 &mut self,
1193 node: Node,
1194 source: &str,
1195 fragments: &mut Vec<WebUIFragment>,
1196 ) -> Result<()> {
1197 self.add_raw_fragment("<head>");
1198 self.process_content_children(node, source, fragments)?;
1199 self.flush_raw_buffer(fragments);
1200 fragments.push(WebUIFragment::signal("head_end", true));
1201 self.add_raw_fragment("</head>");
1202 Ok(())
1203 }
1204
1205 fn process_body_element(
1207 &mut self,
1208 node: Node,
1209 source: &str,
1210 fragments: &mut Vec<WebUIFragment>,
1211 ) -> Result<()> {
1212 let tag_node = self.find_tag_node(node);
1213 if let Some(tag_node) = tag_node {
1214 self.add_raw_fragment("<body");
1215 let binding_count = self.process_tag_attributes(tag_node, source, fragments, false)?;
1216 if let Some(ref mut p) = self.plugin {
1217 if let Some(data) = p.finish_element(binding_count) {
1218 self.add_fragment(WebUIFragment::plugin(data), fragments);
1219 }
1220 }
1221 self.add_raw_fragment(">");
1222 } else {
1223 self.add_raw_fragment("<body>");
1224 }
1225 self.flush_raw_buffer(fragments);
1226 fragments.push(WebUIFragment::signal("body_start", true));
1227 self.process_content_children(node, source, fragments)?;
1228 self.flush_raw_buffer(fragments);
1229 fragments.push(WebUIFragment::signal("body_end", true));
1230 self.add_raw_fragment("</body>");
1231 Ok(())
1232 }
1233
1234 fn process_comment(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
1239 let inner = content
1241 .strip_prefix("<!--")
1242 .and_then(|s| s.strip_suffix("-->"))
1243 .unwrap_or("");
1244
1245 let trimmed = inner.trim();
1246
1247 if !trimmed.contains("{{") {
1249 self.add_raw_fragment(content);
1250 return Ok(());
1251 }
1252
1253 let parsed = self.handlebars_parser.parse(trimmed)?;
1255
1256 let all_signals = parsed
1260 .iter()
1261 .all(|f| matches!(f.fragment.as_ref(), Some(Fragment::Signal(_))));
1262
1263 if all_signals && !parsed.is_empty() {
1264 for fragment in parsed {
1265 self.add_fragment(fragment, fragments);
1266 }
1267 } else {
1268 self.add_raw_fragment(content);
1270 }
1271
1272 Ok(())
1273 }
1274
1275 fn process_for_directive(
1277 &mut self,
1278 node: Node,
1279 source: &str,
1280 fragments: &mut Vec<WebUIFragment>,
1281 ) -> Result<()> {
1282 let each = self
1284 .get_element_attribute(node, "each", source)?
1285 .ok_or_else(|| {
1286 ParserError::Directive("Missing 'each' attribute on <for>".to_string())
1287 })?;
1288
1289 let parts: Vec<&str> = each.split_whitespace().collect();
1291 if parts.len() != 3 || parts[1] != "in" {
1292 return Err(ParserError::Directive(format!(
1293 "Invalid for each: {}",
1294 each
1295 )));
1296 }
1297
1298 let allowed = |s: &str| {
1300 s.chars()
1301 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1302 };
1303 if !allowed(parts[0]) || !allowed(parts[2]) {
1304 return Err(ParserError::Directive(format!(
1305 "Invalid identifier in for each: {}",
1306 each
1307 )));
1308 }
1309
1310 let item = parts[0];
1311 let collection = parts[2];
1312
1313 let fragment_id = self
1315 .get_element_attribute(node, "template", source)?
1316 .unwrap_or_else(|| self.id_counter.next_id("for"));
1317 let mut for_fragment: Vec<WebUIFragment> = Vec::new();
1318
1319 let mut temp_buffer = String::new();
1321 std::mem::swap(&mut self.raw_buffer, &mut temp_buffer);
1322
1323 self.process_content_children(node, source, &mut for_fragment)?;
1325
1326 self.flush_raw_buffer(&mut for_fragment);
1328
1329 std::mem::swap(&mut self.raw_buffer, &mut temp_buffer);
1331
1332 if for_fragment.is_empty() {
1334 return Ok(());
1335 }
1336
1337 self.fragment_records.insert(
1339 fragment_id.clone(),
1340 FragmentList {
1341 fragments: for_fragment,
1342 },
1343 );
1344
1345 self.add_for_fragment(
1347 item.to_string(),
1348 collection.to_string(),
1349 fragment_id,
1350 fragments,
1351 );
1352
1353 Ok(())
1354 }
1355
1356 fn process_if_directive(
1358 &mut self,
1359 node: Node,
1360 source: &str,
1361 fragments: &mut Vec<WebUIFragment>,
1362 ) -> Result<()> {
1363 let condition_str = self
1365 .get_element_attribute(node, "condition", source)?
1366 .ok_or_else(|| {
1367 ParserError::Directive("Missing 'condition' attribute on <if>".to_string())
1368 })?;
1369
1370 let condition_result = self.condition_parser.parse(&condition_str);
1372 let condition = match condition_result {
1373 Ok(cond) => cond,
1374 Err(_) => {
1375 return Err(ParserError::Directive(format!(
1376 "Invalid condition expression: {}",
1377 condition_str
1378 )))
1379 }
1380 };
1381
1382 let fragment_id = self.id_counter.next_id("if");
1384
1385 self.flush_raw_buffer(fragments);
1387
1388 let mut if_fragment: Vec<WebUIFragment> = Vec::new();
1390
1391 let parent_buffer = std::mem::take(&mut self.raw_buffer);
1393
1394 self.process_content_children(node, source, &mut if_fragment)?;
1396
1397 self.flush_raw_buffer(&mut if_fragment);
1399
1400 self.fragment_records.insert(
1402 fragment_id.clone(),
1403 FragmentList {
1404 fragments: if_fragment,
1405 },
1406 );
1407
1408 self.raw_buffer = parent_buffer;
1410
1411 self.add_if_fragment(condition, fragment_id, fragments);
1413
1414 Ok(())
1415 }
1416
1417 fn process_route_directive(
1423 &mut self,
1424 node: Node,
1425 source: &str,
1426 fragments: &mut Vec<WebUIFragment>,
1427 ) -> Result<()> {
1428 let path = self
1430 .get_element_attribute(node, "path", source)?
1431 .unwrap_or_default();
1432
1433 let component = self
1434 .get_element_attribute(node, "component", source)?
1435 .unwrap_or_default();
1436
1437 let exact = self.has_element_attribute(node, "exact", source)?;
1438
1439 let query = self
1440 .get_element_attribute(node, "query", source)?
1441 .unwrap_or_default();
1442
1443 let attrs = route_parser::RouteAttributes {
1444 path: path.clone(),
1445 component: component.clone(),
1446 exact,
1447 query,
1448 keep_alive: self.has_element_attribute(node, "keep-alive", source)?,
1449 cache_tags: self
1450 .get_element_attribute(node, "cache-tags", source)?
1451 .map(|v| route_parser::parse_tag_list(&v))
1452 .unwrap_or_default(),
1453 invalidates: self
1454 .get_element_attribute(node, "invalidates", source)?
1455 .map(|v| route_parser::parse_tag_list(&v))
1456 .unwrap_or_default(),
1457 pending_component: self
1458 .get_element_attribute(node, "pending", source)?
1459 .unwrap_or_default(),
1460 error_component: self
1461 .get_element_attribute(node, "error", source)?
1462 .unwrap_or_default(),
1463 };
1464
1465 route_parser::validate_attributes(&attrs)?;
1467
1468 let route_params: std::collections::HashSet<String> =
1470 route_parser::extract_params(&path)?.into_iter().collect();
1471 if !attrs.cache_tags.is_empty() {
1472 route_parser::validate_tag_placeholders(
1473 &attrs.cache_tags,
1474 &route_params,
1475 "cache-tags",
1476 &path,
1477 )?;
1478 }
1479 if !attrs.invalidates.is_empty() {
1480 route_parser::validate_tag_placeholders(
1481 &attrs.invalidates,
1482 &route_params,
1483 "invalidates",
1484 &path,
1485 )?;
1486 }
1487
1488 if !attrs.pending_component.is_empty() {
1490 self.ensure_route_component_parsed(&attrs.pending_component)?;
1491 }
1492 if !attrs.error_component.is_empty() {
1494 self.ensure_route_component_parsed(&attrs.error_component)?;
1495 }
1496
1497 self.ensure_route_component_parsed(&component)?;
1499
1500 let mut all_params = std::collections::HashSet::new();
1502 all_params.extend(route_params);
1503 let children = self.parse_child_routes(node, source, &all_params)?;
1504
1505 self.flush_raw_buffer(fragments);
1507
1508 let route_fragment =
1510 route_parser::build_route_fragment(&attrs, component.clone(), children);
1511
1512 fragments.push(WebUIFragment::route_from(route_fragment));
1514
1515 Ok(())
1516 }
1517
1518 fn parse_child_routes(
1520 &mut self,
1521 node: Node,
1522 source: &str,
1523 ancestor_params: &std::collections::HashSet<String>,
1524 ) -> Result<Vec<WebUiFragmentRoute>> {
1525 let mut children = Vec::new();
1526 let mut cursor = node.walk();
1527
1528 for child in node.named_children(&mut cursor) {
1529 if child.kind() == "element" {
1530 if let Ok(tag) = self.get_element_tag_name(child, source) {
1531 if tag == "route" {
1532 let child_route =
1533 self.parse_route_as_fragment(child, source, ancestor_params)?;
1534 children.push(child_route);
1535 }
1536 }
1537 }
1538 }
1539
1540 Ok(children)
1541 }
1542
1543 fn parse_route_as_fragment(
1545 &mut self,
1546 node: Node,
1547 source: &str,
1548 ancestor_params: &std::collections::HashSet<String>,
1549 ) -> Result<WebUiFragmentRoute> {
1550 let path = self
1551 .get_element_attribute(node, "path", source)?
1552 .unwrap_or_default();
1553 let component = self
1554 .get_element_attribute(node, "component", source)?
1555 .unwrap_or_default();
1556 let exact = self.has_element_attribute(node, "exact", source)?;
1557
1558 let query = self
1559 .get_element_attribute(node, "query", source)?
1560 .unwrap_or_default();
1561
1562 let attrs = route_parser::RouteAttributes {
1563 path: path.clone(),
1564 component: component.clone(),
1565 exact,
1566 query,
1567 keep_alive: self.has_element_attribute(node, "keep-alive", source)?,
1568 cache_tags: self
1569 .get_element_attribute(node, "cache-tags", source)?
1570 .map(|v| route_parser::parse_tag_list(&v))
1571 .unwrap_or_default(),
1572 invalidates: self
1573 .get_element_attribute(node, "invalidates", source)?
1574 .map(|v| route_parser::parse_tag_list(&v))
1575 .unwrap_or_default(),
1576 pending_component: self
1577 .get_element_attribute(node, "pending", source)?
1578 .unwrap_or_default(),
1579 error_component: self
1580 .get_element_attribute(node, "error", source)?
1581 .unwrap_or_default(),
1582 };
1583
1584 route_parser::validate_attributes(&attrs)?;
1585
1586 let own_params: std::collections::HashSet<String> =
1589 route_parser::extract_params(&path)?.into_iter().collect();
1590 let mut all_params = ancestor_params.clone();
1591 all_params.extend(own_params.iter().cloned());
1592 if !attrs.cache_tags.is_empty() {
1593 route_parser::validate_tag_placeholders(
1594 &attrs.cache_tags,
1595 &all_params,
1596 "cache-tags",
1597 &path,
1598 )?;
1599 }
1600 if !attrs.invalidates.is_empty() {
1601 route_parser::validate_tag_placeholders(
1602 &attrs.invalidates,
1603 &all_params,
1604 "invalidates",
1605 &path,
1606 )?;
1607 }
1608
1609 if !attrs.pending_component.is_empty() {
1611 self.ensure_route_component_parsed(&attrs.pending_component)?;
1612 }
1613 if !attrs.error_component.is_empty() {
1614 self.ensure_route_component_parsed(&attrs.error_component)?;
1615 }
1616
1617 self.ensure_route_component_parsed(&component)?;
1619
1620 let children = self.parse_child_routes(node, source, &all_params)?;
1622
1623 Ok(route_parser::build_route_fragment(
1624 &attrs, component, children,
1625 ))
1626 }
1627
1628 fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
1630 if component.is_empty()
1631 || !self.component_registry.contains(component)
1632 || self.fragment_records.contains_key(component)
1633 {
1634 return Ok(());
1635 }
1636
1637 let component_data = self
1638 .component_registry
1639 .get(component)
1640 .ok_or_else(|| {
1641 crate::error::ParserError::Directive(format!("Component not found: {component}"))
1642 })?
1643 .clone();
1644
1645 self.token_store
1646 .extend(component_data.css_tokens.iter().cloned());
1647
1648 let processed = self.build_component_template(
1649 component,
1650 &component_data.html_content,
1651 component_data.css_content.as_deref(),
1652 );
1653
1654 if let Some(ref mut p) = self.plugin {
1655 p.register_component_template(component, &component_data, &processed)?;
1656 }
1657
1658 let saved_buffer = std::mem::take(&mut self.raw_buffer);
1659 self.parse(component, &processed)?;
1660 self.raw_buffer = saved_buffer;
1661
1662 Ok(())
1663 }
1664
1665 const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
1667 const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-", "aria-"];
1669
1670 fn is_skipped_attribute(name: &str) -> bool {
1671 Self::SKIPPED_ATTRIBUTES.contains(&name)
1672 || Self::SKIPPED_ATTRIBUTE_PREFIXES
1673 .iter()
1674 .any(|prefix| name.starts_with(prefix))
1675 }
1676
1677 fn is_blocked_complex_property(name: &str) -> bool {
1680 matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
1681 }
1682
1683 fn process_component_directive(
1684 &mut self,
1685 node: Node,
1686 source: &str,
1687 fragments: &mut Vec<WebUIFragment>,
1688 tag_name: &str,
1689 ) -> Result<()> {
1690 let tag_node = self.find_tag_node(node);
1691 let is_self_closing = tag_node
1692 .map(|n| n.kind() == "self_closing_tag")
1693 .unwrap_or(false);
1694
1695 self.add_raw_fragment(&format!("<{}", tag_name));
1697
1698 if let Some(tag_node) = tag_node {
1700 let binding_count = self.process_tag_attributes(tag_node, source, fragments, true)?;
1701 if let Some(ref mut p) = self.plugin {
1702 if let Some(data) = p.finish_element(binding_count) {
1703 self.add_fragment(WebUIFragment::plugin(data), fragments);
1704 }
1705 }
1706 }
1707
1708 if is_self_closing {
1709 let tag_text = tag_node
1710 .map(|n| &source[n.start_byte()..n.end_byte()])
1711 .unwrap_or("");
1712 if tag_text.ends_with("/>") {
1713 self.add_raw_fragment("/>");
1714 } else {
1715 self.add_raw_fragment(">");
1716 }
1717 } else {
1718 self.add_raw_fragment(">");
1719 }
1720
1721 self.flush_raw_buffer(fragments);
1723
1724 let (html_content, css_content, css_tokens) = {
1726 let component = self.component_registry.get(tag_name).ok_or_else(|| {
1727 ParserError::Directive(format!("Component not found: {}", tag_name))
1728 })?;
1729 (
1730 component.html_content.clone(),
1731 component.css_content.clone(),
1732 component.css_tokens.clone(),
1733 )
1734 };
1735
1736 self.token_store.extend(css_tokens);
1738
1739 if !self.fragment_records.contains_key(tag_name) {
1741 let component_data = self
1742 .component_registry
1743 .get(tag_name)
1744 .ok_or_else(|| {
1745 ParserError::Directive(format!("Component not found: {}", tag_name))
1746 })?
1747 .clone();
1748 let processed =
1749 self.build_component_template(tag_name, &html_content, css_content.as_deref());
1750
1751 if let Some(ref mut p) = self.plugin {
1753 p.register_component_template(tag_name, &component_data, &processed)?;
1754 }
1755
1756 self.parse(tag_name, &processed)?;
1757 }
1758
1759 fragments.push(WebUIFragment::component(tag_name.to_string()));
1761
1762 if !is_self_closing {
1764 self.process_content_children(node, source, fragments)?;
1765 }
1766
1767 if !is_self_closing {
1769 self.add_raw_fragment(&format!("</{}>", tag_name));
1770 }
1771
1772 Ok(())
1773 }
1774
1775 fn build_component_template(
1780 &mut self,
1781 tag_name: &str,
1782 html: &str,
1783 css_content: Option<&str>,
1784 ) -> String {
1785 let adopted_specifier = match self.css_strategy {
1786 CssStrategy::Module if css_content.is_some() => Some(tag_name.to_string()),
1787 _ => None,
1788 };
1789 let css_injection = match self.css_strategy {
1790 CssStrategy::Link => {
1791 if css_content.is_some() && self.dom_strategy == DomStrategy::Shadow {
1794 Some(format!("<link rel=\"stylesheet\" href=\"{tag_name}.css\">"))
1795 } else {
1796 None
1797 }
1798 }
1799 CssStrategy::Style => css_content.map(|css| format!("<style>{}</style>", css.trim())),
1800 CssStrategy::Module => None,
1801 };
1802
1803 self.process_component_template(
1804 html,
1805 css_injection.as_deref(),
1806 adopted_specifier.as_deref(),
1807 )
1808 }
1809
1810 fn process_component_template(
1837 &mut self,
1838 html: &str,
1839 css_snippet: Option<&str>,
1840 adopted_specifier: Option<&str>,
1841 ) -> String {
1842 let trimmed = html.trim();
1843 let snippet = css_snippet.unwrap_or_default();
1844
1845 if trimmed.starts_with("<template") {
1846 let _ = adopted_specifier;
1850 let stripped = self.strip_runtime_attrs_from_template(trimmed);
1851
1852 if snippet.is_empty() {
1853 return stripped;
1854 }
1855
1856 match tag_scan::find_tag_close(&stripped) {
1860 Some(open_end) => {
1861 let mut result = String::with_capacity(stripped.len() + snippet.len());
1862 result.push_str(&stripped[..=open_end]);
1863 result.push_str(snippet);
1864 result.push_str(&stripped[open_end + 1..]);
1865 result
1866 }
1867 None => stripped,
1870 }
1871 } else {
1872 match self.dom_strategy {
1873 DomStrategy::Shadow => {
1874 let adopted = adopted_specifier.unwrap_or_default();
1875 let adopted_extra = if adopted.is_empty() {
1876 0
1877 } else {
1878 33 + adopted.len()
1879 };
1880 let mut result =
1881 String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
1882 result.push_str("<template shadowrootmode=\"open\"");
1883 if !adopted.is_empty() {
1884 result.push_str(" shadowrootadoptedstylesheets=\"");
1885 result.push_str(adopted);
1886 result.push('"');
1887 }
1888 result.push('>');
1889 result.push_str(snippet);
1890 result.push_str(trimmed);
1891 result.push_str("</template>");
1892 result
1893 }
1894 DomStrategy::Light => {
1895 if snippet.is_empty() {
1896 return trimmed.to_string();
1897 }
1898 let mut result = String::with_capacity(snippet.len() + trimmed.len());
1899 result.push_str(snippet);
1900 result.push_str(trimmed);
1901 result
1902 }
1903 }
1904 }
1905 }
1906
1907 fn strip_runtime_attrs_from_template(&mut self, html: &str) -> String {
1915 if !tag_scan::opening_tag_has_runtime_prefix(html) {
1919 return html.to_string();
1920 }
1921
1922 let tree = match self.parser.parse(html, None) {
1923 Some(t) => t,
1924 None => return html.to_string(),
1925 };
1926
1927 let root = tree.root_node();
1929 let start_tag = Self::find_first_node(root, "start_tag");
1930 let Some(tag) = start_tag else {
1931 return html.to_string();
1932 };
1933
1934 let mut removals: Vec<(usize, usize)> = Vec::new();
1936 let mut cursor = tag.walk();
1937 for child in tag.named_children(&mut cursor) {
1938 if child.kind() == "attribute" {
1939 let name_node = child.child(0);
1940 if let Some(name) = name_node {
1941 let attr_name = &html[name.start_byte()..name.end_byte()];
1942 if attr_name.starts_with('@')
1943 || attr_name.starts_with(':')
1944 || attr_name.starts_with('?')
1945 {
1946 let mut start = child.start_byte();
1948 while start > 0 && html.as_bytes()[start - 1] == b' ' {
1949 start -= 1;
1950 }
1951 removals.push((start, child.end_byte()));
1952 }
1953 }
1954 }
1955 }
1956
1957 if removals.is_empty() {
1958 return html.to_string();
1959 }
1960
1961 let mut result = String::with_capacity(html.len());
1963 let mut pos = 0;
1964 for (start, end) in &removals {
1965 result.push_str(&html[pos..*start]);
1966 pos = *end;
1967 }
1968 result.push_str(&html[pos..]);
1969 result
1970 }
1971
1972 fn find_first_node<'a>(root: Node<'a>, kind: &str) -> Option<Node<'a>> {
1974 let mut stack = vec![root];
1975 while let Some(node) = stack.pop() {
1976 if node.kind() == kind {
1977 return Some(node);
1978 }
1979 let mut cursor = node.walk();
1980 #[allow(clippy::needless_collect)]
1983 let children: Vec<_> = node.children(&mut cursor).collect();
1984 for child in children.into_iter().rev() {
1985 stack.push(child);
1986 }
1987 }
1988 None
1989 }
1990}
1991
1992#[cfg(test)]
1993mod tests {
1994 use webui_test_utils::*;
1995
1996 use super::*;
1997
1998 #[test]
1999 fn test_plugin_display_names() {
2000 assert_eq!(Plugin::Fast.to_string(), "fast");
2001 assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
2002 assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
2003 assert_eq!(Plugin::WebUI.to_string(), "webui");
2004 }
2005
2006 #[test]
2007 fn test_plugin_from_str_names() {
2008 assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
2009 assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
2010 assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
2011 assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
2012 }
2013
2014 #[test]
2015 fn test_plugin_from_str_unknown_mentions_supported_names() {
2016 let err = "legacy".parse::<Plugin>().unwrap_err();
2017 assert!(
2018 err.contains("fast-v3"),
2019 "error should mention fast-v3: {err}"
2020 );
2021 assert!(
2022 err.contains("fast-v2"),
2023 "error should mention fast-v2: {err}"
2024 );
2025 assert!(err.contains("fast"), "error should mention fast: {err}");
2026 }
2027
2028 #[test]
2029 fn test_parse_signal() {
2030 let mut parser = HtmlParser::new();
2031 let html = "Hello, {{name}}!";
2032 let result = parser.parse("test.html", html);
2033
2034 assert!(result.is_ok());
2035 let fragment_records = parser.into_fragment_records();
2036
2037 assert_stream!(
2038 fragment_records,
2039 "test.html",
2040 [raw("Hello, "), signal("name"), raw("!"),]
2041 );
2042 }
2043
2044 #[test]
2045 fn test_parse_raw_signal() {
2046 let mut parser = HtmlParser::new();
2047 let html = "Hello, {{{html_content}}}!";
2048 let result = parser.parse("test.html", html);
2049
2050 assert!(result.is_ok());
2051 let fragment_records = parser.into_fragment_records();
2052
2053 assert_stream!(
2054 fragment_records,
2055 "test.html",
2056 [raw("Hello, "), signal_raw("html_content"), raw("!"),]
2057 );
2058 }
2059
2060 #[test]
2061 fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
2062 let mut parser = HtmlParser::new();
2063 let html = "<nav>{{sectionName}} > {{topicName}}</nav>";
2064 let result = parser.parse("test.html", html);
2065
2066 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2067 let fragment_records = parser.into_fragment_records();
2068
2069 assert_stream!(
2070 fragment_records,
2071 "test.html",
2072 [
2073 raw("<nav>"),
2074 signal("sectionName"),
2075 raw(" > "),
2076 signal("topicName"),
2077 raw("</nav>"),
2078 ]
2079 );
2080 }
2081
2082 #[test]
2083 fn test_parse_for_directive() {
2084 let mut parser = HtmlParser::new();
2085 let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
2086
2087 let result = parser.parse("test.html", html);
2088 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2089 let fragment_records = parser.into_fragment_records();
2090 println!("Fragment records: {:#?}", fragment_records);
2091
2092 assert_stream!(
2093 fragment_records,
2094 "test.html",
2095 [for_loop("item", "items", "for-1"),]
2096 );
2097
2098 assert_stream!(
2100 fragment_records,
2101 "for-1",
2102 [
2103 raw("<div class=\"item\">"),
2104 signal("item.name"),
2105 raw("</div>"),
2106 ]
2107 );
2108 }
2109
2110 #[test]
2111 fn test_parse_if_directive() {
2112 let mut parser = HtmlParser::new();
2113 let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
2114
2115 let result = parser.parse("test.html", html);
2116
2117 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2118 let fragment_records = parser.into_fragment_records();
2119 println!("Fragment records: {:#?}", fragment_records);
2120
2121 assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
2122
2123 assert_stream!(
2125 fragment_records,
2126 "if-1",
2127 [
2128 raw("<div>Welcome back, "),
2129 signal("username"),
2130 raw("!</div>"),
2131 ]
2132 );
2133 }
2134
2135 #[test]
2136 fn test_component_directive() {
2137 let mut parser = HtmlParser::new();
2138 parser.set_dom_strategy(DomStrategy::Light);
2139 parser
2140 .component_registry
2141 .register_component(
2142 "my-component",
2143 "<div>My Component</div>",
2144 Some("div { color: blue; }"),
2145 )
2146 .expect("Failed to register component");
2147
2148 let result = parser.parse("test.html", "<my-component></my-component>");
2149 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2150 let records = parser.into_fragment_records();
2151
2152 assert_stream!(
2153 records,
2154 "test.html",
2155 [
2156 raw("<my-component>"),
2157 component("my-component"),
2158 raw("</my-component>"),
2159 ]
2160 );
2161
2162 let comp = &records["my-component"].fragments;
2164 assert_eq!(comp.len(), 1);
2165 assert!(
2166 matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
2167 !raw.value.contains("<template shadowrootmode") && raw.value.contains("<div>My Component</div>"))
2168 );
2169 }
2170
2171 #[test]
2172 fn test_component_directive_with_slots() {
2173 let mut parser = HtmlParser::new();
2174 parser
2175 .component_registry
2176 .register_component(
2177 "my-component",
2178 "<div>My Component</div>",
2179 Some("div { color: blue; }"),
2180 )
2181 .expect("Failed to register component");
2182
2183 let result = parser.parse(
2184 "test.html",
2185 "Hello<my-component><p>World</p></my-component>",
2186 );
2187 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2188 let records = parser.into_fragment_records();
2189 let fragments = &records["test.html"].fragments;
2190
2191 assert!(fragments.len() >= 3);
2193 assert!(
2195 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component>"))
2196 );
2197 assert!(fragments.iter().any(|f| matches!(
2199 f.fragment.as_ref(),
2200 Some(Fragment::Component(c)) if c.fragment_id == "my-component"
2201 )));
2202 let last = fragments.last().unwrap();
2204 assert!(
2205 matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
2206 );
2207 }
2208
2209 #[test]
2210 fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
2211 let mut parser = HtmlParser::new();
2216 parser.set_dom_strategy(DomStrategy::Shadow);
2217
2218 parser
2219 .component_registry
2220 .register_component(
2221 "app-shell",
2222 r#"<template shadowrootmode="open">
2223 <my-navbar></my-navbar>
2224 <main><outlet /></main>
2225 <cart-panel></cart-panel>
2226 <my-footer></my-footer>
2227 </template>"#,
2228 Some(":host{display:flex}"),
2229 )
2230 .expect("register app-shell");
2231 parser
2232 .component_registry
2233 .register_component("my-navbar", "<nav>Nav</nav>", Some("nav{color:red}"))
2234 .expect("register my-navbar");
2235 parser
2236 .component_registry
2237 .register_component(
2238 "cart-panel",
2239 "<aside>Cart</aside>",
2240 Some("aside{color:green}"),
2241 )
2242 .expect("register cart-panel");
2243 parser
2244 .component_registry
2245 .register_component(
2246 "my-footer",
2247 "<footer>Footer</footer>",
2248 Some("footer{color:blue}"),
2249 )
2250 .expect("register my-footer");
2251
2252 parser
2253 .parse("index.html", "<app-shell></app-shell>")
2254 .expect("parse index.html");
2255 let records = parser.into_fragment_records();
2256
2257 let app_frags = &records["app-shell"].fragments;
2259 let component_ids: Vec<&str> = app_frags
2260 .iter()
2261 .filter_map(|f| match f.fragment.as_ref() {
2262 Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
2263 _ => None,
2264 })
2265 .collect();
2266
2267 assert!(
2268 component_ids.contains(&"my-navbar"),
2269 "app-shell fragments should contain my-navbar: {component_ids:?}"
2270 );
2271 assert!(
2272 component_ids.contains(&"cart-panel"),
2273 "app-shell fragments should contain cart-panel: {component_ids:?}"
2274 );
2275 assert!(
2276 component_ids.contains(&"my-footer"),
2277 "app-shell fragments should contain my-footer: {component_ids:?}"
2278 );
2279 }
2280
2281 #[test]
2284 fn test_component_no_double_wrap_template() {
2285 let mut parser = HtmlParser::new();
2289 parser.set_dom_strategy(DomStrategy::Light);
2290 parser
2291 .component_registry
2292 .register_component(
2293 "custom-element",
2294 r#"<template foo="bar"><slot></slot></template>"#,
2295 None,
2296 )
2297 .expect("register");
2298 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2299 assert!(result.is_ok());
2300 let records = parser.into_fragment_records();
2301
2302 assert_fragments!(
2303 records["index.html"].fragments,
2304 [
2305 raw("<custom-element>"),
2306 component("custom-element"),
2307 raw("Hello</custom-element>"),
2308 ]
2309 );
2310
2311 assert_stream!(
2312 records,
2313 "custom-element",
2314 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
2315 );
2316 }
2317
2318 #[test]
2319 fn test_component_styled_no_double_wrap() {
2320 let mut parser = HtmlParser::new();
2325 parser.set_dom_strategy(DomStrategy::Light);
2326 parser
2327 .component_registry
2328 .register_component(
2329 "custom-element",
2330 r#"<template foo="bar"><slot></slot></template>"#,
2331 Some("div { color: red; }"),
2332 )
2333 .expect("register");
2334 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2335 assert!(result.is_ok());
2336 let records = parser.into_fragment_records();
2337
2338 assert_stream!(
2339 records,
2340 "custom-element",
2341 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
2342 );
2343 }
2344
2345 #[test]
2346 fn test_component_strip_runtime_attrs() {
2347 let mut parser = HtmlParser::new();
2352 parser.set_dom_strategy(DomStrategy::Light);
2353 parser
2354 .component_registry
2355 .register_component(
2356 "custom-element",
2357 r#"<template @click={foo} :bar="baz" ?bool="true"><slot></slot></template>"#,
2358 None,
2359 )
2360 .expect("register");
2361 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2362 assert!(result.is_ok());
2363 let records = parser.into_fragment_records();
2364
2365 assert_stream!(
2366 records,
2367 "custom-element",
2368 [raw("<template><slot></slot></template>"),]
2369 );
2370 }
2371
2372 #[test]
2373 fn test_component_with_slots_and_attrs() {
2374 let mut parser = HtmlParser::new();
2375 parser
2376 .component_registry
2377 .register_component("custom-element", "<slot></slot>", None)
2378 .expect("register");
2379 let result = parser.parse(
2380 "index.html",
2381 r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
2382 );
2383 assert!(result.is_ok());
2384 let records = parser.into_fragment_records();
2385 assert_fragments!(
2386 records["index.html"].fragments,
2387 [
2388 raw("<custom-element"),
2389 attr_raw_start("appearance", "subtle"),
2390 raw(">"),
2391 component("custom-element"),
2392 raw("Hello World</custom-element>"),
2393 ]
2394 );
2395 }
2396
2397 #[test]
2398 fn test_component_legacy_no_styles() {
2399 let mut parser = HtmlParser::new();
2400 parser.set_dom_strategy(DomStrategy::Light);
2401 parser
2402 .component_registry
2403 .register_component("custom-element", "<div>Custom Element</div>", None)
2404 .expect("register");
2405 let result = parser.parse("index.html", "<custom-element></custom-element>");
2406 assert!(result.is_ok());
2407 let records = parser.into_fragment_records();
2408
2409 assert_fragments!(
2410 records["index.html"].fragments,
2411 [
2412 raw("<custom-element>"),
2413 component("custom-element"),
2414 raw("</custom-element>"),
2415 ]
2416 );
2417
2418 assert_stream!(
2419 records,
2420 "custom-element",
2421 [raw("<div>Custom Element</div>"),]
2422 );
2423 }
2424
2425 #[test]
2426 fn test_component_self_closing() {
2427 let mut parser = HtmlParser::new();
2428 parser
2429 .component_registry
2430 .register_component("custom-widget", "<div>Widget Content</div>", None)
2431 .expect("register");
2432 let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
2433 assert!(result.is_ok());
2434 let records = parser.into_fragment_records();
2435
2436 assert_fragments!(
2437 records["index.html"].fragments,
2438 [
2439 raw("<custom-widget"),
2440 attr_start("config", "settings"),
2441 raw("/>"),
2442 component("custom-widget"),
2443 ]
2444 );
2445 }
2446
2447 #[test]
2448 fn test_component_nested_self_closing_in_slot() {
2449 let mut parser = HtmlParser::new();
2450 parser.set_dom_strategy(DomStrategy::Light);
2451 parser
2452 .component_registry
2453 .register_component("custom-icon", "<svg><slot></slot></svg>", None)
2454 .expect("register");
2455 let result = parser.parse(
2456 "index.html",
2457 r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
2458 );
2459 assert!(result.is_ok());
2460 let records = parser.into_fragment_records();
2461
2462 assert_fragments!(
2463 records["index.html"].fragments,
2464 [
2465 raw("<custom-icon>"),
2466 component("custom-icon"),
2467 raw("<use"),
2468 attr_template("href", "attr-1"),
2469 raw("/></custom-icon>"),
2470 ]
2471 );
2472
2473 assert_stream!(records, "custom-icon", [raw("<svg><slot></slot></svg>"),]);
2474 }
2475
2476 #[test]
2477 fn test_component_leading_boolean_attr_start() {
2478 let mut parser = HtmlParser::new();
2479 parser
2480 .component_registry
2481 .register_component("custom-element", "<slot></slot>", None)
2482 .expect("register");
2483 let result = parser.parse(
2484 "index.html",
2485 r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
2486 );
2487 assert!(result.is_ok());
2488 let records = parser.into_fragment_records();
2489
2490 assert_fragments!(
2491 records["index.html"].fragments,
2492 [
2493 raw("<custom-element"),
2494 bool_attr_start("disabled", "isDisabled"),
2496 attr_raw("title", "Hello"),
2498 raw(">"),
2499 component("custom-element"),
2500 raw("</custom-element>"),
2501 ]
2502 );
2503 }
2504
2505 #[test]
2506 fn test_component_boolean_predicate_preserves_condition_tree() {
2507 let mut parser = HtmlParser::new();
2508 parser
2509 .component_registry
2510 .register_component("custom-element", "<slot></slot>", None)
2511 .expect("register");
2512 let result = parser.parse(
2513 "index.html",
2514 r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
2515 );
2516 assert!(result.is_ok());
2517
2518 let records = parser.into_fragment_records();
2519 let fragments = &records["index.html"].fragments;
2520 match fragments
2521 .get(1)
2522 .and_then(|fragment| fragment.fragment.as_ref())
2523 {
2524 Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
2525 assert_eq!(attr.name, "disabled");
2526 assert!(attr.attr_start);
2527 match attr
2528 .condition_tree
2529 .as_ref()
2530 .and_then(|condition| condition.expr.as_ref())
2531 {
2532 Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
2533 assert_eq!(pred.left, "page");
2534 assert_eq!(pred.operator, 3);
2535 assert_eq!(pred.right, "'dashboard'");
2536 }
2537 other => panic!("expected predicate condition tree, got {:?}", other),
2538 }
2539 }
2540 other => panic!("expected attribute fragment, got {:?}", other),
2541 }
2542 }
2543
2544 #[test]
2545 fn test_component_meta_link_tags() {
2546 let (fragments, _) = parse_and_get_fragments(
2547 r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
2548 );
2549 assert!(fragments.len() >= 5);
2550 assert!(
2551 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<head><meta charset=\"utf-8\"") && raw.value.contains("<link"))
2552 );
2553 assert!(
2554 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
2555 );
2556 assert!(
2557 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
2558 );
2559 assert!(
2560 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if s.value == "head_end" && s.raw)
2561 );
2562 assert!(
2563 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
2564 );
2565 }
2566
2567 #[test]
2568 fn test_nested_directives() {
2569 let mut parser = HtmlParser::new();
2570 let html = r#"<for each="category in categories">
2571 <if condition="category.hasItems">
2572 <for each="item in category.items">
2573 {{item.title}}
2574 </for>
2575 </if>
2576 </for>"#;
2577
2578 let result = parser.parse("test.html", html);
2579
2580 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2581 let fragment_records = parser.into_fragment_records();
2582
2583 assert_stream!(
2584 fragment_records,
2585 "test.html",
2586 [for_loop("category", "categories", "for-1"),]
2587 );
2588
2589 assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
2590
2591 assert_stream!(
2592 fragment_records,
2593 "if-1",
2594 [for_loop("item", "category.items", "for-2"),]
2595 );
2596
2597 assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
2598 }
2599
2600 #[test]
2601 fn test_complex_directives() {
2602 let mut parser = HtmlParser::new();
2603 let html = r#"<for each="category in categories">
2604 <div class="category">
2605 <h2>{{category.name}}</h2>
2606 <if condition="category.hasItems">
2607 <ul>
2608 <for each="item in category.items">
2609 <li>{{item.title}}</li>
2610 </for>
2611 </ul>
2612 </if>
2613 </div>
2614 </for>"#;
2615
2616 let result = parser.parse("test.html", html);
2617
2618 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2619 let fragment_records = parser.into_fragment_records();
2620
2621 assert_stream!(
2622 fragment_records,
2623 "test.html",
2624 [for_loop("category", "categories", "for-1"),]
2625 );
2626
2627 assert_stream!(
2629 fragment_records,
2630 "for-1",
2631 [
2632 raw("<div class=\"category\"><h2>"),
2633 signal("category.name"),
2634 raw("</h2>"),
2635 if_cond("if-1"),
2636 raw("</div>"),
2637 ]
2638 );
2639
2640 assert_stream!(
2642 fragment_records,
2643 "if-1",
2644 [
2645 raw("<ul>"),
2646 for_loop("item", "category.items", "for-2"),
2647 raw("</ul>"),
2648 ]
2649 );
2650
2651 assert_stream!(
2653 fragment_records,
2654 "for-2",
2655 [raw("<li>"), signal("item.title"), raw("</li>"),]
2656 );
2657 }
2658
2659 fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
2663 let mut parser = HtmlParser::new();
2664 let result = parser.parse("index.html", html);
2665 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2666 let records = parser.into_fragment_records();
2667 let fragments = records
2668 .get("index.html")
2669 .expect("Failed to get index.html fragment")
2670 .fragments
2671 .clone();
2672 (fragments, records)
2673 }
2674
2675 fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
2677 let mut parser = HtmlParser::new();
2678 parser
2679 .component_registry
2680 .register_component(tag, "<div></div>", None)
2681 .expect("register");
2682 let result = parser.parse("index.html", html);
2683 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2684 let records = parser.into_fragment_records();
2685 let fragments = records
2686 .get("index.html")
2687 .expect("Failed to get index.html fragment")
2688 .fragments
2689 .clone();
2690 (fragments, records)
2691 }
2692
2693 #[test]
2694 fn test_attribute_handlebars_in_href() {
2695 let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
2697 assert_fragments!(
2698 fragments,
2699 [
2700 raw("<a"),
2701 attr("href", "url"),
2702 raw(">"),
2703 signal("name"),
2704 raw("</a>"),
2705 ]
2706 );
2707 }
2708
2709 #[test]
2710 fn test_attribute_boolean_with_handlebars() {
2711 let (fragments, _) =
2713 parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
2714 assert_fragments!(
2715 fragments,
2716 [
2717 raw("<button"),
2718 bool_attr("disabled", "isDisabled"),
2719 raw(">Click</button>"),
2720 ]
2721 );
2722 }
2723
2724 #[test]
2725 fn test_attribute_multiple_boolean() {
2726 let (fragments, _) =
2729 parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
2730
2731 assert_fragments!(
2732 fragments,
2733 [
2734 raw("<input"),
2735 bool_attr("checked", "isChecked"),
2736 bool_attr("disabled", "isDisabled"),
2737 raw("/>"),
2738 ]
2739 );
2740 }
2741
2742 #[test]
2743 fn test_attribute_boolean_and_regular_together() {
2744 let (fragments, _) = parse_and_get_fragments(
2747 r#"<input ?checked="{{isChecked}}" type="checkbox">Hi</input>"#,
2748 );
2749
2750 assert_fragments!(
2751 fragments,
2752 [
2753 raw("<input"),
2754 bool_attr("checked", "isChecked"),
2755 raw(" type=\"checkbox\">Hi</input>"),
2756 ]
2757 );
2758 }
2759
2760 #[test]
2761 fn test_attribute_boolean_sandwiched() {
2762 let (fragments, _) = parse_and_get_fragments(
2765 r#"<input version={{edition}} ?checked="{{isChecked}}" type="checkbox">Hi</input>"#,
2766 );
2767
2768 assert_fragments!(
2769 fragments,
2770 [
2771 raw("<input"),
2772 attr("version", "edition"),
2773 bool_attr("checked", "isChecked"),
2774 raw(" type=\"checkbox\">Hi</input>"),
2775 ]
2776 );
2777 }
2778
2779 #[test]
2780 fn test_attribute_boolean_ending() {
2781 let (fragments, _) = parse_and_get_fragments(
2784 r#"<input version={{edition}} ?checked="{{isChecked}}">Hi</input>"#,
2785 );
2786
2787 assert_fragments!(
2788 fragments,
2789 [
2790 raw("<input"),
2791 attr("version", "edition"),
2792 bool_attr("checked", "isChecked"),
2793 raw(">Hi</input>"),
2794 ]
2795 );
2796 }
2797
2798 #[test]
2799 fn test_attribute_boolean_dotted_path() {
2800 let (fragments, _) =
2803 parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
2804
2805 assert_fragments!(
2806 fragments,
2807 [
2808 raw("<div"),
2809 bool_attr("checked", "layout.isPinned"),
2810 raw(">Content</div>"),
2811 ]
2812 );
2813 }
2814
2815 #[test]
2816 fn test_attribute_colon_prefixed_complex() {
2817 let (fragments, _) = parse_with_component(
2820 "my-component",
2821 r#"<my-component :config="{{settings}}"></my-component>"#,
2822 );
2823
2824 assert_fragments!(
2825 fragments,
2826 [
2827 raw("<my-component"),
2828 attr_complex_start(":config", "settings"),
2829 raw(">"),
2830 component("my-component"),
2831 raw("</my-component>"),
2832 ]
2833 );
2834 }
2835
2836 #[test]
2837 fn test_attribute_multiple_colon_prefixed() {
2838 let (fragments, _) = parse_with_component(
2841 "my-component",
2842 r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
2843 );
2844
2845 assert_fragments!(
2846 fragments,
2847 [
2848 raw("<my-component"),
2849 attr_complex_start(":prop1", "val1"),
2850 attr_complex(":prop2", "val2"),
2851 raw(">"),
2852 component("my-component"),
2853 raw("</my-component>"),
2854 ]
2855 );
2856 }
2857
2858 #[test]
2859 fn test_blocked_complex_property_innerhtml() {
2860 let mut parser = HtmlParser::new();
2861 let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
2862 assert!(
2863 result.is_err(),
2864 "Expected error for :innerHTML on native element"
2865 );
2866 let err = result.unwrap_err().to_string();
2867 assert!(
2868 err.contains("only allowed on custom elements"),
2869 "Error: {err}"
2870 );
2871 }
2872
2873 #[test]
2874 fn test_blocked_complex_on_native_element() {
2875 let mut parser = HtmlParser::new();
2876 let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
2877 assert!(
2878 result.is_err(),
2879 "Expected error for :data on native element"
2880 );
2881 let err = result.unwrap_err().to_string();
2882 assert!(
2883 err.contains("only allowed on custom elements"),
2884 "Error: {err}"
2885 );
2886 }
2887
2888 #[test]
2889 fn test_blocked_complex_property_on_component() {
2890 let mut parser = HtmlParser::new();
2891 parser
2892 .component_registry
2893 .register_component("my-widget", "<div></div>", None)
2894 .expect("register");
2895 let result = parser.parse(
2896 "index.html",
2897 r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
2898 );
2899 assert!(
2900 result.is_err(),
2901 "Expected error for :innerHTML on component"
2902 );
2903 let err = result.unwrap_err().to_string();
2904 assert!(err.contains("HTML injection"), "Error: {err}");
2905 }
2906
2907 #[test]
2908 fn test_allowed_complex_property() {
2909 let (fragments, _) = parse_with_component(
2911 "my-component",
2912 r#"<my-component :config="{{settings}}"></my-component>"#,
2913 );
2914 assert_fragments!(
2915 fragments,
2916 [
2917 raw("<my-component"),
2918 attr_complex_start(":config", "settings"),
2919 raw(">"),
2920 component("my-component"),
2921 raw("</my-component>"),
2922 ]
2923 );
2924 }
2925
2926 #[test]
2927 fn test_attribute_mixed_normal_boolean_colon() {
2928 let (fragments, _) = parse_with_component(
2931 "my-component",
2932 r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
2933 );
2934
2935 assert_fragments!(
2936 fragments,
2937 [
2938 raw("<my-component"),
2939 attr_raw_start("id", "comp"),
2940 attr_complex(":config", "settings"),
2941 bool_attr("enabled", "isEnabled"),
2942 raw(">"),
2943 component("my-component"),
2944 raw("</my-component>"),
2945 ]
2946 );
2947 }
2948
2949 #[test]
2950 fn test_attribute_reject_boolean_without_handlebars() {
2951 let (fragments, _) = parse_and_get_fragments(r#"<input ?checked="name"></input>"#);
2954
2955 assert_fragments!(fragments, [raw("<input></input>"),]);
2957 }
2958
2959 #[test]
2960 fn test_attribute_reject_boolean_with_partial_handlebars() {
2961 let (fragments, _) =
2964 parse_and_get_fragments(r#"<input ?checked="Hello {{name}}"></input>"#);
2965
2966 assert_fragments!(fragments, [raw("<input></input>"),]);
2968 }
2969
2970 #[test]
2971 fn test_attribute_reject_boolean_with_plain_value() {
2972 let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
2975
2976 assert_fragments!(fragments, [raw("<button>Click</button>"),]);
2978 }
2979
2980 #[test]
2981 fn test_attribute_boolean_predicate_equal() {
2982 let (fragments, _) =
2984 parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
2985 assert_fragments!(
2986 fragments,
2987 [
2988 raw("<div"),
2989 bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
2990 raw(">X</div>"),
2991 ]
2992 );
2993 }
2994
2995 #[test]
2996 fn test_attribute_boolean_predicate_greater_than() {
2997 let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
2999 assert_fragments!(
3000 fragments,
3001 [
3002 raw("<span"),
3003 bool_attr_predicate("hidden", "num", 1, "9"),
3004 raw(">X</span>"),
3005 ]
3006 );
3007 }
3008
3009 #[test]
3010 fn test_attribute_boolean_predicate_not_equal() {
3011 let (fragments, _) =
3013 parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
3014 assert_fragments!(
3015 fragments,
3016 [
3017 raw("<a"),
3018 bool_attr_predicate("data-active", "status", 4, "'inactive'"),
3019 raw(">X</a>"),
3020 ]
3021 );
3022 }
3023
3024 #[test]
3025 fn test_attribute_boolean_negation() {
3026 let (fragments, _) =
3028 parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
3029 assert_fragments!(
3030 fragments,
3031 [
3032 raw("<button"),
3033 bool_attr_not("disabled", "isReady"),
3034 raw(">X</button>"),
3035 ]
3036 );
3037 }
3038
3039 #[test]
3040 fn test_attribute_mixed_static_dynamic() {
3041 let (fragments, records) =
3044 parse_and_get_fragments(r#"<input value="hello {{world}}">Hi</input>"#);
3045
3046 assert_fragments!(
3047 fragments,
3048 [
3049 raw("<input"),
3050 attr_template("value", "attr-1"),
3051 raw(">Hi</input>"),
3052 ]
3053 );
3054
3055 assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
3057 }
3058
3059 #[test]
3062 fn test_body_signals() {
3063 let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
3064 assert_fragments!(
3065 fragments,
3066 [
3067 raw("<body>"),
3068 signal_raw("body_start"),
3069 raw("<app-shell></app-shell>"),
3070 signal_raw("body_end"),
3071 raw("</body>"),
3072 ]
3073 );
3074 }
3075
3076 #[test]
3079 fn test_empty_for_produces_nothing() {
3080 let (fragments, records) =
3081 parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
3082 assert_fragments!(fragments, [raw("<div></div>"),]);
3083 assert!(!records.contains_key("for-1"));
3084 }
3085
3086 #[test]
3089 fn test_self_closing_svg_path() {
3090 let (fragments, _) =
3091 parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
3092 assert_fragments!(
3093 fragments,
3094 [raw(
3095 r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
3096 ),]
3097 );
3098 }
3099
3100 #[test]
3101 fn test_html5_void_elements() {
3102 let (fragments, _) = parse_and_get_fragments(
3103 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
3104 );
3105 assert_fragments!(
3106 fragments,
3107 [raw(
3108 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
3109 ),]
3110 );
3111 }
3112
3113 #[test]
3114 fn test_self_closing_with_dynamic_attributes() {
3115 let (fragments, _) =
3116 parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
3117 assert_fragments!(
3118 fragments,
3119 [
3120 raw("<img"),
3121 attr("src", "imageUrl"),
3122 attr("alt", "imageAlt"),
3123 raw("/>"),
3124 ]
3125 );
3126 }
3127
3128 #[test]
3129 fn test_self_closing_with_boolean_attributes() {
3130 let (fragments, _) = parse_and_get_fragments(
3131 r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
3132 );
3133 assert_fragments!(
3134 fragments,
3135 [
3136 raw("<input type=\"checkbox\""),
3137 bool_attr("checked", "isSelected"),
3138 bool_attr("disabled", "isDisabled"),
3139 raw("/>"),
3140 ]
3141 );
3142 }
3143
3144 #[test]
3145 fn test_multiple_self_closing_in_sequence() {
3146 let (fragments, _) =
3147 parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
3148 assert_fragments!(
3149 fragments,
3150 [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
3151 );
3152 }
3153
3154 #[test]
3155 fn test_self_closing_with_mixed_content() {
3156 let (fragments, _) =
3157 parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
3158 assert_fragments!(
3159 fragments,
3160 [
3161 raw("<div>Text before<img"),
3162 attr("src", "url"),
3163 raw("/>Text after</div>"),
3164 ]
3165 );
3166 }
3167
3168 #[test]
3169 fn test_self_closing_svg_elements() {
3170 let (fragments, _) = parse_and_get_fragments(
3171 r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
3172 );
3173 assert_fragments!(
3174 fragments,
3175 [
3176 raw("<svg><circle"),
3177 attr("cx", "x"),
3178 attr("cy", "y"),
3179 raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
3180 ]
3181 );
3182 }
3183
3184 #[test]
3185 fn test_self_closing_inside_for_loop() {
3186 let (fragments, records) = parse_and_get_fragments(
3187 r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
3188 );
3189 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
3190 assert_stream!(
3191 records,
3192 "for-1",
3193 [raw("<img"), attr("src", "item.url"), raw("/>"),]
3194 );
3195 }
3196
3197 #[test]
3198 fn test_self_closing_whitespace_variations() {
3199 let (fragments, _) =
3200 parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
3201 assert_fragments!(
3202 fragments,
3203 [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
3204 );
3205 }
3206
3207 #[test]
3208 fn test_deeply_nested_self_closing() {
3209 let (fragments, _) = parse_and_get_fragments(
3210 r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
3211 );
3212 assert_fragments!(
3213 fragments,
3214 [raw(
3215 r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
3216 ),]
3217 );
3218 }
3219
3220 #[test]
3221 fn test_self_closing_vs_empty_regular_tags() {
3222 let (fragments, _) =
3223 parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
3224 assert_fragments!(
3225 fragments,
3226 [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
3227 );
3228 }
3229
3230 #[test]
3233 fn test_for_custom_template_attribute() {
3234 let (fragments, records) = parse_and_get_fragments(
3236 r#"<for each="item in items" template="static"><span>Item</span></for>"#,
3237 );
3238 assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
3239 assert_stream!(records, "static", [raw("<span>Item</span>"),]);
3240 }
3241
3242 #[test]
3243 fn test_for_recursive_template() {
3244 let mut parser = HtmlParser::new();
3246 let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
3247 let result = parser.parse("index.html", html);
3248 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3249 let records = parser.into_fragment_records();
3250
3251 assert_fragments!(
3252 records["index.html"].fragments,
3253 [for_loop("outerItem", "outerItems", "static"),]
3254 );
3255
3256 assert_stream!(
3257 records,
3258 "static",
3259 [
3260 raw("<div><span>"),
3261 signal("outerItem.name"),
3262 raw("</span>"),
3263 for_loop("innerItem", "innerItems", "static"),
3264 raw("</div>"),
3265 ]
3266 );
3267 }
3268
3269 #[test]
3272 fn test_if_multiple_children() {
3273 let (fragments, records) =
3275 parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
3276 assert_fragments!(fragments, [if_cond("if-1"),]);
3277 assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
3278 }
3279
3280 #[test]
3281 fn test_for_multiple_children() {
3282 let (fragments, records) =
3284 parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
3285 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
3286 assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
3287 }
3288
3289 #[test]
3292 fn test_handlebars_at_beginning() {
3293 let (fragments, _) = parse_and_get_fragments("{{first}}");
3295 assert_fragments!(fragments, [signal("first"),]);
3296 }
3297
3298 #[test]
3299 fn test_handlebars_at_beginning_and_raw() {
3300 let (fragments, _) = parse_and_get_fragments("{{first}}test");
3302 assert_fragments!(fragments, [signal("first"), raw("test"),]);
3303 }
3304
3305 #[test]
3306 fn test_handlebars_raw_and_end() {
3307 let (fragments, _) = parse_and_get_fragments("test{{first}}");
3309 assert_fragments!(fragments, [raw("test"), signal("first"),]);
3310 }
3311
3312 #[test]
3315 fn test_handlebars_invalid_triple_open() {
3316 let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
3318 assert_fragments!(fragments, [raw("{{{invalid}}"),]);
3319 }
3320
3321 #[test]
3322 fn test_handlebars_four_open_braces() {
3323 let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
3325 assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
3326 }
3327
3328 #[test]
3329 fn test_handlebars_five_open_with_valid_double() {
3330 let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
3332 assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
3333 }
3334
3335 #[test]
3336 fn test_entities_preserved() {
3337 let (fragments, _) = parse_and_get_fragments("<p>Hello}World</p>");
3339 assert_fragments!(fragments, [raw("<p>Hello}World</p>"),]);
3340 }
3341
3342 #[test]
3345 fn test_doctype_preserved() {
3346 let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
3348 assert!(!fragments.is_empty());
3349 assert!(
3350 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
3351 );
3352 }
3353
3354 #[test]
3357 fn test_component_attr_skip() {
3358 let mut parser = HtmlParser::new();
3360 parser
3361 .component_registry
3362 .register_component("custom-element", "<slot></slot>", None)
3363 .expect("register");
3364 let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
3365 let result = parser.parse("index.html", html);
3366 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3367 let records = parser.into_fragment_records();
3368
3369 assert_fragments!(
3372 records["index.html"].fragments,
3373 [
3374 raw("<custom-element"),
3375 attr_complex_start(":config", "config"),
3377 attr_skip("class", "value0"),
3379 attr_skip("style", "value1"),
3380 attr_skip("role", "value2"),
3381 attr_skip("data-test", "value3"),
3382 attr_skip("aria-test", "value4"),
3383 raw(">"),
3384 component("custom-element"),
3385 raw("</custom-element>"),
3386 ]
3387 );
3388 }
3389
3390 #[test]
3391 fn test_component_attr_skip_static_and_embedded() {
3392 let mut parser = HtmlParser::new();
3395 parser
3396 .component_registry
3397 .register_component("item-group", "<slot></slot>", None)
3398 .expect("register");
3399
3400 let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
3401 let result = parser.parse("index.html", html);
3402 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3403 let records = parser.into_fragment_records();
3404
3405 assert_fragments!(
3406 records["index.html"].fragments,
3407 [
3408 raw("<item-group"),
3409 attr_skip_raw("role", "list"),
3410 attr_skip_template("aria-labelledby", "attr-1"),
3411 attr_skip_template("data-testid", "attr-2"),
3412 attr_skip_raw("class", "fixed-class"),
3413 raw(">"),
3414 component("item-group"),
3415 raw("</item-group>"),
3416 ]
3417 );
3418
3419 assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
3422 assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
3423 }
3424
3425 #[test]
3426 fn test_component_multiple_nested() {
3427 let mut parser = HtmlParser::new();
3429 parser.set_dom_strategy(DomStrategy::Light);
3430 parser
3431 .component_registry
3432 .register_component(
3433 "custom-element",
3434 "<custom-child></custom-child><slot></slot>",
3435 None,
3436 )
3437 .expect("register");
3438 parser
3439 .component_registry
3440 .register_component("custom-button", "<slot></slot>", None)
3441 .expect("register");
3442 parser
3443 .component_registry
3444 .register_component("custom-child", "<h1>Hello World!</h1>", None)
3445 .expect("register");
3446
3447 let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
3448 let result = parser.parse("index.html", html);
3449 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3450 let records = parser.into_fragment_records();
3451
3452 assert_fragments!(
3454 records["index.html"].fragments,
3455 [for_loop("item", "items", "for-1"),]
3456 );
3457
3458 assert_stream!(
3460 records,
3461 "for-1",
3462 [
3463 raw("<custom-element>"),
3464 component("custom-element"),
3465 raw("<custom-button>"),
3466 component("custom-button"),
3467 raw("Ok</custom-button></custom-element>"),
3468 ]
3469 );
3470
3471 let ce = &records["custom-element"].fragments;
3473 assert_eq!(ce.len(), 3);
3474 assert!(
3475 matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child>"))
3476 );
3477 assert!(
3478 matches!(ce[1].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
3479 );
3480 assert!(
3481 matches!(ce[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><slot></slot>"))
3482 );
3483
3484 assert_stream!(records, "custom-button", [raw("<slot></slot>"),]);
3485
3486 assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
3487 }
3488
3489 #[test]
3492 fn test_invalid_markup_returns_error() {
3493 let mut parser = HtmlParser::new();
3496 let result = parser.parse("index.html", "<div><span>Unclosed div");
3497 assert!(result.is_ok());
3498 }
3499
3500 #[test]
3503 fn test_complex_raw_text_full_page() {
3504 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>"#;
3506 let (fragments, _) = parse_and_get_fragments(html);
3507
3508 assert!(fragments.len() >= 7);
3510 assert!(
3511 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3512 raw.value.contains("<!DOCTYPE HTML>") && raw.value.contains("<title>Test</title>"))
3513 );
3514 assert!(
3515 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
3516 s.value == "head_end" && s.raw)
3517 );
3518 assert!(
3519 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3520 raw.value.contains("</head>") && raw.value.ends_with("<body>"))
3521 );
3522 assert!(
3523 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
3524 s.value == "body_start" && s.raw)
3525 );
3526 assert!(
3527 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3528 raw.value.contains("<app-shell>"))
3529 );
3530 assert!(
3531 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
3532 s.value == "body_end" && s.raw)
3533 );
3534 assert!(
3535 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3536 raw.value.contains("</body>") && raw.value.contains("</html>"))
3537 );
3538 }
3539
3540 #[test]
3541 fn test_css_strategy_external_emits_link_tag() {
3542 let mut parser = HtmlParser::new();
3543 parser
3544 .component_registry_mut()
3545 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3546 .ok();
3547 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3548 let records = parser.into_fragment_records();
3549 let my_card = &records["my-card"].fragments;
3550 let raw_text: String = my_card
3551 .iter()
3552 .filter_map(|f| match &f.fragment {
3553 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3554 _ => None,
3555 })
3556 .collect();
3557 assert!(
3558 raw_text.contains(r#"<link rel="stylesheet" href="my-card.css">"#),
3559 "Expected external <link> tag in: {}",
3560 raw_text
3561 );
3562 }
3563
3564 #[test]
3565 fn test_css_strategy_inline_emits_style_tag() {
3566 let mut parser = HtmlParser::new();
3567 parser.set_css_strategy(CssStrategy::Style);
3568 parser
3569 .component_registry_mut()
3570 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3571 .ok();
3572 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3573 let records = parser.into_fragment_records();
3574 let my_card = &records["my-card"].fragments;
3575 let raw_text: String = my_card
3576 .iter()
3577 .filter_map(|f| match &f.fragment {
3578 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3579 _ => None,
3580 })
3581 .collect();
3582 assert!(
3583 raw_text.contains("<style>p { color: red; }</style>"),
3584 "Expected inline <style> tag in: {}",
3585 raw_text
3586 );
3587 assert!(
3588 !raw_text.contains("<link"),
3589 "Should not have <link> tag in inline mode: {}",
3590 raw_text
3591 );
3592 }
3593
3594 #[test]
3595 fn test_css_strategy_module_emits_adopted_stylesheets() {
3596 let mut parser = HtmlParser::new();
3597 parser.set_dom_strategy(DomStrategy::Light);
3598 parser.set_css_strategy(CssStrategy::Module);
3599 parser
3600 .component_registry_mut()
3601 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3602 .ok();
3603 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3604 let records = parser.into_fragment_records();
3605
3606 let my_card = &records["my-card"].fragments;
3609 let template_text: String = my_card
3610 .iter()
3611 .filter_map(|f| match &f.fragment {
3612 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3613 _ => None,
3614 })
3615 .collect();
3616 assert!(
3617 !template_text.contains("shadowrootadoptedstylesheets"),
3618 "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
3619 );
3620 assert!(
3621 !template_text.contains("<link"),
3622 "Should not have <link> in module mode: {template_text}"
3623 );
3624 assert!(
3627 !template_text.contains(r#"<style type="module""#),
3628 "CSS module should NOT be in raw fragments: {template_text}"
3629 );
3630 }
3631
3632 #[test]
3633 fn test_css_strategy_module_no_css_no_adopted_attr() {
3634 let mut parser = HtmlParser::new();
3635 parser.set_css_strategy(CssStrategy::Module);
3636 parser
3637 .component_registry_mut()
3638 .register_component("my-card", "<p><slot></slot></p>", None)
3639 .ok();
3640 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3641 let records = parser.into_fragment_records();
3642 let my_card = &records["my-card"].fragments;
3643 let template_text: String = my_card
3644 .iter()
3645 .filter_map(|f| match &f.fragment {
3646 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3647 _ => None,
3648 })
3649 .collect();
3650 assert!(
3652 !template_text.contains("shadowrootadoptedstylesheets"),
3653 "Should not have adopted attr without CSS: {template_text}"
3654 );
3655 }
3656
3657 #[test]
3676 fn light_preserves_dev_template_with_static_attrs() {
3677 let mut parser = HtmlParser::new();
3678 parser.set_dom_strategy(DomStrategy::Light);
3679 let processed = parser.process_component_template(
3680 r#"<template foo="bar"><div>hi</div></template>"#,
3681 None,
3682 None,
3683 );
3684 assert!(
3685 processed.contains(r#"<template foo="bar">"#),
3686 "[--dom=light] expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
3687 );
3688 assert!(
3689 processed.contains("</template>"),
3690 "[--dom=light] expected closing </template> preserved, got: {processed}"
3691 );
3692 }
3693
3694 #[test]
3695 fn light_preserves_dev_template_with_signal_fragment_attrs() {
3696 let mut parser = HtmlParser::new();
3697 parser.set_dom_strategy(DomStrategy::Light);
3698 let processed = parser.process_component_template(
3699 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
3700 None,
3701 None,
3702 );
3703 assert!(
3704 processed.contains(r#"<template foo="{{foo}}">"#),
3705 "[--dom=light] expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
3706 );
3707 }
3708
3709 #[test]
3710 fn light_preserves_dev_template_with_multiple_attrs() {
3711 let mut parser = HtmlParser::new();
3712 parser.set_dom_strategy(DomStrategy::Light);
3713 let processed = parser.process_component_template(
3714 r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
3715 None,
3716 None,
3717 );
3718 assert!(
3719 processed.contains("autofocus")
3720 && processed.contains(r#"tabindex="0""#)
3721 && processed.contains(r#"role="region""#)
3722 && processed.contains(r#"data-x="y""#),
3723 "[--dom=light] expected ALL dev template attrs preserved, got: {processed}"
3724 );
3725 }
3726
3727 #[test]
3728 fn light_no_template_no_wrapper_added() {
3729 let mut parser = HtmlParser::new();
3730 parser.set_dom_strategy(DomStrategy::Light);
3731 let processed = parser.process_component_template("<div>hi</div>", None, None);
3732 assert!(
3733 !processed.contains("<template"),
3734 "[--dom=light] framework must NOT add <template> wrapper when dev omits one, got: {processed}"
3735 );
3736 assert!(
3737 processed.contains("<div>hi</div>"),
3738 "[--dom=light] expected inner content emitted as-is, got: {processed}"
3739 );
3740 }
3741
3742 #[test]
3743 fn shadow_preserves_dev_template_with_static_attrs() {
3744 let mut parser = HtmlParser::new();
3745 parser.set_dom_strategy(DomStrategy::Shadow);
3746 let processed = parser.process_component_template(
3747 r#"<template foo="bar"><div>hi</div></template>"#,
3748 None,
3749 None,
3750 );
3751 assert!(
3752 processed.contains(r#"<template foo="bar">"#),
3753 "[--dom=shadow] dev <template foo=\"bar\"> must be preserved verbatim, got: {processed}"
3754 );
3755 assert!(
3756 !processed.contains(r#"shadowrootmode="open""#),
3757 "[--dom=shadow] framework must NOT inject shadowrootmode when dev already supplied a <template>, got: {processed}"
3758 );
3759 }
3760
3761 #[test]
3762 fn shadow_preserves_dev_template_with_shadowrootmode_closed() {
3763 let mut parser = HtmlParser::new();
3764 parser.set_dom_strategy(DomStrategy::Shadow);
3765 let processed = parser.process_component_template(
3766 r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
3767 None,
3768 None,
3769 );
3770 assert!(
3771 processed.contains(r#"shadowrootmode="closed""#),
3772 "[--dom=shadow] framework must respect dev's shadowrootmode=\"closed\" (developer is managing), got: {processed}"
3773 );
3774 assert!(
3775 !processed.contains(r#"shadowrootmode="open""#),
3776 "[--dom=shadow] framework must not overwrite dev's shadowrootmode with \"open\", got: {processed}"
3777 );
3778 }
3779
3780 #[test]
3781 fn shadow_preserves_dev_template_with_signal_fragment_attrs() {
3782 let mut parser = HtmlParser::new();
3783 parser.set_dom_strategy(DomStrategy::Shadow);
3784 let processed = parser.process_component_template(
3785 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
3786 None,
3787 None,
3788 );
3789 assert!(
3790 processed.contains(r#"<template foo="{{foo}}">"#),
3791 "[--dom=shadow] dev <template> with signal-fragment attr must be preserved, got: {processed}"
3792 );
3793 }
3794
3795 #[test]
3796 fn shadow_adds_template_wrapper_when_dev_omits_it() {
3797 let mut parser = HtmlParser::new();
3798 parser.set_dom_strategy(DomStrategy::Shadow);
3799 let processed = parser.process_component_template("<div>hi</div>", None, None);
3800 assert!(
3801 processed.contains(r#"<template shadowrootmode="open""#),
3802 "[--dom=shadow] framework MUST add <template shadowrootmode=\"open\"> when dev omits a wrapper, got: {processed}"
3803 );
3804 assert!(
3805 processed.contains("<div>hi</div>"),
3806 "[--dom=shadow] inner content must survive wrapping, got: {processed}"
3807 );
3808 assert!(
3809 processed.contains("</template>"),
3810 "[--dom=shadow] framework-added wrapper must be closed, got: {processed}"
3811 );
3812 }
3813
3814 #[test]
3826 fn test_body_start_end_injection() {
3827 let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
3831 let (fragments, _) = parse_and_get_fragments(html);
3832
3833 assert_fragments!(
3834 fragments,
3835 [
3836 raw("<html><head><title>Test</title>"),
3837 signal_raw("head_end"),
3838 raw("</head><body>"),
3839 signal_raw("body_start"),
3840 raw("<div>Content</div><p>More</p>"),
3841 signal_raw("body_end"),
3842 raw("</body></html>"),
3843 ]
3844 );
3845 }
3846
3847 #[test]
3848 fn test_body_preserves_static_attributes() {
3849 let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
3854 let (fragments, _) = parse_and_get_fragments(html);
3855
3856 assert_fragments!(
3857 fragments,
3858 [
3859 raw("<html><head><title>T</title>"),
3860 signal_raw("head_end"),
3861 raw(r#"</head><body data-layout="doc" class="page">"#),
3862 signal_raw("body_start"),
3863 raw("<p>x</p>"),
3864 signal_raw("body_end"),
3865 raw("</body></html>"),
3866 ]
3867 );
3868 }
3869
3870 #[test]
3871 fn test_body_preserves_dynamic_attributes() {
3872 let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
3876 let (fragments, _) = parse_and_get_fragments(html);
3877
3878 assert_fragments!(
3879 fragments,
3880 [
3881 raw("<html><head><title>T</title>"),
3882 signal_raw("head_end"),
3883 raw("</head><body"),
3884 attr("data-layout", "layout"),
3885 raw(">"),
3886 signal_raw("body_start"),
3887 raw("<p>x</p>"),
3888 signal_raw("body_end"),
3889 raw("</body></html>"),
3890 ]
3891 );
3892 }
3893
3894 #[test]
3895 fn test_fail_with_invalid_markup() {
3896 let mut parser = HtmlParser::new();
3902 let result = parser.parse("index.html", "<div><span></div></span>");
3903
3904 assert!(
3906 result.is_ok(),
3907 "tree-sitter is lenient and recovers from overlapping tags"
3908 );
3909 }
3910
3911 #[test]
3912 fn test_complex_raw_text_page() {
3913 let html = concat!(
3916 "<!DOCTYPE html>",
3917 "<html lang=\"en\">",
3918 "<head>",
3919 "<meta charset=\"utf-8\">",
3920 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
3921 "<title>Complex Page</title>",
3922 "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
3923 "<link rel=\"stylesheet\" href=\"styles.css\">",
3924 "</head>",
3925 "<body>",
3926 "<h1>Hello World</h1>",
3927 "<script type=\"module\" src=\"./app.js\"></script>",
3928 "</body>",
3929 "</html>",
3930 );
3931 let (fragments, _) = parse_and_get_fragments(html);
3932
3933 assert!(
3936 fragments.len() >= 7,
3937 "Expected at least 7 fragments, got {}",
3938 fragments.len()
3939 );
3940
3941 assert!(
3943 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3944 raw.value.contains("<!DOCTYPE html>") &&
3945 raw.value.contains("<meta charset=\"utf-8\">") &&
3946 raw.value.contains("<meta name=\"viewport\"") &&
3947 raw.value.contains("<title>Complex Page</title>") &&
3948 raw.value.contains("<style>") &&
3949 raw.value.contains("body { margin: 0; padding: 0; }")),
3950 "First fragment should contain all head content, got: {:?}",
3951 fragments[0]
3952 );
3953
3954 assert!(
3956 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
3957 s.value == "head_end" && s.raw),
3958 "Second fragment should be head_end signal"
3959 );
3960
3961 assert!(
3963 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3964 raw.value.contains("</head>") && raw.value.ends_with("<body>")),
3965 "Third fragment should contain </head><body>"
3966 );
3967
3968 assert!(
3970 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
3971 s.value == "body_start" && s.raw),
3972 "Fourth fragment should be body_start signal"
3973 );
3974
3975 assert!(
3977 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3978 raw.value.contains("<h1>Hello World</h1>") &&
3979 raw.value.contains("<script")),
3980 "Fifth fragment should contain body content"
3981 );
3982
3983 assert!(
3985 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
3986 s.value == "body_end" && s.raw),
3987 "Sixth fragment should be body_end signal"
3988 );
3989
3990 assert!(
3992 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3993 raw.value.contains("</body>") && raw.value.contains("</html>")),
3994 "Seventh fragment should contain closing tags"
3995 );
3996 }
3997
3998 struct BindingCountPlugin {
4002 counts: Vec<u32>,
4003 }
4004
4005 impl BindingCountPlugin {
4006 fn new() -> Self {
4007 Self { counts: Vec::new() }
4008 }
4009 }
4010
4011 impl crate::plugin::ParserPlugin for BindingCountPlugin {
4012 fn register_component_template(
4013 &mut self,
4014 _tag_name: &str,
4015 _component: &Component,
4016 _processed_template: &str,
4017 ) -> Result<()> {
4018 Ok(())
4019 }
4020
4021 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
4022 if attr_name.starts_with('@') || attr_name == "f-ref" {
4023 AttributeAction::SkipAndCountBinding
4024 } else {
4025 AttributeAction::Keep
4026 }
4027 }
4028
4029 fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
4030 self.counts.push(binding_attribute_count);
4031 if binding_attribute_count > 0 {
4032 Some(binding_attribute_count.to_le_bytes().to_vec())
4033 } else {
4034 None
4035 }
4036 }
4037 }
4038
4039 #[test]
4040 fn test_component_static_attrs_not_counted_as_bindings() {
4041 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
4042 parser
4043 .component_registry
4044 .register_component("my-btn", "<button><slot></slot></button>", None)
4045 .expect("register");
4046
4047 let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
4049 let result = parser.parse("index.html", html);
4050 assert!(result.is_ok());
4051
4052 let records = parser.into_fragment_records();
4053
4054 let frags = &records["index.html"].fragments;
4056 let plugin_count = frags
4057 .iter()
4058 .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
4059 .count();
4060 assert_eq!(
4061 plugin_count, 0,
4062 "Static-only component attributes should not emit a Plugin fragment"
4063 );
4064 }
4065
4066 #[test]
4067 fn test_component_dynamic_attr_counted_as_binding() {
4068 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
4069 parser
4070 .component_registry
4071 .register_component("my-btn", "<button><slot></slot></button>", None)
4072 .expect("register");
4073
4074 let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
4076 let result = parser.parse("index.html", html);
4077 assert!(result.is_ok());
4078
4079 let records = parser.into_fragment_records();
4080 let frags = &records["index.html"].fragments;
4081
4082 let plugin_frags: Vec<_> = frags
4084 .iter()
4085 .filter_map(|f| match f.fragment.as_ref() {
4086 Some(Fragment::Plugin(p)) => Some(&p.data),
4087 _ => None,
4088 })
4089 .collect();
4090 assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
4091 let count = u32::from_le_bytes([
4092 plugin_frags[0][0],
4093 plugin_frags[0][1],
4094 plugin_frags[0][2],
4095 plugin_frags[0][3],
4096 ]);
4097 assert_eq!(count, 1, "Binding attribute count should be 1");
4098 }
4099
4100 #[test]
4101 fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
4102 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
4103 parser
4104 .component_registry
4105 .register_component("my-btn", "<button><slot></slot></button>", None)
4106 .expect("register");
4107
4108 let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
4110 let result = parser.parse("index.html", html);
4111 assert!(result.is_ok());
4112
4113 let records = parser.into_fragment_records();
4114 let frags = &records["index.html"].fragments;
4115
4116 let plugin_frags: Vec<_> = frags
4117 .iter()
4118 .filter_map(|f| match f.fragment.as_ref() {
4119 Some(Fragment::Plugin(p)) => Some(&p.data),
4120 _ => None,
4121 })
4122 .collect();
4123 assert_eq!(plugin_frags.len(), 1);
4124 let count = u32::from_le_bytes([
4125 plugin_frags[0][0],
4126 plugin_frags[0][1],
4127 plugin_frags[0][2],
4128 plugin_frags[0][3],
4129 ]);
4130 assert_eq!(
4132 count, 2,
4133 "Binding count should include dynamic + plugin-skipped attrs, not static"
4134 );
4135 }
4136
4137 #[test]
4138 fn test_component_only_skipped_plugin_attrs_counted() {
4139 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
4140 parser
4141 .component_registry
4142 .register_component("my-btn", "<button><slot></slot></button>", None)
4143 .expect("register");
4144
4145 let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
4147 let result = parser.parse("index.html", html);
4148 assert!(result.is_ok());
4149
4150 let records = parser.into_fragment_records();
4151 let frags = &records["index.html"].fragments;
4152
4153 let plugin_frags: Vec<_> = frags
4154 .iter()
4155 .filter_map(|f| match f.fragment.as_ref() {
4156 Some(Fragment::Plugin(p)) => Some(&p.data),
4157 _ => None,
4158 })
4159 .collect();
4160 assert_eq!(plugin_frags.len(), 1);
4161 let count = u32::from_le_bytes([
4162 plugin_frags[0][0],
4163 plugin_frags[0][1],
4164 plugin_frags[0][2],
4165 plugin_frags[0][3],
4166 ]);
4167 assert_eq!(
4169 count, 2,
4170 "Only plugin-skipped attrs should be counted, not static title"
4171 );
4172 }
4173
4174 #[test]
4177 fn test_comment_handlebars_signal() {
4178 let mut parser = HtmlParser::new();
4179 let html = "<!--{{tokens}}-->";
4180 parser.parse("test.html", html).expect("parse failed");
4181 let records = parser.into_fragment_records();
4182
4183 assert_stream!(records, "test.html", [signal("tokens")]);
4184 }
4185
4186 #[test]
4187 fn test_comment_triple_brace_raw_signal() {
4188 let mut parser = HtmlParser::new();
4189 let html = "<!--{{{tokens}}}-->";
4190 parser.parse("test.html", html).expect("parse failed");
4191 let records = parser.into_fragment_records();
4192
4193 assert_stream!(records, "test.html", [signal_raw("tokens")]);
4194 }
4195
4196 #[test]
4197 fn test_comment_regular_preserved() {
4198 let mut parser = HtmlParser::new();
4199 let html = "<!-- regular comment -->";
4200 parser.parse("test.html", html).expect("parse failed");
4201 let records = parser.into_fragment_records();
4202
4203 assert_stream!(records, "test.html", [raw("<!-- regular comment -->")]);
4204 }
4205
4206 #[test]
4207 fn test_comment_dotted_signal() {
4208 let mut parser = HtmlParser::new();
4209 let html = "<!--{{tokens.light}}-->";
4210 parser.parse("test.html", html).expect("parse failed");
4211 let records = parser.into_fragment_records();
4212
4213 assert_stream!(records, "test.html", [signal("tokens.light")]);
4214 }
4215
4216 #[test]
4217 fn test_comment_arbitrary_identifier() {
4218 let mut parser = HtmlParser::new();
4219 let html = "<!--{{someOtherBinding}}-->";
4220 parser.parse("test.html", html).expect("parse failed");
4221 let records = parser.into_fragment_records();
4222
4223 assert_stream!(records, "test.html", [signal("someOtherBinding")]);
4224 }
4225
4226 #[test]
4227 fn test_comment_with_surrounding_content() {
4228 let mut parser = HtmlParser::new();
4229 let html = "<div><!--{{tokens}}--></div>";
4230 parser.parse("test.html", html).expect("parse failed");
4231 let records = parser.into_fragment_records();
4232
4233 assert_stream!(
4234 records,
4235 "test.html",
4236 [raw("<div>"), signal("tokens"), raw("</div>")]
4237 );
4238 }
4239
4240 #[test]
4243 fn test_tokens_from_style_tag() {
4244 let mut parser = HtmlParser::new();
4245 let html = r#"<style>
4246 .btn { color: var(--colorPrimary); background: var(--bgColor); }
4247 </style>"#;
4248 parser.parse("test.html", html).expect("parse failed");
4249 let tokens = parser.take_tokens();
4250
4251 assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
4252 }
4253
4254 #[test]
4255 fn test_tokens_from_component_css() {
4256 let mut parser = HtmlParser::new();
4257 parser
4258 .component_registry_mut()
4259 .register_component(
4260 "my-button",
4261 "<button>Click</button>",
4262 Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
4263 )
4264 .expect("register failed");
4265
4266 let html = "<my-button></my-button>";
4267 parser.parse("test.html", html).expect("parse failed");
4268 let tokens = parser.take_tokens();
4269
4270 assert_eq!(tokens, vec!["borderWidth", "textColor"]);
4271 }
4272
4273 #[test]
4274 fn test_tokens_merged_from_style_and_components() {
4275 let mut parser = HtmlParser::new();
4276 parser
4277 .component_registry_mut()
4278 .register_component(
4279 "my-widget",
4280 "<div>Widget</div>",
4281 Some(".w { padding: var(--spacingM); }"),
4282 )
4283 .expect("register failed");
4284
4285 let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
4286 parser.parse("test.html", html).expect("parse failed");
4287 let tokens = parser.take_tokens();
4288
4289 assert_eq!(tokens, vec!["spacingM", "textColor"]);
4290 }
4291
4292 #[test]
4293 fn test_tokens_deduplicated() {
4294 let mut parser = HtmlParser::new();
4295 parser
4296 .component_registry_mut()
4297 .register_component(
4298 "my-btn",
4299 "<button>B</button>",
4300 Some(".b { color: var(--shared); }"),
4301 )
4302 .expect("register failed");
4303
4304 let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
4305 parser.parse("test.html", html).expect("parse failed");
4306 let tokens = parser.take_tokens();
4307
4308 assert_eq!(tokens, vec!["shared"]);
4309 }
4310
4311 #[test]
4312 fn test_tokens_empty_when_no_vars() {
4313 let mut parser = HtmlParser::new();
4314 let html = "<div>Hello</div>";
4315 parser.parse("test.html", html).expect("parse failed");
4316 let tokens = parser.take_tokens();
4317
4318 assert!(tokens.is_empty());
4319 }
4320
4321 #[test]
4322 fn test_tokens_exclude_locally_defined_in_component() {
4323 let mut parser = HtmlParser::new();
4324 parser
4325 .component_registry_mut()
4326 .register_component(
4327 "my-card",
4328 "<div>Card</div>",
4329 Some(":host { --local: 5px; width: var(--external); }"),
4330 )
4331 .expect("register failed");
4332
4333 let html = "<my-card></my-card>";
4334 parser.parse("test.html", html).expect("parse failed");
4335 let tokens = parser.take_tokens();
4336
4337 assert_eq!(tokens, vec!["external"]);
4338 }
4339
4340 #[test]
4341 fn test_tokens_exclude_entry_root_definitions() {
4342 let mut parser = HtmlParser::new();
4343 parser
4344 .component_registry_mut()
4345 .register_component(
4346 "my-btn",
4347 "<button>B</button>",
4348 Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
4349 )
4350 .expect("register failed");
4351
4352 let html = r#"<style>
4355 :root {
4356 --color-primary: #0078d4;
4357 --radius-m: 6px;
4358 }
4359 body { color: var(--color-primary); }
4360 </style>
4361 <my-btn></my-btn>"#;
4362 parser.parse("test.html", html).expect("parse failed");
4363 let tokens = parser.take_tokens();
4364
4365 assert!(
4367 tokens.is_empty(),
4368 "Tokens defined in entry :root should be excluded: {tokens:?}"
4369 );
4370 }
4371
4372 #[test]
4373 fn test_tokens_entry_defs_exclude_but_external_kept() {
4374 let mut parser = HtmlParser::new();
4375 parser
4376 .component_registry_mut()
4377 .register_component(
4378 "my-card",
4379 "<div>Card</div>",
4380 Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
4381 )
4382 .expect("register failed");
4383
4384 let html = r#"<style>
4386 :root { --color-primary: #0078d4; }
4387 </style>
4388 <my-card></my-card>"#;
4389 parser.parse("test.html", html).expect("parse failed");
4390 let tokens = parser.take_tokens();
4391
4392 assert_eq!(tokens, vec!["external-spacing"]);
4394 }
4395
4396 #[test]
4399 fn test_parse_simple_route() {
4400 let mut parser = HtmlParser::new();
4401 let html = r#"<route path="/profile" component="profile-page" exact />"#;
4402 parser.parse("test.html", html).expect("parse failed");
4403
4404 let records = parser.into_fragment_records();
4406 let frags = &records["test.html"].fragments;
4407 assert_eq!(frags.len(), 1);
4408 match frags[0].fragment.as_ref() {
4409 Some(web_ui_fragment::Fragment::Route(r)) => {
4410 assert_eq!(r.path, "/profile");
4411 assert_eq!(r.fragment_id, "profile-page");
4412 assert!(r.exact);
4413 }
4414 other => panic!("expected Fragment::Route, got {:?}", other),
4415 }
4416 }
4417
4418 #[test]
4419 fn test_parse_route_with_params() {
4420 let mut parser = HtmlParser::new();
4421 let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
4422 parser.parse("test.html", html).expect("parse failed");
4423
4424 let records = parser.into_fragment_records();
4426 let frags = &records["test.html"].fragments;
4427 match frags[0].fragment.as_ref() {
4428 Some(web_ui_fragment::Fragment::Route(r)) => {
4429 assert_eq!(r.path, "/profile/:id/view/:section");
4430 }
4431 other => panic!("expected Fragment::Route, got {:?}", other),
4432 }
4433 }
4434
4435 #[test]
4436 fn test_parse_route_requires_component() {
4437 let mut parser = HtmlParser::new();
4438 let html = r#"<route path="/old" />"#;
4439 let result = parser.parse("test.html", html);
4440 assert!(result.is_err());
4441 }
4442
4443 #[test]
4444 fn test_parse_multiple_routes() {
4445 let mut parser = HtmlParser::new();
4446 let html = r#"<route path="/" component="app-layout" />
4447 <route path="/dashboard" component="dash-page" exact />
4448 <route path="/contacts" component="contacts-page" exact />"#;
4449 parser.parse("test.html", html).expect("parse failed");
4450
4451 let records = parser.into_fragment_records();
4452 let frags = &records["test.html"].fragments;
4453 assert_eq!(frags.len(), 3);
4454 for frag in frags {
4456 assert!(matches!(
4457 frag.fragment.as_ref(),
4458 Some(web_ui_fragment::Fragment::Route(_))
4459 ));
4460 }
4461 }
4462
4463 #[test]
4464 fn test_parse_route_requires_component_with_body() {
4465 let mut parser = HtmlParser::new();
4466 let html = r#"<route path="/error">
4467 <div class="error"><h1>Not Found</h1></div>
4468 </route>"#;
4469 let result = parser.parse("test.html", html);
4470 assert!(
4471 result.is_err(),
4472 "Route without component attribute should fail"
4473 );
4474 }
4475
4476 #[test]
4477 fn test_parse_multiple_routes_with_fragments() {
4478 let mut parser = HtmlParser::new();
4479 let html = r#"<route path="/" component="home-page" exact />
4480 <route path="/about" component="about-page" exact />
4481 <route path="/contact/:id" component="contact-page" />"#;
4482 parser.parse("test.html", html).expect("parse failed");
4483
4484 let records = parser.into_fragment_records();
4485 let frags = &records["test.html"].fragments;
4486 assert_eq!(frags.len(), 3);
4487
4488 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
4490 assert_eq!(r.path, "/");
4491 }
4492 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
4493 assert_eq!(r.path, "/about");
4494 }
4495 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
4496 assert_eq!(r.path, "/contact/:id");
4497 }
4498 }
4499
4500 #[test]
4501 fn test_parse_route_with_query_allowlist() {
4502 let mut parser = HtmlParser::new();
4503 let html =
4504 r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
4505 parser.parse("test.html", html).expect("parse failed");
4506
4507 let records = parser.into_fragment_records();
4508 let frags = &records["test.html"].fragments;
4509 assert_eq!(frags.len(), 1);
4510 match frags[0].fragment.as_ref() {
4511 Some(web_ui_fragment::Fragment::Route(r)) => {
4512 assert_eq!(r.path, "/compose");
4513 assert_eq!(r.fragment_id, "compose-page");
4514 assert!(r.exact);
4515 assert_eq!(r.allowed_query, "action,to,subject");
4516 }
4517 other => panic!("expected Fragment::Route, got {:?}", other),
4518 }
4519 }
4520
4521 #[test]
4522 fn test_parse_route_without_query_has_empty_allowed_query() {
4523 let mut parser = HtmlParser::new();
4524 let html = r#"<route path="/profile" component="profile-page" exact />"#;
4525 parser.parse("test.html", html).expect("parse failed");
4526
4527 let records = parser.into_fragment_records();
4528 let frags = &records["test.html"].fragments;
4529 match frags[0].fragment.as_ref() {
4530 Some(web_ui_fragment::Fragment::Route(r)) => {
4531 assert!(r.allowed_query.is_empty());
4532 }
4533 other => panic!("expected Fragment::Route, got {:?}", other),
4534 }
4535 }
4536
4537 #[test]
4538 fn test_outlet_not_captured_by_for_loop() {
4539 let mut parser = HtmlParser::new();
4540 let html = r#"<template shadowrootmode="open">
4541 <ul>
4542 <for each="item in items">
4543 <li>{{item.name}}</li>
4544 </for>
4545 </ul>
4546 <main>
4547 <outlet />
4548 </main>
4549</template>"#;
4550 parser.parse("comp.html", html).expect("parse failed");
4551
4552 let records = parser.into_fragment_records();
4553 let frags = &records["comp.html"].fragments;
4554
4555 let frag_types: Vec<String> = frags
4557 .iter()
4558 .map(|f| match f.fragment.as_ref() {
4559 Some(web_ui_fragment::Fragment::Raw(r)) => {
4560 format!("Raw({:?})", &r.value[..r.value.len().min(40)])
4561 }
4562 Some(web_ui_fragment::Fragment::ForLoop(fl)) => {
4563 format!("ForLoop({})", fl.fragment_id)
4564 }
4565 Some(web_ui_fragment::Fragment::Outlet(_)) => "Outlet".to_string(),
4566 other => format!("{:?}", other),
4567 })
4568 .collect();
4569 eprintln!("Fragment order: {:#?}", frag_types);
4570
4571 let outlet_count = frags
4573 .iter()
4574 .filter(|f| {
4575 matches!(
4576 f.fragment.as_ref(),
4577 Some(web_ui_fragment::Fragment::Outlet(_))
4578 )
4579 })
4580 .count();
4581 assert_eq!(
4582 outlet_count, 1,
4583 "expected exactly 1 outlet in top-level fragments, got {outlet_count}. Fragments: {frag_types:?}"
4584 );
4585
4586 let outlet_idx = frags
4588 .iter()
4589 .position(|f| {
4590 matches!(
4591 f.fragment.as_ref(),
4592 Some(web_ui_fragment::Fragment::Outlet(_))
4593 )
4594 })
4595 .expect("no outlet found");
4596 let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
4597 Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
4598 _ => false,
4599 });
4600 if let Some(ul_idx) = close_ul_idx {
4601 assert!(
4602 outlet_idx > ul_idx,
4603 "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx}). Fragments: {frag_types:?}"
4604 );
4605 }
4606 }
4607
4608 #[test]
4609 fn test_outlet_position_after_for_not_inside() {
4610 let mut parser = HtmlParser::new();
4611 let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
4612 parser.parse("test.html", html).expect("parse failed");
4613
4614 let records = parser.into_fragment_records();
4615 let frags = &records["test.html"].fragments;
4616
4617 let has_outlet = frags.iter().any(|f| {
4619 matches!(
4620 f.fragment.as_ref(),
4621 Some(web_ui_fragment::Fragment::Outlet(_))
4622 )
4623 });
4624 assert!(
4625 has_outlet,
4626 "outlet should be in top-level fragments: {frags:?}"
4627 );
4628
4629 let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
4631 Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
4632 _ => None,
4633 });
4634 if let Some(id) = for_id {
4635 let for_frags = &records[&id].fragments;
4636 let outlet_in_for = for_frags.iter().any(|f| {
4637 matches!(
4638 f.fragment.as_ref(),
4639 Some(web_ui_fragment::Fragment::Outlet(_))
4640 )
4641 });
4642 assert!(
4643 !outlet_in_for,
4644 "outlet should NOT be inside for-loop body: {for_frags:?}"
4645 );
4646 }
4647 }
4648
4649 #[test]
4650 fn test_style_element_with_handlebars_signal() {
4651 let mut parser = HtmlParser::new();
4652 let html = r#"<html><head><style>
4653:root {
4654 /*{{{tokens.light}}}*/
4655}
4656</style></head><body></body></html>"#;
4657
4658 let result = parser.parse("test.html", html);
4659 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4660 let fragment_records = parser.into_fragment_records();
4661
4662 assert_stream!(
4664 fragment_records,
4665 "test.html",
4666 [
4667 raw("<html><head><style>\n:root {\n "),
4668 signal_raw("tokens.light"),
4669 raw("\n}\n</style>"),
4670 signal_raw("head_end"),
4671 raw("</head><body>"),
4672 signal_raw("body_start"),
4673 signal_raw("body_end"),
4674 raw("</body></html>"),
4675 ]
4676 );
4677 }
4678
4679 #[test]
4680 fn test_style_comment_signal_with_spaces() {
4681 let mut parser = HtmlParser::new();
4682 let html = r#"<html><head><style>
4683:root {
4684 /* {{{tokens.light}}} */
4685}
4686</style></head><body></body></html>"#;
4687
4688 let result = parser.parse("test.html", html);
4689 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4690 let fragment_records = parser.into_fragment_records();
4691
4692 assert_stream!(
4694 fragment_records,
4695 "test.html",
4696 [
4697 raw("<html><head><style>\n:root {\n "),
4698 signal_raw("tokens.light"),
4699 raw("\n}\n</style>"),
4700 signal_raw("head_end"),
4701 raw("</head><body>"),
4702 signal_raw("body_start"),
4703 signal_raw("body_end"),
4704 raw("</body></html>"),
4705 ]
4706 );
4707 }
4708
4709 #[test]
4710 fn test_style_comment_signal_double_brace() {
4711 let mut parser = HtmlParser::new();
4712 let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
4713
4714 let result = parser.parse("test.html", html);
4715 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4716 let fragment_records = parser.into_fragment_records();
4717
4718 assert_stream!(
4720 fragment_records,
4721 "test.html",
4722 [
4723 raw("<html><head><style>"),
4724 signal("themeCss"),
4725 raw("</style>"),
4726 signal_raw("head_end"),
4727 raw("</head><body>"),
4728 signal_raw("body_start"),
4729 signal_raw("body_end"),
4730 raw("</body></html>"),
4731 ]
4732 );
4733 }
4734
4735 #[test]
4736 fn test_style_comment_with_extra_text_stays_raw() {
4737 let mut parser = HtmlParser::new();
4738 let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
4740
4741 let result = parser.parse("test.html", html);
4742 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4743 let fragment_records = parser.into_fragment_records();
4744
4745 assert_stream!(
4746 fragment_records,
4747 "test.html",
4748 [
4749 raw("<html><head><style>/* theme: {{token}} */</style>"),
4750 signal_raw("head_end"),
4751 raw("</head><body>"),
4752 signal_raw("body_start"),
4753 signal_raw("body_end"),
4754 raw("</body></html>"),
4755 ]
4756 );
4757 }
4758
4759 #[test]
4760 fn test_style_comment_with_multiple_signals_stays_raw() {
4761 let mut parser = HtmlParser::new();
4762 let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
4764
4765 let result = parser.parse("test.html", html);
4766 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4767 let fragment_records = parser.into_fragment_records();
4768
4769 assert_stream!(
4770 fragment_records,
4771 "test.html",
4772 [
4773 raw("<html><head><style>/*{{a}}{{b}}*/</style>"),
4774 signal_raw("head_end"),
4775 raw("</head><body>"),
4776 signal_raw("body_start"),
4777 signal_raw("body_end"),
4778 raw("</body></html>"),
4779 ]
4780 );
4781 }
4782
4783 #[test]
4784 fn test_style_bare_handlebars_stays_raw() {
4785 let mut parser = HtmlParser::new();
4786 let html =
4788 "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
4789
4790 let result = parser.parse("test.html", html);
4791 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4792 let fragment_records = parser.into_fragment_records();
4793
4794 assert_stream!(
4795 fragment_records,
4796 "test.html",
4797 [
4798 raw("<html><head><style>body { color: {{textColor}}; }</style>"),
4799 signal_raw("head_end"),
4800 raw("</head><body>"),
4801 signal_raw("body_start"),
4802 signal_raw("body_end"),
4803 raw("</body></html>"),
4804 ]
4805 );
4806 }
4807
4808 #[test]
4809 fn test_style_mixed_css_and_comment_signal() {
4810 let mut parser = HtmlParser::new();
4811 let html = r#"<html><head><style>
4812 .a { color: red; }
4813 /*{{themeCss}}*/
4814 .b { color: blue; }
4815</style></head><body></body></html>"#;
4816
4817 let result = parser.parse("test.html", html);
4818 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4819 let fragment_records = parser.into_fragment_records();
4820
4821 assert_stream!(
4822 fragment_records,
4823 "test.html",
4824 [
4825 raw("<html><head><style>\n .a { color: red; }\n "),
4826 signal("themeCss"),
4827 raw("\n .b { color: blue; }\n</style>"),
4828 signal_raw("head_end"),
4829 raw("</head><body>"),
4830 signal_raw("body_start"),
4831 signal_raw("body_end"),
4832 raw("</body></html>"),
4833 ]
4834 );
4835 }
4836
4837 #[test]
4838 fn test_style_element_plain_css_unchanged() {
4839 let mut parser = HtmlParser::new();
4840 let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
4841
4842 let result = parser.parse("test.html", html);
4843 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4844 let fragment_records = parser.into_fragment_records();
4845
4846 assert_stream!(
4848 fragment_records,
4849 "test.html",
4850 [
4851 raw("<html><head><style>body { margin: 0; }</style>"),
4852 signal_raw("head_end"),
4853 raw("</head><body>"),
4854 signal_raw("body_start"),
4855 signal_raw("body_end"),
4856 raw("</body></html>"),
4857 ]
4858 );
4859 }
4860
4861 #[test]
4862 fn test_text_with_binding_after_void_element() {
4863 let (fragments, _) = parse_and_get_fragments(
4866 "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
4867 );
4868 assert_fragments!(
4869 fragments,
4870 [
4871 raw("Part A: "),
4872 signal("value1"),
4873 raw("<button>Click</button><br>Part B: "),
4874 signal("value2"),
4875 raw("<button>Click2</button>"),
4876 ]
4877 );
4878 }
4879}