1mod comment_policy;
8mod component_registry;
9mod condition_parser;
10mod css_link;
11mod css_parser;
12mod diagnostic;
13mod error;
14mod handlebars_parser;
15mod html_parser;
16pub mod plugin;
17mod route_parser;
18mod suggest;
19
20pub use component_registry::{Component, ComponentRegistry};
21pub use condition_parser::ConditionParser;
22pub use css_link::{CssLinkHref, CssLinkOptions, DEFAULT_CSS_FILE_NAME_TEMPLATE};
23pub use css_parser::CssParser;
24use diagnostic::codes;
25pub use diagnostic::{Diagnostic, Severity};
26pub use error::{ParserError, Result};
27pub use handlebars_parser::HandlebarsParser;
28
29use crate::html_parser::{self as html, Attrs, Element, Event, Walker};
30use crate::plugin::{AttributeAction, ParserPlugin, ParserPluginArtifacts};
31use std::collections::{HashMap, HashSet};
32use std::ops::Range;
33use webui_protocol::{
34 web_ui_fragment, web_ui_fragment::Fragment, ConditionExpr, FragmentList, WebUIFragment,
35 WebUIFragmentAttribute, WebUIFragmentRecords, WebUiFragmentRoute,
36};
37
38const MAX_TEMPLATE_BYTES: usize = 16 * 1024 * 1024;
44
45const MAX_TEMPLATE_DEPTH: usize = 512;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
55pub enum CssStrategy {
56 #[default]
58 Link,
59 Style,
61 Module,
65}
66
67impl std::fmt::Display for CssStrategy {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 match self {
70 CssStrategy::Link => write!(f, "link"),
71 CssStrategy::Style => write!(f, "style"),
72 CssStrategy::Module => write!(f, "module"),
73 }
74 }
75}
76
77impl std::str::FromStr for CssStrategy {
78 type Err = String;
79
80 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
81 match s {
82 "link" => Ok(CssStrategy::Link),
83 "style" => Ok(CssStrategy::Style),
84 "module" => Ok(CssStrategy::Module),
85 other => Err(format!(
86 "Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
87 )),
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
95pub enum DomStrategy {
96 #[default]
98 Shadow,
99 Light,
101}
102
103impl std::fmt::Display for DomStrategy {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 match self {
106 DomStrategy::Shadow => write!(f, "shadow"),
107 DomStrategy::Light => write!(f, "light"),
108 }
109 }
110}
111
112impl std::str::FromStr for DomStrategy {
113 type Err = String;
114
115 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
116 match s {
117 "shadow" => Ok(DomStrategy::Shadow),
118 "light" => Ok(DomStrategy::Light),
119 other => Err(format!(
120 "Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
121 )),
122 }
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
128#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
129pub enum LegalComments {
130 None,
132 #[default]
134 Inline,
135}
136
137impl std::fmt::Display for LegalComments {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 match self {
140 LegalComments::None => write!(f, "none"),
141 LegalComments::Inline => write!(f, "inline"),
142 }
143 }
144}
145
146impl std::str::FromStr for LegalComments {
147 type Err = String;
148
149 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
150 match s {
151 "none" => Ok(LegalComments::None),
152 "inline" => Ok(LegalComments::Inline),
153 other => Err(format!(
154 "Unknown legal comments strategy: {other}. Use \"none\" or \"inline\"."
155 )),
156 }
157 }
158}
159
160#[derive(Debug, Clone, Default)]
162pub struct ParserOptions {
163 pub css_strategy: CssStrategy,
165 pub dom_strategy: DomStrategy,
167 pub css_link_options: CssLinkOptions,
169 pub legal_comments: LegalComments,
171}
172
173impl ParserOptions {
174 pub fn try_new(
180 css_strategy: CssStrategy,
181 dom_strategy: DomStrategy,
182 css_file_name_template: &str,
183 css_public_base: Option<&str>,
184 legal_comments: LegalComments,
185 ) -> Result<Self> {
186 let css_link_options = if css_strategy == CssStrategy::Link {
187 CssLinkOptions::try_new(
188 css_file_name_template.to_string(),
189 css_public_base.map(std::string::ToString::to_string),
190 )?
191 } else {
192 CssLinkOptions::default()
193 };
194
195 Ok(Self {
196 css_strategy,
197 dom_strategy,
198 css_link_options,
199 legal_comments,
200 })
201 }
202}
203
204impl From<CssStrategy> for ParserOptions {
205 fn from(css_strategy: CssStrategy) -> Self {
206 Self {
207 css_strategy,
208 ..Self::default()
209 }
210 }
211}
212
213impl From<DomStrategy> for ParserOptions {
214 fn from(dom_strategy: DomStrategy) -> Self {
215 Self {
216 dom_strategy,
217 ..Self::default()
218 }
219 }
220}
221
222impl From<(CssStrategy, DomStrategy)> for ParserOptions {
223 fn from((css_strategy, dom_strategy): (CssStrategy, DomStrategy)) -> Self {
224 Self {
225 css_strategy,
226 dom_strategy,
227 ..Self::default()
228 }
229 }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
235pub enum Plugin {
236 #[cfg_attr(feature = "cli", value(name = "fast"))]
238 Fast,
239 #[cfg_attr(feature = "cli", value(name = "fast-v2"))]
241 FastV2,
242 #[cfg_attr(feature = "cli", value(name = "fast-v3"))]
244 FastV3,
245 #[cfg_attr(feature = "cli", value(name = "webui"))]
247 WebUI,
248}
249
250impl std::fmt::Display for Plugin {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 match self {
253 Plugin::Fast => write!(f, "fast"),
254 Plugin::FastV2 => write!(f, "fast-v2"),
255 Plugin::FastV3 => write!(f, "fast-v3"),
256 Plugin::WebUI => write!(f, "webui"),
257 }
258 }
259}
260
261impl std::str::FromStr for Plugin {
262 type Err = String;
263
264 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
265 match s {
266 "fast" => Ok(Plugin::Fast),
267 "fast-v2" => Ok(Plugin::FastV2),
268 "fast-v3" => Ok(Plugin::FastV3),
269 "webui" => Ok(Plugin::WebUI),
270 other => Err(format!(
271 "Unknown plugin: {other}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
272 )),
273 }
274 }
275}
276
277struct FragmentIdCounter {
279 counters: HashMap<String, usize>,
281}
282
283impl FragmentIdCounter {
284 fn new() -> Self {
286 Self {
287 counters: HashMap::new(),
288 }
289 }
290
291 fn next_id(&mut self, prefix: &str) -> String {
293 let count = self.counters.entry(prefix.to_string()).or_insert(0);
294 *count += 1;
295 format!("{}-{}", prefix, count)
296 }
297}
298
299struct ParseContext {
300 fragments: Vec<WebUIFragment>,
301 raw_buffer: String,
302}
303
304enum ParseOp<'a> {
305 Parse {
306 range: Range<usize>,
307 depth: usize,
308 },
309 EmitClose(&'a str),
310 EndHead,
311 EndBody,
312 CompleteFor {
313 parent: ParseContext,
314 item: String,
315 collection: String,
316 fragment_id: String,
317 keep_empty: bool,
318 },
319 CompleteIf {
320 parent: ParseContext,
321 condition: ConditionExpr,
322 fragment_id: String,
323 },
324}
325
326impl Default for HtmlParser {
327 fn default() -> Self {
328 Self::new()
329 }
330}
331
332pub struct HtmlParser {
334 css_parser: CssParser,
336
337 id_counter: FragmentIdCounter,
339
340 condition_parser: ConditionParser,
342
343 handlebars_parser: HandlebarsParser,
345
346 component_registry: ComponentRegistry,
348
349 fragment_records: WebUIFragmentRecords,
351
352 raw_buffer: String,
354
355 options: ParserOptions,
357
358 plugin: Option<Box<dyn ParserPlugin>>,
360
361 token_store: HashSet<String>,
364
365 token_definitions: HashSet<String>,
369
370 in_progress_fragments: HashSet<String>,
373
374 current_fragment_id: String,
377}
378
379impl HtmlParser {
380 #[must_use]
382 pub fn new() -> Self {
383 Self::with_options(ParserOptions::default())
384 }
385
386 #[must_use]
388 pub fn with_options(options: impl Into<ParserOptions>) -> Self {
389 let options = options.into();
390
391 Self {
392 component_registry: ComponentRegistry::with_legal_comments(options.legal_comments),
393 css_parser: CssParser::new(),
394 id_counter: FragmentIdCounter::new(),
395 condition_parser: ConditionParser::new(),
396 handlebars_parser: HandlebarsParser::new(),
397 raw_buffer: String::new(),
398 fragment_records: WebUIFragmentRecords::new(),
399 options,
400 plugin: None,
401 token_store: HashSet::new(),
402 token_definitions: HashSet::new(),
403 in_progress_fragments: HashSet::new(),
404 current_fragment_id: String::new(),
405 }
406 }
407
408 #[must_use]
410 pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
411 Self::with_plugin_options(plugin, ParserOptions::default())
412 }
413
414 #[must_use]
416 pub fn with_plugin_options(
417 plugin: Box<dyn ParserPlugin>,
418 options: impl Into<ParserOptions>,
419 ) -> Self {
420 let mut p = Self::with_options(options);
421 p.plugin = Some(plugin);
422 p.configure_plugin();
423 p
424 }
425
426 fn configure_plugin(&mut self) {
427 if let Some(ref mut plugin) = self.plugin {
428 plugin.configure(&self.options);
429 }
430 }
431
432 pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
434 &mut self.component_registry
435 }
436
437 #[must_use]
439 pub fn component_registry(&self) -> &ComponentRegistry {
440 &self.component_registry
441 }
442
443 pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
444 std::mem::take(&mut self.fragment_records)
445 }
446
447 pub fn has_fragment(&self, fragment_id: &str) -> bool {
449 self.fragment_records.contains_key(fragment_id)
450 }
451
452 pub fn take_plugin_artifacts(&mut self) -> Result<ParserPluginArtifacts> {
459 self.plugin
460 .take()
461 .map_or(Ok(ParserPluginArtifacts::None), |plugin| {
462 plugin.into_artifacts()
463 })
464 }
465
466 #[must_use]
474 pub fn take_tokens(&mut self) -> Vec<String> {
475 let definitions = std::mem::take(&mut self.token_definitions);
476 let mut tokens: Vec<String> = std::mem::take(&mut self.token_store)
477 .into_iter()
478 .filter(|t| !definitions.contains(t))
479 .collect();
480 tokens.sort();
481 tokens
482 }
483
484 pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
486 let fragment_key = fragment_id.to_string();
487 let previous_fragment_id =
493 std::mem::replace(&mut self.current_fragment_id, fragment_key.clone());
494 if !self.in_progress_fragments.insert(fragment_key.clone()) {
495 let err = self
496 .authoring_error(
497 codes::RECURSIVE_TEMPLATE,
498 format!("recursive template reference while parsing <{fragment_id}>"),
499 )
500 .help(
501 "move the recursive usage behind runtime data, or split the component graph \
502 so templates do not reference themselves at build time",
503 )
504 .into();
505 self.current_fragment_id = previous_fragment_id;
506 return Err(err);
507 }
508
509 let result = self.parse_inner(fragment_id, html_content);
510 self.in_progress_fragments.remove(&fragment_key);
511 self.current_fragment_id = previous_fragment_id;
512 result
513 }
514
515 fn parse_inner(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
516 if html_content.len() > MAX_TEMPLATE_BYTES {
517 return Err(ParserError::Html(format!(
518 "Template '{fragment_id}' is {} bytes, which exceeds the {MAX_TEMPLATE_BYTES} byte parser limit. Split very large templates into components or reduce generated markup before build.",
519 html_content.len()
520 )));
521 }
522
523 self.raw_buffer.clear();
525 if let Some(ref mut plugin) = self.plugin {
526 plugin.start_fragment(fragment_id);
527 }
528
529 let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
530 self.parse_range(html_content, 0..html_content.len(), &mut entry_fragment, 0)?;
531
532 self.flush_raw_buffer(&mut entry_fragment);
533
534 self.fragment_records.insert(
535 fragment_id.to_string(),
536 FragmentList {
537 fragments: entry_fragment,
538 },
539 );
540
541 Ok(())
542 }
543
544 fn add_raw_fragment(&mut self, content: &str) {
546 if !content.is_empty() {
547 self.raw_buffer.push_str(content);
548 }
549 }
550
551 fn add_for_fragment(
553 &mut self,
554 item: String,
555 collection: String,
556 fragment_id: String,
557 fragments: &mut Vec<WebUIFragment>,
558 ) {
559 self.flush_raw_buffer(fragments);
560 fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
561 }
562
563 fn add_if_fragment(
565 &mut self,
566 condition: ConditionExpr,
567 fragment_id: String,
568 fragments: &mut Vec<WebUIFragment>,
569 ) {
570 self.flush_raw_buffer(fragments);
571 fragments.push(WebUIFragment::if_cond(condition, fragment_id));
572 }
573
574 fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
576 self.flush_raw_buffer(fragments);
577 fragments.push(fragment);
578 }
579
580 fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
582 if !self.raw_buffer.is_empty() {
583 fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
584 }
585 }
586
587 fn should_emit_text_content(content: &str) -> bool {
593 if content.is_empty() {
594 return false;
595 }
596
597 if !content.trim().is_empty() {
598 return true;
599 }
600
601 content.chars().all(char::is_whitespace)
602 && !content.contains('\n')
603 && !content.contains('\r')
604 }
605
606 fn parse_range(
607 &mut self,
608 source: &str,
609 range: Range<usize>,
610 fragments: &mut Vec<WebUIFragment>,
611 depth: usize,
612 ) -> Result<()> {
613 let mut ops = vec![ParseOp::Parse { range, depth }];
614
615 while let Some(op) = ops.pop() {
616 match op {
617 ParseOp::Parse { range, depth } => {
618 if depth > MAX_TEMPLATE_DEPTH {
619 return Err(self
620 .html_error(
621 codes::EXCESSIVE_NESTING,
622 format!(
623 "template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"
624 ),
625 source,
626 range.start,
627 )
628 .help(
629 "split deeply nested markup into components, or reduce generated nesting",
630 )
631 .into());
632 }
633
634 let end = range.end;
635 let mut index = range.start;
636 while index < end {
637 let remaining = &source[index..end];
638 if remaining.starts_with("<!--") {
639 let Some(close) = html::find_comment_close(remaining) else {
640 return Err(self
641 .html_error(
642 codes::UNTERMINATED_HTML_COMMENT,
643 "unterminated HTML comment",
644 source,
645 index,
646 )
647 .help("close the comment with `-->`")
648 .into());
649 };
650 index += close;
651 continue;
652 }
653
654 if remaining.starts_with("<!") {
655 let Some(close) = html::find_declaration_close(remaining) else {
656 return Err(self
657 .html_error(
658 codes::UNTERMINATED_HTML_DECLARATION,
659 "unterminated HTML declaration",
660 source,
661 index,
662 )
663 .help("close the declaration with `>`")
664 .into());
665 };
666 self.add_raw_fragment(&remaining[..close]);
667 index += close;
668 continue;
669 }
670
671 if remaining.starts_with('<') {
672 let Some(tag) = html::parse_tag(remaining) else {
673 return Err(self
674 .html_error(
675 codes::MALFORMED_HTML_TAG,
676 "malformed HTML tag",
677 source,
678 index,
679 )
680 .help(
681 "close the tag with `>`, or escape a literal `<` as `<`",
682 )
683 .into());
684 };
685 if tag.closing {
686 return Err(self
687 .html_error(
688 codes::UNEXPECTED_CLOSING_TAG,
689 format!("unexpected closing tag </{}>", tag.name),
690 source,
691 index,
692 )
693 .help("remove it, or add the matching opening tag before it")
694 .into());
695 }
696
697 let content_start = index + tag.close + 1;
698 let (content_end, close_end) = if tag.self_closing
699 || html::is_void_element(tag.name)
700 {
701 (content_start, content_start)
702 } else if let Some((close_start, close_end)) =
703 html::find_matching_end(remaining, tag.name, tag.close + 1)
704 {
705 (index + close_start, index + close_end)
706 } else {
707 return Err(self
708 .html_error(
709 codes::UNCLOSED_HTML_TAG,
710 format!("unclosed <{}> tag", tag.name),
711 source,
712 index,
713 )
714 .help(format!(
715 "add the matching </{}> closing tag, or make the element self-closing",
716 tag.name
717 ))
718 .into());
719 };
720
721 let element = Element {
722 source,
723 start: index,
724 tag,
725 content_start,
726 content_end,
727 close_end,
728 };
729
730 if close_end < end {
731 ops.push(ParseOp::Parse {
732 range: close_end..end,
733 depth,
734 });
735 }
736
737 match element.name() {
738 "for" => {
739 self.enter_for_directive(&element, fragments, depth, &mut ops)?;
740 }
741 "if" => {
742 self.enter_if_directive(&element, fragments, depth, &mut ops)?;
743 }
744 "body" => {
745 self.enter_body_element(&element, fragments, depth, &mut ops)?;
746 }
747 "head" => {
748 self.enter_head_element(&element, depth, &mut ops);
749 }
750 "route" => {
751 self.process_route_directive(&element, fragments)?;
752 }
753 "outlet" => {
754 self.flush_raw_buffer(fragments);
755 fragments.push(WebUIFragment::outlet());
756 }
757 name if name.eq_ignore_ascii_case("style") => {
758 self.process_style_element(&element, fragments)?;
759 }
760 _ if self.component_registry.contains(element.name()) => {
761 self.enter_component_directive(
762 &element, fragments, depth, &mut ops,
763 )?;
764 }
765 _ => {
766 self.check_component_typo(&element)?;
767 self.enter_regular_element(
768 &element, fragments, depth, &mut ops,
769 )?;
770 }
771 }
772
773 break;
774 }
775
776 let next = remaining.find('<').unwrap_or(remaining.len());
777 let text_end = if next == 0 {
778 remaining
779 .chars()
780 .next()
781 .map_or(remaining.len(), char::len_utf8)
782 } else {
783 next
784 };
785 self.process_text(&remaining[..text_end], fragments)?;
786 index += text_end;
787 }
788 }
789 ParseOp::EmitClose(name) => {
790 self.add_raw_fragment("</");
791 self.add_raw_fragment(name);
792 self.add_raw_fragment(">");
793 }
794 ParseOp::EndHead => {
795 self.flush_raw_buffer(fragments);
796 fragments.push(WebUIFragment::signal("head_end", true));
797 self.add_raw_fragment("</head>");
798 }
799 ParseOp::EndBody => {
800 self.flush_raw_buffer(fragments);
801 fragments.push(WebUIFragment::signal("body_end", true));
802 self.add_raw_fragment("</body>");
803 }
804 ParseOp::CompleteFor {
805 parent,
806 item,
807 collection,
808 fragment_id,
809 keep_empty,
810 } => {
811 self.flush_raw_buffer(fragments);
812 let for_fragment = std::mem::take(fragments);
813 *fragments = parent.fragments;
814 self.raw_buffer = parent.raw_buffer;
815
816 if !for_fragment.is_empty() {
817 self.fragment_records.insert(
818 fragment_id.clone(),
819 FragmentList {
820 fragments: for_fragment,
821 },
822 );
823 } else if !keep_empty {
824 continue;
825 }
826
827 self.add_for_fragment(item, collection, fragment_id, fragments);
828 }
829 ParseOp::CompleteIf {
830 parent,
831 condition,
832 fragment_id,
833 } => {
834 self.flush_raw_buffer(fragments);
835 let if_fragment = std::mem::take(fragments);
836 *fragments = parent.fragments;
837 self.raw_buffer = parent.raw_buffer;
838
839 self.fragment_records.insert(
840 fragment_id.clone(),
841 FragmentList {
842 fragments: if_fragment,
843 },
844 );
845 self.add_if_fragment(condition, fragment_id, fragments);
846 }
847 }
848 }
849
850 Ok(())
851 }
852
853 fn enter_regular_element<'a>(
854 &mut self,
855 element: &Element<'a>,
856 fragments: &mut Vec<WebUIFragment>,
857 depth: usize,
858 ops: &mut Vec<ParseOp<'a>>,
859 ) -> Result<()> {
860 self.add_raw_fragment("<");
861 self.add_raw_fragment(element.name());
862
863 let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
864 if let Some(ref mut p) = self.plugin {
865 if let Some(data) = p.finish_element(binding_count) {
866 self.add_fragment(WebUIFragment::plugin(data), fragments);
867 }
868 }
869
870 if element.self_closing() {
871 self.add_raw_fragment("/>");
872 return Ok(());
873 }
874
875 self.add_raw_fragment(">");
876 if !element.is_void() {
877 if element.close_end() > element.content_end() {
878 ops.push(ParseOp::EmitClose(element.name()));
879 }
880 ops.push(ParseOp::Parse {
881 range: element.inner(),
882 depth: depth + 1,
883 });
884 }
885 Ok(())
886 }
887
888 fn enter_head_element<'a>(
889 &mut self,
890 element: &Element<'a>,
891 depth: usize,
892 ops: &mut Vec<ParseOp<'a>>,
893 ) {
894 self.add_raw_fragment("<head>");
895 ops.push(ParseOp::EndHead);
896 ops.push(ParseOp::Parse {
897 range: element.inner(),
898 depth: depth + 1,
899 });
900 }
901
902 fn enter_body_element<'a>(
903 &mut self,
904 element: &Element<'a>,
905 fragments: &mut Vec<WebUIFragment>,
906 depth: usize,
907 ops: &mut Vec<ParseOp<'a>>,
908 ) -> Result<()> {
909 self.add_raw_fragment("<body");
910 let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
911 if let Some(ref mut p) = self.plugin {
912 if let Some(data) = p.finish_element(binding_count) {
913 self.add_fragment(WebUIFragment::plugin(data), fragments);
914 }
915 }
916 self.add_raw_fragment(">");
917 self.flush_raw_buffer(fragments);
918 fragments.push(WebUIFragment::signal("body_start", true));
919 ops.push(ParseOp::EndBody);
920 ops.push(ParseOp::Parse {
921 range: element.inner(),
922 depth: depth + 1,
923 });
924 Ok(())
925 }
926
927 #[cold]
934 #[inline(never)]
935 fn authoring_error(&self, code: &'static str, title: impl Into<String>) -> Diagnostic {
936 let diag = Diagnostic::error(title).code(code);
937 if self.current_fragment_id.is_empty() {
938 diag
939 } else {
940 diag.component(self.current_fragment_id.clone())
941 }
942 }
943
944 #[cold]
948 #[inline(never)]
949 fn authoring_error_at(
950 &self,
951 code: &'static str,
952 title: impl Into<String>,
953 element: &Element<'_>,
954 ) -> Diagnostic {
955 self.authoring_error(code, title)
956 .at_offset(element.source(), element.start)
957 }
958
959 #[cold]
963 #[inline(never)]
964 fn html_error(
965 &self,
966 code: &'static str,
967 title: impl Into<String>,
968 source: &str,
969 offset: usize,
970 ) -> Diagnostic {
971 self.authoring_error(code, title).at_offset(source, offset)
972 }
973
974 #[cold]
980 #[inline(never)]
981 fn unknown_component_help(&self, name: &str) -> String {
982 match suggest::closest_match(name, self.component_registry.names()) {
983 Some(suggestion) => format!(
984 "did you mean <{suggestion}>? otherwise register the component by adding a \
985 matching .html file"
986 ),
987 None => "register the component (add a matching .html file) or check the tag name \
988 for a typo"
989 .to_string(),
990 }
991 }
992
993 fn suggest_component(&self, tag: &str) -> Option<&str> {
1002 let (prefix, _) = tag.split_once('-')?;
1003 let same_namespace = self
1004 .component_registry
1005 .names()
1006 .filter(|name| name.split_once('-').map(|(p, _)| p) == Some(prefix));
1007 suggest::closest_match(tag, same_namespace)
1008 }
1009
1010 fn check_component_typo(&self, element: &Element<'_>) -> Result<()> {
1015 if let Some(suggestion) = self.suggest_component(element.name()) {
1016 return Err(self
1017 .authoring_error_at(
1018 codes::UNKNOWN_COMPONENT,
1019 format!("unknown component <{}>", element.name()),
1020 element,
1021 )
1022 .help(format!(
1023 "did you mean <{suggestion}>? otherwise register <{}> by adding a matching \
1024 .html file",
1025 element.name()
1026 ))
1027 .into());
1028 }
1029 Ok(())
1030 }
1031
1032 #[cold]
1036 #[inline(never)]
1037 fn attr_typo_suggestion<'a>(element: &Element<'a>, expected: &str) -> Option<&'a str> {
1038 suggest::closest_match(expected, element.attrs().map(|attr| attr.name))
1039 .filter(|&name| name != expected)
1040 }
1041
1042 #[cold]
1048 #[inline(never)]
1049 fn css_diagnostic(&self, err: ParserError) -> ParserError {
1050 match err {
1051 ParserError::Css(message) => self.authoring_error(codes::INVALID_CSS, message).into(),
1052 other => other,
1053 }
1054 }
1055
1056 #[cold]
1058 #[inline(never)]
1059 fn for_each_missing_error(&self, element: &Element<'_>) -> ParserError {
1060 let diag = self
1061 .authoring_error_at(
1062 codes::MISSING_FOR_EACH,
1063 "missing each attribute on <for>",
1064 element,
1065 )
1066 .element("for");
1067 match Self::attr_typo_suggestion(element, "each") {
1068 Some(typo) => diag.help(format!(
1069 "found `{typo}` \u{2014} did you mean each=\"item in collection\"?"
1070 )),
1071 None => diag.help("add each=\"item in collection\", e.g. <for each=\"todo in todos\">"),
1072 }
1073 .into()
1074 }
1075
1076 #[cold]
1078 #[inline(never)]
1079 fn for_each_invalid_error(&self, element: &Element<'_>, each: &str) -> ParserError {
1080 self.authoring_error_at(
1081 codes::INVALID_FOR_EACH,
1082 "invalid <for> each expression",
1083 element,
1084 )
1085 .element("for")
1086 .snippet(format!("each=\"{each}\""))
1087 .help("use the form each=\"item in collection\", e.g. each=\"todo in todos\"")
1088 .into()
1089 }
1090
1091 #[cold]
1094 #[inline(never)]
1095 fn for_identifier_error(&self, element: &Element<'_>, each: &str) -> ParserError {
1096 self.authoring_error_at(
1097 codes::INVALID_FOR_IDENTIFIER,
1098 "invalid identifier in <for> each expression",
1099 element,
1100 )
1101 .element("for")
1102 .snippet(format!("each=\"{each}\""))
1103 .help("item and collection names may use only letters, digits, '_', '-', and '.'")
1104 .into()
1105 }
1106
1107 fn enter_for_directive<'a>(
1108 &mut self,
1109 element: &Element<'a>,
1110 fragments: &mut Vec<WebUIFragment>,
1111 depth: usize,
1112 ops: &mut Vec<ParseOp<'a>>,
1113 ) -> Result<()> {
1114 let each = element
1115 .attr("each")
1116 .map(ToString::to_string)
1117 .ok_or_else(|| self.for_each_missing_error(element))?;
1118
1119 let mut parts = each.split_whitespace();
1120 let (Some(item), Some(in_kw), Some(collection), None) =
1121 (parts.next(), parts.next(), parts.next(), parts.next())
1122 else {
1123 return Err(self.for_each_invalid_error(element, &each));
1124 };
1125 if in_kw != "in" {
1126 return Err(self.for_each_invalid_error(element, &each));
1127 }
1128
1129 let allowed = |s: &str| {
1130 s.chars()
1131 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1132 };
1133 if !allowed(item) || !allowed(collection) {
1134 return Err(self.for_identifier_error(element, &each));
1135 }
1136
1137 let custom_fragment_id = element.attr("template").map(ToString::to_string);
1138 let keep_empty = custom_fragment_id.is_some();
1139 let fragment_id = custom_fragment_id.unwrap_or_else(|| self.id_counter.next_id("for"));
1140 let parent = ParseContext {
1141 fragments: std::mem::take(fragments),
1142 raw_buffer: std::mem::take(&mut self.raw_buffer),
1143 };
1144
1145 ops.push(ParseOp::CompleteFor {
1146 parent,
1147 item: item.to_string(),
1148 collection: collection.to_string(),
1149 fragment_id,
1150 keep_empty,
1151 });
1152 ops.push(ParseOp::Parse {
1153 range: element.inner(),
1154 depth: depth + 1,
1155 });
1156 Ok(())
1157 }
1158
1159 #[cold]
1162 #[inline(never)]
1163 fn if_condition_missing_error(&self, element: &Element<'_>) -> ParserError {
1164 let diag = self
1165 .authoring_error_at(
1166 codes::MISSING_IF_CONDITION,
1167 "missing condition attribute on <if>",
1168 element,
1169 )
1170 .element("if");
1171 match Self::attr_typo_suggestion(element, "condition") {
1172 Some(typo) => diag.help(format!(
1173 "found `{typo}` \u{2014} did you mean condition=\"expression\"?"
1174 )),
1175 None => diag.help("add condition=\"expression\", e.g. <if condition=\"isActive\">"),
1176 }
1177 .into()
1178 }
1179
1180 #[cold]
1182 #[inline(never)]
1183 fn if_condition_invalid_error(&self, element: &Element<'_>, condition: &str) -> ParserError {
1184 self.authoring_error_at(
1185 codes::INVALID_IF_CONDITION,
1186 "invalid <if> condition expression",
1187 element,
1188 )
1189 .element("if")
1190 .snippet(format!("condition=\"{condition}\""))
1191 .help("use a simple expression like \"isActive\", \"count > 0\", or \"!hidden\"")
1192 .into()
1193 }
1194
1195 fn enter_if_directive<'a>(
1196 &mut self,
1197 element: &Element<'a>,
1198 fragments: &mut Vec<WebUIFragment>,
1199 depth: usize,
1200 ops: &mut Vec<ParseOp<'a>>,
1201 ) -> Result<()> {
1202 let condition_str = element
1203 .attr("condition")
1204 .map(ToString::to_string)
1205 .ok_or_else(|| self.if_condition_missing_error(element))?;
1206
1207 let condition = self
1208 .condition_parser
1209 .parse(&condition_str)
1210 .map_err(|_| self.if_condition_invalid_error(element, &condition_str))?;
1211
1212 self.flush_raw_buffer(fragments);
1213 let parent = ParseContext {
1214 fragments: std::mem::take(fragments),
1215 raw_buffer: std::mem::take(&mut self.raw_buffer),
1216 };
1217 let fragment_id = self.id_counter.next_id("if");
1218
1219 ops.push(ParseOp::CompleteIf {
1220 parent,
1221 condition,
1222 fragment_id,
1223 });
1224 ops.push(ParseOp::Parse {
1225 range: element.inner(),
1226 depth: depth + 1,
1227 });
1228 Ok(())
1229 }
1230
1231 fn enter_component_directive<'a>(
1232 &mut self,
1233 element: &Element<'a>,
1234 fragments: &mut Vec<WebUIFragment>,
1235 depth: usize,
1236 ops: &mut Vec<ParseOp<'a>>,
1237 ) -> Result<()> {
1238 self.add_raw_fragment("<");
1239 self.add_raw_fragment(element.name());
1240
1241 let binding_count = self.process_tag_attributes(element.attrs(), fragments, true)?;
1242 if let Some(ref mut p) = self.plugin {
1243 if let Some(data) = p.finish_element(binding_count) {
1244 self.add_fragment(WebUIFragment::plugin(data), fragments);
1245 }
1246 }
1247
1248 if element.self_closing() {
1249 self.add_raw_fragment("/>");
1250 } else {
1251 self.add_raw_fragment(">");
1252 }
1253
1254 self.flush_raw_buffer(fragments);
1255
1256 let (html_content, css_content, css_tokens) = {
1257 let component = self.component_registry.get(element.name()).ok_or_else(|| {
1258 self.authoring_error_at(
1259 codes::UNKNOWN_COMPONENT,
1260 format!("unknown component <{}>", element.name()),
1261 element,
1262 )
1263 .help(self.unknown_component_help(element.name()))
1264 })?;
1265 (
1266 component.html_content.clone(),
1267 component.css_content.clone(),
1268 component.css_tokens.clone(),
1269 )
1270 };
1271
1272 self.token_store.extend(css_tokens);
1273
1274 if !self.fragment_records.contains_key(element.name()) {
1275 let component_data = self
1276 .component_registry
1277 .get(element.name())
1278 .ok_or_else(|| {
1279 self.authoring_error_at(
1280 codes::UNKNOWN_COMPONENT,
1281 format!("unknown component <{}>", element.name()),
1282 element,
1283 )
1284 .help(self.unknown_component_help(element.name()))
1285 })?
1286 .clone();
1287 let processed = self.build_component_template(
1288 element.name(),
1289 &html_content,
1290 css_content.as_deref(),
1291 )?;
1292
1293 if let Some(ref mut p) = self.plugin {
1294 p.register_component_template(element.name(), &component_data, &processed)?;
1295 }
1296
1297 self.parse(element.name(), &processed)?;
1298 }
1299
1300 fragments.push(WebUIFragment::component(element.name().to_string()));
1301
1302 if !element.self_closing() {
1303 ops.push(ParseOp::EmitClose(element.name()));
1304 ops.push(ParseOp::Parse {
1305 range: element.inner(),
1306 depth: depth + 1,
1307 });
1308 }
1309
1310 Ok(())
1311 }
1312
1313 fn process_text(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
1314 if !Self::should_emit_text_content(content) {
1315 return Ok(());
1316 }
1317
1318 let parsed_fragments = self.handlebars_parser.parse(content)?;
1319 for fragment in parsed_fragments {
1320 if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
1321 if Self::should_emit_text_content(&raw.value) {
1322 self.add_raw_fragment(&raw.value);
1323 }
1324 } else {
1325 self.add_fragment(fragment, fragments);
1326 }
1327 }
1328 Ok(())
1329 }
1330
1331 fn process_style_content(
1332 &mut self,
1333 css: &str,
1334 comments: &[crate::css_parser::CssComment],
1335 fragments: &mut Vec<WebUIFragment>,
1336 ) {
1337 let mut last_end = 0usize;
1338
1339 for comment in comments {
1340 if comment.start_byte < last_end {
1341 if comment.end_byte > last_end {
1342 last_end = comment.end_byte;
1343 }
1344 continue;
1345 }
1346
1347 self.add_raw_fragment(&css[last_end..comment.start_byte]);
1348 let comment_text = &css[comment.start_byte..comment.end_byte];
1349 if let Some(fragment) = self.css_signal_comment_fragment(comment_text) {
1350 self.add_fragment(fragment, fragments);
1351 } else if comment.preserve {
1352 self.add_raw_fragment(comment_text);
1353 }
1354 last_end = comment.end_byte;
1355 }
1356
1357 self.add_raw_fragment(&css[last_end..]);
1358 }
1359
1360 fn css_signal_comment_fragment(&self, comment: &str) -> Option<WebUIFragment> {
1361 let signal = comment_policy::parse_css_signal_comment(comment)?;
1362 Some(WebUIFragment::signal(signal.path, signal.raw))
1363 }
1364
1365 fn extract_single_handlebars(value: &str) -> Option<&str> {
1368 let trimmed = value.trim();
1369 if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
1370 let inner = trimmed[2..trimmed.len() - 2].trim();
1371 if !inner.contains("{{") && !inner.is_empty() {
1373 return Some(inner);
1374 }
1375 }
1376 None
1377 }
1378
1379 fn contains_handlebars(value: &str) -> bool {
1381 value.contains("{{")
1382 }
1383
1384 fn process_tag_attributes(
1385 &mut self,
1386 attrs: Attrs<'_>,
1387 fragments: &mut Vec<WebUIFragment>,
1388 is_component: bool,
1389 ) -> Result<u32> {
1390 let mut first_dynamic_emitted = false;
1391 let mut binding_count: u32 = 0;
1392 for attr in attrs {
1393 let attr_name = attr.name;
1394
1395 if let Some(ref mut p) = self.plugin {
1396 match p.classify_attribute(attr_name) {
1397 AttributeAction::Keep => {}
1398 AttributeAction::Skip => continue,
1399 AttributeAction::SkipAndCountBinding => {
1400 binding_count += 1;
1401 continue;
1402 }
1403 }
1404 }
1405
1406 let attr_value = attr.value;
1407
1408 if let Some(bool_name) = attr_name.strip_prefix('?') {
1409 if is_component {
1410 if let Some(condition) = self.parse_boolean_condition(attr_value) {
1411 let frag = Self::maybe_mark_attr_start(
1412 WebUIFragment::attribute_boolean(bool_name, condition),
1413 &mut first_dynamic_emitted,
1414 );
1415 self.add_fragment(frag, fragments);
1416 binding_count += 1;
1417 }
1418 } else if self.process_boolean_attribute(bool_name, attr_value, fragments)? {
1419 binding_count += 1;
1420 }
1421 } else if let Some(prop_name) = attr_name.strip_prefix(':') {
1422 if !is_component {
1423 return Err(ParserError::Parse(format!(
1424 ":{prop_name} complex binding is only allowed on custom elements. \
1425 Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
1426 )));
1427 }
1428 if Self::is_blocked_complex_property(prop_name) {
1429 return Err(ParserError::Parse(format!(
1430 ":{prop_name} is not allowed as a complex attribute binding \
1431 because it enables arbitrary HTML injection. \
1432 Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
1433 )));
1434 }
1435 if let Some(val) = attr_value {
1436 if let Some(signal_name) = Self::extract_single_handlebars(val) {
1437 let frag = Self::maybe_mark_attr_start(
1438 WebUIFragment::attribute_complex(attr_name, signal_name),
1439 &mut first_dynamic_emitted,
1440 );
1441 self.add_fragment(frag, fragments);
1442 binding_count += 1;
1443 }
1444 }
1445 } else if is_component && Self::is_skipped_attribute(attr_name) {
1446 if let Some(val) = attr_value {
1447 if let Some(signal_name) = Self::extract_single_handlebars(val) {
1448 let frag = WebUIFragment {
1449 fragment: Some(web_ui_fragment::Fragment::Attribute(
1450 WebUIFragmentAttribute {
1451 name: attr_name.to_string(),
1452 value: signal_name.to_string(),
1453 attr_skip: true,
1454 ..Default::default()
1455 },
1456 )),
1457 };
1458 self.add_fragment(frag, fragments);
1459 binding_count += 1;
1460 } else if Self::contains_handlebars(val) {
1461 let template_id = self.id_counter.next_id("attr");
1462 let parsed = self.handlebars_parser.parse(val)?;
1463 self.fragment_records
1464 .insert(template_id.clone(), FragmentList { fragments: parsed });
1465 let frag = WebUIFragment {
1466 fragment: Some(web_ui_fragment::Fragment::Attribute(
1467 WebUIFragmentAttribute {
1468 name: attr_name.to_string(),
1469 template: template_id,
1470 attr_skip: true,
1471 ..Default::default()
1472 },
1473 )),
1474 };
1475 self.add_fragment(frag, fragments);
1476 binding_count += 1;
1477 } else {
1478 let frag = WebUIFragment {
1479 fragment: Some(web_ui_fragment::Fragment::Attribute(
1480 WebUIFragmentAttribute {
1481 name: attr_name.to_string(),
1482 value: val.to_string(),
1483 raw_value: true,
1484 attr_skip: true,
1485 ..Default::default()
1486 },
1487 )),
1488 };
1489 self.add_fragment(frag, fragments);
1490 }
1491 }
1492 } else if let Some(val) = attr_value {
1493 if Self::contains_handlebars(val) {
1494 if is_component {
1495 if let Some(signal_name) = Self::extract_single_handlebars(val) {
1496 let frag = Self::maybe_mark_attr_start(
1497 WebUIFragment::attribute(attr_name, signal_name),
1498 &mut first_dynamic_emitted,
1499 );
1500 self.add_fragment(frag, fragments);
1501 binding_count += 1;
1502 } else {
1503 let template_id = self.id_counter.next_id("attr");
1504 let parsed = self.handlebars_parser.parse(val)?;
1505 self.fragment_records
1506 .insert(template_id.clone(), FragmentList { fragments: parsed });
1507 let frag = Self::maybe_mark_attr_start(
1508 WebUIFragment::attribute_template(attr_name, template_id),
1509 &mut first_dynamic_emitted,
1510 );
1511 self.add_fragment(frag, fragments);
1512 binding_count += 1;
1513 }
1514 } else {
1515 self.process_dynamic_attribute(attr_name, val, fragments)?;
1516 binding_count += 1;
1517 }
1518 } else if is_component {
1519 let frag = Self::maybe_mark_attr_start(
1520 WebUIFragment {
1521 fragment: Some(web_ui_fragment::Fragment::Attribute(
1522 WebUIFragmentAttribute {
1523 name: attr_name.to_string(),
1524 value: val.to_string(),
1525 raw_value: true,
1526 ..Default::default()
1527 },
1528 )),
1529 },
1530 &mut first_dynamic_emitted,
1531 );
1532 self.add_fragment(frag, fragments);
1533 } else {
1534 self.add_raw_fragment(" ");
1535 self.add_raw_fragment(attr.raw);
1536 }
1537 } else {
1538 self.add_raw_fragment(" ");
1539 self.add_raw_fragment(attr_name);
1540 }
1541 }
1542 Ok(binding_count)
1543 }
1544
1545 fn maybe_mark_attr_start(
1548 mut frag: WebUIFragment,
1549 first_dynamic_emitted: &mut bool,
1550 ) -> WebUIFragment {
1551 if !*first_dynamic_emitted {
1552 if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
1553 a.attr_start = true;
1554 }
1555 *first_dynamic_emitted = true;
1556 }
1557 frag
1558 }
1559
1560 fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
1563 if let Some(val) = value {
1564 if let Some(expr_str) = Self::extract_single_handlebars(val) {
1565 return Some(
1566 self.condition_parser
1567 .parse(expr_str)
1568 .unwrap_or_else(|_| ConditionExpr::identifier(expr_str)),
1569 );
1570 }
1571 }
1572
1573 None
1574 }
1575
1576 fn process_boolean_attribute(
1577 &mut self,
1578 name: &str,
1579 value: Option<&str>,
1580 fragments: &mut Vec<WebUIFragment>,
1581 ) -> Result<bool> {
1582 if let Some(condition) = self.parse_boolean_condition(value) {
1583 self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
1584 return Ok(true);
1585 }
1586 Ok(false)
1588 }
1589
1590 fn process_dynamic_attribute(
1592 &mut self,
1593 name: &str,
1594 value: &str,
1595 fragments: &mut Vec<WebUIFragment>,
1596 ) -> Result<()> {
1597 if let Some(signal_name) = Self::extract_single_handlebars(value) {
1598 self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
1600 } else {
1601 let template_id = self.id_counter.next_id("attr");
1603 let parsed = self.handlebars_parser.parse(value)?;
1604
1605 self.fragment_records
1606 .insert(template_id.clone(), FragmentList { fragments: parsed });
1607
1608 self.add_fragment(
1609 WebUIFragment::attribute_template(name, template_id),
1610 fragments,
1611 );
1612 }
1613 Ok(())
1614 }
1615
1616 fn process_style_element(
1617 &mut self,
1618 element: &Element<'_>,
1619 fragments: &mut Vec<WebUIFragment>,
1620 ) -> Result<()> {
1621 self.add_raw_fragment(element.opening());
1622 let inner = element.inner();
1623 let style_content = &element.source()[inner.start..inner.end];
1624 let (tokens, defs, comments) = self
1625 .css_parser
1626 .extract_tokens_definitions_and_comments(style_content, self.options.legal_comments)
1627 .map_err(|e| self.css_diagnostic(e))?;
1628 self.token_store.extend(tokens);
1629 self.token_definitions.extend(defs);
1630 self.process_style_content(style_content, &comments, fragments);
1631 if element.close_end() > element.content_end() {
1632 self.add_raw_fragment("</");
1636 self.add_raw_fragment(element.name());
1637 self.add_raw_fragment(">");
1638 }
1639 Ok(())
1640 }
1641
1642 fn process_route_directive(
1648 &mut self,
1649 element: &Element<'_>,
1650 fragments: &mut Vec<WebUIFragment>,
1651 ) -> Result<()> {
1652 let attrs = Self::route_attrs_from_element(element);
1653 let path = attrs.path.clone();
1654 let component = attrs.component.clone();
1655 route_parser::validate_attributes(&attrs)?;
1656
1657 let route_params: std::collections::HashSet<String> =
1658 route_parser::extract_params(&path)?.into_iter().collect();
1659 if !attrs.cache_tags.is_empty() {
1660 route_parser::validate_tag_placeholders(
1661 &attrs.cache_tags,
1662 &route_params,
1663 "cache-tags",
1664 &path,
1665 )?;
1666 }
1667 if !attrs.invalidates.is_empty() {
1668 route_parser::validate_tag_placeholders(
1669 &attrs.invalidates,
1670 &route_params,
1671 "invalidates",
1672 &path,
1673 )?;
1674 }
1675
1676 if !attrs.pending_component.is_empty() {
1677 self.ensure_route_component_parsed(&attrs.pending_component)?;
1678 }
1679 if !attrs.error_component.is_empty() {
1680 self.ensure_route_component_parsed(&attrs.error_component)?;
1681 }
1682 self.ensure_route_component_parsed(&component)?;
1683
1684 let mut all_params = std::collections::HashSet::new();
1685 all_params.extend(route_params);
1686 self.validate_route_nesting_depth(element.source(), element.inner(), 1)?;
1687 let children =
1688 self.parse_child_routes(element.source(), element.inner(), &all_params, 1)?;
1689
1690 self.flush_raw_buffer(fragments);
1691 let route_fragment =
1692 route_parser::build_route_fragment(&attrs, component.clone(), children);
1693 fragments.push(WebUIFragment::route_from(route_fragment));
1694
1695 Ok(())
1696 }
1697
1698 fn parse_child_routes(
1705 &mut self,
1706 source: &str,
1707 range: Range<usize>,
1708 ancestor_params: &std::collections::HashSet<String>,
1709 depth: usize,
1710 ) -> Result<Vec<WebUiFragmentRoute>> {
1711 if depth > MAX_TEMPLATE_DEPTH {
1712 return Err(self
1713 .html_error(
1714 codes::EXCESSIVE_NESTING,
1715 format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
1716 source,
1717 range.start,
1718 )
1719 .help("flatten deeply nested <route> trees before building")
1720 .into());
1721 }
1722
1723 let mut children = Vec::new();
1724 for event in Walker::new_range(source, range.start, range.end) {
1725 match event {
1726 Event::Element(element) => {
1727 self.validate_closed_element(&element)?;
1728 if element.name() == "route" {
1729 children.push(self.parse_route_as_fragment(
1730 &element,
1731 ancestor_params,
1732 depth,
1733 )?);
1734 } else if element.name().eq_ignore_ascii_case("style") {
1735 self.validate_style_element(&element)?;
1736 } else if !element.self_closing() && !element.is_void() {
1737 self.validate_ignored_route_html(element.source(), element.inner(), depth)?;
1738 }
1739 }
1740 Event::Text(text) => {
1741 if text.contains('<') {
1742 return Err(self
1743 .authoring_error(
1744 codes::MALFORMED_HTML_TAG,
1745 "malformed HTML tag in <route>",
1746 )
1747 .help("close the tag with `>`, or escape a literal `<` as `<`")
1748 .into());
1749 }
1750 }
1751 Event::Comment(comment_range) => {
1752 self.validate_comment_range(source, comment_range)?;
1753 }
1754 Event::Declaration(declaration_range) => {
1755 self.validate_declaration_range(source, declaration_range)?;
1756 }
1757 Event::ClosingTag(closing_range) => {
1758 return Err(self.unexpected_closing_tag_error(source, closing_range));
1759 }
1760 }
1761 }
1762
1763 Ok(children)
1764 }
1765
1766 fn validate_route_nesting_depth(
1767 &self,
1768 source: &str,
1769 range: Range<usize>,
1770 depth: usize,
1771 ) -> Result<()> {
1772 let mut stack = vec![(range, depth)];
1773 while let Some((range, depth)) = stack.pop() {
1774 if depth > MAX_TEMPLATE_DEPTH {
1775 return Err(self
1776 .html_error(
1777 codes::EXCESSIVE_NESTING,
1778 format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
1779 source,
1780 range.start,
1781 )
1782 .help("flatten deeply nested <route> trees before building")
1783 .into());
1784 }
1785
1786 for event in Walker::new_range(source, range.start, range.end) {
1787 if let Event::Element(element) = event {
1788 self.validate_closed_element(&element)?;
1789 if element.name() == "route" && !element.self_closing() {
1790 stack.push((element.inner(), depth + 1));
1791 }
1792 }
1793 }
1794 }
1795 Ok(())
1796 }
1797
1798 fn validate_ignored_route_html(
1799 &mut self,
1800 source: &str,
1801 range: Range<usize>,
1802 depth: usize,
1803 ) -> Result<()> {
1804 let mut stack = vec![(range, depth)];
1805 while let Some((range, depth)) = stack.pop() {
1806 if depth > MAX_TEMPLATE_DEPTH {
1807 return Err(self
1808 .html_error(
1809 codes::EXCESSIVE_NESTING,
1810 format!("template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
1811 source,
1812 range.start,
1813 )
1814 .help("split deeply nested markup into components, or reduce generated nesting")
1815 .into());
1816 }
1817
1818 for event in Walker::new_range(source, range.start, range.end) {
1819 match event {
1820 Event::Element(element) => {
1821 self.validate_closed_element(&element)?;
1822 if element.name().eq_ignore_ascii_case("style") {
1823 self.validate_style_element(&element)?;
1824 } else if !element.self_closing() && !element.is_void() {
1825 stack.push((element.inner(), depth + 1));
1826 }
1827 }
1828 Event::Text(text) => {
1829 if text.contains('<') {
1830 return Err(self
1831 .authoring_error(
1832 codes::MALFORMED_HTML_TAG,
1833 "malformed HTML tag in <route>",
1834 )
1835 .help("close the tag with `>`, or escape a literal `<` as `<`")
1836 .into());
1837 }
1838 }
1839 Event::Comment(comment_range) => {
1840 self.validate_comment_range(source, comment_range)?;
1841 }
1842 Event::Declaration(declaration_range) => {
1843 self.validate_declaration_range(source, declaration_range)?;
1844 }
1845 Event::ClosingTag(closing_range) => {
1846 return Err(self.unexpected_closing_tag_error(source, closing_range));
1847 }
1848 }
1849 }
1850 }
1851 Ok(())
1852 }
1853
1854 fn validate_closed_element(&self, element: &Element<'_>) -> Result<()> {
1855 if !element.self_closing()
1856 && !element.is_void()
1857 && element.close_end() == element.content_end()
1858 {
1859 return Err(self
1860 .html_error(
1861 codes::UNCLOSED_HTML_TAG,
1862 format!("unclosed <{}> tag", element.name()),
1863 element.source(),
1864 element.start,
1865 )
1866 .help(format!(
1867 "add the matching </{}> closing tag, or make the element self-closing",
1868 element.name()
1869 ))
1870 .into());
1871 }
1872 Ok(())
1873 }
1874
1875 fn validate_style_element(&mut self, element: &Element<'_>) -> Result<()> {
1876 let inner = element.inner();
1877 let style_content = &element.source()[inner.start..inner.end];
1878 self.css_parser
1879 .extract_tokens_definitions_and_comments(style_content, self.options.legal_comments)
1880 .map_err(|e| self.css_diagnostic(e))?;
1881 Ok(())
1882 }
1883
1884 fn validate_comment_range(&self, source: &str, range: Range<usize>) -> Result<()> {
1885 let start = range.start;
1886 if source[range].ends_with("-->") {
1887 return Ok(());
1888 }
1889 Err(self
1890 .html_error(
1891 codes::UNTERMINATED_HTML_COMMENT,
1892 "unterminated HTML comment in <route>",
1893 source,
1894 start,
1895 )
1896 .help("close the comment with `-->`")
1897 .into())
1898 }
1899
1900 fn validate_declaration_range(&self, source: &str, range: Range<usize>) -> Result<()> {
1901 let start = range.start;
1902 if source[range].ends_with('>') {
1903 return Ok(());
1904 }
1905 Err(self
1906 .html_error(
1907 codes::UNTERMINATED_HTML_DECLARATION,
1908 "unterminated HTML declaration in <route>",
1909 source,
1910 start,
1911 )
1912 .help("close the declaration with `>`")
1913 .into())
1914 }
1915
1916 fn unexpected_closing_tag_error(&self, source: &str, range: Range<usize>) -> ParserError {
1917 let start = range.start;
1918 let title = match html::parse_tag(&source[range]) {
1919 Some(tag) => format!("unexpected closing tag </{}> in <route>", tag.name),
1920 None => "unexpected closing tag in <route>".to_string(),
1921 };
1922 self.html_error(codes::UNEXPECTED_CLOSING_TAG, title, source, start)
1923 .help("remove it, or add the matching opening tag before it")
1924 .into()
1925 }
1926
1927 fn route_attrs_from_element(element: &Element<'_>) -> route_parser::RouteAttributes {
1928 route_parser::RouteAttributes {
1929 path: element.attr("path").unwrap_or_default().to_string(),
1930 component: element.attr("component").unwrap_or_default().to_string(),
1931 exact: element.has_attr("exact"),
1932 query: element.attr("query").unwrap_or_default().to_string(),
1933 keep_alive: element.has_attr("keep-alive"),
1934 cache_tags: element
1935 .attr("cache-tags")
1936 .map(route_parser::parse_tag_list)
1937 .unwrap_or_default(),
1938 invalidates: element
1939 .attr("invalidates")
1940 .map(route_parser::parse_tag_list)
1941 .unwrap_or_default(),
1942 pending_component: element.attr("pending").unwrap_or_default().to_string(),
1943 error_component: element.attr("error").unwrap_or_default().to_string(),
1944 }
1945 }
1946
1947 fn parse_route_as_fragment(
1948 &mut self,
1949 element: &Element<'_>,
1950 ancestor_params: &std::collections::HashSet<String>,
1951 depth: usize,
1952 ) -> Result<WebUiFragmentRoute> {
1953 let attrs = Self::route_attrs_from_element(element);
1954 let path = attrs.path.clone();
1955 let component = attrs.component.clone();
1956
1957 route_parser::validate_attributes(&attrs)?;
1958
1959 let own_params: std::collections::HashSet<String> =
1960 route_parser::extract_params(&path)?.into_iter().collect();
1961 let mut all_params = ancestor_params.clone();
1962 all_params.extend(own_params.iter().cloned());
1963 if !attrs.cache_tags.is_empty() {
1964 route_parser::validate_tag_placeholders(
1965 &attrs.cache_tags,
1966 &all_params,
1967 "cache-tags",
1968 &path,
1969 )?;
1970 }
1971 if !attrs.invalidates.is_empty() {
1972 route_parser::validate_tag_placeholders(
1973 &attrs.invalidates,
1974 &all_params,
1975 "invalidates",
1976 &path,
1977 )?;
1978 }
1979
1980 if !attrs.pending_component.is_empty() {
1981 self.ensure_route_component_parsed(&attrs.pending_component)?;
1982 }
1983 if !attrs.error_component.is_empty() {
1984 self.ensure_route_component_parsed(&attrs.error_component)?;
1985 }
1986
1987 self.ensure_route_component_parsed(&component)?;
1988 let children =
1989 self.parse_child_routes(element.source(), element.inner(), &all_params, depth + 1)?;
1990
1991 Ok(route_parser::build_route_fragment(
1992 &attrs, component, children,
1993 ))
1994 }
1995
1996 fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
1998 if component.is_empty()
1999 || !self.component_registry.contains(component)
2000 || self.fragment_records.contains_key(component)
2001 {
2002 return Ok(());
2003 }
2004
2005 let component_data = self
2006 .component_registry
2007 .get(component)
2008 .ok_or_else(|| {
2009 self.authoring_error(
2010 codes::UNKNOWN_COMPONENT,
2011 format!("unknown component <{component}>"),
2012 )
2013 .help(self.unknown_component_help(component))
2014 })?
2015 .clone();
2016
2017 self.token_store
2018 .extend(component_data.css_tokens.iter().cloned());
2019
2020 let processed = self.build_component_template(
2021 component,
2022 &component_data.html_content,
2023 component_data.css_content.as_deref(),
2024 )?;
2025
2026 if let Some(ref mut p) = self.plugin {
2027 p.register_component_template(component, &component_data, &processed)?;
2028 }
2029
2030 let saved_buffer = std::mem::take(&mut self.raw_buffer);
2031 self.parse(component, &processed)?;
2032 self.raw_buffer = saved_buffer;
2033
2034 Ok(())
2035 }
2036
2037 const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
2039 const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-", "aria-"];
2041
2042 fn is_skipped_attribute(name: &str) -> bool {
2043 Self::SKIPPED_ATTRIBUTES.contains(&name)
2044 || Self::SKIPPED_ATTRIBUTE_PREFIXES
2045 .iter()
2046 .any(|prefix| name.starts_with(prefix))
2047 }
2048
2049 fn is_blocked_complex_property(name: &str) -> bool {
2052 matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
2053 }
2054
2055 fn build_component_template(
2060 &mut self,
2061 tag_name: &str,
2062 html: &str,
2063 css_content: Option<&str>,
2064 ) -> Result<String> {
2065 let adopted_specifier = match self.options.css_strategy {
2066 CssStrategy::Module if css_content.is_some() => Some(tag_name.to_string()),
2067 _ => None,
2068 };
2069 let css_injection = match self.options.css_strategy {
2070 CssStrategy::Link => {
2071 if let (Some(css), DomStrategy::Shadow) = (css_content, self.options.dom_strategy) {
2074 let href = self.options.css_link_options.resolve(tag_name, css);
2075 let mut link = String::with_capacity(31 + href.href.len());
2076 link.push_str("<link rel=\"stylesheet\" href=\"");
2077 link.push_str(&href.href);
2078 link.push_str("\">");
2079 Some(link)
2080 } else {
2081 None
2082 }
2083 }
2084 CssStrategy::Style => css_content.map(|css| {
2085 let trimmed = css.trim();
2086 let mut style = String::with_capacity(15 + trimmed.len());
2087 style.push_str("<style>");
2088 style.push_str(trimmed);
2089 style.push_str("</style>");
2090 style
2091 }),
2092 CssStrategy::Module => None,
2093 };
2094
2095 self.process_component_template(
2096 html,
2097 css_injection.as_deref(),
2098 adopted_specifier.as_deref(),
2099 )
2100 }
2101
2102 fn process_component_template(
2130 &mut self,
2131 html: &str,
2132 css_snippet: Option<&str>,
2133 adopted_specifier: Option<&str>,
2134 ) -> Result<String> {
2135 let trimmed = html.trim();
2136 let snippet = css_snippet.unwrap_or_default();
2137
2138 let processed = if trimmed.starts_with("<template") {
2139 let stripped = self.strip_runtime_attrs_from_template(trimmed);
2140
2141 if let Some(tag) = adopted_specifier {
2145 let opening = match html::find_tag_close(&stripped) {
2146 Some(end) => &stripped[..=end],
2147 None => stripped.as_str(),
2148 };
2149 if !opening.contains("shadowrootadoptedstylesheets=") {
2150 return Err(ParserError::MissingAdoptedStylesheets {
2151 tag: tag.to_string(),
2152 });
2153 }
2154 }
2155
2156 if snippet.is_empty() {
2157 stripped
2158 } else {
2159 match html::find_tag_close(&stripped) {
2163 Some(open_end) => {
2164 let mut result = String::with_capacity(stripped.len() + snippet.len());
2165 result.push_str(&stripped[..=open_end]);
2166 result.push_str(snippet);
2167 result.push_str(&stripped[open_end + 1..]);
2168 result
2169 }
2170 None => stripped,
2173 }
2174 }
2175 } else {
2176 match self.options.dom_strategy {
2177 DomStrategy::Shadow => {
2178 let adopted = adopted_specifier.unwrap_or_default();
2179 let adopted_extra = if adopted.is_empty() {
2180 0
2181 } else {
2182 33 + adopted.len()
2183 };
2184 let mut result =
2185 String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
2186 result.push_str("<template shadowrootmode=\"open\"");
2187 if !adopted.is_empty() {
2188 result.push_str(" shadowrootadoptedstylesheets=\"");
2189 result.push_str(adopted);
2190 result.push('"');
2191 }
2192 result.push('>');
2193 result.push_str(snippet);
2194 result.push_str(trimmed);
2195 result.push_str("</template>");
2196 result
2197 }
2198 DomStrategy::Light => {
2199 if snippet.is_empty() {
2200 trimmed.to_string()
2201 } else {
2202 let mut result = String::with_capacity(snippet.len() + trimmed.len());
2203 result.push_str(snippet);
2204 result.push_str(trimmed);
2205 result
2206 }
2207 }
2208 }
2209 };
2210
2211 self.strip_template_comments(processed)
2212 }
2213
2214 fn strip_template_comments(&mut self, html: String) -> Result<String> {
2215 let mut ranges = Vec::new();
2216 let mut style_ranges = Vec::new();
2217 Self::collect_html_comment_and_style_ranges(&html, &mut ranges, &mut style_ranges)?;
2218
2219 for (style_start, style_end) in style_ranges {
2220 let css = &html[style_start..style_end];
2221 let (_tokens, _defs, comments) = self
2222 .css_parser
2223 .extract_tokens_definitions_and_comments(css, self.options.legal_comments)
2224 .map_err(|e| self.css_diagnostic(e))?;
2225 for comment in comments {
2226 let comment_text = &css[comment.start_byte..comment.end_byte];
2227 if comment.preserve || self.css_signal_comment_fragment(comment_text).is_some() {
2228 continue;
2229 }
2230 ranges.push((
2231 style_start + comment.start_byte,
2232 style_start + comment.end_byte,
2233 ));
2234 }
2235 }
2236
2237 if ranges.is_empty() {
2238 return Ok(html);
2239 }
2240
2241 Ok(comment_policy::strip_ranges(&html, ranges.as_mut_slice()).into_owned())
2242 }
2243
2244 fn collect_html_comment_and_style_ranges(
2246 html: &str,
2247 comment_ranges: &mut Vec<(usize, usize)>,
2248 style_ranges: &mut Vec<(usize, usize)>,
2249 ) -> Result<()> {
2250 let mut index = 0usize;
2251 while index < html.len() {
2252 let remaining = &html[index..];
2253 if remaining.starts_with("<!--") {
2254 let Some(end) = html::find_comment_close(remaining) else {
2255 return Err(Diagnostic::error("unterminated HTML comment")
2256 .code(codes::UNTERMINATED_HTML_COMMENT)
2257 .at_offset(html, index)
2258 .help("close the comment with `-->`")
2259 .into());
2260 };
2261 comment_ranges.push((index, index + end));
2262 index += end;
2263 continue;
2264 }
2265
2266 if remaining.starts_with('<') {
2267 if let Some((style_start, style_end, close_end)) =
2268 html::style_element_bounds(remaining)
2269 {
2270 style_ranges.push((index + style_start, index + style_end));
2271 index += close_end;
2272 continue;
2273 }
2274
2275 if let Some(tag) = html::parse_tag(remaining) {
2276 index += tag.close + 1;
2277 continue;
2278 }
2279 }
2280
2281 let Some(ch) = remaining.chars().next() else {
2282 break;
2283 };
2284 index += ch.len_utf8();
2285 }
2286 Ok(())
2287 }
2288
2289 fn strip_runtime_attrs_from_template(&mut self, html: &str) -> String {
2294 let Some(tag) = html::parse_tag(html) else {
2295 return html.to_string();
2296 };
2297
2298 let mut removals: Vec<(usize, usize)> = Vec::new();
2299 for attr in tag.attrs() {
2300 if attr.name.starts_with('@')
2301 || attr.name.starts_with(':')
2302 || attr.name.starts_with('?')
2303 {
2304 let mut start = attr.raw_range.start;
2305 while start > 0 && html.as_bytes()[start - 1].is_ascii_whitespace() {
2306 start -= 1;
2307 }
2308 removals.push((start, attr.raw_range.end));
2309 }
2310 }
2311
2312 if removals.is_empty() {
2313 return html.to_string();
2314 }
2315
2316 let mut result = String::with_capacity(html.len());
2318 let mut pos = 0;
2319 for (start, end) in &removals {
2320 result.push_str(&html[pos..*start]);
2321 pos = *end;
2322 }
2323 result.push_str(&html[pos..]);
2324 result
2325 }
2326}
2327
2328#[cfg(test)]
2329mod tests {
2330 use webui_test_utils::*;
2331
2332 use super::*;
2333
2334 #[test]
2335 fn test_plugin_display_names() {
2336 assert_eq!(Plugin::Fast.to_string(), "fast");
2337 assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
2338 assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
2339 assert_eq!(Plugin::WebUI.to_string(), "webui");
2340 }
2341
2342 #[test]
2343 fn test_plugin_from_str_names() {
2344 assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
2345 assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
2346 assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
2347 assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
2348 }
2349
2350 #[test]
2351 fn test_plugin_from_str_unknown_mentions_supported_names() {
2352 let err = "legacy".parse::<Plugin>().unwrap_err();
2353 assert!(
2354 err.contains("fast-v3"),
2355 "error should mention fast-v3: {err}"
2356 );
2357 assert!(
2358 err.contains("fast-v2"),
2359 "error should mention fast-v2: {err}"
2360 );
2361 assert!(err.contains("fast"), "error should mention fast: {err}");
2362 }
2363
2364 #[test]
2365 fn test_parse_signal() {
2366 let mut parser = HtmlParser::new();
2367 let html = "Hello, {{name}}!";
2368 let result = parser.parse("test.html", html);
2369
2370 assert!(result.is_ok());
2371 let fragment_records = parser.into_fragment_records();
2372
2373 assert_stream!(
2374 fragment_records,
2375 "test.html",
2376 [raw("Hello, "), signal("name"), raw("!"),]
2377 );
2378 }
2379
2380 #[test]
2381 fn test_parse_raw_signal() {
2382 let mut parser = HtmlParser::new();
2383 let html = "Hello, {{{html_content}}}!";
2384 let result = parser.parse("test.html", html);
2385
2386 assert!(result.is_ok());
2387 let fragment_records = parser.into_fragment_records();
2388
2389 assert_stream!(
2390 fragment_records,
2391 "test.html",
2392 [raw("Hello, "), signal_raw("html_content"), raw("!"),]
2393 );
2394 }
2395
2396 #[test]
2397 fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
2398 let mut parser = HtmlParser::new();
2399 let html = "<nav>{{sectionName}} > {{topicName}}</nav>";
2400 let result = parser.parse("test.html", html);
2401
2402 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2403 let fragment_records = parser.into_fragment_records();
2404
2405 assert_stream!(
2406 fragment_records,
2407 "test.html",
2408 [
2409 raw("<nav>"),
2410 signal("sectionName"),
2411 raw(" > "),
2412 signal("topicName"),
2413 raw("</nav>"),
2414 ]
2415 );
2416 }
2417
2418 #[test]
2419 fn test_invalid_for_each_is_template_diagnostic() {
2420 let mut parser = HtmlParser::new();
2421 let err = parser
2422 .parse(
2423 "index.html",
2424 "<div>\n <for each=\"person inpeople\"><p>x</p></for>\n</div>",
2425 )
2426 .expect_err("invalid for-each must error");
2427 assert!(matches!(err, ParserError::Template(_)));
2428 let msg = err.to_string();
2429 assert!(msg.contains("invalid <for> each expression"), "{msg}");
2430 assert!(msg.contains(r#"each="person inpeople""#), "{msg}");
2431 assert!(
2434 msg.contains("--> index.html:2:3"),
2435 "missing line:column — {msg}"
2436 );
2437 assert!(msg.contains("help:"), "{msg}");
2438 }
2439
2440 #[test]
2441 fn test_invalid_if_condition_is_template_diagnostic() {
2442 let mut parser = HtmlParser::new();
2443 let err = parser
2444 .parse("index.html", r#"<if condition="a +"><p>x</p></if>"#)
2445 .expect_err("invalid condition must error");
2446 assert!(matches!(err, ParserError::Template(_)));
2447 let msg = err.to_string();
2448 assert!(msg.contains("invalid <if> condition expression"), "{msg}");
2449 assert!(
2450 msg.contains("--> index.html:1:1"),
2451 "missing line:column — {msg}"
2452 );
2453 }
2454
2455 #[test]
2456 fn test_parse_for_directive() {
2457 let mut parser = HtmlParser::new();
2458 let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
2459
2460 let result = parser.parse("test.html", html);
2461 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2462 let fragment_records = parser.into_fragment_records();
2463
2464 assert_stream!(
2465 fragment_records,
2466 "test.html",
2467 [for_loop("item", "items", "for-1"),]
2468 );
2469
2470 assert_stream!(
2472 fragment_records,
2473 "for-1",
2474 [
2475 raw("<div class=\"item\">"),
2476 signal("item.name"),
2477 raw("</div>"),
2478 ]
2479 );
2480 }
2481
2482 #[test]
2483 fn test_parse_if_directive() {
2484 let mut parser = HtmlParser::new();
2485 let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
2486
2487 let result = parser.parse("test.html", html);
2488 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2489 let fragment_records = parser.into_fragment_records();
2490
2491 assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
2492
2493 assert_stream!(
2495 fragment_records,
2496 "if-1",
2497 [
2498 raw("<div>Welcome back, "),
2499 signal("username"),
2500 raw("!</div>"),
2501 ]
2502 );
2503 }
2504
2505 #[test]
2506 fn test_component_directive() {
2507 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2508 parser
2509 .component_registry
2510 .register_component(
2511 "my-component",
2512 "<div>My Component</div>",
2513 Some("div { color: blue; }"),
2514 )
2515 .expect("Failed to register component");
2516
2517 let result = parser.parse("test.html", "<my-component></my-component>");
2518 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2519 let records = parser.into_fragment_records();
2520
2521 assert_stream!(
2522 records,
2523 "test.html",
2524 [
2525 raw("<my-component>"),
2526 component("my-component"),
2527 raw("</my-component>"),
2528 ]
2529 );
2530
2531 let comp = &records["my-component"].fragments;
2533 assert_eq!(comp.len(), 1);
2534 assert!(
2535 matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
2536 !raw.value.contains("<template shadowrootmode") && raw.value.contains("<div>My Component</div>"))
2537 );
2538 }
2539
2540 #[test]
2541 fn unknown_component_typo_in_same_namespace_errors_with_suggestion() {
2542 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2543 parser
2544 .component_registry
2545 .register_component("mp-button", "<button>b</button>", None)
2546 .expect("Failed to register component");
2547
2548 let err = parser
2550 .parse("test.html", "<mp-buton></mp-buton>")
2551 .expect_err("a near-typo component tag should error");
2552 let ParserError::Template(diag) = err else {
2553 panic!("expected ParserError::Template, got {err:?}");
2554 };
2555 assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
2556 let help = diag
2557 .help_text()
2558 .expect("diagnostic should carry a help line");
2559 assert!(
2560 help.contains("did you mean <mp-button>?"),
2561 "expected a suggestion, got: {help}"
2562 );
2563 }
2564
2565 #[test]
2566 fn external_custom_element_in_other_namespace_passes_through() {
2567 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2568 parser
2569 .component_registry
2570 .register_component("mp-button", "<button>b</button>", None)
2571 .expect("Failed to register component");
2572
2573 let result = parser.parse("test.html", "<md-button></md-button>");
2576 assert!(
2577 result.is_ok(),
2578 "external custom element should pass through, got: {:?}",
2579 result.err()
2580 );
2581 }
2582
2583 #[test]
2584 fn for_missing_each_suggests_typoed_attribute() {
2585 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2586 let err = parser
2588 .parse("test.html", "<for eahc=\"todo in todos\"></for>")
2589 .expect_err("a <for> without `each` should error");
2590 let ParserError::Template(diag) = err else {
2591 panic!("expected ParserError::Template, got {err:?}");
2592 };
2593 assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
2594 let help = diag
2595 .help_text()
2596 .expect("diagnostic should carry a help line");
2597 assert!(
2598 help.contains("found `eahc`") && help.contains("did you mean"),
2599 "expected an attribute-typo suggestion, got: {help}"
2600 );
2601 }
2602
2603 #[test]
2604 fn parent_diagnostic_owner_survives_nested_component_parse() {
2605 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2610 parser
2611 .component_registry
2612 .register_component("my-card", "<div>card</div>", None)
2613 .expect("register");
2614
2615 let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
2617 let err = parser
2618 .parse("index.html", html)
2619 .expect_err("the malformed <for> should error");
2620 let ParserError::Template(diag) = err else {
2621 panic!("expected ParserError::Template, got {err:?}");
2622 };
2623 assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
2624 assert_eq!(
2625 diag.component_name(),
2626 Some("index.html"),
2627 "parent diagnostic must be owned by index.html, not the nested component"
2628 );
2629 }
2630
2631 #[test]
2632 fn test_component_directive_with_slots() {
2633 let mut parser = HtmlParser::new();
2634 parser
2635 .component_registry
2636 .register_component(
2637 "my-component",
2638 "<div>My Component</div>",
2639 Some("div { color: blue; }"),
2640 )
2641 .expect("Failed to register component");
2642
2643 let result = parser.parse(
2644 "test.html",
2645 "Hello<my-component><p>World</p></my-component>",
2646 );
2647 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2648 let records = parser.into_fragment_records();
2649 let fragments = &records["test.html"].fragments;
2650
2651 assert!(fragments.len() >= 3);
2653 assert!(
2655 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component>"))
2656 );
2657 assert!(fragments.iter().any(|f| matches!(
2659 f.fragment.as_ref(),
2660 Some(Fragment::Component(c)) if c.fragment_id == "my-component"
2661 )));
2662 let last = fragments.last().unwrap();
2664 assert!(
2665 matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
2666 );
2667 }
2668
2669 #[test]
2670 fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
2671 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
2676
2677 parser
2678 .component_registry
2679 .register_component(
2680 "app-shell",
2681 r#"<template shadowrootmode="open">
2682 <my-navbar></my-navbar>
2683 <main><outlet /></main>
2684 <cart-panel></cart-panel>
2685 <my-footer></my-footer>
2686 </template>"#,
2687 Some(":host{display:flex}"),
2688 )
2689 .expect("register app-shell");
2690 parser
2691 .component_registry
2692 .register_component("my-navbar", "<nav>Nav</nav>", Some("nav{color:red}"))
2693 .expect("register my-navbar");
2694 parser
2695 .component_registry
2696 .register_component(
2697 "cart-panel",
2698 "<aside>Cart</aside>",
2699 Some("aside{color:green}"),
2700 )
2701 .expect("register cart-panel");
2702 parser
2703 .component_registry
2704 .register_component(
2705 "my-footer",
2706 "<footer>Footer</footer>",
2707 Some("footer{color:blue}"),
2708 )
2709 .expect("register my-footer");
2710
2711 parser
2712 .parse("index.html", "<app-shell></app-shell>")
2713 .expect("parse index.html");
2714 let records = parser.into_fragment_records();
2715
2716 let app_frags = &records["app-shell"].fragments;
2718 let component_ids: Vec<&str> = app_frags
2719 .iter()
2720 .filter_map(|f| match f.fragment.as_ref() {
2721 Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
2722 _ => None,
2723 })
2724 .collect();
2725
2726 assert!(
2727 component_ids.contains(&"my-navbar"),
2728 "app-shell fragments should contain my-navbar: {component_ids:?}"
2729 );
2730 assert!(
2731 component_ids.contains(&"cart-panel"),
2732 "app-shell fragments should contain cart-panel: {component_ids:?}"
2733 );
2734 assert!(
2735 component_ids.contains(&"my-footer"),
2736 "app-shell fragments should contain my-footer: {component_ids:?}"
2737 );
2738 }
2739
2740 #[test]
2743 fn test_component_no_double_wrap_template() {
2744 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2748 parser
2749 .component_registry
2750 .register_component(
2751 "custom-element",
2752 r#"<template foo="bar"><slot></slot></template>"#,
2753 None,
2754 )
2755 .expect("register");
2756 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2757 assert!(result.is_ok());
2758 let records = parser.into_fragment_records();
2759
2760 assert_fragments!(
2761 records["index.html"].fragments,
2762 [
2763 raw("<custom-element>"),
2764 component("custom-element"),
2765 raw("Hello</custom-element>"),
2766 ]
2767 );
2768
2769 assert_stream!(
2770 records,
2771 "custom-element",
2772 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
2773 );
2774 }
2775
2776 #[test]
2777 fn test_component_styled_no_double_wrap() {
2778 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2783 parser
2784 .component_registry
2785 .register_component(
2786 "custom-element",
2787 r#"<template foo="bar"><slot></slot></template>"#,
2788 Some("div { color: red; }"),
2789 )
2790 .expect("register");
2791 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2792 assert!(result.is_ok());
2793 let records = parser.into_fragment_records();
2794
2795 assert_stream!(
2796 records,
2797 "custom-element",
2798 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
2799 );
2800 }
2801
2802 #[test]
2803 fn test_component_strip_runtime_attrs() {
2804 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2809 parser
2810 .component_registry
2811 .register_component(
2812 "custom-element",
2813 r#"<template @click={foo} :bar="baz" ?bool="true"><slot></slot></template>"#,
2814 None,
2815 )
2816 .expect("register");
2817 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2818 assert!(result.is_ok());
2819 let records = parser.into_fragment_records();
2820
2821 assert_stream!(
2822 records,
2823 "custom-element",
2824 [raw("<template><slot></slot></template>"),]
2825 );
2826 }
2827
2828 #[test]
2829 fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
2830 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2831 parser
2832 .component_registry
2833 .register_component(
2834 "custom-element",
2835 r#"<template data-note="@click={foo}" @click={foo}><slot></slot></template>"#,
2836 None,
2837 )
2838 .expect("register");
2839 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2840 assert!(result.is_ok());
2841 let records = parser.into_fragment_records();
2842
2843 assert_stream!(
2844 records,
2845 "custom-element",
2846 [raw(
2847 r#"<template data-note="@click={foo}"><slot></slot></template>"#
2848 ),]
2849 );
2850 }
2851
2852 #[test]
2853 fn test_component_strip_runtime_attrs_handles_duplicate_runtime_attrs() {
2854 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2855 parser
2856 .component_registry
2857 .register_component(
2858 "custom-element",
2859 r#"<template @click={foo} @click={bar}><slot></slot></template>"#,
2860 None,
2861 )
2862 .expect("register");
2863 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
2864 assert!(result.is_ok());
2865 let records = parser.into_fragment_records();
2866
2867 assert_stream!(
2868 records,
2869 "custom-element",
2870 [raw("<template><slot></slot></template>"),]
2871 );
2872 }
2873
2874 #[test]
2875 fn test_component_with_slots_and_attrs() {
2876 let mut parser = HtmlParser::new();
2877 parser
2878 .component_registry
2879 .register_component("custom-element", "<slot></slot>", None)
2880 .expect("register");
2881 let result = parser.parse(
2882 "index.html",
2883 r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
2884 );
2885 assert!(result.is_ok());
2886 let records = parser.into_fragment_records();
2887 assert_fragments!(
2888 records["index.html"].fragments,
2889 [
2890 raw("<custom-element"),
2891 attr_raw_start("appearance", "subtle"),
2892 raw(">"),
2893 component("custom-element"),
2894 raw("Hello World</custom-element>"),
2895 ]
2896 );
2897 }
2898
2899 #[test]
2900 fn test_component_legacy_no_styles() {
2901 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2902 parser
2903 .component_registry
2904 .register_component("custom-element", "<div>Custom Element</div>", None)
2905 .expect("register");
2906 let result = parser.parse("index.html", "<custom-element></custom-element>");
2907 assert!(result.is_ok());
2908 let records = parser.into_fragment_records();
2909
2910 assert_fragments!(
2911 records["index.html"].fragments,
2912 [
2913 raw("<custom-element>"),
2914 component("custom-element"),
2915 raw("</custom-element>"),
2916 ]
2917 );
2918
2919 assert_stream!(
2920 records,
2921 "custom-element",
2922 [raw("<div>Custom Element</div>"),]
2923 );
2924 }
2925
2926 #[test]
2927 fn test_component_self_closing() {
2928 let mut parser = HtmlParser::new();
2929 parser
2930 .component_registry
2931 .register_component("custom-widget", "<div>Widget Content</div>", None)
2932 .expect("register");
2933 let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
2934 assert!(result.is_ok());
2935 let records = parser.into_fragment_records();
2936
2937 assert_fragments!(
2938 records["index.html"].fragments,
2939 [
2940 raw("<custom-widget"),
2941 attr_start("config", "settings"),
2942 raw("/>"),
2943 component("custom-widget"),
2944 ]
2945 );
2946 }
2947
2948 #[test]
2949 fn test_component_nested_self_closing_in_slot() {
2950 let mut parser = HtmlParser::with_options(DomStrategy::Light);
2951 parser
2952 .component_registry
2953 .register_component("custom-icon", "<svg><slot></slot></svg>", None)
2954 .expect("register");
2955 let result = parser.parse(
2956 "index.html",
2957 r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
2958 );
2959 assert!(result.is_ok());
2960 let records = parser.into_fragment_records();
2961
2962 assert_fragments!(
2963 records["index.html"].fragments,
2964 [
2965 raw("<custom-icon>"),
2966 component("custom-icon"),
2967 raw("<use"),
2968 attr_template("href", "attr-1"),
2969 raw("/></custom-icon>"),
2970 ]
2971 );
2972
2973 assert_stream!(records, "custom-icon", [raw("<svg><slot></slot></svg>"),]);
2974 }
2975
2976 #[test]
2977 fn test_component_leading_boolean_attr_start() {
2978 let mut parser = HtmlParser::new();
2979 parser
2980 .component_registry
2981 .register_component("custom-element", "<slot></slot>", None)
2982 .expect("register");
2983 let result = parser.parse(
2984 "index.html",
2985 r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
2986 );
2987 assert!(result.is_ok());
2988 let records = parser.into_fragment_records();
2989
2990 assert_fragments!(
2991 records["index.html"].fragments,
2992 [
2993 raw("<custom-element"),
2994 bool_attr_start("disabled", "isDisabled"),
2996 attr_raw("title", "Hello"),
2998 raw(">"),
2999 component("custom-element"),
3000 raw("</custom-element>"),
3001 ]
3002 );
3003 }
3004
3005 #[test]
3006 fn test_component_boolean_predicate_preserves_condition_tree() {
3007 let mut parser = HtmlParser::new();
3008 parser
3009 .component_registry
3010 .register_component("custom-element", "<slot></slot>", None)
3011 .expect("register");
3012 let result = parser.parse(
3013 "index.html",
3014 r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
3015 );
3016 assert!(result.is_ok());
3017
3018 let records = parser.into_fragment_records();
3019 let fragments = &records["index.html"].fragments;
3020 match fragments
3021 .get(1)
3022 .and_then(|fragment| fragment.fragment.as_ref())
3023 {
3024 Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
3025 assert_eq!(attr.name, "disabled");
3026 assert!(attr.attr_start);
3027 match attr
3028 .condition_tree
3029 .as_ref()
3030 .and_then(|condition| condition.expr.as_ref())
3031 {
3032 Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
3033 assert_eq!(pred.left, "page");
3034 assert_eq!(pred.operator, 3);
3035 assert_eq!(pred.right, "'dashboard'");
3036 }
3037 other => panic!("expected predicate condition tree, got {:?}", other),
3038 }
3039 }
3040 other => panic!("expected attribute fragment, got {:?}", other),
3041 }
3042 }
3043
3044 #[test]
3045 fn test_component_meta_link_tags() {
3046 let (fragments, _) = parse_and_get_fragments(
3047 r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
3048 );
3049 assert!(fragments.len() >= 5);
3050 assert!(
3051 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<head><meta charset=\"utf-8\"") && raw.value.contains("<link"))
3052 );
3053 assert!(
3054 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
3055 );
3056 assert!(
3057 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
3058 );
3059 assert!(
3060 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if s.value == "head_end" && s.raw)
3061 );
3062 assert!(
3063 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
3064 );
3065 }
3066
3067 #[test]
3068 fn test_nested_directives() {
3069 let mut parser = HtmlParser::new();
3070 let html = r#"<for each="category in categories">
3071 <if condition="category.hasItems">
3072 <for each="item in category.items">
3073 {{item.title}}
3074 </for>
3075 </if>
3076 </for>"#;
3077
3078 let result = parser.parse("test.html", html);
3079
3080 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3081 let fragment_records = parser.into_fragment_records();
3082
3083 assert_stream!(
3084 fragment_records,
3085 "test.html",
3086 [for_loop("category", "categories", "for-1"),]
3087 );
3088
3089 assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
3090
3091 assert_stream!(
3092 fragment_records,
3093 "if-1",
3094 [for_loop("item", "category.items", "for-2"),]
3095 );
3096
3097 assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
3098 }
3099
3100 #[test]
3101 fn test_complex_directives() {
3102 let mut parser = HtmlParser::new();
3103 let html = r#"<for each="category in categories">
3104 <div class="category">
3105 <h2>{{category.name}}</h2>
3106 <if condition="category.hasItems">
3107 <ul>
3108 <for each="item in category.items">
3109 <li>{{item.title}}</li>
3110 </for>
3111 </ul>
3112 </if>
3113 </div>
3114 </for>"#;
3115
3116 let result = parser.parse("test.html", html);
3117
3118 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3119 let fragment_records = parser.into_fragment_records();
3120
3121 assert_stream!(
3122 fragment_records,
3123 "test.html",
3124 [for_loop("category", "categories", "for-1"),]
3125 );
3126
3127 assert_stream!(
3129 fragment_records,
3130 "for-1",
3131 [
3132 raw("<div class=\"category\"><h2>"),
3133 signal("category.name"),
3134 raw("</h2>"),
3135 if_cond("if-1"),
3136 raw("</div>"),
3137 ]
3138 );
3139
3140 assert_stream!(
3142 fragment_records,
3143 "if-1",
3144 [
3145 raw("<ul>"),
3146 for_loop("item", "category.items", "for-2"),
3147 raw("</ul>"),
3148 ]
3149 );
3150
3151 assert_stream!(
3153 fragment_records,
3154 "for-2",
3155 [raw("<li>"), signal("item.title"), raw("</li>"),]
3156 );
3157 }
3158
3159 fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
3163 let mut parser = HtmlParser::new();
3164 let result = parser.parse("index.html", html);
3165 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3166 let records = parser.into_fragment_records();
3167 let fragments = records
3168 .get("index.html")
3169 .expect("Failed to get index.html fragment")
3170 .fragments
3171 .clone();
3172 (fragments, records)
3173 }
3174
3175 fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
3177 let mut parser = HtmlParser::new();
3178 parser
3179 .component_registry
3180 .register_component(tag, "<div></div>", None)
3181 .expect("register");
3182 let result = parser.parse("index.html", html);
3183 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3184 let records = parser.into_fragment_records();
3185 let fragments = records
3186 .get("index.html")
3187 .expect("Failed to get index.html fragment")
3188 .fragments
3189 .clone();
3190 (fragments, records)
3191 }
3192
3193 #[test]
3194 fn test_attribute_handlebars_in_href() {
3195 let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
3197 assert_fragments!(
3198 fragments,
3199 [
3200 raw("<a"),
3201 attr("href", "url"),
3202 raw(">"),
3203 signal("name"),
3204 raw("</a>"),
3205 ]
3206 );
3207 }
3208
3209 #[test]
3210 fn test_attribute_boolean_with_handlebars() {
3211 let (fragments, _) =
3213 parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
3214 assert_fragments!(
3215 fragments,
3216 [
3217 raw("<button"),
3218 bool_attr("disabled", "isDisabled"),
3219 raw(">Click</button>"),
3220 ]
3221 );
3222 }
3223
3224 #[test]
3225 fn test_attribute_multiple_boolean() {
3226 let (fragments, _) =
3229 parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
3230
3231 assert_fragments!(
3232 fragments,
3233 [
3234 raw("<input"),
3235 bool_attr("checked", "isChecked"),
3236 bool_attr("disabled", "isDisabled"),
3237 raw("/>"),
3238 ]
3239 );
3240 }
3241
3242 #[test]
3243 fn test_attribute_boolean_and_regular_together() {
3244 let (fragments, _) = parse_and_get_fragments(
3247 r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
3248 );
3249
3250 assert_fragments!(
3251 fragments,
3252 [
3253 raw("<button"),
3254 bool_attr("disabled", "isDisabled"),
3255 raw(" type=\"button\">Hi</button>"),
3256 ]
3257 );
3258 }
3259
3260 #[test]
3261 fn test_attribute_boolean_sandwiched() {
3262 let (fragments, _) = parse_and_get_fragments(
3265 r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
3266 );
3267
3268 assert_fragments!(
3269 fragments,
3270 [
3271 raw("<button"),
3272 attr("version", "edition"),
3273 bool_attr("disabled", "isDisabled"),
3274 raw(" type=\"button\">Hi</button>"),
3275 ]
3276 );
3277 }
3278
3279 #[test]
3280 fn test_attribute_boolean_ending() {
3281 let (fragments, _) = parse_and_get_fragments(
3284 r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
3285 );
3286
3287 assert_fragments!(
3288 fragments,
3289 [
3290 raw("<button"),
3291 attr("version", "edition"),
3292 bool_attr("disabled", "isDisabled"),
3293 raw(">Hi</button>"),
3294 ]
3295 );
3296 }
3297
3298 #[test]
3299 fn test_attribute_boolean_dotted_path() {
3300 let (fragments, _) =
3303 parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
3304
3305 assert_fragments!(
3306 fragments,
3307 [
3308 raw("<div"),
3309 bool_attr("checked", "layout.isPinned"),
3310 raw(">Content</div>"),
3311 ]
3312 );
3313 }
3314
3315 #[test]
3316 fn test_attribute_colon_prefixed_complex() {
3317 let (fragments, _) = parse_with_component(
3320 "my-component",
3321 r#"<my-component :config="{{settings}}"></my-component>"#,
3322 );
3323
3324 assert_fragments!(
3325 fragments,
3326 [
3327 raw("<my-component"),
3328 attr_complex_start(":config", "settings"),
3329 raw(">"),
3330 component("my-component"),
3331 raw("</my-component>"),
3332 ]
3333 );
3334 }
3335
3336 #[test]
3337 fn test_attribute_multiple_colon_prefixed() {
3338 let (fragments, _) = parse_with_component(
3341 "my-component",
3342 r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
3343 );
3344
3345 assert_fragments!(
3346 fragments,
3347 [
3348 raw("<my-component"),
3349 attr_complex_start(":prop1", "val1"),
3350 attr_complex(":prop2", "val2"),
3351 raw(">"),
3352 component("my-component"),
3353 raw("</my-component>"),
3354 ]
3355 );
3356 }
3357
3358 #[test]
3359 fn test_blocked_complex_property_innerhtml() {
3360 let mut parser = HtmlParser::new();
3361 let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
3362 assert!(
3363 result.is_err(),
3364 "Expected error for :innerHTML on native element"
3365 );
3366 let err = result.unwrap_err().to_string();
3367 assert!(
3368 err.contains("only allowed on custom elements"),
3369 "Error: {err}"
3370 );
3371 }
3372
3373 #[test]
3374 fn test_blocked_complex_on_native_element() {
3375 let mut parser = HtmlParser::new();
3376 let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
3377 assert!(
3378 result.is_err(),
3379 "Expected error for :data on native element"
3380 );
3381 let err = result.unwrap_err().to_string();
3382 assert!(
3383 err.contains("only allowed on custom elements"),
3384 "Error: {err}"
3385 );
3386 }
3387
3388 #[test]
3389 fn test_blocked_complex_property_on_component() {
3390 let mut parser = HtmlParser::new();
3391 parser
3392 .component_registry
3393 .register_component("my-widget", "<div></div>", None)
3394 .expect("register");
3395 let result = parser.parse(
3396 "index.html",
3397 r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
3398 );
3399 assert!(
3400 result.is_err(),
3401 "Expected error for :innerHTML on component"
3402 );
3403 let err = result.unwrap_err().to_string();
3404 assert!(err.contains("HTML injection"), "Error: {err}");
3405 }
3406
3407 #[test]
3408 fn test_allowed_complex_property() {
3409 let (fragments, _) = parse_with_component(
3411 "my-component",
3412 r#"<my-component :config="{{settings}}"></my-component>"#,
3413 );
3414 assert_fragments!(
3415 fragments,
3416 [
3417 raw("<my-component"),
3418 attr_complex_start(":config", "settings"),
3419 raw(">"),
3420 component("my-component"),
3421 raw("</my-component>"),
3422 ]
3423 );
3424 }
3425
3426 #[test]
3427 fn test_attribute_mixed_normal_boolean_colon() {
3428 let (fragments, _) = parse_with_component(
3431 "my-component",
3432 r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
3433 );
3434
3435 assert_fragments!(
3436 fragments,
3437 [
3438 raw("<my-component"),
3439 attr_raw_start("id", "comp"),
3440 attr_complex(":config", "settings"),
3441 bool_attr("enabled", "isEnabled"),
3442 raw(">"),
3443 component("my-component"),
3444 raw("</my-component>"),
3445 ]
3446 );
3447 }
3448
3449 #[test]
3450 fn test_attribute_reject_boolean_without_handlebars() {
3451 let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
3454
3455 assert_fragments!(fragments, [raw("<button></button>"),]);
3457 }
3458
3459 #[test]
3460 fn test_attribute_reject_boolean_with_partial_handlebars() {
3461 let (fragments, _) =
3464 parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
3465
3466 assert_fragments!(fragments, [raw("<button></button>"),]);
3468 }
3469
3470 #[test]
3471 fn test_attribute_reject_boolean_with_plain_value() {
3472 let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
3475
3476 assert_fragments!(fragments, [raw("<button>Click</button>"),]);
3478 }
3479
3480 #[test]
3481 fn test_attribute_boolean_predicate_equal() {
3482 let (fragments, _) =
3484 parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
3485 assert_fragments!(
3486 fragments,
3487 [
3488 raw("<div"),
3489 bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
3490 raw(">X</div>"),
3491 ]
3492 );
3493 }
3494
3495 #[test]
3496 fn test_attribute_boolean_predicate_greater_than() {
3497 let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
3499 assert_fragments!(
3500 fragments,
3501 [
3502 raw("<span"),
3503 bool_attr_predicate("hidden", "num", 1, "9"),
3504 raw(">X</span>"),
3505 ]
3506 );
3507 }
3508
3509 #[test]
3510 fn test_attribute_boolean_predicate_not_equal() {
3511 let (fragments, _) =
3513 parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
3514 assert_fragments!(
3515 fragments,
3516 [
3517 raw("<a"),
3518 bool_attr_predicate("data-active", "status", 4, "'inactive'"),
3519 raw(">X</a>"),
3520 ]
3521 );
3522 }
3523
3524 #[test]
3525 fn test_attribute_boolean_negation() {
3526 let (fragments, _) =
3528 parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
3529 assert_fragments!(
3530 fragments,
3531 [
3532 raw("<button"),
3533 bool_attr_not("disabled", "isReady"),
3534 raw(">X</button>"),
3535 ]
3536 );
3537 }
3538
3539 #[test]
3540 fn test_attribute_mixed_static_dynamic() {
3541 let (fragments, records) =
3544 parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
3545
3546 assert_fragments!(
3547 fragments,
3548 [
3549 raw("<textarea"),
3550 attr_template("value", "attr-1"),
3551 raw(">Hi</textarea>"),
3552 ]
3553 );
3554
3555 assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
3557 }
3558
3559 #[test]
3562 fn test_body_signals() {
3563 let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
3564 assert_fragments!(
3565 fragments,
3566 [
3567 raw("<body>"),
3568 signal_raw("body_start"),
3569 raw("<app-shell></app-shell>"),
3570 signal_raw("body_end"),
3571 raw("</body>"),
3572 ]
3573 );
3574 }
3575
3576 #[test]
3579 fn test_empty_for_produces_nothing() {
3580 let (fragments, records) =
3581 parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
3582 assert_fragments!(fragments, [raw("<div></div>"),]);
3583 assert!(!records.contains_key("for-1"));
3584 }
3585
3586 #[test]
3589 fn test_self_closing_svg_path() {
3590 let (fragments, _) =
3591 parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
3592 assert_fragments!(
3593 fragments,
3594 [raw(
3595 r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
3596 ),]
3597 );
3598 }
3599
3600 #[test]
3601 fn test_html5_void_elements() {
3602 let (fragments, _) = parse_and_get_fragments(
3603 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
3604 );
3605 assert_fragments!(
3606 fragments,
3607 [raw(
3608 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
3609 ),]
3610 );
3611 }
3612
3613 #[test]
3614 fn test_self_closing_with_dynamic_attributes() {
3615 let (fragments, _) =
3616 parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
3617 assert_fragments!(
3618 fragments,
3619 [
3620 raw("<img"),
3621 attr("src", "imageUrl"),
3622 attr("alt", "imageAlt"),
3623 raw("/>"),
3624 ]
3625 );
3626 }
3627
3628 #[test]
3629 fn test_self_closing_with_boolean_attributes() {
3630 let (fragments, _) = parse_and_get_fragments(
3631 r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
3632 );
3633 assert_fragments!(
3634 fragments,
3635 [
3636 raw("<input type=\"checkbox\""),
3637 bool_attr("checked", "isSelected"),
3638 bool_attr("disabled", "isDisabled"),
3639 raw("/>"),
3640 ]
3641 );
3642 }
3643
3644 #[test]
3645 fn test_multiple_self_closing_in_sequence() {
3646 let (fragments, _) =
3647 parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
3648 assert_fragments!(
3649 fragments,
3650 [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
3651 );
3652 }
3653
3654 #[test]
3655 fn test_self_closing_with_mixed_content() {
3656 let (fragments, _) =
3657 parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
3658 assert_fragments!(
3659 fragments,
3660 [
3661 raw("<div>Text before<img"),
3662 attr("src", "url"),
3663 raw("/>Text after</div>"),
3664 ]
3665 );
3666 }
3667
3668 #[test]
3669 fn test_self_closing_svg_elements() {
3670 let (fragments, _) = parse_and_get_fragments(
3671 r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
3672 );
3673 assert_fragments!(
3674 fragments,
3675 [
3676 raw("<svg><circle"),
3677 attr("cx", "x"),
3678 attr("cy", "y"),
3679 raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
3680 ]
3681 );
3682 }
3683
3684 #[test]
3685 fn test_self_closing_inside_for_loop() {
3686 let (fragments, records) = parse_and_get_fragments(
3687 r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
3688 );
3689 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
3690 assert_stream!(
3691 records,
3692 "for-1",
3693 [raw("<img"), attr("src", "item.url"), raw("/>"),]
3694 );
3695 }
3696
3697 #[test]
3698 fn test_self_closing_whitespace_variations() {
3699 let (fragments, _) =
3700 parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
3701 assert_fragments!(
3702 fragments,
3703 [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
3704 );
3705 }
3706
3707 #[test]
3708 fn test_deeply_nested_self_closing() {
3709 let (fragments, _) = parse_and_get_fragments(
3710 r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
3711 );
3712 assert_fragments!(
3713 fragments,
3714 [raw(
3715 r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
3716 ),]
3717 );
3718 }
3719
3720 #[test]
3721 fn test_self_closing_vs_empty_regular_tags() {
3722 let (fragments, _) =
3723 parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
3724 assert_fragments!(
3725 fragments,
3726 [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
3727 );
3728 }
3729
3730 #[test]
3733 fn test_for_custom_template_attribute() {
3734 let (fragments, records) = parse_and_get_fragments(
3736 r#"<for each="item in items" template="static"><span>Item</span></for>"#,
3737 );
3738 assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
3739 assert_stream!(records, "static", [raw("<span>Item</span>"),]);
3740 }
3741
3742 #[test]
3743 fn test_for_recursive_template() {
3744 let mut parser = HtmlParser::new();
3746 let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
3747 let result = parser.parse("index.html", html);
3748 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3749 let records = parser.into_fragment_records();
3750
3751 assert_fragments!(
3752 records["index.html"].fragments,
3753 [for_loop("outerItem", "outerItems", "static"),]
3754 );
3755
3756 assert_stream!(
3757 records,
3758 "static",
3759 [
3760 raw("<div><span>"),
3761 signal("outerItem.name"),
3762 raw("</span>"),
3763 for_loop("innerItem", "innerItems", "static"),
3764 raw("</div>"),
3765 ]
3766 );
3767 }
3768
3769 #[test]
3772 fn test_if_multiple_children() {
3773 let (fragments, records) =
3775 parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
3776 assert_fragments!(fragments, [if_cond("if-1"),]);
3777 assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
3778 }
3779
3780 #[test]
3781 fn test_for_multiple_children() {
3782 let (fragments, records) =
3784 parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
3785 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
3786 assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
3787 }
3788
3789 #[test]
3792 fn test_handlebars_at_beginning() {
3793 let (fragments, _) = parse_and_get_fragments("{{first}}");
3795 assert_fragments!(fragments, [signal("first"),]);
3796 }
3797
3798 #[test]
3799 fn test_handlebars_at_beginning_and_raw() {
3800 let (fragments, _) = parse_and_get_fragments("{{first}}test");
3802 assert_fragments!(fragments, [signal("first"), raw("test"),]);
3803 }
3804
3805 #[test]
3806 fn test_handlebars_raw_and_end() {
3807 let (fragments, _) = parse_and_get_fragments("test{{first}}");
3809 assert_fragments!(fragments, [raw("test"), signal("first"),]);
3810 }
3811
3812 #[test]
3815 fn test_handlebars_invalid_triple_open() {
3816 let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
3818 assert_fragments!(fragments, [raw("{{{invalid}}"),]);
3819 }
3820
3821 #[test]
3822 fn test_handlebars_four_open_braces() {
3823 let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
3825 assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
3826 }
3827
3828 #[test]
3829 fn test_handlebars_five_open_with_valid_double() {
3830 let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
3832 assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
3833 }
3834
3835 #[test]
3836 fn test_entities_preserved() {
3837 let (fragments, _) = parse_and_get_fragments("<p>Hello}World</p>");
3839 assert_fragments!(fragments, [raw("<p>Hello}World</p>"),]);
3840 }
3841
3842 #[test]
3845 fn test_doctype_preserved() {
3846 let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
3848 assert!(!fragments.is_empty());
3849 assert!(
3850 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
3851 );
3852 }
3853
3854 #[test]
3857 fn test_component_attr_skip() {
3858 let mut parser = HtmlParser::new();
3860 parser
3861 .component_registry
3862 .register_component("custom-element", "<slot></slot>", None)
3863 .expect("register");
3864 let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
3865 let result = parser.parse("index.html", html);
3866 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3867 let records = parser.into_fragment_records();
3868
3869 assert_fragments!(
3872 records["index.html"].fragments,
3873 [
3874 raw("<custom-element"),
3875 attr_complex_start(":config", "config"),
3877 attr_skip("class", "value0"),
3879 attr_skip("style", "value1"),
3880 attr_skip("role", "value2"),
3881 attr_skip("data-test", "value3"),
3882 attr_skip("aria-test", "value4"),
3883 raw(">"),
3884 component("custom-element"),
3885 raw("</custom-element>"),
3886 ]
3887 );
3888 }
3889
3890 #[test]
3891 fn test_component_attr_skip_static_and_embedded() {
3892 let mut parser = HtmlParser::new();
3895 parser
3896 .component_registry
3897 .register_component("item-group", "<slot></slot>", None)
3898 .expect("register");
3899
3900 let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
3901 let result = parser.parse("index.html", html);
3902 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3903 let records = parser.into_fragment_records();
3904
3905 assert_fragments!(
3906 records["index.html"].fragments,
3907 [
3908 raw("<item-group"),
3909 attr_skip_raw("role", "list"),
3910 attr_skip_template("aria-labelledby", "attr-1"),
3911 attr_skip_template("data-testid", "attr-2"),
3912 attr_skip_raw("class", "fixed-class"),
3913 raw(">"),
3914 component("item-group"),
3915 raw("</item-group>"),
3916 ]
3917 );
3918
3919 assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
3922 assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
3923 }
3924
3925 #[test]
3926 fn test_component_multiple_nested() {
3927 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3929 parser
3930 .component_registry
3931 .register_component(
3932 "custom-element",
3933 "<custom-child></custom-child><slot></slot>",
3934 None,
3935 )
3936 .expect("register");
3937 parser
3938 .component_registry
3939 .register_component("custom-button", "<slot></slot>", None)
3940 .expect("register");
3941 parser
3942 .component_registry
3943 .register_component("custom-child", "<h1>Hello World!</h1>", None)
3944 .expect("register");
3945
3946 let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
3947 let result = parser.parse("index.html", html);
3948 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3949 let records = parser.into_fragment_records();
3950
3951 assert_fragments!(
3953 records["index.html"].fragments,
3954 [for_loop("item", "items", "for-1"),]
3955 );
3956
3957 assert_stream!(
3959 records,
3960 "for-1",
3961 [
3962 raw("<custom-element>"),
3963 component("custom-element"),
3964 raw("<custom-button>"),
3965 component("custom-button"),
3966 raw("Ok</custom-button></custom-element>"),
3967 ]
3968 );
3969
3970 let ce = &records["custom-element"].fragments;
3972 assert_eq!(ce.len(), 3);
3973 assert!(
3974 matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child>"))
3975 );
3976 assert!(
3977 matches!(ce[1].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
3978 );
3979 assert!(
3980 matches!(ce[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><slot></slot>"))
3981 );
3982
3983 assert_stream!(records, "custom-button", [raw("<slot></slot>"),]);
3984
3985 assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
3986 }
3987
3988 #[test]
3991 fn test_invalid_markup_returns_error() {
3992 let mut parser = HtmlParser::new();
3994 let result = parser.parse("index.html", "<div><span>Unclosed div");
3995 assert!(matches!(result, Err(ParserError::Template(diag)) if
3996 diag.to_string().contains("unclosed <div> tag")
3997 ));
3998 }
3999
4000 #[test]
4001 fn test_unterminated_opening_tag_returns_error() {
4002 let mut parser = HtmlParser::new();
4003 let result = parser.parse("index.html", "before <div");
4004 assert!(matches!(result, Err(ParserError::Template(diag)) if
4005 diag.to_string().contains("malformed HTML tag")
4006 ));
4007 }
4008
4009 #[test]
4010 fn test_unterminated_html_comment_returns_error() {
4011 let mut parser = HtmlParser::new();
4012 let result = parser.parse("index.html", "<div><!-- missing close</div>");
4013 assert!(matches!(result, Err(ParserError::Template(diag)) if
4014 diag.to_string().contains("unclosed <div> tag")
4015 || diag.to_string().contains("unterminated HTML comment")
4016 ));
4017 }
4018
4019 #[test]
4020 fn test_unexpected_closing_tag_returns_error() {
4021 let mut parser = HtmlParser::new();
4022 let result = parser.parse("index.html", "</span>");
4023 assert!(matches!(result, Err(ParserError::Template(diag)) if
4024 diag.to_string().contains("unexpected closing tag </span>")
4025 ));
4026 }
4027
4028 #[test]
4029 fn test_rejects_templates_over_size_limit() {
4030 let mut parser = HtmlParser::new();
4031 let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
4032 let result = parser.parse("index.html", &html);
4033
4034 assert!(matches!(result, Err(ParserError::Html(message)) if
4035 message.contains("exceeds") && message.contains("parser limit")
4036 ));
4037 }
4038
4039 #[test]
4040 fn test_rejects_templates_over_depth_limit() {
4041 let mut parser = HtmlParser::new();
4042 let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
4043 for _ in 0..=MAX_TEMPLATE_DEPTH {
4044 html.push_str("<div>");
4045 }
4046 for _ in 0..=MAX_TEMPLATE_DEPTH {
4047 html.push_str("</div>");
4048 }
4049
4050 let result = parser.parse("index.html", &html);
4051
4052 assert!(matches!(result, Err(ParserError::Template(diag)) if
4053 diag.to_string().contains("nesting exceeds")
4054 ));
4055 }
4056
4057 #[test]
4058 fn test_rejects_routes_over_depth_limit() {
4059 let mut parser = HtmlParser::new();
4060 parser
4061 .component_registry
4062 .register_component("route-page", "<slot></slot>", None)
4063 .expect("register");
4064
4065 let depth = MAX_TEMPLATE_DEPTH + 2;
4066 let mut html = String::with_capacity(depth * 48);
4067 for _ in 0..depth {
4068 html.push_str(r#"<route path="x" component="route-page">"#);
4069 }
4070 for _ in 0..depth {
4071 html.push_str("</route>");
4072 }
4073
4074 let result = parser.parse("index.html", &html);
4075
4076 assert!(matches!(result, Err(ParserError::Template(diag)) if
4077 diag.error_code() == Some(codes::EXCESSIVE_NESTING)
4078 && diag.to_string().contains("route nesting exceeds")
4079 ));
4080 }
4081
4082 #[test]
4083 fn test_rejects_recursive_component_templates() {
4084 let mut parser = HtmlParser::new();
4085 parser
4086 .component_registry
4087 .register_component("self-card", "<self-card></self-card>", None)
4088 .expect("register");
4089
4090 let result = parser.parse("index.html", "<self-card></self-card>");
4091
4092 assert!(matches!(result, Err(ParserError::Template(diag)) if
4093 diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
4094 && diag.to_string().contains("recursive template reference")
4095 ));
4096 }
4097
4098 #[test]
4101 fn test_complex_raw_text_full_page() {
4102 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>"#;
4104 let (fragments, _) = parse_and_get_fragments(html);
4105
4106 assert!(fragments.len() >= 7);
4108 assert!(
4109 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4110 raw.value.contains("<!DOCTYPE HTML>") && raw.value.contains("<title>Test</title>"))
4111 );
4112 assert!(
4113 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
4114 s.value == "head_end" && s.raw)
4115 );
4116 assert!(
4117 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4118 raw.value.contains("</head>") && raw.value.ends_with("<body>"))
4119 );
4120 assert!(
4121 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
4122 s.value == "body_start" && s.raw)
4123 );
4124 assert!(
4125 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4126 raw.value.contains("<app-shell>"))
4127 );
4128 assert!(
4129 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
4130 s.value == "body_end" && s.raw)
4131 );
4132 assert!(
4133 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4134 raw.value.contains("</body>") && raw.value.contains("</html>"))
4135 );
4136 }
4137
4138 #[test]
4139 fn test_css_strategy_external_emits_link_tag() {
4140 let mut parser = HtmlParser::new();
4141 parser
4142 .component_registry_mut()
4143 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
4144 .ok();
4145 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4146 let records = parser.into_fragment_records();
4147 let my_card = &records["my-card"].fragments;
4148 let raw_text: String = my_card
4149 .iter()
4150 .filter_map(|f| match &f.fragment {
4151 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4152 _ => None,
4153 })
4154 .collect();
4155 assert!(
4156 raw_text.contains(r#"<link rel="stylesheet" href="my-card.css">"#),
4157 "Expected external <link> tag in: {}",
4158 raw_text
4159 );
4160 }
4161
4162 #[test]
4163 fn test_uppercase_style_element_is_processed_as_css() {
4164 let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
4169 assert!(
4170 fragments.iter().any(|f| matches!(
4171 f.fragment.as_ref(),
4172 Some(Fragment::Signal(s)) if s.value == "tokens" && !s.raw
4173 )),
4174 "uppercase <STYLE> should be CSS-processed, got: {:?}",
4175 fragments
4176 );
4177 let raw: String = fragments
4178 .iter()
4179 .filter_map(|f| match f.fragment.as_ref() {
4180 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4181 _ => None,
4182 })
4183 .collect();
4184 assert!(
4185 raw.contains("</STYLE>"),
4186 "closing tag should preserve source case, got: {raw}"
4187 );
4188 }
4189
4190 #[test]
4191 fn test_css_strategy_inline_emits_style_tag() {
4192 let mut parser = HtmlParser::with_options(CssStrategy::Style);
4193 parser
4194 .component_registry_mut()
4195 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
4196 .ok();
4197 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4198 let records = parser.into_fragment_records();
4199 let my_card = &records["my-card"].fragments;
4200 let raw_text: String = my_card
4201 .iter()
4202 .filter_map(|f| match &f.fragment {
4203 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4204 _ => None,
4205 })
4206 .collect();
4207 assert!(
4208 raw_text.contains("<style>p { color: red; }</style>"),
4209 "Expected inline <style> tag in: {}",
4210 raw_text
4211 );
4212 assert!(
4213 !raw_text.contains("<link"),
4214 "Should not have <link> tag in inline mode: {}",
4215 raw_text
4216 );
4217 }
4218
4219 #[test]
4220 fn test_css_strategy_module_emits_adopted_stylesheets() {
4221 let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
4222 parser
4223 .component_registry_mut()
4224 .register_component("my-card", "<p><slot></slot></p>", Some("p { color: red; }"))
4225 .ok();
4226 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4227 let records = parser.into_fragment_records();
4228
4229 let my_card = &records["my-card"].fragments;
4232 let template_text: String = my_card
4233 .iter()
4234 .filter_map(|f| match &f.fragment {
4235 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4236 _ => None,
4237 })
4238 .collect();
4239 assert!(
4240 !template_text.contains("shadowrootadoptedstylesheets"),
4241 "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
4242 );
4243 assert!(
4244 !template_text.contains("<link"),
4245 "Should not have <link> in module mode: {template_text}"
4246 );
4247 assert!(
4251 !template_text.contains(r#"<style type="module""#),
4252 "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
4253 );
4254 assert!(
4255 !template_text.contains(r#"<script type="importmap""#),
4256 "CSS module importmap should NOT be in raw fragments: {template_text}"
4257 );
4258 }
4259
4260 #[test]
4261 fn test_css_strategy_module_no_css_no_adopted_attr() {
4262 let mut parser = HtmlParser::with_options(CssStrategy::Module);
4263 parser
4264 .component_registry_mut()
4265 .register_component("my-card", "<p><slot></slot></p>", None)
4266 .ok();
4267 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4268 let records = parser.into_fragment_records();
4269 let my_card = &records["my-card"].fragments;
4270 let template_text: String = my_card
4271 .iter()
4272 .filter_map(|f| match &f.fragment {
4273 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4274 _ => None,
4275 })
4276 .collect();
4277 assert!(
4279 !template_text.contains("shadowrootadoptedstylesheets"),
4280 "Should not have adopted attr without CSS: {template_text}"
4281 );
4282 }
4283
4284 #[test]
4303 fn light_preserves_dev_template_with_static_attrs() {
4304 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4305 let processed = parser
4306 .process_component_template(
4307 r#"<template foo="bar"><div>hi</div></template>"#,
4308 None,
4309 None,
4310 )
4311 .expect("process failed");
4312 assert!(
4313 processed.contains(r#"<template foo="bar">"#),
4314 "[--dom=light] expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
4315 );
4316 assert!(
4317 processed.contains("</template>"),
4318 "[--dom=light] expected closing </template> preserved, got: {processed}"
4319 );
4320 }
4321
4322 #[test]
4323 fn light_preserves_dev_template_with_signal_fragment_attrs() {
4324 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4325 let processed = parser
4326 .process_component_template(
4327 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
4328 None,
4329 None,
4330 )
4331 .expect("process failed");
4332 assert!(
4333 processed.contains(r#"<template foo="{{foo}}">"#),
4334 "[--dom=light] expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
4335 );
4336 }
4337
4338 #[test]
4339 fn light_preserves_dev_template_with_multiple_attrs() {
4340 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4341 let processed = parser.process_component_template(
4342 r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
4343 None,
4344 None,
4345 )
4346 .expect("process failed");
4347 assert!(
4348 processed.contains("autofocus")
4349 && processed.contains(r#"tabindex="0""#)
4350 && processed.contains(r#"role="region""#)
4351 && processed.contains(r#"data-x="y""#),
4352 "[--dom=light] expected ALL dev template attrs preserved, got: {processed}"
4353 );
4354 }
4355
4356 #[test]
4357 fn light_no_template_no_wrapper_added() {
4358 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4359 let processed = parser
4360 .process_component_template("<div>hi</div>", None, None)
4361 .expect("process failed");
4362 assert!(
4363 !processed.contains("<template"),
4364 "[--dom=light] framework must NOT add <template> wrapper when dev omits one, got: {processed}"
4365 );
4366 assert!(
4367 processed.contains("<div>hi</div>"),
4368 "[--dom=light] expected inner content emitted as-is, got: {processed}"
4369 );
4370 }
4371
4372 #[test]
4373 fn shadow_preserves_dev_template_with_static_attrs() {
4374 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4375 let processed = parser
4376 .process_component_template(
4377 r#"<template foo="bar"><div>hi</div></template>"#,
4378 None,
4379 None,
4380 )
4381 .expect("process failed");
4382 assert!(
4383 processed.contains(r#"<template foo="bar">"#),
4384 "[--dom=shadow] dev <template foo=\"bar\"> must be preserved verbatim, got: {processed}"
4385 );
4386 assert!(
4387 !processed.contains(r#"shadowrootmode="open""#),
4388 "[--dom=shadow] framework must NOT inject shadowrootmode when dev already supplied a <template>, got: {processed}"
4389 );
4390 }
4391
4392 #[test]
4393 fn shadow_preserves_dev_template_with_shadowrootmode_closed() {
4394 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4395 let processed = parser
4396 .process_component_template(
4397 r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
4398 None,
4399 None,
4400 )
4401 .expect("process failed");
4402 assert!(
4403 processed.contains(r#"shadowrootmode="closed""#),
4404 "[--dom=shadow] framework must respect dev's shadowrootmode=\"closed\" (developer is managing), got: {processed}"
4405 );
4406 assert!(
4407 !processed.contains(r#"shadowrootmode="open""#),
4408 "[--dom=shadow] framework must not overwrite dev's shadowrootmode with \"open\", got: {processed}"
4409 );
4410 }
4411
4412 #[test]
4413 fn shadow_preserves_dev_template_with_signal_fragment_attrs() {
4414 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4415 let processed = parser
4416 .process_component_template(
4417 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
4418 None,
4419 None,
4420 )
4421 .expect("process failed");
4422 assert!(
4423 processed.contains(r#"<template foo="{{foo}}">"#),
4424 "[--dom=shadow] dev <template> with signal-fragment attr must be preserved, got: {processed}"
4425 );
4426 }
4427
4428 #[test]
4429 fn shadow_adds_template_wrapper_when_dev_omits_it() {
4430 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4431 let processed = parser
4432 .process_component_template("<div>hi</div>", None, None)
4433 .expect("process failed");
4434 assert!(
4435 processed.contains(r#"<template shadowrootmode="open""#),
4436 "[--dom=shadow] framework MUST add <template shadowrootmode=\"open\"> when dev omits a wrapper, got: {processed}"
4437 );
4438 assert!(
4439 processed.contains("<div>hi</div>"),
4440 "[--dom=shadow] inner content must survive wrapping, got: {processed}"
4441 );
4442 assert!(
4443 processed.contains("</template>"),
4444 "[--dom=shadow] framework-added wrapper must be closed, got: {processed}"
4445 );
4446 }
4447
4448 #[test]
4461 fn dev_template_module_strategy_errors_when_attr_missing() {
4462 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4463 let err = parser
4464 .process_component_template(
4465 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
4466 None,
4467 Some("my-comp"),
4468 )
4469 .expect_err("expected ParserError::MissingAdoptedStylesheets");
4470 match err {
4471 ParserError::MissingAdoptedStylesheets { tag } => {
4472 assert_eq!(tag, "my-comp");
4473 }
4474 other => panic!("expected MissingAdoptedStylesheets, got: {other:?}"),
4475 }
4476 }
4477
4478 #[test]
4479 fn dev_template_module_strategy_error_message_is_actionable() {
4480 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4481 let err = parser
4482 .process_component_template(
4483 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
4484 None,
4485 Some("mp-app"),
4486 )
4487 .expect_err("expected ParserError::MissingAdoptedStylesheets");
4488 let msg = err.to_string();
4489 assert!(msg.contains("<mp-app>"), "missing tag name: {msg}");
4493 assert!(
4494 msg.contains(r#"shadowrootadoptedstylesheets="mp-app""#),
4495 "missing copy-pasteable fix: {msg}"
4496 );
4497 assert!(
4498 msg.contains("--css=module"),
4499 "missing strategy context: {msg}"
4500 );
4501 assert!(
4502 msg.contains("remove the <template> wrapper"),
4503 "missing opt-out hint: {msg}"
4504 );
4505 }
4506
4507 #[test]
4508 fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
4509 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4510 let processed = parser
4511 .process_component_template(
4512 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
4513 None,
4514 Some("my-comp"),
4515 )
4516 .unwrap();
4517 assert!(
4518 processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
4519 "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
4520 );
4521 assert_eq!(
4522 processed.matches("shadowrootadoptedstylesheets").count(),
4523 1,
4524 "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
4525 );
4526 }
4527
4528 #[test]
4529 fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
4530 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4535 let processed = parser
4536 .process_component_template(
4537 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
4538 None,
4539 Some("my-comp"),
4540 )
4541 .unwrap();
4542 assert!(
4543 processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
4544 "dev's multi-specifier list must be preserved verbatim, got: {processed}"
4545 );
4546 }
4547
4548 #[test]
4549 fn dev_template_no_adopted_specifier_does_not_validate() {
4550 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4554 let processed = parser
4555 .process_component_template(
4556 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
4557 None,
4558 None,
4559 )
4560 .unwrap();
4561 assert!(
4562 !processed.contains("shadowrootadoptedstylesheets"),
4563 "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
4564 );
4565 }
4566
4567 #[test]
4579 fn test_body_start_end_injection() {
4580 let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
4584 let (fragments, _) = parse_and_get_fragments(html);
4585
4586 assert_fragments!(
4587 fragments,
4588 [
4589 raw("<html><head><title>Test</title>"),
4590 signal_raw("head_end"),
4591 raw("</head><body>"),
4592 signal_raw("body_start"),
4593 raw("<div>Content</div><p>More</p>"),
4594 signal_raw("body_end"),
4595 raw("</body></html>"),
4596 ]
4597 );
4598 }
4599
4600 #[test]
4601 fn test_body_preserves_static_attributes() {
4602 let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
4607 let (fragments, _) = parse_and_get_fragments(html);
4608
4609 assert_fragments!(
4610 fragments,
4611 [
4612 raw("<html><head><title>T</title>"),
4613 signal_raw("head_end"),
4614 raw(r#"</head><body data-layout="doc" class="page">"#),
4615 signal_raw("body_start"),
4616 raw("<p>x</p>"),
4617 signal_raw("body_end"),
4618 raw("</body></html>"),
4619 ]
4620 );
4621 }
4622
4623 #[test]
4624 fn test_body_preserves_dynamic_attributes() {
4625 let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
4629 let (fragments, _) = parse_and_get_fragments(html);
4630
4631 assert_fragments!(
4632 fragments,
4633 [
4634 raw("<html><head><title>T</title>"),
4635 signal_raw("head_end"),
4636 raw("</head><body"),
4637 attr("data-layout", "layout"),
4638 raw(">"),
4639 signal_raw("body_start"),
4640 raw("<p>x</p>"),
4641 signal_raw("body_end"),
4642 raw("</body></html>"),
4643 ]
4644 );
4645 }
4646
4647 #[test]
4648 fn test_fail_with_invalid_markup() {
4649 let mut parser = HtmlParser::new();
4651 let result = parser.parse("index.html", "<div><span></div></span>");
4652
4653 assert!(matches!(result, Err(ParserError::Template(diag)) if
4654 diag.to_string().contains("unclosed <span> tag")
4655 ));
4656 }
4657
4658 #[test]
4659 fn test_complex_raw_text_page() {
4660 let html = concat!(
4663 "<!DOCTYPE html>",
4664 "<html lang=\"en\">",
4665 "<head>",
4666 "<meta charset=\"utf-8\">",
4667 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
4668 "<title>Complex Page</title>",
4669 "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
4670 "<link rel=\"stylesheet\" href=\"styles.css\">",
4671 "</head>",
4672 "<body>",
4673 "<h1>Hello World</h1>",
4674 "<script type=\"module\" src=\"./app.js\"></script>",
4675 "</body>",
4676 "</html>",
4677 );
4678 let (fragments, _) = parse_and_get_fragments(html);
4679
4680 assert!(
4683 fragments.len() >= 7,
4684 "Expected at least 7 fragments, got {}",
4685 fragments.len()
4686 );
4687
4688 assert!(
4690 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4691 raw.value.contains("<!DOCTYPE html>") &&
4692 raw.value.contains("<meta charset=\"utf-8\">") &&
4693 raw.value.contains("<meta name=\"viewport\"") &&
4694 raw.value.contains("<title>Complex Page</title>") &&
4695 raw.value.contains("<style>") &&
4696 raw.value.contains("body { margin: 0; padding: 0; }")),
4697 "First fragment should contain all head content, got: {:?}",
4698 fragments[0]
4699 );
4700
4701 assert!(
4703 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
4704 s.value == "head_end" && s.raw),
4705 "Second fragment should be head_end signal"
4706 );
4707
4708 assert!(
4710 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4711 raw.value.contains("</head>") && raw.value.ends_with("<body>")),
4712 "Third fragment should contain </head><body>"
4713 );
4714
4715 assert!(
4717 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
4718 s.value == "body_start" && s.raw),
4719 "Fourth fragment should be body_start signal"
4720 );
4721
4722 assert!(
4724 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4725 raw.value.contains("<h1>Hello World</h1>") &&
4726 raw.value.contains("<script")),
4727 "Fifth fragment should contain body content"
4728 );
4729
4730 assert!(
4732 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
4733 s.value == "body_end" && s.raw),
4734 "Sixth fragment should be body_end signal"
4735 );
4736
4737 assert!(
4739 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4740 raw.value.contains("</body>") && raw.value.contains("</html>")),
4741 "Seventh fragment should contain closing tags"
4742 );
4743 }
4744
4745 struct BindingCountPlugin {
4749 counts: Vec<u32>,
4750 }
4751
4752 impl BindingCountPlugin {
4753 fn new() -> Self {
4754 Self { counts: Vec::new() }
4755 }
4756 }
4757
4758 impl crate::plugin::ParserPlugin for BindingCountPlugin {
4759 fn register_component_template(
4760 &mut self,
4761 _tag_name: &str,
4762 _component: &Component,
4763 _processed_template: &str,
4764 ) -> Result<()> {
4765 Ok(())
4766 }
4767
4768 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
4769 if attr_name.starts_with('@') || attr_name == "f-ref" {
4770 AttributeAction::SkipAndCountBinding
4771 } else {
4772 AttributeAction::Keep
4773 }
4774 }
4775
4776 fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
4777 self.counts.push(binding_attribute_count);
4778 if binding_attribute_count > 0 {
4779 Some(binding_attribute_count.to_le_bytes().to_vec())
4780 } else {
4781 None
4782 }
4783 }
4784 }
4785
4786 #[test]
4787 fn test_component_static_attrs_not_counted_as_bindings() {
4788 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
4789 parser
4790 .component_registry
4791 .register_component("my-btn", "<button><slot></slot></button>", None)
4792 .expect("register");
4793
4794 let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
4796 let result = parser.parse("index.html", html);
4797 assert!(result.is_ok());
4798
4799 let records = parser.into_fragment_records();
4800
4801 let frags = &records["index.html"].fragments;
4803 let plugin_count = frags
4804 .iter()
4805 .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
4806 .count();
4807 assert_eq!(
4808 plugin_count, 0,
4809 "Static-only component attributes should not emit a Plugin fragment"
4810 );
4811 }
4812
4813 #[test]
4814 fn test_component_dynamic_attr_counted_as_binding() {
4815 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
4816 parser
4817 .component_registry
4818 .register_component("my-btn", "<button><slot></slot></button>", None)
4819 .expect("register");
4820
4821 let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
4823 let result = parser.parse("index.html", html);
4824 assert!(result.is_ok());
4825
4826 let records = parser.into_fragment_records();
4827 let frags = &records["index.html"].fragments;
4828
4829 let plugin_frags: Vec<_> = frags
4831 .iter()
4832 .filter_map(|f| match f.fragment.as_ref() {
4833 Some(Fragment::Plugin(p)) => Some(&p.data),
4834 _ => None,
4835 })
4836 .collect();
4837 assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
4838 let count = u32::from_le_bytes([
4839 plugin_frags[0][0],
4840 plugin_frags[0][1],
4841 plugin_frags[0][2],
4842 plugin_frags[0][3],
4843 ]);
4844 assert_eq!(count, 1, "Binding attribute count should be 1");
4845 }
4846
4847 #[test]
4848 fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
4849 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
4850 parser
4851 .component_registry
4852 .register_component("my-btn", "<button><slot></slot></button>", None)
4853 .expect("register");
4854
4855 let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
4857 let result = parser.parse("index.html", html);
4858 assert!(result.is_ok());
4859
4860 let records = parser.into_fragment_records();
4861 let frags = &records["index.html"].fragments;
4862
4863 let plugin_frags: Vec<_> = frags
4864 .iter()
4865 .filter_map(|f| match f.fragment.as_ref() {
4866 Some(Fragment::Plugin(p)) => Some(&p.data),
4867 _ => None,
4868 })
4869 .collect();
4870 assert_eq!(plugin_frags.len(), 1);
4871 let count = u32::from_le_bytes([
4872 plugin_frags[0][0],
4873 plugin_frags[0][1],
4874 plugin_frags[0][2],
4875 plugin_frags[0][3],
4876 ]);
4877 assert_eq!(
4879 count, 2,
4880 "Binding count should include dynamic + plugin-skipped attrs, not static"
4881 );
4882 }
4883
4884 #[test]
4885 fn test_component_only_skipped_plugin_attrs_counted() {
4886 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
4887 parser
4888 .component_registry
4889 .register_component("my-btn", "<button><slot></slot></button>", None)
4890 .expect("register");
4891
4892 let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
4894 let result = parser.parse("index.html", html);
4895 assert!(result.is_ok());
4896
4897 let records = parser.into_fragment_records();
4898 let frags = &records["index.html"].fragments;
4899
4900 let plugin_frags: Vec<_> = frags
4901 .iter()
4902 .filter_map(|f| match f.fragment.as_ref() {
4903 Some(Fragment::Plugin(p)) => Some(&p.data),
4904 _ => None,
4905 })
4906 .collect();
4907 assert_eq!(plugin_frags.len(), 1);
4908 let count = u32::from_le_bytes([
4909 plugin_frags[0][0],
4910 plugin_frags[0][1],
4911 plugin_frags[0][2],
4912 plugin_frags[0][3],
4913 ]);
4914 assert_eq!(
4916 count, 2,
4917 "Only plugin-skipped attrs should be counted, not static title"
4918 );
4919 }
4920
4921 #[test]
4924 fn test_comment_handlebars_signal_is_stripped() {
4925 let mut parser = HtmlParser::new();
4926 let html = "<!--{{tokens}}-->";
4927 parser.parse("test.html", html).expect("parse failed");
4928 let records = parser.into_fragment_records();
4929
4930 assert_stream!(records, "test.html", []);
4931 }
4932
4933 #[test]
4934 fn test_comment_triple_brace_raw_signal_is_stripped() {
4935 let mut parser = HtmlParser::new();
4936 let html = "<!--{{{tokens}}}-->";
4937 parser.parse("test.html", html).expect("parse failed");
4938 let records = parser.into_fragment_records();
4939
4940 assert_stream!(records, "test.html", []);
4941 }
4942
4943 #[test]
4944 fn test_comment_regular_stripped() {
4945 let mut parser = HtmlParser::new();
4946 let html = "<!-- regular comment -->";
4947 parser.parse("test.html", html).expect("parse failed");
4948 let records = parser.into_fragment_records();
4949
4950 assert_stream!(records, "test.html", []);
4951 }
4952
4953 #[test]
4954 fn test_comment_dotted_signal_is_stripped() {
4955 let mut parser = HtmlParser::new();
4956 let html = "<!--{{tokens.light}}-->";
4957 parser.parse("test.html", html).expect("parse failed");
4958 let records = parser.into_fragment_records();
4959
4960 assert_stream!(records, "test.html", []);
4961 }
4962
4963 #[test]
4964 fn test_comment_arbitrary_identifier_is_stripped() {
4965 let mut parser = HtmlParser::new();
4966 let html = "<!--{{someOtherBinding}}-->";
4967 parser.parse("test.html", html).expect("parse failed");
4968 let records = parser.into_fragment_records();
4969
4970 assert_stream!(records, "test.html", []);
4971 }
4972
4973 #[test]
4974 fn test_comment_with_surrounding_content_is_stripped() {
4975 let mut parser = HtmlParser::new();
4976 let html = "<div><!--{{tokens}}--></div>";
4977 parser.parse("test.html", html).expect("parse failed");
4978 let records = parser.into_fragment_records();
4979
4980 assert_stream!(records, "test.html", [raw("<div></div>")]);
4981 }
4982
4983 #[test]
4984 fn test_component_template_unterminated_comment_returns_error() {
4985 let mut parser = HtmlParser::new();
4986 parser
4987 .component_registry_mut()
4988 .register_component("x-bad", "<span>ok</span><!-- missing close", None)
4989 .expect("register failed");
4990
4991 let result = parser.parse("index.html", "<x-bad></x-bad>");
4992
4993 assert!(matches!(result, Err(ParserError::Template(diag)) if
4994 diag.to_string().contains("unterminated HTML comment")
4995 ));
4996 }
4997
4998 #[test]
4999 fn test_webui_plugin_strips_component_comments_before_metadata() {
5000 let mut parser =
5001 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5002 parser
5003 .component_registry_mut()
5004 .register_component(
5005 "x-bleed",
5006 r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
5007 None,
5008 )
5009 .expect("register failed");
5010
5011 parser
5012 .parse("index.html", "<x-bleed></x-bleed>")
5013 .expect("parse failed");
5014
5015 let artifacts = parser.take_plugin_artifacts().unwrap();
5016 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
5017 panic!("expected component templates");
5018 };
5019 let template = &templates[0].template_json;
5020 assert!(template.contains("<div>hello</div>"));
5021 assert!(!template.contains("{{path}}"));
5022 assert!(!template.contains("@click"));
5023 assert!(!template.contains("?hidden"));
5024 assert!(!template.contains("-->"));
5025 }
5026
5027 #[test]
5028 fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
5029 let mut parser =
5030 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5031 parser
5032 .component_registry_mut()
5033 .register_component(
5034 "x-style",
5035 "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
5036 None,
5037 )
5038 .expect("register failed");
5039
5040 parser
5041 .parse("index.html", "<x-style></x-style>")
5042 .expect("parse failed");
5043
5044 let artifacts = parser.take_plugin_artifacts().unwrap();
5045 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
5046 panic!("expected component templates");
5047 };
5048 let template = &templates[0].template_json;
5049 assert!(template.contains(".x { color: red; }"));
5050 assert!(!template.contains("ignored"));
5051 assert!(!template.contains("//"));
5052 }
5053
5054 #[test]
5057 fn test_tokens_from_style_tag() {
5058 let mut parser = HtmlParser::new();
5059 let html = r#"<style>
5060 .btn { color: var(--colorPrimary); background: var(--bgColor); }
5061 </style>"#;
5062 parser.parse("test.html", html).expect("parse failed");
5063 let tokens = parser.take_tokens();
5064
5065 assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
5066 }
5067
5068 #[test]
5069 fn test_tokens_from_malformed_style_error_on_unclosed_var() {
5070 let mut parser = HtmlParser::new();
5071 let html = r#"<style>
5072 .bad { color: var(--dangling; }
5073 .ok { color: var(--valid); }
5074 </style>"#;
5075 let result = parser.parse("test.html", html);
5076
5077 assert!(matches!(result, Err(ParserError::Template(diag)) if
5078 diag.error_code() == Some(codes::INVALID_CSS)
5079 && diag.to_string().contains("Unterminated CSS var() call")
5080 ));
5081 }
5082
5083 #[test]
5084 fn test_tokens_from_component_css() {
5085 let mut parser = HtmlParser::new();
5086 parser
5087 .component_registry_mut()
5088 .register_component(
5089 "my-button",
5090 "<button>Click</button>",
5091 Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
5092 )
5093 .expect("register failed");
5094
5095 let html = "<my-button></my-button>";
5096 parser.parse("test.html", html).expect("parse failed");
5097 let tokens = parser.take_tokens();
5098
5099 assert_eq!(tokens, vec!["borderWidth", "textColor"]);
5100 }
5101
5102 #[test]
5103 fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
5104 let mut parser = HtmlParser::new();
5105 let result = parser.component_registry_mut().register_component(
5106 "my-card",
5107 "<div>Card</div>",
5108 Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
5109 );
5110
5111 assert!(matches!(result, Err(ParserError::Css(message)) if
5112 message.contains("Unterminated CSS var() call")
5113 ));
5114 }
5115
5116 #[test]
5117 fn test_tokens_merged_from_style_and_components() {
5118 let mut parser = HtmlParser::new();
5119 parser
5120 .component_registry_mut()
5121 .register_component(
5122 "my-widget",
5123 "<div>Widget</div>",
5124 Some(".w { padding: var(--spacingM); }"),
5125 )
5126 .expect("register failed");
5127
5128 let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
5129 parser.parse("test.html", html).expect("parse failed");
5130 let tokens = parser.take_tokens();
5131
5132 assert_eq!(tokens, vec!["spacingM", "textColor"]);
5133 }
5134
5135 #[test]
5136 fn test_tokens_deduplicated() {
5137 let mut parser = HtmlParser::new();
5138 parser
5139 .component_registry_mut()
5140 .register_component(
5141 "my-btn",
5142 "<button>B</button>",
5143 Some(".b { color: var(--shared); }"),
5144 )
5145 .expect("register failed");
5146
5147 let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
5148 parser.parse("test.html", html).expect("parse failed");
5149 let tokens = parser.take_tokens();
5150
5151 assert_eq!(tokens, vec!["shared"]);
5152 }
5153
5154 #[test]
5155 fn test_tokens_empty_when_no_vars() {
5156 let mut parser = HtmlParser::new();
5157 let html = "<div>Hello</div>";
5158 parser.parse("test.html", html).expect("parse failed");
5159 let tokens = parser.take_tokens();
5160
5161 assert!(tokens.is_empty());
5162 }
5163
5164 #[test]
5165 fn test_tokens_exclude_locally_defined_in_component() {
5166 let mut parser = HtmlParser::new();
5167 parser
5168 .component_registry_mut()
5169 .register_component(
5170 "my-card",
5171 "<div>Card</div>",
5172 Some(":host { --local: 5px; width: var(--external); }"),
5173 )
5174 .expect("register failed");
5175
5176 let html = "<my-card></my-card>";
5177 parser.parse("test.html", html).expect("parse failed");
5178 let tokens = parser.take_tokens();
5179
5180 assert_eq!(tokens, vec!["external"]);
5181 }
5182
5183 #[test]
5184 fn test_tokens_exclude_entry_root_definitions() {
5185 let mut parser = HtmlParser::new();
5186 parser
5187 .component_registry_mut()
5188 .register_component(
5189 "my-btn",
5190 "<button>B</button>",
5191 Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
5192 )
5193 .expect("register failed");
5194
5195 let html = r#"<style>
5198 :root {
5199 --color-primary: #0078d4;
5200 --radius-m: 6px;
5201 }
5202 body { color: var(--color-primary); }
5203 </style>
5204 <my-btn></my-btn>"#;
5205 parser.parse("test.html", html).expect("parse failed");
5206 let tokens = parser.take_tokens();
5207
5208 assert!(
5210 tokens.is_empty(),
5211 "Tokens defined in entry :root should be excluded: {tokens:?}"
5212 );
5213 }
5214
5215 #[test]
5216 fn test_tokens_entry_defs_exclude_but_external_kept() {
5217 let mut parser = HtmlParser::new();
5218 parser
5219 .component_registry_mut()
5220 .register_component(
5221 "my-card",
5222 "<div>Card</div>",
5223 Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
5224 )
5225 .expect("register failed");
5226
5227 let html = r#"<style>
5229 :root { --color-primary: #0078d4; }
5230 </style>
5231 <my-card></my-card>"#;
5232 parser.parse("test.html", html).expect("parse failed");
5233 let tokens = parser.take_tokens();
5234
5235 assert_eq!(tokens, vec!["external-spacing"]);
5237 }
5238
5239 #[test]
5242 fn test_parse_simple_route() {
5243 let mut parser = HtmlParser::new();
5244 let html = r#"<route path="/profile" component="profile-page" exact />"#;
5245 parser.parse("test.html", html).expect("parse failed");
5246
5247 let records = parser.into_fragment_records();
5249 let frags = &records["test.html"].fragments;
5250 assert_eq!(frags.len(), 1);
5251 match frags[0].fragment.as_ref() {
5252 Some(web_ui_fragment::Fragment::Route(r)) => {
5253 assert_eq!(r.path, "/profile");
5254 assert_eq!(r.fragment_id, "profile-page");
5255 assert!(r.exact);
5256 }
5257 other => panic!("expected Fragment::Route, got {:?}", other),
5258 }
5259 }
5260
5261 #[test]
5262 fn test_parse_route_with_params() {
5263 let mut parser = HtmlParser::new();
5264 let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
5265 parser.parse("test.html", html).expect("parse failed");
5266
5267 let records = parser.into_fragment_records();
5269 let frags = &records["test.html"].fragments;
5270 match frags[0].fragment.as_ref() {
5271 Some(web_ui_fragment::Fragment::Route(r)) => {
5272 assert_eq!(r.path, "/profile/:id/view/:section");
5273 }
5274 other => panic!("expected Fragment::Route, got {:?}", other),
5275 }
5276 }
5277
5278 #[test]
5279 fn test_parse_route_requires_component() {
5280 let mut parser = HtmlParser::new();
5281 let html = r#"<route path="/old" />"#;
5282 let result = parser.parse("test.html", html);
5283 assert!(result.is_err());
5284 }
5285
5286 #[test]
5287 fn test_parse_multiple_routes() {
5288 let mut parser = HtmlParser::new();
5289 let html = r#"<route path="/" component="app-layout" />
5290 <route path="/dashboard" component="dash-page" exact />
5291 <route path="/contacts" component="contacts-page" exact />"#;
5292 parser.parse("test.html", html).expect("parse failed");
5293
5294 let records = parser.into_fragment_records();
5295 let frags = &records["test.html"].fragments;
5296 assert_eq!(frags.len(), 3);
5297 for frag in frags {
5299 assert!(matches!(
5300 frag.fragment.as_ref(),
5301 Some(web_ui_fragment::Fragment::Route(_))
5302 ));
5303 }
5304 }
5305
5306 #[test]
5307 fn test_parse_route_requires_component_with_body() {
5308 let mut parser = HtmlParser::new();
5309 let html = r#"<route path="/error">
5310 <div class="error"><h1>Not Found</h1></div>
5311 </route>"#;
5312 let result = parser.parse("test.html", html);
5313 assert!(
5314 result.is_err(),
5315 "Route without component attribute should fail"
5316 );
5317 }
5318
5319 #[test]
5320 fn test_parse_multiple_routes_with_fragments() {
5321 let mut parser = HtmlParser::new();
5322 let html = r#"<route path="/" component="home-page" exact />
5323 <route path="/about" component="about-page" exact />
5324 <route path="/contact/:id" component="contact-page" />"#;
5325 parser.parse("test.html", html).expect("parse failed");
5326
5327 let records = parser.into_fragment_records();
5328 let frags = &records["test.html"].fragments;
5329 assert_eq!(frags.len(), 3);
5330
5331 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
5333 assert_eq!(r.path, "/");
5334 }
5335 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
5336 assert_eq!(r.path, "/about");
5337 }
5338 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
5339 assert_eq!(r.path, "/contact/:id");
5340 }
5341 }
5342
5343 #[test]
5344 fn test_parse_route_with_query_allowlist() {
5345 let mut parser = HtmlParser::new();
5346 let html =
5347 r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
5348 parser.parse("test.html", html).expect("parse failed");
5349
5350 let records = parser.into_fragment_records();
5351 let frags = &records["test.html"].fragments;
5352 assert_eq!(frags.len(), 1);
5353 match frags[0].fragment.as_ref() {
5354 Some(web_ui_fragment::Fragment::Route(r)) => {
5355 assert_eq!(r.path, "/compose");
5356 assert_eq!(r.fragment_id, "compose-page");
5357 assert!(r.exact);
5358 assert_eq!(r.allowed_query, "action,to,subject");
5359 }
5360 other => panic!("expected Fragment::Route, got {:?}", other),
5361 }
5362 }
5363
5364 #[test]
5365 fn test_parse_route_without_query_has_empty_allowed_query() {
5366 let mut parser = HtmlParser::new();
5367 let html = r#"<route path="/profile" component="profile-page" exact />"#;
5368 parser.parse("test.html", html).expect("parse failed");
5369
5370 let records = parser.into_fragment_records();
5371 let frags = &records["test.html"].fragments;
5372 match frags[0].fragment.as_ref() {
5373 Some(web_ui_fragment::Fragment::Route(r)) => {
5374 assert!(r.allowed_query.is_empty());
5375 }
5376 other => panic!("expected Fragment::Route, got {:?}", other),
5377 }
5378 }
5379
5380 #[test]
5381 fn test_route_body_rejects_malformed_non_route_html() {
5382 let mut parser = HtmlParser::new();
5383 let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
5384 let result = parser.parse("test.html", html);
5385
5386 assert!(matches!(result, Err(ParserError::Template(diag)) if
5387 diag.to_string().contains("unclosed <span> tag")
5388 ));
5389 }
5390
5391 #[test]
5392 fn test_route_body_rejects_malformed_style_css() {
5393 let mut parser = HtmlParser::new();
5394 let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
5395 let result = parser.parse("test.html", html);
5396
5397 assert!(matches!(result, Err(ParserError::Template(diag)) if
5398 diag.error_code() == Some(codes::INVALID_CSS)
5399 && diag.to_string().contains("Unterminated CSS var() call")
5400 ));
5401 }
5402
5403 #[test]
5404 fn test_outlet_not_captured_by_for_loop() {
5405 let mut parser = HtmlParser::new();
5406 let html = r#"<template shadowrootmode="open">
5407 <ul>
5408 <for each="item in items">
5409 <li>{{item.name}}</li>
5410 </for>
5411 </ul>
5412 <main>
5413 <outlet />
5414 </main>
5415</template>"#;
5416 parser.parse("comp.html", html).expect("parse failed");
5417
5418 let records = parser.into_fragment_records();
5419 let frags = &records["comp.html"].fragments;
5420
5421 let outlet_count = frags
5423 .iter()
5424 .filter(|f| {
5425 matches!(
5426 f.fragment.as_ref(),
5427 Some(web_ui_fragment::Fragment::Outlet(_))
5428 )
5429 })
5430 .count();
5431 assert_eq!(
5432 outlet_count, 1,
5433 "expected exactly 1 outlet in top-level fragments, got {outlet_count}"
5434 );
5435
5436 let outlet_idx = frags
5438 .iter()
5439 .position(|f| {
5440 matches!(
5441 f.fragment.as_ref(),
5442 Some(web_ui_fragment::Fragment::Outlet(_))
5443 )
5444 })
5445 .expect("no outlet found");
5446 let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
5447 Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
5448 _ => false,
5449 });
5450 if let Some(ul_idx) = close_ul_idx {
5451 assert!(
5452 outlet_idx > ul_idx,
5453 "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
5454 );
5455 }
5456 }
5457
5458 #[test]
5459 fn test_outlet_position_after_for_not_inside() {
5460 let mut parser = HtmlParser::new();
5461 let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
5462 parser.parse("test.html", html).expect("parse failed");
5463
5464 let records = parser.into_fragment_records();
5465 let frags = &records["test.html"].fragments;
5466
5467 let has_outlet = frags.iter().any(|f| {
5469 matches!(
5470 f.fragment.as_ref(),
5471 Some(web_ui_fragment::Fragment::Outlet(_))
5472 )
5473 });
5474 assert!(
5475 has_outlet,
5476 "outlet should be in top-level fragments: {frags:?}"
5477 );
5478
5479 let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
5481 Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
5482 _ => None,
5483 });
5484 if let Some(id) = for_id {
5485 let for_frags = &records[&id].fragments;
5486 let outlet_in_for = for_frags.iter().any(|f| {
5487 matches!(
5488 f.fragment.as_ref(),
5489 Some(web_ui_fragment::Fragment::Outlet(_))
5490 )
5491 });
5492 assert!(
5493 !outlet_in_for,
5494 "outlet should NOT be inside for-loop body: {for_frags:?}"
5495 );
5496 }
5497 }
5498
5499 #[test]
5500 fn test_style_element_with_handlebars_signal_comment_emits_signal() {
5501 let mut parser = HtmlParser::new();
5502 let html = r#"<html><head><style>
5503:root {
5504 /*{{{tokens.light}}}*/
5505}
5506</style></head><body></body></html>"#;
5507
5508 let result = parser.parse("test.html", html);
5509 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5510 let fragment_records = parser.into_fragment_records();
5511
5512 assert_stream!(
5513 fragment_records,
5514 "test.html",
5515 [
5516 raw("<html><head><style>\n:root {\n "),
5517 signal_raw("tokens.light"),
5518 raw("\n}\n</style>"),
5519 signal_raw("head_end"),
5520 raw("</head><body>"),
5521 signal_raw("body_start"),
5522 signal_raw("body_end"),
5523 raw("</body></html>"),
5524 ]
5525 );
5526 }
5527
5528 #[test]
5529 fn test_style_comment_signal_with_spaces_emits_signal() {
5530 let mut parser = HtmlParser::new();
5531 let html = r#"<html><head><style>
5532:root {
5533 /* {{{tokens.light}}} */
5534}
5535</style></head><body></body></html>"#;
5536
5537 let result = parser.parse("test.html", html);
5538 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5539 let fragment_records = parser.into_fragment_records();
5540
5541 assert_stream!(
5542 fragment_records,
5543 "test.html",
5544 [
5545 raw("<html><head><style>\n:root {\n "),
5546 signal_raw("tokens.light"),
5547 raw("\n}\n</style>"),
5548 signal_raw("head_end"),
5549 raw("</head><body>"),
5550 signal_raw("body_start"),
5551 signal_raw("body_end"),
5552 raw("</body></html>"),
5553 ]
5554 );
5555 }
5556
5557 #[test]
5558 fn test_style_comment_signal_double_brace_emits_signal() {
5559 let mut parser = HtmlParser::new();
5560 let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
5561
5562 let result = parser.parse("test.html", html);
5563 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5564 let fragment_records = parser.into_fragment_records();
5565
5566 assert_stream!(
5567 fragment_records,
5568 "test.html",
5569 [
5570 raw("<html><head><style>"),
5571 signal("themeCss"),
5572 raw("</style>"),
5573 signal_raw("head_end"),
5574 raw("</head><body>"),
5575 signal_raw("body_start"),
5576 signal_raw("body_end"),
5577 raw("</body></html>"),
5578 ]
5579 );
5580 }
5581
5582 #[test]
5583 fn test_style_comment_with_extra_text_is_stripped() {
5584 let mut parser = HtmlParser::new();
5585 let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
5586
5587 let result = parser.parse("test.html", html);
5588 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5589 let fragment_records = parser.into_fragment_records();
5590
5591 assert_stream!(
5592 fragment_records,
5593 "test.html",
5594 [
5595 raw("<html><head><style></style>"),
5596 signal_raw("head_end"),
5597 raw("</head><body>"),
5598 signal_raw("body_start"),
5599 signal_raw("body_end"),
5600 raw("</body></html>"),
5601 ]
5602 );
5603 }
5604
5605 #[test]
5606 fn test_style_comment_with_multiple_signals_is_stripped() {
5607 let mut parser = HtmlParser::new();
5608 let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
5609
5610 let result = parser.parse("test.html", html);
5611 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5612 let fragment_records = parser.into_fragment_records();
5613
5614 assert_stream!(
5615 fragment_records,
5616 "test.html",
5617 [
5618 raw("<html><head><style></style>"),
5619 signal_raw("head_end"),
5620 raw("</head><body>"),
5621 signal_raw("body_start"),
5622 signal_raw("body_end"),
5623 raw("</body></html>"),
5624 ]
5625 );
5626 }
5627
5628 #[test]
5629 fn test_style_bare_handlebars_stays_raw() {
5630 let mut parser = HtmlParser::new();
5631 let html =
5633 "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
5634
5635 let result = parser.parse("test.html", html);
5636 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5637 let fragment_records = parser.into_fragment_records();
5638
5639 assert_stream!(
5640 fragment_records,
5641 "test.html",
5642 [
5643 raw("<html><head><style>body { color: {{textColor}}; }</style>"),
5644 signal_raw("head_end"),
5645 raw("</head><body>"),
5646 signal_raw("body_start"),
5647 signal_raw("body_end"),
5648 raw("</body></html>"),
5649 ]
5650 );
5651 }
5652
5653 #[test]
5654 fn test_style_mixed_css_and_comment_signal_emits_signal() {
5655 let mut parser = HtmlParser::new();
5656 let html = r#"<html><head><style>
5657 .a { color: red; }
5658 /*{{themeCss}}*/
5659 .b { color: blue; }
5660</style></head><body></body></html>"#;
5661
5662 let result = parser.parse("test.html", html);
5663 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5664 let fragment_records = parser.into_fragment_records();
5665
5666 assert_stream!(
5667 fragment_records,
5668 "test.html",
5669 [
5670 raw("<html><head><style>\n .a { color: red; }\n "),
5671 signal("themeCss"),
5672 raw("\n .b { color: blue; }\n</style>"),
5673 signal_raw("head_end"),
5674 raw("</head><body>"),
5675 signal_raw("body_start"),
5676 signal_raw("body_end"),
5677 raw("</body></html>"),
5678 ]
5679 );
5680 }
5681
5682 #[test]
5683 fn test_style_legal_comment_preserved_inline_by_default() {
5684 let mut parser = HtmlParser::new();
5685 let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
5686
5687 parser.parse("test.html", html).expect("parse failed");
5688 let fragment_records = parser.into_fragment_records();
5689
5690 assert_stream!(
5691 fragment_records,
5692 "test.html",
5693 [
5694 raw("<html><head><style>/*! @license MIT */ .x { color: red; } </style>"),
5695 signal_raw("head_end"),
5696 raw("</head><body>"),
5697 signal_raw("body_start"),
5698 signal_raw("body_end"),
5699 raw("</body></html>"),
5700 ]
5701 );
5702 }
5703
5704 #[test]
5705 fn test_style_legal_comments_none_strips_legal_comment() {
5706 let mut parser = HtmlParser::with_options(ParserOptions {
5707 legal_comments: LegalComments::None,
5708 ..ParserOptions::default()
5709 });
5710 let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
5711
5712 parser.parse("test.html", html).expect("parse failed");
5713 let fragment_records = parser.into_fragment_records();
5714
5715 assert_stream!(
5716 fragment_records,
5717 "test.html",
5718 [
5719 raw("<html><head><style> .x { color: red; }</style>"),
5720 signal_raw("head_end"),
5721 raw("</head><body>"),
5722 signal_raw("body_start"),
5723 signal_raw("body_end"),
5724 raw("</body></html>"),
5725 ]
5726 );
5727 }
5728
5729 #[test]
5730 fn test_style_element_plain_css_unchanged() {
5731 let mut parser = HtmlParser::new();
5732 let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
5733
5734 let result = parser.parse("test.html", html);
5735 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5736 let fragment_records = parser.into_fragment_records();
5737
5738 assert_stream!(
5740 fragment_records,
5741 "test.html",
5742 [
5743 raw("<html><head><style>body { margin: 0; }</style>"),
5744 signal_raw("head_end"),
5745 raw("</head><body>"),
5746 signal_raw("body_start"),
5747 signal_raw("body_end"),
5748 raw("</body></html>"),
5749 ]
5750 );
5751 }
5752
5753 #[test]
5754 fn test_unclosed_style_element_returns_error() {
5755 let mut parser = HtmlParser::new();
5756 let result = parser.parse("index.html", "<style>.x { color: red; ");
5757 assert!(matches!(result, Err(ParserError::Template(diag)) if
5758 diag.to_string().contains("unclosed <style> tag")
5759 ));
5760 }
5761
5762 #[test]
5763 fn test_text_with_binding_after_void_element() {
5764 let (fragments, _) = parse_and_get_fragments(
5767 "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
5768 );
5769 assert_fragments!(
5770 fragments,
5771 [
5772 raw("Part A: "),
5773 signal("value1"),
5774 raw("<button>Click</button><br>Part B: "),
5775 signal("value2"),
5776 raw("<button>Click2</button>"),
5777 ]
5778 );
5779 }
5780}