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