1mod asset_filename;
8mod comment_policy;
9mod component_registry;
10mod condition_parser;
11mod css_link;
12mod css_parser;
13mod diagnostic;
14mod error;
15mod handlebars_parser;
16mod html_parser;
17pub mod plugin;
18mod route_parser;
19mod suggest;
20
21pub use asset_filename::{
22 AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
23};
24pub use component_registry::{Component, ComponentRegistry};
25pub use condition_parser::ConditionParser;
26pub use css_link::{CssLinkHref, CssLinkOptions, DEFAULT_CSS_FILE_NAME_TEMPLATE};
27pub use css_parser::CssParser;
28pub use diagnostic::{codes, Diagnostic, Severity};
29pub use error::{ParserError, Result};
30pub use handlebars_parser::HandlebarsParser;
31pub use webui_tokens::CssFallbackChain;
32
33use crate::html_parser::{self as html, Attrs, Element, Event, Walker};
34use crate::plugin::{AttributeAction, ParserPlugin, ParserPluginArtifacts};
35use std::collections::{HashMap, HashSet};
36use std::ops::Range;
37use webui_protocol::{
38 web_ui_fragment, web_ui_fragment::Fragment, ConditionExpr, FragmentList, WebUIFragment,
39 WebUIFragmentAttribute, WebUIFragmentRecords, WebUiFragmentRoute,
40};
41
42const MAX_TEMPLATE_BYTES: usize = 16 * 1024 * 1024;
48
49const MAX_TEMPLATE_DEPTH: usize = 512;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
59pub enum CssStrategy {
60 #[default]
62 Link,
63 Style,
65 Module,
69}
70
71impl std::fmt::Display for CssStrategy {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 match self {
74 CssStrategy::Link => write!(f, "link"),
75 CssStrategy::Style => write!(f, "style"),
76 CssStrategy::Module => write!(f, "module"),
77 }
78 }
79}
80
81impl std::str::FromStr for CssStrategy {
82 type Err = String;
83
84 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
85 match s {
86 "link" => Ok(CssStrategy::Link),
87 "style" => Ok(CssStrategy::Style),
88 "module" => Ok(CssStrategy::Module),
89 other => Err(format!(
90 "Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
91 )),
92 }
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
98#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
99pub enum DomStrategy {
100 #[default]
102 Shadow,
103 Light,
105}
106
107impl std::fmt::Display for DomStrategy {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 match self {
110 DomStrategy::Shadow => write!(f, "shadow"),
111 DomStrategy::Light => write!(f, "light"),
112 }
113 }
114}
115
116impl std::str::FromStr for DomStrategy {
117 type Err = String;
118
119 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
120 match s {
121 "shadow" => Ok(DomStrategy::Shadow),
122 "light" => Ok(DomStrategy::Light),
123 other => Err(format!(
124 "Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
125 )),
126 }
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
133pub enum LegalComments {
134 None,
136 #[default]
138 Inline,
139}
140
141impl std::fmt::Display for LegalComments {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 match self {
144 LegalComments::None => write!(f, "none"),
145 LegalComments::Inline => write!(f, "inline"),
146 }
147 }
148}
149
150impl std::str::FromStr for LegalComments {
151 type Err = String;
152
153 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
154 match s {
155 "none" => Ok(LegalComments::None),
156 "inline" => Ok(LegalComments::Inline),
157 other => Err(format!(
158 "Unknown legal comments strategy: {other}. Use \"none\" or \"inline\"."
159 )),
160 }
161 }
162}
163
164#[derive(Debug, Clone, Default)]
166pub struct ParserOptions {
167 pub css_strategy: CssStrategy,
169 pub dom_strategy: DomStrategy,
171 pub css_link_options: CssLinkOptions,
173 pub legal_comments: LegalComments,
175}
176
177impl ParserOptions {
178 pub fn try_new(
184 css_strategy: CssStrategy,
185 dom_strategy: DomStrategy,
186 css_file_name_template: &str,
187 css_public_base: Option<&str>,
188 legal_comments: LegalComments,
189 ) -> Result<Self> {
190 let css_link_options = if css_strategy == CssStrategy::Link {
191 CssLinkOptions::try_new(
192 css_file_name_template.to_string(),
193 css_public_base.map(std::string::ToString::to_string),
194 )?
195 } else {
196 CssLinkOptions::default()
197 };
198
199 Ok(Self {
200 css_strategy,
201 dom_strategy,
202 css_link_options,
203 legal_comments,
204 })
205 }
206}
207
208impl From<CssStrategy> for ParserOptions {
209 fn from(css_strategy: CssStrategy) -> Self {
210 Self {
211 css_strategy,
212 ..Self::default()
213 }
214 }
215}
216
217impl From<DomStrategy> for ParserOptions {
218 fn from(dom_strategy: DomStrategy) -> Self {
219 Self {
220 dom_strategy,
221 ..Self::default()
222 }
223 }
224}
225
226impl From<(CssStrategy, DomStrategy)> for ParserOptions {
227 fn from((css_strategy, dom_strategy): (CssStrategy, DomStrategy)) -> Self {
228 Self {
229 css_strategy,
230 dom_strategy,
231 ..Self::default()
232 }
233 }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
239pub enum Plugin {
240 #[cfg_attr(feature = "cli", value(name = "fast"))]
242 Fast,
243 #[cfg_attr(feature = "cli", value(name = "fast-v2"))]
245 FastV2,
246 #[cfg_attr(feature = "cli", value(name = "fast-v3"))]
248 FastV3,
249 #[cfg_attr(feature = "cli", value(name = "webui"))]
251 WebUI,
252}
253
254impl std::fmt::Display for Plugin {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 match self {
257 Plugin::Fast => write!(f, "fast"),
258 Plugin::FastV2 => write!(f, "fast-v2"),
259 Plugin::FastV3 => write!(f, "fast-v3"),
260 Plugin::WebUI => write!(f, "webui"),
261 }
262 }
263}
264
265impl std::str::FromStr for Plugin {
266 type Err = String;
267
268 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
269 match s {
270 "fast" => Ok(Plugin::Fast),
271 "fast-v2" => Ok(Plugin::FastV2),
272 "fast-v3" => Ok(Plugin::FastV3),
273 "webui" => Ok(Plugin::WebUI),
274 other => Err(format!(
275 "Unknown plugin: {other}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
276 )),
277 }
278 }
279}
280
281struct FragmentIdCounter {
283 counters: HashMap<String, usize>,
285}
286
287impl FragmentIdCounter {
288 fn new() -> Self {
290 Self {
291 counters: HashMap::new(),
292 }
293 }
294
295 fn next_id(&mut self, prefix: &str) -> String {
297 let count = self.counters.entry(prefix.to_string()).or_insert(0);
298 *count += 1;
299 format!("{}-{}", prefix, count)
300 }
301}
302
303struct ParseContext {
304 fragments: Vec<WebUIFragment>,
305 raw_buffer: String,
306}
307
308#[derive(Default)]
309struct FragmentCssTokens {
310 definitions: Vec<String>,
311 fallback_chains: Vec<CssFallbackChain>,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct CssTokenAnalysis {
317 pub protocol_tokens: Vec<String>,
319 pub fallback_chains: Vec<CssFallbackChain>,
322 pub(crate) token_sites: HashMap<String, TokenSite>,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq)]
334pub(crate) struct TokenSite {
335 owner: String,
336 position: Option<(usize, usize)>,
337 snippet: Option<String>,
338}
339
340enum ParseOp<'a> {
341 Parse {
342 range: Range<usize>,
343 depth: usize,
344 },
345 EmitClose(&'a str),
346 EndHead,
347 EndBody,
348 CompleteFor {
349 parent: ParseContext,
350 item: String,
351 collection: String,
352 fragment_id: String,
353 keep_empty: bool,
354 },
355 CompleteIf {
356 parent: ParseContext,
357 condition: ConditionExpr,
358 fragment_id: String,
359 },
360}
361
362enum TokenGraphOp<'a> {
363 EnterFragment(&'a str),
364 EnterComponent(&'a str),
365 EnterRoute(&'a WebUiFragmentRoute),
366 ExitDefinitions(&'a [String]),
367}
368
369impl Default for HtmlParser {
370 fn default() -> Self {
371 Self::new()
372 }
373}
374
375pub struct HtmlParser {
377 css_parser: CssParser,
379
380 id_counter: FragmentIdCounter,
382
383 condition_parser: ConditionParser,
385
386 handlebars_parser: HandlebarsParser,
388
389 component_registry: ComponentRegistry,
391
392 fragment_records: WebUIFragmentRecords,
394
395 raw_buffer: String,
397
398 options: ParserOptions,
400
401 plugin: Option<Box<dyn ParserPlugin>>,
403
404 token_roots: Vec<String>,
407
408 fragment_css_tokens: HashMap<String, FragmentCssTokens>,
411
412 in_progress_fragments: HashSet<String>,
415
416 current_fragment_id: String,
419}
420
421struct BuiltComponentTemplate {
422 ssr: String,
423 artifact: Option<String>,
424}
425
426impl BuiltComponentTemplate {
427 fn artifact(&self) -> &str {
428 self.artifact.as_deref().unwrap_or(&self.ssr)
429 }
430}
431
432fn add_token_definitions(definitions: &[String], available_counts: &mut HashMap<String, usize>) {
433 for definition in definitions {
434 let count = available_counts.entry(definition.clone()).or_insert(0);
435 *count += 1;
436 }
437}
438
439fn remove_token_definitions(definitions: &[String], available_counts: &mut HashMap<String, usize>) {
440 for definition in definitions {
441 if let Some(count) = available_counts.get_mut(definition) {
442 if *count == 1 {
443 available_counts.remove(definition);
444 } else {
445 *count -= 1;
446 }
447 }
448 }
449}
450
451#[derive(Default)]
455struct UnresolvedTokens {
456 chains: Vec<CssFallbackChain>,
457 sites: HashMap<String, TokenSite>,
458}
459
460fn record_unresolved_requirements(
461 source: &[CssFallbackChain],
462 available_counts: &HashMap<String, usize>,
463 owner: &str,
464 css_source: Option<&str>,
465 out: &mut UnresolvedTokens,
466) {
467 for requirement in source {
468 let tokens: Vec<String> = requirement
469 .tokens
470 .iter()
471 .filter(|token| !available_counts.contains_key(*token))
472 .cloned()
473 .collect();
474 if tokens.is_empty() {
475 continue;
476 }
477 for token in &tokens {
478 if !out.sites.contains_key(token) {
479 out.sites
480 .insert(token.clone(), token_site(owner, css_source, token));
481 }
482 }
483 out.chains.push(CssFallbackChain {
484 tokens,
485 has_literal_fallback: requirement.has_literal_fallback,
486 });
487 }
488}
489
490fn token_site(owner: &str, css_source: Option<&str>, token: &str) -> TokenSite {
494 let (position, snippet) = match css_source.and_then(|css| locate_css_token(css, token)) {
495 Some((line, column, snippet)) => (Some((line, column)), Some(snippet)),
496 None => (None, None),
497 };
498 TokenSite {
499 owner: owner.to_string(),
500 position,
501 snippet,
502 }
503}
504
505fn locate_css_token(css: &str, token: &str) -> Option<(usize, usize, String)> {
511 let bytes = css.as_bytes();
512 let name = token.as_bytes();
513 let mut index = 0;
514 while index + 2 + name.len() <= bytes.len() {
515 let is_prefixed = bytes[index] == b'-'
516 && bytes[index + 1] == b'-'
517 && bytes.get(index + 2..index + 2 + name.len()) == Some(name);
518 if is_prefixed {
519 let after = index + 2 + name.len();
520 let is_exact_name = bytes
521 .get(after)
522 .is_none_or(|b| !(b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_'));
523 if is_exact_name {
524 let mut cursor = after;
525 while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
526 cursor += 1;
527 }
528 if bytes.get(cursor) != Some(&b':') {
530 let (line, column) = diagnostic::line_column(css, index);
531 return Some((line, column, css_line_snippet(css, index)));
532 }
533 }
534 }
535 index += 1;
536 }
537 None
538}
539
540fn css_line_snippet(css: &str, offset: usize) -> String {
543 const MAX_SNIPPET: usize = 80;
544 let start = css[..offset].rfind('\n').map_or(0, |n| n + 1);
545 let end = css[offset..].find('\n').map_or(css.len(), |n| offset + n);
546 let line = css[start..end].trim();
547 if line.chars().count() > MAX_SNIPPET {
548 let truncated: String = line.chars().take(MAX_SNIPPET).collect();
549 format!("{truncated}…")
550 } else {
551 line.to_string()
552 }
553}
554
555fn css_owner_label(tag_name: &str) -> String {
558 let mut label = String::with_capacity(tag_name.len() + 4);
559 label.push_str(tag_name);
560 label.push_str(".css");
561 label
562}
563
564impl CssTokenAnalysis {
565 pub fn validate_theme_tokens(&self, theme: &webui_tokens::TokenFile) -> Result<()> {
582 webui_tokens::validate_chain_tokens(&self.fallback_chains, theme)
583 .map_err(|source| self.theme_token_error(source, theme))
584 }
585
586 #[must_use]
591 pub fn unthemed_literal_fallback_tokens(&self, theme: &webui_tokens::TokenFile) -> Vec<String> {
592 webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme)
593 }
594
595 #[must_use]
603 pub fn theme_token_warnings(&self, theme: &webui_tokens::TokenFile) -> Vec<Diagnostic> {
604 let mut warnings = Vec::new();
605 for token in webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme) {
606 let help = match closest_theme_token(&token, theme.themes.values()) {
607 Some(suggestion) => {
608 format!("did you mean --{suggestion}? otherwise the literal fallback is used")
609 }
610 None => format!(
611 "define --{token} in the theme, or keep its literal fallback if intentional"
612 ),
613 };
614 let diag = self.locate_diagnostic(
615 Diagnostic::warning(format!("unthemed CSS token --{token}"))
616 .code(codes::UNTHEMED_TOKEN)
617 .help(help),
618 &token,
619 );
620 warnings.push(diag);
621 }
622 warnings
623 }
624
625 #[cold]
629 #[inline(never)]
630 fn theme_token_error(
631 &self,
632 source: webui_tokens::TokenError,
633 theme: &webui_tokens::TokenFile,
634 ) -> ParserError {
635 match source {
636 webui_tokens::TokenError::MissingToken {
637 theme: theme_name,
638 token,
639 } => {
640 let help =
644 match closest_theme_token(&token, theme.themes.get(&theme_name).into_iter()) {
645 Some(suggestion) => {
646 format!("did you mean --{suggestion}? otherwise define it locally")
647 }
648 None => {
649 "define it locally if it should not come from the theme".to_string()
650 }
651 };
652 self.locate_diagnostic(
653 Diagnostic::error("missing theme token")
654 .code(codes::MISSING_THEME_TOKEN)
655 .help(help),
656 &token,
657 )
658 .into()
659 }
660 other => ParserError::Generic(format!("Theme token validation failed: {other}")),
661 }
662 }
663
664 fn locate_diagnostic(&self, diag: Diagnostic, token: &str) -> Diagnostic {
667 let Some(site) = self.token_sites.get(token) else {
668 return diag.snippet(format!("--{token}"));
669 };
670 let mut diag = diag.component(site.owner.clone());
671 if let Some((line, column)) = site.position {
672 diag = diag.position(line, column);
673 }
674 match &site.snippet {
675 Some(snippet) => diag.snippet(snippet.clone()),
676 None => diag.snippet(format!("--{token}")),
677 }
678 }
679}
680
681#[cold]
684#[inline(never)]
685fn closest_theme_token<'a>(
686 target: &str,
687 themes: impl Iterator<Item = &'a std::collections::HashMap<String, String>>,
688) -> Option<String> {
689 let mut names: Vec<&str> = themes
690 .flat_map(|theme| theme.keys().map(String::as_str))
691 .collect();
692 names.sort_unstable();
693 names.dedup();
694 crate::suggest::closest_match(target, names.into_iter()).map(ToOwned::to_owned)
695}
696
697impl HtmlParser {
698 #[must_use]
700 pub fn new() -> Self {
701 Self::with_options(ParserOptions::default())
702 }
703
704 #[must_use]
706 pub fn with_options(options: impl Into<ParserOptions>) -> Self {
707 let options = options.into();
708
709 Self {
710 component_registry: ComponentRegistry::with_legal_comments(options.legal_comments),
711 css_parser: CssParser::new(),
712 id_counter: FragmentIdCounter::new(),
713 condition_parser: ConditionParser::new(),
714 handlebars_parser: HandlebarsParser::new(),
715 raw_buffer: String::new(),
716 fragment_records: WebUIFragmentRecords::new(),
717 options,
718 plugin: None,
719 token_roots: Vec::new(),
720 fragment_css_tokens: HashMap::new(),
721 in_progress_fragments: HashSet::new(),
722 current_fragment_id: String::new(),
723 }
724 }
725
726 #[must_use]
728 pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
729 Self::with_plugin_options(plugin, ParserOptions::default())
730 }
731
732 #[must_use]
734 pub fn with_plugin_options(
735 plugin: Box<dyn ParserPlugin>,
736 options: impl Into<ParserOptions>,
737 ) -> Self {
738 let mut p = Self::with_options(options);
739 p.plugin = Some(plugin);
740 p.configure_plugin();
741 p
742 }
743
744 fn configure_plugin(&mut self) {
745 if let Some(ref mut plugin) = self.plugin {
746 plugin.configure(&self.options);
747 }
748 }
749
750 pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
752 &mut self.component_registry
753 }
754
755 #[must_use]
757 pub fn component_registry(&self) -> &ComponentRegistry {
758 &self.component_registry
759 }
760
761 pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
762 std::mem::take(&mut self.fragment_records)
763 }
764
765 pub fn has_fragment(&self, fragment_id: &str) -> bool {
767 self.fragment_records.contains_key(fragment_id)
768 }
769
770 pub fn take_plugin_artifacts(&mut self) -> Result<ParserPluginArtifacts> {
777 self.plugin
778 .take()
779 .map_or(Ok(ParserPluginArtifacts::None), |plugin| {
780 plugin.into_artifacts()
781 })
782 }
783
784 #[must_use]
788 pub fn take_tokens(&mut self) -> Vec<String> {
789 self.token_analysis().protocol_tokens
790 }
791
792 #[must_use]
799 pub fn token_analysis(&self) -> CssTokenAnalysis {
800 let (fallback_chains, token_sites) = self.collect_unresolved_fallback_chains();
801 let mut protocol_token_set = HashSet::new();
802 for chain in &fallback_chains {
803 for token in &chain.tokens {
804 protocol_token_set.insert(token.clone());
805 }
806 }
807 let mut protocol_tokens: Vec<String> = protocol_token_set.into_iter().collect();
808 protocol_tokens.sort();
809 CssTokenAnalysis {
810 protocol_tokens,
811 fallback_chains,
812 token_sites,
813 }
814 }
815
816 fn collect_unresolved_fallback_chains(
817 &self,
818 ) -> (Vec<CssFallbackChain>, HashMap<String, TokenSite>) {
819 let mut out = UnresolvedTokens::default();
820 let mut available_counts: HashMap<String, usize> = HashMap::new();
821 let mut ops: Vec<TokenGraphOp<'_>> = Vec::with_capacity(self.token_roots.len());
822 for root in self.token_roots.iter().rev() {
823 ops.push(TokenGraphOp::EnterFragment(root.as_str()));
824 }
825
826 while let Some(op) = ops.pop() {
827 match op {
828 TokenGraphOp::EnterFragment(fragment_id) => {
829 self.enter_token_fragment(
830 fragment_id,
831 &mut available_counts,
832 &mut out,
833 &mut ops,
834 );
835 }
836 TokenGraphOp::EnterComponent(tag_name) => {
837 self.enter_token_component(tag_name, &mut available_counts, &mut out, &mut ops);
838 }
839 TokenGraphOp::EnterRoute(route) => {
840 self.enter_token_route(route, &mut available_counts, &mut out, &mut ops);
841 }
842 TokenGraphOp::ExitDefinitions(definitions) => {
843 remove_token_definitions(definitions, &mut available_counts);
844 }
845 }
846 }
847
848 (out.chains, out.sites)
849 }
850
851 fn enter_token_fragment<'a>(
852 &'a self,
853 fragment_id: &'a str,
854 available_counts: &mut HashMap<String, usize>,
855 out: &mut UnresolvedTokens,
856 ops: &mut Vec<TokenGraphOp<'a>>,
857 ) {
858 if let Some(css) = self.fragment_css_tokens.get(fragment_id) {
859 add_token_definitions(&css.definitions, available_counts);
860 record_unresolved_requirements(
864 &css.fallback_chains,
865 available_counts,
866 fragment_id,
867 None,
868 out,
869 );
870 ops.push(TokenGraphOp::ExitDefinitions(&css.definitions));
871 }
872
873 let Some(fragments) = self.fragment_records.get(fragment_id) else {
874 return;
875 };
876 for fragment in fragments.fragments.iter().rev() {
877 match fragment.fragment.as_ref() {
878 Some(web_ui_fragment::Fragment::Component(component)) => {
879 ops.push(TokenGraphOp::EnterComponent(component.fragment_id.as_str()));
880 }
881 Some(web_ui_fragment::Fragment::ForLoop(for_loop)) => {
882 ops.push(TokenGraphOp::EnterFragment(for_loop.fragment_id.as_str()));
883 }
884 Some(web_ui_fragment::Fragment::IfCond(if_cond)) => {
885 ops.push(TokenGraphOp::EnterFragment(if_cond.fragment_id.as_str()));
886 }
887 Some(web_ui_fragment::Fragment::Route(route)) => {
888 ops.push(TokenGraphOp::EnterRoute(route));
889 }
890 _ => {}
891 }
892 }
893 }
894
895 fn enter_token_component<'a>(
896 &'a self,
897 tag_name: &'a str,
898 available_counts: &mut HashMap<String, usize>,
899 out: &mut UnresolvedTokens,
900 ops: &mut Vec<TokenGraphOp<'a>>,
901 ) {
902 let Some(component) = self.component_registry.get(tag_name) else {
903 return;
904 };
905 add_token_definitions(&component.css_definitions, available_counts);
906 record_unresolved_requirements(
907 &component.css_fallback_chains,
908 available_counts,
909 &css_owner_label(tag_name),
910 component.css_content.as_deref(),
911 out,
912 );
913 ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
914 ops.push(TokenGraphOp::EnterFragment(tag_name));
915 }
916
917 fn enter_token_route<'a>(
918 &'a self,
919 route: &'a WebUiFragmentRoute,
920 available_counts: &mut HashMap<String, usize>,
921 out: &mut UnresolvedTokens,
922 ops: &mut Vec<TokenGraphOp<'a>>,
923 ) {
924 let Some(component) = self.component_registry.get(&route.fragment_id) else {
925 return;
926 };
927 add_token_definitions(&component.css_definitions, available_counts);
928 record_unresolved_requirements(
929 &component.css_fallback_chains,
930 available_counts,
931 &css_owner_label(&route.fragment_id),
932 component.css_content.as_deref(),
933 out,
934 );
935 ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
936 if !route.error_component.is_empty() {
937 ops.push(TokenGraphOp::EnterComponent(route.error_component.as_str()));
938 }
939 if !route.pending_component.is_empty() {
940 ops.push(TokenGraphOp::EnterComponent(
941 route.pending_component.as_str(),
942 ));
943 }
944 for child in route.children.iter().rev() {
945 ops.push(TokenGraphOp::EnterRoute(child));
946 }
947 ops.push(TokenGraphOp::EnterFragment(route.fragment_id.as_str()));
948 }
949
950 fn record_fragment_css_tokens(
951 &mut self,
952 definitions: HashSet<String>,
953 fallback_chains: Vec<CssFallbackChain>,
954 ) {
955 let css = self
956 .fragment_css_tokens
957 .entry(self.current_fragment_id.clone())
958 .or_default();
959 css.definitions.extend(definitions);
960 css.definitions.sort();
961 css.definitions.dedup();
962 css.fallback_chains.extend(fallback_chains);
963 }
964
965 pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
967 let fragment_key = fragment_id.to_string();
968 let is_token_root = self.in_progress_fragments.is_empty();
969 let previous_fragment_id =
975 std::mem::replace(&mut self.current_fragment_id, fragment_key.clone());
976 if !self.in_progress_fragments.insert(fragment_key.clone()) {
977 let err = self
978 .authoring_error(
979 codes::RECURSIVE_TEMPLATE,
980 format!("recursive template reference while parsing <{fragment_id}>"),
981 )
982 .help(
983 "move the recursive usage behind runtime data, or split the component graph \
984 so templates do not reference themselves at build time",
985 )
986 .into();
987 self.current_fragment_id = previous_fragment_id;
988 return Err(err);
989 }
990 if is_token_root && !self.token_roots.contains(&fragment_key) {
991 self.token_roots.push(fragment_key.clone());
992 }
993
994 let result = self.parse_inner(fragment_id, html_content);
995 self.in_progress_fragments.remove(&fragment_key);
996 self.current_fragment_id = previous_fragment_id;
997 result
998 }
999
1000 fn parse_inner(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
1001 if html_content.len() > MAX_TEMPLATE_BYTES {
1002 return Err(ParserError::Html(format!(
1003 "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.",
1004 html_content.len()
1005 )));
1006 }
1007
1008 self.raw_buffer.clear();
1010 if let Some(ref mut plugin) = self.plugin {
1011 plugin.start_fragment(fragment_id);
1012 }
1013
1014 let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
1015 self.parse_range(html_content, 0..html_content.len(), &mut entry_fragment, 0)?;
1016
1017 self.flush_raw_buffer(&mut entry_fragment);
1018
1019 self.fragment_records.insert(
1020 fragment_id.to_string(),
1021 FragmentList {
1022 fragments: entry_fragment,
1023 },
1024 );
1025
1026 Ok(())
1027 }
1028
1029 fn add_raw_fragment(&mut self, content: &str) {
1031 if !content.is_empty() {
1032 self.raw_buffer.push_str(content);
1033 }
1034 }
1035
1036 fn add_for_fragment(
1038 &mut self,
1039 item: String,
1040 collection: String,
1041 fragment_id: String,
1042 fragments: &mut Vec<WebUIFragment>,
1043 ) {
1044 self.flush_raw_buffer(fragments);
1045 fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
1046 }
1047
1048 fn add_if_fragment(
1050 &mut self,
1051 condition: ConditionExpr,
1052 fragment_id: String,
1053 fragments: &mut Vec<WebUIFragment>,
1054 ) {
1055 self.flush_raw_buffer(fragments);
1056 fragments.push(WebUIFragment::if_cond(condition, fragment_id));
1057 }
1058
1059 fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
1061 self.flush_raw_buffer(fragments);
1062 fragments.push(fragment);
1063 }
1064
1065 fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
1067 if !self.raw_buffer.is_empty() {
1068 fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
1069 }
1070 }
1071
1072 fn should_emit_text_content(content: &str) -> bool {
1078 if content.is_empty() {
1079 return false;
1080 }
1081
1082 if !content.trim().is_empty() {
1083 return true;
1084 }
1085
1086 content.chars().all(char::is_whitespace)
1087 && !content.contains('\n')
1088 && !content.contains('\r')
1089 }
1090
1091 fn parse_range(
1092 &mut self,
1093 source: &str,
1094 range: Range<usize>,
1095 fragments: &mut Vec<WebUIFragment>,
1096 depth: usize,
1097 ) -> Result<()> {
1098 let mut ops = vec![ParseOp::Parse { range, depth }];
1099
1100 while let Some(op) = ops.pop() {
1101 match op {
1102 ParseOp::Parse { range, depth } => {
1103 if depth > MAX_TEMPLATE_DEPTH {
1104 return Err(self
1105 .html_error(
1106 codes::EXCESSIVE_NESTING,
1107 format!(
1108 "template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"
1109 ),
1110 source,
1111 range.start,
1112 )
1113 .help(
1114 "split deeply nested markup into components, or reduce generated nesting",
1115 )
1116 .into());
1117 }
1118
1119 let end = range.end;
1120 let mut index = range.start;
1121 while index < end {
1122 let remaining = &source[index..end];
1123 if remaining.starts_with("<!--") {
1124 let Some(close) = html::find_comment_close(remaining) else {
1125 return Err(self
1126 .html_error(
1127 codes::UNTERMINATED_HTML_COMMENT,
1128 "unterminated HTML comment",
1129 source,
1130 index,
1131 )
1132 .help("close the comment with `-->`")
1133 .into());
1134 };
1135 index += close;
1136 continue;
1137 }
1138
1139 if remaining.starts_with("<!") {
1140 let Some(close) = html::find_declaration_close(remaining) else {
1141 return Err(self
1142 .html_error(
1143 codes::UNTERMINATED_HTML_DECLARATION,
1144 "unterminated HTML declaration",
1145 source,
1146 index,
1147 )
1148 .help("close the declaration with `>`")
1149 .into());
1150 };
1151 self.add_raw_fragment(&remaining[..close]);
1152 index += close;
1153 continue;
1154 }
1155
1156 if remaining.starts_with('<') {
1157 let Some(tag) = html::parse_tag(remaining) else {
1158 return Err(self
1159 .html_error(
1160 codes::MALFORMED_HTML_TAG,
1161 "malformed HTML tag",
1162 source,
1163 index,
1164 )
1165 .help(
1166 "close the tag with `>`, or escape a literal `<` as `<`",
1167 )
1168 .into());
1169 };
1170 if tag.closing {
1171 return Err(self
1172 .html_error(
1173 codes::UNEXPECTED_CLOSING_TAG,
1174 format!("unexpected closing tag </{}>", tag.name),
1175 source,
1176 index,
1177 )
1178 .help("remove it, or add the matching opening tag before it")
1179 .into());
1180 }
1181
1182 let content_start = index + tag.close + 1;
1183 let (content_end, close_end) = if tag.self_closing
1184 || html::is_void_element(tag.name)
1185 {
1186 (content_start, content_start)
1187 } else if let Some((close_start, close_end)) =
1188 html::find_matching_end(remaining, tag.name, tag.close + 1)
1189 {
1190 (index + close_start, index + close_end)
1191 } else {
1192 return Err(self
1193 .html_error(
1194 codes::UNCLOSED_HTML_TAG,
1195 format!("unclosed <{}> tag", tag.name),
1196 source,
1197 index,
1198 )
1199 .help(format!(
1200 "add the matching </{}> closing tag, or make the element self-closing",
1201 tag.name
1202 ))
1203 .into());
1204 };
1205
1206 let element = Element {
1207 source,
1208 start: index,
1209 tag,
1210 content_start,
1211 content_end,
1212 close_end,
1213 };
1214
1215 if close_end < end {
1216 ops.push(ParseOp::Parse {
1217 range: close_end..end,
1218 depth,
1219 });
1220 }
1221
1222 match element.name() {
1223 "for" => {
1224 self.enter_for_directive(&element, fragments, depth, &mut ops)?;
1225 }
1226 "if" => {
1227 self.enter_if_directive(&element, fragments, depth, &mut ops)?;
1228 }
1229 "body" => {
1230 self.enter_body_element(&element, fragments, depth, &mut ops)?;
1231 }
1232 "head" => {
1233 self.enter_head_element(&element, depth, &mut ops);
1234 }
1235 "route" => {
1236 self.process_route_directive(&element, fragments)?;
1237 }
1238 "outlet" => {
1239 self.flush_raw_buffer(fragments);
1240 fragments.push(WebUIFragment::outlet());
1241 }
1242 name if name.eq_ignore_ascii_case("style") => {
1243 self.process_style_element(&element, fragments)?;
1244 }
1245 _ if self.component_registry.contains(element.name()) => {
1246 self.enter_component_directive(
1247 &element, fragments, depth, &mut ops,
1248 )?;
1249 }
1250 _ => {
1251 self.check_component_typo(&element)?;
1252 self.enter_regular_element(
1253 &element, fragments, depth, &mut ops,
1254 )?;
1255 }
1256 }
1257
1258 break;
1259 }
1260
1261 let next = remaining.find('<').unwrap_or(remaining.len());
1262 let text_end = if next == 0 {
1263 remaining
1264 .chars()
1265 .next()
1266 .map_or(remaining.len(), char::len_utf8)
1267 } else {
1268 next
1269 };
1270 self.process_text(&remaining[..text_end], fragments)?;
1271 index += text_end;
1272 }
1273 }
1274 ParseOp::EmitClose(name) => {
1275 self.add_raw_fragment("</");
1276 self.add_raw_fragment(name);
1277 self.add_raw_fragment(">");
1278 }
1279 ParseOp::EndHead => {
1280 self.flush_raw_buffer(fragments);
1281 fragments.push(WebUIFragment::signal("head_end", true));
1282 self.add_raw_fragment("</head>");
1283 }
1284 ParseOp::EndBody => {
1285 self.flush_raw_buffer(fragments);
1286 fragments.push(WebUIFragment::signal("body_end", true));
1287 self.add_raw_fragment("</body>");
1288 }
1289 ParseOp::CompleteFor {
1290 parent,
1291 item,
1292 collection,
1293 fragment_id,
1294 keep_empty,
1295 } => {
1296 self.flush_raw_buffer(fragments);
1297 let for_fragment = std::mem::take(fragments);
1298 *fragments = parent.fragments;
1299 self.raw_buffer = parent.raw_buffer;
1300
1301 if !for_fragment.is_empty() {
1302 self.fragment_records.insert(
1303 fragment_id.clone(),
1304 FragmentList {
1305 fragments: for_fragment,
1306 },
1307 );
1308 } else if !keep_empty {
1309 continue;
1310 }
1311
1312 self.add_for_fragment(item, collection, fragment_id, fragments);
1313 }
1314 ParseOp::CompleteIf {
1315 parent,
1316 condition,
1317 fragment_id,
1318 } => {
1319 self.flush_raw_buffer(fragments);
1320 let if_fragment = std::mem::take(fragments);
1321 *fragments = parent.fragments;
1322 self.raw_buffer = parent.raw_buffer;
1323
1324 self.fragment_records.insert(
1325 fragment_id.clone(),
1326 FragmentList {
1327 fragments: if_fragment,
1328 },
1329 );
1330 self.add_if_fragment(condition, fragment_id, fragments);
1331 }
1332 }
1333 }
1334
1335 Ok(())
1336 }
1337
1338 fn enter_regular_element<'a>(
1339 &mut self,
1340 element: &Element<'a>,
1341 fragments: &mut Vec<WebUIFragment>,
1342 depth: usize,
1343 ops: &mut Vec<ParseOp<'a>>,
1344 ) -> Result<()> {
1345 self.add_raw_fragment("<");
1346 self.add_raw_fragment(element.name());
1347
1348 let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
1349 if let Some(ref mut p) = self.plugin {
1350 if let Some(data) = p.finish_element(binding_count) {
1351 self.add_fragment(WebUIFragment::plugin(data), fragments);
1352 }
1353 }
1354
1355 if element.self_closing() {
1356 self.add_raw_fragment("/>");
1357 return Ok(());
1358 }
1359
1360 self.add_raw_fragment(">");
1361 if !element.is_void() {
1362 if element.close_end() > element.content_end() {
1363 ops.push(ParseOp::EmitClose(element.name()));
1364 }
1365 ops.push(ParseOp::Parse {
1366 range: element.inner(),
1367 depth: depth + 1,
1368 });
1369 }
1370 Ok(())
1371 }
1372
1373 fn enter_head_element<'a>(
1374 &mut self,
1375 element: &Element<'a>,
1376 depth: usize,
1377 ops: &mut Vec<ParseOp<'a>>,
1378 ) {
1379 self.add_raw_fragment("<head>");
1380 ops.push(ParseOp::EndHead);
1381 ops.push(ParseOp::Parse {
1382 range: element.inner(),
1383 depth: depth + 1,
1384 });
1385 }
1386
1387 fn enter_body_element<'a>(
1388 &mut self,
1389 element: &Element<'a>,
1390 fragments: &mut Vec<WebUIFragment>,
1391 depth: usize,
1392 ops: &mut Vec<ParseOp<'a>>,
1393 ) -> Result<()> {
1394 self.add_raw_fragment("<body");
1395 let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
1396 if let Some(ref mut p) = self.plugin {
1397 if let Some(data) = p.finish_element(binding_count) {
1398 self.add_fragment(WebUIFragment::plugin(data), fragments);
1399 }
1400 }
1401 self.add_raw_fragment(">");
1402 self.flush_raw_buffer(fragments);
1403 fragments.push(WebUIFragment::signal("body_start", true));
1404 ops.push(ParseOp::EndBody);
1405 ops.push(ParseOp::Parse {
1406 range: element.inner(),
1407 depth: depth + 1,
1408 });
1409 Ok(())
1410 }
1411
1412 #[cold]
1419 #[inline(never)]
1420 fn authoring_error(&self, code: &'static str, title: impl Into<String>) -> Diagnostic {
1421 let diag = Diagnostic::error(title).code(code);
1422 if self.current_fragment_id.is_empty() {
1423 diag
1424 } else {
1425 diag.component(self.current_fragment_id.clone())
1426 }
1427 }
1428
1429 #[cold]
1433 #[inline(never)]
1434 fn authoring_error_at(
1435 &self,
1436 code: &'static str,
1437 title: impl Into<String>,
1438 element: &Element<'_>,
1439 ) -> Diagnostic {
1440 self.authoring_error(code, title)
1441 .at_offset(element.source(), element.start)
1442 }
1443
1444 #[cold]
1448 #[inline(never)]
1449 fn html_error(
1450 &self,
1451 code: &'static str,
1452 title: impl Into<String>,
1453 source: &str,
1454 offset: usize,
1455 ) -> Diagnostic {
1456 self.authoring_error(code, title).at_offset(source, offset)
1457 }
1458
1459 #[cold]
1465 #[inline(never)]
1466 fn unknown_component_help(&self, name: &str) -> String {
1467 match suggest::closest_match(name, self.component_registry.names()) {
1468 Some(suggestion) => format!(
1469 "did you mean <{suggestion}>? otherwise register the component by adding a \
1470 matching .html file"
1471 ),
1472 None => "register the component (add a matching .html file) or check the tag name \
1473 for a typo"
1474 .to_string(),
1475 }
1476 }
1477
1478 fn suggest_component(&self, tag: &str) -> Option<&str> {
1487 let (prefix, _) = tag.split_once('-')?;
1488 let same_namespace = self
1489 .component_registry
1490 .names()
1491 .filter(|name| name.split_once('-').map(|(p, _)| p) == Some(prefix));
1492 suggest::closest_match(tag, same_namespace)
1493 }
1494
1495 fn check_component_typo(&self, element: &Element<'_>) -> Result<()> {
1500 if let Some(suggestion) = self.suggest_component(element.name()) {
1501 return Err(self
1502 .authoring_error_at(
1503 codes::UNKNOWN_COMPONENT,
1504 format!("unknown component <{}>", element.name()),
1505 element,
1506 )
1507 .help(format!(
1508 "did you mean <{suggestion}>? otherwise register <{}> by adding a matching \
1509 .html file",
1510 element.name()
1511 ))
1512 .into());
1513 }
1514 Ok(())
1515 }
1516
1517 #[cold]
1521 #[inline(never)]
1522 fn attr_typo_suggestion<'a>(element: &Element<'a>, expected: &str) -> Option<&'a str> {
1523 suggest::closest_match(expected, element.attrs().map(|attr| attr.name))
1524 .filter(|&name| name != expected)
1525 }
1526
1527 #[cold]
1533 #[inline(never)]
1534 fn css_diagnostic(&self, err: ParserError) -> ParserError {
1535 match err {
1536 ParserError::Css(message) => self.authoring_error(codes::INVALID_CSS, message).into(),
1537 other => other,
1538 }
1539 }
1540
1541 #[cold]
1543 #[inline(never)]
1544 fn for_each_missing_error(&self, element: &Element<'_>) -> ParserError {
1545 let diag = self
1546 .authoring_error_at(
1547 codes::MISSING_FOR_EACH,
1548 "missing each attribute on <for>",
1549 element,
1550 )
1551 .element("for");
1552 match Self::attr_typo_suggestion(element, "each") {
1553 Some(typo) => diag.help(format!(
1554 "found `{typo}` \u{2014} did you mean each=\"item in collection\"?"
1555 )),
1556 None => diag.help("add each=\"item in collection\", e.g. <for each=\"todo in todos\">"),
1557 }
1558 .into()
1559 }
1560
1561 #[cold]
1563 #[inline(never)]
1564 fn for_each_invalid_error(&self, element: &Element<'_>, each: &str) -> ParserError {
1565 self.authoring_error_at(
1566 codes::INVALID_FOR_EACH,
1567 "invalid <for> each expression",
1568 element,
1569 )
1570 .element("for")
1571 .snippet(format!("each=\"{each}\""))
1572 .help("use the form each=\"item in collection\", e.g. each=\"todo in todos\"")
1573 .into()
1574 }
1575
1576 #[cold]
1579 #[inline(never)]
1580 fn for_identifier_error(&self, element: &Element<'_>, each: &str) -> ParserError {
1581 self.authoring_error_at(
1582 codes::INVALID_FOR_IDENTIFIER,
1583 "invalid identifier in <for> each expression",
1584 element,
1585 )
1586 .element("for")
1587 .snippet(format!("each=\"{each}\""))
1588 .help("item and collection names may use only letters, digits, '_', '-', and '.'")
1589 .into()
1590 }
1591
1592 fn enter_for_directive<'a>(
1593 &mut self,
1594 element: &Element<'a>,
1595 fragments: &mut Vec<WebUIFragment>,
1596 depth: usize,
1597 ops: &mut Vec<ParseOp<'a>>,
1598 ) -> Result<()> {
1599 let each = element
1600 .attr("each")
1601 .map(ToString::to_string)
1602 .ok_or_else(|| self.for_each_missing_error(element))?;
1603
1604 let mut parts = each.split_whitespace();
1605 let (Some(item), Some(in_kw), Some(collection), None) =
1606 (parts.next(), parts.next(), parts.next(), parts.next())
1607 else {
1608 return Err(self.for_each_invalid_error(element, &each));
1609 };
1610 if in_kw != "in" {
1611 return Err(self.for_each_invalid_error(element, &each));
1612 }
1613
1614 let allowed = |s: &str| {
1615 s.chars()
1616 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1617 };
1618 if !allowed(item) || !allowed(collection) {
1619 return Err(self.for_identifier_error(element, &each));
1620 }
1621
1622 let custom_fragment_id = element.attr("template").map(ToString::to_string);
1623 let keep_empty = custom_fragment_id.is_some();
1624 let fragment_id = custom_fragment_id.unwrap_or_else(|| self.id_counter.next_id("for"));
1625 let parent = ParseContext {
1626 fragments: std::mem::take(fragments),
1627 raw_buffer: std::mem::take(&mut self.raw_buffer),
1628 };
1629
1630 ops.push(ParseOp::CompleteFor {
1631 parent,
1632 item: item.to_string(),
1633 collection: collection.to_string(),
1634 fragment_id,
1635 keep_empty,
1636 });
1637 ops.push(ParseOp::Parse {
1638 range: element.inner(),
1639 depth: depth + 1,
1640 });
1641 Ok(())
1642 }
1643
1644 #[cold]
1647 #[inline(never)]
1648 fn if_condition_missing_error(&self, element: &Element<'_>) -> ParserError {
1649 let diag = self
1650 .authoring_error_at(
1651 codes::MISSING_IF_CONDITION,
1652 "missing condition attribute on <if>",
1653 element,
1654 )
1655 .element("if");
1656 match Self::attr_typo_suggestion(element, "condition") {
1657 Some(typo) => diag.help(format!(
1658 "found `{typo}` \u{2014} did you mean condition=\"expression\"?"
1659 )),
1660 None => diag.help("add condition=\"expression\", e.g. <if condition=\"isActive\">"),
1661 }
1662 .into()
1663 }
1664
1665 #[cold]
1667 #[inline(never)]
1668 fn if_condition_invalid_error(&self, element: &Element<'_>, condition: &str) -> ParserError {
1669 self.authoring_error_at(
1670 codes::INVALID_IF_CONDITION,
1671 "invalid <if> condition expression",
1672 element,
1673 )
1674 .element("if")
1675 .snippet(format!("condition=\"{condition}\""))
1676 .help("use a simple expression like \"isActive\", \"count > 0\", or \"!hidden\"")
1677 .into()
1678 }
1679
1680 fn enter_if_directive<'a>(
1681 &mut self,
1682 element: &Element<'a>,
1683 fragments: &mut Vec<WebUIFragment>,
1684 depth: usize,
1685 ops: &mut Vec<ParseOp<'a>>,
1686 ) -> Result<()> {
1687 let condition_str = element
1688 .attr("condition")
1689 .map(ToString::to_string)
1690 .ok_or_else(|| self.if_condition_missing_error(element))?;
1691
1692 let condition = self
1693 .condition_parser
1694 .parse(&condition_str)
1695 .map_err(|_| self.if_condition_invalid_error(element, &condition_str))?;
1696
1697 self.flush_raw_buffer(fragments);
1698 let parent = ParseContext {
1699 fragments: std::mem::take(fragments),
1700 raw_buffer: std::mem::take(&mut self.raw_buffer),
1701 };
1702 let fragment_id = self.id_counter.next_id("if");
1703
1704 ops.push(ParseOp::CompleteIf {
1705 parent,
1706 condition,
1707 fragment_id,
1708 });
1709 ops.push(ParseOp::Parse {
1710 range: element.inner(),
1711 depth: depth + 1,
1712 });
1713 Ok(())
1714 }
1715
1716 fn enter_component_directive<'a>(
1717 &mut self,
1718 element: &Element<'a>,
1719 fragments: &mut Vec<WebUIFragment>,
1720 depth: usize,
1721 ops: &mut Vec<ParseOp<'a>>,
1722 ) -> Result<()> {
1723 self.add_raw_fragment("<");
1724 self.add_raw_fragment(element.name());
1725
1726 let binding_count = self.process_tag_attributes(element.attrs(), fragments, true)?;
1727 if let Some(ref mut p) = self.plugin {
1728 if let Some(data) = p.finish_element(binding_count) {
1729 self.add_fragment(WebUIFragment::plugin(data), fragments);
1730 }
1731 }
1732
1733 if element.self_closing() {
1734 self.add_raw_fragment("/>");
1735 } else {
1736 self.add_raw_fragment(">");
1737 }
1738
1739 self.flush_raw_buffer(fragments);
1740
1741 let (html_content, css_content) = {
1742 let component = self.component_registry.get(element.name()).ok_or_else(|| {
1743 self.authoring_error_at(
1744 codes::UNKNOWN_COMPONENT,
1745 format!("unknown component <{}>", element.name()),
1746 element,
1747 )
1748 .help(self.unknown_component_help(element.name()))
1749 })?;
1750 (
1751 component.html_content.clone(),
1752 component.css_content.clone(),
1753 )
1754 };
1755
1756 if !self.fragment_records.contains_key(element.name()) {
1757 let component_data = self
1758 .component_registry
1759 .get(element.name())
1760 .ok_or_else(|| {
1761 self.authoring_error_at(
1762 codes::UNKNOWN_COMPONENT,
1763 format!("unknown component <{}>", element.name()),
1764 element,
1765 )
1766 .help(self.unknown_component_help(element.name()))
1767 })?
1768 .clone();
1769 let built = self.build_component_templates(
1770 element.name(),
1771 &html_content,
1772 css_content.as_deref(),
1773 self.plugin.is_some(),
1774 )?;
1775
1776 if let Some(ref mut p) = self.plugin {
1777 p.register_component_template(element.name(), &component_data, built.artifact())?;
1778 }
1779
1780 self.parse(element.name(), &built.ssr)?;
1781 }
1782
1783 fragments.push(WebUIFragment::component(element.name().to_string()));
1784
1785 if !element.self_closing() {
1786 ops.push(ParseOp::EmitClose(element.name()));
1787 ops.push(ParseOp::Parse {
1788 range: element.inner(),
1789 depth: depth + 1,
1790 });
1791 }
1792
1793 Ok(())
1794 }
1795
1796 fn process_text(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
1797 if !Self::should_emit_text_content(content) {
1798 return Ok(());
1799 }
1800
1801 let parsed_fragments = self.handlebars_parser.parse(content)?;
1802 for fragment in parsed_fragments {
1803 if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
1804 if Self::should_emit_text_content(&raw.value) {
1805 self.add_raw_fragment(&raw.value);
1806 }
1807 } else {
1808 self.add_fragment(fragment, fragments);
1809 }
1810 }
1811 Ok(())
1812 }
1813
1814 fn process_style_content(
1815 &mut self,
1816 css: &str,
1817 comments: &[crate::css_parser::CssComment],
1818 fragments: &mut Vec<WebUIFragment>,
1819 ) {
1820 let mut last_end = 0usize;
1821
1822 for comment in comments {
1823 if comment.start_byte < last_end {
1824 if comment.end_byte > last_end {
1825 last_end = comment.end_byte;
1826 }
1827 continue;
1828 }
1829
1830 self.add_raw_fragment(&css[last_end..comment.start_byte]);
1831 let comment_text = &css[comment.start_byte..comment.end_byte];
1832 if let Some(fragment) = self.css_signal_comment_fragment(comment_text) {
1833 self.add_fragment(fragment, fragments);
1834 } else if comment.preserve {
1835 self.add_raw_fragment(comment_text);
1836 }
1837 last_end = comment.end_byte;
1838 }
1839
1840 self.add_raw_fragment(&css[last_end..]);
1841 }
1842
1843 fn css_signal_comment_fragment(&self, comment: &str) -> Option<WebUIFragment> {
1844 let signal = comment_policy::parse_css_signal_comment(comment)?;
1845 Some(WebUIFragment::signal(signal.path, signal.raw))
1846 }
1847
1848 fn extract_single_handlebars(value: &str) -> Option<&str> {
1851 let trimmed = value.trim();
1852 if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
1853 let inner = trimmed[2..trimmed.len() - 2].trim();
1854 if !inner.contains("{{") && !inner.is_empty() {
1856 return Some(inner);
1857 }
1858 }
1859 None
1860 }
1861
1862 fn contains_handlebars(value: &str) -> bool {
1864 value.contains("{{")
1865 }
1866
1867 fn process_tag_attributes(
1868 &mut self,
1869 attrs: Attrs<'_>,
1870 fragments: &mut Vec<WebUIFragment>,
1871 is_component: bool,
1872 ) -> Result<u32> {
1873 let mut first_dynamic_emitted = false;
1874 let mut binding_count: u32 = 0;
1875 for attr in attrs {
1876 let attr_name = attr.name;
1877
1878 if let Some(ref mut p) = self.plugin {
1879 match p.classify_attribute(attr_name) {
1880 AttributeAction::Keep => {}
1881 AttributeAction::Skip => continue,
1882 AttributeAction::SkipAndCountBinding => {
1883 binding_count += 1;
1884 continue;
1885 }
1886 }
1887 }
1888
1889 let attr_value = attr.value;
1890
1891 if let Some(bool_name) = attr_name.strip_prefix('?') {
1892 if is_component {
1893 if let Some(condition) = self.parse_boolean_condition(attr_value) {
1894 let frag = Self::maybe_mark_attr_start(
1895 WebUIFragment::attribute_boolean(bool_name, condition),
1896 &mut first_dynamic_emitted,
1897 );
1898 self.add_fragment(frag, fragments);
1899 binding_count += 1;
1900 }
1901 } else if self.process_boolean_attribute(bool_name, attr_value, fragments)? {
1902 binding_count += 1;
1903 }
1904 } else if let Some(prop_name) = attr_name.strip_prefix(':') {
1905 if !is_component {
1906 return Err(ParserError::Parse(format!(
1907 ":{prop_name} complex binding is only allowed on custom elements. \
1908 Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
1909 )));
1910 }
1911 if Self::is_blocked_complex_property(prop_name) {
1912 return Err(ParserError::Parse(format!(
1913 ":{prop_name} is not allowed as a complex attribute binding \
1914 because it enables arbitrary HTML injection. \
1915 Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
1916 )));
1917 }
1918 if let Some(val) = attr_value {
1919 if let Some(signal_name) = Self::extract_single_handlebars(val) {
1920 let frag = Self::maybe_mark_attr_start(
1921 WebUIFragment::attribute_complex(attr_name, signal_name),
1922 &mut first_dynamic_emitted,
1923 );
1924 self.add_fragment(frag, fragments);
1925 binding_count += 1;
1926 }
1927 }
1928 } else if is_component && Self::is_skipped_attribute(attr_name) {
1929 if let Some(val) = attr_value {
1930 if let Some(signal_name) = Self::extract_single_handlebars(val) {
1931 let frag = WebUIFragment {
1932 fragment: Some(web_ui_fragment::Fragment::Attribute(
1933 WebUIFragmentAttribute {
1934 name: attr_name.to_string(),
1935 value: signal_name.to_string(),
1936 attr_skip: true,
1937 ..Default::default()
1938 },
1939 )),
1940 };
1941 self.add_fragment(frag, fragments);
1942 binding_count += 1;
1943 } else if Self::contains_handlebars(val) {
1944 let template_id = self.id_counter.next_id("attr");
1945 let parsed = self.handlebars_parser.parse(val)?;
1946 self.fragment_records
1947 .insert(template_id.clone(), FragmentList { fragments: parsed });
1948 let frag = WebUIFragment {
1949 fragment: Some(web_ui_fragment::Fragment::Attribute(
1950 WebUIFragmentAttribute {
1951 name: attr_name.to_string(),
1952 template: template_id,
1953 attr_skip: true,
1954 ..Default::default()
1955 },
1956 )),
1957 };
1958 self.add_fragment(frag, fragments);
1959 binding_count += 1;
1960 } else {
1961 let frag = WebUIFragment {
1962 fragment: Some(web_ui_fragment::Fragment::Attribute(
1963 WebUIFragmentAttribute {
1964 name: attr_name.to_string(),
1965 value: val.to_string(),
1966 raw_value: true,
1967 attr_skip: true,
1968 ..Default::default()
1969 },
1970 )),
1971 };
1972 self.add_fragment(frag, fragments);
1973 }
1974 }
1975 } else if let Some(val) = attr_value {
1976 if Self::contains_handlebars(val) {
1977 if is_component {
1978 if let Some(signal_name) = Self::extract_single_handlebars(val) {
1979 let frag = Self::maybe_mark_attr_start(
1980 WebUIFragment::attribute(attr_name, signal_name),
1981 &mut first_dynamic_emitted,
1982 );
1983 self.add_fragment(frag, fragments);
1984 binding_count += 1;
1985 } else {
1986 let template_id = self.id_counter.next_id("attr");
1987 let parsed = self.handlebars_parser.parse(val)?;
1988 self.fragment_records
1989 .insert(template_id.clone(), FragmentList { fragments: parsed });
1990 let frag = Self::maybe_mark_attr_start(
1991 WebUIFragment::attribute_template(attr_name, template_id),
1992 &mut first_dynamic_emitted,
1993 );
1994 self.add_fragment(frag, fragments);
1995 binding_count += 1;
1996 }
1997 } else {
1998 self.process_dynamic_attribute(attr_name, val, fragments)?;
1999 binding_count += 1;
2000 }
2001 } else if is_component {
2002 let frag = Self::maybe_mark_attr_start(
2003 WebUIFragment {
2004 fragment: Some(web_ui_fragment::Fragment::Attribute(
2005 WebUIFragmentAttribute {
2006 name: attr_name.to_string(),
2007 value: val.to_string(),
2008 raw_value: true,
2009 ..Default::default()
2010 },
2011 )),
2012 },
2013 &mut first_dynamic_emitted,
2014 );
2015 self.add_fragment(frag, fragments);
2016 } else {
2017 self.add_raw_fragment(" ");
2018 self.add_raw_fragment(attr.raw);
2019 }
2020 } else {
2021 self.add_raw_fragment(" ");
2022 self.add_raw_fragment(attr_name);
2023 }
2024 }
2025 Ok(binding_count)
2026 }
2027
2028 fn maybe_mark_attr_start(
2031 mut frag: WebUIFragment,
2032 first_dynamic_emitted: &mut bool,
2033 ) -> WebUIFragment {
2034 if !*first_dynamic_emitted {
2035 if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
2036 a.attr_start = true;
2037 }
2038 *first_dynamic_emitted = true;
2039 }
2040 frag
2041 }
2042
2043 fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
2046 if let Some(val) = value {
2047 if let Some(expr_str) = Self::extract_single_handlebars(val) {
2048 return Some(
2049 self.condition_parser
2050 .parse(expr_str)
2051 .unwrap_or_else(|_| ConditionExpr::identifier(expr_str)),
2052 );
2053 }
2054 }
2055
2056 None
2057 }
2058
2059 fn process_boolean_attribute(
2060 &mut self,
2061 name: &str,
2062 value: Option<&str>,
2063 fragments: &mut Vec<WebUIFragment>,
2064 ) -> Result<bool> {
2065 if let Some(condition) = self.parse_boolean_condition(value) {
2066 self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
2067 return Ok(true);
2068 }
2069 Ok(false)
2071 }
2072
2073 fn process_dynamic_attribute(
2075 &mut self,
2076 name: &str,
2077 value: &str,
2078 fragments: &mut Vec<WebUIFragment>,
2079 ) -> Result<()> {
2080 if let Some(signal_name) = Self::extract_single_handlebars(value) {
2081 self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
2083 } else {
2084 let template_id = self.id_counter.next_id("attr");
2086 let parsed = self.handlebars_parser.parse(value)?;
2087
2088 self.fragment_records
2089 .insert(template_id.clone(), FragmentList { fragments: parsed });
2090
2091 self.add_fragment(
2092 WebUIFragment::attribute_template(name, template_id),
2093 fragments,
2094 );
2095 }
2096 Ok(())
2097 }
2098
2099 fn process_style_element(
2100 &mut self,
2101 element: &Element<'_>,
2102 fragments: &mut Vec<WebUIFragment>,
2103 ) -> Result<()> {
2104 self.add_raw_fragment(element.opening());
2105 let inner = element.inner();
2106 let style_content = &element.source()[inner.start..inner.end];
2107 let (_tokens, defs, requirements, comments) = self
2108 .css_parser
2109 .extract_tokens_definitions_requirements_and_comments(
2110 style_content,
2111 self.options.legal_comments,
2112 )
2113 .map_err(|e| self.css_diagnostic(e))?;
2114 self.record_fragment_css_tokens(defs, requirements);
2115 self.process_style_content(style_content, &comments, fragments);
2116 if element.close_end() > element.content_end() {
2117 self.add_raw_fragment("</");
2121 self.add_raw_fragment(element.name());
2122 self.add_raw_fragment(">");
2123 }
2124 Ok(())
2125 }
2126
2127 fn process_route_directive(
2133 &mut self,
2134 element: &Element<'_>,
2135 fragments: &mut Vec<WebUIFragment>,
2136 ) -> Result<()> {
2137 let attrs = Self::route_attrs_from_element(element);
2138 let path = attrs.path.clone();
2139 let component = attrs.component.clone();
2140 route_parser::validate_attributes(&attrs)?;
2141
2142 let route_params: std::collections::HashSet<String> =
2143 route_parser::extract_params(&path)?.into_iter().collect();
2144 if !attrs.cache_tags.is_empty() {
2145 route_parser::validate_tag_placeholders(
2146 &attrs.cache_tags,
2147 &route_params,
2148 "cache-tags",
2149 &path,
2150 )?;
2151 }
2152 if !attrs.invalidates.is_empty() {
2153 route_parser::validate_tag_placeholders(
2154 &attrs.invalidates,
2155 &route_params,
2156 "invalidates",
2157 &path,
2158 )?;
2159 }
2160
2161 if !attrs.pending_component.is_empty() {
2162 self.ensure_route_component_parsed(&attrs.pending_component)?;
2163 }
2164 if !attrs.error_component.is_empty() {
2165 self.ensure_route_component_parsed(&attrs.error_component)?;
2166 }
2167 self.ensure_route_component_parsed(&component)?;
2168
2169 let mut all_params = std::collections::HashSet::new();
2170 all_params.extend(route_params);
2171 self.validate_route_nesting_depth(element.source(), element.inner(), 1)?;
2172 let children =
2173 self.parse_child_routes(element.source(), element.inner(), &all_params, 1)?;
2174
2175 self.flush_raw_buffer(fragments);
2176 let route_fragment =
2177 route_parser::build_route_fragment(&attrs, component.clone(), children);
2178 fragments.push(WebUIFragment::route_from(route_fragment));
2179
2180 Ok(())
2181 }
2182
2183 fn parse_child_routes(
2190 &mut self,
2191 source: &str,
2192 range: Range<usize>,
2193 ancestor_params: &std::collections::HashSet<String>,
2194 depth: usize,
2195 ) -> Result<Vec<WebUiFragmentRoute>> {
2196 if depth > MAX_TEMPLATE_DEPTH {
2197 return Err(self
2198 .html_error(
2199 codes::EXCESSIVE_NESTING,
2200 format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
2201 source,
2202 range.start,
2203 )
2204 .help("flatten deeply nested <route> trees before building")
2205 .into());
2206 }
2207
2208 let mut children = Vec::new();
2209 for event in Walker::new_range(source, range.start, range.end) {
2210 match event {
2211 Event::Element(element) => {
2212 self.validate_closed_element(&element)?;
2213 if element.name() == "route" {
2214 children.push(self.parse_route_as_fragment(
2215 &element,
2216 ancestor_params,
2217 depth,
2218 )?);
2219 } else if element.name().eq_ignore_ascii_case("style") {
2220 self.validate_style_element(&element)?;
2221 } else if !element.self_closing() && !element.is_void() {
2222 self.validate_ignored_route_html(element.source(), element.inner(), depth)?;
2223 }
2224 }
2225 Event::Text(text) => {
2226 if text.contains('<') {
2227 return Err(self
2228 .authoring_error(
2229 codes::MALFORMED_HTML_TAG,
2230 "malformed HTML tag in <route>",
2231 )
2232 .help("close the tag with `>`, or escape a literal `<` as `<`")
2233 .into());
2234 }
2235 }
2236 Event::Comment(comment_range) => {
2237 self.validate_comment_range(source, comment_range)?;
2238 }
2239 Event::Declaration(declaration_range) => {
2240 self.validate_declaration_range(source, declaration_range)?;
2241 }
2242 Event::ClosingTag(closing_range) => {
2243 return Err(self.unexpected_closing_tag_error(source, closing_range));
2244 }
2245 }
2246 }
2247
2248 Ok(children)
2249 }
2250
2251 fn validate_route_nesting_depth(
2252 &self,
2253 source: &str,
2254 range: Range<usize>,
2255 depth: usize,
2256 ) -> Result<()> {
2257 let mut stack = vec![(range, depth)];
2258 while let Some((range, depth)) = stack.pop() {
2259 if depth > MAX_TEMPLATE_DEPTH {
2260 return Err(self
2261 .html_error(
2262 codes::EXCESSIVE_NESTING,
2263 format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
2264 source,
2265 range.start,
2266 )
2267 .help("flatten deeply nested <route> trees before building")
2268 .into());
2269 }
2270
2271 for event in Walker::new_range(source, range.start, range.end) {
2272 if let Event::Element(element) = event {
2273 self.validate_closed_element(&element)?;
2274 if element.name() == "route" && !element.self_closing() {
2275 stack.push((element.inner(), depth + 1));
2276 }
2277 }
2278 }
2279 }
2280 Ok(())
2281 }
2282
2283 fn validate_ignored_route_html(
2284 &mut self,
2285 source: &str,
2286 range: Range<usize>,
2287 depth: usize,
2288 ) -> Result<()> {
2289 let mut stack = vec![(range, depth)];
2290 while let Some((range, depth)) = stack.pop() {
2291 if depth > MAX_TEMPLATE_DEPTH {
2292 return Err(self
2293 .html_error(
2294 codes::EXCESSIVE_NESTING,
2295 format!("template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
2296 source,
2297 range.start,
2298 )
2299 .help("split deeply nested markup into components, or reduce generated nesting")
2300 .into());
2301 }
2302
2303 for event in Walker::new_range(source, range.start, range.end) {
2304 match event {
2305 Event::Element(element) => {
2306 self.validate_closed_element(&element)?;
2307 if element.name().eq_ignore_ascii_case("style") {
2308 self.validate_style_element(&element)?;
2309 } else if !element.self_closing() && !element.is_void() {
2310 stack.push((element.inner(), depth + 1));
2311 }
2312 }
2313 Event::Text(text) => {
2314 if text.contains('<') {
2315 return Err(self
2316 .authoring_error(
2317 codes::MALFORMED_HTML_TAG,
2318 "malformed HTML tag in <route>",
2319 )
2320 .help("close the tag with `>`, or escape a literal `<` as `<`")
2321 .into());
2322 }
2323 }
2324 Event::Comment(comment_range) => {
2325 self.validate_comment_range(source, comment_range)?;
2326 }
2327 Event::Declaration(declaration_range) => {
2328 self.validate_declaration_range(source, declaration_range)?;
2329 }
2330 Event::ClosingTag(closing_range) => {
2331 return Err(self.unexpected_closing_tag_error(source, closing_range));
2332 }
2333 }
2334 }
2335 }
2336 Ok(())
2337 }
2338
2339 fn validate_closed_element(&self, element: &Element<'_>) -> Result<()> {
2340 if !element.self_closing()
2341 && !element.is_void()
2342 && element.close_end() == element.content_end()
2343 {
2344 return Err(self
2345 .html_error(
2346 codes::UNCLOSED_HTML_TAG,
2347 format!("unclosed <{}> tag", element.name()),
2348 element.source(),
2349 element.start,
2350 )
2351 .help(format!(
2352 "add the matching </{}> closing tag, or make the element self-closing",
2353 element.name()
2354 ))
2355 .into());
2356 }
2357 Ok(())
2358 }
2359
2360 fn validate_style_element(&mut self, element: &Element<'_>) -> Result<()> {
2361 let inner = element.inner();
2362 let style_content = &element.source()[inner.start..inner.end];
2363 self.css_parser
2364 .extract_tokens_definitions_requirements_and_comments(
2365 style_content,
2366 self.options.legal_comments,
2367 )
2368 .map_err(|e| self.css_diagnostic(e))?;
2369 Ok(())
2370 }
2371
2372 fn validate_comment_range(&self, source: &str, range: Range<usize>) -> Result<()> {
2373 let start = range.start;
2374 if source[range].ends_with("-->") {
2375 return Ok(());
2376 }
2377 Err(self
2378 .html_error(
2379 codes::UNTERMINATED_HTML_COMMENT,
2380 "unterminated HTML comment in <route>",
2381 source,
2382 start,
2383 )
2384 .help("close the comment with `-->`")
2385 .into())
2386 }
2387
2388 fn validate_declaration_range(&self, source: &str, range: Range<usize>) -> Result<()> {
2389 let start = range.start;
2390 if source[range].ends_with('>') {
2391 return Ok(());
2392 }
2393 Err(self
2394 .html_error(
2395 codes::UNTERMINATED_HTML_DECLARATION,
2396 "unterminated HTML declaration in <route>",
2397 source,
2398 start,
2399 )
2400 .help("close the declaration with `>`")
2401 .into())
2402 }
2403
2404 fn unexpected_closing_tag_error(&self, source: &str, range: Range<usize>) -> ParserError {
2405 let start = range.start;
2406 let title = match html::parse_tag(&source[range]) {
2407 Some(tag) => format!("unexpected closing tag </{}> in <route>", tag.name),
2408 None => "unexpected closing tag in <route>".to_string(),
2409 };
2410 self.html_error(codes::UNEXPECTED_CLOSING_TAG, title, source, start)
2411 .help("remove it, or add the matching opening tag before it")
2412 .into()
2413 }
2414
2415 fn route_attrs_from_element(element: &Element<'_>) -> route_parser::RouteAttributes {
2416 route_parser::RouteAttributes {
2417 path: element.attr("path").unwrap_or_default().to_string(),
2418 component: element.attr("component").unwrap_or_default().to_string(),
2419 exact: element.has_attr("exact"),
2420 query: element.attr("query").unwrap_or_default().to_string(),
2421 keep_alive: element.has_attr("keep-alive"),
2422 cache_tags: element
2423 .attr("cache-tags")
2424 .map(route_parser::parse_tag_list)
2425 .unwrap_or_default(),
2426 invalidates: element
2427 .attr("invalidates")
2428 .map(route_parser::parse_tag_list)
2429 .unwrap_or_default(),
2430 pending_component: element.attr("pending").unwrap_or_default().to_string(),
2431 error_component: element.attr("error").unwrap_or_default().to_string(),
2432 }
2433 }
2434
2435 fn parse_route_as_fragment(
2436 &mut self,
2437 element: &Element<'_>,
2438 ancestor_params: &std::collections::HashSet<String>,
2439 depth: usize,
2440 ) -> Result<WebUiFragmentRoute> {
2441 let attrs = Self::route_attrs_from_element(element);
2442 let path = attrs.path.clone();
2443 let component = attrs.component.clone();
2444
2445 route_parser::validate_attributes(&attrs)?;
2446
2447 let own_params: std::collections::HashSet<String> =
2448 route_parser::extract_params(&path)?.into_iter().collect();
2449 let mut all_params = ancestor_params.clone();
2450 all_params.extend(own_params.iter().cloned());
2451 if !attrs.cache_tags.is_empty() {
2452 route_parser::validate_tag_placeholders(
2453 &attrs.cache_tags,
2454 &all_params,
2455 "cache-tags",
2456 &path,
2457 )?;
2458 }
2459 if !attrs.invalidates.is_empty() {
2460 route_parser::validate_tag_placeholders(
2461 &attrs.invalidates,
2462 &all_params,
2463 "invalidates",
2464 &path,
2465 )?;
2466 }
2467
2468 if !attrs.pending_component.is_empty() {
2469 self.ensure_route_component_parsed(&attrs.pending_component)?;
2470 }
2471 if !attrs.error_component.is_empty() {
2472 self.ensure_route_component_parsed(&attrs.error_component)?;
2473 }
2474
2475 self.ensure_route_component_parsed(&component)?;
2476 let children =
2477 self.parse_child_routes(element.source(), element.inner(), &all_params, depth + 1)?;
2478
2479 Ok(route_parser::build_route_fragment(
2480 &attrs, component, children,
2481 ))
2482 }
2483
2484 fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
2486 if component.is_empty()
2487 || !self.component_registry.contains(component)
2488 || self.fragment_records.contains_key(component)
2489 {
2490 return Ok(());
2491 }
2492
2493 let component_data = self
2494 .component_registry
2495 .get(component)
2496 .ok_or_else(|| {
2497 self.authoring_error(
2498 codes::UNKNOWN_COMPONENT,
2499 format!("unknown component <{component}>"),
2500 )
2501 .help(self.unknown_component_help(component))
2502 })?
2503 .clone();
2504
2505 let built = self.build_component_templates(
2506 component,
2507 &component_data.html_content,
2508 component_data.css_content.as_deref(),
2509 self.plugin.is_some(),
2510 )?;
2511
2512 if let Some(ref mut p) = self.plugin {
2513 p.register_component_template(component, &component_data, built.artifact())?;
2514 }
2515
2516 let saved_buffer = std::mem::take(&mut self.raw_buffer);
2517 self.parse(component, &built.ssr)?;
2518 self.raw_buffer = saved_buffer;
2519
2520 Ok(())
2521 }
2522
2523 const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
2525 const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-", "aria-"];
2527 const ADOPTED_STYLESHEETS_ATTR: &str = "shadowrootadoptedstylesheets";
2528
2529 fn is_skipped_attribute(name: &str) -> bool {
2530 Self::SKIPPED_ATTRIBUTES.contains(&name)
2531 || Self::SKIPPED_ATTRIBUTE_PREFIXES
2532 .iter()
2533 .any(|prefix| name.starts_with(prefix))
2534 }
2535
2536 fn is_blocked_complex_property(name: &str) -> bool {
2539 matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
2540 }
2541
2542 fn build_component_templates(
2544 &mut self,
2545 tag_name: &str,
2546 html: &str,
2547 css_content: Option<&str>,
2548 artifact_needed: bool,
2549 ) -> Result<BuiltComponentTemplate> {
2550 let adopted_specifier = match self.options.css_strategy {
2551 CssStrategy::Module if css_content.is_some() => Some(tag_name),
2552 _ => None,
2553 };
2554 let css_injection = match self.options.css_strategy {
2555 CssStrategy::Link => {
2556 if let (Some(css), DomStrategy::Shadow) = (css_content, self.options.dom_strategy) {
2559 let href = self.options.css_link_options.resolve(tag_name, css);
2560 let mut link = String::with_capacity(31 + href.href.len());
2561 link.push_str("<link rel=\"stylesheet\" href=\"");
2562 link.push_str(&href.href);
2563 link.push_str("\">");
2564 Some(link)
2565 } else {
2566 None
2567 }
2568 }
2569 CssStrategy::Style => css_content.map(|css| {
2570 let trimmed = css.trim();
2571 let mut style = String::with_capacity(15 + trimmed.len());
2572 style.push_str("<style>");
2573 style.push_str(trimmed);
2574 style.push_str("</style>");
2575 style
2576 }),
2577 CssStrategy::Module => None,
2578 };
2579
2580 let artifact_differs = artifact_needed && Self::template_has_stripped_runtime_attrs(html);
2581 let ssr =
2582 self.process_component_template(html, css_injection.as_deref(), adopted_specifier)?;
2583 let artifact = if artifact_differs {
2584 Some(self.process_component_artifact_template(
2585 html,
2586 css_injection.as_deref(),
2587 adopted_specifier,
2588 )?)
2589 } else {
2590 None
2591 };
2592
2593 Ok(BuiltComponentTemplate { ssr, artifact })
2594 }
2595
2596 fn process_component_template(
2621 &mut self,
2622 html: &str,
2623 css_snippet: Option<&str>,
2624 adopted_specifier: Option<&str>,
2625 ) -> Result<String> {
2626 self.process_component_template_with_mode(html, css_snippet, adopted_specifier, false)
2627 }
2628
2629 fn process_component_artifact_template(
2630 &mut self,
2631 html: &str,
2632 css_snippet: Option<&str>,
2633 adopted_specifier: Option<&str>,
2634 ) -> Result<String> {
2635 self.process_component_template_with_mode(html, css_snippet, adopted_specifier, true)
2636 }
2637
2638 fn process_component_template_with_mode(
2639 &mut self,
2640 html: &str,
2641 css_snippet: Option<&str>,
2642 adopted_specifier: Option<&str>,
2643 preserve_runtime_attrs: bool,
2644 ) -> Result<String> {
2645 let trimmed = html.trim();
2646 let snippet = css_snippet.unwrap_or_default();
2647
2648 let processed = if trimmed.starts_with("<template") {
2649 let base = if preserve_runtime_attrs {
2650 trimmed.to_string()
2651 } else {
2652 self.strip_runtime_attrs_from_template(trimmed)
2653 };
2654 let with_adopted = Self::append_adopted_attr_if_missing(base, adopted_specifier);
2655 Self::inject_css_snippet_into_template(with_adopted, snippet)
2656 } else {
2657 match self.options.dom_strategy {
2658 DomStrategy::Shadow => {
2659 let adopted = adopted_specifier.unwrap_or_default();
2660 let adopted_extra = if adopted.is_empty() {
2661 0
2662 } else {
2663 Self::adopted_attr_len(adopted)
2664 };
2665 let mut result =
2666 String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
2667 result.push_str("<template shadowrootmode=\"open\"");
2668 if !adopted.is_empty() {
2669 Self::push_adopted_attr(&mut result, adopted);
2670 }
2671 result.push('>');
2672 result.push_str(snippet);
2673 result.push_str(trimmed);
2674 result.push_str("</template>");
2675 result
2676 }
2677 DomStrategy::Light => {
2678 if snippet.is_empty() {
2679 trimmed.to_string()
2680 } else {
2681 let mut result = String::with_capacity(snippet.len() + trimmed.len());
2682 result.push_str(snippet);
2683 result.push_str(trimmed);
2684 result
2685 }
2686 }
2687 }
2688 };
2689
2690 self.strip_template_comments(processed)
2691 }
2692
2693 fn inject_css_snippet_into_template(html: String, snippet: &str) -> String {
2694 if snippet.is_empty() {
2695 return html;
2696 }
2697
2698 match html::find_tag_close(&html) {
2699 Some(open_end) => {
2700 let mut result = String::with_capacity(html.len() + snippet.len());
2701 result.push_str(&html[..=open_end]);
2702 result.push_str(snippet);
2703 result.push_str(&html[open_end + 1..]);
2704 result
2705 }
2706 None => html,
2709 }
2710 }
2711
2712 fn append_adopted_attr_if_missing(html: String, adopted_specifier: Option<&str>) -> String {
2713 let Some(adopted) = adopted_specifier else {
2714 return html;
2715 };
2716 let Some(tag) = html::parse_tag(&html) else {
2717 return html;
2718 };
2719 if tag.name != "template" || tag.closing || tag.has_attr(Self::ADOPTED_STYLESHEETS_ATTR) {
2720 return html;
2721 }
2722
2723 let mut result = String::with_capacity(html.len() + Self::adopted_attr_len(adopted));
2724 result.push_str(&html[..tag.close]);
2725 Self::push_adopted_attr(&mut result, adopted);
2726 result.push_str(&html[tag.close..]);
2727 result
2728 }
2729
2730 fn push_adopted_attr(out: &mut String, adopted: &str) {
2731 out.push(' ');
2732 out.push_str(Self::ADOPTED_STYLESHEETS_ATTR);
2733 out.push_str("=\"");
2734 out.push_str(adopted);
2735 out.push('"');
2736 }
2737
2738 fn adopted_attr_len(adopted: &str) -> usize {
2739 4 + Self::ADOPTED_STYLESHEETS_ATTR.len() + adopted.len()
2740 }
2741
2742 fn template_has_stripped_runtime_attrs(html: &str) -> bool {
2743 let trimmed = html.trim_start();
2744 let Some(tag) = html::parse_tag(trimmed) else {
2745 return false;
2746 };
2747 if tag.name != "template" || tag.closing {
2748 return false;
2749 }
2750 tag.attrs().any(|attr| {
2751 attr.name.starts_with('@') || attr.name.starts_with(':') || attr.name.starts_with('?')
2752 })
2753 }
2754
2755 fn strip_template_comments(&mut self, html: String) -> Result<String> {
2756 let mut ranges = Vec::new();
2757 let mut style_ranges = Vec::new();
2758 Self::collect_html_comment_and_style_ranges(&html, &mut ranges, &mut style_ranges)?;
2759
2760 for (style_start, style_end) in style_ranges {
2761 let css = &html[style_start..style_end];
2762 let (_tokens, _defs, _requirements, comments) = self
2763 .css_parser
2764 .extract_tokens_definitions_requirements_and_comments(
2765 css,
2766 self.options.legal_comments,
2767 )
2768 .map_err(|e| self.css_diagnostic(e))?;
2769 for comment in comments {
2770 let comment_text = &css[comment.start_byte..comment.end_byte];
2771 if comment.preserve || self.css_signal_comment_fragment(comment_text).is_some() {
2772 continue;
2773 }
2774 ranges.push((
2775 style_start + comment.start_byte,
2776 style_start + comment.end_byte,
2777 ));
2778 }
2779 }
2780
2781 if ranges.is_empty() {
2782 return Ok(html);
2783 }
2784
2785 Ok(comment_policy::strip_ranges(&html, ranges.as_mut_slice()).into_owned())
2786 }
2787
2788 fn collect_html_comment_and_style_ranges(
2790 html: &str,
2791 comment_ranges: &mut Vec<(usize, usize)>,
2792 style_ranges: &mut Vec<(usize, usize)>,
2793 ) -> Result<()> {
2794 let mut index = 0usize;
2795 while index < html.len() {
2796 let remaining = &html[index..];
2797 if remaining.starts_with("<!--") {
2798 let Some(end) = html::find_comment_close(remaining) else {
2799 return Err(Diagnostic::error("unterminated HTML comment")
2800 .code(codes::UNTERMINATED_HTML_COMMENT)
2801 .at_offset(html, index)
2802 .help("close the comment with `-->`")
2803 .into());
2804 };
2805 comment_ranges.push((index, index + end));
2806 index += end;
2807 continue;
2808 }
2809
2810 if remaining.starts_with('<') {
2811 if let Some((style_start, style_end, close_end)) =
2812 html::style_element_bounds(remaining)
2813 {
2814 style_ranges.push((index + style_start, index + style_end));
2815 index += close_end;
2816 continue;
2817 }
2818
2819 if let Some(tag) = html::parse_tag(remaining) {
2820 index += tag.close + 1;
2821 continue;
2822 }
2823 }
2824
2825 let Some(ch) = remaining.chars().next() else {
2826 break;
2827 };
2828 index += ch.len_utf8();
2829 }
2830 Ok(())
2831 }
2832
2833 fn strip_runtime_attrs_from_template(&mut self, html: &str) -> String {
2838 let Some(tag) = html::parse_tag(html) else {
2839 return html.to_string();
2840 };
2841
2842 let mut removals: Vec<(usize, usize)> = Vec::new();
2843 for attr in tag.attrs() {
2844 if attr.name.starts_with('@')
2845 || attr.name.starts_with(':')
2846 || attr.name.starts_with('?')
2847 {
2848 let mut start = attr.raw_range.start;
2849 while start > 0 && html.as_bytes()[start - 1].is_ascii_whitespace() {
2850 start -= 1;
2851 }
2852 removals.push((start, attr.raw_range.end));
2853 }
2854 }
2855
2856 if removals.is_empty() {
2857 return html.to_string();
2858 }
2859
2860 let mut result = String::with_capacity(html.len());
2862 let mut pos = 0;
2863 for (start, end) in &removals {
2864 result.push_str(&html[pos..*start]);
2865 pos = *end;
2866 }
2867 result.push_str(&html[pos..]);
2868 result
2869 }
2870}
2871
2872#[cfg(test)]
2873mod tests {
2874 use webui_test_utils::*;
2875
2876 use super::*;
2877
2878 #[test]
2879 fn test_plugin_display_names() {
2880 assert_eq!(Plugin::Fast.to_string(), "fast");
2881 assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
2882 assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
2883 assert_eq!(Plugin::WebUI.to_string(), "webui");
2884 }
2885
2886 #[test]
2887 fn test_plugin_from_str_names() {
2888 assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
2889 assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
2890 assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
2891 assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
2892 }
2893
2894 #[test]
2895 fn test_plugin_from_str_unknown_mentions_supported_names() {
2896 let err = "legacy".parse::<Plugin>().unwrap_err();
2897 assert!(
2898 err.contains("fast-v3"),
2899 "error should mention fast-v3: {err}"
2900 );
2901 assert!(
2902 err.contains("fast-v2"),
2903 "error should mention fast-v2: {err}"
2904 );
2905 assert!(err.contains("fast"), "error should mention fast: {err}");
2906 }
2907
2908 #[test]
2909 fn test_parse_signal() {
2910 let mut parser = HtmlParser::new();
2911 let html = "Hello, {{name}}!";
2912 let result = parser.parse("test.html", html);
2913
2914 assert!(result.is_ok());
2915 let fragment_records = parser.into_fragment_records();
2916
2917 assert_stream!(
2918 fragment_records,
2919 "test.html",
2920 [raw("Hello, "), signal("name"), raw("!"),]
2921 );
2922 }
2923
2924 #[test]
2925 fn test_parse_raw_signal() {
2926 let mut parser = HtmlParser::new();
2927 let html = "Hello, {{{html_content}}}!";
2928 let result = parser.parse("test.html", html);
2929
2930 assert!(result.is_ok());
2931 let fragment_records = parser.into_fragment_records();
2932
2933 assert_stream!(
2934 fragment_records,
2935 "test.html",
2936 [raw("Hello, "), signal_raw("html_content"), raw("!"),]
2937 );
2938 }
2939
2940 #[test]
2941 fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
2942 let mut parser = HtmlParser::new();
2943 let html = "<nav>{{sectionName}} > {{topicName}}</nav>";
2944 let result = parser.parse("test.html", html);
2945
2946 assert!(result.is_ok(), "Parse error: {:?}", result.err());
2947 let fragment_records = parser.into_fragment_records();
2948
2949 assert_stream!(
2950 fragment_records,
2951 "test.html",
2952 [
2953 raw("<nav>"),
2954 signal("sectionName"),
2955 raw(" > "),
2956 signal("topicName"),
2957 raw("</nav>"),
2958 ]
2959 );
2960 }
2961
2962 #[test]
2963 fn test_invalid_for_each_is_template_diagnostic() {
2964 let mut parser = HtmlParser::new();
2965 let err = parser
2966 .parse(
2967 "index.html",
2968 "<div>\n <for each=\"person inpeople\"><p>x</p></for>\n</div>",
2969 )
2970 .expect_err("invalid for-each must error");
2971 assert!(matches!(err, ParserError::Template(_)));
2972 let msg = err.to_string();
2973 assert!(msg.contains("invalid <for> each expression"), "{msg}");
2974 assert!(msg.contains(r#"each="person inpeople""#), "{msg}");
2975 assert!(
2978 msg.contains("--> index.html:2:3"),
2979 "missing line:column — {msg}"
2980 );
2981 assert!(msg.contains("help:"), "{msg}");
2982 }
2983
2984 #[test]
2985 fn test_invalid_if_condition_is_template_diagnostic() {
2986 let mut parser = HtmlParser::new();
2987 let err = parser
2988 .parse("index.html", r#"<if condition="a +"><p>x</p></if>"#)
2989 .expect_err("invalid condition must error");
2990 assert!(matches!(err, ParserError::Template(_)));
2991 let msg = err.to_string();
2992 assert!(msg.contains("invalid <if> condition expression"), "{msg}");
2993 assert!(
2994 msg.contains("--> index.html:1:1"),
2995 "missing line:column — {msg}"
2996 );
2997 }
2998
2999 #[test]
3000 fn test_parse_for_directive() {
3001 let mut parser = HtmlParser::new();
3002 let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
3003
3004 let result = parser.parse("test.html", html);
3005 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3006 let fragment_records = parser.into_fragment_records();
3007
3008 assert_stream!(
3009 fragment_records,
3010 "test.html",
3011 [for_loop("item", "items", "for-1"),]
3012 );
3013
3014 assert_stream!(
3016 fragment_records,
3017 "for-1",
3018 [
3019 raw("<div class=\"item\">"),
3020 signal("item.name"),
3021 raw("</div>"),
3022 ]
3023 );
3024 }
3025
3026 #[test]
3027 fn test_parse_if_directive() {
3028 let mut parser = HtmlParser::new();
3029 let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
3030
3031 let result = parser.parse("test.html", html);
3032 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3033 let fragment_records = parser.into_fragment_records();
3034
3035 assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
3036
3037 assert_stream!(
3039 fragment_records,
3040 "if-1",
3041 [
3042 raw("<div>Welcome back, "),
3043 signal("username"),
3044 raw("!</div>"),
3045 ]
3046 );
3047 }
3048
3049 #[test]
3050 fn test_component_directive() {
3051 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3052 parser
3053 .component_registry
3054 .register_component(
3055 "my-component",
3056 "<div>My Component</div>",
3057 Some("div { color: blue; }"),
3058 true,
3059 )
3060 .expect("Failed to register component");
3061
3062 let result = parser.parse("test.html", "<my-component></my-component>");
3063 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3064 let records = parser.into_fragment_records();
3065
3066 assert_stream!(
3067 records,
3068 "test.html",
3069 [
3070 raw("<my-component>"),
3071 component("my-component"),
3072 raw("</my-component>"),
3073 ]
3074 );
3075
3076 let comp = &records["my-component"].fragments;
3078 assert_eq!(comp.len(), 1);
3079 assert!(
3080 matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3081 !raw.value.contains("<template shadowrootmode") && raw.value.contains("<div>My Component</div>"))
3082 );
3083 }
3084
3085 #[test]
3086 fn unknown_component_typo_in_same_namespace_errors_with_suggestion() {
3087 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3088 parser
3089 .component_registry
3090 .register_component("mp-button", "<button>b</button>", None, true)
3091 .expect("Failed to register component");
3092
3093 let err = parser
3095 .parse("test.html", "<mp-buton></mp-buton>")
3096 .expect_err("a near-typo component tag should error");
3097 let ParserError::Template(diag) = err else {
3098 panic!("expected ParserError::Template, got {err:?}");
3099 };
3100 assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
3101 let help = diag
3102 .help_text()
3103 .expect("diagnostic should carry a help line");
3104 assert!(
3105 help.contains("did you mean <mp-button>?"),
3106 "expected a suggestion, got: {help}"
3107 );
3108 }
3109
3110 #[test]
3111 fn external_custom_element_in_other_namespace_passes_through() {
3112 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3113 parser
3114 .component_registry
3115 .register_component("mp-button", "<button>b</button>", None, true)
3116 .expect("Failed to register component");
3117
3118 let result = parser.parse("test.html", "<md-button></md-button>");
3121 assert!(
3122 result.is_ok(),
3123 "external custom element should pass through, got: {:?}",
3124 result.err()
3125 );
3126 }
3127
3128 #[test]
3129 fn for_missing_each_suggests_typoed_attribute() {
3130 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3131 let err = parser
3133 .parse("test.html", "<for eahc=\"todo in todos\"></for>")
3134 .expect_err("a <for> without `each` should error");
3135 let ParserError::Template(diag) = err else {
3136 panic!("expected ParserError::Template, got {err:?}");
3137 };
3138 assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
3139 let help = diag
3140 .help_text()
3141 .expect("diagnostic should carry a help line");
3142 assert!(
3143 help.contains("found `eahc`") && help.contains("did you mean"),
3144 "expected an attribute-typo suggestion, got: {help}"
3145 );
3146 }
3147
3148 #[test]
3149 fn parent_diagnostic_owner_survives_nested_component_parse() {
3150 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3155 parser
3156 .component_registry
3157 .register_component("my-card", "<div>card</div>", None, true)
3158 .expect("register");
3159
3160 let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
3162 let err = parser
3163 .parse("index.html", html)
3164 .expect_err("the malformed <for> should error");
3165 let ParserError::Template(diag) = err else {
3166 panic!("expected ParserError::Template, got {err:?}");
3167 };
3168 assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
3169 assert_eq!(
3170 diag.component_name(),
3171 Some("index.html"),
3172 "parent diagnostic must be owned by index.html, not the nested component"
3173 );
3174 }
3175
3176 #[test]
3177 fn test_component_directive_with_slots() {
3178 let mut parser = HtmlParser::new();
3179 parser
3180 .component_registry
3181 .register_component(
3182 "my-component",
3183 "<div>My Component</div>",
3184 Some("div { color: blue; }"),
3185 true,
3186 )
3187 .expect("Failed to register component");
3188
3189 let result = parser.parse(
3190 "test.html",
3191 "Hello<my-component><p>World</p></my-component>",
3192 );
3193 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3194 let records = parser.into_fragment_records();
3195 let fragments = &records["test.html"].fragments;
3196
3197 assert!(fragments.len() >= 3);
3199 assert!(
3201 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component>"))
3202 );
3203 assert!(fragments.iter().any(|f| matches!(
3205 f.fragment.as_ref(),
3206 Some(Fragment::Component(c)) if c.fragment_id == "my-component"
3207 )));
3208 let last = fragments.last().unwrap();
3210 assert!(
3211 matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
3212 );
3213 }
3214
3215 #[test]
3216 fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
3217 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
3222
3223 parser
3224 .component_registry
3225 .register_component(
3226 "app-shell",
3227 r#"<template shadowrootmode="open">
3228 <my-navbar></my-navbar>
3229 <main><outlet /></main>
3230 <cart-panel></cart-panel>
3231 <my-footer></my-footer>
3232 </template>"#,
3233 Some(":host{display:flex}"),
3234 true,
3235 )
3236 .expect("register app-shell");
3237 parser
3238 .component_registry
3239 .register_component("my-navbar", "<nav>Nav</nav>", Some("nav{color:red}"), true)
3240 .expect("register my-navbar");
3241 parser
3242 .component_registry
3243 .register_component(
3244 "cart-panel",
3245 "<aside>Cart</aside>",
3246 Some("aside{color:green}"),
3247 true,
3248 )
3249 .expect("register cart-panel");
3250 parser
3251 .component_registry
3252 .register_component(
3253 "my-footer",
3254 "<footer>Footer</footer>",
3255 Some("footer{color:blue}"),
3256 true,
3257 )
3258 .expect("register my-footer");
3259
3260 parser
3261 .parse("index.html", "<app-shell></app-shell>")
3262 .expect("parse index.html");
3263 let records = parser.into_fragment_records();
3264
3265 let app_frags = &records["app-shell"].fragments;
3267 let component_ids: Vec<&str> = app_frags
3268 .iter()
3269 .filter_map(|f| match f.fragment.as_ref() {
3270 Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
3271 _ => None,
3272 })
3273 .collect();
3274
3275 assert!(
3276 component_ids.contains(&"my-navbar"),
3277 "app-shell fragments should contain my-navbar: {component_ids:?}"
3278 );
3279 assert!(
3280 component_ids.contains(&"cart-panel"),
3281 "app-shell fragments should contain cart-panel: {component_ids:?}"
3282 );
3283 assert!(
3284 component_ids.contains(&"my-footer"),
3285 "app-shell fragments should contain my-footer: {component_ids:?}"
3286 );
3287 }
3288
3289 #[test]
3292 fn test_component_no_double_wrap_template() {
3293 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3297 parser
3298 .component_registry
3299 .register_component(
3300 "custom-element",
3301 r#"<template foo="bar"><slot></slot></template>"#,
3302 None,
3303 true,
3304 )
3305 .expect("register");
3306 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3307 assert!(result.is_ok());
3308 let records = parser.into_fragment_records();
3309
3310 assert_fragments!(
3311 records["index.html"].fragments,
3312 [
3313 raw("<custom-element>"),
3314 component("custom-element"),
3315 raw("Hello</custom-element>"),
3316 ]
3317 );
3318
3319 assert_stream!(
3320 records,
3321 "custom-element",
3322 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
3323 );
3324 }
3325
3326 #[test]
3327 fn test_component_styled_no_double_wrap() {
3328 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3333 parser
3334 .component_registry
3335 .register_component(
3336 "custom-element",
3337 r#"<template foo="bar"><slot></slot></template>"#,
3338 Some("div { color: red; }"),
3339 true,
3340 )
3341 .expect("register");
3342 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3343 assert!(result.is_ok());
3344 let records = parser.into_fragment_records();
3345
3346 assert_stream!(
3347 records,
3348 "custom-element",
3349 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
3350 );
3351 }
3352
3353 #[test]
3354 fn test_component_strip_runtime_attrs() {
3355 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3360 parser
3361 .component_registry
3362 .register_component(
3363 "custom-element",
3364 r#"<template @click={foo} :bar="baz" ?bool="true"><slot></slot></template>"#,
3365 None,
3366 true,
3367 )
3368 .expect("register");
3369 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3370 assert!(result.is_ok());
3371 let records = parser.into_fragment_records();
3372
3373 assert_stream!(
3374 records,
3375 "custom-element",
3376 [raw("<template><slot></slot></template>"),]
3377 );
3378 }
3379
3380 #[test]
3381 fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
3382 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3383 parser
3384 .component_registry
3385 .register_component(
3386 "custom-element",
3387 r#"<template data-note="@click={foo}" @click={foo}><slot></slot></template>"#,
3388 None,
3389 true,
3390 )
3391 .expect("register");
3392 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3393 assert!(result.is_ok());
3394 let records = parser.into_fragment_records();
3395
3396 assert_stream!(
3397 records,
3398 "custom-element",
3399 [raw(
3400 r#"<template data-note="@click={foo}"><slot></slot></template>"#
3401 ),]
3402 );
3403 }
3404
3405 #[test]
3406 fn test_component_strip_runtime_attrs_handles_duplicate_runtime_attrs() {
3407 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3408 parser
3409 .component_registry
3410 .register_component(
3411 "custom-element",
3412 r#"<template @click={foo} @click={bar}><slot></slot></template>"#,
3413 None,
3414 true,
3415 )
3416 .expect("register");
3417 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3418 assert!(result.is_ok());
3419 let records = parser.into_fragment_records();
3420
3421 assert_stream!(
3422 records,
3423 "custom-element",
3424 [raw("<template><slot></slot></template>"),]
3425 );
3426 }
3427
3428 #[test]
3429 fn test_component_with_slots_and_attrs() {
3430 let mut parser = HtmlParser::new();
3431 parser
3432 .component_registry
3433 .register_component("custom-element", "<slot></slot>", None, true)
3434 .expect("register");
3435 let result = parser.parse(
3436 "index.html",
3437 r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
3438 );
3439 assert!(result.is_ok());
3440 let records = parser.into_fragment_records();
3441 assert_fragments!(
3442 records["index.html"].fragments,
3443 [
3444 raw("<custom-element"),
3445 attr_raw_start("appearance", "subtle"),
3446 raw(">"),
3447 component("custom-element"),
3448 raw("Hello World</custom-element>"),
3449 ]
3450 );
3451 }
3452
3453 #[test]
3454 fn test_component_legacy_no_styles() {
3455 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3456 parser
3457 .component_registry
3458 .register_component("custom-element", "<div>Custom Element</div>", None, true)
3459 .expect("register");
3460 let result = parser.parse("index.html", "<custom-element></custom-element>");
3461 assert!(result.is_ok());
3462 let records = parser.into_fragment_records();
3463
3464 assert_fragments!(
3465 records["index.html"].fragments,
3466 [
3467 raw("<custom-element>"),
3468 component("custom-element"),
3469 raw("</custom-element>"),
3470 ]
3471 );
3472
3473 assert_stream!(
3474 records,
3475 "custom-element",
3476 [raw("<div>Custom Element</div>"),]
3477 );
3478 }
3479
3480 #[test]
3481 fn test_component_self_closing() {
3482 let mut parser = HtmlParser::new();
3483 parser
3484 .component_registry
3485 .register_component("custom-widget", "<div>Widget Content</div>", None, true)
3486 .expect("register");
3487 let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
3488 assert!(result.is_ok());
3489 let records = parser.into_fragment_records();
3490
3491 assert_fragments!(
3492 records["index.html"].fragments,
3493 [
3494 raw("<custom-widget"),
3495 attr_start("config", "settings"),
3496 raw("/>"),
3497 component("custom-widget"),
3498 ]
3499 );
3500 }
3501
3502 #[test]
3503 fn test_component_nested_self_closing_in_slot() {
3504 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3505 parser
3506 .component_registry
3507 .register_component("custom-icon", "<svg><slot></slot></svg>", None, true)
3508 .expect("register");
3509 let result = parser.parse(
3510 "index.html",
3511 r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
3512 );
3513 assert!(result.is_ok());
3514 let records = parser.into_fragment_records();
3515
3516 assert_fragments!(
3517 records["index.html"].fragments,
3518 [
3519 raw("<custom-icon>"),
3520 component("custom-icon"),
3521 raw("<use"),
3522 attr_template("href", "attr-1"),
3523 raw("/></custom-icon>"),
3524 ]
3525 );
3526
3527 assert_stream!(records, "custom-icon", [raw("<svg><slot></slot></svg>"),]);
3528 }
3529
3530 #[test]
3531 fn test_component_leading_boolean_attr_start() {
3532 let mut parser = HtmlParser::new();
3533 parser
3534 .component_registry
3535 .register_component("custom-element", "<slot></slot>", None, true)
3536 .expect("register");
3537 let result = parser.parse(
3538 "index.html",
3539 r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
3540 );
3541 assert!(result.is_ok());
3542 let records = parser.into_fragment_records();
3543
3544 assert_fragments!(
3545 records["index.html"].fragments,
3546 [
3547 raw("<custom-element"),
3548 bool_attr_start("disabled", "isDisabled"),
3550 attr_raw("title", "Hello"),
3552 raw(">"),
3553 component("custom-element"),
3554 raw("</custom-element>"),
3555 ]
3556 );
3557 }
3558
3559 #[test]
3560 fn test_component_boolean_predicate_preserves_condition_tree() {
3561 let mut parser = HtmlParser::new();
3562 parser
3563 .component_registry
3564 .register_component("custom-element", "<slot></slot>", None, true)
3565 .expect("register");
3566 let result = parser.parse(
3567 "index.html",
3568 r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
3569 );
3570 assert!(result.is_ok());
3571
3572 let records = parser.into_fragment_records();
3573 let fragments = &records["index.html"].fragments;
3574 match fragments
3575 .get(1)
3576 .and_then(|fragment| fragment.fragment.as_ref())
3577 {
3578 Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
3579 assert_eq!(attr.name, "disabled");
3580 assert!(attr.attr_start);
3581 match attr
3582 .condition_tree
3583 .as_ref()
3584 .and_then(|condition| condition.expr.as_ref())
3585 {
3586 Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
3587 assert_eq!(pred.left, "page");
3588 assert_eq!(pred.operator, 3);
3589 assert_eq!(pred.right, "'dashboard'");
3590 }
3591 other => panic!("expected predicate condition tree, got {:?}", other),
3592 }
3593 }
3594 other => panic!("expected attribute fragment, got {:?}", other),
3595 }
3596 }
3597
3598 #[test]
3599 fn test_component_meta_link_tags() {
3600 let (fragments, _) = parse_and_get_fragments(
3601 r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
3602 );
3603 assert!(fragments.len() >= 5);
3604 assert!(
3605 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<head><meta charset=\"utf-8\"") && raw.value.contains("<link"))
3606 );
3607 assert!(
3608 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
3609 );
3610 assert!(
3611 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
3612 );
3613 assert!(
3614 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if s.value == "head_end" && s.raw)
3615 );
3616 assert!(
3617 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
3618 );
3619 }
3620
3621 #[test]
3622 fn test_nested_directives() {
3623 let mut parser = HtmlParser::new();
3624 let html = r#"<for each="category in categories">
3625 <if condition="category.hasItems">
3626 <for each="item in category.items">
3627 {{item.title}}
3628 </for>
3629 </if>
3630 </for>"#;
3631
3632 let result = parser.parse("test.html", html);
3633
3634 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3635 let fragment_records = parser.into_fragment_records();
3636
3637 assert_stream!(
3638 fragment_records,
3639 "test.html",
3640 [for_loop("category", "categories", "for-1"),]
3641 );
3642
3643 assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
3644
3645 assert_stream!(
3646 fragment_records,
3647 "if-1",
3648 [for_loop("item", "category.items", "for-2"),]
3649 );
3650
3651 assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
3652 }
3653
3654 #[test]
3655 fn test_complex_directives() {
3656 let mut parser = HtmlParser::new();
3657 let html = r#"<for each="category in categories">
3658 <div class="category">
3659 <h2>{{category.name}}</h2>
3660 <if condition="category.hasItems">
3661 <ul>
3662 <for each="item in category.items">
3663 <li>{{item.title}}</li>
3664 </for>
3665 </ul>
3666 </if>
3667 </div>
3668 </for>"#;
3669
3670 let result = parser.parse("test.html", html);
3671
3672 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3673 let fragment_records = parser.into_fragment_records();
3674
3675 assert_stream!(
3676 fragment_records,
3677 "test.html",
3678 [for_loop("category", "categories", "for-1"),]
3679 );
3680
3681 assert_stream!(
3683 fragment_records,
3684 "for-1",
3685 [
3686 raw("<div class=\"category\"><h2>"),
3687 signal("category.name"),
3688 raw("</h2>"),
3689 if_cond("if-1"),
3690 raw("</div>"),
3691 ]
3692 );
3693
3694 assert_stream!(
3696 fragment_records,
3697 "if-1",
3698 [
3699 raw("<ul>"),
3700 for_loop("item", "category.items", "for-2"),
3701 raw("</ul>"),
3702 ]
3703 );
3704
3705 assert_stream!(
3707 fragment_records,
3708 "for-2",
3709 [raw("<li>"), signal("item.title"), raw("</li>"),]
3710 );
3711 }
3712
3713 fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
3717 let mut parser = HtmlParser::new();
3718 let result = parser.parse("index.html", html);
3719 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3720 let records = parser.into_fragment_records();
3721 let fragments = records
3722 .get("index.html")
3723 .expect("Failed to get index.html fragment")
3724 .fragments
3725 .clone();
3726 (fragments, records)
3727 }
3728
3729 fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
3731 let mut parser = HtmlParser::new();
3732 parser
3733 .component_registry
3734 .register_component(tag, "<div></div>", None, true)
3735 .expect("register");
3736 let result = parser.parse("index.html", html);
3737 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3738 let records = parser.into_fragment_records();
3739 let fragments = records
3740 .get("index.html")
3741 .expect("Failed to get index.html fragment")
3742 .fragments
3743 .clone();
3744 (fragments, records)
3745 }
3746
3747 #[test]
3748 fn test_attribute_handlebars_in_href() {
3749 let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
3751 assert_fragments!(
3752 fragments,
3753 [
3754 raw("<a"),
3755 attr("href", "url"),
3756 raw(">"),
3757 signal("name"),
3758 raw("</a>"),
3759 ]
3760 );
3761 }
3762
3763 #[test]
3764 fn test_attribute_boolean_with_handlebars() {
3765 let (fragments, _) =
3767 parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
3768 assert_fragments!(
3769 fragments,
3770 [
3771 raw("<button"),
3772 bool_attr("disabled", "isDisabled"),
3773 raw(">Click</button>"),
3774 ]
3775 );
3776 }
3777
3778 #[test]
3779 fn test_attribute_multiple_boolean() {
3780 let (fragments, _) =
3783 parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
3784
3785 assert_fragments!(
3786 fragments,
3787 [
3788 raw("<input"),
3789 bool_attr("checked", "isChecked"),
3790 bool_attr("disabled", "isDisabled"),
3791 raw("/>"),
3792 ]
3793 );
3794 }
3795
3796 #[test]
3797 fn test_attribute_boolean_and_regular_together() {
3798 let (fragments, _) = parse_and_get_fragments(
3801 r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
3802 );
3803
3804 assert_fragments!(
3805 fragments,
3806 [
3807 raw("<button"),
3808 bool_attr("disabled", "isDisabled"),
3809 raw(" type=\"button\">Hi</button>"),
3810 ]
3811 );
3812 }
3813
3814 #[test]
3815 fn test_attribute_boolean_sandwiched() {
3816 let (fragments, _) = parse_and_get_fragments(
3819 r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
3820 );
3821
3822 assert_fragments!(
3823 fragments,
3824 [
3825 raw("<button"),
3826 attr("version", "edition"),
3827 bool_attr("disabled", "isDisabled"),
3828 raw(" type=\"button\">Hi</button>"),
3829 ]
3830 );
3831 }
3832
3833 #[test]
3834 fn test_attribute_boolean_ending() {
3835 let (fragments, _) = parse_and_get_fragments(
3838 r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
3839 );
3840
3841 assert_fragments!(
3842 fragments,
3843 [
3844 raw("<button"),
3845 attr("version", "edition"),
3846 bool_attr("disabled", "isDisabled"),
3847 raw(">Hi</button>"),
3848 ]
3849 );
3850 }
3851
3852 #[test]
3853 fn test_attribute_boolean_dotted_path() {
3854 let (fragments, _) =
3857 parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
3858
3859 assert_fragments!(
3860 fragments,
3861 [
3862 raw("<div"),
3863 bool_attr("checked", "layout.isPinned"),
3864 raw(">Content</div>"),
3865 ]
3866 );
3867 }
3868
3869 #[test]
3870 fn test_attribute_colon_prefixed_complex() {
3871 let (fragments, _) = parse_with_component(
3874 "my-component",
3875 r#"<my-component :config="{{settings}}"></my-component>"#,
3876 );
3877
3878 assert_fragments!(
3879 fragments,
3880 [
3881 raw("<my-component"),
3882 attr_complex_start(":config", "settings"),
3883 raw(">"),
3884 component("my-component"),
3885 raw("</my-component>"),
3886 ]
3887 );
3888 }
3889
3890 #[test]
3891 fn test_attribute_multiple_colon_prefixed() {
3892 let (fragments, _) = parse_with_component(
3895 "my-component",
3896 r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
3897 );
3898
3899 assert_fragments!(
3900 fragments,
3901 [
3902 raw("<my-component"),
3903 attr_complex_start(":prop1", "val1"),
3904 attr_complex(":prop2", "val2"),
3905 raw(">"),
3906 component("my-component"),
3907 raw("</my-component>"),
3908 ]
3909 );
3910 }
3911
3912 #[test]
3913 fn test_blocked_complex_property_innerhtml() {
3914 let mut parser = HtmlParser::new();
3915 let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
3916 assert!(
3917 result.is_err(),
3918 "Expected error for :innerHTML on native element"
3919 );
3920 let err = result.unwrap_err().to_string();
3921 assert!(
3922 err.contains("only allowed on custom elements"),
3923 "Error: {err}"
3924 );
3925 }
3926
3927 #[test]
3928 fn test_blocked_complex_on_native_element() {
3929 let mut parser = HtmlParser::new();
3930 let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
3931 assert!(
3932 result.is_err(),
3933 "Expected error for :data on native element"
3934 );
3935 let err = result.unwrap_err().to_string();
3936 assert!(
3937 err.contains("only allowed on custom elements"),
3938 "Error: {err}"
3939 );
3940 }
3941
3942 #[test]
3943 fn test_blocked_complex_property_on_component() {
3944 let mut parser = HtmlParser::new();
3945 parser
3946 .component_registry
3947 .register_component("my-widget", "<div></div>", None, true)
3948 .expect("register");
3949 let result = parser.parse(
3950 "index.html",
3951 r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
3952 );
3953 assert!(
3954 result.is_err(),
3955 "Expected error for :innerHTML on component"
3956 );
3957 let err = result.unwrap_err().to_string();
3958 assert!(err.contains("HTML injection"), "Error: {err}");
3959 }
3960
3961 #[test]
3962 fn test_allowed_complex_property() {
3963 let (fragments, _) = parse_with_component(
3965 "my-component",
3966 r#"<my-component :config="{{settings}}"></my-component>"#,
3967 );
3968 assert_fragments!(
3969 fragments,
3970 [
3971 raw("<my-component"),
3972 attr_complex_start(":config", "settings"),
3973 raw(">"),
3974 component("my-component"),
3975 raw("</my-component>"),
3976 ]
3977 );
3978 }
3979
3980 #[test]
3981 fn test_attribute_mixed_normal_boolean_colon() {
3982 let (fragments, _) = parse_with_component(
3985 "my-component",
3986 r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
3987 );
3988
3989 assert_fragments!(
3990 fragments,
3991 [
3992 raw("<my-component"),
3993 attr_raw_start("id", "comp"),
3994 attr_complex(":config", "settings"),
3995 bool_attr("enabled", "isEnabled"),
3996 raw(">"),
3997 component("my-component"),
3998 raw("</my-component>"),
3999 ]
4000 );
4001 }
4002
4003 #[test]
4004 fn test_attribute_reject_boolean_without_handlebars() {
4005 let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
4008
4009 assert_fragments!(fragments, [raw("<button></button>"),]);
4011 }
4012
4013 #[test]
4014 fn test_attribute_reject_boolean_with_partial_handlebars() {
4015 let (fragments, _) =
4018 parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
4019
4020 assert_fragments!(fragments, [raw("<button></button>"),]);
4022 }
4023
4024 #[test]
4025 fn test_attribute_reject_boolean_with_plain_value() {
4026 let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
4029
4030 assert_fragments!(fragments, [raw("<button>Click</button>"),]);
4032 }
4033
4034 #[test]
4035 fn test_attribute_boolean_predicate_equal() {
4036 let (fragments, _) =
4038 parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
4039 assert_fragments!(
4040 fragments,
4041 [
4042 raw("<div"),
4043 bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
4044 raw(">X</div>"),
4045 ]
4046 );
4047 }
4048
4049 #[test]
4050 fn test_attribute_boolean_predicate_greater_than() {
4051 let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
4053 assert_fragments!(
4054 fragments,
4055 [
4056 raw("<span"),
4057 bool_attr_predicate("hidden", "num", 1, "9"),
4058 raw(">X</span>"),
4059 ]
4060 );
4061 }
4062
4063 #[test]
4064 fn test_attribute_boolean_predicate_not_equal() {
4065 let (fragments, _) =
4067 parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
4068 assert_fragments!(
4069 fragments,
4070 [
4071 raw("<a"),
4072 bool_attr_predicate("data-active", "status", 4, "'inactive'"),
4073 raw(">X</a>"),
4074 ]
4075 );
4076 }
4077
4078 #[test]
4079 fn test_attribute_boolean_negation() {
4080 let (fragments, _) =
4082 parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
4083 assert_fragments!(
4084 fragments,
4085 [
4086 raw("<button"),
4087 bool_attr_not("disabled", "isReady"),
4088 raw(">X</button>"),
4089 ]
4090 );
4091 }
4092
4093 #[test]
4094 fn test_attribute_mixed_static_dynamic() {
4095 let (fragments, records) =
4098 parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
4099
4100 assert_fragments!(
4101 fragments,
4102 [
4103 raw("<textarea"),
4104 attr_template("value", "attr-1"),
4105 raw(">Hi</textarea>"),
4106 ]
4107 );
4108
4109 assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
4111 }
4112
4113 #[test]
4116 fn test_body_signals() {
4117 let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
4118 assert_fragments!(
4119 fragments,
4120 [
4121 raw("<body>"),
4122 signal_raw("body_start"),
4123 raw("<app-shell></app-shell>"),
4124 signal_raw("body_end"),
4125 raw("</body>"),
4126 ]
4127 );
4128 }
4129
4130 #[test]
4133 fn test_empty_for_produces_nothing() {
4134 let (fragments, records) =
4135 parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
4136 assert_fragments!(fragments, [raw("<div></div>"),]);
4137 assert!(!records.contains_key("for-1"));
4138 }
4139
4140 #[test]
4143 fn test_self_closing_svg_path() {
4144 let (fragments, _) =
4145 parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
4146 assert_fragments!(
4147 fragments,
4148 [raw(
4149 r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
4150 ),]
4151 );
4152 }
4153
4154 #[test]
4155 fn test_html5_void_elements() {
4156 let (fragments, _) = parse_and_get_fragments(
4157 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
4158 );
4159 assert_fragments!(
4160 fragments,
4161 [raw(
4162 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
4163 ),]
4164 );
4165 }
4166
4167 #[test]
4168 fn test_self_closing_with_dynamic_attributes() {
4169 let (fragments, _) =
4170 parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
4171 assert_fragments!(
4172 fragments,
4173 [
4174 raw("<img"),
4175 attr("src", "imageUrl"),
4176 attr("alt", "imageAlt"),
4177 raw("/>"),
4178 ]
4179 );
4180 }
4181
4182 #[test]
4183 fn test_self_closing_with_boolean_attributes() {
4184 let (fragments, _) = parse_and_get_fragments(
4185 r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
4186 );
4187 assert_fragments!(
4188 fragments,
4189 [
4190 raw("<input type=\"checkbox\""),
4191 bool_attr("checked", "isSelected"),
4192 bool_attr("disabled", "isDisabled"),
4193 raw("/>"),
4194 ]
4195 );
4196 }
4197
4198 #[test]
4199 fn test_multiple_self_closing_in_sequence() {
4200 let (fragments, _) =
4201 parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
4202 assert_fragments!(
4203 fragments,
4204 [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
4205 );
4206 }
4207
4208 #[test]
4209 fn test_self_closing_with_mixed_content() {
4210 let (fragments, _) =
4211 parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
4212 assert_fragments!(
4213 fragments,
4214 [
4215 raw("<div>Text before<img"),
4216 attr("src", "url"),
4217 raw("/>Text after</div>"),
4218 ]
4219 );
4220 }
4221
4222 #[test]
4223 fn test_self_closing_svg_elements() {
4224 let (fragments, _) = parse_and_get_fragments(
4225 r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
4226 );
4227 assert_fragments!(
4228 fragments,
4229 [
4230 raw("<svg><circle"),
4231 attr("cx", "x"),
4232 attr("cy", "y"),
4233 raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
4234 ]
4235 );
4236 }
4237
4238 #[test]
4239 fn test_self_closing_inside_for_loop() {
4240 let (fragments, records) = parse_and_get_fragments(
4241 r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
4242 );
4243 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
4244 assert_stream!(
4245 records,
4246 "for-1",
4247 [raw("<img"), attr("src", "item.url"), raw("/>"),]
4248 );
4249 }
4250
4251 #[test]
4252 fn test_self_closing_whitespace_variations() {
4253 let (fragments, _) =
4254 parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
4255 assert_fragments!(
4256 fragments,
4257 [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
4258 );
4259 }
4260
4261 #[test]
4262 fn test_deeply_nested_self_closing() {
4263 let (fragments, _) = parse_and_get_fragments(
4264 r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
4265 );
4266 assert_fragments!(
4267 fragments,
4268 [raw(
4269 r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
4270 ),]
4271 );
4272 }
4273
4274 #[test]
4275 fn test_self_closing_vs_empty_regular_tags() {
4276 let (fragments, _) =
4277 parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
4278 assert_fragments!(
4279 fragments,
4280 [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
4281 );
4282 }
4283
4284 #[test]
4287 fn test_for_custom_template_attribute() {
4288 let (fragments, records) = parse_and_get_fragments(
4290 r#"<for each="item in items" template="static"><span>Item</span></for>"#,
4291 );
4292 assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
4293 assert_stream!(records, "static", [raw("<span>Item</span>"),]);
4294 }
4295
4296 #[test]
4297 fn test_for_recursive_template() {
4298 let mut parser = HtmlParser::new();
4300 let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
4301 let result = parser.parse("index.html", html);
4302 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4303 let records = parser.into_fragment_records();
4304
4305 assert_fragments!(
4306 records["index.html"].fragments,
4307 [for_loop("outerItem", "outerItems", "static"),]
4308 );
4309
4310 assert_stream!(
4311 records,
4312 "static",
4313 [
4314 raw("<div><span>"),
4315 signal("outerItem.name"),
4316 raw("</span>"),
4317 for_loop("innerItem", "innerItems", "static"),
4318 raw("</div>"),
4319 ]
4320 );
4321 }
4322
4323 #[test]
4326 fn test_if_multiple_children() {
4327 let (fragments, records) =
4329 parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
4330 assert_fragments!(fragments, [if_cond("if-1"),]);
4331 assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
4332 }
4333
4334 #[test]
4335 fn test_for_multiple_children() {
4336 let (fragments, records) =
4338 parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
4339 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
4340 assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
4341 }
4342
4343 #[test]
4346 fn test_handlebars_at_beginning() {
4347 let (fragments, _) = parse_and_get_fragments("{{first}}");
4349 assert_fragments!(fragments, [signal("first"),]);
4350 }
4351
4352 #[test]
4353 fn test_handlebars_at_beginning_and_raw() {
4354 let (fragments, _) = parse_and_get_fragments("{{first}}test");
4356 assert_fragments!(fragments, [signal("first"), raw("test"),]);
4357 }
4358
4359 #[test]
4360 fn test_handlebars_raw_and_end() {
4361 let (fragments, _) = parse_and_get_fragments("test{{first}}");
4363 assert_fragments!(fragments, [raw("test"), signal("first"),]);
4364 }
4365
4366 #[test]
4369 fn test_handlebars_invalid_triple_open() {
4370 let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
4372 assert_fragments!(fragments, [raw("{{{invalid}}"),]);
4373 }
4374
4375 #[test]
4376 fn test_handlebars_four_open_braces() {
4377 let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
4379 assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
4380 }
4381
4382 #[test]
4383 fn test_handlebars_five_open_with_valid_double() {
4384 let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
4386 assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
4387 }
4388
4389 #[test]
4390 fn test_entities_preserved() {
4391 let (fragments, _) = parse_and_get_fragments("<p>Hello}World</p>");
4393 assert_fragments!(fragments, [raw("<p>Hello}World</p>"),]);
4394 }
4395
4396 #[test]
4399 fn test_doctype_preserved() {
4400 let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
4402 assert!(!fragments.is_empty());
4403 assert!(
4404 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
4405 );
4406 }
4407
4408 #[test]
4411 fn test_component_attr_skip() {
4412 let mut parser = HtmlParser::new();
4414 parser
4415 .component_registry
4416 .register_component("custom-element", "<slot></slot>", None, true)
4417 .expect("register");
4418 let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
4419 let result = parser.parse("index.html", html);
4420 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4421 let records = parser.into_fragment_records();
4422
4423 assert_fragments!(
4426 records["index.html"].fragments,
4427 [
4428 raw("<custom-element"),
4429 attr_complex_start(":config", "config"),
4431 attr_skip("class", "value0"),
4433 attr_skip("style", "value1"),
4434 attr_skip("role", "value2"),
4435 attr_skip("data-test", "value3"),
4436 attr_skip("aria-test", "value4"),
4437 raw(">"),
4438 component("custom-element"),
4439 raw("</custom-element>"),
4440 ]
4441 );
4442 }
4443
4444 #[test]
4445 fn test_component_attr_skip_static_and_embedded() {
4446 let mut parser = HtmlParser::new();
4449 parser
4450 .component_registry
4451 .register_component("item-group", "<slot></slot>", None, true)
4452 .expect("register");
4453
4454 let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
4455 let result = parser.parse("index.html", html);
4456 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4457 let records = parser.into_fragment_records();
4458
4459 assert_fragments!(
4460 records["index.html"].fragments,
4461 [
4462 raw("<item-group"),
4463 attr_skip_raw("role", "list"),
4464 attr_skip_template("aria-labelledby", "attr-1"),
4465 attr_skip_template("data-testid", "attr-2"),
4466 attr_skip_raw("class", "fixed-class"),
4467 raw(">"),
4468 component("item-group"),
4469 raw("</item-group>"),
4470 ]
4471 );
4472
4473 assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
4476 assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
4477 }
4478
4479 #[test]
4480 fn test_component_multiple_nested() {
4481 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4483 parser
4484 .component_registry
4485 .register_component(
4486 "custom-element",
4487 "<custom-child></custom-child><slot></slot>",
4488 None,
4489 true,
4490 )
4491 .expect("register");
4492 parser
4493 .component_registry
4494 .register_component("custom-button", "<slot></slot>", None, true)
4495 .expect("register");
4496 parser
4497 .component_registry
4498 .register_component("custom-child", "<h1>Hello World!</h1>", None, true)
4499 .expect("register");
4500
4501 let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
4502 let result = parser.parse("index.html", html);
4503 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4504 let records = parser.into_fragment_records();
4505
4506 assert_fragments!(
4508 records["index.html"].fragments,
4509 [for_loop("item", "items", "for-1"),]
4510 );
4511
4512 assert_stream!(
4514 records,
4515 "for-1",
4516 [
4517 raw("<custom-element>"),
4518 component("custom-element"),
4519 raw("<custom-button>"),
4520 component("custom-button"),
4521 raw("Ok</custom-button></custom-element>"),
4522 ]
4523 );
4524
4525 let ce = &records["custom-element"].fragments;
4527 assert_eq!(ce.len(), 3);
4528 assert!(
4529 matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child>"))
4530 );
4531 assert!(
4532 matches!(ce[1].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
4533 );
4534 assert!(
4535 matches!(ce[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><slot></slot>"))
4536 );
4537
4538 assert_stream!(records, "custom-button", [raw("<slot></slot>"),]);
4539
4540 assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
4541 }
4542
4543 #[test]
4546 fn test_invalid_markup_returns_error() {
4547 let mut parser = HtmlParser::new();
4549 let result = parser.parse("index.html", "<div><span>Unclosed div");
4550 assert!(matches!(result, Err(ParserError::Template(diag)) if
4551 diag.to_string().contains("unclosed <div> tag")
4552 ));
4553 }
4554
4555 #[test]
4556 fn test_unterminated_opening_tag_returns_error() {
4557 let mut parser = HtmlParser::new();
4558 let result = parser.parse("index.html", "before <div");
4559 assert!(matches!(result, Err(ParserError::Template(diag)) if
4560 diag.to_string().contains("malformed HTML tag")
4561 ));
4562 }
4563
4564 #[test]
4565 fn test_unterminated_html_comment_returns_error() {
4566 let mut parser = HtmlParser::new();
4567 let result = parser.parse("index.html", "<div><!-- missing close</div>");
4568 assert!(matches!(result, Err(ParserError::Template(diag)) if
4569 diag.to_string().contains("unclosed <div> tag")
4570 || diag.to_string().contains("unterminated HTML comment")
4571 ));
4572 }
4573
4574 #[test]
4575 fn test_unexpected_closing_tag_returns_error() {
4576 let mut parser = HtmlParser::new();
4577 let result = parser.parse("index.html", "</span>");
4578 assert!(matches!(result, Err(ParserError::Template(diag)) if
4579 diag.to_string().contains("unexpected closing tag </span>")
4580 ));
4581 }
4582
4583 #[test]
4584 fn test_rejects_templates_over_size_limit() {
4585 let mut parser = HtmlParser::new();
4586 let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
4587 let result = parser.parse("index.html", &html);
4588
4589 assert!(matches!(result, Err(ParserError::Html(message)) if
4590 message.contains("exceeds") && message.contains("parser limit")
4591 ));
4592 }
4593
4594 #[test]
4595 fn test_rejects_templates_over_depth_limit() {
4596 let mut parser = HtmlParser::new();
4597 let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
4598 for _ in 0..=MAX_TEMPLATE_DEPTH {
4599 html.push_str("<div>");
4600 }
4601 for _ in 0..=MAX_TEMPLATE_DEPTH {
4602 html.push_str("</div>");
4603 }
4604
4605 let result = parser.parse("index.html", &html);
4606
4607 assert!(matches!(result, Err(ParserError::Template(diag)) if
4608 diag.to_string().contains("nesting exceeds")
4609 ));
4610 }
4611
4612 #[test]
4613 fn test_rejects_routes_over_depth_limit() {
4614 let mut parser = HtmlParser::new();
4615 parser
4616 .component_registry
4617 .register_component("route-page", "<slot></slot>", None, true)
4618 .expect("register");
4619
4620 let depth = MAX_TEMPLATE_DEPTH + 2;
4621 let mut html = String::with_capacity(depth * 48);
4622 for _ in 0..depth {
4623 html.push_str(r#"<route path="x" component="route-page">"#);
4624 }
4625 for _ in 0..depth {
4626 html.push_str("</route>");
4627 }
4628
4629 let result = parser.parse("index.html", &html);
4630
4631 assert!(matches!(result, Err(ParserError::Template(diag)) if
4632 diag.error_code() == Some(codes::EXCESSIVE_NESTING)
4633 && diag.to_string().contains("route nesting exceeds")
4634 ));
4635 }
4636
4637 #[test]
4638 fn test_rejects_recursive_component_templates() {
4639 let mut parser = HtmlParser::new();
4640 parser
4641 .component_registry
4642 .register_component("self-card", "<self-card></self-card>", None, true)
4643 .expect("register");
4644
4645 let result = parser.parse("index.html", "<self-card></self-card>");
4646
4647 assert!(matches!(result, Err(ParserError::Template(diag)) if
4648 diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
4649 && diag.to_string().contains("recursive template reference")
4650 ));
4651 }
4652
4653 #[test]
4656 fn test_complex_raw_text_full_page() {
4657 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>"#;
4659 let (fragments, _) = parse_and_get_fragments(html);
4660
4661 assert!(fragments.len() >= 7);
4663 assert!(
4664 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4665 raw.value.contains("<!DOCTYPE HTML>") && raw.value.contains("<title>Test</title>"))
4666 );
4667 assert!(
4668 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
4669 s.value == "head_end" && s.raw)
4670 );
4671 assert!(
4672 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4673 raw.value.contains("</head>") && raw.value.ends_with("<body>"))
4674 );
4675 assert!(
4676 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
4677 s.value == "body_start" && s.raw)
4678 );
4679 assert!(
4680 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4681 raw.value.contains("<app-shell>"))
4682 );
4683 assert!(
4684 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
4685 s.value == "body_end" && s.raw)
4686 );
4687 assert!(
4688 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4689 raw.value.contains("</body>") && raw.value.contains("</html>"))
4690 );
4691 }
4692
4693 #[test]
4694 fn test_css_strategy_external_emits_link_tag() {
4695 let mut parser = HtmlParser::new();
4696 parser
4697 .component_registry_mut()
4698 .register_component(
4699 "my-card",
4700 "<p><slot></slot></p>",
4701 Some("p { color: red; }"),
4702 true,
4703 )
4704 .ok();
4705 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4706 let records = parser.into_fragment_records();
4707 let my_card = &records["my-card"].fragments;
4708 let raw_text: String = my_card
4709 .iter()
4710 .filter_map(|f| match &f.fragment {
4711 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4712 _ => None,
4713 })
4714 .collect();
4715 assert!(
4716 raw_text.contains(r#"<link rel="stylesheet" href="my-card.css">"#),
4717 "Expected external <link> tag in: {}",
4718 raw_text
4719 );
4720 }
4721
4722 #[test]
4723 fn test_uppercase_style_element_is_processed_as_css() {
4724 let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
4729 assert!(
4730 fragments.iter().any(|f| matches!(
4731 f.fragment.as_ref(),
4732 Some(Fragment::Signal(s)) if s.value == "tokens" && !s.raw
4733 )),
4734 "uppercase <STYLE> should be CSS-processed, got: {:?}",
4735 fragments
4736 );
4737 let raw: String = fragments
4738 .iter()
4739 .filter_map(|f| match f.fragment.as_ref() {
4740 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4741 _ => None,
4742 })
4743 .collect();
4744 assert!(
4745 raw.contains("</STYLE>"),
4746 "closing tag should preserve source case, got: {raw}"
4747 );
4748 }
4749
4750 #[test]
4751 fn test_css_strategy_inline_emits_style_tag() {
4752 let mut parser = HtmlParser::with_options(CssStrategy::Style);
4753 parser
4754 .component_registry_mut()
4755 .register_component(
4756 "my-card",
4757 "<p><slot></slot></p>",
4758 Some("p { color: red; }"),
4759 true,
4760 )
4761 .ok();
4762 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4763 let records = parser.into_fragment_records();
4764 let my_card = &records["my-card"].fragments;
4765 let raw_text: String = my_card
4766 .iter()
4767 .filter_map(|f| match &f.fragment {
4768 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4769 _ => None,
4770 })
4771 .collect();
4772 assert!(
4773 raw_text.contains("<style>p { color: red; }</style>"),
4774 "Expected inline <style> tag in: {}",
4775 raw_text
4776 );
4777 assert!(
4778 !raw_text.contains("<link"),
4779 "Should not have <link> tag in inline mode: {}",
4780 raw_text
4781 );
4782 }
4783
4784 #[test]
4785 fn test_css_strategy_module_emits_adopted_stylesheets() {
4786 let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
4787 parser
4788 .component_registry_mut()
4789 .register_component(
4790 "my-card",
4791 "<p><slot></slot></p>",
4792 Some("p { color: red; }"),
4793 true,
4794 )
4795 .ok();
4796 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4797 let records = parser.into_fragment_records();
4798
4799 let my_card = &records["my-card"].fragments;
4802 let template_text: String = my_card
4803 .iter()
4804 .filter_map(|f| match &f.fragment {
4805 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4806 _ => None,
4807 })
4808 .collect();
4809 assert!(
4810 !template_text.contains("shadowrootadoptedstylesheets"),
4811 "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
4812 );
4813 assert!(
4814 !template_text.contains("<link"),
4815 "Should not have <link> in module mode: {template_text}"
4816 );
4817 assert!(
4821 !template_text.contains(r#"<style type="module""#),
4822 "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
4823 );
4824 assert!(
4825 !template_text.contains(r#"<script type="importmap""#),
4826 "CSS module importmap should NOT be in raw fragments: {template_text}"
4827 );
4828 }
4829
4830 #[test]
4831 fn test_css_strategy_module_no_css_no_adopted_attr() {
4832 let mut parser = HtmlParser::with_options(CssStrategy::Module);
4833 parser
4834 .component_registry_mut()
4835 .register_component("my-card", "<p><slot></slot></p>", None, true)
4836 .ok();
4837 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4838 let records = parser.into_fragment_records();
4839 let my_card = &records["my-card"].fragments;
4840 let template_text: String = my_card
4841 .iter()
4842 .filter_map(|f| match &f.fragment {
4843 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4844 _ => None,
4845 })
4846 .collect();
4847 assert!(
4849 !template_text.contains("shadowrootadoptedstylesheets"),
4850 "Should not have adopted attr without CSS: {template_text}"
4851 );
4852 }
4853
4854 #[test]
4873 fn light_preserves_dev_template_with_static_attrs() {
4874 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4875 let processed = parser
4876 .process_component_template(
4877 r#"<template foo="bar"><div>hi</div></template>"#,
4878 None,
4879 None,
4880 )
4881 .expect("process failed");
4882 assert!(
4883 processed.contains(r#"<template foo="bar">"#),
4884 "[--dom=light] expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
4885 );
4886 assert!(
4887 processed.contains("</template>"),
4888 "[--dom=light] expected closing </template> preserved, got: {processed}"
4889 );
4890 }
4891
4892 #[test]
4893 fn light_preserves_dev_template_with_signal_fragment_attrs() {
4894 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4895 let processed = parser
4896 .process_component_template(
4897 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
4898 None,
4899 None,
4900 )
4901 .expect("process failed");
4902 assert!(
4903 processed.contains(r#"<template foo="{{foo}}">"#),
4904 "[--dom=light] expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
4905 );
4906 }
4907
4908 #[test]
4909 fn light_preserves_dev_template_with_multiple_attrs() {
4910 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4911 let processed = parser.process_component_template(
4912 r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
4913 None,
4914 None,
4915 )
4916 .expect("process failed");
4917 assert!(
4918 processed.contains("autofocus")
4919 && processed.contains(r#"tabindex="0""#)
4920 && processed.contains(r#"role="region""#)
4921 && processed.contains(r#"data-x="y""#),
4922 "[--dom=light] expected ALL dev template attrs preserved, got: {processed}"
4923 );
4924 }
4925
4926 #[test]
4927 fn light_no_template_no_wrapper_added() {
4928 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4929 let processed = parser
4930 .process_component_template("<div>hi</div>", None, None)
4931 .expect("process failed");
4932 assert!(
4933 !processed.contains("<template"),
4934 "[--dom=light] framework must NOT add <template> wrapper when dev omits one, got: {processed}"
4935 );
4936 assert!(
4937 processed.contains("<div>hi</div>"),
4938 "[--dom=light] expected inner content emitted as-is, got: {processed}"
4939 );
4940 }
4941
4942 #[test]
4943 fn shadow_preserves_dev_template_with_static_attrs() {
4944 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4945 let processed = parser
4946 .process_component_template(
4947 r#"<template foo="bar"><div>hi</div></template>"#,
4948 None,
4949 None,
4950 )
4951 .expect("process failed");
4952 assert!(
4953 processed.contains(r#"<template foo="bar">"#),
4954 "[--dom=shadow] dev <template foo=\"bar\"> must be preserved verbatim, got: {processed}"
4955 );
4956 assert!(
4957 !processed.contains(r#"shadowrootmode="open""#),
4958 "[--dom=shadow] framework must NOT inject shadowrootmode when dev already supplied a <template>, got: {processed}"
4959 );
4960 }
4961
4962 #[test]
4963 fn shadow_preserves_dev_template_with_shadowrootmode_closed() {
4964 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4965 let processed = parser
4966 .process_component_template(
4967 r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
4968 None,
4969 None,
4970 )
4971 .expect("process failed");
4972 assert!(
4973 processed.contains(r#"shadowrootmode="closed""#),
4974 "[--dom=shadow] framework must respect dev's shadowrootmode=\"closed\" (developer is managing), got: {processed}"
4975 );
4976 assert!(
4977 !processed.contains(r#"shadowrootmode="open""#),
4978 "[--dom=shadow] framework must not overwrite dev's shadowrootmode with \"open\", got: {processed}"
4979 );
4980 }
4981
4982 #[test]
4983 fn shadow_preserves_dev_template_with_signal_fragment_attrs() {
4984 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4985 let processed = parser
4986 .process_component_template(
4987 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
4988 None,
4989 None,
4990 )
4991 .expect("process failed");
4992 assert!(
4993 processed.contains(r#"<template foo="{{foo}}">"#),
4994 "[--dom=shadow] dev <template> with signal-fragment attr must be preserved, got: {processed}"
4995 );
4996 }
4997
4998 #[test]
4999 fn shadow_adds_template_wrapper_when_dev_omits_it() {
5000 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5001 let processed = parser
5002 .process_component_template("<div>hi</div>", None, None)
5003 .expect("process failed");
5004 assert!(
5005 processed.contains(r#"<template shadowrootmode="open""#),
5006 "[--dom=shadow] framework MUST add <template shadowrootmode=\"open\"> when dev omits a wrapper, got: {processed}"
5007 );
5008 assert!(
5009 processed.contains("<div>hi</div>"),
5010 "[--dom=shadow] inner content must survive wrapping, got: {processed}"
5011 );
5012 assert!(
5013 processed.contains("</template>"),
5014 "[--dom=shadow] framework-added wrapper must be closed, got: {processed}"
5015 );
5016 }
5017
5018 #[test]
5028 fn dev_template_module_strategy_appends_adopted_attr_when_missing() {
5029 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5030 let processed = parser
5031 .process_component_template(
5032 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
5033 None,
5034 Some("my-comp"),
5035 )
5036 .expect("process failed");
5037 assert!(
5038 processed.contains(r#"shadowrootmode="open""#),
5039 "dev-authored shadowrootmode must be preserved, got: {processed}"
5040 );
5041 assert!(
5042 processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5043 "module CSS should append adopted stylesheets, got: {processed}"
5044 );
5045 }
5046
5047 #[test]
5048 fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
5049 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5050 let processed = parser
5051 .process_component_template(
5052 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
5053 None,
5054 Some("my-comp"),
5055 )
5056 .unwrap();
5057 assert!(
5058 processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5059 "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
5060 );
5061 assert_eq!(
5062 processed.matches("shadowrootadoptedstylesheets").count(),
5063 1,
5064 "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
5065 );
5066 }
5067
5068 #[test]
5069 fn dev_template_module_strategy_appends_adopted_attr_and_preserves_root_attrs() {
5070 for dom_strategy in [DomStrategy::Shadow, DomStrategy::Light] {
5071 let mut parser = HtmlParser::with_options((CssStrategy::Module, dom_strategy));
5072 let built = match parser.build_component_templates(
5073 "my-comp",
5074 r#"<template shadowrootmode="open" @click="{onClick()}">Hello</template>"#,
5075 Some(":host { color: red; }"),
5076 true,
5077 ) {
5078 Ok(built) => built,
5079 Err(err) => panic!(
5080 "dev-authored <template> should be accepted under {dom_strategy:?} with module CSS, got: {err}"
5081 ),
5082 };
5083 let artifact = built.artifact();
5084
5085 assert!(
5086 artifact.contains(r#"shadowrootmode="open""#),
5087 "dev-authored shadowrootmode must be preserved under {dom_strategy:?}, got: {artifact}"
5088 );
5089 assert!(
5090 artifact.contains(r#"@click="{onClick()}""#),
5091 "dev-authored root event must be preserved under {dom_strategy:?}, got: {artifact}"
5092 );
5093 assert!(
5094 artifact.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5095 "module CSS should append adopted stylesheets under {dom_strategy:?}, got: {artifact}"
5096 );
5097 assert_eq!(
5098 artifact.matches("shadowrootadoptedstylesheets").count(),
5099 1,
5100 "module CSS should append adopted stylesheets once under {dom_strategy:?}, got: {artifact}"
5101 );
5102 assert!(
5103 !built.ssr.contains("@click"),
5104 "SSR template must still strip runtime attrs, got: {}",
5105 built.ssr
5106 );
5107 }
5108 }
5109
5110 #[test]
5111 fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
5112 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5117 let processed = parser
5118 .process_component_template(
5119 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
5120 None,
5121 Some("my-comp"),
5122 )
5123 .unwrap();
5124 assert!(
5125 processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
5126 "dev's multi-specifier list must be preserved verbatim, got: {processed}"
5127 );
5128 }
5129
5130 #[test]
5131 fn dev_template_no_adopted_specifier_does_not_validate() {
5132 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5136 let processed = parser
5137 .process_component_template(
5138 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
5139 None,
5140 None,
5141 )
5142 .unwrap();
5143 assert!(
5144 !processed.contains("shadowrootadoptedstylesheets"),
5145 "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
5146 );
5147 }
5148
5149 #[test]
5161 fn test_body_start_end_injection() {
5162 let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
5166 let (fragments, _) = parse_and_get_fragments(html);
5167
5168 assert_fragments!(
5169 fragments,
5170 [
5171 raw("<html><head><title>Test</title>"),
5172 signal_raw("head_end"),
5173 raw("</head><body>"),
5174 signal_raw("body_start"),
5175 raw("<div>Content</div><p>More</p>"),
5176 signal_raw("body_end"),
5177 raw("</body></html>"),
5178 ]
5179 );
5180 }
5181
5182 #[test]
5183 fn test_body_preserves_static_attributes() {
5184 let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
5189 let (fragments, _) = parse_and_get_fragments(html);
5190
5191 assert_fragments!(
5192 fragments,
5193 [
5194 raw("<html><head><title>T</title>"),
5195 signal_raw("head_end"),
5196 raw(r#"</head><body data-layout="doc" class="page">"#),
5197 signal_raw("body_start"),
5198 raw("<p>x</p>"),
5199 signal_raw("body_end"),
5200 raw("</body></html>"),
5201 ]
5202 );
5203 }
5204
5205 #[test]
5206 fn test_body_preserves_dynamic_attributes() {
5207 let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
5211 let (fragments, _) = parse_and_get_fragments(html);
5212
5213 assert_fragments!(
5214 fragments,
5215 [
5216 raw("<html><head><title>T</title>"),
5217 signal_raw("head_end"),
5218 raw("</head><body"),
5219 attr("data-layout", "layout"),
5220 raw(">"),
5221 signal_raw("body_start"),
5222 raw("<p>x</p>"),
5223 signal_raw("body_end"),
5224 raw("</body></html>"),
5225 ]
5226 );
5227 }
5228
5229 #[test]
5230 fn test_fail_with_invalid_markup() {
5231 let mut parser = HtmlParser::new();
5233 let result = parser.parse("index.html", "<div><span></div></span>");
5234
5235 assert!(matches!(result, Err(ParserError::Template(diag)) if
5236 diag.to_string().contains("unclosed <span> tag")
5237 ));
5238 }
5239
5240 #[test]
5241 fn test_complex_raw_text_page() {
5242 let html = concat!(
5245 "<!DOCTYPE html>",
5246 "<html lang=\"en\">",
5247 "<head>",
5248 "<meta charset=\"utf-8\">",
5249 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
5250 "<title>Complex Page</title>",
5251 "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
5252 "<link rel=\"stylesheet\" href=\"styles.css\">",
5253 "</head>",
5254 "<body>",
5255 "<h1>Hello World</h1>",
5256 "<script type=\"module\" src=\"./app.js\"></script>",
5257 "</body>",
5258 "</html>",
5259 );
5260 let (fragments, _) = parse_and_get_fragments(html);
5261
5262 assert!(
5265 fragments.len() >= 7,
5266 "Expected at least 7 fragments, got {}",
5267 fragments.len()
5268 );
5269
5270 assert!(
5272 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5273 raw.value.contains("<!DOCTYPE html>") &&
5274 raw.value.contains("<meta charset=\"utf-8\">") &&
5275 raw.value.contains("<meta name=\"viewport\"") &&
5276 raw.value.contains("<title>Complex Page</title>") &&
5277 raw.value.contains("<style>") &&
5278 raw.value.contains("body { margin: 0; padding: 0; }")),
5279 "First fragment should contain all head content, got: {:?}",
5280 fragments[0]
5281 );
5282
5283 assert!(
5285 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
5286 s.value == "head_end" && s.raw),
5287 "Second fragment should be head_end signal"
5288 );
5289
5290 assert!(
5292 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5293 raw.value.contains("</head>") && raw.value.ends_with("<body>")),
5294 "Third fragment should contain </head><body>"
5295 );
5296
5297 assert!(
5299 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
5300 s.value == "body_start" && s.raw),
5301 "Fourth fragment should be body_start signal"
5302 );
5303
5304 assert!(
5306 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5307 raw.value.contains("<h1>Hello World</h1>") &&
5308 raw.value.contains("<script")),
5309 "Fifth fragment should contain body content"
5310 );
5311
5312 assert!(
5314 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
5315 s.value == "body_end" && s.raw),
5316 "Sixth fragment should be body_end signal"
5317 );
5318
5319 assert!(
5321 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5322 raw.value.contains("</body>") && raw.value.contains("</html>")),
5323 "Seventh fragment should contain closing tags"
5324 );
5325 }
5326
5327 struct BindingCountPlugin {
5331 counts: Vec<u32>,
5332 }
5333
5334 impl BindingCountPlugin {
5335 fn new() -> Self {
5336 Self { counts: Vec::new() }
5337 }
5338 }
5339
5340 impl crate::plugin::ParserPlugin for BindingCountPlugin {
5341 fn register_component_template(
5342 &mut self,
5343 _tag_name: &str,
5344 _component: &Component,
5345 _processed_template: &str,
5346 ) -> Result<()> {
5347 Ok(())
5348 }
5349
5350 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
5351 if attr_name.starts_with('@') || attr_name == "f-ref" {
5352 AttributeAction::SkipAndCountBinding
5353 } else {
5354 AttributeAction::Keep
5355 }
5356 }
5357
5358 fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
5359 self.counts.push(binding_attribute_count);
5360 if binding_attribute_count > 0 {
5361 Some(binding_attribute_count.to_le_bytes().to_vec())
5362 } else {
5363 None
5364 }
5365 }
5366 }
5367
5368 struct TemplateCapturePlugin {
5369 template: Option<String>,
5370 }
5371
5372 impl TemplateCapturePlugin {
5373 fn new() -> Self {
5374 Self { template: None }
5375 }
5376 }
5377
5378 impl crate::plugin::ParserPlugin for TemplateCapturePlugin {
5379 fn register_component_template(
5380 &mut self,
5381 _tag_name: &str,
5382 _component: &Component,
5383 processed_template: &str,
5384 ) -> Result<()> {
5385 self.template = Some(processed_template.to_string());
5386 Ok(())
5387 }
5388
5389 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
5390 if attr_name.starts_with('@') || attr_name == "f-ref" {
5391 AttributeAction::SkipAndCountBinding
5392 } else {
5393 AttributeAction::Keep
5394 }
5395 }
5396
5397 fn finish_element(&mut self, _binding_attribute_count: u32) -> Option<Vec<u8>> {
5398 None
5399 }
5400
5401 fn into_artifacts(self: Box<Self>) -> Result<ParserPluginArtifacts> {
5402 match self.template {
5403 Some(template) => Ok(ParserPluginArtifacts::ComponentTemplates(vec![
5404 crate::plugin::ComponentTemplateArtifact::template(
5405 "todo-app".to_string(),
5406 template,
5407 ),
5408 ])),
5409 None => Ok(ParserPluginArtifacts::None),
5410 }
5411 }
5412 }
5413
5414 #[test]
5415 fn parser_passes_user_template_root_attrs_to_plugin_without_css_modules() {
5416 let mut parser = HtmlParser::with_plugin_options(
5417 Box::new(TemplateCapturePlugin::new()),
5418 CssStrategy::Link,
5419 );
5420 parser
5421 .component_registry_mut()
5422 .register_component(
5423 "todo-app",
5424 r#"<template shadowrootmode="open" @toggle-item="{onToggleItem($e)}" @delete-item="{onDeleteItem($e)}" f-ref="{root}"><div>items</div></template>"#,
5425 Some(":host { display: block; }"),
5426 true,)
5427 .expect("register todo-app");
5428
5429 parser
5430 .parse("index.html", "<todo-app></todo-app>")
5431 .expect("parse failed");
5432
5433 let artifacts = parser.take_plugin_artifacts().expect("artifacts");
5434 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
5435 panic!("expected captured component template");
5436 };
5437 let template = &templates[0].template;
5438
5439 assert!(
5440 template.contains(r#"shadowrootmode="open""#),
5441 "parser must pass dev-authored root template attrs to plugins, got: {template}"
5442 );
5443 assert!(
5444 template.contains(r#"@toggle-item="{onToggleItem($e)}""#),
5445 "parser must pass dev-authored root event attrs to plugins, got: {template}"
5446 );
5447 assert!(
5448 template.contains(r#"@delete-item="{onDeleteItem($e)}""#),
5449 "parser must pass dev-authored root event attrs to plugins, got: {template}"
5450 );
5451 assert!(
5452 template.contains(r#"f-ref="{root}""#),
5453 "parser must pass dev-authored root FAST attrs to plugins, got: {template}"
5454 );
5455 assert!(
5456 !template.contains("shadowrootadoptedstylesheets"),
5457 "link CSS must not add CSS module adoption attrs, got: {template}"
5458 );
5459 }
5460
5461 #[test]
5462 fn test_component_static_attrs_not_counted_as_bindings() {
5463 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
5464 parser
5465 .component_registry
5466 .register_component("my-btn", "<button><slot></slot></button>", None, true)
5467 .expect("register");
5468
5469 let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
5471 let result = parser.parse("index.html", html);
5472 assert!(result.is_ok());
5473
5474 let records = parser.into_fragment_records();
5475
5476 let frags = &records["index.html"].fragments;
5478 let plugin_count = frags
5479 .iter()
5480 .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
5481 .count();
5482 assert_eq!(
5483 plugin_count, 0,
5484 "Static-only component attributes should not emit a Plugin fragment"
5485 );
5486 }
5487
5488 #[test]
5489 fn test_component_dynamic_attr_counted_as_binding() {
5490 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
5491 parser
5492 .component_registry
5493 .register_component("my-btn", "<button><slot></slot></button>", None, true)
5494 .expect("register");
5495
5496 let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
5498 let result = parser.parse("index.html", html);
5499 assert!(result.is_ok());
5500
5501 let records = parser.into_fragment_records();
5502 let frags = &records["index.html"].fragments;
5503
5504 let plugin_frags: Vec<_> = frags
5506 .iter()
5507 .filter_map(|f| match f.fragment.as_ref() {
5508 Some(Fragment::Plugin(p)) => Some(&p.data),
5509 _ => None,
5510 })
5511 .collect();
5512 assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
5513 let count = u32::from_le_bytes([
5514 plugin_frags[0][0],
5515 plugin_frags[0][1],
5516 plugin_frags[0][2],
5517 plugin_frags[0][3],
5518 ]);
5519 assert_eq!(count, 1, "Binding attribute count should be 1");
5520 }
5521
5522 #[test]
5523 fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
5524 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
5525 parser
5526 .component_registry
5527 .register_component("my-btn", "<button><slot></slot></button>", None, true)
5528 .expect("register");
5529
5530 let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
5532 let result = parser.parse("index.html", html);
5533 assert!(result.is_ok());
5534
5535 let records = parser.into_fragment_records();
5536 let frags = &records["index.html"].fragments;
5537
5538 let plugin_frags: Vec<_> = frags
5539 .iter()
5540 .filter_map(|f| match f.fragment.as_ref() {
5541 Some(Fragment::Plugin(p)) => Some(&p.data),
5542 _ => None,
5543 })
5544 .collect();
5545 assert_eq!(plugin_frags.len(), 1);
5546 let count = u32::from_le_bytes([
5547 plugin_frags[0][0],
5548 plugin_frags[0][1],
5549 plugin_frags[0][2],
5550 plugin_frags[0][3],
5551 ]);
5552 assert_eq!(
5554 count, 2,
5555 "Binding count should include dynamic + plugin-skipped attrs, not static"
5556 );
5557 }
5558
5559 #[test]
5560 fn test_component_only_skipped_plugin_attrs_counted() {
5561 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
5562 parser
5563 .component_registry
5564 .register_component("my-btn", "<button><slot></slot></button>", None, true)
5565 .expect("register");
5566
5567 let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
5569 let result = parser.parse("index.html", html);
5570 assert!(result.is_ok());
5571
5572 let records = parser.into_fragment_records();
5573 let frags = &records["index.html"].fragments;
5574
5575 let plugin_frags: Vec<_> = frags
5576 .iter()
5577 .filter_map(|f| match f.fragment.as_ref() {
5578 Some(Fragment::Plugin(p)) => Some(&p.data),
5579 _ => None,
5580 })
5581 .collect();
5582 assert_eq!(plugin_frags.len(), 1);
5583 let count = u32::from_le_bytes([
5584 plugin_frags[0][0],
5585 plugin_frags[0][1],
5586 plugin_frags[0][2],
5587 plugin_frags[0][3],
5588 ]);
5589 assert_eq!(
5591 count, 2,
5592 "Only plugin-skipped attrs should be counted, not static title"
5593 );
5594 }
5595
5596 #[test]
5599 fn test_comment_handlebars_signal_is_stripped() {
5600 let mut parser = HtmlParser::new();
5601 let html = "<!--{{tokens}}-->";
5602 parser.parse("test.html", html).expect("parse failed");
5603 let records = parser.into_fragment_records();
5604
5605 assert_stream!(records, "test.html", []);
5606 }
5607
5608 #[test]
5609 fn test_comment_triple_brace_raw_signal_is_stripped() {
5610 let mut parser = HtmlParser::new();
5611 let html = "<!--{{{tokens}}}-->";
5612 parser.parse("test.html", html).expect("parse failed");
5613 let records = parser.into_fragment_records();
5614
5615 assert_stream!(records, "test.html", []);
5616 }
5617
5618 #[test]
5619 fn test_comment_regular_stripped() {
5620 let mut parser = HtmlParser::new();
5621 let html = "<!-- regular comment -->";
5622 parser.parse("test.html", html).expect("parse failed");
5623 let records = parser.into_fragment_records();
5624
5625 assert_stream!(records, "test.html", []);
5626 }
5627
5628 #[test]
5629 fn test_comment_dotted_signal_is_stripped() {
5630 let mut parser = HtmlParser::new();
5631 let html = "<!--{{tokens.light}}-->";
5632 parser.parse("test.html", html).expect("parse failed");
5633 let records = parser.into_fragment_records();
5634
5635 assert_stream!(records, "test.html", []);
5636 }
5637
5638 #[test]
5639 fn test_comment_arbitrary_identifier_is_stripped() {
5640 let mut parser = HtmlParser::new();
5641 let html = "<!--{{someOtherBinding}}-->";
5642 parser.parse("test.html", html).expect("parse failed");
5643 let records = parser.into_fragment_records();
5644
5645 assert_stream!(records, "test.html", []);
5646 }
5647
5648 #[test]
5649 fn test_comment_with_surrounding_content_is_stripped() {
5650 let mut parser = HtmlParser::new();
5651 let html = "<div><!--{{tokens}}--></div>";
5652 parser.parse("test.html", html).expect("parse failed");
5653 let records = parser.into_fragment_records();
5654
5655 assert_stream!(records, "test.html", [raw("<div></div>")]);
5656 }
5657
5658 #[test]
5659 fn test_component_template_unterminated_comment_returns_error() {
5660 let mut parser = HtmlParser::new();
5661 parser
5662 .component_registry_mut()
5663 .register_component("x-bad", "<span>ok</span><!-- missing close", None, true)
5664 .expect("register failed");
5665
5666 let result = parser.parse("index.html", "<x-bad></x-bad>");
5667
5668 assert!(matches!(result, Err(ParserError::Template(diag)) if
5669 diag.to_string().contains("unterminated HTML comment")
5670 ));
5671 }
5672
5673 #[test]
5674 fn test_webui_plugin_strips_component_comments_before_metadata() {
5675 let mut parser =
5676 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5677 parser
5678 .component_registry_mut()
5679 .register_component(
5680 "x-bleed",
5681 r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
5682 None,
5683 true,
5684 )
5685 .expect("register failed");
5686
5687 parser
5688 .parse("index.html", "<x-bleed></x-bleed>")
5689 .expect("parse failed");
5690
5691 let artifacts = parser.take_plugin_artifacts().unwrap();
5692 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
5693 panic!("expected component templates");
5694 };
5695 let template = &templates[0].template_json;
5696 assert!(template.contains("<div>hello</div>"));
5697 assert!(!template.contains("{{path}}"));
5698 assert!(!template.contains("@click"));
5699 assert!(!template.contains("?hidden"));
5700 assert!(!template.contains("-->"));
5701 }
5702
5703 #[test]
5704 fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
5705 let mut parser =
5706 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5707 parser
5708 .component_registry_mut()
5709 .register_component(
5710 "x-style",
5711 "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
5712 None,
5713 true,
5714 )
5715 .expect("register failed");
5716
5717 parser
5718 .parse("index.html", "<x-style></x-style>")
5719 .expect("parse failed");
5720
5721 let artifacts = parser.take_plugin_artifacts().unwrap();
5722 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
5723 panic!("expected component templates");
5724 };
5725 let template = &templates[0].template_json;
5726 assert!(template.contains(".x { color: red; }"));
5727 assert!(!template.contains("ignored"));
5728 assert!(!template.contains("//"));
5729 }
5730
5731 #[test]
5734 fn test_tokens_from_style_tag() {
5735 let mut parser = HtmlParser::new();
5736 let html = r#"<style>
5737 .btn { color: var(--colorPrimary); background: var(--bgColor); }
5738 </style>"#;
5739 parser.parse("test.html", html).expect("parse failed");
5740 let tokens = parser.take_tokens();
5741
5742 assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
5743 }
5744
5745 #[test]
5746 fn test_tokens_from_malformed_style_error_on_unclosed_var() {
5747 let mut parser = HtmlParser::new();
5748 let html = r#"<style>
5749 .bad { color: var(--dangling; }
5750 .ok { color: var(--valid); }
5751 </style>"#;
5752 let result = parser.parse("test.html", html);
5753
5754 assert!(matches!(result, Err(ParserError::Template(diag)) if
5755 diag.error_code() == Some(codes::INVALID_CSS)
5756 && diag.to_string().contains("Unterminated CSS var() call")
5757 ));
5758 }
5759
5760 #[test]
5761 fn test_tokens_from_component_css() {
5762 let mut parser = HtmlParser::new();
5763 parser
5764 .component_registry_mut()
5765 .register_component(
5766 "my-button",
5767 "<button>Click</button>",
5768 Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
5769 true,
5770 )
5771 .expect("register failed");
5772
5773 let html = "<my-button></my-button>";
5774 parser.parse("test.html", html).expect("parse failed");
5775 let tokens = parser.take_tokens();
5776
5777 assert_eq!(tokens, vec!["borderWidth", "textColor"]);
5778 }
5779
5780 #[test]
5781 fn test_token_requirements_preserve_fallback_chains() {
5782 let mut parser = HtmlParser::new();
5783 parser
5784 .component_registry_mut()
5785 .register_component(
5786 "my-card",
5787 "<div>Card</div>",
5788 Some(":host { color: var(--token-a, var(--token-b, var(--token-c), true)); }"),
5789 true,
5790 )
5791 .expect("register failed");
5792
5793 parser
5794 .parse("test.html", "<my-card></my-card>")
5795 .expect("parse failed");
5796 let analysis = parser.token_analysis();
5797
5798 assert_eq!(analysis.fallback_chains.len(), 1);
5799 assert_eq!(
5800 analysis.fallback_chains[0].tokens,
5801 vec!["token-a", "token-b", "token-c"]
5802 );
5803 assert!(!analysis.fallback_chains[0].has_literal_fallback);
5804 }
5805
5806 #[test]
5807 fn test_token_requirements_remove_locally_defined_candidates_from_fallback_chain() {
5808 let mut parser = HtmlParser::new();
5809 parser
5810 .component_registry_mut()
5811 .register_component(
5812 "my-card",
5813 "<div>Card</div>",
5814 Some(
5815 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
5816 ),
5817 true,)
5818 .expect("register failed");
5819
5820 parser
5821 .parse("test.html", "<my-card></my-card>")
5822 .expect("parse failed");
5823 let analysis = parser.token_analysis();
5824
5825 assert_eq!(analysis.protocol_tokens, vec!["token-b", "token-c"]);
5826 assert_eq!(analysis.fallback_chains.len(), 1);
5827 assert_eq!(
5828 analysis.fallback_chains[0].tokens,
5829 vec!["token-b", "token-c"]
5830 );
5831 }
5832
5833 #[test]
5834 fn test_token_analysis_theme_validation_reports_missing_unresolved_token() {
5835 let mut parser = HtmlParser::new();
5836 parser
5837 .component_registry_mut()
5838 .register_component(
5839 "my-card",
5840 "<div>Card</div>",
5841 Some(
5842 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
5843 ),
5844 true,)
5845 .expect("register failed");
5846 parser
5847 .parse("test.html", "<my-card></my-card>")
5848 .expect("parse failed");
5849
5850 let analysis = parser.token_analysis();
5851 let theme = webui_tokens::TokenFile {
5852 themes: HashMap::from([(
5853 "light".to_string(),
5854 HashMap::from([("token-b".to_string(), "green".to_string())]),
5855 )]),
5856 };
5857
5858 let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
5859 panic!("missing --token-c in the theme must fail parser validation");
5860 };
5861 assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
5862 let help = diagnostic.help_text().expect("help text");
5865 assert!(help.contains("did you mean --token-b?"), "help: {help}");
5866 assert!(help.contains("define it locally"), "help: {help}");
5867 assert!(!help.contains("add --token"), "help: {help}");
5868 }
5869
5870 #[test]
5871 fn test_token_analysis_literal_fallback_token_exempt_from_theme_validation() {
5872 let mut parser = HtmlParser::new();
5873 parser
5874 .component_registry_mut()
5875 .register_component(
5876 "my-card",
5877 "<div>Card</div>",
5878 Some(":host { color: var(--brand, #000); }"),
5879 true,
5880 )
5881 .expect("register failed");
5882 parser
5883 .parse("test.html", "<my-card></my-card>")
5884 .expect("parse failed");
5885
5886 let analysis = parser.token_analysis();
5887 assert_eq!(analysis.protocol_tokens, vec!["brand"]);
5890
5891 let theme = webui_tokens::TokenFile {
5894 themes: HashMap::from([("light".to_string(), HashMap::new())]),
5895 };
5896 analysis
5897 .validate_theme_tokens(&theme)
5898 .expect("literal fallback must exempt --brand from theme validation");
5899 }
5900
5901 #[test]
5902 fn test_token_analysis_mixed_literal_and_bare_usage_requires_token() {
5903 let mut parser = HtmlParser::new();
5904 parser
5905 .component_registry_mut()
5906 .register_component(
5907 "my-card",
5908 "<div>Card</div>",
5909 Some(":host { color: var(--brand, #000); background: var(--brand); }"),
5912 true,
5913 )
5914 .expect("register failed");
5915 parser
5916 .parse("test.html", "<my-card></my-card>")
5917 .expect("parse failed");
5918
5919 let analysis = parser.token_analysis();
5920 let theme = webui_tokens::TokenFile {
5921 themes: HashMap::from([("light".to_string(), HashMap::new())]),
5922 };
5923
5924 let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
5925 panic!("a bare var(--brand) usage must require --brand in the theme");
5926 };
5927 assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
5928 assert!(diagnostic.to_string().contains("--brand"));
5929 }
5930
5931 #[test]
5932 fn test_unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
5933 let mut parser = HtmlParser::new();
5934 parser
5935 .component_registry_mut()
5936 .register_component(
5937 "my-card",
5938 "<div>Card</div>",
5939 Some(
5940 ":host { \
5941 color: var(--colr-brand, #000); \
5942 border: var(--present, 1px); \
5943 margin: var(--required); \
5944 }",
5945 ),
5946 true,
5947 )
5948 .expect("register failed");
5949 parser
5950 .parse("test.html", "<my-card></my-card>")
5951 .expect("parse failed");
5952
5953 let analysis = parser.token_analysis();
5954 let theme = webui_tokens::TokenFile {
5955 themes: HashMap::from([(
5956 "light".to_string(),
5957 HashMap::from([
5958 ("present".to_string(), "2px".to_string()),
5959 ("required".to_string(), "8px".to_string()),
5960 ]),
5961 )]),
5962 };
5963
5964 assert_eq!(
5968 analysis.unthemed_literal_fallback_tokens(&theme),
5969 vec!["colr-brand".to_string()]
5970 );
5971 }
5972
5973 #[test]
5974 fn test_theme_token_error_reports_location_and_suggestion() {
5975 let mut parser = HtmlParser::new();
5976 parser
5977 .component_registry_mut()
5978 .register_component(
5979 "my-card",
5980 "<div>Card</div>",
5981 ":host {\n color: var(--color-neutral-2000);\n}".into(),
5982 true,
5983 )
5984 .expect("register failed");
5985 parser
5986 .parse("test.html", "<my-card></my-card>")
5987 .expect("parse failed");
5988
5989 let analysis = parser.token_analysis();
5990 let theme = webui_tokens::TokenFile {
5991 themes: HashMap::from([(
5992 "dark".to_string(),
5993 HashMap::from([("color-neutral-200".to_string(), "#222".to_string())]),
5994 )]),
5995 };
5996
5997 let Err(ParserError::Template(diag)) = analysis.validate_theme_tokens(&theme) else {
5998 panic!("missing --color-neutral-2000 must fail validation");
5999 };
6000 assert_eq!(diag.error_code(), Some(codes::MISSING_THEME_TOKEN));
6001 let location = diag.location().expect("a source location");
6003 assert!(location.contains("my-card.css:2:"), "location: {location}");
6004 assert!(
6006 diag.snippet_text()
6007 .is_some_and(|s| s.contains("--color-neutral-2000")),
6008 "snippet: {:?}",
6009 diag.snippet_text()
6010 );
6011 assert!(
6013 diag.help_text()
6014 .is_some_and(|h| h.contains("did you mean --color-neutral-200?")),
6015 "help: {:?}",
6016 diag.help_text()
6017 );
6018 }
6019
6020 #[test]
6021 fn test_theme_token_warning_reports_location_and_suggestion() {
6022 let mut parser = HtmlParser::new();
6023 parser
6024 .component_registry_mut()
6025 .register_component(
6026 "my-card",
6027 "<div>Card</div>",
6028 ":host {\n color: var(--colr-brand, #000);\n}".into(),
6029 true,
6030 )
6031 .expect("register failed");
6032 parser
6033 .parse("test.html", "<my-card></my-card>")
6034 .expect("parse failed");
6035
6036 let analysis = parser.token_analysis();
6037 let theme = webui_tokens::TokenFile {
6038 themes: HashMap::from([(
6039 "light".to_string(),
6040 HashMap::from([("color-brand".to_string(), "#abc".to_string())]),
6041 )]),
6042 };
6043
6044 let warnings = analysis.theme_token_warnings(&theme);
6045 assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
6046 let body = warnings[0].body();
6047 assert!(body.contains("my-card.css:2:"), "warning: {body}");
6048 assert!(body.contains("--colr-brand"), "warning: {body}");
6049 assert!(
6050 body.contains("did you mean --color-brand?"),
6051 "warning: {body}"
6052 );
6053 }
6054
6055 #[test]
6056 fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
6057 let mut parser = HtmlParser::new();
6058 let result = parser.component_registry_mut().register_component(
6059 "my-card",
6060 "<div>Card</div>",
6061 Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
6062 true,
6063 );
6064
6065 assert!(matches!(result, Err(ParserError::Css(message)) if
6066 message.contains("Unterminated CSS var() call")
6067 ));
6068 }
6069
6070 #[test]
6071 fn test_tokens_merged_from_style_and_components() {
6072 let mut parser = HtmlParser::new();
6073 parser
6074 .component_registry_mut()
6075 .register_component(
6076 "my-widget",
6077 "<div>Widget</div>",
6078 Some(".w { padding: var(--spacingM); }"),
6079 true,
6080 )
6081 .expect("register failed");
6082
6083 let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
6084 parser.parse("test.html", html).expect("parse failed");
6085 let tokens = parser.take_tokens();
6086
6087 assert_eq!(tokens, vec!["spacingM", "textColor"]);
6088 }
6089
6090 #[test]
6091 fn test_tokens_deduplicated() {
6092 let mut parser = HtmlParser::new();
6093 parser
6094 .component_registry_mut()
6095 .register_component(
6096 "my-btn",
6097 "<button>B</button>",
6098 Some(".b { color: var(--shared); }"),
6099 true,
6100 )
6101 .expect("register failed");
6102
6103 let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
6104 parser.parse("test.html", html).expect("parse failed");
6105 let tokens = parser.take_tokens();
6106
6107 assert_eq!(tokens, vec!["shared"]);
6108 }
6109
6110 #[test]
6111 fn test_tokens_empty_when_no_vars() {
6112 let mut parser = HtmlParser::new();
6113 let html = "<div>Hello</div>";
6114 parser.parse("test.html", html).expect("parse failed");
6115 let tokens = parser.take_tokens();
6116
6117 assert!(tokens.is_empty());
6118 }
6119
6120 #[test]
6121 fn test_tokens_exclude_locally_defined_in_component() {
6122 let mut parser = HtmlParser::new();
6123 parser
6124 .component_registry_mut()
6125 .register_component(
6126 "my-card",
6127 "<div>Card</div>",
6128 Some(":host { --local: 5px; width: var(--external); }"),
6129 true,
6130 )
6131 .expect("register failed");
6132
6133 let html = "<my-card></my-card>";
6134 parser.parse("test.html", html).expect("parse failed");
6135 let tokens = parser.take_tokens();
6136
6137 assert_eq!(tokens, vec!["external"]);
6138 }
6139
6140 #[test]
6141 fn test_tokens_exclude_entry_root_definitions() {
6142 let mut parser = HtmlParser::new();
6143 parser
6144 .component_registry_mut()
6145 .register_component(
6146 "my-btn",
6147 "<button>B</button>",
6148 Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
6149 true,
6150 )
6151 .expect("register failed");
6152
6153 let html = r#"<style>
6156 :root {
6157 --color-primary: #0078d4;
6158 --radius-m: 6px;
6159 }
6160 body { color: var(--color-primary); }
6161 </style>
6162 <my-btn></my-btn>"#;
6163 parser.parse("test.html", html).expect("parse failed");
6164 let tokens = parser.take_tokens();
6165
6166 assert!(
6168 tokens.is_empty(),
6169 "Tokens defined in entry :root should be excluded: {tokens:?}"
6170 );
6171 }
6172
6173 #[test]
6174 fn test_tokens_entry_defs_exclude_but_external_kept() {
6175 let mut parser = HtmlParser::new();
6176 parser
6177 .component_registry_mut()
6178 .register_component(
6179 "my-card",
6180 "<div>Card</div>",
6181 Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
6182 true,
6183 )
6184 .expect("register failed");
6185
6186 let html = r#"<style>
6188 :root { --color-primary: #0078d4; }
6189 </style>
6190 <my-card></my-card>"#;
6191 parser.parse("test.html", html).expect("parse failed");
6192 let tokens = parser.take_tokens();
6193
6194 assert_eq!(tokens, vec!["external-spacing"]);
6196 }
6197
6198 #[test]
6201 fn test_parse_simple_route() {
6202 let mut parser = HtmlParser::new();
6203 let html = r#"<route path="/profile" component="profile-page" exact />"#;
6204 parser.parse("test.html", html).expect("parse failed");
6205
6206 let records = parser.into_fragment_records();
6208 let frags = &records["test.html"].fragments;
6209 assert_eq!(frags.len(), 1);
6210 match frags[0].fragment.as_ref() {
6211 Some(web_ui_fragment::Fragment::Route(r)) => {
6212 assert_eq!(r.path, "/profile");
6213 assert_eq!(r.fragment_id, "profile-page");
6214 assert!(r.exact);
6215 }
6216 other => panic!("expected Fragment::Route, got {:?}", other),
6217 }
6218 }
6219
6220 #[test]
6221 fn test_parse_route_with_params() {
6222 let mut parser = HtmlParser::new();
6223 let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
6224 parser.parse("test.html", html).expect("parse failed");
6225
6226 let records = parser.into_fragment_records();
6228 let frags = &records["test.html"].fragments;
6229 match frags[0].fragment.as_ref() {
6230 Some(web_ui_fragment::Fragment::Route(r)) => {
6231 assert_eq!(r.path, "/profile/:id/view/:section");
6232 }
6233 other => panic!("expected Fragment::Route, got {:?}", other),
6234 }
6235 }
6236
6237 #[test]
6238 fn test_parse_route_requires_component() {
6239 let mut parser = HtmlParser::new();
6240 let html = r#"<route path="/old" />"#;
6241 let result = parser.parse("test.html", html);
6242 assert!(result.is_err());
6243 }
6244
6245 #[test]
6246 fn test_parse_multiple_routes() {
6247 let mut parser = HtmlParser::new();
6248 let html = r#"<route path="/" component="app-layout" />
6249 <route path="/dashboard" component="dash-page" exact />
6250 <route path="/contacts" component="contacts-page" exact />"#;
6251 parser.parse("test.html", html).expect("parse failed");
6252
6253 let records = parser.into_fragment_records();
6254 let frags = &records["test.html"].fragments;
6255 assert_eq!(frags.len(), 3);
6256 for frag in frags {
6258 assert!(matches!(
6259 frag.fragment.as_ref(),
6260 Some(web_ui_fragment::Fragment::Route(_))
6261 ));
6262 }
6263 }
6264
6265 #[test]
6266 fn test_parse_route_requires_component_with_body() {
6267 let mut parser = HtmlParser::new();
6268 let html = r#"<route path="/error">
6269 <div class="error"><h1>Not Found</h1></div>
6270 </route>"#;
6271 let result = parser.parse("test.html", html);
6272 assert!(
6273 result.is_err(),
6274 "Route without component attribute should fail"
6275 );
6276 }
6277
6278 #[test]
6279 fn test_parse_multiple_routes_with_fragments() {
6280 let mut parser = HtmlParser::new();
6281 let html = r#"<route path="/" component="home-page" exact />
6282 <route path="/about" component="about-page" exact />
6283 <route path="/contact/:id" component="contact-page" />"#;
6284 parser.parse("test.html", html).expect("parse failed");
6285
6286 let records = parser.into_fragment_records();
6287 let frags = &records["test.html"].fragments;
6288 assert_eq!(frags.len(), 3);
6289
6290 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
6292 assert_eq!(r.path, "/");
6293 }
6294 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
6295 assert_eq!(r.path, "/about");
6296 }
6297 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
6298 assert_eq!(r.path, "/contact/:id");
6299 }
6300 }
6301
6302 #[test]
6303 fn test_parse_route_with_query_allowlist() {
6304 let mut parser = HtmlParser::new();
6305 let html =
6306 r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
6307 parser.parse("test.html", html).expect("parse failed");
6308
6309 let records = parser.into_fragment_records();
6310 let frags = &records["test.html"].fragments;
6311 assert_eq!(frags.len(), 1);
6312 match frags[0].fragment.as_ref() {
6313 Some(web_ui_fragment::Fragment::Route(r)) => {
6314 assert_eq!(r.path, "/compose");
6315 assert_eq!(r.fragment_id, "compose-page");
6316 assert!(r.exact);
6317 assert_eq!(r.allowed_query, "action,to,subject");
6318 }
6319 other => panic!("expected Fragment::Route, got {:?}", other),
6320 }
6321 }
6322
6323 #[test]
6324 fn test_parse_route_without_query_has_empty_allowed_query() {
6325 let mut parser = HtmlParser::new();
6326 let html = r#"<route path="/profile" component="profile-page" exact />"#;
6327 parser.parse("test.html", html).expect("parse failed");
6328
6329 let records = parser.into_fragment_records();
6330 let frags = &records["test.html"].fragments;
6331 match frags[0].fragment.as_ref() {
6332 Some(web_ui_fragment::Fragment::Route(r)) => {
6333 assert!(r.allowed_query.is_empty());
6334 }
6335 other => panic!("expected Fragment::Route, got {:?}", other),
6336 }
6337 }
6338
6339 #[test]
6340 fn test_route_body_rejects_malformed_non_route_html() {
6341 let mut parser = HtmlParser::new();
6342 let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
6343 let result = parser.parse("test.html", html);
6344
6345 assert!(matches!(result, Err(ParserError::Template(diag)) if
6346 diag.to_string().contains("unclosed <span> tag")
6347 ));
6348 }
6349
6350 #[test]
6351 fn test_route_body_rejects_malformed_style_css() {
6352 let mut parser = HtmlParser::new();
6353 let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
6354 let result = parser.parse("test.html", html);
6355
6356 assert!(matches!(result, Err(ParserError::Template(diag)) if
6357 diag.error_code() == Some(codes::INVALID_CSS)
6358 && diag.to_string().contains("Unterminated CSS var() call")
6359 ));
6360 }
6361
6362 #[test]
6363 fn test_outlet_not_captured_by_for_loop() {
6364 let mut parser = HtmlParser::new();
6365 let html = r#"<template shadowrootmode="open">
6366 <ul>
6367 <for each="item in items">
6368 <li>{{item.name}}</li>
6369 </for>
6370 </ul>
6371 <main>
6372 <outlet />
6373 </main>
6374</template>"#;
6375 parser.parse("comp.html", html).expect("parse failed");
6376
6377 let records = parser.into_fragment_records();
6378 let frags = &records["comp.html"].fragments;
6379
6380 let outlet_count = frags
6382 .iter()
6383 .filter(|f| {
6384 matches!(
6385 f.fragment.as_ref(),
6386 Some(web_ui_fragment::Fragment::Outlet(_))
6387 )
6388 })
6389 .count();
6390 assert_eq!(
6391 outlet_count, 1,
6392 "expected exactly 1 outlet in top-level fragments, got {outlet_count}"
6393 );
6394
6395 let outlet_idx = frags
6397 .iter()
6398 .position(|f| {
6399 matches!(
6400 f.fragment.as_ref(),
6401 Some(web_ui_fragment::Fragment::Outlet(_))
6402 )
6403 })
6404 .expect("no outlet found");
6405 let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
6406 Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
6407 _ => false,
6408 });
6409 if let Some(ul_idx) = close_ul_idx {
6410 assert!(
6411 outlet_idx > ul_idx,
6412 "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
6413 );
6414 }
6415 }
6416
6417 #[test]
6418 fn test_outlet_position_after_for_not_inside() {
6419 let mut parser = HtmlParser::new();
6420 let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
6421 parser.parse("test.html", html).expect("parse failed");
6422
6423 let records = parser.into_fragment_records();
6424 let frags = &records["test.html"].fragments;
6425
6426 let has_outlet = frags.iter().any(|f| {
6428 matches!(
6429 f.fragment.as_ref(),
6430 Some(web_ui_fragment::Fragment::Outlet(_))
6431 )
6432 });
6433 assert!(
6434 has_outlet,
6435 "outlet should be in top-level fragments: {frags:?}"
6436 );
6437
6438 let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
6440 Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
6441 _ => None,
6442 });
6443 if let Some(id) = for_id {
6444 let for_frags = &records[&id].fragments;
6445 let outlet_in_for = for_frags.iter().any(|f| {
6446 matches!(
6447 f.fragment.as_ref(),
6448 Some(web_ui_fragment::Fragment::Outlet(_))
6449 )
6450 });
6451 assert!(
6452 !outlet_in_for,
6453 "outlet should NOT be inside for-loop body: {for_frags:?}"
6454 );
6455 }
6456 }
6457
6458 #[test]
6459 fn test_style_element_with_handlebars_signal_comment_emits_signal() {
6460 let mut parser = HtmlParser::new();
6461 let html = r#"<html><head><style>
6462:root {
6463 /*{{{tokens.light}}}*/
6464}
6465</style></head><body></body></html>"#;
6466
6467 let result = parser.parse("test.html", html);
6468 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6469 let fragment_records = parser.into_fragment_records();
6470
6471 assert_stream!(
6472 fragment_records,
6473 "test.html",
6474 [
6475 raw("<html><head><style>\n:root {\n "),
6476 signal_raw("tokens.light"),
6477 raw("\n}\n</style>"),
6478 signal_raw("head_end"),
6479 raw("</head><body>"),
6480 signal_raw("body_start"),
6481 signal_raw("body_end"),
6482 raw("</body></html>"),
6483 ]
6484 );
6485 }
6486
6487 #[test]
6488 fn test_style_comment_signal_with_spaces_emits_signal() {
6489 let mut parser = HtmlParser::new();
6490 let html = r#"<html><head><style>
6491:root {
6492 /* {{{tokens.light}}} */
6493}
6494</style></head><body></body></html>"#;
6495
6496 let result = parser.parse("test.html", html);
6497 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6498 let fragment_records = parser.into_fragment_records();
6499
6500 assert_stream!(
6501 fragment_records,
6502 "test.html",
6503 [
6504 raw("<html><head><style>\n:root {\n "),
6505 signal_raw("tokens.light"),
6506 raw("\n}\n</style>"),
6507 signal_raw("head_end"),
6508 raw("</head><body>"),
6509 signal_raw("body_start"),
6510 signal_raw("body_end"),
6511 raw("</body></html>"),
6512 ]
6513 );
6514 }
6515
6516 #[test]
6517 fn test_style_comment_signal_double_brace_emits_signal() {
6518 let mut parser = HtmlParser::new();
6519 let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
6520
6521 let result = parser.parse("test.html", html);
6522 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6523 let fragment_records = parser.into_fragment_records();
6524
6525 assert_stream!(
6526 fragment_records,
6527 "test.html",
6528 [
6529 raw("<html><head><style>"),
6530 signal("themeCss"),
6531 raw("</style>"),
6532 signal_raw("head_end"),
6533 raw("</head><body>"),
6534 signal_raw("body_start"),
6535 signal_raw("body_end"),
6536 raw("</body></html>"),
6537 ]
6538 );
6539 }
6540
6541 #[test]
6542 fn test_style_comment_with_extra_text_is_stripped() {
6543 let mut parser = HtmlParser::new();
6544 let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
6545
6546 let result = parser.parse("test.html", html);
6547 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6548 let fragment_records = parser.into_fragment_records();
6549
6550 assert_stream!(
6551 fragment_records,
6552 "test.html",
6553 [
6554 raw("<html><head><style></style>"),
6555 signal_raw("head_end"),
6556 raw("</head><body>"),
6557 signal_raw("body_start"),
6558 signal_raw("body_end"),
6559 raw("</body></html>"),
6560 ]
6561 );
6562 }
6563
6564 #[test]
6565 fn test_style_comment_with_multiple_signals_is_stripped() {
6566 let mut parser = HtmlParser::new();
6567 let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
6568
6569 let result = parser.parse("test.html", html);
6570 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6571 let fragment_records = parser.into_fragment_records();
6572
6573 assert_stream!(
6574 fragment_records,
6575 "test.html",
6576 [
6577 raw("<html><head><style></style>"),
6578 signal_raw("head_end"),
6579 raw("</head><body>"),
6580 signal_raw("body_start"),
6581 signal_raw("body_end"),
6582 raw("</body></html>"),
6583 ]
6584 );
6585 }
6586
6587 #[test]
6588 fn test_style_bare_handlebars_stays_raw() {
6589 let mut parser = HtmlParser::new();
6590 let html =
6592 "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
6593
6594 let result = parser.parse("test.html", html);
6595 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6596 let fragment_records = parser.into_fragment_records();
6597
6598 assert_stream!(
6599 fragment_records,
6600 "test.html",
6601 [
6602 raw("<html><head><style>body { color: {{textColor}}; }</style>"),
6603 signal_raw("head_end"),
6604 raw("</head><body>"),
6605 signal_raw("body_start"),
6606 signal_raw("body_end"),
6607 raw("</body></html>"),
6608 ]
6609 );
6610 }
6611
6612 #[test]
6613 fn test_style_mixed_css_and_comment_signal_emits_signal() {
6614 let mut parser = HtmlParser::new();
6615 let html = r#"<html><head><style>
6616 .a { color: red; }
6617 /*{{themeCss}}*/
6618 .b { color: blue; }
6619</style></head><body></body></html>"#;
6620
6621 let result = parser.parse("test.html", html);
6622 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6623 let fragment_records = parser.into_fragment_records();
6624
6625 assert_stream!(
6626 fragment_records,
6627 "test.html",
6628 [
6629 raw("<html><head><style>\n .a { color: red; }\n "),
6630 signal("themeCss"),
6631 raw("\n .b { color: blue; }\n</style>"),
6632 signal_raw("head_end"),
6633 raw("</head><body>"),
6634 signal_raw("body_start"),
6635 signal_raw("body_end"),
6636 raw("</body></html>"),
6637 ]
6638 );
6639 }
6640
6641 #[test]
6642 fn test_style_legal_comment_preserved_inline_by_default() {
6643 let mut parser = HtmlParser::new();
6644 let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
6645
6646 parser.parse("test.html", html).expect("parse failed");
6647 let fragment_records = parser.into_fragment_records();
6648
6649 assert_stream!(
6650 fragment_records,
6651 "test.html",
6652 [
6653 raw("<html><head><style>/*! @license MIT */ .x { color: red; } </style>"),
6654 signal_raw("head_end"),
6655 raw("</head><body>"),
6656 signal_raw("body_start"),
6657 signal_raw("body_end"),
6658 raw("</body></html>"),
6659 ]
6660 );
6661 }
6662
6663 #[test]
6664 fn test_style_legal_comments_none_strips_legal_comment() {
6665 let mut parser = HtmlParser::with_options(ParserOptions {
6666 legal_comments: LegalComments::None,
6667 ..ParserOptions::default()
6668 });
6669 let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
6670
6671 parser.parse("test.html", html).expect("parse failed");
6672 let fragment_records = parser.into_fragment_records();
6673
6674 assert_stream!(
6675 fragment_records,
6676 "test.html",
6677 [
6678 raw("<html><head><style> .x { color: red; }</style>"),
6679 signal_raw("head_end"),
6680 raw("</head><body>"),
6681 signal_raw("body_start"),
6682 signal_raw("body_end"),
6683 raw("</body></html>"),
6684 ]
6685 );
6686 }
6687
6688 #[test]
6689 fn test_style_element_plain_css_unchanged() {
6690 let mut parser = HtmlParser::new();
6691 let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
6692
6693 let result = parser.parse("test.html", html);
6694 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6695 let fragment_records = parser.into_fragment_records();
6696
6697 assert_stream!(
6699 fragment_records,
6700 "test.html",
6701 [
6702 raw("<html><head><style>body { margin: 0; }</style>"),
6703 signal_raw("head_end"),
6704 raw("</head><body>"),
6705 signal_raw("body_start"),
6706 signal_raw("body_end"),
6707 raw("</body></html>"),
6708 ]
6709 );
6710 }
6711
6712 #[test]
6713 fn test_unclosed_style_element_returns_error() {
6714 let mut parser = HtmlParser::new();
6715 let result = parser.parse("index.html", "<style>.x { color: red; ");
6716 assert!(matches!(result, Err(ParserError::Template(diag)) if
6717 diag.to_string().contains("unclosed <style> tag")
6718 ));
6719 }
6720
6721 #[test]
6722 fn test_text_with_binding_after_void_element() {
6723 let (fragments, _) = parse_and_get_fragments(
6726 "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
6727 );
6728 assert_fragments!(
6729 fragments,
6730 [
6731 raw("Part A: "),
6732 signal("value1"),
6733 raw("<button>Click</button><br>Part B: "),
6734 signal("value2"),
6735 raw("<button>Click2</button>"),
6736 ]
6737 );
6738 }
6739}