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