1mod comment_policy;
8mod component_registry;
9mod condition_parser;
10mod css_link;
11mod css_parser;
12mod error;
13mod handlebars_parser;
14pub mod plugin;
15mod route_parser;
16mod tag_scan;
17
18pub use component_registry::{Component, ComponentRegistry};
19pub use condition_parser::ConditionParser;
20pub use css_link::{CssLinkHref, CssLinkOptions, DEFAULT_CSS_FILE_NAME_TEMPLATE};
21pub use css_parser::CssParser;
22pub use error::{ParserError, Result};
23pub use handlebars_parser::HandlebarsParser;
24
25use crate::plugin::{AttributeAction, ParserPlugin, ParserPluginArtifacts};
26use std::collections::{HashMap, HashSet};
27use tree_sitter::{Node, Parser, Query, QueryCursor, StreamingIteratorMut};
28use tree_sitter_html::LANGUAGE;
29use webui_protocol::{
30 web_ui_fragment, web_ui_fragment::Fragment, ConditionExpr, FragmentList, WebUIFragment,
31 WebUIFragmentAttribute, WebUIFragmentRecords, WebUiFragmentRoute,
32};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
37pub enum CssStrategy {
38 #[default]
40 Link,
41 Style,
43 Module,
47}
48
49impl std::fmt::Display for CssStrategy {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 match self {
52 CssStrategy::Link => write!(f, "link"),
53 CssStrategy::Style => write!(f, "style"),
54 CssStrategy::Module => write!(f, "module"),
55 }
56 }
57}
58
59impl std::str::FromStr for CssStrategy {
60 type Err = String;
61
62 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
63 match s {
64 "link" => Ok(CssStrategy::Link),
65 "style" => Ok(CssStrategy::Style),
66 "module" => Ok(CssStrategy::Module),
67 other => Err(format!(
68 "Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
69 )),
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
77pub enum DomStrategy {
78 #[default]
80 Shadow,
81 Light,
83}
84
85impl std::fmt::Display for DomStrategy {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 match self {
88 DomStrategy::Shadow => write!(f, "shadow"),
89 DomStrategy::Light => write!(f, "light"),
90 }
91 }
92}
93
94impl std::str::FromStr for DomStrategy {
95 type Err = String;
96
97 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
98 match s {
99 "shadow" => Ok(DomStrategy::Shadow),
100 "light" => Ok(DomStrategy::Light),
101 other => Err(format!(
102 "Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
103 )),
104 }
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
111pub enum LegalComments {
112 None,
114 #[default]
116 Inline,
117}
118
119impl std::fmt::Display for LegalComments {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 match self {
122 LegalComments::None => write!(f, "none"),
123 LegalComments::Inline => write!(f, "inline"),
124 }
125 }
126}
127
128impl std::str::FromStr for LegalComments {
129 type Err = String;
130
131 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
132 match s {
133 "none" => Ok(LegalComments::None),
134 "inline" => Ok(LegalComments::Inline),
135 other => Err(format!(
136 "Unknown legal comments strategy: {other}. Use \"none\" or \"inline\"."
137 )),
138 }
139 }
140}
141
142#[derive(Debug, Clone, Default)]
144pub struct ParserOptions {
145 pub css_strategy: CssStrategy,
147 pub dom_strategy: DomStrategy,
149 pub css_link_options: CssLinkOptions,
151 pub legal_comments: LegalComments,
153}
154
155impl ParserOptions {
156 pub fn try_new(
162 css_strategy: CssStrategy,
163 dom_strategy: DomStrategy,
164 css_file_name_template: &str,
165 css_public_base: Option<&str>,
166 legal_comments: LegalComments,
167 ) -> Result<Self> {
168 let css_link_options = if css_strategy == CssStrategy::Link {
169 CssLinkOptions::try_new(
170 css_file_name_template.to_string(),
171 css_public_base.map(std::string::ToString::to_string),
172 )?
173 } else {
174 CssLinkOptions::default()
175 };
176
177 Ok(Self {
178 css_strategy,
179 dom_strategy,
180 css_link_options,
181 legal_comments,
182 })
183 }
184}
185
186impl From<CssStrategy> for ParserOptions {
187 fn from(css_strategy: CssStrategy) -> Self {
188 Self {
189 css_strategy,
190 ..Self::default()
191 }
192 }
193}
194
195impl From<DomStrategy> for ParserOptions {
196 fn from(dom_strategy: DomStrategy) -> Self {
197 Self {
198 dom_strategy,
199 ..Self::default()
200 }
201 }
202}
203
204impl From<(CssStrategy, DomStrategy)> for ParserOptions {
205 fn from((css_strategy, dom_strategy): (CssStrategy, DomStrategy)) -> Self {
206 Self {
207 css_strategy,
208 dom_strategy,
209 ..Self::default()
210 }
211 }
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
217pub enum Plugin {
218 #[cfg_attr(feature = "cli", value(name = "fast"))]
220 Fast,
221 #[cfg_attr(feature = "cli", value(name = "fast-v2"))]
223 FastV2,
224 #[cfg_attr(feature = "cli", value(name = "fast-v3"))]
226 FastV3,
227 #[cfg_attr(feature = "cli", value(name = "webui"))]
229 WebUI,
230}
231
232impl std::fmt::Display for Plugin {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 match self {
235 Plugin::Fast => write!(f, "fast"),
236 Plugin::FastV2 => write!(f, "fast-v2"),
237 Plugin::FastV3 => write!(f, "fast-v3"),
238 Plugin::WebUI => write!(f, "webui"),
239 }
240 }
241}
242
243impl std::str::FromStr for Plugin {
244 type Err = String;
245
246 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
247 match s {
248 "fast" => Ok(Plugin::Fast),
249 "fast-v2" => Ok(Plugin::FastV2),
250 "fast-v3" => Ok(Plugin::FastV3),
251 "webui" => Ok(Plugin::WebUI),
252 other => Err(format!(
253 "Unknown plugin: {other}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
254 )),
255 }
256 }
257}
258
259struct FragmentIdCounter {
261 counters: HashMap<String, usize>,
263}
264
265impl FragmentIdCounter {
266 fn new() -> Self {
268 Self {
269 counters: HashMap::new(),
270 }
271 }
272
273 fn next_id(&mut self, prefix: &str) -> String {
275 let count = self.counters.entry(prefix.to_string()).or_insert(0);
276 *count += 1;
277 format!("{}-{}", prefix, count)
278 }
279}
280
281impl Default for HtmlParser {
282 fn default() -> Self {
283 Self::new()
284 }
285}
286
287pub struct HtmlParser {
289 css_parser: CssParser,
291
292 parser: Parser,
294
295 id_counter: FragmentIdCounter,
297
298 condition_parser: ConditionParser,
300
301 handlebars_parser: HandlebarsParser,
303
304 component_registry: ComponentRegistry,
306
307 fragment_records: WebUIFragmentRecords,
309
310 raw_buffer: String,
312
313 options: ParserOptions,
315
316 plugin: Option<Box<dyn ParserPlugin>>,
318
319 token_store: HashSet<String>,
322
323 token_definitions: HashSet<String>,
327}
328
329impl HtmlParser {
330 #[must_use]
332 pub fn new() -> Self {
333 Self::with_options(ParserOptions::default())
334 }
335
336 #[must_use]
338 pub fn with_options(options: impl Into<ParserOptions>) -> Self {
339 let options = options.into();
340 let mut parser = Parser::new();
341 #[allow(clippy::disallowed_methods)]
343 parser
344 .set_language(&LANGUAGE.into())
345 .expect("Error loading HTML grammar");
346
347 Self {
348 component_registry: ComponentRegistry::with_legal_comments(options.legal_comments),
349 css_parser: CssParser::new(),
350 id_counter: FragmentIdCounter::new(),
351 condition_parser: ConditionParser::new(),
352 handlebars_parser: HandlebarsParser::new(),
353 raw_buffer: String::new(),
354 fragment_records: WebUIFragmentRecords::new(),
355 options,
356 plugin: None,
357 token_store: HashSet::new(),
358 token_definitions: HashSet::new(),
359 parser,
360 }
361 }
362
363 #[must_use]
365 pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
366 Self::with_plugin_options(plugin, ParserOptions::default())
367 }
368
369 #[must_use]
371 pub fn with_plugin_options(
372 plugin: Box<dyn ParserPlugin>,
373 options: impl Into<ParserOptions>,
374 ) -> Self {
375 let mut p = Self::with_options(options);
376 p.plugin = Some(plugin);
377 p.configure_plugin();
378 p
379 }
380
381 fn configure_plugin(&mut self) {
382 if let Some(ref mut plugin) = self.plugin {
383 plugin.configure(&self.options);
384 }
385 }
386
387 pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
389 &mut self.component_registry
390 }
391
392 #[must_use]
394 pub fn component_registry(&self) -> &ComponentRegistry {
395 &self.component_registry
396 }
397
398 pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
399 std::mem::take(&mut self.fragment_records)
400 }
401
402 pub fn has_fragment(&self, fragment_id: &str) -> bool {
404 self.fragment_records.contains_key(fragment_id)
405 }
406
407 #[must_use]
409 pub fn take_plugin_artifacts(&mut self) -> ParserPluginArtifacts {
410 self.plugin
411 .take()
412 .map_or(ParserPluginArtifacts::None, |plugin| {
413 plugin.into_artifacts()
414 })
415 }
416
417 #[must_use]
425 pub fn take_tokens(&mut self) -> Vec<String> {
426 let definitions = std::mem::take(&mut self.token_definitions);
427 let mut tokens: Vec<String> = std::mem::take(&mut self.token_store)
428 .into_iter()
429 .filter(|t| !definitions.contains(t))
430 .collect();
431 tokens.sort();
432 tokens
433 }
434
435 pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
437 self.raw_buffer.clear();
439 if let Some(ref mut plugin) = self.plugin {
440 plugin.start_fragment(fragment_id);
441 }
442
443 let tree = self
445 .parser
446 .parse(html_content, None)
447 .ok_or_else(|| ParserError::Html("Failed to parse HTML".to_string()))?;
448
449 let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
450
451 self.process_html_node(tree.root_node(), html_content, &mut entry_fragment)?;
453
454 self.flush_raw_buffer(&mut entry_fragment);
455
456 self.fragment_records.insert(
458 fragment_id.to_string(),
459 FragmentList {
460 fragments: entry_fragment,
461 },
462 );
463
464 Ok(())
466 }
467
468 fn add_raw_fragment(&mut self, content: &str) {
470 if !content.is_empty() {
471 self.raw_buffer.push_str(content);
472 }
473 }
474
475 fn add_for_fragment(
477 &mut self,
478 item: String,
479 collection: String,
480 fragment_id: String,
481 fragments: &mut Vec<WebUIFragment>,
482 ) {
483 self.flush_raw_buffer(fragments);
484 fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
485 }
486
487 fn add_if_fragment(
489 &mut self,
490 condition: ConditionExpr,
491 fragment_id: String,
492 fragments: &mut Vec<WebUIFragment>,
493 ) {
494 self.flush_raw_buffer(fragments);
495 fragments.push(WebUIFragment::if_cond(condition, fragment_id));
496 }
497
498 fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
500 self.flush_raw_buffer(fragments);
501 fragments.push(fragment);
502 }
503
504 fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
506 if !self.raw_buffer.is_empty() {
507 fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
508 }
509 }
510
511 fn should_emit_text_content(content: &str) -> bool {
518 if content.is_empty() {
519 return false;
520 }
521
522 if !content.trim().is_empty() {
523 return true;
524 }
525
526 content.chars().all(char::is_whitespace)
527 && !content.contains('\n')
528 && !content.contains('\r')
529 }
530
531 fn process_children_with_source_gaps(
537 &mut self,
538 node: Node,
539 source: &str,
540 fragments: &mut Vec<WebUIFragment>,
541 skip_structural_tags: bool,
542 ) -> Result<()> {
543 let mut cursor = node.walk();
544 let mut last_end = node.start_byte();
545
546 for child in node.children(&mut cursor) {
547 if child.start_byte() > last_end {
548 let gap = &source[last_end..child.start_byte()];
549 if Self::should_emit_text_content(gap) {
550 self.add_raw_fragment(gap);
551 }
552 }
553
554 let kind = child.kind();
555 if !skip_structural_tags || (kind != "start_tag" && kind != "end_tag") {
556 self.process_child_node(child, source, fragments)?;
557 }
558
559 last_end = child.end_byte();
560 }
561
562 if node.end_byte() > last_end {
563 let gap = &source[last_end..node.end_byte()];
564 if Self::should_emit_text_content(gap) {
565 self.add_raw_fragment(gap);
566 }
567 }
568
569 Ok(())
570 }
571
572 fn process_content_children(
574 &mut self,
575 node: Node,
576 source: &str,
577 fragments: &mut Vec<WebUIFragment>,
578 ) -> Result<()> {
579 self.process_children_with_source_gaps(node, source, fragments, true)
580 }
581 fn process_html_node(
583 &mut self,
584 node: Node,
585 source: &str,
586 fragments: &mut Vec<WebUIFragment>,
587 ) -> Result<()> {
588 if node.kind() == "document" || node.kind() == "fragment" || node.kind() == "element" {
589 self.process_children_with_source_gaps(node, source, fragments, false)?;
590 } else {
591 let content = &source[node.start_byte()..node.end_byte()];
593 if Self::should_emit_text_content(content) {
594 self.add_raw_fragment(content);
595 }
596 }
597
598 Ok(())
599 }
600
601 fn process_child_node(
603 &mut self,
604 node: Node,
605 source: &str,
606 fragments: &mut Vec<WebUIFragment>,
607 ) -> Result<()> {
608 match node.kind() {
609 "element" => {
610 let tag_name = self.get_element_tag_name(node, source)?;
612
613 match tag_name.as_str() {
615 "for" => return self.process_for_directive(node, source, fragments),
616 "if" => return self.process_if_directive(node, source, fragments),
617 "body" => return self.process_body_element(node, source, fragments),
618 "head" => return self.process_head_element(node, source, fragments),
619 "route" => return self.process_route_directive(node, source, fragments),
620 "outlet" => {
621 self.flush_raw_buffer(fragments);
622 fragments.push(WebUIFragment::outlet());
623 return Ok(());
624 }
625 _ => {
626 if self.component_registry.contains(tag_name.as_str()) {
627 return self.process_component_directive(
628 node,
629 source,
630 fragments,
631 tag_name.as_str(),
632 );
633 }
634
635 self.process_regular_element(node, source, fragments, &tag_name)?;
637
638 return Ok(());
639 }
640 }
641 }
642 "style_element" => {
643 self.add_raw_fragment("<style>");
647 for child in node.named_children(&mut node.walk()) {
648 if child.kind() == "raw_text" {
649 let style_content = &source[child.start_byte()..child.end_byte()];
650
651 let (tokens, defs, comments) =
652 self.css_parser.extract_tokens_definitions_and_comments(
653 style_content,
654 self.options.legal_comments,
655 )?;
656 self.token_store.extend(tokens);
657 self.token_definitions.extend(defs);
658 self.process_style_content(style_content, &comments, fragments);
659 }
660 }
661 self.add_raw_fragment("</style>");
662 }
663 "text" | "raw_text" => {
664 let content = &source[node.start_byte()..node.end_byte()];
665 if Self::should_emit_text_content(content) {
666 let handlebars_result = self.handlebars_parser.parse(content);
667 match handlebars_result {
668 Ok(parsed_fragments) => {
669 for fragment in parsed_fragments {
670 if matches!(fragment.fragment.as_ref(), Some(Fragment::Raw(_))) {
671 if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
672 self.add_raw_fragment(&raw.value);
673 }
674 } else {
675 self.add_fragment(fragment, fragments);
676 }
677 }
678 }
679 Err(error) => return Err(error),
680 }
681 }
682 }
683 "doctype" => {
688 let content = &source[node.start_byte()..node.end_byte()];
689 self.add_raw_fragment(content);
690 }
691 "comment" => {}
694 _ => {
696 self.process_html_node(node, source, fragments)?;
697 }
698 }
699
700 Ok(())
701 }
702
703 fn process_style_content(
704 &mut self,
705 css: &str,
706 comments: &[crate::css_parser::CssComment],
707 fragments: &mut Vec<WebUIFragment>,
708 ) {
709 let mut last_end = 0usize;
710
711 for comment in comments {
712 if comment.start_byte < last_end {
713 if comment.end_byte > last_end {
714 last_end = comment.end_byte;
715 }
716 continue;
717 }
718
719 self.add_raw_fragment(&css[last_end..comment.start_byte]);
720 let comment_text = &css[comment.start_byte..comment.end_byte];
721 if let Some(fragment) = self.css_signal_comment_fragment(comment_text) {
722 self.add_fragment(fragment, fragments);
723 } else if comment.preserve {
724 self.add_raw_fragment(comment_text);
725 }
726 last_end = comment.end_byte;
727 }
728
729 self.add_raw_fragment(&css[last_end..]);
730 }
731
732 fn css_signal_comment_fragment(&self, comment: &str) -> Option<WebUIFragment> {
733 let signal = comment_policy::parse_css_signal_comment(comment)?;
734 Some(WebUIFragment::signal(signal.path, signal.raw))
735 }
736
737 fn get_element_tag_name(&self, node: Node, source: &str) -> Result<String> {
739 let mut cursor = node.walk();
740 for child in node.named_children(&mut cursor) {
741 if child.kind() == "start_tag" || child.kind() == "self_closing_tag" {
742 let mut tag_cursor = child.walk();
743 for tag_name_node in child.named_children(&mut tag_cursor) {
744 if tag_name_node.kind() == "tag_name" {
745 let tag_name = source[tag_name_node.start_byte()..tag_name_node.end_byte()]
746 .to_string();
747 return Ok(tag_name);
748 }
749 }
750 }
751 }
752
753 Err(ParserError::Html("Failed to extract tag name".to_string()))
754 }
755
756 fn get_element_attribute(
757 &self,
758 node: Node,
759 attr_name: &str,
760 source: &str,
761 ) -> Result<Option<String>> {
762 let query_str = r#"
763 (element
764 [
765 (start_tag
766 (attribute
767 (attribute_name) @name
768 [(quoted_attribute_value (attribute_value) @value)
769 (attribute_value) @value]))
770 (self_closing_tag
771 (attribute
772 (attribute_name) @name
773 [(quoted_attribute_value (attribute_value) @value)
774 (attribute_value) @value]))
775 ]
776 )
777 "#;
778
779 let query = Query::new(&LANGUAGE.into(), query_str)
780 .map_err(|e| ParserError::Html(format!("Failed to create attribute query: {:?}", e)))?;
781
782 let mut cursor = QueryCursor::new();
783 let mut matches = cursor.matches(&query, node, source.as_bytes());
784 while let Some(m) = matches.next_mut() {
785 let mut found_name = false;
786 let mut found_value: Option<String> = None;
787
788 for capture in m.captures.iter() {
789 let node = capture.node;
790 let capture_name = query.capture_names()[capture.index as usize];
791
792 if capture_name == "name" {
793 let name_text = node.utf8_text(source.as_bytes()).map_err(|_| {
794 ParserError::Html("Invalid UTF-8 for attribute name".to_string())
795 })?;
796 if name_text == attr_name {
797 found_name = true;
798 }
799 } else if capture_name == "value" {
800 let value_text = node.utf8_text(source.as_bytes()).map_err(|_| {
801 ParserError::Html("Invalid UTF-8 for attribute value".to_string())
802 })?;
803 found_value = Some(value_text.to_string());
804 }
805 }
806
807 if found_name {
808 return Ok(found_value);
809 }
810 }
811
812 Ok(None)
813 }
814
815 fn has_element_attribute(&self, node: Node, attr_name: &str, source: &str) -> Result<bool> {
818 if self
820 .get_element_attribute(node, attr_name, source)?
821 .is_some()
822 {
823 return Ok(true);
824 }
825
826 let query_str = r#"
830 (element
831 [
832 (start_tag
833 (attribute
834 (attribute_name) @name))
835 (self_closing_tag
836 (attribute
837 (attribute_name) @name))
838 ]
839 )
840 "#;
841
842 let query = Query::new(&LANGUAGE.into(), query_str)
843 .map_err(|e| ParserError::Html(format!("Failed to create attribute query: {:?}", e)))?;
844
845 let tag_node_id = node
847 .named_children(&mut node.walk())
848 .find(|c| c.kind() == "start_tag" || c.kind() == "self_closing_tag")
849 .map(|c| c.id());
850
851 let mut cursor = QueryCursor::new();
852 let mut matches = cursor.matches(&query, node, source.as_bytes());
853 while let Some(m) = matches.next_mut() {
854 for capture in m.captures.iter() {
855 let capture_name = query.capture_names()[capture.index as usize];
856 if capture_name == "name" {
857 let attr_parent = capture.node.parent().and_then(|a| a.parent());
859 if attr_parent
860 .map(|p| Some(p.id()) == tag_node_id)
861 .unwrap_or(false)
862 {
863 let name_text =
864 capture.node.utf8_text(source.as_bytes()).map_err(|_| {
865 ParserError::Html("Invalid UTF-8 for attribute name".to_string())
866 })?;
867 if name_text == attr_name {
868 return Ok(true);
869 }
870 }
871 }
872 }
873 }
874
875 Ok(false)
876 }
877
878 fn find_tag_node<'a>(&self, node: Node<'a>) -> Option<Node<'a>> {
880 let mut cursor = node.walk();
881 let result = node
882 .named_children(&mut cursor)
883 .find(|child| child.kind() == "start_tag" || child.kind() == "self_closing_tag");
884 result
885 }
886
887 fn extract_single_handlebars(value: &str) -> Option<String> {
890 let trimmed = value.trim();
891 if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
892 let inner = trimmed[2..trimmed.len() - 2].trim();
893 if !inner.contains("{{") && !inner.is_empty() {
895 return Some(inner.to_string());
896 }
897 }
898 None
899 }
900
901 fn contains_handlebars(value: &str) -> bool {
903 value.contains("{{")
904 }
905
906 fn has_end_tag(&self, node: Node) -> bool {
908 let mut cursor = node.walk();
909 let result = node
910 .named_children(&mut cursor)
911 .any(|child| child.kind() == "end_tag");
912 result
913 }
914
915 fn process_regular_element(
917 &mut self,
918 node: Node,
919 source: &str,
920 fragments: &mut Vec<WebUIFragment>,
921 tag_name: &str,
922 ) -> Result<()> {
923 let tag_node = self.find_tag_node(node);
924 let is_self_closing = tag_node
925 .map(|n| n.kind() == "self_closing_tag")
926 .unwrap_or(false);
927 let has_end = self.has_end_tag(node);
928
929 if let Some(tag_node) = tag_node {
930 self.add_raw_fragment(&format!("<{}", tag_name));
932
933 let binding_count = self.process_tag_attributes(tag_node, source, fragments, false)?;
935 if let Some(ref mut p) = self.plugin {
936 if let Some(data) = p.finish_element(binding_count) {
937 self.add_fragment(WebUIFragment::plugin(data), fragments);
938 }
939 }
940
941 if is_self_closing {
942 let tag_text = &source[tag_node.start_byte()..tag_node.end_byte()];
944 if tag_text.ends_with("/>") {
945 self.add_raw_fragment("/>");
946 } else {
947 self.add_raw_fragment(">");
948 }
949 } else if !has_end {
950 self.add_raw_fragment(">");
955 self.process_content_children(node, source, fragments)?;
956 } else {
957 self.add_raw_fragment(">");
958
959 self.process_content_children(node, source, fragments)?;
960
961 self.add_raw_fragment(&format!("</{}>", tag_name));
963 }
964 }
965
966 Ok(())
967 }
968
969 fn process_tag_attributes(
976 &mut self,
977 tag_node: Node,
978 source: &str,
979 fragments: &mut Vec<WebUIFragment>,
980 is_component: bool,
981 ) -> Result<u32> {
982 let mut first_dynamic_emitted = false;
983 let mut binding_count: u32 = 0;
984 let mut cursor = tag_node.walk();
985
986 for child in tag_node.named_children(&mut cursor) {
987 if child.kind() != "attribute" {
988 continue;
989 }
990
991 let attr_name = self.get_attr_name(child, source)?;
992
993 if let Some(ref mut p) = self.plugin {
994 match p.classify_attribute(&attr_name) {
995 AttributeAction::Keep => {}
996 AttributeAction::Skip => continue,
997 AttributeAction::SkipAndCountBinding => {
998 binding_count += 1;
999 continue;
1000 }
1001 }
1002 }
1003
1004 let attr_value = self.get_attr_value(child, source);
1005
1006 if let Some(bool_name) = attr_name.strip_prefix('?') {
1007 if is_component {
1009 if let Some(condition) = self.parse_boolean_condition(attr_value.as_deref()) {
1010 let frag = Self::maybe_mark_attr_start(
1011 WebUIFragment::attribute_boolean(bool_name, condition),
1012 &mut first_dynamic_emitted,
1013 );
1014 self.add_fragment(frag, fragments);
1015 binding_count += 1;
1016 }
1017 } else if self.process_boolean_attribute(
1018 bool_name,
1019 attr_value.as_deref(),
1020 fragments,
1021 )? {
1022 binding_count += 1;
1023 }
1024 } else if let Some(prop_name) = attr_name.strip_prefix(':') {
1025 if !is_component {
1031 return Err(ParserError::Parse(format!(
1032 ":{prop_name} complex binding is only allowed on custom elements. \
1033 Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
1034 )));
1035 }
1036 if Self::is_blocked_complex_property(prop_name) {
1037 return Err(ParserError::Parse(format!(
1038 ":{prop_name} is not allowed as a complex attribute binding \
1039 because it enables arbitrary HTML injection. \
1040 Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
1041 )));
1042 }
1043 if let Some(val) = &attr_value {
1044 if let Some(signal_name) = Self::extract_single_handlebars(val) {
1045 let frag = Self::maybe_mark_attr_start(
1046 WebUIFragment::attribute_complex(&attr_name, signal_name),
1047 &mut first_dynamic_emitted,
1048 );
1049 self.add_fragment(frag, fragments);
1050 binding_count += 1;
1051 }
1052 }
1053 } else if is_component && Self::is_skipped_attribute(&attr_name) {
1054 if let Some(val) = &attr_value {
1056 if let Some(signal_name) = Self::extract_single_handlebars(val) {
1057 let frag = WebUIFragment {
1059 fragment: Some(web_ui_fragment::Fragment::Attribute(
1060 WebUIFragmentAttribute {
1061 name: attr_name,
1062 value: signal_name,
1063 attr_skip: true,
1064 ..Default::default()
1065 },
1066 )),
1067 };
1068 self.add_fragment(frag, fragments);
1069 binding_count += 1;
1070 } else if Self::contains_handlebars(val) {
1071 let template_id = self.id_counter.next_id("attr");
1073 let parsed = self.handlebars_parser.parse(val)?;
1074 self.fragment_records
1075 .insert(template_id.clone(), FragmentList { fragments: parsed });
1076 let frag = WebUIFragment {
1077 fragment: Some(web_ui_fragment::Fragment::Attribute(
1078 WebUIFragmentAttribute {
1079 name: attr_name,
1080 template: template_id,
1081 attr_skip: true,
1082 ..Default::default()
1083 },
1084 )),
1085 };
1086 self.add_fragment(frag, fragments);
1087 binding_count += 1;
1088 } else {
1089 let frag = WebUIFragment {
1091 fragment: Some(web_ui_fragment::Fragment::Attribute(
1092 WebUIFragmentAttribute {
1093 name: attr_name,
1094 value: val.clone(),
1095 raw_value: true,
1096 attr_skip: true,
1097 ..Default::default()
1098 },
1099 )),
1100 };
1101 self.add_fragment(frag, fragments);
1102 }
1103 }
1104 } else if let Some(ref val) = attr_value {
1105 if Self::contains_handlebars(val) {
1106 if is_component {
1107 if let Some(signal_name) = Self::extract_single_handlebars(val) {
1108 let frag = Self::maybe_mark_attr_start(
1109 WebUIFragment::attribute(&attr_name, signal_name),
1110 &mut first_dynamic_emitted,
1111 );
1112 self.add_fragment(frag, fragments);
1113 binding_count += 1;
1114 } else {
1115 let template_id = self.id_counter.next_id("attr");
1116 let parsed = self.handlebars_parser.parse(val)?;
1117 self.fragment_records
1118 .insert(template_id.clone(), FragmentList { fragments: parsed });
1119 let frag = Self::maybe_mark_attr_start(
1120 WebUIFragment::attribute_template(&attr_name, template_id),
1121 &mut first_dynamic_emitted,
1122 );
1123 self.add_fragment(frag, fragments);
1124 binding_count += 1;
1125 }
1126 } else {
1127 self.process_dynamic_attribute(&attr_name, val, fragments)?;
1128 binding_count += 1;
1129 }
1130 } else if is_component {
1131 let frag = Self::maybe_mark_attr_start(
1134 WebUIFragment {
1135 fragment: Some(web_ui_fragment::Fragment::Attribute(
1136 WebUIFragmentAttribute {
1137 name: attr_name,
1138 value: val.clone(),
1139 raw_value: true,
1140 ..Default::default()
1141 },
1142 )),
1143 },
1144 &mut first_dynamic_emitted,
1145 );
1146 self.add_fragment(frag, fragments);
1147 } else {
1148 let attr_text = &source[child.start_byte()..child.end_byte()];
1150 self.add_raw_fragment(&format!(" {}", attr_text));
1151 }
1152 } else {
1153 self.add_raw_fragment(&format!(" {}", attr_name));
1155 }
1156 }
1157 Ok(binding_count)
1158 }
1159
1160 fn maybe_mark_attr_start(
1163 mut frag: WebUIFragment,
1164 first_dynamic_emitted: &mut bool,
1165 ) -> WebUIFragment {
1166 if !*first_dynamic_emitted {
1167 if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
1168 a.attr_start = true;
1169 }
1170 *first_dynamic_emitted = true;
1171 }
1172 frag
1173 }
1174
1175 fn get_attr_name(&self, attr_node: Node, source: &str) -> Result<String> {
1177 let mut cursor = attr_node.walk();
1178 for child in attr_node.children(&mut cursor) {
1179 if child.kind() == "attribute_name" {
1180 return Ok(source[child.start_byte()..child.end_byte()].to_string());
1181 }
1182 }
1183 Err(ParserError::Html(
1184 "Attribute node missing attribute_name".to_string(),
1185 ))
1186 }
1187
1188 fn get_attr_value(&self, attr_node: Node, source: &str) -> Option<String> {
1191 let mut cursor = attr_node.walk();
1192 for child in attr_node.children(&mut cursor) {
1193 match child.kind() {
1194 "quoted_attribute_value" => {
1195 let mut inner_cursor = child.walk();
1197 for inner in child.children(&mut inner_cursor) {
1198 if inner.kind() == "attribute_value" {
1199 return Some(source[inner.start_byte()..inner.end_byte()].to_string());
1200 }
1201 }
1202 return Some(String::new());
1204 }
1205 "attribute_value" => {
1206 return Some(source[child.start_byte()..child.end_byte()].to_string());
1207 }
1208 _ => {}
1209 }
1210 }
1211 None
1212 }
1213
1214 fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
1217 if let Some(val) = value {
1218 if let Some(expr_str) = Self::extract_single_handlebars(val) {
1219 return Some(
1220 self.condition_parser
1221 .parse(&expr_str)
1222 .unwrap_or_else(|_| ConditionExpr::identifier(&expr_str)),
1223 );
1224 }
1225 }
1226
1227 None
1228 }
1229
1230 fn process_boolean_attribute(
1231 &mut self,
1232 name: &str,
1233 value: Option<&str>,
1234 fragments: &mut Vec<WebUIFragment>,
1235 ) -> Result<bool> {
1236 if let Some(condition) = self.parse_boolean_condition(value) {
1237 self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
1238 return Ok(true);
1239 }
1240 Ok(false)
1242 }
1243
1244 fn process_dynamic_attribute(
1246 &mut self,
1247 name: &str,
1248 value: &str,
1249 fragments: &mut Vec<WebUIFragment>,
1250 ) -> Result<()> {
1251 if let Some(signal_name) = Self::extract_single_handlebars(value) {
1252 self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
1254 } else {
1255 let template_id = self.id_counter.next_id("attr");
1257 let parsed = self.handlebars_parser.parse(value)?;
1258
1259 self.fragment_records
1260 .insert(template_id.clone(), FragmentList { fragments: parsed });
1261
1262 self.add_fragment(
1263 WebUIFragment::attribute_template(name, template_id),
1264 fragments,
1265 );
1266 }
1267 Ok(())
1268 }
1269
1270 fn process_head_element(
1272 &mut self,
1273 node: Node,
1274 source: &str,
1275 fragments: &mut Vec<WebUIFragment>,
1276 ) -> Result<()> {
1277 self.add_raw_fragment("<head>");
1278 self.process_content_children(node, source, fragments)?;
1279 self.flush_raw_buffer(fragments);
1280 fragments.push(WebUIFragment::signal("head_end", true));
1281 self.add_raw_fragment("</head>");
1282 Ok(())
1283 }
1284
1285 fn process_body_element(
1287 &mut self,
1288 node: Node,
1289 source: &str,
1290 fragments: &mut Vec<WebUIFragment>,
1291 ) -> Result<()> {
1292 let tag_node = self.find_tag_node(node);
1293 if let Some(tag_node) = tag_node {
1294 self.add_raw_fragment("<body");
1295 let binding_count = self.process_tag_attributes(tag_node, source, fragments, false)?;
1296 if let Some(ref mut p) = self.plugin {
1297 if let Some(data) = p.finish_element(binding_count) {
1298 self.add_fragment(WebUIFragment::plugin(data), fragments);
1299 }
1300 }
1301 self.add_raw_fragment(">");
1302 } else {
1303 self.add_raw_fragment("<body>");
1304 }
1305 self.flush_raw_buffer(fragments);
1306 fragments.push(WebUIFragment::signal("body_start", true));
1307 self.process_content_children(node, source, fragments)?;
1308 self.flush_raw_buffer(fragments);
1309 fragments.push(WebUIFragment::signal("body_end", true));
1310 self.add_raw_fragment("</body>");
1311 Ok(())
1312 }
1313
1314 fn process_for_directive(
1316 &mut self,
1317 node: Node,
1318 source: &str,
1319 fragments: &mut Vec<WebUIFragment>,
1320 ) -> Result<()> {
1321 let each = self
1323 .get_element_attribute(node, "each", source)?
1324 .ok_or_else(|| {
1325 ParserError::Directive("Missing 'each' attribute on <for>".to_string())
1326 })?;
1327
1328 let parts: Vec<&str> = each.split_whitespace().collect();
1330 if parts.len() != 3 || parts[1] != "in" {
1331 return Err(ParserError::Directive(format!(
1332 "Invalid for each: {}",
1333 each
1334 )));
1335 }
1336
1337 let allowed = |s: &str| {
1339 s.chars()
1340 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1341 };
1342 if !allowed(parts[0]) || !allowed(parts[2]) {
1343 return Err(ParserError::Directive(format!(
1344 "Invalid identifier in for each: {}",
1345 each
1346 )));
1347 }
1348
1349 let item = parts[0];
1350 let collection = parts[2];
1351
1352 let fragment_id = self
1354 .get_element_attribute(node, "template", source)?
1355 .unwrap_or_else(|| self.id_counter.next_id("for"));
1356 let mut for_fragment: Vec<WebUIFragment> = Vec::new();
1357
1358 let mut temp_buffer = String::new();
1360 std::mem::swap(&mut self.raw_buffer, &mut temp_buffer);
1361
1362 self.process_content_children(node, source, &mut for_fragment)?;
1364
1365 self.flush_raw_buffer(&mut for_fragment);
1367
1368 std::mem::swap(&mut self.raw_buffer, &mut temp_buffer);
1370
1371 if for_fragment.is_empty() {
1373 return Ok(());
1374 }
1375
1376 self.fragment_records.insert(
1378 fragment_id.clone(),
1379 FragmentList {
1380 fragments: for_fragment,
1381 },
1382 );
1383
1384 self.add_for_fragment(
1386 item.to_string(),
1387 collection.to_string(),
1388 fragment_id,
1389 fragments,
1390 );
1391
1392 Ok(())
1393 }
1394
1395 fn process_if_directive(
1397 &mut self,
1398 node: Node,
1399 source: &str,
1400 fragments: &mut Vec<WebUIFragment>,
1401 ) -> Result<()> {
1402 let condition_str = self
1404 .get_element_attribute(node, "condition", source)?
1405 .ok_or_else(|| {
1406 ParserError::Directive("Missing 'condition' attribute on <if>".to_string())
1407 })?;
1408
1409 let condition_result = self.condition_parser.parse(&condition_str);
1411 let condition = match condition_result {
1412 Ok(cond) => cond,
1413 Err(_) => {
1414 return Err(ParserError::Directive(format!(
1415 "Invalid condition expression: {}",
1416 condition_str
1417 )))
1418 }
1419 };
1420
1421 let fragment_id = self.id_counter.next_id("if");
1423
1424 self.flush_raw_buffer(fragments);
1426
1427 let mut if_fragment: Vec<WebUIFragment> = Vec::new();
1429
1430 let parent_buffer = std::mem::take(&mut self.raw_buffer);
1432
1433 self.process_content_children(node, source, &mut if_fragment)?;
1435
1436 self.flush_raw_buffer(&mut if_fragment);
1438
1439 self.fragment_records.insert(
1441 fragment_id.clone(),
1442 FragmentList {
1443 fragments: if_fragment,
1444 },
1445 );
1446
1447 self.raw_buffer = parent_buffer;
1449
1450 self.add_if_fragment(condition, fragment_id, fragments);
1452
1453 Ok(())
1454 }
1455
1456 fn process_route_directive(
1462 &mut self,
1463 node: Node,
1464 source: &str,
1465 fragments: &mut Vec<WebUIFragment>,
1466 ) -> Result<()> {
1467 let path = self
1469 .get_element_attribute(node, "path", source)?
1470 .unwrap_or_default();
1471
1472 let component = self
1473 .get_element_attribute(node, "component", source)?
1474 .unwrap_or_default();
1475
1476 let exact = self.has_element_attribute(node, "exact", source)?;
1477
1478 let query = self
1479 .get_element_attribute(node, "query", source)?
1480 .unwrap_or_default();
1481
1482 let attrs = route_parser::RouteAttributes {
1483 path: path.clone(),
1484 component: component.clone(),
1485 exact,
1486 query,
1487 keep_alive: self.has_element_attribute(node, "keep-alive", source)?,
1488 cache_tags: self
1489 .get_element_attribute(node, "cache-tags", source)?
1490 .map(|v| route_parser::parse_tag_list(&v))
1491 .unwrap_or_default(),
1492 invalidates: self
1493 .get_element_attribute(node, "invalidates", source)?
1494 .map(|v| route_parser::parse_tag_list(&v))
1495 .unwrap_or_default(),
1496 pending_component: self
1497 .get_element_attribute(node, "pending", source)?
1498 .unwrap_or_default(),
1499 error_component: self
1500 .get_element_attribute(node, "error", source)?
1501 .unwrap_or_default(),
1502 };
1503
1504 route_parser::validate_attributes(&attrs)?;
1506
1507 let route_params: std::collections::HashSet<String> =
1509 route_parser::extract_params(&path)?.into_iter().collect();
1510 if !attrs.cache_tags.is_empty() {
1511 route_parser::validate_tag_placeholders(
1512 &attrs.cache_tags,
1513 &route_params,
1514 "cache-tags",
1515 &path,
1516 )?;
1517 }
1518 if !attrs.invalidates.is_empty() {
1519 route_parser::validate_tag_placeholders(
1520 &attrs.invalidates,
1521 &route_params,
1522 "invalidates",
1523 &path,
1524 )?;
1525 }
1526
1527 if !attrs.pending_component.is_empty() {
1529 self.ensure_route_component_parsed(&attrs.pending_component)?;
1530 }
1531 if !attrs.error_component.is_empty() {
1533 self.ensure_route_component_parsed(&attrs.error_component)?;
1534 }
1535
1536 self.ensure_route_component_parsed(&component)?;
1538
1539 let mut all_params = std::collections::HashSet::new();
1541 all_params.extend(route_params);
1542 let children = self.parse_child_routes(node, source, &all_params)?;
1543
1544 self.flush_raw_buffer(fragments);
1546
1547 let route_fragment =
1549 route_parser::build_route_fragment(&attrs, component.clone(), children);
1550
1551 fragments.push(WebUIFragment::route_from(route_fragment));
1553
1554 Ok(())
1555 }
1556
1557 fn parse_child_routes(
1559 &mut self,
1560 node: Node,
1561 source: &str,
1562 ancestor_params: &std::collections::HashSet<String>,
1563 ) -> Result<Vec<WebUiFragmentRoute>> {
1564 let mut children = Vec::new();
1565 let mut cursor = node.walk();
1566
1567 for child in node.named_children(&mut cursor) {
1568 if child.kind() == "element" {
1569 if let Ok(tag) = self.get_element_tag_name(child, source) {
1570 if tag == "route" {
1571 let child_route =
1572 self.parse_route_as_fragment(child, source, ancestor_params)?;
1573 children.push(child_route);
1574 }
1575 }
1576 }
1577 }
1578
1579 Ok(children)
1580 }
1581
1582 fn parse_route_as_fragment(
1584 &mut self,
1585 node: Node,
1586 source: &str,
1587 ancestor_params: &std::collections::HashSet<String>,
1588 ) -> Result<WebUiFragmentRoute> {
1589 let path = self
1590 .get_element_attribute(node, "path", source)?
1591 .unwrap_or_default();
1592 let component = self
1593 .get_element_attribute(node, "component", source)?
1594 .unwrap_or_default();
1595 let exact = self.has_element_attribute(node, "exact", source)?;
1596
1597 let query = self
1598 .get_element_attribute(node, "query", source)?
1599 .unwrap_or_default();
1600
1601 let attrs = route_parser::RouteAttributes {
1602 path: path.clone(),
1603 component: component.clone(),
1604 exact,
1605 query,
1606 keep_alive: self.has_element_attribute(node, "keep-alive", source)?,
1607 cache_tags: self
1608 .get_element_attribute(node, "cache-tags", source)?
1609 .map(|v| route_parser::parse_tag_list(&v))
1610 .unwrap_or_default(),
1611 invalidates: self
1612 .get_element_attribute(node, "invalidates", source)?
1613 .map(|v| route_parser::parse_tag_list(&v))
1614 .unwrap_or_default(),
1615 pending_component: self
1616 .get_element_attribute(node, "pending", source)?
1617 .unwrap_or_default(),
1618 error_component: self
1619 .get_element_attribute(node, "error", source)?
1620 .unwrap_or_default(),
1621 };
1622
1623 route_parser::validate_attributes(&attrs)?;
1624
1625 let own_params: std::collections::HashSet<String> =
1628 route_parser::extract_params(&path)?.into_iter().collect();
1629 let mut all_params = ancestor_params.clone();
1630 all_params.extend(own_params.iter().cloned());
1631 if !attrs.cache_tags.is_empty() {
1632 route_parser::validate_tag_placeholders(
1633 &attrs.cache_tags,
1634 &all_params,
1635 "cache-tags",
1636 &path,
1637 )?;
1638 }
1639 if !attrs.invalidates.is_empty() {
1640 route_parser::validate_tag_placeholders(
1641 &attrs.invalidates,
1642 &all_params,
1643 "invalidates",
1644 &path,
1645 )?;
1646 }
1647
1648 if !attrs.pending_component.is_empty() {
1650 self.ensure_route_component_parsed(&attrs.pending_component)?;
1651 }
1652 if !attrs.error_component.is_empty() {
1653 self.ensure_route_component_parsed(&attrs.error_component)?;
1654 }
1655
1656 self.ensure_route_component_parsed(&component)?;
1658
1659 let children = self.parse_child_routes(node, source, &all_params)?;
1661
1662 Ok(route_parser::build_route_fragment(
1663 &attrs, component, children,
1664 ))
1665 }
1666
1667 fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
1669 if component.is_empty()
1670 || !self.component_registry.contains(component)
1671 || self.fragment_records.contains_key(component)
1672 {
1673 return Ok(());
1674 }
1675
1676 let component_data = self
1677 .component_registry
1678 .get(component)
1679 .ok_or_else(|| {
1680 crate::error::ParserError::Directive(format!("Component not found: {component}"))
1681 })?
1682 .clone();
1683
1684 self.token_store
1685 .extend(component_data.css_tokens.iter().cloned());
1686
1687 let processed = self.build_component_template(
1688 component,
1689 &component_data.html_content,
1690 component_data.css_content.as_deref(),
1691 )?;
1692
1693 if let Some(ref mut p) = self.plugin {
1694 p.register_component_template(component, &component_data, &processed)?;
1695 }
1696
1697 let saved_buffer = std::mem::take(&mut self.raw_buffer);
1698 self.parse(component, &processed)?;
1699 self.raw_buffer = saved_buffer;
1700
1701 Ok(())
1702 }
1703
1704 const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
1706 const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-", "aria-"];
1708
1709 fn is_skipped_attribute(name: &str) -> bool {
1710 Self::SKIPPED_ATTRIBUTES.contains(&name)
1711 || Self::SKIPPED_ATTRIBUTE_PREFIXES
1712 .iter()
1713 .any(|prefix| name.starts_with(prefix))
1714 }
1715
1716 fn is_blocked_complex_property(name: &str) -> bool {
1719 matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
1720 }
1721
1722 fn process_component_directive(
1723 &mut self,
1724 node: Node,
1725 source: &str,
1726 fragments: &mut Vec<WebUIFragment>,
1727 tag_name: &str,
1728 ) -> Result<()> {
1729 let tag_node = self.find_tag_node(node);
1730 let is_self_closing = tag_node
1731 .map(|n| n.kind() == "self_closing_tag")
1732 .unwrap_or(false);
1733
1734 self.add_raw_fragment(&format!("<{}", tag_name));
1736
1737 if let Some(tag_node) = tag_node {
1739 let binding_count = self.process_tag_attributes(tag_node, source, fragments, true)?;
1740 if let Some(ref mut p) = self.plugin {
1741 if let Some(data) = p.finish_element(binding_count) {
1742 self.add_fragment(WebUIFragment::plugin(data), fragments);
1743 }
1744 }
1745 }
1746
1747 if is_self_closing {
1748 let tag_text = tag_node
1749 .map(|n| &source[n.start_byte()..n.end_byte()])
1750 .unwrap_or("");
1751 if tag_text.ends_with("/>") {
1752 self.add_raw_fragment("/>");
1753 } else {
1754 self.add_raw_fragment(">");
1755 }
1756 } else {
1757 self.add_raw_fragment(">");
1758 }
1759
1760 self.flush_raw_buffer(fragments);
1762
1763 let (html_content, css_content, css_tokens) = {
1765 let component = self.component_registry.get(tag_name).ok_or_else(|| {
1766 ParserError::Directive(format!("Component not found: {}", tag_name))
1767 })?;
1768 (
1769 component.html_content.clone(),
1770 component.css_content.clone(),
1771 component.css_tokens.clone(),
1772 )
1773 };
1774
1775 self.token_store.extend(css_tokens);
1777
1778 if !self.fragment_records.contains_key(tag_name) {
1780 let component_data = self
1781 .component_registry
1782 .get(tag_name)
1783 .ok_or_else(|| {
1784 ParserError::Directive(format!("Component not found: {}", tag_name))
1785 })?
1786 .clone();
1787 let processed =
1788 self.build_component_template(tag_name, &html_content, css_content.as_deref())?;
1789
1790 if let Some(ref mut p) = self.plugin {
1792 p.register_component_template(tag_name, &component_data, &processed)?;
1793 }
1794
1795 self.parse(tag_name, &processed)?;
1796 }
1797
1798 fragments.push(WebUIFragment::component(tag_name.to_string()));
1800
1801 if !is_self_closing {
1803 self.process_content_children(node, source, fragments)?;
1804 }
1805
1806 if !is_self_closing {
1808 self.add_raw_fragment(&format!("</{}>", tag_name));
1809 }
1810
1811 Ok(())
1812 }
1813
1814 fn build_component_template(
1819 &mut self,
1820 tag_name: &str,
1821 html: &str,
1822 css_content: Option<&str>,
1823 ) -> Result<String> {
1824 let adopted_specifier = match self.options.css_strategy {
1825 CssStrategy::Module if css_content.is_some() => Some(tag_name.to_string()),
1826 _ => None,
1827 };
1828 let css_injection = match self.options.css_strategy {
1829 CssStrategy::Link => {
1830 if let (Some(css), DomStrategy::Shadow) = (css_content, self.options.dom_strategy) {
1833 let href = self.options.css_link_options.resolve(tag_name, css);
1834 let mut link = String::with_capacity(31 + href.href.len());
1835 link.push_str("<link rel=\"stylesheet\" href=\"");
1836 link.push_str(&href.href);
1837 link.push_str("\">");
1838 Some(link)
1839 } else {
1840 None
1841 }
1842 }
1843 CssStrategy::Style => css_content.map(|css| {
1844 let trimmed = css.trim();
1845 let mut style = String::with_capacity(15 + trimmed.len());
1846 style.push_str("<style>");
1847 style.push_str(trimmed);
1848 style.push_str("</style>");
1849 style
1850 }),
1851 CssStrategy::Module => None,
1852 };
1853
1854 self.process_component_template(
1855 html,
1856 css_injection.as_deref(),
1857 adopted_specifier.as_deref(),
1858 )
1859 }
1860
1861 fn process_component_template(
1890 &mut self,
1891 html: &str,
1892 css_snippet: Option<&str>,
1893 adopted_specifier: Option<&str>,
1894 ) -> Result<String> {
1895 let trimmed = html.trim();
1896 let snippet = css_snippet.unwrap_or_default();
1897
1898 let processed = if trimmed.starts_with("<template") {
1899 let stripped = self.strip_runtime_attrs_from_template(trimmed);
1900
1901 if let Some(tag) = adopted_specifier {
1905 let opening = match tag_scan::find_tag_close(&stripped) {
1906 Some(end) => &stripped[..=end],
1907 None => stripped.as_str(),
1908 };
1909 if !opening.contains("shadowrootadoptedstylesheets=") {
1910 return Err(ParserError::MissingAdoptedStylesheets {
1911 tag: tag.to_string(),
1912 });
1913 }
1914 }
1915
1916 if snippet.is_empty() {
1917 stripped
1918 } else {
1919 match tag_scan::find_tag_close(&stripped) {
1923 Some(open_end) => {
1924 let mut result = String::with_capacity(stripped.len() + snippet.len());
1925 result.push_str(&stripped[..=open_end]);
1926 result.push_str(snippet);
1927 result.push_str(&stripped[open_end + 1..]);
1928 result
1929 }
1930 None => stripped,
1933 }
1934 }
1935 } else {
1936 match self.options.dom_strategy {
1937 DomStrategy::Shadow => {
1938 let adopted = adopted_specifier.unwrap_or_default();
1939 let adopted_extra = if adopted.is_empty() {
1940 0
1941 } else {
1942 33 + adopted.len()
1943 };
1944 let mut result =
1945 String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
1946 result.push_str("<template shadowrootmode=\"open\"");
1947 if !adopted.is_empty() {
1948 result.push_str(" shadowrootadoptedstylesheets=\"");
1949 result.push_str(adopted);
1950 result.push('"');
1951 }
1952 result.push('>');
1953 result.push_str(snippet);
1954 result.push_str(trimmed);
1955 result.push_str("</template>");
1956 result
1957 }
1958 DomStrategy::Light => {
1959 if snippet.is_empty() {
1960 trimmed.to_string()
1961 } else {
1962 let mut result = String::with_capacity(snippet.len() + trimmed.len());
1963 result.push_str(snippet);
1964 result.push_str(trimmed);
1965 result
1966 }
1967 }
1968 }
1969 };
1970
1971 self.strip_template_comments(processed)
1972 }
1973
1974 fn strip_template_comments(&mut self, html: String) -> Result<String> {
1975 if !html.contains("<!--")
1976 && !html.contains("/*")
1977 && !Self::may_contain_style_line_comment(&html)
1978 {
1979 return Ok(html);
1980 }
1981
1982 let tree = self.parser.parse(&html, None).ok_or_else(|| {
1983 ParserError::Html("Failed to parse HTML for comment stripping".into())
1984 })?;
1985
1986 let mut ranges = Vec::new();
1987 let mut style_ranges = Vec::new();
1988 Self::collect_html_comment_and_style_ranges(
1989 tree.root_node(),
1990 &mut ranges,
1991 &mut style_ranges,
1992 );
1993
1994 for (style_start, style_end) in style_ranges {
1995 let css = &html[style_start..style_end];
1996 if !css.contains("/*") && !css.contains("//") {
1997 continue;
1998 }
1999 let (_tokens, _defs, comments) = self
2000 .css_parser
2001 .extract_tokens_definitions_and_comments(css, self.options.legal_comments)?;
2002 for comment in comments {
2003 let comment_text = &css[comment.start_byte..comment.end_byte];
2004 if comment.preserve || self.css_signal_comment_fragment(comment_text).is_some() {
2005 continue;
2006 }
2007 ranges.push((
2008 style_start + comment.start_byte,
2009 style_start + comment.end_byte,
2010 ));
2011 }
2012 }
2013
2014 if ranges.is_empty() {
2015 return Ok(html);
2016 }
2017
2018 Ok(comment_policy::strip_ranges(&html, ranges.as_mut_slice()).into_owned())
2019 }
2020
2021 fn may_contain_style_line_comment(html: &str) -> bool {
2022 if !html.contains("//") {
2023 return false;
2024 }
2025
2026 let bytes = html.as_bytes();
2027 let mut index = 0usize;
2028 while index + 6 <= bytes.len() {
2029 if bytes[index] == b'<'
2030 && bytes.get(index + 1) != Some(&b'/')
2031 && bytes[index + 1..]
2032 .get(..5)
2033 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(b"style"))
2034 {
2035 return true;
2036 }
2037 index += 1;
2038 }
2039 false
2040 }
2041
2042 #[allow(clippy::cast_possible_truncation)] fn collect_html_comment_and_style_ranges(
2047 root: Node<'_>,
2048 comment_ranges: &mut Vec<(usize, usize)>,
2049 style_ranges: &mut Vec<(usize, usize)>,
2050 ) {
2051 let mut stack = vec![root];
2052
2053 while let Some(node) = stack.pop() {
2054 match node.kind() {
2055 "comment" => comment_ranges.push((node.start_byte(), node.end_byte())),
2056 "style_element" => {
2057 let count = node.child_count();
2058 for i in 0..count {
2059 if let Some(child) = node.child(i as u32) {
2060 if child.kind() == "raw_text" {
2061 style_ranges.push((child.start_byte(), child.end_byte()));
2062 }
2063 }
2064 }
2065 }
2066 _ => {}
2067 }
2068
2069 let count = node.child_count();
2070 for i in (0..count).rev() {
2071 if let Some(child) = node.child(i as u32) {
2072 stack.push(child);
2073 }
2074 }
2075 }
2076 }
2077
2078 fn strip_runtime_attrs_from_template(&mut self, html: &str) -> String {
2086 if !tag_scan::opening_tag_has_runtime_prefix(html) {
2090 return html.to_string();
2091 }
2092
2093 let tree = match self.parser.parse(html, None) {
2094 Some(t) => t,
2095 None => return html.to_string(),
2096 };
2097
2098 let root = tree.root_node();
2100 let start_tag = Self::find_first_node(root, "start_tag");
2101 let Some(tag) = start_tag else {
2102 return html.to_string();
2103 };
2104
2105 let mut removals: Vec<(usize, usize)> = Vec::new();
2107 let mut cursor = tag.walk();
2108 for child in tag.named_children(&mut cursor) {
2109 if child.kind() == "attribute" {
2110 let name_node = child.child(0);
2111 if let Some(name) = name_node {
2112 let attr_name = &html[name.start_byte()..name.end_byte()];
2113 if attr_name.starts_with('@')
2114 || attr_name.starts_with(':')
2115 || attr_name.starts_with('?')
2116 {
2117 let mut start = child.start_byte();
2119 while start > 0 && html.as_bytes()[start - 1] == b' ' {
2120 start -= 1;
2121 }
2122 removals.push((start, child.end_byte()));
2123 }
2124 }
2125 }
2126 }
2127
2128 if removals.is_empty() {
2129 return html.to_string();
2130 }
2131
2132 let mut result = String::with_capacity(html.len());
2134 let mut pos = 0;
2135 for (start, end) in &removals {
2136 result.push_str(&html[pos..*start]);
2137 pos = *end;
2138 }
2139 result.push_str(&html[pos..]);
2140 result
2141 }
2142
2143 fn find_first_node<'a>(root: Node<'a>, kind: &str) -> Option<Node<'a>> {
2145 let mut stack = vec![root];
2146 while let Some(node) = stack.pop() {
2147 if node.kind() == kind {
2148 return Some(node);
2149 }
2150 let mut cursor = node.walk();
2151 #[allow(clippy::needless_collect)]
2154 let children: Vec<_> = node.children(&mut cursor).collect();
2155 for child in children.into_iter().rev() {
2156 stack.push(child);
2157 }
2158 }
2159 None
2160 }
2161}
2162
2163#[cfg(test)]
2164mod tests {
2165 use webui_test_utils::*;
2166
2167 use super::*;
2168
2169 #[test]
2170 fn test_plugin_display_names() {
2171 assert_eq!(Plugin::Fast.to_string(), "fast");
2172 assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
2173 assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
2174 assert_eq!(Plugin::WebUI.to_string(), "webui");
2175 }
2176
2177 #[test]
2178 fn test_plugin_from_str_names() {
2179 assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
2180 assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
2181 assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
2182 assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
2183 }
2184
2185 #[test]
2186 fn test_plugin_from_str_unknown_mentions_supported_names() {
2187 let err = "legacy".parse::<Plugin>().unwrap_err();
2188 assert!(
2189 err.contains("fast-v3"),
2190 "error should mention fast-v3: {err}"
2191 );
2192 assert!(
2193 err.contains("fast-v2"),
2194 "error should mention fast-v2: {err}"
2195 );
2196 assert!(err.contains("fast"), "error should mention fast: {err}");
2197 }
2198
2199 #[test]
2200 fn test_parse_signal() {
2201 let mut parser = HtmlParser::new();
2202 let html = "Hello, {{name}}!";
2203 let result = parser.parse("test.html", html);
2204
2205 assert!(result.is_ok());
2206 let fragment_records = parser.into_fragment_records();
2207
2208 assert_stream!(
2209 fragment_records,
2210 "test.html",
2211 [raw("Hello, "), signal("name"), raw("!"),]
2212 );
2213 }
2214
2215 #[test]
2216 fn test_parse_raw_signal() {
2217 let mut parser = HtmlParser::new();
2218 let html = "Hello, {{{html_content}}}!";
2219 let result = parser.parse("test.html", html);
2220
2221 assert!(result.is_ok());
2222 let fragment_records = parser.into_fragment_records();
2223
2224 assert_stream!(
2225 fragment_records,
2226 "test.html",
2227 [raw("Hello, "), signal_raw("html_content"), raw("!"),]
2228 );
2229 }
2230
2231 #[test]
2232 fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
2233 let mut parser = HtmlParser::new();
2234 let html = "<nav>{{sectionName}} > {{topicName}}</nav>";
2235 let result = parser.parse("test.html", html);
2236
2237 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2238 let fragment_records = parser.into_fragment_records();
2239
2240 assert_stream!(
2241 fragment_records,
2242 "test.html",
2243 [
2244 raw("<nav>"),
2245 signal("sectionName"),
2246 raw(" > "),
2247 signal("topicName"),
2248 raw("</nav>"),
2249 ]
2250 );
2251 }
2252
2253 #[test]
2254 fn test_parse_for_directive() {
2255 let mut parser = HtmlParser::new();
2256 let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
2257
2258 let result = parser.parse("test.html", html);
2259 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2260 let fragment_records = parser.into_fragment_records();
2261 println!("Fragment records: {:#?}", fragment_records);
2262
2263 assert_stream!(
2264 fragment_records,
2265 "test.html",
2266 [for_loop("item", "items", "for-1"),]
2267 );
2268
2269 assert_stream!(
2271 fragment_records,
2272 "for-1",
2273 [
2274 raw("<div class=\"item\">"),
2275 signal("item.name"),
2276 raw("</div>"),
2277 ]
2278 );
2279 }
2280
2281 #[test]
2282 fn test_parse_if_directive() {
2283 let mut parser = HtmlParser::new();
2284 let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
2285
2286 let result = parser.parse("test.html", html);
2287
2288 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2289 let fragment_records = parser.into_fragment_records();
2290 println!("Fragment records: {:#?}", fragment_records);
2291
2292 assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
2293
2294 assert_stream!(
2296 fragment_records,
2297 "if-1",
2298 [
2299 raw("<div>Welcome back, "),
2300 signal("username"),
2301 raw("!</div>"),
2302 ]
2303 );
2304 }
2305
2306 #[test]
2307 fn test_component_directive() {
2308 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2309 parser
2310 .component_registry
2311 .register_component(
2312 "my-component",
2313 "<div>My Component</div>",
2314 Some("div { color: blue; }"),
2315 )
2316 .expect("Failed to register component");
2317
2318 let result = parser.parse("test.html", "<my-component></my-component>");
2319 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2320 let records = parser.into_fragment_records();
2321
2322 assert_stream!(
2323 records,
2324 "test.html",
2325 [
2326 raw("<my-component>"),
2327 component("my-component"),
2328 raw("</my-component>"),
2329 ]
2330 );
2331
2332 let comp = &records["my-component"].fragments;
2334 assert_eq!(comp.len(), 1);
2335 assert!(
2336 matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
2337 !raw.value.contains("<template shadowrootmode") && raw.value.contains("<div>My Component</div>"))
2338 );
2339 }
2340
2341 #[test]
2342 fn test_component_directive_with_slots() {
2343 let mut parser = HtmlParser::new();
2344 parser
2345 .component_registry
2346 .register_component(
2347 "my-component",
2348 "<div>My Component</div>",
2349 Some("div { color: blue; }"),
2350 )
2351 .expect("Failed to register component");
2352
2353 let result = parser.parse(
2354 "test.html",
2355 "Hello<my-component><p>World</p></my-component>",
2356 );
2357 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2358 let records = parser.into_fragment_records();
2359 let fragments = &records["test.html"].fragments;
2360
2361 assert!(fragments.len() >= 3);
2363 assert!(
2365 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component>"))
2366 );
2367 assert!(fragments.iter().any(|f| matches!(
2369 f.fragment.as_ref(),
2370 Some(Fragment::Component(c)) if c.fragment_id == "my-component"
2371 )));
2372 let last = fragments.last().unwrap();
2374 assert!(
2375 matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
2376 );
2377 }
2378
2379 #[test]
2380 fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
2381 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
2386
2387 parser
2388 .component_registry
2389 .register_component(
2390 "app-shell",
2391 r#"<template shadowrootmode="open">
2392 <my-navbar></my-navbar>
2393 <main><outlet /></main>
2394 <cart-panel></cart-panel>
2395 <my-footer></my-footer>
2396 </template>"#,
2397 Some(":host{display:flex}"),
2398 )
2399 .expect("register app-shell");
2400 parser
2401 .component_registry
2402 .register_component("my-navbar", "<nav>Nav</nav>", Some("nav{color:red}"))
2403 .expect("register my-navbar");
2404 parser
2405 .component_registry
2406 .register_component(
2407 "cart-panel",
2408 "<aside>Cart</aside>",
2409 Some("aside{color:green}"),
2410 )
2411 .expect("register cart-panel");
2412 parser
2413 .component_registry
2414 .register_component(
2415 "my-footer",
2416 "<footer>Footer</footer>",
2417 Some("footer{color:blue}"),
2418 )
2419 .expect("register my-footer");
2420
2421 parser
2422 .parse("index.html", "<app-shell></app-shell>")
2423 .expect("parse index.html");
2424 let records = parser.into_fragment_records();
2425
2426 let app_frags = &records["app-shell"].fragments;
2428 let component_ids: Vec<&str> = app_frags
2429 .iter()
2430 .filter_map(|f| match f.fragment.as_ref() {
2431 Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
2432 _ => None,
2433 })
2434 .collect();
2435
2436 assert!(
2437 component_ids.contains(&"my-navbar"),
2438 "app-shell fragments should contain my-navbar: {component_ids:?}"
2439 );
2440 assert!(
2441 component_ids.contains(&"cart-panel"),
2442 "app-shell fragments should contain cart-panel: {component_ids:?}"
2443 );
2444 assert!(
2445 component_ids.contains(&"my-footer"),
2446 "app-shell fragments should contain my-footer: {component_ids:?}"
2447 );
2448 }
2449
2450 #[test]
2453 fn test_component_no_double_wrap_template() {
2454 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2458 parser
2459 .component_registry
2460 .register_component(
2461 "custom-element",
2462 r#"<template foo="bar"><slot></slot></template>"#,
2463 None,
2464 )
2465 .expect("register");
2466 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2467 assert!(result.is_ok());
2468 let records = parser.into_fragment_records();
2469
2470 assert_fragments!(
2471 records["index.html"].fragments,
2472 [
2473 raw("<custom-element>"),
2474 component("custom-element"),
2475 raw("Hello</custom-element>"),
2476 ]
2477 );
2478
2479 assert_stream!(
2480 records,
2481 "custom-element",
2482 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
2483 );
2484 }
2485
2486 #[test]
2487 fn test_component_styled_no_double_wrap() {
2488 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2493 parser
2494 .component_registry
2495 .register_component(
2496 "custom-element",
2497 r#"<template foo="bar"><slot></slot></template>"#,
2498 Some("div { color: red; }"),
2499 )
2500 .expect("register");
2501 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2502 assert!(result.is_ok());
2503 let records = parser.into_fragment_records();
2504
2505 assert_stream!(
2506 records,
2507 "custom-element",
2508 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
2509 );
2510 }
2511
2512 #[test]
2513 fn test_component_strip_runtime_attrs() {
2514 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2519 parser
2520 .component_registry
2521 .register_component(
2522 "custom-element",
2523 r#"<template @click={foo} :bar="baz" ?bool="true"><slot></slot></template>"#,
2524 None,
2525 )
2526 .expect("register");
2527 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2528 assert!(result.is_ok());
2529 let records = parser.into_fragment_records();
2530
2531 assert_stream!(
2532 records,
2533 "custom-element",
2534 [raw("<template><slot></slot></template>"),]
2535 );
2536 }
2537
2538 #[test]
2539 fn test_component_with_slots_and_attrs() {
2540 let mut parser = HtmlParser::new();
2541 parser
2542 .component_registry
2543 .register_component("custom-element", "<slot></slot>", None)
2544 .expect("register");
2545 let result = parser.parse(
2546 "index.html",
2547 r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
2548 );
2549 assert!(result.is_ok());
2550 let records = parser.into_fragment_records();
2551 assert_fragments!(
2552 records["index.html"].fragments,
2553 [
2554 raw("<custom-element"),
2555 attr_raw_start("appearance", "subtle"),
2556 raw(">"),
2557 component("custom-element"),
2558 raw("Hello World</custom-element>"),
2559 ]
2560 );
2561 }
2562
2563 #[test]
2564 fn test_component_legacy_no_styles() {
2565 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2566 parser
2567 .component_registry
2568 .register_component("custom-element", "<div>Custom Element</div>", None)
2569 .expect("register");
2570 let result = parser.parse("index.html", "<custom-element></custom-element>");
2571 assert!(result.is_ok());
2572 let records = parser.into_fragment_records();
2573
2574 assert_fragments!(
2575 records["index.html"].fragments,
2576 [
2577 raw("<custom-element>"),
2578 component("custom-element"),
2579 raw("</custom-element>"),
2580 ]
2581 );
2582
2583 assert_stream!(
2584 records,
2585 "custom-element",
2586 [raw("<div>Custom Element</div>"),]
2587 );
2588 }
2589
2590 #[test]
2591 fn test_component_self_closing() {
2592 let mut parser = HtmlParser::new();
2593 parser
2594 .component_registry
2595 .register_component("custom-widget", "<div>Widget Content</div>", None)
2596 .expect("register");
2597 let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
2598 assert!(result.is_ok());
2599 let records = parser.into_fragment_records();
2600
2601 assert_fragments!(
2602 records["index.html"].fragments,
2603 [
2604 raw("<custom-widget"),
2605 attr_start("config", "settings"),
2606 raw("/>"),
2607 component("custom-widget"),
2608 ]
2609 );
2610 }
2611
2612 #[test]
2613 fn test_component_nested_self_closing_in_slot() {
2614 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2615 parser
2616 .component_registry
2617 .register_component("custom-icon", "<svg><slot></slot></svg>", None)
2618 .expect("register");
2619 let result = parser.parse(
2620 "index.html",
2621 r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
2622 );
2623 assert!(result.is_ok());
2624 let records = parser.into_fragment_records();
2625
2626 assert_fragments!(
2627 records["index.html"].fragments,
2628 [
2629 raw("<custom-icon>"),
2630 component("custom-icon"),
2631 raw("<use"),
2632 attr_template("href", "attr-1"),
2633 raw("/></custom-icon>"),
2634 ]
2635 );
2636
2637 assert_stream!(records, "custom-icon", [raw("<svg><slot></slot></svg>"),]);
2638 }
2639
2640 #[test]
2641 fn test_component_leading_boolean_attr_start() {
2642 let mut parser = HtmlParser::new();
2643 parser
2644 .component_registry
2645 .register_component("custom-element", "<slot></slot>", None)
2646 .expect("register");
2647 let result = parser.parse(
2648 "index.html",
2649 r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
2650 );
2651 assert!(result.is_ok());
2652 let records = parser.into_fragment_records();
2653
2654 assert_fragments!(
2655 records["index.html"].fragments,
2656 [
2657 raw("<custom-element"),
2658 bool_attr_start("disabled", "isDisabled"),
2660 attr_raw("title", "Hello"),
2662 raw(">"),
2663 component("custom-element"),
2664 raw("</custom-element>"),
2665 ]
2666 );
2667 }
2668
2669 #[test]
2670 fn test_component_boolean_predicate_preserves_condition_tree() {
2671 let mut parser = HtmlParser::new();
2672 parser
2673 .component_registry
2674 .register_component("custom-element", "<slot></slot>", None)
2675 .expect("register");
2676 let result = parser.parse(
2677 "index.html",
2678 r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
2679 );
2680 assert!(result.is_ok());
2681
2682 let records = parser.into_fragment_records();
2683 let fragments = &records["index.html"].fragments;
2684 match fragments
2685 .get(1)
2686 .and_then(|fragment| fragment.fragment.as_ref())
2687 {
2688 Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
2689 assert_eq!(attr.name, "disabled");
2690 assert!(attr.attr_start);
2691 match attr
2692 .condition_tree
2693 .as_ref()
2694 .and_then(|condition| condition.expr.as_ref())
2695 {
2696 Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
2697 assert_eq!(pred.left, "page");
2698 assert_eq!(pred.operator, 3);
2699 assert_eq!(pred.right, "'dashboard'");
2700 }
2701 other => panic!("expected predicate condition tree, got {:?}", other),
2702 }
2703 }
2704 other => panic!("expected attribute fragment, got {:?}", other),
2705 }
2706 }
2707
2708 #[test]
2709 fn test_component_meta_link_tags() {
2710 let (fragments, _) = parse_and_get_fragments(
2711 r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
2712 );
2713 assert!(fragments.len() >= 5);
2714 assert!(
2715 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<head><meta charset=\"utf-8\"") && raw.value.contains("<link"))
2716 );
2717 assert!(
2718 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
2719 );
2720 assert!(
2721 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
2722 );
2723 assert!(
2724 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if s.value == "head_end" && s.raw)
2725 );
2726 assert!(
2727 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
2728 );
2729 }
2730
2731 #[test]
2732 fn test_nested_directives() {
2733 let mut parser = HtmlParser::new();
2734 let html = r#"<for each="category in categories">
2735 <if condition="category.hasItems">
2736 <for each="item in category.items">
2737 {{item.title}}
2738 </for>
2739 </if>
2740 </for>"#;
2741
2742 let result = parser.parse("test.html", html);
2743
2744 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2745 let fragment_records = parser.into_fragment_records();
2746
2747 assert_stream!(
2748 fragment_records,
2749 "test.html",
2750 [for_loop("category", "categories", "for-1"),]
2751 );
2752
2753 assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
2754
2755 assert_stream!(
2756 fragment_records,
2757 "if-1",
2758 [for_loop("item", "category.items", "for-2"),]
2759 );
2760
2761 assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
2762 }
2763
2764 #[test]
2765 fn test_complex_directives() {
2766 let mut parser = HtmlParser::new();
2767 let html = r#"<for each="category in categories">
2768 <div class="category">
2769 <h2>{{category.name}}</h2>
2770 <if condition="category.hasItems">
2771 <ul>
2772 <for each="item in category.items">
2773 <li>{{item.title}}</li>
2774 </for>
2775 </ul>
2776 </if>
2777 </div>
2778 </for>"#;
2779
2780 let result = parser.parse("test.html", html);
2781
2782 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2783 let fragment_records = parser.into_fragment_records();
2784
2785 assert_stream!(
2786 fragment_records,
2787 "test.html",
2788 [for_loop("category", "categories", "for-1"),]
2789 );
2790
2791 assert_stream!(
2793 fragment_records,
2794 "for-1",
2795 [
2796 raw("<div class=\"category\"><h2>"),
2797 signal("category.name"),
2798 raw("</h2>"),
2799 if_cond("if-1"),
2800 raw("</div>"),
2801 ]
2802 );
2803
2804 assert_stream!(
2806 fragment_records,
2807 "if-1",
2808 [
2809 raw("<ul>"),
2810 for_loop("item", "category.items", "for-2"),
2811 raw("</ul>"),
2812 ]
2813 );
2814
2815 assert_stream!(
2817 fragment_records,
2818 "for-2",
2819 [raw("<li>"), signal("item.title"), raw("</li>"),]
2820 );
2821 }
2822
2823 fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
2827 let mut parser = HtmlParser::new();
2828 let result = parser.parse("index.html", html);
2829 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2830 let records = parser.into_fragment_records();
2831 let fragments = records
2832 .get("index.html")
2833 .expect("Failed to get index.html fragment")
2834 .fragments
2835 .clone();
2836 (fragments, records)
2837 }
2838
2839 fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
2841 let mut parser = HtmlParser::new();
2842 parser
2843 .component_registry
2844 .register_component(tag, "<div></div>", None)
2845 .expect("register");
2846 let result = parser.parse("index.html", html);
2847 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2848 let records = parser.into_fragment_records();
2849 let fragments = records
2850 .get("index.html")
2851 .expect("Failed to get index.html fragment")
2852 .fragments
2853 .clone();
2854 (fragments, records)
2855 }
2856
2857 #[test]
2858 fn test_attribute_handlebars_in_href() {
2859 let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
2861 assert_fragments!(
2862 fragments,
2863 [
2864 raw("<a"),
2865 attr("href", "url"),
2866 raw(">"),
2867 signal("name"),
2868 raw("</a>"),
2869 ]
2870 );
2871 }
2872
2873 #[test]
2874 fn test_attribute_boolean_with_handlebars() {
2875 let (fragments, _) =
2877 parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
2878 assert_fragments!(
2879 fragments,
2880 [
2881 raw("<button"),
2882 bool_attr("disabled", "isDisabled"),
2883 raw(">Click</button>"),
2884 ]
2885 );
2886 }
2887
2888 #[test]
2889 fn test_attribute_multiple_boolean() {
2890 let (fragments, _) =
2893 parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
2894
2895 assert_fragments!(
2896 fragments,
2897 [
2898 raw("<input"),
2899 bool_attr("checked", "isChecked"),
2900 bool_attr("disabled", "isDisabled"),
2901 raw("/>"),
2902 ]
2903 );
2904 }
2905
2906 #[test]
2907 fn test_attribute_boolean_and_regular_together() {
2908 let (fragments, _) = parse_and_get_fragments(
2911 r#"<input ?checked="{{isChecked}}" type="checkbox">Hi</input>"#,
2912 );
2913
2914 assert_fragments!(
2915 fragments,
2916 [
2917 raw("<input"),
2918 bool_attr("checked", "isChecked"),
2919 raw(" type=\"checkbox\">Hi</input>"),
2920 ]
2921 );
2922 }
2923
2924 #[test]
2925 fn test_attribute_boolean_sandwiched() {
2926 let (fragments, _) = parse_and_get_fragments(
2929 r#"<input version={{edition}} ?checked="{{isChecked}}" type="checkbox">Hi</input>"#,
2930 );
2931
2932 assert_fragments!(
2933 fragments,
2934 [
2935 raw("<input"),
2936 attr("version", "edition"),
2937 bool_attr("checked", "isChecked"),
2938 raw(" type=\"checkbox\">Hi</input>"),
2939 ]
2940 );
2941 }
2942
2943 #[test]
2944 fn test_attribute_boolean_ending() {
2945 let (fragments, _) = parse_and_get_fragments(
2948 r#"<input version={{edition}} ?checked="{{isChecked}}">Hi</input>"#,
2949 );
2950
2951 assert_fragments!(
2952 fragments,
2953 [
2954 raw("<input"),
2955 attr("version", "edition"),
2956 bool_attr("checked", "isChecked"),
2957 raw(">Hi</input>"),
2958 ]
2959 );
2960 }
2961
2962 #[test]
2963 fn test_attribute_boolean_dotted_path() {
2964 let (fragments, _) =
2967 parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
2968
2969 assert_fragments!(
2970 fragments,
2971 [
2972 raw("<div"),
2973 bool_attr("checked", "layout.isPinned"),
2974 raw(">Content</div>"),
2975 ]
2976 );
2977 }
2978
2979 #[test]
2980 fn test_attribute_colon_prefixed_complex() {
2981 let (fragments, _) = parse_with_component(
2984 "my-component",
2985 r#"<my-component :config="{{settings}}"></my-component>"#,
2986 );
2987
2988 assert_fragments!(
2989 fragments,
2990 [
2991 raw("<my-component"),
2992 attr_complex_start(":config", "settings"),
2993 raw(">"),
2994 component("my-component"),
2995 raw("</my-component>"),
2996 ]
2997 );
2998 }
2999
3000 #[test]
3001 fn test_attribute_multiple_colon_prefixed() {
3002 let (fragments, _) = parse_with_component(
3005 "my-component",
3006 r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
3007 );
3008
3009 assert_fragments!(
3010 fragments,
3011 [
3012 raw("<my-component"),
3013 attr_complex_start(":prop1", "val1"),
3014 attr_complex(":prop2", "val2"),
3015 raw(">"),
3016 component("my-component"),
3017 raw("</my-component>"),
3018 ]
3019 );
3020 }
3021
3022 #[test]
3023 fn test_blocked_complex_property_innerhtml() {
3024 let mut parser = HtmlParser::new();
3025 let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
3026 assert!(
3027 result.is_err(),
3028 "Expected error for :innerHTML on native element"
3029 );
3030 let err = result.unwrap_err().to_string();
3031 assert!(
3032 err.contains("only allowed on custom elements"),
3033 "Error: {err}"
3034 );
3035 }
3036
3037 #[test]
3038 fn test_blocked_complex_on_native_element() {
3039 let mut parser = HtmlParser::new();
3040 let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
3041 assert!(
3042 result.is_err(),
3043 "Expected error for :data on native element"
3044 );
3045 let err = result.unwrap_err().to_string();
3046 assert!(
3047 err.contains("only allowed on custom elements"),
3048 "Error: {err}"
3049 );
3050 }
3051
3052 #[test]
3053 fn test_blocked_complex_property_on_component() {
3054 let mut parser = HtmlParser::new();
3055 parser
3056 .component_registry
3057 .register_component("my-widget", "<div></div>", None)
3058 .expect("register");
3059 let result = parser.parse(
3060 "index.html",
3061 r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
3062 );
3063 assert!(
3064 result.is_err(),
3065 "Expected error for :innerHTML on component"
3066 );
3067 let err = result.unwrap_err().to_string();
3068 assert!(err.contains("HTML injection"), "Error: {err}");
3069 }
3070
3071 #[test]
3072 fn test_allowed_complex_property() {
3073 let (fragments, _) = parse_with_component(
3075 "my-component",
3076 r#"<my-component :config="{{settings}}"></my-component>"#,
3077 );
3078 assert_fragments!(
3079 fragments,
3080 [
3081 raw("<my-component"),
3082 attr_complex_start(":config", "settings"),
3083 raw(">"),
3084 component("my-component"),
3085 raw("</my-component>"),
3086 ]
3087 );
3088 }
3089
3090 #[test]
3091 fn test_attribute_mixed_normal_boolean_colon() {
3092 let (fragments, _) = parse_with_component(
3095 "my-component",
3096 r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
3097 );
3098
3099 assert_fragments!(
3100 fragments,
3101 [
3102 raw("<my-component"),
3103 attr_raw_start("id", "comp"),
3104 attr_complex(":config", "settings"),
3105 bool_attr("enabled", "isEnabled"),
3106 raw(">"),
3107 component("my-component"),
3108 raw("</my-component>"),
3109 ]
3110 );
3111 }
3112
3113 #[test]
3114 fn test_attribute_reject_boolean_without_handlebars() {
3115 let (fragments, _) = parse_and_get_fragments(r#"<input ?checked="name"></input>"#);
3118
3119 assert_fragments!(fragments, [raw("<input></input>"),]);
3121 }
3122
3123 #[test]
3124 fn test_attribute_reject_boolean_with_partial_handlebars() {
3125 let (fragments, _) =
3128 parse_and_get_fragments(r#"<input ?checked="Hello {{name}}"></input>"#);
3129
3130 assert_fragments!(fragments, [raw("<input></input>"),]);
3132 }
3133
3134 #[test]
3135 fn test_attribute_reject_boolean_with_plain_value() {
3136 let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
3139
3140 assert_fragments!(fragments, [raw("<button>Click</button>"),]);
3142 }
3143
3144 #[test]
3145 fn test_attribute_boolean_predicate_equal() {
3146 let (fragments, _) =
3148 parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
3149 assert_fragments!(
3150 fragments,
3151 [
3152 raw("<div"),
3153 bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
3154 raw(">X</div>"),
3155 ]
3156 );
3157 }
3158
3159 #[test]
3160 fn test_attribute_boolean_predicate_greater_than() {
3161 let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
3163 assert_fragments!(
3164 fragments,
3165 [
3166 raw("<span"),
3167 bool_attr_predicate("hidden", "num", 1, "9"),
3168 raw(">X</span>"),
3169 ]
3170 );
3171 }
3172
3173 #[test]
3174 fn test_attribute_boolean_predicate_not_equal() {
3175 let (fragments, _) =
3177 parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
3178 assert_fragments!(
3179 fragments,
3180 [
3181 raw("<a"),
3182 bool_attr_predicate("data-active", "status", 4, "'inactive'"),
3183 raw(">X</a>"),
3184 ]
3185 );
3186 }
3187
3188 #[test]
3189 fn test_attribute_boolean_negation() {
3190 let (fragments, _) =
3192 parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
3193 assert_fragments!(
3194 fragments,
3195 [
3196 raw("<button"),
3197 bool_attr_not("disabled", "isReady"),
3198 raw(">X</button>"),
3199 ]
3200 );
3201 }
3202
3203 #[test]
3204 fn test_attribute_mixed_static_dynamic() {
3205 let (fragments, records) =
3208 parse_and_get_fragments(r#"<input value="hello {{world}}">Hi</input>"#);
3209
3210 assert_fragments!(
3211 fragments,
3212 [
3213 raw("<input"),
3214 attr_template("value", "attr-1"),
3215 raw(">Hi</input>"),
3216 ]
3217 );
3218
3219 assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
3221 }
3222
3223 #[test]
3226 fn test_body_signals() {
3227 let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
3228 assert_fragments!(
3229 fragments,
3230 [
3231 raw("<body>"),
3232 signal_raw("body_start"),
3233 raw("<app-shell></app-shell>"),
3234 signal_raw("body_end"),
3235 raw("</body>"),
3236 ]
3237 );
3238 }
3239
3240 #[test]
3243 fn test_empty_for_produces_nothing() {
3244 let (fragments, records) =
3245 parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
3246 assert_fragments!(fragments, [raw("<div></div>"),]);
3247 assert!(!records.contains_key("for-1"));
3248 }
3249
3250 #[test]
3253 fn test_self_closing_svg_path() {
3254 let (fragments, _) =
3255 parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
3256 assert_fragments!(
3257 fragments,
3258 [raw(
3259 r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
3260 ),]
3261 );
3262 }
3263
3264 #[test]
3265 fn test_html5_void_elements() {
3266 let (fragments, _) = parse_and_get_fragments(
3267 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
3268 );
3269 assert_fragments!(
3270 fragments,
3271 [raw(
3272 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
3273 ),]
3274 );
3275 }
3276
3277 #[test]
3278 fn test_self_closing_with_dynamic_attributes() {
3279 let (fragments, _) =
3280 parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
3281 assert_fragments!(
3282 fragments,
3283 [
3284 raw("<img"),
3285 attr("src", "imageUrl"),
3286 attr("alt", "imageAlt"),
3287 raw("/>"),
3288 ]
3289 );
3290 }
3291
3292 #[test]
3293 fn test_self_closing_with_boolean_attributes() {
3294 let (fragments, _) = parse_and_get_fragments(
3295 r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
3296 );
3297 assert_fragments!(
3298 fragments,
3299 [
3300 raw("<input type=\"checkbox\""),
3301 bool_attr("checked", "isSelected"),
3302 bool_attr("disabled", "isDisabled"),
3303 raw("/>"),
3304 ]
3305 );
3306 }
3307
3308 #[test]
3309 fn test_multiple_self_closing_in_sequence() {
3310 let (fragments, _) =
3311 parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
3312 assert_fragments!(
3313 fragments,
3314 [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
3315 );
3316 }
3317
3318 #[test]
3319 fn test_self_closing_with_mixed_content() {
3320 let (fragments, _) =
3321 parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
3322 assert_fragments!(
3323 fragments,
3324 [
3325 raw("<div>Text before<img"),
3326 attr("src", "url"),
3327 raw("/>Text after</div>"),
3328 ]
3329 );
3330 }
3331
3332 #[test]
3333 fn test_self_closing_svg_elements() {
3334 let (fragments, _) = parse_and_get_fragments(
3335 r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
3336 );
3337 assert_fragments!(
3338 fragments,
3339 [
3340 raw("<svg><circle"),
3341 attr("cx", "x"),
3342 attr("cy", "y"),
3343 raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
3344 ]
3345 );
3346 }
3347
3348 #[test]
3349 fn test_self_closing_inside_for_loop() {
3350 let (fragments, records) = parse_and_get_fragments(
3351 r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
3352 );
3353 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
3354 assert_stream!(
3355 records,
3356 "for-1",
3357 [raw("<img"), attr("src", "item.url"), raw("/>"),]
3358 );
3359 }
3360
3361 #[test]
3362 fn test_self_closing_whitespace_variations() {
3363 let (fragments, _) =
3364 parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
3365 assert_fragments!(
3366 fragments,
3367 [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
3368 );
3369 }
3370
3371 #[test]
3372 fn test_deeply_nested_self_closing() {
3373 let (fragments, _) = parse_and_get_fragments(
3374 r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
3375 );
3376 assert_fragments!(
3377 fragments,
3378 [raw(
3379 r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
3380 ),]
3381 );
3382 }
3383
3384 #[test]
3385 fn test_self_closing_vs_empty_regular_tags() {
3386 let (fragments, _) =
3387 parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
3388 assert_fragments!(
3389 fragments,
3390 [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
3391 );
3392 }
3393
3394 #[test]
3397 fn test_for_custom_template_attribute() {
3398 let (fragments, records) = parse_and_get_fragments(
3400 r#"<for each="item in items" template="static"><span>Item</span></for>"#,
3401 );
3402 assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
3403 assert_stream!(records, "static", [raw("<span>Item</span>"),]);
3404 }
3405
3406 #[test]
3407 fn test_for_recursive_template() {
3408 let mut parser = HtmlParser::new();
3410 let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
3411 let result = parser.parse("index.html", html);
3412 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3413 let records = parser.into_fragment_records();
3414
3415 assert_fragments!(
3416 records["index.html"].fragments,
3417 [for_loop("outerItem", "outerItems", "static"),]
3418 );
3419
3420 assert_stream!(
3421 records,
3422 "static",
3423 [
3424 raw("<div><span>"),
3425 signal("outerItem.name"),
3426 raw("</span>"),
3427 for_loop("innerItem", "innerItems", "static"),
3428 raw("</div>"),
3429 ]
3430 );
3431 }
3432
3433 #[test]
3436 fn test_if_multiple_children() {
3437 let (fragments, records) =
3439 parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
3440 assert_fragments!(fragments, [if_cond("if-1"),]);
3441 assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
3442 }
3443
3444 #[test]
3445 fn test_for_multiple_children() {
3446 let (fragments, records) =
3448 parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
3449 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
3450 assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
3451 }
3452
3453 #[test]
3456 fn test_handlebars_at_beginning() {
3457 let (fragments, _) = parse_and_get_fragments("{{first}}");
3459 assert_fragments!(fragments, [signal("first"),]);
3460 }
3461
3462 #[test]
3463 fn test_handlebars_at_beginning_and_raw() {
3464 let (fragments, _) = parse_and_get_fragments("{{first}}test");
3466 assert_fragments!(fragments, [signal("first"), raw("test"),]);
3467 }
3468
3469 #[test]
3470 fn test_handlebars_raw_and_end() {
3471 let (fragments, _) = parse_and_get_fragments("test{{first}}");
3473 assert_fragments!(fragments, [raw("test"), signal("first"),]);
3474 }
3475
3476 #[test]
3479 fn test_handlebars_invalid_triple_open() {
3480 let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
3482 assert_fragments!(fragments, [raw("{{{invalid}}"),]);
3483 }
3484
3485 #[test]
3486 fn test_handlebars_four_open_braces() {
3487 let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
3489 assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
3490 }
3491
3492 #[test]
3493 fn test_handlebars_five_open_with_valid_double() {
3494 let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
3496 assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
3497 }
3498
3499 #[test]
3500 fn test_entities_preserved() {
3501 let (fragments, _) = parse_and_get_fragments("<p>Hello}World</p>");
3503 assert_fragments!(fragments, [raw("<p>Hello}World</p>"),]);
3504 }
3505
3506 #[test]
3509 fn test_doctype_preserved() {
3510 let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
3512 assert!(!fragments.is_empty());
3513 assert!(
3514 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
3515 );
3516 }
3517
3518 #[test]
3521 fn test_component_attr_skip() {
3522 let mut parser = HtmlParser::new();
3524 parser
3525 .component_registry
3526 .register_component("custom-element", "<slot></slot>", None)
3527 .expect("register");
3528 let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
3529 let result = parser.parse("index.html", html);
3530 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3531 let records = parser.into_fragment_records();
3532
3533 assert_fragments!(
3536 records["index.html"].fragments,
3537 [
3538 raw("<custom-element"),
3539 attr_complex_start(":config", "config"),
3541 attr_skip("class", "value0"),
3543 attr_skip("style", "value1"),
3544 attr_skip("role", "value2"),
3545 attr_skip("data-test", "value3"),
3546 attr_skip("aria-test", "value4"),
3547 raw(">"),
3548 component("custom-element"),
3549 raw("</custom-element>"),
3550 ]
3551 );
3552 }
3553
3554 #[test]
3555 fn test_component_attr_skip_static_and_embedded() {
3556 let mut parser = HtmlParser::new();
3559 parser
3560 .component_registry
3561 .register_component("item-group", "<slot></slot>", None)
3562 .expect("register");
3563
3564 let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
3565 let result = parser.parse("index.html", html);
3566 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3567 let records = parser.into_fragment_records();
3568
3569 assert_fragments!(
3570 records["index.html"].fragments,
3571 [
3572 raw("<item-group"),
3573 attr_skip_raw("role", "list"),
3574 attr_skip_template("aria-labelledby", "attr-1"),
3575 attr_skip_template("data-testid", "attr-2"),
3576 attr_skip_raw("class", "fixed-class"),
3577 raw(">"),
3578 component("item-group"),
3579 raw("</item-group>"),
3580 ]
3581 );
3582
3583 assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
3586 assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
3587 }
3588
3589 #[test]
3590 fn test_component_multiple_nested() {
3591 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3593 parser
3594 .component_registry
3595 .register_component(
3596 "custom-element",
3597 "<custom-child></custom-child><slot></slot>",
3598 None,
3599 )
3600 .expect("register");
3601 parser
3602 .component_registry
3603 .register_component("custom-button", "<slot></slot>", None)
3604 .expect("register");
3605 parser
3606 .component_registry
3607 .register_component("custom-child", "<h1>Hello World!</h1>", None)
3608 .expect("register");
3609
3610 let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
3611 let result = parser.parse("index.html", html);
3612 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3613 let records = parser.into_fragment_records();
3614
3615 assert_fragments!(
3617 records["index.html"].fragments,
3618 [for_loop("item", "items", "for-1"),]
3619 );
3620
3621 assert_stream!(
3623 records,
3624 "for-1",
3625 [
3626 raw("<custom-element>"),
3627 component("custom-element"),
3628 raw("<custom-button>"),
3629 component("custom-button"),
3630 raw("Ok</custom-button></custom-element>"),
3631 ]
3632 );
3633
3634 let ce = &records["custom-element"].fragments;
3636 assert_eq!(ce.len(), 3);
3637 assert!(
3638 matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child>"))
3639 );
3640 assert!(
3641 matches!(ce[1].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
3642 );
3643 assert!(
3644 matches!(ce[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><slot></slot>"))
3645 );
3646
3647 assert_stream!(records, "custom-button", [raw("<slot></slot>"),]);
3648
3649 assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
3650 }
3651
3652 #[test]
3655 fn test_invalid_markup_returns_error() {
3656 let mut parser = HtmlParser::new();
3659 let result = parser.parse("index.html", "<div><span>Unclosed div");
3660 assert!(result.is_ok());
3661 }
3662
3663 #[test]
3666 fn test_complex_raw_text_full_page() {
3667 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>"#;
3669 let (fragments, _) = parse_and_get_fragments(html);
3670
3671 assert!(fragments.len() >= 7);
3673 assert!(
3674 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3675 raw.value.contains("<!DOCTYPE HTML>") && raw.value.contains("<title>Test</title>"))
3676 );
3677 assert!(
3678 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
3679 s.value == "head_end" && s.raw)
3680 );
3681 assert!(
3682 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3683 raw.value.contains("</head>") && raw.value.ends_with("<body>"))
3684 );
3685 assert!(
3686 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
3687 s.value == "body_start" && s.raw)
3688 );
3689 assert!(
3690 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3691 raw.value.contains("<app-shell>"))
3692 );
3693 assert!(
3694 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
3695 s.value == "body_end" && s.raw)
3696 );
3697 assert!(
3698 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3699 raw.value.contains("</body>") && raw.value.contains("</html>"))
3700 );
3701 }
3702
3703 #[test]
3704 fn test_css_strategy_external_emits_link_tag() {
3705 let mut parser = HtmlParser::new();
3706 parser
3707 .component_registry_mut()
3708 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3709 .ok();
3710 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3711 let records = parser.into_fragment_records();
3712 let my_card = &records["my-card"].fragments;
3713 let raw_text: String = my_card
3714 .iter()
3715 .filter_map(|f| match &f.fragment {
3716 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3717 _ => None,
3718 })
3719 .collect();
3720 assert!(
3721 raw_text.contains(r#"<link rel="stylesheet" href="my-card.css">"#),
3722 "Expected external <link> tag in: {}",
3723 raw_text
3724 );
3725 }
3726
3727 #[test]
3728 fn test_css_strategy_inline_emits_style_tag() {
3729 let mut parser = HtmlParser::with_options(CssStrategy::Style);
3730 parser
3731 .component_registry_mut()
3732 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3733 .ok();
3734 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3735 let records = parser.into_fragment_records();
3736 let my_card = &records["my-card"].fragments;
3737 let raw_text: String = my_card
3738 .iter()
3739 .filter_map(|f| match &f.fragment {
3740 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3741 _ => None,
3742 })
3743 .collect();
3744 assert!(
3745 raw_text.contains("<style>p { color: red; }</style>"),
3746 "Expected inline <style> tag in: {}",
3747 raw_text
3748 );
3749 assert!(
3750 !raw_text.contains("<link"),
3751 "Should not have <link> tag in inline mode: {}",
3752 raw_text
3753 );
3754 }
3755
3756 #[test]
3757 fn test_css_strategy_module_emits_adopted_stylesheets() {
3758 let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
3759 parser
3760 .component_registry_mut()
3761 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
3762 .ok();
3763 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3764 let records = parser.into_fragment_records();
3765
3766 let my_card = &records["my-card"].fragments;
3769 let template_text: String = my_card
3770 .iter()
3771 .filter_map(|f| match &f.fragment {
3772 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3773 _ => None,
3774 })
3775 .collect();
3776 assert!(
3777 !template_text.contains("shadowrootadoptedstylesheets"),
3778 "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
3779 );
3780 assert!(
3781 !template_text.contains("<link"),
3782 "Should not have <link> in module mode: {template_text}"
3783 );
3784 assert!(
3788 !template_text.contains(r#"<style type="module""#),
3789 "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
3790 );
3791 assert!(
3792 !template_text.contains(r#"<script type="importmap""#),
3793 "CSS module importmap should NOT be in raw fragments: {template_text}"
3794 );
3795 }
3796
3797 #[test]
3798 fn test_css_strategy_module_no_css_no_adopted_attr() {
3799 let mut parser = HtmlParser::with_options(CssStrategy::Module);
3800 parser
3801 .component_registry_mut()
3802 .register_component("my-card", "<p><slot></slot></p>", None)
3803 .ok();
3804 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
3805 let records = parser.into_fragment_records();
3806 let my_card = &records["my-card"].fragments;
3807 let template_text: String = my_card
3808 .iter()
3809 .filter_map(|f| match &f.fragment {
3810 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
3811 _ => None,
3812 })
3813 .collect();
3814 assert!(
3816 !template_text.contains("shadowrootadoptedstylesheets"),
3817 "Should not have adopted attr without CSS: {template_text}"
3818 );
3819 }
3820
3821 #[test]
3840 fn light_preserves_dev_template_with_static_attrs() {
3841 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3842 let processed = parser
3843 .process_component_template(
3844 r#"<template foo="bar"><div>hi</div></template>"#,
3845 None,
3846 None,
3847 )
3848 .expect("process failed");
3849 assert!(
3850 processed.contains(r#"<template foo="bar">"#),
3851 "[--dom=light] expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
3852 );
3853 assert!(
3854 processed.contains("</template>"),
3855 "[--dom=light] expected closing </template> preserved, got: {processed}"
3856 );
3857 }
3858
3859 #[test]
3860 fn light_preserves_dev_template_with_signal_fragment_attrs() {
3861 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3862 let processed = parser
3863 .process_component_template(
3864 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
3865 None,
3866 None,
3867 )
3868 .expect("process failed");
3869 assert!(
3870 processed.contains(r#"<template foo="{{foo}}">"#),
3871 "[--dom=light] expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
3872 );
3873 }
3874
3875 #[test]
3876 fn light_preserves_dev_template_with_multiple_attrs() {
3877 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3878 let processed = parser.process_component_template(
3879 r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
3880 None,
3881 None,
3882 )
3883 .expect("process failed");
3884 assert!(
3885 processed.contains("autofocus")
3886 && processed.contains(r#"tabindex="0""#)
3887 && processed.contains(r#"role="region""#)
3888 && processed.contains(r#"data-x="y""#),
3889 "[--dom=light] expected ALL dev template attrs preserved, got: {processed}"
3890 );
3891 }
3892
3893 #[test]
3894 fn light_no_template_no_wrapper_added() {
3895 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3896 let processed = parser
3897 .process_component_template("<div>hi</div>", None, None)
3898 .expect("process failed");
3899 assert!(
3900 !processed.contains("<template"),
3901 "[--dom=light] framework must NOT add <template> wrapper when dev omits one, got: {processed}"
3902 );
3903 assert!(
3904 processed.contains("<div>hi</div>"),
3905 "[--dom=light] expected inner content emitted as-is, got: {processed}"
3906 );
3907 }
3908
3909 #[test]
3910 fn shadow_preserves_dev_template_with_static_attrs() {
3911 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
3912 let processed = parser
3913 .process_component_template(
3914 r#"<template foo="bar"><div>hi</div></template>"#,
3915 None,
3916 None,
3917 )
3918 .expect("process failed");
3919 assert!(
3920 processed.contains(r#"<template foo="bar">"#),
3921 "[--dom=shadow] dev <template foo=\"bar\"> must be preserved verbatim, got: {processed}"
3922 );
3923 assert!(
3924 !processed.contains(r#"shadowrootmode="open""#),
3925 "[--dom=shadow] framework must NOT inject shadowrootmode when dev already supplied a <template>, got: {processed}"
3926 );
3927 }
3928
3929 #[test]
3930 fn shadow_preserves_dev_template_with_shadowrootmode_closed() {
3931 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
3932 let processed = parser
3933 .process_component_template(
3934 r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
3935 None,
3936 None,
3937 )
3938 .expect("process failed");
3939 assert!(
3940 processed.contains(r#"shadowrootmode="closed""#),
3941 "[--dom=shadow] framework must respect dev's shadowrootmode=\"closed\" (developer is managing), got: {processed}"
3942 );
3943 assert!(
3944 !processed.contains(r#"shadowrootmode="open""#),
3945 "[--dom=shadow] framework must not overwrite dev's shadowrootmode with \"open\", got: {processed}"
3946 );
3947 }
3948
3949 #[test]
3950 fn shadow_preserves_dev_template_with_signal_fragment_attrs() {
3951 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
3952 let processed = parser
3953 .process_component_template(
3954 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
3955 None,
3956 None,
3957 )
3958 .expect("process failed");
3959 assert!(
3960 processed.contains(r#"<template foo="{{foo}}">"#),
3961 "[--dom=shadow] dev <template> with signal-fragment attr must be preserved, got: {processed}"
3962 );
3963 }
3964
3965 #[test]
3966 fn shadow_adds_template_wrapper_when_dev_omits_it() {
3967 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
3968 let processed = parser
3969 .process_component_template("<div>hi</div>", None, None)
3970 .expect("process failed");
3971 assert!(
3972 processed.contains(r#"<template shadowrootmode="open""#),
3973 "[--dom=shadow] framework MUST add <template shadowrootmode=\"open\"> when dev omits a wrapper, got: {processed}"
3974 );
3975 assert!(
3976 processed.contains("<div>hi</div>"),
3977 "[--dom=shadow] inner content must survive wrapping, got: {processed}"
3978 );
3979 assert!(
3980 processed.contains("</template>"),
3981 "[--dom=shadow] framework-added wrapper must be closed, got: {processed}"
3982 );
3983 }
3984
3985 #[test]
3998 fn dev_template_module_strategy_errors_when_attr_missing() {
3999 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4000 let err = parser
4001 .process_component_template(
4002 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
4003 None,
4004 Some("my-comp"),
4005 )
4006 .expect_err("expected ParserError::MissingAdoptedStylesheets");
4007 match err {
4008 ParserError::MissingAdoptedStylesheets { tag } => {
4009 assert_eq!(tag, "my-comp");
4010 }
4011 other => panic!("expected MissingAdoptedStylesheets, got: {other:?}"),
4012 }
4013 }
4014
4015 #[test]
4016 fn dev_template_module_strategy_error_message_is_actionable() {
4017 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4018 let err = parser
4019 .process_component_template(
4020 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
4021 None,
4022 Some("mp-app"),
4023 )
4024 .expect_err("expected ParserError::MissingAdoptedStylesheets");
4025 let msg = err.to_string();
4026 assert!(msg.contains("<mp-app>"), "missing tag name: {msg}");
4030 assert!(
4031 msg.contains(r#"shadowrootadoptedstylesheets="mp-app""#),
4032 "missing copy-pasteable fix: {msg}"
4033 );
4034 assert!(
4035 msg.contains("--css=module"),
4036 "missing strategy context: {msg}"
4037 );
4038 assert!(
4039 msg.contains("remove the <template> wrapper"),
4040 "missing opt-out hint: {msg}"
4041 );
4042 }
4043
4044 #[test]
4045 fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
4046 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4047 let processed = parser
4048 .process_component_template(
4049 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
4050 None,
4051 Some("my-comp"),
4052 )
4053 .unwrap();
4054 assert!(
4055 processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
4056 "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
4057 );
4058 assert_eq!(
4059 processed.matches("shadowrootadoptedstylesheets").count(),
4060 1,
4061 "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
4062 );
4063 }
4064
4065 #[test]
4066 fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
4067 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4072 let processed = parser
4073 .process_component_template(
4074 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
4075 None,
4076 Some("my-comp"),
4077 )
4078 .unwrap();
4079 assert!(
4080 processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
4081 "dev's multi-specifier list must be preserved verbatim, got: {processed}"
4082 );
4083 }
4084
4085 #[test]
4086 fn dev_template_no_adopted_specifier_does_not_validate() {
4087 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4091 let processed = parser
4092 .process_component_template(
4093 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
4094 None,
4095 None,
4096 )
4097 .unwrap();
4098 assert!(
4099 !processed.contains("shadowrootadoptedstylesheets"),
4100 "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
4101 );
4102 }
4103
4104 #[test]
4116 fn test_body_start_end_injection() {
4117 let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
4121 let (fragments, _) = parse_and_get_fragments(html);
4122
4123 assert_fragments!(
4124 fragments,
4125 [
4126 raw("<html><head><title>Test</title>"),
4127 signal_raw("head_end"),
4128 raw("</head><body>"),
4129 signal_raw("body_start"),
4130 raw("<div>Content</div><p>More</p>"),
4131 signal_raw("body_end"),
4132 raw("</body></html>"),
4133 ]
4134 );
4135 }
4136
4137 #[test]
4138 fn test_body_preserves_static_attributes() {
4139 let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
4144 let (fragments, _) = parse_and_get_fragments(html);
4145
4146 assert_fragments!(
4147 fragments,
4148 [
4149 raw("<html><head><title>T</title>"),
4150 signal_raw("head_end"),
4151 raw(r#"</head><body data-layout="doc" class="page">"#),
4152 signal_raw("body_start"),
4153 raw("<p>x</p>"),
4154 signal_raw("body_end"),
4155 raw("</body></html>"),
4156 ]
4157 );
4158 }
4159
4160 #[test]
4161 fn test_body_preserves_dynamic_attributes() {
4162 let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
4166 let (fragments, _) = parse_and_get_fragments(html);
4167
4168 assert_fragments!(
4169 fragments,
4170 [
4171 raw("<html><head><title>T</title>"),
4172 signal_raw("head_end"),
4173 raw("</head><body"),
4174 attr("data-layout", "layout"),
4175 raw(">"),
4176 signal_raw("body_start"),
4177 raw("<p>x</p>"),
4178 signal_raw("body_end"),
4179 raw("</body></html>"),
4180 ]
4181 );
4182 }
4183
4184 #[test]
4185 fn test_fail_with_invalid_markup() {
4186 let mut parser = HtmlParser::new();
4192 let result = parser.parse("index.html", "<div><span></div></span>");
4193
4194 assert!(
4196 result.is_ok(),
4197 "tree-sitter is lenient and recovers from overlapping tags"
4198 );
4199 }
4200
4201 #[test]
4202 fn test_complex_raw_text_page() {
4203 let html = concat!(
4206 "<!DOCTYPE html>",
4207 "<html lang=\"en\">",
4208 "<head>",
4209 "<meta charset=\"utf-8\">",
4210 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
4211 "<title>Complex Page</title>",
4212 "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
4213 "<link rel=\"stylesheet\" href=\"styles.css\">",
4214 "</head>",
4215 "<body>",
4216 "<h1>Hello World</h1>",
4217 "<script type=\"module\" src=\"./app.js\"></script>",
4218 "</body>",
4219 "</html>",
4220 );
4221 let (fragments, _) = parse_and_get_fragments(html);
4222
4223 assert!(
4226 fragments.len() >= 7,
4227 "Expected at least 7 fragments, got {}",
4228 fragments.len()
4229 );
4230
4231 assert!(
4233 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4234 raw.value.contains("<!DOCTYPE html>") &&
4235 raw.value.contains("<meta charset=\"utf-8\">") &&
4236 raw.value.contains("<meta name=\"viewport\"") &&
4237 raw.value.contains("<title>Complex Page</title>") &&
4238 raw.value.contains("<style>") &&
4239 raw.value.contains("body { margin: 0; padding: 0; }")),
4240 "First fragment should contain all head content, got: {:?}",
4241 fragments[0]
4242 );
4243
4244 assert!(
4246 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
4247 s.value == "head_end" && s.raw),
4248 "Second fragment should be head_end signal"
4249 );
4250
4251 assert!(
4253 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4254 raw.value.contains("</head>") && raw.value.ends_with("<body>")),
4255 "Third fragment should contain </head><body>"
4256 );
4257
4258 assert!(
4260 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
4261 s.value == "body_start" && s.raw),
4262 "Fourth fragment should be body_start signal"
4263 );
4264
4265 assert!(
4267 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4268 raw.value.contains("<h1>Hello World</h1>") &&
4269 raw.value.contains("<script")),
4270 "Fifth fragment should contain body content"
4271 );
4272
4273 assert!(
4275 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
4276 s.value == "body_end" && s.raw),
4277 "Sixth fragment should be body_end signal"
4278 );
4279
4280 assert!(
4282 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4283 raw.value.contains("</body>") && raw.value.contains("</html>")),
4284 "Seventh fragment should contain closing tags"
4285 );
4286 }
4287
4288 struct BindingCountPlugin {
4292 counts: Vec<u32>,
4293 }
4294
4295 impl BindingCountPlugin {
4296 fn new() -> Self {
4297 Self { counts: Vec::new() }
4298 }
4299 }
4300
4301 impl crate::plugin::ParserPlugin for BindingCountPlugin {
4302 fn register_component_template(
4303 &mut self,
4304 _tag_name: &str,
4305 _component: &Component,
4306 _processed_template: &str,
4307 ) -> Result<()> {
4308 Ok(())
4309 }
4310
4311 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
4312 if attr_name.starts_with('@') || attr_name == "f-ref" {
4313 AttributeAction::SkipAndCountBinding
4314 } else {
4315 AttributeAction::Keep
4316 }
4317 }
4318
4319 fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
4320 self.counts.push(binding_attribute_count);
4321 if binding_attribute_count > 0 {
4322 Some(binding_attribute_count.to_le_bytes().to_vec())
4323 } else {
4324 None
4325 }
4326 }
4327 }
4328
4329 #[test]
4330 fn test_component_static_attrs_not_counted_as_bindings() {
4331 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
4332 parser
4333 .component_registry
4334 .register_component("my-btn", "<button><slot></slot></button>", None)
4335 .expect("register");
4336
4337 let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
4339 let result = parser.parse("index.html", html);
4340 assert!(result.is_ok());
4341
4342 let records = parser.into_fragment_records();
4343
4344 let frags = &records["index.html"].fragments;
4346 let plugin_count = frags
4347 .iter()
4348 .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
4349 .count();
4350 assert_eq!(
4351 plugin_count, 0,
4352 "Static-only component attributes should not emit a Plugin fragment"
4353 );
4354 }
4355
4356 #[test]
4357 fn test_component_dynamic_attr_counted_as_binding() {
4358 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
4359 parser
4360 .component_registry
4361 .register_component("my-btn", "<button><slot></slot></button>", None)
4362 .expect("register");
4363
4364 let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
4366 let result = parser.parse("index.html", html);
4367 assert!(result.is_ok());
4368
4369 let records = parser.into_fragment_records();
4370 let frags = &records["index.html"].fragments;
4371
4372 let plugin_frags: Vec<_> = frags
4374 .iter()
4375 .filter_map(|f| match f.fragment.as_ref() {
4376 Some(Fragment::Plugin(p)) => Some(&p.data),
4377 _ => None,
4378 })
4379 .collect();
4380 assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
4381 let count = u32::from_le_bytes([
4382 plugin_frags[0][0],
4383 plugin_frags[0][1],
4384 plugin_frags[0][2],
4385 plugin_frags[0][3],
4386 ]);
4387 assert_eq!(count, 1, "Binding attribute count should be 1");
4388 }
4389
4390 #[test]
4391 fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
4392 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
4393 parser
4394 .component_registry
4395 .register_component("my-btn", "<button><slot></slot></button>", None)
4396 .expect("register");
4397
4398 let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
4400 let result = parser.parse("index.html", html);
4401 assert!(result.is_ok());
4402
4403 let records = parser.into_fragment_records();
4404 let frags = &records["index.html"].fragments;
4405
4406 let plugin_frags: Vec<_> = frags
4407 .iter()
4408 .filter_map(|f| match f.fragment.as_ref() {
4409 Some(Fragment::Plugin(p)) => Some(&p.data),
4410 _ => None,
4411 })
4412 .collect();
4413 assert_eq!(plugin_frags.len(), 1);
4414 let count = u32::from_le_bytes([
4415 plugin_frags[0][0],
4416 plugin_frags[0][1],
4417 plugin_frags[0][2],
4418 plugin_frags[0][3],
4419 ]);
4420 assert_eq!(
4422 count, 2,
4423 "Binding count should include dynamic + plugin-skipped attrs, not static"
4424 );
4425 }
4426
4427 #[test]
4428 fn test_component_only_skipped_plugin_attrs_counted() {
4429 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
4430 parser
4431 .component_registry
4432 .register_component("my-btn", "<button><slot></slot></button>", None)
4433 .expect("register");
4434
4435 let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
4437 let result = parser.parse("index.html", html);
4438 assert!(result.is_ok());
4439
4440 let records = parser.into_fragment_records();
4441 let frags = &records["index.html"].fragments;
4442
4443 let plugin_frags: Vec<_> = frags
4444 .iter()
4445 .filter_map(|f| match f.fragment.as_ref() {
4446 Some(Fragment::Plugin(p)) => Some(&p.data),
4447 _ => None,
4448 })
4449 .collect();
4450 assert_eq!(plugin_frags.len(), 1);
4451 let count = u32::from_le_bytes([
4452 plugin_frags[0][0],
4453 plugin_frags[0][1],
4454 plugin_frags[0][2],
4455 plugin_frags[0][3],
4456 ]);
4457 assert_eq!(
4459 count, 2,
4460 "Only plugin-skipped attrs should be counted, not static title"
4461 );
4462 }
4463
4464 #[test]
4467 fn test_comment_handlebars_signal_is_stripped() {
4468 let mut parser = HtmlParser::new();
4469 let html = "<!--{{tokens}}-->";
4470 parser.parse("test.html", html).expect("parse failed");
4471 let records = parser.into_fragment_records();
4472
4473 assert_stream!(records, "test.html", []);
4474 }
4475
4476 #[test]
4477 fn test_comment_triple_brace_raw_signal_is_stripped() {
4478 let mut parser = HtmlParser::new();
4479 let html = "<!--{{{tokens}}}-->";
4480 parser.parse("test.html", html).expect("parse failed");
4481 let records = parser.into_fragment_records();
4482
4483 assert_stream!(records, "test.html", []);
4484 }
4485
4486 #[test]
4487 fn test_comment_regular_stripped() {
4488 let mut parser = HtmlParser::new();
4489 let html = "<!-- regular comment -->";
4490 parser.parse("test.html", html).expect("parse failed");
4491 let records = parser.into_fragment_records();
4492
4493 assert_stream!(records, "test.html", []);
4494 }
4495
4496 #[test]
4497 fn test_comment_dotted_signal_is_stripped() {
4498 let mut parser = HtmlParser::new();
4499 let html = "<!--{{tokens.light}}-->";
4500 parser.parse("test.html", html).expect("parse failed");
4501 let records = parser.into_fragment_records();
4502
4503 assert_stream!(records, "test.html", []);
4504 }
4505
4506 #[test]
4507 fn test_comment_arbitrary_identifier_is_stripped() {
4508 let mut parser = HtmlParser::new();
4509 let html = "<!--{{someOtherBinding}}-->";
4510 parser.parse("test.html", html).expect("parse failed");
4511 let records = parser.into_fragment_records();
4512
4513 assert_stream!(records, "test.html", []);
4514 }
4515
4516 #[test]
4517 fn test_comment_with_surrounding_content_is_stripped() {
4518 let mut parser = HtmlParser::new();
4519 let html = "<div><!--{{tokens}}--></div>";
4520 parser.parse("test.html", html).expect("parse failed");
4521 let records = parser.into_fragment_records();
4522
4523 assert_stream!(records, "test.html", [raw("<div></div>")]);
4524 }
4525
4526 #[test]
4527 fn test_webui_plugin_strips_component_comments_before_metadata() {
4528 let mut parser =
4529 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
4530 parser
4531 .component_registry_mut()
4532 .register_component(
4533 "x-bleed",
4534 r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
4535 None,
4536 )
4537 .expect("register failed");
4538
4539 parser
4540 .parse("index.html", "<x-bleed></x-bleed>")
4541 .expect("parse failed");
4542
4543 let artifacts = parser.take_plugin_artifacts();
4544 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
4545 panic!("expected component templates");
4546 };
4547 let template = &templates[0].1;
4548 assert!(template.contains("<div>hello</div>"));
4549 assert!(!template.contains("{{path}}"));
4550 assert!(!template.contains("@click"));
4551 assert!(!template.contains("?hidden"));
4552 assert!(!template.contains("-->"));
4553 }
4554
4555 #[test]
4556 fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
4557 let mut parser =
4558 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
4559 parser
4560 .component_registry_mut()
4561 .register_component(
4562 "x-style",
4563 "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
4564 None,
4565 )
4566 .expect("register failed");
4567
4568 parser
4569 .parse("index.html", "<x-style></x-style>")
4570 .expect("parse failed");
4571
4572 let artifacts = parser.take_plugin_artifacts();
4573 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
4574 panic!("expected component templates");
4575 };
4576 let template = &templates[0].1;
4577 assert!(template.contains(".x { color: red; }"));
4578 assert!(!template.contains("ignored"));
4579 assert!(!template.contains("//"));
4580 }
4581
4582 #[test]
4585 fn test_tokens_from_style_tag() {
4586 let mut parser = HtmlParser::new();
4587 let html = r#"<style>
4588 .btn { color: var(--colorPrimary); background: var(--bgColor); }
4589 </style>"#;
4590 parser.parse("test.html", html).expect("parse failed");
4591 let tokens = parser.take_tokens();
4592
4593 assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
4594 }
4595
4596 #[test]
4597 fn test_tokens_from_component_css() {
4598 let mut parser = HtmlParser::new();
4599 parser
4600 .component_registry_mut()
4601 .register_component(
4602 "my-button",
4603 "<button>Click</button>",
4604 Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
4605 )
4606 .expect("register failed");
4607
4608 let html = "<my-button></my-button>";
4609 parser.parse("test.html", html).expect("parse failed");
4610 let tokens = parser.take_tokens();
4611
4612 assert_eq!(tokens, vec!["borderWidth", "textColor"]);
4613 }
4614
4615 #[test]
4616 fn test_tokens_merged_from_style_and_components() {
4617 let mut parser = HtmlParser::new();
4618 parser
4619 .component_registry_mut()
4620 .register_component(
4621 "my-widget",
4622 "<div>Widget</div>",
4623 Some(".w { padding: var(--spacingM); }"),
4624 )
4625 .expect("register failed");
4626
4627 let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
4628 parser.parse("test.html", html).expect("parse failed");
4629 let tokens = parser.take_tokens();
4630
4631 assert_eq!(tokens, vec!["spacingM", "textColor"]);
4632 }
4633
4634 #[test]
4635 fn test_tokens_deduplicated() {
4636 let mut parser = HtmlParser::new();
4637 parser
4638 .component_registry_mut()
4639 .register_component(
4640 "my-btn",
4641 "<button>B</button>",
4642 Some(".b { color: var(--shared); }"),
4643 )
4644 .expect("register failed");
4645
4646 let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
4647 parser.parse("test.html", html).expect("parse failed");
4648 let tokens = parser.take_tokens();
4649
4650 assert_eq!(tokens, vec!["shared"]);
4651 }
4652
4653 #[test]
4654 fn test_tokens_empty_when_no_vars() {
4655 let mut parser = HtmlParser::new();
4656 let html = "<div>Hello</div>";
4657 parser.parse("test.html", html).expect("parse failed");
4658 let tokens = parser.take_tokens();
4659
4660 assert!(tokens.is_empty());
4661 }
4662
4663 #[test]
4664 fn test_tokens_exclude_locally_defined_in_component() {
4665 let mut parser = HtmlParser::new();
4666 parser
4667 .component_registry_mut()
4668 .register_component(
4669 "my-card",
4670 "<div>Card</div>",
4671 Some(":host { --local: 5px; width: var(--external); }"),
4672 )
4673 .expect("register failed");
4674
4675 let html = "<my-card></my-card>";
4676 parser.parse("test.html", html).expect("parse failed");
4677 let tokens = parser.take_tokens();
4678
4679 assert_eq!(tokens, vec!["external"]);
4680 }
4681
4682 #[test]
4683 fn test_tokens_exclude_entry_root_definitions() {
4684 let mut parser = HtmlParser::new();
4685 parser
4686 .component_registry_mut()
4687 .register_component(
4688 "my-btn",
4689 "<button>B</button>",
4690 Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
4691 )
4692 .expect("register failed");
4693
4694 let html = r#"<style>
4697 :root {
4698 --color-primary: #0078d4;
4699 --radius-m: 6px;
4700 }
4701 body { color: var(--color-primary); }
4702 </style>
4703 <my-btn></my-btn>"#;
4704 parser.parse("test.html", html).expect("parse failed");
4705 let tokens = parser.take_tokens();
4706
4707 assert!(
4709 tokens.is_empty(),
4710 "Tokens defined in entry :root should be excluded: {tokens:?}"
4711 );
4712 }
4713
4714 #[test]
4715 fn test_tokens_entry_defs_exclude_but_external_kept() {
4716 let mut parser = HtmlParser::new();
4717 parser
4718 .component_registry_mut()
4719 .register_component(
4720 "my-card",
4721 "<div>Card</div>",
4722 Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
4723 )
4724 .expect("register failed");
4725
4726 let html = r#"<style>
4728 :root { --color-primary: #0078d4; }
4729 </style>
4730 <my-card></my-card>"#;
4731 parser.parse("test.html", html).expect("parse failed");
4732 let tokens = parser.take_tokens();
4733
4734 assert_eq!(tokens, vec!["external-spacing"]);
4736 }
4737
4738 #[test]
4741 fn test_parse_simple_route() {
4742 let mut parser = HtmlParser::new();
4743 let html = r#"<route path="/profile" component="profile-page" exact />"#;
4744 parser.parse("test.html", html).expect("parse failed");
4745
4746 let records = parser.into_fragment_records();
4748 let frags = &records["test.html"].fragments;
4749 assert_eq!(frags.len(), 1);
4750 match frags[0].fragment.as_ref() {
4751 Some(web_ui_fragment::Fragment::Route(r)) => {
4752 assert_eq!(r.path, "/profile");
4753 assert_eq!(r.fragment_id, "profile-page");
4754 assert!(r.exact);
4755 }
4756 other => panic!("expected Fragment::Route, got {:?}", other),
4757 }
4758 }
4759
4760 #[test]
4761 fn test_parse_route_with_params() {
4762 let mut parser = HtmlParser::new();
4763 let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
4764 parser.parse("test.html", html).expect("parse failed");
4765
4766 let records = parser.into_fragment_records();
4768 let frags = &records["test.html"].fragments;
4769 match frags[0].fragment.as_ref() {
4770 Some(web_ui_fragment::Fragment::Route(r)) => {
4771 assert_eq!(r.path, "/profile/:id/view/:section");
4772 }
4773 other => panic!("expected Fragment::Route, got {:?}", other),
4774 }
4775 }
4776
4777 #[test]
4778 fn test_parse_route_requires_component() {
4779 let mut parser = HtmlParser::new();
4780 let html = r#"<route path="/old" />"#;
4781 let result = parser.parse("test.html", html);
4782 assert!(result.is_err());
4783 }
4784
4785 #[test]
4786 fn test_parse_multiple_routes() {
4787 let mut parser = HtmlParser::new();
4788 let html = r#"<route path="/" component="app-layout" />
4789 <route path="/dashboard" component="dash-page" exact />
4790 <route path="/contacts" component="contacts-page" exact />"#;
4791 parser.parse("test.html", html).expect("parse failed");
4792
4793 let records = parser.into_fragment_records();
4794 let frags = &records["test.html"].fragments;
4795 assert_eq!(frags.len(), 3);
4796 for frag in frags {
4798 assert!(matches!(
4799 frag.fragment.as_ref(),
4800 Some(web_ui_fragment::Fragment::Route(_))
4801 ));
4802 }
4803 }
4804
4805 #[test]
4806 fn test_parse_route_requires_component_with_body() {
4807 let mut parser = HtmlParser::new();
4808 let html = r#"<route path="/error">
4809 <div class="error"><h1>Not Found</h1></div>
4810 </route>"#;
4811 let result = parser.parse("test.html", html);
4812 assert!(
4813 result.is_err(),
4814 "Route without component attribute should fail"
4815 );
4816 }
4817
4818 #[test]
4819 fn test_parse_multiple_routes_with_fragments() {
4820 let mut parser = HtmlParser::new();
4821 let html = r#"<route path="/" component="home-page" exact />
4822 <route path="/about" component="about-page" exact />
4823 <route path="/contact/:id" component="contact-page" />"#;
4824 parser.parse("test.html", html).expect("parse failed");
4825
4826 let records = parser.into_fragment_records();
4827 let frags = &records["test.html"].fragments;
4828 assert_eq!(frags.len(), 3);
4829
4830 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
4832 assert_eq!(r.path, "/");
4833 }
4834 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
4835 assert_eq!(r.path, "/about");
4836 }
4837 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
4838 assert_eq!(r.path, "/contact/:id");
4839 }
4840 }
4841
4842 #[test]
4843 fn test_parse_route_with_query_allowlist() {
4844 let mut parser = HtmlParser::new();
4845 let html =
4846 r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
4847 parser.parse("test.html", html).expect("parse failed");
4848
4849 let records = parser.into_fragment_records();
4850 let frags = &records["test.html"].fragments;
4851 assert_eq!(frags.len(), 1);
4852 match frags[0].fragment.as_ref() {
4853 Some(web_ui_fragment::Fragment::Route(r)) => {
4854 assert_eq!(r.path, "/compose");
4855 assert_eq!(r.fragment_id, "compose-page");
4856 assert!(r.exact);
4857 assert_eq!(r.allowed_query, "action,to,subject");
4858 }
4859 other => panic!("expected Fragment::Route, got {:?}", other),
4860 }
4861 }
4862
4863 #[test]
4864 fn test_parse_route_without_query_has_empty_allowed_query() {
4865 let mut parser = HtmlParser::new();
4866 let html = r#"<route path="/profile" component="profile-page" exact />"#;
4867 parser.parse("test.html", html).expect("parse failed");
4868
4869 let records = parser.into_fragment_records();
4870 let frags = &records["test.html"].fragments;
4871 match frags[0].fragment.as_ref() {
4872 Some(web_ui_fragment::Fragment::Route(r)) => {
4873 assert!(r.allowed_query.is_empty());
4874 }
4875 other => panic!("expected Fragment::Route, got {:?}", other),
4876 }
4877 }
4878
4879 #[test]
4880 fn test_outlet_not_captured_by_for_loop() {
4881 let mut parser = HtmlParser::new();
4882 let html = r#"<template shadowrootmode="open">
4883 <ul>
4884 <for each="item in items">
4885 <li>{{item.name}}</li>
4886 </for>
4887 </ul>
4888 <main>
4889 <outlet />
4890 </main>
4891</template>"#;
4892 parser.parse("comp.html", html).expect("parse failed");
4893
4894 let records = parser.into_fragment_records();
4895 let frags = &records["comp.html"].fragments;
4896
4897 let frag_types: Vec<String> = frags
4899 .iter()
4900 .map(|f| match f.fragment.as_ref() {
4901 Some(web_ui_fragment::Fragment::Raw(r)) => {
4902 format!("Raw({:?})", &r.value[..r.value.len().min(40)])
4903 }
4904 Some(web_ui_fragment::Fragment::ForLoop(fl)) => {
4905 format!("ForLoop({})", fl.fragment_id)
4906 }
4907 Some(web_ui_fragment::Fragment::Outlet(_)) => "Outlet".to_string(),
4908 other => format!("{:?}", other),
4909 })
4910 .collect();
4911 eprintln!("Fragment order: {:#?}", frag_types);
4912
4913 let outlet_count = frags
4915 .iter()
4916 .filter(|f| {
4917 matches!(
4918 f.fragment.as_ref(),
4919 Some(web_ui_fragment::Fragment::Outlet(_))
4920 )
4921 })
4922 .count();
4923 assert_eq!(
4924 outlet_count, 1,
4925 "expected exactly 1 outlet in top-level fragments, got {outlet_count}. Fragments: {frag_types:?}"
4926 );
4927
4928 let outlet_idx = frags
4930 .iter()
4931 .position(|f| {
4932 matches!(
4933 f.fragment.as_ref(),
4934 Some(web_ui_fragment::Fragment::Outlet(_))
4935 )
4936 })
4937 .expect("no outlet found");
4938 let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
4939 Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
4940 _ => false,
4941 });
4942 if let Some(ul_idx) = close_ul_idx {
4943 assert!(
4944 outlet_idx > ul_idx,
4945 "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx}). Fragments: {frag_types:?}"
4946 );
4947 }
4948 }
4949
4950 #[test]
4951 fn test_outlet_position_after_for_not_inside() {
4952 let mut parser = HtmlParser::new();
4953 let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
4954 parser.parse("test.html", html).expect("parse failed");
4955
4956 let records = parser.into_fragment_records();
4957 let frags = &records["test.html"].fragments;
4958
4959 let has_outlet = frags.iter().any(|f| {
4961 matches!(
4962 f.fragment.as_ref(),
4963 Some(web_ui_fragment::Fragment::Outlet(_))
4964 )
4965 });
4966 assert!(
4967 has_outlet,
4968 "outlet should be in top-level fragments: {frags:?}"
4969 );
4970
4971 let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
4973 Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
4974 _ => None,
4975 });
4976 if let Some(id) = for_id {
4977 let for_frags = &records[&id].fragments;
4978 let outlet_in_for = for_frags.iter().any(|f| {
4979 matches!(
4980 f.fragment.as_ref(),
4981 Some(web_ui_fragment::Fragment::Outlet(_))
4982 )
4983 });
4984 assert!(
4985 !outlet_in_for,
4986 "outlet should NOT be inside for-loop body: {for_frags:?}"
4987 );
4988 }
4989 }
4990
4991 #[test]
4992 fn test_style_element_with_handlebars_signal_comment_emits_signal() {
4993 let mut parser = HtmlParser::new();
4994 let html = r#"<html><head><style>
4995:root {
4996 /*{{{tokens.light}}}*/
4997}
4998</style></head><body></body></html>"#;
4999
5000 let result = parser.parse("test.html", html);
5001 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5002 let fragment_records = parser.into_fragment_records();
5003
5004 assert_stream!(
5005 fragment_records,
5006 "test.html",
5007 [
5008 raw("<html><head><style>\n:root {\n "),
5009 signal_raw("tokens.light"),
5010 raw("\n}\n</style>"),
5011 signal_raw("head_end"),
5012 raw("</head><body>"),
5013 signal_raw("body_start"),
5014 signal_raw("body_end"),
5015 raw("</body></html>"),
5016 ]
5017 );
5018 }
5019
5020 #[test]
5021 fn test_style_comment_signal_with_spaces_emits_signal() {
5022 let mut parser = HtmlParser::new();
5023 let html = r#"<html><head><style>
5024:root {
5025 /* {{{tokens.light}}} */
5026}
5027</style></head><body></body></html>"#;
5028
5029 let result = parser.parse("test.html", html);
5030 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5031 let fragment_records = parser.into_fragment_records();
5032
5033 assert_stream!(
5034 fragment_records,
5035 "test.html",
5036 [
5037 raw("<html><head><style>\n:root {\n "),
5038 signal_raw("tokens.light"),
5039 raw("\n}\n</style>"),
5040 signal_raw("head_end"),
5041 raw("</head><body>"),
5042 signal_raw("body_start"),
5043 signal_raw("body_end"),
5044 raw("</body></html>"),
5045 ]
5046 );
5047 }
5048
5049 #[test]
5050 fn test_style_comment_signal_double_brace_emits_signal() {
5051 let mut parser = HtmlParser::new();
5052 let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
5053
5054 let result = parser.parse("test.html", html);
5055 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5056 let fragment_records = parser.into_fragment_records();
5057
5058 assert_stream!(
5059 fragment_records,
5060 "test.html",
5061 [
5062 raw("<html><head><style>"),
5063 signal("themeCss"),
5064 raw("</style>"),
5065 signal_raw("head_end"),
5066 raw("</head><body>"),
5067 signal_raw("body_start"),
5068 signal_raw("body_end"),
5069 raw("</body></html>"),
5070 ]
5071 );
5072 }
5073
5074 #[test]
5075 fn test_style_comment_with_extra_text_is_stripped() {
5076 let mut parser = HtmlParser::new();
5077 let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
5078
5079 let result = parser.parse("test.html", html);
5080 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5081 let fragment_records = parser.into_fragment_records();
5082
5083 assert_stream!(
5084 fragment_records,
5085 "test.html",
5086 [
5087 raw("<html><head><style></style>"),
5088 signal_raw("head_end"),
5089 raw("</head><body>"),
5090 signal_raw("body_start"),
5091 signal_raw("body_end"),
5092 raw("</body></html>"),
5093 ]
5094 );
5095 }
5096
5097 #[test]
5098 fn test_style_comment_with_multiple_signals_is_stripped() {
5099 let mut parser = HtmlParser::new();
5100 let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
5101
5102 let result = parser.parse("test.html", html);
5103 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5104 let fragment_records = parser.into_fragment_records();
5105
5106 assert_stream!(
5107 fragment_records,
5108 "test.html",
5109 [
5110 raw("<html><head><style></style>"),
5111 signal_raw("head_end"),
5112 raw("</head><body>"),
5113 signal_raw("body_start"),
5114 signal_raw("body_end"),
5115 raw("</body></html>"),
5116 ]
5117 );
5118 }
5119
5120 #[test]
5121 fn test_style_bare_handlebars_stays_raw() {
5122 let mut parser = HtmlParser::new();
5123 let html =
5125 "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
5126
5127 let result = parser.parse("test.html", html);
5128 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5129 let fragment_records = parser.into_fragment_records();
5130
5131 assert_stream!(
5132 fragment_records,
5133 "test.html",
5134 [
5135 raw("<html><head><style>body { color: {{textColor}}; }</style>"),
5136 signal_raw("head_end"),
5137 raw("</head><body>"),
5138 signal_raw("body_start"),
5139 signal_raw("body_end"),
5140 raw("</body></html>"),
5141 ]
5142 );
5143 }
5144
5145 #[test]
5146 fn test_style_mixed_css_and_comment_signal_emits_signal() {
5147 let mut parser = HtmlParser::new();
5148 let html = r#"<html><head><style>
5149 .a { color: red; }
5150 /*{{themeCss}}*/
5151 .b { color: blue; }
5152</style></head><body></body></html>"#;
5153
5154 let result = parser.parse("test.html", html);
5155 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5156 let fragment_records = parser.into_fragment_records();
5157
5158 assert_stream!(
5159 fragment_records,
5160 "test.html",
5161 [
5162 raw("<html><head><style>\n .a { color: red; }\n "),
5163 signal("themeCss"),
5164 raw("\n .b { color: blue; }\n</style>"),
5165 signal_raw("head_end"),
5166 raw("</head><body>"),
5167 signal_raw("body_start"),
5168 signal_raw("body_end"),
5169 raw("</body></html>"),
5170 ]
5171 );
5172 }
5173
5174 #[test]
5175 fn test_style_legal_comment_preserved_inline_by_default() {
5176 let mut parser = HtmlParser::new();
5177 let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
5178
5179 parser.parse("test.html", html).expect("parse failed");
5180 let fragment_records = parser.into_fragment_records();
5181
5182 assert_stream!(
5183 fragment_records,
5184 "test.html",
5185 [
5186 raw("<html><head><style>/*! @license MIT */ .x { color: red; } </style>"),
5187 signal_raw("head_end"),
5188 raw("</head><body>"),
5189 signal_raw("body_start"),
5190 signal_raw("body_end"),
5191 raw("</body></html>"),
5192 ]
5193 );
5194 }
5195
5196 #[test]
5197 fn test_style_legal_comments_none_strips_legal_comment() {
5198 let mut parser = HtmlParser::with_options(ParserOptions {
5199 legal_comments: LegalComments::None,
5200 ..ParserOptions::default()
5201 });
5202 let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
5203
5204 parser.parse("test.html", html).expect("parse failed");
5205 let fragment_records = parser.into_fragment_records();
5206
5207 assert_stream!(
5208 fragment_records,
5209 "test.html",
5210 [
5211 raw("<html><head><style> .x { color: red; }</style>"),
5212 signal_raw("head_end"),
5213 raw("</head><body>"),
5214 signal_raw("body_start"),
5215 signal_raw("body_end"),
5216 raw("</body></html>"),
5217 ]
5218 );
5219 }
5220
5221 #[test]
5222 fn test_style_element_plain_css_unchanged() {
5223 let mut parser = HtmlParser::new();
5224 let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
5225
5226 let result = parser.parse("test.html", html);
5227 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5228 let fragment_records = parser.into_fragment_records();
5229
5230 assert_stream!(
5232 fragment_records,
5233 "test.html",
5234 [
5235 raw("<html><head><style>body { margin: 0; }</style>"),
5236 signal_raw("head_end"),
5237 raw("</head><body>"),
5238 signal_raw("body_start"),
5239 signal_raw("body_end"),
5240 raw("</body></html>"),
5241 ]
5242 );
5243 }
5244
5245 #[test]
5246 fn test_text_with_binding_after_void_element() {
5247 let (fragments, _) = parse_and_get_fragments(
5250 "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
5251 );
5252 assert_fragments!(
5253 fragments,
5254 [
5255 raw("Part A: "),
5256 signal("value1"),
5257 raw("<button>Click</button><br>Part B: "),
5258 signal("value2"),
5259 raw("<button>Click2</button>"),
5260 ]
5261 );
5262 }
5263}