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