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, ComponentRegistration, 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(ComponentRegistration::new(
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(ComponentRegistration::new(
3091 "mp-button",
3092 "<button>b</button>",
3093 None,
3094 true,
3095 ))
3096 .expect("Failed to register component");
3097
3098 let err = parser
3100 .parse("test.html", "<mp-buton></mp-buton>")
3101 .expect_err("a near-typo component tag should error");
3102 let ParserError::Template(diag) = err else {
3103 panic!("expected ParserError::Template, got {err:?}");
3104 };
3105 assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
3106 let help = diag
3107 .help_text()
3108 .expect("diagnostic should carry a help line");
3109 assert!(
3110 help.contains("did you mean <mp-button>?"),
3111 "expected a suggestion, got: {help}"
3112 );
3113 }
3114
3115 #[test]
3116 fn external_custom_element_in_other_namespace_passes_through() {
3117 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3118 parser
3119 .component_registry
3120 .register_component(ComponentRegistration::new(
3121 "mp-button",
3122 "<button>b</button>",
3123 None,
3124 true,
3125 ))
3126 .expect("Failed to register component");
3127
3128 let result = parser.parse("test.html", "<md-button></md-button>");
3131 assert!(
3132 result.is_ok(),
3133 "external custom element should pass through, got: {:?}",
3134 result.err()
3135 );
3136 }
3137
3138 #[test]
3139 fn for_missing_each_suggests_typoed_attribute() {
3140 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3141 let err = parser
3143 .parse("test.html", "<for eahc=\"todo in todos\"></for>")
3144 .expect_err("a <for> without `each` should error");
3145 let ParserError::Template(diag) = err else {
3146 panic!("expected ParserError::Template, got {err:?}");
3147 };
3148 assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
3149 let help = diag
3150 .help_text()
3151 .expect("diagnostic should carry a help line");
3152 assert!(
3153 help.contains("found `eahc`") && help.contains("did you mean"),
3154 "expected an attribute-typo suggestion, got: {help}"
3155 );
3156 }
3157
3158 #[test]
3159 fn parent_diagnostic_owner_survives_nested_component_parse() {
3160 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3165 parser
3166 .component_registry
3167 .register_component(ComponentRegistration::new(
3168 "my-card",
3169 "<div>card</div>",
3170 None,
3171 true,
3172 ))
3173 .expect("register");
3174
3175 let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
3177 let err = parser
3178 .parse("index.html", html)
3179 .expect_err("the malformed <for> should error");
3180 let ParserError::Template(diag) = err else {
3181 panic!("expected ParserError::Template, got {err:?}");
3182 };
3183 assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
3184 assert_eq!(
3185 diag.component_name(),
3186 Some("index.html"),
3187 "parent diagnostic must be owned by index.html, not the nested component"
3188 );
3189 }
3190
3191 #[test]
3192 fn test_component_directive_with_slots() {
3193 let mut parser = HtmlParser::new();
3194 parser
3195 .component_registry
3196 .register_component(ComponentRegistration::new(
3197 "my-component",
3198 "<div>My Component</div>",
3199 Some("div { color: blue; }"),
3200 true,
3201 ))
3202 .expect("Failed to register component");
3203
3204 let result = parser.parse(
3205 "test.html",
3206 "Hello<my-component><p>World</p></my-component>",
3207 );
3208 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3209 let records = parser.into_fragment_records();
3210 let fragments = &records["test.html"].fragments;
3211
3212 assert!(fragments.len() >= 3);
3214 assert!(
3216 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component>"))
3217 );
3218 assert!(fragments.iter().any(|f| matches!(
3220 f.fragment.as_ref(),
3221 Some(Fragment::Component(c)) if c.fragment_id == "my-component"
3222 )));
3223 let last = fragments.last().unwrap();
3225 assert!(
3226 matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
3227 );
3228 }
3229
3230 #[test]
3231 fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
3232 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
3237
3238 parser
3239 .component_registry
3240 .register_component(ComponentRegistration::new(
3241 "app-shell",
3242 r#"<template shadowrootmode="open">
3243 <my-navbar></my-navbar>
3244 <main><outlet /></main>
3245 <cart-panel></cart-panel>
3246 <my-footer></my-footer>
3247 </template>"#,
3248 Some(":host{display:flex}"),
3249 true,
3250 ))
3251 .expect("register app-shell");
3252 parser
3253 .component_registry
3254 .register_component(ComponentRegistration::new(
3255 "my-navbar",
3256 "<nav>Nav</nav>",
3257 Some("nav{color:red}"),
3258 true,
3259 ))
3260 .expect("register my-navbar");
3261 parser
3262 .component_registry
3263 .register_component(ComponentRegistration::new(
3264 "cart-panel",
3265 "<aside>Cart</aside>",
3266 Some("aside{color:green}"),
3267 true,
3268 ))
3269 .expect("register cart-panel");
3270 parser
3271 .component_registry
3272 .register_component(ComponentRegistration::new(
3273 "my-footer",
3274 "<footer>Footer</footer>",
3275 Some("footer{color:blue}"),
3276 true,
3277 ))
3278 .expect("register my-footer");
3279
3280 parser
3281 .parse("index.html", "<app-shell></app-shell>")
3282 .expect("parse index.html");
3283 let records = parser.into_fragment_records();
3284
3285 let app_frags = &records["app-shell"].fragments;
3287 let component_ids: Vec<&str> = app_frags
3288 .iter()
3289 .filter_map(|f| match f.fragment.as_ref() {
3290 Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
3291 _ => None,
3292 })
3293 .collect();
3294
3295 assert!(
3296 component_ids.contains(&"my-navbar"),
3297 "app-shell fragments should contain my-navbar: {component_ids:?}"
3298 );
3299 assert!(
3300 component_ids.contains(&"cart-panel"),
3301 "app-shell fragments should contain cart-panel: {component_ids:?}"
3302 );
3303 assert!(
3304 component_ids.contains(&"my-footer"),
3305 "app-shell fragments should contain my-footer: {component_ids:?}"
3306 );
3307 }
3308
3309 #[test]
3312 fn test_component_no_double_wrap_template() {
3313 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3317 parser
3318 .component_registry
3319 .register_component(ComponentRegistration::new(
3320 "custom-element",
3321 r#"<template foo="bar"><slot></slot></template>"#,
3322 None,
3323 true,
3324 ))
3325 .expect("register");
3326 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3327 assert!(result.is_ok());
3328 let records = parser.into_fragment_records();
3329
3330 assert_fragments!(
3331 records["index.html"].fragments,
3332 [
3333 raw("<custom-element>"),
3334 component("custom-element"),
3335 raw("Hello</custom-element>"),
3336 ]
3337 );
3338
3339 assert_stream!(
3340 records,
3341 "custom-element",
3342 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
3343 );
3344 }
3345
3346 #[test]
3347 fn test_component_styled_no_double_wrap() {
3348 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3353 parser
3354 .component_registry
3355 .register_component(ComponentRegistration::new(
3356 "custom-element",
3357 r#"<template foo="bar"><slot></slot></template>"#,
3358 Some("div { color: red; }"),
3359 true,
3360 ))
3361 .expect("register");
3362 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3363 assert!(result.is_ok());
3364 let records = parser.into_fragment_records();
3365
3366 assert_stream!(
3367 records,
3368 "custom-element",
3369 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
3370 );
3371 }
3372
3373 #[test]
3374 fn test_component_strip_runtime_attrs() {
3375 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3380 parser
3381 .component_registry
3382 .register_component(ComponentRegistration::new(
3383 "custom-element",
3384 r#"<template @click={foo} :bar="baz" ?bool="true"><slot></slot></template>"#,
3385 None,
3386 true,
3387 ))
3388 .expect("register");
3389 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3390 assert!(result.is_ok());
3391 let records = parser.into_fragment_records();
3392
3393 assert_stream!(
3394 records,
3395 "custom-element",
3396 [raw("<template><slot></slot></template>"),]
3397 );
3398 }
3399
3400 #[test]
3401 fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
3402 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3403 parser
3404 .component_registry
3405 .register_component(ComponentRegistration::new(
3406 "custom-element",
3407 r#"<template data-note="@click={foo}" @click={foo}><slot></slot></template>"#,
3408 None,
3409 true,
3410 ))
3411 .expect("register");
3412 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3413 assert!(result.is_ok());
3414 let records = parser.into_fragment_records();
3415
3416 assert_stream!(
3417 records,
3418 "custom-element",
3419 [raw(
3420 r#"<template data-note="@click={foo}"><slot></slot></template>"#
3421 ),]
3422 );
3423 }
3424
3425 #[test]
3426 fn test_component_strip_runtime_attrs_handles_duplicate_runtime_attrs() {
3427 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3428 parser
3429 .component_registry
3430 .register_component(ComponentRegistration::new(
3431 "custom-element",
3432 r#"<template @click={foo} @click={bar}><slot></slot></template>"#,
3433 None,
3434 true,
3435 ))
3436 .expect("register");
3437 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
3438 assert!(result.is_ok());
3439 let records = parser.into_fragment_records();
3440
3441 assert_stream!(
3442 records,
3443 "custom-element",
3444 [raw("<template><slot></slot></template>"),]
3445 );
3446 }
3447
3448 #[test]
3449 fn test_component_with_slots_and_attrs() {
3450 let mut parser = HtmlParser::new();
3451 parser
3452 .component_registry
3453 .register_component(ComponentRegistration::new(
3454 "custom-element",
3455 "<slot></slot>",
3456 None,
3457 true,
3458 ))
3459 .expect("register");
3460 let result = parser.parse(
3461 "index.html",
3462 r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
3463 );
3464 assert!(result.is_ok());
3465 let records = parser.into_fragment_records();
3466 assert_fragments!(
3467 records["index.html"].fragments,
3468 [
3469 raw("<custom-element"),
3470 attr_raw_start("appearance", "subtle"),
3471 raw(">"),
3472 component("custom-element"),
3473 raw("Hello World</custom-element>"),
3474 ]
3475 );
3476 }
3477
3478 #[test]
3479 fn test_component_legacy_no_styles() {
3480 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3481 parser
3482 .component_registry
3483 .register_component(ComponentRegistration::new(
3484 "custom-element",
3485 "<div>Custom Element</div>",
3486 None,
3487 true,
3488 ))
3489 .expect("register");
3490 let result = parser.parse("index.html", "<custom-element></custom-element>");
3491 assert!(result.is_ok());
3492 let records = parser.into_fragment_records();
3493
3494 assert_fragments!(
3495 records["index.html"].fragments,
3496 [
3497 raw("<custom-element>"),
3498 component("custom-element"),
3499 raw("</custom-element>"),
3500 ]
3501 );
3502
3503 assert_stream!(
3504 records,
3505 "custom-element",
3506 [raw("<div>Custom Element</div>"),]
3507 );
3508 }
3509
3510 #[test]
3511 fn test_component_self_closing() {
3512 let mut parser = HtmlParser::new();
3513 parser
3514 .component_registry
3515 .register_component(ComponentRegistration::new(
3516 "custom-widget",
3517 "<div>Widget Content</div>",
3518 None,
3519 true,
3520 ))
3521 .expect("register");
3522 let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
3523 assert!(result.is_ok());
3524 let records = parser.into_fragment_records();
3525
3526 assert_fragments!(
3527 records["index.html"].fragments,
3528 [
3529 raw("<custom-widget"),
3530 attr_start("config", "settings"),
3531 raw("/>"),
3532 component("custom-widget"),
3533 ]
3534 );
3535 }
3536
3537 #[test]
3538 fn test_component_nested_self_closing_in_slot() {
3539 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3540 parser
3541 .component_registry
3542 .register_component(ComponentRegistration::new(
3543 "custom-icon",
3544 "<svg><slot></slot></svg>",
3545 None,
3546 true,
3547 ))
3548 .expect("register");
3549 let result = parser.parse(
3550 "index.html",
3551 r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
3552 );
3553 assert!(result.is_ok());
3554 let records = parser.into_fragment_records();
3555
3556 assert_fragments!(
3557 records["index.html"].fragments,
3558 [
3559 raw("<custom-icon>"),
3560 component("custom-icon"),
3561 raw("<use"),
3562 attr_template("href", "attr-1"),
3563 raw("/></custom-icon>"),
3564 ]
3565 );
3566
3567 assert_stream!(records, "custom-icon", [raw("<svg><slot></slot></svg>"),]);
3568 }
3569
3570 #[test]
3571 fn test_component_leading_boolean_attr_start() {
3572 let mut parser = HtmlParser::new();
3573 parser
3574 .component_registry
3575 .register_component(ComponentRegistration::new(
3576 "custom-element",
3577 "<slot></slot>",
3578 None,
3579 true,
3580 ))
3581 .expect("register");
3582 let result = parser.parse(
3583 "index.html",
3584 r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
3585 );
3586 assert!(result.is_ok());
3587 let records = parser.into_fragment_records();
3588
3589 assert_fragments!(
3590 records["index.html"].fragments,
3591 [
3592 raw("<custom-element"),
3593 bool_attr_start("disabled", "isDisabled"),
3595 attr_raw("title", "Hello"),
3597 raw(">"),
3598 component("custom-element"),
3599 raw("</custom-element>"),
3600 ]
3601 );
3602 }
3603
3604 #[test]
3605 fn test_component_boolean_predicate_preserves_condition_tree() {
3606 let mut parser = HtmlParser::new();
3607 parser
3608 .component_registry
3609 .register_component(ComponentRegistration::new(
3610 "custom-element",
3611 "<slot></slot>",
3612 None,
3613 true,
3614 ))
3615 .expect("register");
3616 let result = parser.parse(
3617 "index.html",
3618 r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
3619 );
3620 assert!(result.is_ok());
3621
3622 let records = parser.into_fragment_records();
3623 let fragments = &records["index.html"].fragments;
3624 match fragments
3625 .get(1)
3626 .and_then(|fragment| fragment.fragment.as_ref())
3627 {
3628 Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
3629 assert_eq!(attr.name, "disabled");
3630 assert!(attr.attr_start);
3631 match attr
3632 .condition_tree
3633 .as_ref()
3634 .and_then(|condition| condition.expr.as_ref())
3635 {
3636 Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
3637 assert_eq!(pred.left, "page");
3638 assert_eq!(pred.operator, 3);
3639 assert_eq!(pred.right, "'dashboard'");
3640 }
3641 other => panic!("expected predicate condition tree, got {:?}", other),
3642 }
3643 }
3644 other => panic!("expected attribute fragment, got {:?}", other),
3645 }
3646 }
3647
3648 #[test]
3649 fn test_component_meta_link_tags() {
3650 let (fragments, _) = parse_and_get_fragments(
3651 r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
3652 );
3653 assert!(fragments.len() >= 5);
3654 assert!(
3655 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<head><meta charset=\"utf-8\"") && raw.value.contains("<link"))
3656 );
3657 assert!(
3658 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
3659 );
3660 assert!(
3661 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
3662 );
3663 assert!(
3664 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if s.value == "head_end" && s.raw)
3665 );
3666 assert!(
3667 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
3668 );
3669 }
3670
3671 #[test]
3672 fn test_nested_directives() {
3673 let mut parser = HtmlParser::new();
3674 let html = r#"<for each="category in categories">
3675 <if condition="category.hasItems">
3676 <for each="item in category.items">
3677 {{item.title}}
3678 </for>
3679 </if>
3680 </for>"#;
3681
3682 let result = parser.parse("test.html", html);
3683
3684 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3685 let fragment_records = parser.into_fragment_records();
3686
3687 assert_stream!(
3688 fragment_records,
3689 "test.html",
3690 [for_loop("category", "categories", "for-1"),]
3691 );
3692
3693 assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
3694
3695 assert_stream!(
3696 fragment_records,
3697 "if-1",
3698 [for_loop("item", "category.items", "for-2"),]
3699 );
3700
3701 assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
3702 }
3703
3704 #[test]
3705 fn test_complex_directives() {
3706 let mut parser = HtmlParser::new();
3707 let html = r#"<for each="category in categories">
3708 <div class="category">
3709 <h2>{{category.name}}</h2>
3710 <if condition="category.hasItems">
3711 <ul>
3712 <for each="item in category.items">
3713 <li>{{item.title}}</li>
3714 </for>
3715 </ul>
3716 </if>
3717 </div>
3718 </for>"#;
3719
3720 let result = parser.parse("test.html", html);
3721
3722 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3723 let fragment_records = parser.into_fragment_records();
3724
3725 assert_stream!(
3726 fragment_records,
3727 "test.html",
3728 [for_loop("category", "categories", "for-1"),]
3729 );
3730
3731 assert_stream!(
3733 fragment_records,
3734 "for-1",
3735 [
3736 raw("<div class=\"category\"><h2>"),
3737 signal("category.name"),
3738 raw("</h2>"),
3739 if_cond("if-1"),
3740 raw("</div>"),
3741 ]
3742 );
3743
3744 assert_stream!(
3746 fragment_records,
3747 "if-1",
3748 [
3749 raw("<ul>"),
3750 for_loop("item", "category.items", "for-2"),
3751 raw("</ul>"),
3752 ]
3753 );
3754
3755 assert_stream!(
3757 fragment_records,
3758 "for-2",
3759 [raw("<li>"), signal("item.title"), raw("</li>"),]
3760 );
3761 }
3762
3763 fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
3767 let mut parser = HtmlParser::new();
3768 let result = parser.parse("index.html", html);
3769 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3770 let records = parser.into_fragment_records();
3771 let fragments = records
3772 .get("index.html")
3773 .expect("Failed to get index.html fragment")
3774 .fragments
3775 .clone();
3776 (fragments, records)
3777 }
3778
3779 fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
3781 let mut parser = HtmlParser::new();
3782 parser
3783 .component_registry
3784 .register_component(ComponentRegistration::new(tag, "<div></div>", None, true))
3785 .expect("register");
3786 let result = parser.parse("index.html", html);
3787 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3788 let records = parser.into_fragment_records();
3789 let fragments = records
3790 .get("index.html")
3791 .expect("Failed to get index.html fragment")
3792 .fragments
3793 .clone();
3794 (fragments, records)
3795 }
3796
3797 #[test]
3798 fn test_attribute_handlebars_in_href() {
3799 let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
3801 assert_fragments!(
3802 fragments,
3803 [
3804 raw("<a"),
3805 attr("href", "url"),
3806 raw(">"),
3807 signal("name"),
3808 raw("</a>"),
3809 ]
3810 );
3811 }
3812
3813 #[test]
3814 fn test_attribute_boolean_with_handlebars() {
3815 let (fragments, _) =
3817 parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
3818 assert_fragments!(
3819 fragments,
3820 [
3821 raw("<button"),
3822 bool_attr("disabled", "isDisabled"),
3823 raw(">Click</button>"),
3824 ]
3825 );
3826 }
3827
3828 #[test]
3829 fn test_attribute_multiple_boolean() {
3830 let (fragments, _) =
3833 parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
3834
3835 assert_fragments!(
3836 fragments,
3837 [
3838 raw("<input"),
3839 bool_attr("checked", "isChecked"),
3840 bool_attr("disabled", "isDisabled"),
3841 raw("/>"),
3842 ]
3843 );
3844 }
3845
3846 #[test]
3847 fn test_attribute_boolean_and_regular_together() {
3848 let (fragments, _) = parse_and_get_fragments(
3851 r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
3852 );
3853
3854 assert_fragments!(
3855 fragments,
3856 [
3857 raw("<button"),
3858 bool_attr("disabled", "isDisabled"),
3859 raw(" type=\"button\">Hi</button>"),
3860 ]
3861 );
3862 }
3863
3864 #[test]
3865 fn test_attribute_boolean_sandwiched() {
3866 let (fragments, _) = parse_and_get_fragments(
3869 r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
3870 );
3871
3872 assert_fragments!(
3873 fragments,
3874 [
3875 raw("<button"),
3876 attr("version", "edition"),
3877 bool_attr("disabled", "isDisabled"),
3878 raw(" type=\"button\">Hi</button>"),
3879 ]
3880 );
3881 }
3882
3883 #[test]
3884 fn test_attribute_boolean_ending() {
3885 let (fragments, _) = parse_and_get_fragments(
3888 r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
3889 );
3890
3891 assert_fragments!(
3892 fragments,
3893 [
3894 raw("<button"),
3895 attr("version", "edition"),
3896 bool_attr("disabled", "isDisabled"),
3897 raw(">Hi</button>"),
3898 ]
3899 );
3900 }
3901
3902 #[test]
3903 fn test_attribute_boolean_dotted_path() {
3904 let (fragments, _) =
3907 parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
3908
3909 assert_fragments!(
3910 fragments,
3911 [
3912 raw("<div"),
3913 bool_attr("checked", "layout.isPinned"),
3914 raw(">Content</div>"),
3915 ]
3916 );
3917 }
3918
3919 #[test]
3920 fn test_attribute_colon_prefixed_complex() {
3921 let (fragments, _) = parse_with_component(
3924 "my-component",
3925 r#"<my-component :config="{{settings}}"></my-component>"#,
3926 );
3927
3928 assert_fragments!(
3929 fragments,
3930 [
3931 raw("<my-component"),
3932 attr_complex_start(":config", "settings"),
3933 raw(">"),
3934 component("my-component"),
3935 raw("</my-component>"),
3936 ]
3937 );
3938 }
3939
3940 #[test]
3941 fn test_attribute_multiple_colon_prefixed() {
3942 let (fragments, _) = parse_with_component(
3945 "my-component",
3946 r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
3947 );
3948
3949 assert_fragments!(
3950 fragments,
3951 [
3952 raw("<my-component"),
3953 attr_complex_start(":prop1", "val1"),
3954 attr_complex(":prop2", "val2"),
3955 raw(">"),
3956 component("my-component"),
3957 raw("</my-component>"),
3958 ]
3959 );
3960 }
3961
3962 #[test]
3963 fn test_blocked_complex_property_innerhtml() {
3964 let mut parser = HtmlParser::new();
3965 let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
3966 assert!(
3967 result.is_err(),
3968 "Expected error for :innerHTML on native element"
3969 );
3970 let err = result.unwrap_err().to_string();
3971 assert!(
3972 err.contains("only allowed on custom elements"),
3973 "Error: {err}"
3974 );
3975 }
3976
3977 #[test]
3978 fn test_blocked_complex_on_native_element() {
3979 let mut parser = HtmlParser::new();
3980 let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
3981 assert!(
3982 result.is_err(),
3983 "Expected error for :data on native element"
3984 );
3985 let err = result.unwrap_err().to_string();
3986 assert!(
3987 err.contains("only allowed on custom elements"),
3988 "Error: {err}"
3989 );
3990 }
3991
3992 #[test]
3993 fn test_blocked_complex_property_on_component() {
3994 let mut parser = HtmlParser::new();
3995 parser
3996 .component_registry
3997 .register_component(ComponentRegistration::new(
3998 "my-widget",
3999 "<div></div>",
4000 None,
4001 true,
4002 ))
4003 .expect("register");
4004 let result = parser.parse(
4005 "index.html",
4006 r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
4007 );
4008 assert!(
4009 result.is_err(),
4010 "Expected error for :innerHTML on component"
4011 );
4012 let err = result.unwrap_err().to_string();
4013 assert!(err.contains("HTML injection"), "Error: {err}");
4014 }
4015
4016 #[test]
4017 fn test_allowed_complex_property() {
4018 let (fragments, _) = parse_with_component(
4020 "my-component",
4021 r#"<my-component :config="{{settings}}"></my-component>"#,
4022 );
4023 assert_fragments!(
4024 fragments,
4025 [
4026 raw("<my-component"),
4027 attr_complex_start(":config", "settings"),
4028 raw(">"),
4029 component("my-component"),
4030 raw("</my-component>"),
4031 ]
4032 );
4033 }
4034
4035 #[test]
4036 fn test_attribute_mixed_normal_boolean_colon() {
4037 let (fragments, _) = parse_with_component(
4040 "my-component",
4041 r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
4042 );
4043
4044 assert_fragments!(
4045 fragments,
4046 [
4047 raw("<my-component"),
4048 attr_raw_start("id", "comp"),
4049 attr_complex(":config", "settings"),
4050 bool_attr("enabled", "isEnabled"),
4051 raw(">"),
4052 component("my-component"),
4053 raw("</my-component>"),
4054 ]
4055 );
4056 }
4057
4058 #[test]
4059 fn test_attribute_reject_boolean_without_handlebars() {
4060 let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
4063
4064 assert_fragments!(fragments, [raw("<button></button>"),]);
4066 }
4067
4068 #[test]
4069 fn test_attribute_reject_boolean_with_partial_handlebars() {
4070 let (fragments, _) =
4073 parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
4074
4075 assert_fragments!(fragments, [raw("<button></button>"),]);
4077 }
4078
4079 #[test]
4080 fn test_attribute_reject_boolean_with_plain_value() {
4081 let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
4084
4085 assert_fragments!(fragments, [raw("<button>Click</button>"),]);
4087 }
4088
4089 #[test]
4090 fn test_attribute_boolean_predicate_equal() {
4091 let (fragments, _) =
4093 parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
4094 assert_fragments!(
4095 fragments,
4096 [
4097 raw("<div"),
4098 bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
4099 raw(">X</div>"),
4100 ]
4101 );
4102 }
4103
4104 #[test]
4105 fn test_attribute_boolean_predicate_greater_than() {
4106 let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
4108 assert_fragments!(
4109 fragments,
4110 [
4111 raw("<span"),
4112 bool_attr_predicate("hidden", "num", 1, "9"),
4113 raw(">X</span>"),
4114 ]
4115 );
4116 }
4117
4118 #[test]
4119 fn test_attribute_boolean_predicate_not_equal() {
4120 let (fragments, _) =
4122 parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
4123 assert_fragments!(
4124 fragments,
4125 [
4126 raw("<a"),
4127 bool_attr_predicate("data-active", "status", 4, "'inactive'"),
4128 raw(">X</a>"),
4129 ]
4130 );
4131 }
4132
4133 #[test]
4134 fn test_attribute_boolean_negation() {
4135 let (fragments, _) =
4137 parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
4138 assert_fragments!(
4139 fragments,
4140 [
4141 raw("<button"),
4142 bool_attr_not("disabled", "isReady"),
4143 raw(">X</button>"),
4144 ]
4145 );
4146 }
4147
4148 #[test]
4149 fn test_attribute_mixed_static_dynamic() {
4150 let (fragments, records) =
4153 parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
4154
4155 assert_fragments!(
4156 fragments,
4157 [
4158 raw("<textarea"),
4159 attr_template("value", "attr-1"),
4160 raw(">Hi</textarea>"),
4161 ]
4162 );
4163
4164 assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
4166 }
4167
4168 #[test]
4171 fn test_body_signals() {
4172 let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
4173 assert_fragments!(
4174 fragments,
4175 [
4176 raw("<body>"),
4177 signal_raw("body_start"),
4178 raw("<app-shell></app-shell>"),
4179 signal_raw("body_end"),
4180 raw("</body>"),
4181 ]
4182 );
4183 }
4184
4185 #[test]
4188 fn test_empty_for_produces_nothing() {
4189 let (fragments, records) =
4190 parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
4191 assert_fragments!(fragments, [raw("<div></div>"),]);
4192 assert!(!records.contains_key("for-1"));
4193 }
4194
4195 #[test]
4198 fn test_self_closing_svg_path() {
4199 let (fragments, _) =
4200 parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
4201 assert_fragments!(
4202 fragments,
4203 [raw(
4204 r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
4205 ),]
4206 );
4207 }
4208
4209 #[test]
4210 fn test_html5_void_elements() {
4211 let (fragments, _) = parse_and_get_fragments(
4212 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
4213 );
4214 assert_fragments!(
4215 fragments,
4216 [raw(
4217 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
4218 ),]
4219 );
4220 }
4221
4222 #[test]
4223 fn test_self_closing_with_dynamic_attributes() {
4224 let (fragments, _) =
4225 parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
4226 assert_fragments!(
4227 fragments,
4228 [
4229 raw("<img"),
4230 attr("src", "imageUrl"),
4231 attr("alt", "imageAlt"),
4232 raw("/>"),
4233 ]
4234 );
4235 }
4236
4237 #[test]
4238 fn test_self_closing_with_boolean_attributes() {
4239 let (fragments, _) = parse_and_get_fragments(
4240 r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
4241 );
4242 assert_fragments!(
4243 fragments,
4244 [
4245 raw("<input type=\"checkbox\""),
4246 bool_attr("checked", "isSelected"),
4247 bool_attr("disabled", "isDisabled"),
4248 raw("/>"),
4249 ]
4250 );
4251 }
4252
4253 #[test]
4254 fn test_multiple_self_closing_in_sequence() {
4255 let (fragments, _) =
4256 parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
4257 assert_fragments!(
4258 fragments,
4259 [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
4260 );
4261 }
4262
4263 #[test]
4264 fn test_self_closing_with_mixed_content() {
4265 let (fragments, _) =
4266 parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
4267 assert_fragments!(
4268 fragments,
4269 [
4270 raw("<div>Text before<img"),
4271 attr("src", "url"),
4272 raw("/>Text after</div>"),
4273 ]
4274 );
4275 }
4276
4277 #[test]
4278 fn test_self_closing_svg_elements() {
4279 let (fragments, _) = parse_and_get_fragments(
4280 r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
4281 );
4282 assert_fragments!(
4283 fragments,
4284 [
4285 raw("<svg><circle"),
4286 attr("cx", "x"),
4287 attr("cy", "y"),
4288 raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
4289 ]
4290 );
4291 }
4292
4293 #[test]
4294 fn test_self_closing_inside_for_loop() {
4295 let (fragments, records) = parse_and_get_fragments(
4296 r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
4297 );
4298 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
4299 assert_stream!(
4300 records,
4301 "for-1",
4302 [raw("<img"), attr("src", "item.url"), raw("/>"),]
4303 );
4304 }
4305
4306 #[test]
4307 fn test_self_closing_whitespace_variations() {
4308 let (fragments, _) =
4309 parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
4310 assert_fragments!(
4311 fragments,
4312 [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
4313 );
4314 }
4315
4316 #[test]
4317 fn test_deeply_nested_self_closing() {
4318 let (fragments, _) = parse_and_get_fragments(
4319 r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
4320 );
4321 assert_fragments!(
4322 fragments,
4323 [raw(
4324 r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
4325 ),]
4326 );
4327 }
4328
4329 #[test]
4330 fn test_self_closing_vs_empty_regular_tags() {
4331 let (fragments, _) =
4332 parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
4333 assert_fragments!(
4334 fragments,
4335 [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
4336 );
4337 }
4338
4339 #[test]
4342 fn test_for_custom_template_attribute() {
4343 let (fragments, records) = parse_and_get_fragments(
4345 r#"<for each="item in items" template="static"><span>Item</span></for>"#,
4346 );
4347 assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
4348 assert_stream!(records, "static", [raw("<span>Item</span>"),]);
4349 }
4350
4351 #[test]
4352 fn test_for_recursive_template() {
4353 let mut parser = HtmlParser::new();
4355 let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
4356 let result = parser.parse("index.html", html);
4357 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4358 let records = parser.into_fragment_records();
4359
4360 assert_fragments!(
4361 records["index.html"].fragments,
4362 [for_loop("outerItem", "outerItems", "static"),]
4363 );
4364
4365 assert_stream!(
4366 records,
4367 "static",
4368 [
4369 raw("<div><span>"),
4370 signal("outerItem.name"),
4371 raw("</span>"),
4372 for_loop("innerItem", "innerItems", "static"),
4373 raw("</div>"),
4374 ]
4375 );
4376 }
4377
4378 #[test]
4381 fn test_if_multiple_children() {
4382 let (fragments, records) =
4384 parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
4385 assert_fragments!(fragments, [if_cond("if-1"),]);
4386 assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
4387 }
4388
4389 #[test]
4390 fn test_for_multiple_children() {
4391 let (fragments, records) =
4393 parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
4394 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
4395 assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
4396 }
4397
4398 #[test]
4401 fn test_handlebars_at_beginning() {
4402 let (fragments, _) = parse_and_get_fragments("{{first}}");
4404 assert_fragments!(fragments, [signal("first"),]);
4405 }
4406
4407 #[test]
4408 fn test_handlebars_at_beginning_and_raw() {
4409 let (fragments, _) = parse_and_get_fragments("{{first}}test");
4411 assert_fragments!(fragments, [signal("first"), raw("test"),]);
4412 }
4413
4414 #[test]
4415 fn test_handlebars_raw_and_end() {
4416 let (fragments, _) = parse_and_get_fragments("test{{first}}");
4418 assert_fragments!(fragments, [raw("test"), signal("first"),]);
4419 }
4420
4421 #[test]
4424 fn test_handlebars_invalid_triple_open() {
4425 let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
4427 assert_fragments!(fragments, [raw("{{{invalid}}"),]);
4428 }
4429
4430 #[test]
4431 fn test_handlebars_four_open_braces() {
4432 let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
4434 assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
4435 }
4436
4437 #[test]
4438 fn test_handlebars_five_open_with_valid_double() {
4439 let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
4441 assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
4442 }
4443
4444 #[test]
4445 fn test_entities_preserved() {
4446 let (fragments, _) = parse_and_get_fragments("<p>Hello}World</p>");
4448 assert_fragments!(fragments, [raw("<p>Hello}World</p>"),]);
4449 }
4450
4451 #[test]
4454 fn test_doctype_preserved() {
4455 let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
4457 assert!(!fragments.is_empty());
4458 assert!(
4459 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
4460 );
4461 }
4462
4463 #[test]
4466 fn test_component_attr_skip() {
4467 let mut parser = HtmlParser::new();
4469 parser
4470 .component_registry
4471 .register_component(ComponentRegistration::new(
4472 "custom-element",
4473 "<slot></slot>",
4474 None,
4475 true,
4476 ))
4477 .expect("register");
4478 let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
4479 let result = parser.parse("index.html", html);
4480 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4481 let records = parser.into_fragment_records();
4482
4483 assert_fragments!(
4486 records["index.html"].fragments,
4487 [
4488 raw("<custom-element"),
4489 attr_complex_start(":config", "config"),
4491 attr_skip("class", "value0"),
4493 attr_skip("style", "value1"),
4494 attr_skip("role", "value2"),
4495 attr_skip("data-test", "value3"),
4496 attr_skip("aria-test", "value4"),
4497 raw(">"),
4498 component("custom-element"),
4499 raw("</custom-element>"),
4500 ]
4501 );
4502 }
4503
4504 #[test]
4505 fn test_component_attr_skip_static_and_embedded() {
4506 let mut parser = HtmlParser::new();
4509 parser
4510 .component_registry
4511 .register_component(ComponentRegistration::new(
4512 "item-group",
4513 "<slot></slot>",
4514 None,
4515 true,
4516 ))
4517 .expect("register");
4518
4519 let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
4520 let result = parser.parse("index.html", html);
4521 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4522 let records = parser.into_fragment_records();
4523
4524 assert_fragments!(
4525 records["index.html"].fragments,
4526 [
4527 raw("<item-group"),
4528 attr_skip_raw("role", "list"),
4529 attr_skip_template("aria-labelledby", "attr-1"),
4530 attr_skip_template("data-testid", "attr-2"),
4531 attr_skip_raw("class", "fixed-class"),
4532 raw(">"),
4533 component("item-group"),
4534 raw("</item-group>"),
4535 ]
4536 );
4537
4538 assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
4541 assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
4542 }
4543
4544 #[test]
4545 fn test_component_multiple_nested() {
4546 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4548 parser
4549 .component_registry
4550 .register_component(ComponentRegistration::new(
4551 "custom-element",
4552 "<custom-child></custom-child><slot></slot>",
4553 None,
4554 true,
4555 ))
4556 .expect("register");
4557 parser
4558 .component_registry
4559 .register_component(ComponentRegistration::new(
4560 "custom-button",
4561 "<slot></slot>",
4562 None,
4563 true,
4564 ))
4565 .expect("register");
4566 parser
4567 .component_registry
4568 .register_component(ComponentRegistration::new(
4569 "custom-child",
4570 "<h1>Hello World!</h1>",
4571 None,
4572 true,
4573 ))
4574 .expect("register");
4575
4576 let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
4577 let result = parser.parse("index.html", html);
4578 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4579 let records = parser.into_fragment_records();
4580
4581 assert_fragments!(
4583 records["index.html"].fragments,
4584 [for_loop("item", "items", "for-1"),]
4585 );
4586
4587 assert_stream!(
4589 records,
4590 "for-1",
4591 [
4592 raw("<custom-element>"),
4593 component("custom-element"),
4594 raw("<custom-button>"),
4595 component("custom-button"),
4596 raw("Ok</custom-button></custom-element>"),
4597 ]
4598 );
4599
4600 let ce = &records["custom-element"].fragments;
4602 assert_eq!(ce.len(), 3);
4603 assert!(
4604 matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child>"))
4605 );
4606 assert!(
4607 matches!(ce[1].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
4608 );
4609 assert!(
4610 matches!(ce[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><slot></slot>"))
4611 );
4612
4613 assert_stream!(records, "custom-button", [raw("<slot></slot>"),]);
4614
4615 assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
4616 }
4617
4618 #[test]
4621 fn test_invalid_markup_returns_error() {
4622 let mut parser = HtmlParser::new();
4624 let result = parser.parse("index.html", "<div><span>Unclosed div");
4625 assert!(matches!(result, Err(ParserError::Template(diag)) if
4626 diag.to_string().contains("unclosed <div> tag")
4627 ));
4628 }
4629
4630 #[test]
4631 fn test_unterminated_opening_tag_returns_error() {
4632 let mut parser = HtmlParser::new();
4633 let result = parser.parse("index.html", "before <div");
4634 assert!(matches!(result, Err(ParserError::Template(diag)) if
4635 diag.to_string().contains("malformed HTML tag")
4636 ));
4637 }
4638
4639 #[test]
4640 fn test_unterminated_html_comment_returns_error() {
4641 let mut parser = HtmlParser::new();
4642 let result = parser.parse("index.html", "<div><!-- missing close</div>");
4643 assert!(matches!(result, Err(ParserError::Template(diag)) if
4644 diag.to_string().contains("unclosed <div> tag")
4645 || diag.to_string().contains("unterminated HTML comment")
4646 ));
4647 }
4648
4649 #[test]
4650 fn test_unexpected_closing_tag_returns_error() {
4651 let mut parser = HtmlParser::new();
4652 let result = parser.parse("index.html", "</span>");
4653 assert!(matches!(result, Err(ParserError::Template(diag)) if
4654 diag.to_string().contains("unexpected closing tag </span>")
4655 ));
4656 }
4657
4658 #[test]
4659 fn test_rejects_templates_over_size_limit() {
4660 let mut parser = HtmlParser::new();
4661 let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
4662 let result = parser.parse("index.html", &html);
4663
4664 assert!(matches!(result, Err(ParserError::Html(message)) if
4665 message.contains("exceeds") && message.contains("parser limit")
4666 ));
4667 }
4668
4669 #[test]
4670 fn test_rejects_templates_over_depth_limit() {
4671 let mut parser = HtmlParser::new();
4672 let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
4673 for _ in 0..=MAX_TEMPLATE_DEPTH {
4674 html.push_str("<div>");
4675 }
4676 for _ in 0..=MAX_TEMPLATE_DEPTH {
4677 html.push_str("</div>");
4678 }
4679
4680 let result = parser.parse("index.html", &html);
4681
4682 assert!(matches!(result, Err(ParserError::Template(diag)) if
4683 diag.to_string().contains("nesting exceeds")
4684 ));
4685 }
4686
4687 #[test]
4688 fn test_rejects_routes_over_depth_limit() {
4689 let mut parser = HtmlParser::new();
4690 parser
4691 .component_registry
4692 .register_component(ComponentRegistration::new(
4693 "route-page",
4694 "<slot></slot>",
4695 None,
4696 true,
4697 ))
4698 .expect("register");
4699
4700 let depth = MAX_TEMPLATE_DEPTH + 2;
4701 let mut html = String::with_capacity(depth * 48);
4702 for _ in 0..depth {
4703 html.push_str(r#"<route path="x" component="route-page">"#);
4704 }
4705 for _ in 0..depth {
4706 html.push_str("</route>");
4707 }
4708
4709 let result = parser.parse("index.html", &html);
4710
4711 assert!(matches!(result, Err(ParserError::Template(diag)) if
4712 diag.error_code() == Some(codes::EXCESSIVE_NESTING)
4713 && diag.to_string().contains("route nesting exceeds")
4714 ));
4715 }
4716
4717 #[test]
4718 fn test_rejects_recursive_component_templates() {
4719 let mut parser = HtmlParser::new();
4720 parser
4721 .component_registry
4722 .register_component(ComponentRegistration::new(
4723 "self-card",
4724 "<self-card></self-card>",
4725 None,
4726 true,
4727 ))
4728 .expect("register");
4729
4730 let result = parser.parse("index.html", "<self-card></self-card>");
4731
4732 assert!(matches!(result, Err(ParserError::Template(diag)) if
4733 diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
4734 && diag.to_string().contains("recursive template reference")
4735 ));
4736 }
4737
4738 #[test]
4741 fn test_complex_raw_text_full_page() {
4742 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>"#;
4744 let (fragments, _) = parse_and_get_fragments(html);
4745
4746 assert!(fragments.len() >= 7);
4748 assert!(
4749 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4750 raw.value.contains("<!DOCTYPE HTML>") && raw.value.contains("<title>Test</title>"))
4751 );
4752 assert!(
4753 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
4754 s.value == "head_end" && s.raw)
4755 );
4756 assert!(
4757 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4758 raw.value.contains("</head>") && raw.value.ends_with("<body>"))
4759 );
4760 assert!(
4761 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
4762 s.value == "body_start" && s.raw)
4763 );
4764 assert!(
4765 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4766 raw.value.contains("<app-shell>"))
4767 );
4768 assert!(
4769 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
4770 s.value == "body_end" && s.raw)
4771 );
4772 assert!(
4773 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
4774 raw.value.contains("</body>") && raw.value.contains("</html>"))
4775 );
4776 }
4777
4778 #[test]
4779 fn test_css_strategy_external_emits_link_tag() {
4780 let mut parser = HtmlParser::new();
4781 parser
4782 .component_registry_mut()
4783 .register_component(ComponentRegistration::new(
4784 "my-card",
4785 "<p><slot></slot></p>",
4786 Some("p { color: red; }"),
4787 true,
4788 ))
4789 .ok();
4790 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4791 let records = parser.into_fragment_records();
4792 let my_card = &records["my-card"].fragments;
4793 let raw_text: String = my_card
4794 .iter()
4795 .filter_map(|f| match &f.fragment {
4796 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4797 _ => None,
4798 })
4799 .collect();
4800 assert!(
4801 raw_text.contains(r#"<link rel="stylesheet" href="my-card.css">"#),
4802 "Expected external <link> tag in: {}",
4803 raw_text
4804 );
4805 }
4806
4807 #[test]
4808 fn test_uppercase_style_element_is_processed_as_css() {
4809 let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
4814 assert!(
4815 fragments.iter().any(|f| matches!(
4816 f.fragment.as_ref(),
4817 Some(Fragment::Signal(s)) if s.value == "tokens" && !s.raw
4818 )),
4819 "uppercase <STYLE> should be CSS-processed, got: {:?}",
4820 fragments
4821 );
4822 let raw: String = fragments
4823 .iter()
4824 .filter_map(|f| match f.fragment.as_ref() {
4825 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4826 _ => None,
4827 })
4828 .collect();
4829 assert!(
4830 raw.contains("</STYLE>"),
4831 "closing tag should preserve source case, got: {raw}"
4832 );
4833 }
4834
4835 #[test]
4836 fn test_css_strategy_inline_emits_style_tag() {
4837 let mut parser = HtmlParser::with_options(CssStrategy::Style);
4838 parser
4839 .component_registry_mut()
4840 .register_component(ComponentRegistration::new(
4841 "my-card",
4842 "<p><slot></slot></p>",
4843 Some("p { color: red; }"),
4844 true,
4845 ))
4846 .ok();
4847 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4848 let records = parser.into_fragment_records();
4849 let my_card = &records["my-card"].fragments;
4850 let raw_text: String = my_card
4851 .iter()
4852 .filter_map(|f| match &f.fragment {
4853 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4854 _ => None,
4855 })
4856 .collect();
4857 assert!(
4858 raw_text.contains("<style>p { color: red; }</style>"),
4859 "Expected inline <style> tag in: {}",
4860 raw_text
4861 );
4862 assert!(
4863 !raw_text.contains("<link"),
4864 "Should not have <link> tag in inline mode: {}",
4865 raw_text
4866 );
4867 }
4868
4869 #[test]
4870 fn test_css_strategy_module_emits_adopted_stylesheets() {
4871 let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
4872 parser
4873 .component_registry_mut()
4874 .register_component(ComponentRegistration::new(
4875 "my-card",
4876 "<p><slot></slot></p>",
4877 Some("p { color: red; }"),
4878 true,
4879 ))
4880 .ok();
4881 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4882 let records = parser.into_fragment_records();
4883
4884 let my_card = &records["my-card"].fragments;
4887 let template_text: String = my_card
4888 .iter()
4889 .filter_map(|f| match &f.fragment {
4890 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4891 _ => None,
4892 })
4893 .collect();
4894 assert!(
4895 !template_text.contains("shadowrootadoptedstylesheets"),
4896 "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
4897 );
4898 assert!(
4899 !template_text.contains("<link"),
4900 "Should not have <link> in module mode: {template_text}"
4901 );
4902 assert!(
4906 !template_text.contains(r#"<style type="module""#),
4907 "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
4908 );
4909 assert!(
4910 !template_text.contains(r#"<script type="importmap""#),
4911 "CSS module importmap should NOT be in raw fragments: {template_text}"
4912 );
4913 }
4914
4915 #[test]
4916 fn test_css_strategy_module_no_css_no_adopted_attr() {
4917 let mut parser = HtmlParser::with_options(CssStrategy::Module);
4918 parser
4919 .component_registry_mut()
4920 .register_component(ComponentRegistration::new(
4921 "my-card",
4922 "<p><slot></slot></p>",
4923 None,
4924 true,
4925 ))
4926 .ok();
4927 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
4928 let records = parser.into_fragment_records();
4929 let my_card = &records["my-card"].fragments;
4930 let template_text: String = my_card
4931 .iter()
4932 .filter_map(|f| match &f.fragment {
4933 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
4934 _ => None,
4935 })
4936 .collect();
4937 assert!(
4939 !template_text.contains("shadowrootadoptedstylesheets"),
4940 "Should not have adopted attr without CSS: {template_text}"
4941 );
4942 }
4943
4944 #[test]
4963 fn light_preserves_dev_template_with_static_attrs() {
4964 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4965 let processed = parser
4966 .process_component_template(
4967 r#"<template foo="bar"><div>hi</div></template>"#,
4968 None,
4969 None,
4970 )
4971 .expect("process failed");
4972 assert!(
4973 processed.contains(r#"<template foo="bar">"#),
4974 "[--dom=light] expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
4975 );
4976 assert!(
4977 processed.contains("</template>"),
4978 "[--dom=light] expected closing </template> preserved, got: {processed}"
4979 );
4980 }
4981
4982 #[test]
4983 fn light_preserves_dev_template_with_signal_fragment_attrs() {
4984 let mut parser = HtmlParser::with_options(DomStrategy::Light);
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=light] expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
4995 );
4996 }
4997
4998 #[test]
4999 fn light_preserves_dev_template_with_multiple_attrs() {
5000 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5001 let processed = parser.process_component_template(
5002 r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
5003 None,
5004 None,
5005 )
5006 .expect("process failed");
5007 assert!(
5008 processed.contains("autofocus")
5009 && processed.contains(r#"tabindex="0""#)
5010 && processed.contains(r#"role="region""#)
5011 && processed.contains(r#"data-x="y""#),
5012 "[--dom=light] expected ALL dev template attrs preserved, got: {processed}"
5013 );
5014 }
5015
5016 #[test]
5017 fn light_no_template_no_wrapper_added() {
5018 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5019 let processed = parser
5020 .process_component_template("<div>hi</div>", None, None)
5021 .expect("process failed");
5022 assert!(
5023 !processed.contains("<template"),
5024 "[--dom=light] framework must NOT add <template> wrapper when dev omits one, got: {processed}"
5025 );
5026 assert!(
5027 processed.contains("<div>hi</div>"),
5028 "[--dom=light] expected inner content emitted as-is, got: {processed}"
5029 );
5030 }
5031
5032 #[test]
5033 fn shadow_preserves_dev_template_with_static_attrs() {
5034 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5035 let processed = parser
5036 .process_component_template(
5037 r#"<template foo="bar"><div>hi</div></template>"#,
5038 None,
5039 None,
5040 )
5041 .expect("process failed");
5042 assert!(
5043 processed.contains(r#"<template foo="bar">"#),
5044 "[--dom=shadow] dev <template foo=\"bar\"> must be preserved verbatim, got: {processed}"
5045 );
5046 assert!(
5047 !processed.contains(r#"shadowrootmode="open""#),
5048 "[--dom=shadow] framework must NOT inject shadowrootmode when dev already supplied a <template>, got: {processed}"
5049 );
5050 }
5051
5052 #[test]
5053 fn shadow_preserves_dev_template_with_shadowrootmode_closed() {
5054 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5055 let processed = parser
5056 .process_component_template(
5057 r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
5058 None,
5059 None,
5060 )
5061 .expect("process failed");
5062 assert!(
5063 processed.contains(r#"shadowrootmode="closed""#),
5064 "[--dom=shadow] framework must respect dev's shadowrootmode=\"closed\" (developer is managing), got: {processed}"
5065 );
5066 assert!(
5067 !processed.contains(r#"shadowrootmode="open""#),
5068 "[--dom=shadow] framework must not overwrite dev's shadowrootmode with \"open\", got: {processed}"
5069 );
5070 }
5071
5072 #[test]
5073 fn shadow_preserves_dev_template_with_signal_fragment_attrs() {
5074 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5075 let processed = parser
5076 .process_component_template(
5077 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
5078 None,
5079 None,
5080 )
5081 .expect("process failed");
5082 assert!(
5083 processed.contains(r#"<template foo="{{foo}}">"#),
5084 "[--dom=shadow] dev <template> with signal-fragment attr must be preserved, got: {processed}"
5085 );
5086 }
5087
5088 #[test]
5089 fn shadow_adds_template_wrapper_when_dev_omits_it() {
5090 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5091 let processed = parser
5092 .process_component_template("<div>hi</div>", None, None)
5093 .expect("process failed");
5094 assert!(
5095 processed.contains(r#"<template shadowrootmode="open""#),
5096 "[--dom=shadow] framework MUST add <template shadowrootmode=\"open\"> when dev omits a wrapper, got: {processed}"
5097 );
5098 assert!(
5099 processed.contains("<div>hi</div>"),
5100 "[--dom=shadow] inner content must survive wrapping, got: {processed}"
5101 );
5102 assert!(
5103 processed.contains("</template>"),
5104 "[--dom=shadow] framework-added wrapper must be closed, got: {processed}"
5105 );
5106 }
5107
5108 #[test]
5118 fn dev_template_module_strategy_appends_adopted_attr_when_missing() {
5119 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5120 let processed = parser
5121 .process_component_template(
5122 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
5123 None,
5124 Some("my-comp"),
5125 )
5126 .expect("process failed");
5127 assert!(
5128 processed.contains(r#"shadowrootmode="open""#),
5129 "dev-authored shadowrootmode must be preserved, got: {processed}"
5130 );
5131 assert!(
5132 processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5133 "module CSS should append adopted stylesheets, got: {processed}"
5134 );
5135 }
5136
5137 #[test]
5138 fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
5139 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5140 let processed = parser
5141 .process_component_template(
5142 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
5143 None,
5144 Some("my-comp"),
5145 )
5146 .unwrap();
5147 assert!(
5148 processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5149 "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
5150 );
5151 assert_eq!(
5152 processed.matches("shadowrootadoptedstylesheets").count(),
5153 1,
5154 "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
5155 );
5156 }
5157
5158 #[test]
5159 fn dev_template_module_strategy_appends_adopted_attr_and_preserves_root_attrs() {
5160 for dom_strategy in [DomStrategy::Shadow, DomStrategy::Light] {
5161 let mut parser = HtmlParser::with_options((CssStrategy::Module, dom_strategy));
5162 let built = match parser.build_component_templates(
5163 "my-comp",
5164 r#"<template shadowrootmode="open" @click="{onClick()}">Hello</template>"#,
5165 Some(":host { color: red; }"),
5166 true,
5167 ) {
5168 Ok(built) => built,
5169 Err(err) => panic!(
5170 "dev-authored <template> should be accepted under {dom_strategy:?} with module CSS, got: {err}"
5171 ),
5172 };
5173 let artifact = built.artifact();
5174
5175 assert!(
5176 artifact.contains(r#"shadowrootmode="open""#),
5177 "dev-authored shadowrootmode must be preserved under {dom_strategy:?}, got: {artifact}"
5178 );
5179 assert!(
5180 artifact.contains(r#"@click="{onClick()}""#),
5181 "dev-authored root event must be preserved under {dom_strategy:?}, got: {artifact}"
5182 );
5183 assert!(
5184 artifact.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5185 "module CSS should append adopted stylesheets under {dom_strategy:?}, got: {artifact}"
5186 );
5187 assert_eq!(
5188 artifact.matches("shadowrootadoptedstylesheets").count(),
5189 1,
5190 "module CSS should append adopted stylesheets once under {dom_strategy:?}, got: {artifact}"
5191 );
5192 assert!(
5193 !built.ssr.contains("@click"),
5194 "SSR template must still strip runtime attrs, got: {}",
5195 built.ssr
5196 );
5197 }
5198 }
5199
5200 #[test]
5201 fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
5202 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5207 let processed = parser
5208 .process_component_template(
5209 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
5210 None,
5211 Some("my-comp"),
5212 )
5213 .unwrap();
5214 assert!(
5215 processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
5216 "dev's multi-specifier list must be preserved verbatim, got: {processed}"
5217 );
5218 }
5219
5220 #[test]
5221 fn dev_template_no_adopted_specifier_does_not_validate() {
5222 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5226 let processed = parser
5227 .process_component_template(
5228 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
5229 None,
5230 None,
5231 )
5232 .unwrap();
5233 assert!(
5234 !processed.contains("shadowrootadoptedstylesheets"),
5235 "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
5236 );
5237 }
5238
5239 #[test]
5251 fn test_body_start_end_injection() {
5252 let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
5256 let (fragments, _) = parse_and_get_fragments(html);
5257
5258 assert_fragments!(
5259 fragments,
5260 [
5261 raw("<html><head><title>Test</title>"),
5262 signal_raw("head_end"),
5263 raw("</head><body>"),
5264 signal_raw("body_start"),
5265 raw("<div>Content</div><p>More</p>"),
5266 signal_raw("body_end"),
5267 raw("</body></html>"),
5268 ]
5269 );
5270 }
5271
5272 #[test]
5273 fn test_body_preserves_static_attributes() {
5274 let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
5279 let (fragments, _) = parse_and_get_fragments(html);
5280
5281 assert_fragments!(
5282 fragments,
5283 [
5284 raw("<html><head><title>T</title>"),
5285 signal_raw("head_end"),
5286 raw(r#"</head><body data-layout="doc" class="page">"#),
5287 signal_raw("body_start"),
5288 raw("<p>x</p>"),
5289 signal_raw("body_end"),
5290 raw("</body></html>"),
5291 ]
5292 );
5293 }
5294
5295 #[test]
5296 fn test_body_preserves_dynamic_attributes() {
5297 let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
5301 let (fragments, _) = parse_and_get_fragments(html);
5302
5303 assert_fragments!(
5304 fragments,
5305 [
5306 raw("<html><head><title>T</title>"),
5307 signal_raw("head_end"),
5308 raw("</head><body"),
5309 attr("data-layout", "layout"),
5310 raw(">"),
5311 signal_raw("body_start"),
5312 raw("<p>x</p>"),
5313 signal_raw("body_end"),
5314 raw("</body></html>"),
5315 ]
5316 );
5317 }
5318
5319 #[test]
5320 fn test_fail_with_invalid_markup() {
5321 let mut parser = HtmlParser::new();
5323 let result = parser.parse("index.html", "<div><span></div></span>");
5324
5325 assert!(matches!(result, Err(ParserError::Template(diag)) if
5326 diag.to_string().contains("unclosed <span> tag")
5327 ));
5328 }
5329
5330 #[test]
5331 fn test_complex_raw_text_page() {
5332 let html = concat!(
5335 "<!DOCTYPE html>",
5336 "<html lang=\"en\">",
5337 "<head>",
5338 "<meta charset=\"utf-8\">",
5339 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
5340 "<title>Complex Page</title>",
5341 "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
5342 "<link rel=\"stylesheet\" href=\"styles.css\">",
5343 "</head>",
5344 "<body>",
5345 "<h1>Hello World</h1>",
5346 "<script type=\"module\" src=\"./app.js\"></script>",
5347 "</body>",
5348 "</html>",
5349 );
5350 let (fragments, _) = parse_and_get_fragments(html);
5351
5352 assert!(
5355 fragments.len() >= 7,
5356 "Expected at least 7 fragments, got {}",
5357 fragments.len()
5358 );
5359
5360 assert!(
5362 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5363 raw.value.contains("<!DOCTYPE html>") &&
5364 raw.value.contains("<meta charset=\"utf-8\">") &&
5365 raw.value.contains("<meta name=\"viewport\"") &&
5366 raw.value.contains("<title>Complex Page</title>") &&
5367 raw.value.contains("<style>") &&
5368 raw.value.contains("body { margin: 0; padding: 0; }")),
5369 "First fragment should contain all head content, got: {:?}",
5370 fragments[0]
5371 );
5372
5373 assert!(
5375 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
5376 s.value == "head_end" && s.raw),
5377 "Second fragment should be head_end signal"
5378 );
5379
5380 assert!(
5382 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5383 raw.value.contains("</head>") && raw.value.ends_with("<body>")),
5384 "Third fragment should contain </head><body>"
5385 );
5386
5387 assert!(
5389 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
5390 s.value == "body_start" && s.raw),
5391 "Fourth fragment should be body_start signal"
5392 );
5393
5394 assert!(
5396 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5397 raw.value.contains("<h1>Hello World</h1>") &&
5398 raw.value.contains("<script")),
5399 "Fifth fragment should contain body content"
5400 );
5401
5402 assert!(
5404 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
5405 s.value == "body_end" && s.raw),
5406 "Sixth fragment should be body_end signal"
5407 );
5408
5409 assert!(
5411 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5412 raw.value.contains("</body>") && raw.value.contains("</html>")),
5413 "Seventh fragment should contain closing tags"
5414 );
5415 }
5416
5417 struct BindingCountPlugin {
5421 counts: Vec<u32>,
5422 }
5423
5424 impl BindingCountPlugin {
5425 fn new() -> Self {
5426 Self { counts: Vec::new() }
5427 }
5428 }
5429
5430 impl crate::plugin::ParserPlugin for BindingCountPlugin {
5431 fn register_component_template(
5432 &mut self,
5433 _tag_name: &str,
5434 _component: &Component,
5435 _processed_template: &str,
5436 ) -> Result<()> {
5437 Ok(())
5438 }
5439
5440 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
5441 if attr_name.starts_with('@') || attr_name == "f-ref" {
5442 AttributeAction::SkipAndCountBinding
5443 } else {
5444 AttributeAction::Keep
5445 }
5446 }
5447
5448 fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
5449 self.counts.push(binding_attribute_count);
5450 if binding_attribute_count > 0 {
5451 Some(binding_attribute_count.to_le_bytes().to_vec())
5452 } else {
5453 None
5454 }
5455 }
5456 }
5457
5458 struct TemplateCapturePlugin {
5459 template: Option<String>,
5460 }
5461
5462 impl TemplateCapturePlugin {
5463 fn new() -> Self {
5464 Self { template: None }
5465 }
5466 }
5467
5468 impl crate::plugin::ParserPlugin for TemplateCapturePlugin {
5469 fn register_component_template(
5470 &mut self,
5471 _tag_name: &str,
5472 _component: &Component,
5473 processed_template: &str,
5474 ) -> Result<()> {
5475 self.template = Some(processed_template.to_string());
5476 Ok(())
5477 }
5478
5479 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
5480 if attr_name.starts_with('@') || attr_name == "f-ref" {
5481 AttributeAction::SkipAndCountBinding
5482 } else {
5483 AttributeAction::Keep
5484 }
5485 }
5486
5487 fn finish_element(&mut self, _binding_attribute_count: u32) -> Option<Vec<u8>> {
5488 None
5489 }
5490
5491 fn into_artifacts(self: Box<Self>) -> Result<ParserPluginArtifacts> {
5492 match self.template {
5493 Some(template) => Ok(ParserPluginArtifacts::ComponentTemplates(vec![
5494 crate::plugin::ComponentTemplateArtifact::template(
5495 "todo-app".to_string(),
5496 template,
5497 ),
5498 ])),
5499 None => Ok(ParserPluginArtifacts::None),
5500 }
5501 }
5502 }
5503
5504 #[test]
5505 fn parser_passes_user_template_root_attrs_to_plugin_without_css_modules() {
5506 let mut parser = HtmlParser::with_plugin_options(
5507 Box::new(TemplateCapturePlugin::new()),
5508 CssStrategy::Link,
5509 );
5510 parser
5511 .component_registry_mut()
5512 .register_component(ComponentRegistration::new(
5513 "todo-app",
5514 r#"<template shadowrootmode="open" @toggle-item="{onToggleItem($e)}" @delete-item="{onDeleteItem($e)}" f-ref="{root}"><div>items</div></template>"#,
5515 Some(":host { display: block; }"),
5516 true,))
5517 .expect("register todo-app");
5518
5519 parser
5520 .parse("index.html", "<todo-app></todo-app>")
5521 .expect("parse failed");
5522
5523 let artifacts = parser.take_plugin_artifacts().expect("artifacts");
5524 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
5525 panic!("expected captured component template");
5526 };
5527 let template = &templates[0].template;
5528
5529 assert!(
5530 template.contains(r#"shadowrootmode="open""#),
5531 "parser must pass dev-authored root template attrs to plugins, got: {template}"
5532 );
5533 assert!(
5534 template.contains(r#"@toggle-item="{onToggleItem($e)}""#),
5535 "parser must pass dev-authored root event attrs to plugins, got: {template}"
5536 );
5537 assert!(
5538 template.contains(r#"@delete-item="{onDeleteItem($e)}""#),
5539 "parser must pass dev-authored root event attrs to plugins, got: {template}"
5540 );
5541 assert!(
5542 template.contains(r#"f-ref="{root}""#),
5543 "parser must pass dev-authored root FAST attrs to plugins, got: {template}"
5544 );
5545 assert!(
5546 !template.contains("shadowrootadoptedstylesheets"),
5547 "link CSS must not add CSS module adoption attrs, got: {template}"
5548 );
5549 }
5550
5551 #[test]
5552 fn test_component_static_attrs_not_counted_as_bindings() {
5553 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
5554 parser
5555 .component_registry
5556 .register_component(ComponentRegistration::new(
5557 "my-btn",
5558 "<button><slot></slot></button>",
5559 None,
5560 true,
5561 ))
5562 .expect("register");
5563
5564 let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
5566 let result = parser.parse("index.html", html);
5567 assert!(result.is_ok());
5568
5569 let records = parser.into_fragment_records();
5570
5571 let frags = &records["index.html"].fragments;
5573 let plugin_count = frags
5574 .iter()
5575 .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
5576 .count();
5577 assert_eq!(
5578 plugin_count, 0,
5579 "Static-only component attributes should not emit a Plugin fragment"
5580 );
5581 }
5582
5583 #[test]
5584 fn test_component_dynamic_attr_counted_as_binding() {
5585 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
5586 parser
5587 .component_registry
5588 .register_component(ComponentRegistration::new(
5589 "my-btn",
5590 "<button><slot></slot></button>",
5591 None,
5592 true,
5593 ))
5594 .expect("register");
5595
5596 let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
5598 let result = parser.parse("index.html", html);
5599 assert!(result.is_ok());
5600
5601 let records = parser.into_fragment_records();
5602 let frags = &records["index.html"].fragments;
5603
5604 let plugin_frags: Vec<_> = frags
5606 .iter()
5607 .filter_map(|f| match f.fragment.as_ref() {
5608 Some(Fragment::Plugin(p)) => Some(&p.data),
5609 _ => None,
5610 })
5611 .collect();
5612 assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
5613 let count = u32::from_le_bytes([
5614 plugin_frags[0][0],
5615 plugin_frags[0][1],
5616 plugin_frags[0][2],
5617 plugin_frags[0][3],
5618 ]);
5619 assert_eq!(count, 1, "Binding attribute count should be 1");
5620 }
5621
5622 #[test]
5623 fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
5624 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
5625 parser
5626 .component_registry
5627 .register_component(ComponentRegistration::new(
5628 "my-btn",
5629 "<button><slot></slot></button>",
5630 None,
5631 true,
5632 ))
5633 .expect("register");
5634
5635 let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
5637 let result = parser.parse("index.html", html);
5638 assert!(result.is_ok());
5639
5640 let records = parser.into_fragment_records();
5641 let frags = &records["index.html"].fragments;
5642
5643 let plugin_frags: Vec<_> = frags
5644 .iter()
5645 .filter_map(|f| match f.fragment.as_ref() {
5646 Some(Fragment::Plugin(p)) => Some(&p.data),
5647 _ => None,
5648 })
5649 .collect();
5650 assert_eq!(plugin_frags.len(), 1);
5651 let count = u32::from_le_bytes([
5652 plugin_frags[0][0],
5653 plugin_frags[0][1],
5654 plugin_frags[0][2],
5655 plugin_frags[0][3],
5656 ]);
5657 assert_eq!(
5659 count, 2,
5660 "Binding count should include dynamic + plugin-skipped attrs, not static"
5661 );
5662 }
5663
5664 #[test]
5665 fn test_component_only_skipped_plugin_attrs_counted() {
5666 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
5667 parser
5668 .component_registry
5669 .register_component(ComponentRegistration::new(
5670 "my-btn",
5671 "<button><slot></slot></button>",
5672 None,
5673 true,
5674 ))
5675 .expect("register");
5676
5677 let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
5679 let result = parser.parse("index.html", html);
5680 assert!(result.is_ok());
5681
5682 let records = parser.into_fragment_records();
5683 let frags = &records["index.html"].fragments;
5684
5685 let plugin_frags: Vec<_> = frags
5686 .iter()
5687 .filter_map(|f| match f.fragment.as_ref() {
5688 Some(Fragment::Plugin(p)) => Some(&p.data),
5689 _ => None,
5690 })
5691 .collect();
5692 assert_eq!(plugin_frags.len(), 1);
5693 let count = u32::from_le_bytes([
5694 plugin_frags[0][0],
5695 plugin_frags[0][1],
5696 plugin_frags[0][2],
5697 plugin_frags[0][3],
5698 ]);
5699 assert_eq!(
5701 count, 2,
5702 "Only plugin-skipped attrs should be counted, not static title"
5703 );
5704 }
5705
5706 #[test]
5709 fn test_comment_handlebars_signal_is_stripped() {
5710 let mut parser = HtmlParser::new();
5711 let html = "<!--{{tokens}}-->";
5712 parser.parse("test.html", html).expect("parse failed");
5713 let records = parser.into_fragment_records();
5714
5715 assert_stream!(records, "test.html", []);
5716 }
5717
5718 #[test]
5719 fn test_comment_triple_brace_raw_signal_is_stripped() {
5720 let mut parser = HtmlParser::new();
5721 let html = "<!--{{{tokens}}}-->";
5722 parser.parse("test.html", html).expect("parse failed");
5723 let records = parser.into_fragment_records();
5724
5725 assert_stream!(records, "test.html", []);
5726 }
5727
5728 #[test]
5729 fn test_comment_regular_stripped() {
5730 let mut parser = HtmlParser::new();
5731 let html = "<!-- regular comment -->";
5732 parser.parse("test.html", html).expect("parse failed");
5733 let records = parser.into_fragment_records();
5734
5735 assert_stream!(records, "test.html", []);
5736 }
5737
5738 #[test]
5739 fn test_comment_dotted_signal_is_stripped() {
5740 let mut parser = HtmlParser::new();
5741 let html = "<!--{{tokens.light}}-->";
5742 parser.parse("test.html", html).expect("parse failed");
5743 let records = parser.into_fragment_records();
5744
5745 assert_stream!(records, "test.html", []);
5746 }
5747
5748 #[test]
5749 fn test_comment_arbitrary_identifier_is_stripped() {
5750 let mut parser = HtmlParser::new();
5751 let html = "<!--{{someOtherBinding}}-->";
5752 parser.parse("test.html", html).expect("parse failed");
5753 let records = parser.into_fragment_records();
5754
5755 assert_stream!(records, "test.html", []);
5756 }
5757
5758 #[test]
5759 fn test_comment_with_surrounding_content_is_stripped() {
5760 let mut parser = HtmlParser::new();
5761 let html = "<div><!--{{tokens}}--></div>";
5762 parser.parse("test.html", html).expect("parse failed");
5763 let records = parser.into_fragment_records();
5764
5765 assert_stream!(records, "test.html", [raw("<div></div>")]);
5766 }
5767
5768 #[test]
5769 fn test_component_template_unterminated_comment_returns_error() {
5770 let mut parser = HtmlParser::new();
5771 parser
5772 .component_registry_mut()
5773 .register_component(ComponentRegistration::new(
5774 "x-bad",
5775 "<span>ok</span><!-- missing close",
5776 None,
5777 true,
5778 ))
5779 .expect("register failed");
5780
5781 let result = parser.parse("index.html", "<x-bad></x-bad>");
5782
5783 assert!(matches!(result, Err(ParserError::Template(diag)) if
5784 diag.to_string().contains("unterminated HTML comment")
5785 ));
5786 }
5787
5788 #[test]
5789 fn test_webui_plugin_strips_component_comments_before_metadata() {
5790 let mut parser =
5791 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5792 parser
5793 .component_registry_mut()
5794 .register_component(ComponentRegistration::new(
5795 "x-bleed",
5796 r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
5797 None,
5798 true,
5799 ))
5800 .expect("register failed");
5801
5802 parser
5803 .parse("index.html", "<x-bleed></x-bleed>")
5804 .expect("parse failed");
5805
5806 let artifacts = parser.take_plugin_artifacts().unwrap();
5807 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
5808 panic!("expected component templates");
5809 };
5810 let template = &templates[0].template_json;
5811 assert!(template.contains("<div>hello</div>"));
5812 assert!(!template.contains("{{path}}"));
5813 assert!(!template.contains("@click"));
5814 assert!(!template.contains("?hidden"));
5815 assert!(!template.contains("-->"));
5816 }
5817
5818 #[test]
5819 fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
5820 let mut parser =
5821 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5822 parser
5823 .component_registry_mut()
5824 .register_component(ComponentRegistration::new(
5825 "x-style",
5826 "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
5827 None,
5828 true,
5829 ))
5830 .expect("register failed");
5831
5832 parser
5833 .parse("index.html", "<x-style></x-style>")
5834 .expect("parse failed");
5835
5836 let artifacts = parser.take_plugin_artifacts().unwrap();
5837 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
5838 panic!("expected component templates");
5839 };
5840 let template = &templates[0].template_json;
5841 assert!(template.contains(".x { color: red; }"));
5842 assert!(!template.contains("ignored"));
5843 assert!(!template.contains("//"));
5844 }
5845
5846 #[test]
5849 fn test_tokens_from_style_tag() {
5850 let mut parser = HtmlParser::new();
5851 let html = r#"<style>
5852 .btn { color: var(--colorPrimary); background: var(--bgColor); }
5853 </style>"#;
5854 parser.parse("test.html", html).expect("parse failed");
5855 let tokens = parser.take_tokens();
5856
5857 assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
5858 }
5859
5860 #[test]
5861 fn test_tokens_from_malformed_style_error_on_unclosed_var() {
5862 let mut parser = HtmlParser::new();
5863 let html = r#"<style>
5864 .bad { color: var(--dangling; }
5865 .ok { color: var(--valid); }
5866 </style>"#;
5867 let result = parser.parse("test.html", html);
5868
5869 assert!(matches!(result, Err(ParserError::Template(diag)) if
5870 diag.error_code() == Some(codes::INVALID_CSS)
5871 && diag.to_string().contains("Unterminated CSS var() call")
5872 ));
5873 }
5874
5875 #[test]
5876 fn test_tokens_from_component_css() {
5877 let mut parser = HtmlParser::new();
5878 parser
5879 .component_registry_mut()
5880 .register_component(ComponentRegistration::new(
5881 "my-button",
5882 "<button>Click</button>",
5883 Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
5884 true,
5885 ))
5886 .expect("register failed");
5887
5888 let html = "<my-button></my-button>";
5889 parser.parse("test.html", html).expect("parse failed");
5890 let tokens = parser.take_tokens();
5891
5892 assert_eq!(tokens, vec!["borderWidth", "textColor"]);
5893 }
5894
5895 #[test]
5896 fn test_token_requirements_preserve_fallback_chains() {
5897 let mut parser = HtmlParser::new();
5898 parser
5899 .component_registry_mut()
5900 .register_component(ComponentRegistration::new(
5901 "my-card",
5902 "<div>Card</div>",
5903 Some(":host { color: var(--token-a, var(--token-b, var(--token-c), true)); }"),
5904 true,
5905 ))
5906 .expect("register failed");
5907
5908 parser
5909 .parse("test.html", "<my-card></my-card>")
5910 .expect("parse failed");
5911 let analysis = parser.token_analysis();
5912
5913 assert_eq!(analysis.fallback_chains.len(), 1);
5914 assert_eq!(
5915 analysis.fallback_chains[0].tokens,
5916 vec!["token-a", "token-b", "token-c"]
5917 );
5918 assert!(!analysis.fallback_chains[0].has_literal_fallback);
5919 }
5920
5921 #[test]
5922 fn test_token_requirements_remove_locally_defined_candidates_from_fallback_chain() {
5923 let mut parser = HtmlParser::new();
5924 parser
5925 .component_registry_mut()
5926 .register_component(ComponentRegistration::new(
5927 "my-card",
5928 "<div>Card</div>",
5929 Some(
5930 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
5931 ),
5932 true,))
5933 .expect("register failed");
5934
5935 parser
5936 .parse("test.html", "<my-card></my-card>")
5937 .expect("parse failed");
5938 let analysis = parser.token_analysis();
5939
5940 assert_eq!(analysis.protocol_tokens, vec!["token-b", "token-c"]);
5941 assert_eq!(analysis.fallback_chains.len(), 1);
5942 assert_eq!(
5943 analysis.fallback_chains[0].tokens,
5944 vec!["token-b", "token-c"]
5945 );
5946 }
5947
5948 #[test]
5949 fn test_token_analysis_theme_validation_reports_missing_unresolved_token() {
5950 let mut parser = HtmlParser::new();
5951 parser
5952 .component_registry_mut()
5953 .register_component(ComponentRegistration::new(
5954 "my-card",
5955 "<div>Card</div>",
5956 Some(
5957 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
5958 ),
5959 true,))
5960 .expect("register failed");
5961 parser
5962 .parse("test.html", "<my-card></my-card>")
5963 .expect("parse failed");
5964
5965 let analysis = parser.token_analysis();
5966 let theme = webui_tokens::TokenFile {
5967 themes: HashMap::from([(
5968 "light".to_string(),
5969 HashMap::from([("token-b".to_string(), "green".to_string())]),
5970 )]),
5971 };
5972
5973 let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
5974 panic!("missing --token-c in the theme must fail parser validation");
5975 };
5976 assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
5977 let help = diagnostic.help_text().expect("help text");
5980 assert!(help.contains("did you mean --token-b?"), "help: {help}");
5981 assert!(help.contains("define it locally"), "help: {help}");
5982 assert!(!help.contains("add --token"), "help: {help}");
5983 }
5984
5985 #[test]
5986 fn test_token_analysis_literal_fallback_token_exempt_from_theme_validation() {
5987 let mut parser = HtmlParser::new();
5988 parser
5989 .component_registry_mut()
5990 .register_component(ComponentRegistration::new(
5991 "my-card",
5992 "<div>Card</div>",
5993 Some(":host { color: var(--brand, #000); }"),
5994 true,
5995 ))
5996 .expect("register failed");
5997 parser
5998 .parse("test.html", "<my-card></my-card>")
5999 .expect("parse failed");
6000
6001 let analysis = parser.token_analysis();
6002 assert_eq!(analysis.protocol_tokens, vec!["brand"]);
6005
6006 let theme = webui_tokens::TokenFile {
6009 themes: HashMap::from([("light".to_string(), HashMap::new())]),
6010 };
6011 analysis
6012 .validate_theme_tokens(&theme)
6013 .expect("literal fallback must exempt --brand from theme validation");
6014 }
6015
6016 #[test]
6017 fn test_token_analysis_mixed_literal_and_bare_usage_requires_token() {
6018 let mut parser = HtmlParser::new();
6019 parser
6020 .component_registry_mut()
6021 .register_component(ComponentRegistration::new(
6022 "my-card",
6023 "<div>Card</div>",
6024 Some(":host { color: var(--brand, #000); background: var(--brand); }"),
6027 true,
6028 ))
6029 .expect("register failed");
6030 parser
6031 .parse("test.html", "<my-card></my-card>")
6032 .expect("parse failed");
6033
6034 let analysis = parser.token_analysis();
6035 let theme = webui_tokens::TokenFile {
6036 themes: HashMap::from([("light".to_string(), HashMap::new())]),
6037 };
6038
6039 let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
6040 panic!("a bare var(--brand) usage must require --brand in the theme");
6041 };
6042 assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
6043 assert!(diagnostic.to_string().contains("--brand"));
6044 }
6045
6046 #[test]
6047 fn test_unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
6048 let mut parser = HtmlParser::new();
6049 parser
6050 .component_registry_mut()
6051 .register_component(ComponentRegistration::new(
6052 "my-card",
6053 "<div>Card</div>",
6054 Some(
6055 ":host { \
6056 color: var(--colr-brand, #000); \
6057 border: var(--present, 1px); \
6058 margin: var(--required); \
6059 }",
6060 ),
6061 true,
6062 ))
6063 .expect("register failed");
6064 parser
6065 .parse("test.html", "<my-card></my-card>")
6066 .expect("parse failed");
6067
6068 let analysis = parser.token_analysis();
6069 let theme = webui_tokens::TokenFile {
6070 themes: HashMap::from([(
6071 "light".to_string(),
6072 HashMap::from([
6073 ("present".to_string(), "2px".to_string()),
6074 ("required".to_string(), "8px".to_string()),
6075 ]),
6076 )]),
6077 };
6078
6079 assert_eq!(
6083 analysis.unthemed_literal_fallback_tokens(&theme),
6084 vec!["colr-brand".to_string()]
6085 );
6086 }
6087
6088 #[test]
6089 fn test_theme_token_error_reports_location_and_suggestion() {
6090 let mut parser = HtmlParser::new();
6091 parser
6092 .component_registry_mut()
6093 .register_component(ComponentRegistration::new(
6094 "my-card",
6095 "<div>Card</div>",
6096 ":host {\n color: var(--color-neutral-2000);\n}".into(),
6097 true,
6098 ))
6099 .expect("register failed");
6100 parser
6101 .parse("test.html", "<my-card></my-card>")
6102 .expect("parse failed");
6103
6104 let analysis = parser.token_analysis();
6105 let theme = webui_tokens::TokenFile {
6106 themes: HashMap::from([(
6107 "dark".to_string(),
6108 HashMap::from([("color-neutral-200".to_string(), "#222".to_string())]),
6109 )]),
6110 };
6111
6112 let Err(ParserError::Template(diag)) = analysis.validate_theme_tokens(&theme) else {
6113 panic!("missing --color-neutral-2000 must fail validation");
6114 };
6115 assert_eq!(diag.error_code(), Some(codes::MISSING_THEME_TOKEN));
6116 let location = diag.location().expect("a source location");
6118 assert!(location.contains("my-card.css:2:"), "location: {location}");
6119 assert!(
6121 diag.snippet_text()
6122 .is_some_and(|s| s.contains("--color-neutral-2000")),
6123 "snippet: {:?}",
6124 diag.snippet_text()
6125 );
6126 assert!(
6128 diag.help_text()
6129 .is_some_and(|h| h.contains("did you mean --color-neutral-200?")),
6130 "help: {:?}",
6131 diag.help_text()
6132 );
6133 }
6134
6135 #[test]
6136 fn test_theme_token_warning_reports_location_and_suggestion() {
6137 let mut parser = HtmlParser::new();
6138 parser
6139 .component_registry_mut()
6140 .register_component(ComponentRegistration::new(
6141 "my-card",
6142 "<div>Card</div>",
6143 ":host {\n color: var(--colr-brand, #000);\n}".into(),
6144 true,
6145 ))
6146 .expect("register failed");
6147 parser
6148 .parse("test.html", "<my-card></my-card>")
6149 .expect("parse failed");
6150
6151 let analysis = parser.token_analysis();
6152 let theme = webui_tokens::TokenFile {
6153 themes: HashMap::from([(
6154 "light".to_string(),
6155 HashMap::from([("color-brand".to_string(), "#abc".to_string())]),
6156 )]),
6157 };
6158
6159 let warnings = analysis.theme_token_warnings(&theme);
6160 assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
6161 let body = warnings[0].body();
6162 assert!(body.contains("my-card.css:2:"), "warning: {body}");
6163 assert!(body.contains("--colr-brand"), "warning: {body}");
6164 assert!(
6165 body.contains("did you mean --color-brand?"),
6166 "warning: {body}"
6167 );
6168 }
6169
6170 #[test]
6171 fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
6172 let mut parser = HtmlParser::new();
6173 let result =
6174 parser
6175 .component_registry_mut()
6176 .register_component(ComponentRegistration::new(
6177 "my-card",
6178 "<div>Card</div>",
6179 Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
6180 true,
6181 ));
6182
6183 assert!(matches!(result, Err(ParserError::Css(message)) if
6184 message.contains("Unterminated CSS var() call")
6185 ));
6186 }
6187
6188 #[test]
6189 fn test_tokens_merged_from_style_and_components() {
6190 let mut parser = HtmlParser::new();
6191 parser
6192 .component_registry_mut()
6193 .register_component(ComponentRegistration::new(
6194 "my-widget",
6195 "<div>Widget</div>",
6196 Some(".w { padding: var(--spacingM); }"),
6197 true,
6198 ))
6199 .expect("register failed");
6200
6201 let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
6202 parser.parse("test.html", html).expect("parse failed");
6203 let tokens = parser.take_tokens();
6204
6205 assert_eq!(tokens, vec!["spacingM", "textColor"]);
6206 }
6207
6208 #[test]
6209 fn test_tokens_deduplicated() {
6210 let mut parser = HtmlParser::new();
6211 parser
6212 .component_registry_mut()
6213 .register_component(ComponentRegistration::new(
6214 "my-btn",
6215 "<button>B</button>",
6216 Some(".b { color: var(--shared); }"),
6217 true,
6218 ))
6219 .expect("register failed");
6220
6221 let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
6222 parser.parse("test.html", html).expect("parse failed");
6223 let tokens = parser.take_tokens();
6224
6225 assert_eq!(tokens, vec!["shared"]);
6226 }
6227
6228 #[test]
6229 fn test_tokens_empty_when_no_vars() {
6230 let mut parser = HtmlParser::new();
6231 let html = "<div>Hello</div>";
6232 parser.parse("test.html", html).expect("parse failed");
6233 let tokens = parser.take_tokens();
6234
6235 assert!(tokens.is_empty());
6236 }
6237
6238 #[test]
6239 fn test_tokens_exclude_locally_defined_in_component() {
6240 let mut parser = HtmlParser::new();
6241 parser
6242 .component_registry_mut()
6243 .register_component(ComponentRegistration::new(
6244 "my-card",
6245 "<div>Card</div>",
6246 Some(":host { --local: 5px; width: var(--external); }"),
6247 true,
6248 ))
6249 .expect("register failed");
6250
6251 let html = "<my-card></my-card>";
6252 parser.parse("test.html", html).expect("parse failed");
6253 let tokens = parser.take_tokens();
6254
6255 assert_eq!(tokens, vec!["external"]);
6256 }
6257
6258 #[test]
6259 fn test_tokens_exclude_entry_root_definitions() {
6260 let mut parser = HtmlParser::new();
6261 parser
6262 .component_registry_mut()
6263 .register_component(ComponentRegistration::new(
6264 "my-btn",
6265 "<button>B</button>",
6266 Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
6267 true,
6268 ))
6269 .expect("register failed");
6270
6271 let html = r#"<style>
6274 :root {
6275 --color-primary: #0078d4;
6276 --radius-m: 6px;
6277 }
6278 body { color: var(--color-primary); }
6279 </style>
6280 <my-btn></my-btn>"#;
6281 parser.parse("test.html", html).expect("parse failed");
6282 let tokens = parser.take_tokens();
6283
6284 assert!(
6286 tokens.is_empty(),
6287 "Tokens defined in entry :root should be excluded: {tokens:?}"
6288 );
6289 }
6290
6291 #[test]
6292 fn test_tokens_entry_defs_exclude_but_external_kept() {
6293 let mut parser = HtmlParser::new();
6294 parser
6295 .component_registry_mut()
6296 .register_component(ComponentRegistration::new(
6297 "my-card",
6298 "<div>Card</div>",
6299 Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
6300 true,
6301 ))
6302 .expect("register failed");
6303
6304 let html = r#"<style>
6306 :root { --color-primary: #0078d4; }
6307 </style>
6308 <my-card></my-card>"#;
6309 parser.parse("test.html", html).expect("parse failed");
6310 let tokens = parser.take_tokens();
6311
6312 assert_eq!(tokens, vec!["external-spacing"]);
6314 }
6315
6316 #[test]
6319 fn test_parse_simple_route() {
6320 let mut parser = HtmlParser::new();
6321 let html = r#"<route path="/profile" component="profile-page" exact />"#;
6322 parser.parse("test.html", html).expect("parse failed");
6323
6324 let records = parser.into_fragment_records();
6326 let frags = &records["test.html"].fragments;
6327 assert_eq!(frags.len(), 1);
6328 match frags[0].fragment.as_ref() {
6329 Some(web_ui_fragment::Fragment::Route(r)) => {
6330 assert_eq!(r.path, "/profile");
6331 assert_eq!(r.fragment_id, "profile-page");
6332 assert!(r.exact);
6333 }
6334 other => panic!("expected Fragment::Route, got {:?}", other),
6335 }
6336 }
6337
6338 #[test]
6339 fn test_parse_route_with_params() {
6340 let mut parser = HtmlParser::new();
6341 let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
6342 parser.parse("test.html", html).expect("parse failed");
6343
6344 let records = parser.into_fragment_records();
6346 let frags = &records["test.html"].fragments;
6347 match frags[0].fragment.as_ref() {
6348 Some(web_ui_fragment::Fragment::Route(r)) => {
6349 assert_eq!(r.path, "/profile/:id/view/:section");
6350 }
6351 other => panic!("expected Fragment::Route, got {:?}", other),
6352 }
6353 }
6354
6355 #[test]
6356 fn test_parse_route_requires_component() {
6357 let mut parser = HtmlParser::new();
6358 let html = r#"<route path="/old" />"#;
6359 let result = parser.parse("test.html", html);
6360 assert!(result.is_err());
6361 }
6362
6363 #[test]
6364 fn test_parse_multiple_routes() {
6365 let mut parser = HtmlParser::new();
6366 let html = r#"<route path="/" component="app-layout" />
6367 <route path="/dashboard" component="dash-page" exact />
6368 <route path="/contacts" component="contacts-page" exact />"#;
6369 parser.parse("test.html", html).expect("parse failed");
6370
6371 let records = parser.into_fragment_records();
6372 let frags = &records["test.html"].fragments;
6373 assert_eq!(frags.len(), 3);
6374 for frag in frags {
6376 assert!(matches!(
6377 frag.fragment.as_ref(),
6378 Some(web_ui_fragment::Fragment::Route(_))
6379 ));
6380 }
6381 }
6382
6383 #[test]
6384 fn test_parse_route_requires_component_with_body() {
6385 let mut parser = HtmlParser::new();
6386 let html = r#"<route path="/error">
6387 <div class="error"><h1>Not Found</h1></div>
6388 </route>"#;
6389 let result = parser.parse("test.html", html);
6390 assert!(
6391 result.is_err(),
6392 "Route without component attribute should fail"
6393 );
6394 }
6395
6396 #[test]
6397 fn test_parse_multiple_routes_with_fragments() {
6398 let mut parser = HtmlParser::new();
6399 let html = r#"<route path="/" component="home-page" exact />
6400 <route path="/about" component="about-page" exact />
6401 <route path="/contact/:id" component="contact-page" />"#;
6402 parser.parse("test.html", html).expect("parse failed");
6403
6404 let records = parser.into_fragment_records();
6405 let frags = &records["test.html"].fragments;
6406 assert_eq!(frags.len(), 3);
6407
6408 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
6410 assert_eq!(r.path, "/");
6411 }
6412 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
6413 assert_eq!(r.path, "/about");
6414 }
6415 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
6416 assert_eq!(r.path, "/contact/:id");
6417 }
6418 }
6419
6420 #[test]
6421 fn test_parse_route_with_query_allowlist() {
6422 let mut parser = HtmlParser::new();
6423 let html =
6424 r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
6425 parser.parse("test.html", html).expect("parse failed");
6426
6427 let records = parser.into_fragment_records();
6428 let frags = &records["test.html"].fragments;
6429 assert_eq!(frags.len(), 1);
6430 match frags[0].fragment.as_ref() {
6431 Some(web_ui_fragment::Fragment::Route(r)) => {
6432 assert_eq!(r.path, "/compose");
6433 assert_eq!(r.fragment_id, "compose-page");
6434 assert!(r.exact);
6435 assert_eq!(r.allowed_query, "action,to,subject");
6436 }
6437 other => panic!("expected Fragment::Route, got {:?}", other),
6438 }
6439 }
6440
6441 #[test]
6442 fn test_parse_route_without_query_has_empty_allowed_query() {
6443 let mut parser = HtmlParser::new();
6444 let html = r#"<route path="/profile" component="profile-page" exact />"#;
6445 parser.parse("test.html", html).expect("parse failed");
6446
6447 let records = parser.into_fragment_records();
6448 let frags = &records["test.html"].fragments;
6449 match frags[0].fragment.as_ref() {
6450 Some(web_ui_fragment::Fragment::Route(r)) => {
6451 assert!(r.allowed_query.is_empty());
6452 }
6453 other => panic!("expected Fragment::Route, got {:?}", other),
6454 }
6455 }
6456
6457 #[test]
6458 fn test_route_body_rejects_malformed_non_route_html() {
6459 let mut parser = HtmlParser::new();
6460 let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
6461 let result = parser.parse("test.html", html);
6462
6463 assert!(matches!(result, Err(ParserError::Template(diag)) if
6464 diag.to_string().contains("unclosed <span> tag")
6465 ));
6466 }
6467
6468 #[test]
6469 fn test_route_body_rejects_malformed_style_css() {
6470 let mut parser = HtmlParser::new();
6471 let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
6472 let result = parser.parse("test.html", html);
6473
6474 assert!(matches!(result, Err(ParserError::Template(diag)) if
6475 diag.error_code() == Some(codes::INVALID_CSS)
6476 && diag.to_string().contains("Unterminated CSS var() call")
6477 ));
6478 }
6479
6480 #[test]
6481 fn test_outlet_not_captured_by_for_loop() {
6482 let mut parser = HtmlParser::new();
6483 let html = r#"<template shadowrootmode="open">
6484 <ul>
6485 <for each="item in items">
6486 <li>{{item.name}}</li>
6487 </for>
6488 </ul>
6489 <main>
6490 <outlet />
6491 </main>
6492</template>"#;
6493 parser.parse("comp.html", html).expect("parse failed");
6494
6495 let records = parser.into_fragment_records();
6496 let frags = &records["comp.html"].fragments;
6497
6498 let outlet_count = frags
6500 .iter()
6501 .filter(|f| {
6502 matches!(
6503 f.fragment.as_ref(),
6504 Some(web_ui_fragment::Fragment::Outlet(_))
6505 )
6506 })
6507 .count();
6508 assert_eq!(
6509 outlet_count, 1,
6510 "expected exactly 1 outlet in top-level fragments, got {outlet_count}"
6511 );
6512
6513 let outlet_idx = frags
6515 .iter()
6516 .position(|f| {
6517 matches!(
6518 f.fragment.as_ref(),
6519 Some(web_ui_fragment::Fragment::Outlet(_))
6520 )
6521 })
6522 .expect("no outlet found");
6523 let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
6524 Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
6525 _ => false,
6526 });
6527 if let Some(ul_idx) = close_ul_idx {
6528 assert!(
6529 outlet_idx > ul_idx,
6530 "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
6531 );
6532 }
6533 }
6534
6535 #[test]
6536 fn test_outlet_position_after_for_not_inside() {
6537 let mut parser = HtmlParser::new();
6538 let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
6539 parser.parse("test.html", html).expect("parse failed");
6540
6541 let records = parser.into_fragment_records();
6542 let frags = &records["test.html"].fragments;
6543
6544 let has_outlet = frags.iter().any(|f| {
6546 matches!(
6547 f.fragment.as_ref(),
6548 Some(web_ui_fragment::Fragment::Outlet(_))
6549 )
6550 });
6551 assert!(
6552 has_outlet,
6553 "outlet should be in top-level fragments: {frags:?}"
6554 );
6555
6556 let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
6558 Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
6559 _ => None,
6560 });
6561 if let Some(id) = for_id {
6562 let for_frags = &records[&id].fragments;
6563 let outlet_in_for = for_frags.iter().any(|f| {
6564 matches!(
6565 f.fragment.as_ref(),
6566 Some(web_ui_fragment::Fragment::Outlet(_))
6567 )
6568 });
6569 assert!(
6570 !outlet_in_for,
6571 "outlet should NOT be inside for-loop body: {for_frags:?}"
6572 );
6573 }
6574 }
6575
6576 #[test]
6577 fn test_style_element_with_handlebars_signal_comment_emits_signal() {
6578 let mut parser = HtmlParser::new();
6579 let html = r#"<html><head><style>
6580:root {
6581 /*{{{tokens.light}}}*/
6582}
6583</style></head><body></body></html>"#;
6584
6585 let result = parser.parse("test.html", html);
6586 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6587 let fragment_records = parser.into_fragment_records();
6588
6589 assert_stream!(
6590 fragment_records,
6591 "test.html",
6592 [
6593 raw("<html><head><style>\n:root {\n "),
6594 signal_raw("tokens.light"),
6595 raw("\n}\n</style>"),
6596 signal_raw("head_end"),
6597 raw("</head><body>"),
6598 signal_raw("body_start"),
6599 signal_raw("body_end"),
6600 raw("</body></html>"),
6601 ]
6602 );
6603 }
6604
6605 #[test]
6606 fn test_style_comment_signal_with_spaces_emits_signal() {
6607 let mut parser = HtmlParser::new();
6608 let html = r#"<html><head><style>
6609:root {
6610 /* {{{tokens.light}}} */
6611}
6612</style></head><body></body></html>"#;
6613
6614 let result = parser.parse("test.html", html);
6615 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6616 let fragment_records = parser.into_fragment_records();
6617
6618 assert_stream!(
6619 fragment_records,
6620 "test.html",
6621 [
6622 raw("<html><head><style>\n:root {\n "),
6623 signal_raw("tokens.light"),
6624 raw("\n}\n</style>"),
6625 signal_raw("head_end"),
6626 raw("</head><body>"),
6627 signal_raw("body_start"),
6628 signal_raw("body_end"),
6629 raw("</body></html>"),
6630 ]
6631 );
6632 }
6633
6634 #[test]
6635 fn test_style_comment_signal_double_brace_emits_signal() {
6636 let mut parser = HtmlParser::new();
6637 let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
6638
6639 let result = parser.parse("test.html", html);
6640 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6641 let fragment_records = parser.into_fragment_records();
6642
6643 assert_stream!(
6644 fragment_records,
6645 "test.html",
6646 [
6647 raw("<html><head><style>"),
6648 signal("themeCss"),
6649 raw("</style>"),
6650 signal_raw("head_end"),
6651 raw("</head><body>"),
6652 signal_raw("body_start"),
6653 signal_raw("body_end"),
6654 raw("</body></html>"),
6655 ]
6656 );
6657 }
6658
6659 #[test]
6660 fn test_style_comment_with_extra_text_is_stripped() {
6661 let mut parser = HtmlParser::new();
6662 let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
6663
6664 let result = parser.parse("test.html", html);
6665 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6666 let fragment_records = parser.into_fragment_records();
6667
6668 assert_stream!(
6669 fragment_records,
6670 "test.html",
6671 [
6672 raw("<html><head><style></style>"),
6673 signal_raw("head_end"),
6674 raw("</head><body>"),
6675 signal_raw("body_start"),
6676 signal_raw("body_end"),
6677 raw("</body></html>"),
6678 ]
6679 );
6680 }
6681
6682 #[test]
6683 fn test_style_comment_with_multiple_signals_is_stripped() {
6684 let mut parser = HtmlParser::new();
6685 let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
6686
6687 let result = parser.parse("test.html", html);
6688 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6689 let fragment_records = parser.into_fragment_records();
6690
6691 assert_stream!(
6692 fragment_records,
6693 "test.html",
6694 [
6695 raw("<html><head><style></style>"),
6696 signal_raw("head_end"),
6697 raw("</head><body>"),
6698 signal_raw("body_start"),
6699 signal_raw("body_end"),
6700 raw("</body></html>"),
6701 ]
6702 );
6703 }
6704
6705 #[test]
6706 fn test_style_bare_handlebars_stays_raw() {
6707 let mut parser = HtmlParser::new();
6708 let html =
6710 "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
6711
6712 let result = parser.parse("test.html", html);
6713 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6714 let fragment_records = parser.into_fragment_records();
6715
6716 assert_stream!(
6717 fragment_records,
6718 "test.html",
6719 [
6720 raw("<html><head><style>body { color: {{textColor}}; }</style>"),
6721 signal_raw("head_end"),
6722 raw("</head><body>"),
6723 signal_raw("body_start"),
6724 signal_raw("body_end"),
6725 raw("</body></html>"),
6726 ]
6727 );
6728 }
6729
6730 #[test]
6731 fn test_style_mixed_css_and_comment_signal_emits_signal() {
6732 let mut parser = HtmlParser::new();
6733 let html = r#"<html><head><style>
6734 .a { color: red; }
6735 /*{{themeCss}}*/
6736 .b { color: blue; }
6737</style></head><body></body></html>"#;
6738
6739 let result = parser.parse("test.html", html);
6740 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6741 let fragment_records = parser.into_fragment_records();
6742
6743 assert_stream!(
6744 fragment_records,
6745 "test.html",
6746 [
6747 raw("<html><head><style>\n .a { color: red; }\n "),
6748 signal("themeCss"),
6749 raw("\n .b { color: blue; }\n</style>"),
6750 signal_raw("head_end"),
6751 raw("</head><body>"),
6752 signal_raw("body_start"),
6753 signal_raw("body_end"),
6754 raw("</body></html>"),
6755 ]
6756 );
6757 }
6758
6759 #[test]
6760 fn test_style_legal_comment_preserved_inline_by_default() {
6761 let mut parser = HtmlParser::new();
6762 let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
6763
6764 parser.parse("test.html", html).expect("parse failed");
6765 let fragment_records = parser.into_fragment_records();
6766
6767 assert_stream!(
6768 fragment_records,
6769 "test.html",
6770 [
6771 raw("<html><head><style>/*! @license MIT */ .x { color: red; } </style>"),
6772 signal_raw("head_end"),
6773 raw("</head><body>"),
6774 signal_raw("body_start"),
6775 signal_raw("body_end"),
6776 raw("</body></html>"),
6777 ]
6778 );
6779 }
6780
6781 #[test]
6782 fn test_style_legal_comments_none_strips_legal_comment() {
6783 let mut parser = HtmlParser::with_options(ParserOptions {
6784 legal_comments: LegalComments::None,
6785 ..ParserOptions::default()
6786 });
6787 let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
6788
6789 parser.parse("test.html", html).expect("parse failed");
6790 let fragment_records = parser.into_fragment_records();
6791
6792 assert_stream!(
6793 fragment_records,
6794 "test.html",
6795 [
6796 raw("<html><head><style> .x { color: red; }</style>"),
6797 signal_raw("head_end"),
6798 raw("</head><body>"),
6799 signal_raw("body_start"),
6800 signal_raw("body_end"),
6801 raw("</body></html>"),
6802 ]
6803 );
6804 }
6805
6806 #[test]
6807 fn test_style_element_plain_css_unchanged() {
6808 let mut parser = HtmlParser::new();
6809 let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
6810
6811 let result = parser.parse("test.html", html);
6812 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6813 let fragment_records = parser.into_fragment_records();
6814
6815 assert_stream!(
6817 fragment_records,
6818 "test.html",
6819 [
6820 raw("<html><head><style>body { margin: 0; }</style>"),
6821 signal_raw("head_end"),
6822 raw("</head><body>"),
6823 signal_raw("body_start"),
6824 signal_raw("body_end"),
6825 raw("</body></html>"),
6826 ]
6827 );
6828 }
6829
6830 #[test]
6831 fn test_unclosed_style_element_returns_error() {
6832 let mut parser = HtmlParser::new();
6833 let result = parser.parse("index.html", "<style>.x { color: red; ");
6834 assert!(matches!(result, Err(ParserError::Template(diag)) if
6835 diag.to_string().contains("unclosed <style> tag")
6836 ));
6837 }
6838
6839 #[test]
6840 fn test_text_with_binding_after_void_element() {
6841 let (fragments, _) = parse_and_get_fragments(
6844 "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
6845 );
6846 assert_fragments!(
6847 fragments,
6848 [
6849 raw("Part A: "),
6850 signal("value1"),
6851 raw("<button>Click</button><br>Part B: "),
6852 signal("value2"),
6853 raw("<button>Click2</button>"),
6854 ]
6855 );
6856 }
6857}