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