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