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
56const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
62
63fn structural_signal(value: impl AsRef<str>) -> WebUIFragment {
64 WebUIFragment::signal(
65 format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
66 true,
67 )
68}
69
70#[derive(Clone, Copy, PartialEq, Eq)]
74enum FosterContext {
75 Transparent,
77 Hostile,
80 Barrier,
83}
84
85#[inline]
91fn foster_context_of(name: &str) -> FosterContext {
92 match name.len() {
93 2 => {
94 if name.eq_ignore_ascii_case("tr") {
95 FosterContext::Hostile
96 } else if name.eq_ignore_ascii_case("td") || name.eq_ignore_ascii_case("th") {
97 FosterContext::Barrier
98 } else {
99 FosterContext::Transparent
100 }
101 }
102 5 => {
103 if name.eq_ignore_ascii_case("table")
104 || name.eq_ignore_ascii_case("tbody")
105 || name.eq_ignore_ascii_case("thead")
106 || name.eq_ignore_ascii_case("tfoot")
107 {
108 FosterContext::Hostile
109 } else {
110 FosterContext::Transparent
111 }
112 }
113 6 => {
114 if name.eq_ignore_ascii_case("select") {
115 FosterContext::Hostile
116 } else {
117 FosterContext::Transparent
118 }
119 }
120 7 => {
121 if name.eq_ignore_ascii_case("caption") {
122 FosterContext::Barrier
123 } else {
124 FosterContext::Transparent
125 }
126 }
127 8 => {
128 if name.eq_ignore_ascii_case("optgroup") || name.eq_ignore_ascii_case("colgroup") {
129 FosterContext::Hostile
130 } else {
131 FosterContext::Transparent
132 }
133 }
134 _ => FosterContext::Transparent,
135 }
136}
137
138#[inline]
141fn boundary_parent_scope(name: &str) -> Option<&'static str> {
142 match name.len() {
143 3 if name.eq_ignore_ascii_case("xmp") => Some("<xmp> raw-text content"),
144 5 if name.eq_ignore_ascii_case("title") => Some("<title> text content"),
145 6 if name.eq_ignore_ascii_case("iframe") => Some("<iframe> raw-text content"),
146 6 if name.eq_ignore_ascii_case("script") => Some("<script> raw-text content"),
147 7 if name.eq_ignore_ascii_case("noembed") => Some("<noembed> raw-text content"),
148 8 if name.eq_ignore_ascii_case("template") => Some("<template> inert content"),
149 8 if name.eq_ignore_ascii_case("textarea") => Some("<textarea> text content"),
150 8 if name.eq_ignore_ascii_case("noframes") => Some("<noframes> raw-text content"),
151 8 if name.eq_ignore_ascii_case("noscript") => Some("<noscript> inert content"),
152 9 if name.eq_ignore_ascii_case("plaintext") => Some("<plaintext> text content"),
153 _ => None,
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
159#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
160pub enum CssStrategy {
161 #[default]
163 Link,
164 Style,
166 Module,
170}
171
172impl std::fmt::Display for CssStrategy {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 match self {
175 CssStrategy::Link => write!(f, "link"),
176 CssStrategy::Style => write!(f, "style"),
177 CssStrategy::Module => write!(f, "module"),
178 }
179 }
180}
181
182impl std::str::FromStr for CssStrategy {
183 type Err = String;
184
185 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
186 match s {
187 "link" => Ok(CssStrategy::Link),
188 "style" => Ok(CssStrategy::Style),
189 "module" => Ok(CssStrategy::Module),
190 other => Err(format!(
191 "Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
192 )),
193 }
194 }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
199#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
200pub enum DomStrategy {
201 #[default]
203 Shadow,
204 Light,
206}
207
208impl std::fmt::Display for DomStrategy {
209 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210 match self {
211 DomStrategy::Shadow => write!(f, "shadow"),
212 DomStrategy::Light => write!(f, "light"),
213 }
214 }
215}
216
217impl std::str::FromStr for DomStrategy {
218 type Err = String;
219
220 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
221 match s {
222 "shadow" => Ok(DomStrategy::Shadow),
223 "light" => Ok(DomStrategy::Light),
224 other => Err(format!(
225 "Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
226 )),
227 }
228 }
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
233#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
234pub enum LegalComments {
235 None,
237 #[default]
239 Inline,
240}
241
242impl std::fmt::Display for LegalComments {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 LegalComments::None => write!(f, "none"),
246 LegalComments::Inline => write!(f, "inline"),
247 }
248 }
249}
250
251impl std::str::FromStr for LegalComments {
252 type Err = String;
253
254 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
255 match s {
256 "none" => Ok(LegalComments::None),
257 "inline" => Ok(LegalComments::Inline),
258 other => Err(format!(
259 "Unknown legal comments strategy: {other}. Use \"none\" or \"inline\"."
260 )),
261 }
262 }
263}
264
265#[derive(Debug, Clone, Default)]
267pub struct ParserOptions {
268 pub css_strategy: CssStrategy,
270 pub dom_strategy: DomStrategy,
272 pub css_link_options: CssLinkOptions,
274 pub legal_comments: LegalComments,
276}
277
278impl ParserOptions {
279 pub fn try_new(
285 css_strategy: CssStrategy,
286 dom_strategy: DomStrategy,
287 css_file_name_template: &str,
288 css_public_base: Option<&str>,
289 legal_comments: LegalComments,
290 ) -> Result<Self> {
291 let css_link_options = if css_strategy == CssStrategy::Link {
292 CssLinkOptions::try_new(
293 css_file_name_template.to_string(),
294 css_public_base.map(std::string::ToString::to_string),
295 )?
296 } else {
297 CssLinkOptions::default()
298 };
299
300 Ok(Self {
301 css_strategy,
302 dom_strategy,
303 css_link_options,
304 legal_comments,
305 })
306 }
307}
308
309impl From<CssStrategy> for ParserOptions {
310 fn from(css_strategy: CssStrategy) -> Self {
311 Self {
312 css_strategy,
313 ..Self::default()
314 }
315 }
316}
317
318impl From<DomStrategy> for ParserOptions {
319 fn from(dom_strategy: DomStrategy) -> Self {
320 Self {
321 dom_strategy,
322 ..Self::default()
323 }
324 }
325}
326
327impl From<(CssStrategy, DomStrategy)> for ParserOptions {
328 fn from((css_strategy, dom_strategy): (CssStrategy, DomStrategy)) -> Self {
329 Self {
330 css_strategy,
331 dom_strategy,
332 ..Self::default()
333 }
334 }
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
340pub enum Plugin {
341 #[cfg_attr(feature = "cli", value(name = "fast"))]
343 Fast,
344 #[cfg_attr(feature = "cli", value(name = "fast-v2"))]
346 FastV2,
347 #[cfg_attr(feature = "cli", value(name = "fast-v3"))]
349 FastV3,
350 #[cfg_attr(feature = "cli", value(name = "webui"))]
352 WebUI,
353}
354
355impl std::fmt::Display for Plugin {
356 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357 match self {
358 Plugin::Fast => write!(f, "fast"),
359 Plugin::FastV2 => write!(f, "fast-v2"),
360 Plugin::FastV3 => write!(f, "fast-v3"),
361 Plugin::WebUI => write!(f, "webui"),
362 }
363 }
364}
365
366impl std::str::FromStr for Plugin {
367 type Err = String;
368
369 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
370 match s {
371 "fast" => Ok(Plugin::Fast),
372 "fast-v2" => Ok(Plugin::FastV2),
373 "fast-v3" => Ok(Plugin::FastV3),
374 "webui" => Ok(Plugin::WebUI),
375 other => Err(format!(
376 "Unknown plugin: {other}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
377 )),
378 }
379 }
380}
381
382struct FragmentIdCounter {
384 counters: HashMap<String, usize>,
386}
387
388impl FragmentIdCounter {
389 fn new() -> Self {
391 Self {
392 counters: HashMap::new(),
393 }
394 }
395
396 fn next_id(&mut self, prefix: &str) -> String {
398 let count = self.counters.entry(prefix.to_string()).or_insert(0);
399 *count += 1;
400 format!("{}-{}", prefix, count)
401 }
402}
403
404struct ParseContext {
405 fragments: Vec<WebUIFragment>,
406 raw_buffer: String,
407}
408
409#[derive(Default)]
410struct FragmentCssTokens {
411 definitions: Vec<String>,
412 fallback_chains: Vec<CssFallbackChain>,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq)]
417pub struct CssTokenAnalysis {
418 pub protocol_tokens: Vec<String>,
420 pub fallback_chains: Vec<CssFallbackChain>,
423 pub(crate) token_sites: HashMap<String, TokenSite>,
427}
428
429#[derive(Debug, Clone, PartialEq, Eq)]
435pub(crate) struct TokenSite {
436 owner: String,
437 position: Option<(usize, usize)>,
438 snippet: Option<String>,
439}
440
441enum ParseOp<'a> {
442 Parse {
443 range: Range<usize>,
444 depth: usize,
445 },
446 EmitClose(&'a str),
447 EndHead(&'a str),
448 EndBody(&'a str),
449 EndBoundary(u32),
453 RestoreFosterDepth(u32),
459 RestoreBoundaryParentScope(Option<&'static str>),
462 CompleteFor {
463 parent: ParseContext,
464 item: String,
465 collection: String,
466 fragment_id: String,
467 keep_empty: bool,
468 },
469 CompleteIf {
470 parent: ParseContext,
471 condition: ConditionExpr,
472 fragment_id: String,
473 },
474}
475
476enum TokenGraphOp<'a> {
477 EnterFragment(&'a str),
478 EnterComponent(&'a str),
479 EnterRoute(&'a WebUiFragmentRoute),
480 ExitDefinitions(&'a [String]),
481}
482
483impl Default for HtmlParser {
484 fn default() -> Self {
485 Self::new()
486 }
487}
488
489pub struct HtmlParser {
491 css_parser: CssParser,
493
494 id_counter: FragmentIdCounter,
496
497 condition_parser: ConditionParser,
499
500 handlebars_parser: HandlebarsParser,
502
503 component_registry: ComponentRegistry,
505
506 fragment_records: WebUIFragmentRecords,
508
509 raw_buffer: String,
511
512 options: ParserOptions,
514
515 plugin: Option<Box<dyn ParserPlugin>>,
517
518 token_roots: Vec<String>,
521
522 fragment_css_tokens: HashMap<String, FragmentCssTokens>,
525
526 in_progress_fragments: HashSet<String>,
529
530 current_fragment_id: String,
533
534 boundary_sequence: u32,
538
539 boundary_names: HashSet<String>,
542
543 boundary_names_in_order: Vec<String>,
546
547 in_boundary: bool,
550
551 body_depth: usize,
555
556 structural_scopes: Vec<&'static str>,
560
561 foster_context_depth: u32,
570
571 boundary_parent_scope: Option<&'static str>,
576
577 module_entry_srcs: Vec<String>,
589}
590
591struct BuiltComponentTemplate {
592 ssr: String,
593 artifact: Option<String>,
594}
595
596impl BuiltComponentTemplate {
597 fn artifact(&self) -> &str {
598 self.artifact.as_deref().unwrap_or(&self.ssr)
599 }
600}
601
602fn add_token_definitions(definitions: &[String], available_counts: &mut HashMap<String, usize>) {
603 for definition in definitions {
604 let count = available_counts.entry(definition.clone()).or_insert(0);
605 *count += 1;
606 }
607}
608
609fn remove_token_definitions(definitions: &[String], available_counts: &mut HashMap<String, usize>) {
610 for definition in definitions {
611 if let Some(count) = available_counts.get_mut(definition) {
612 if *count == 1 {
613 available_counts.remove(definition);
614 } else {
615 *count -= 1;
616 }
617 }
618 }
619}
620
621#[derive(Default)]
625struct UnresolvedTokens {
626 chains: Vec<CssFallbackChain>,
627 sites: HashMap<String, TokenSite>,
628}
629
630fn record_unresolved_requirements(
631 source: &[CssFallbackChain],
632 available_counts: &HashMap<String, usize>,
633 owner: &str,
634 css_source: Option<&str>,
635 out: &mut UnresolvedTokens,
636) {
637 for requirement in source {
638 let tokens: Vec<String> = requirement
639 .tokens
640 .iter()
641 .filter(|token| !available_counts.contains_key(*token))
642 .cloned()
643 .collect();
644 if tokens.is_empty() {
645 continue;
646 }
647 for token in &tokens {
648 if !out.sites.contains_key(token) {
649 out.sites
650 .insert(token.clone(), token_site(owner, css_source, token));
651 }
652 }
653 out.chains.push(CssFallbackChain {
654 tokens,
655 has_literal_fallback: requirement.has_literal_fallback,
656 });
657 }
658}
659
660fn token_site(owner: &str, css_source: Option<&str>, token: &str) -> TokenSite {
664 let (position, snippet) = match css_source.and_then(|css| locate_css_token(css, token)) {
665 Some((line, column, snippet)) => (Some((line, column)), Some(snippet)),
666 None => (None, None),
667 };
668 TokenSite {
669 owner: owner.to_string(),
670 position,
671 snippet,
672 }
673}
674
675fn locate_css_token(css: &str, token: &str) -> Option<(usize, usize, String)> {
681 let bytes = css.as_bytes();
682 let name = token.as_bytes();
683 let mut index = 0;
684 while index + 2 + name.len() <= bytes.len() {
685 let is_prefixed = bytes[index] == b'-'
686 && bytes[index + 1] == b'-'
687 && bytes.get(index + 2..index + 2 + name.len()) == Some(name);
688 if is_prefixed {
689 let after = index + 2 + name.len();
690 let is_exact_name = bytes
691 .get(after)
692 .is_none_or(|b| !(b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_'));
693 if is_exact_name {
694 let mut cursor = after;
695 while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
696 cursor += 1;
697 }
698 if bytes.get(cursor) != Some(&b':') {
700 let (line, column) = diagnostic::line_column(css, index);
701 return Some((line, column, css_line_snippet(css, index)));
702 }
703 }
704 }
705 index += 1;
706 }
707 None
708}
709
710fn css_line_snippet(css: &str, offset: usize) -> String {
713 const MAX_SNIPPET: usize = 80;
714 let start = css[..offset].rfind('\n').map_or(0, |n| n + 1);
715 let end = css[offset..].find('\n').map_or(css.len(), |n| offset + n);
716 let line = css[start..end].trim();
717 if line.chars().count() > MAX_SNIPPET {
718 let truncated: String = line.chars().take(MAX_SNIPPET).collect();
719 format!("{truncated}…")
720 } else {
721 line.to_string()
722 }
723}
724
725fn css_owner_label(tag_name: &str) -> String {
728 let mut label = String::with_capacity(tag_name.len() + 4);
729 label.push_str(tag_name);
730 label.push_str(".css");
731 label
732}
733
734impl CssTokenAnalysis {
735 pub fn validate_theme_tokens(&self, theme: &webui_tokens::TokenFile) -> Result<()> {
752 webui_tokens::validate_chain_tokens(&self.fallback_chains, theme)
753 .map_err(|source| self.theme_token_error(source, theme))
754 }
755
756 #[must_use]
761 pub fn unthemed_literal_fallback_tokens(&self, theme: &webui_tokens::TokenFile) -> Vec<String> {
762 webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme)
763 }
764
765 #[must_use]
773 pub fn theme_token_warnings(&self, theme: &webui_tokens::TokenFile) -> Vec<Diagnostic> {
774 let mut warnings = Vec::new();
775 for token in webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme) {
776 let help = match closest_theme_token(&token, theme.themes.values()) {
777 Some(suggestion) => {
778 format!("did you mean --{suggestion}? otherwise the literal fallback is used")
779 }
780 None => format!(
781 "define --{token} in the theme, or keep its literal fallback if intentional"
782 ),
783 };
784 let diag = self.locate_diagnostic(
785 Diagnostic::warning(format!("unthemed CSS token --{token}"))
786 .code(codes::UNTHEMED_TOKEN)
787 .help(help),
788 &token,
789 );
790 warnings.push(diag);
791 }
792 warnings
793 }
794
795 #[cold]
799 #[inline(never)]
800 fn theme_token_error(
801 &self,
802 source: webui_tokens::TokenError,
803 theme: &webui_tokens::TokenFile,
804 ) -> ParserError {
805 match source {
806 webui_tokens::TokenError::MissingToken {
807 theme: theme_name,
808 token,
809 } => {
810 let help =
814 match closest_theme_token(&token, theme.themes.get(&theme_name).into_iter()) {
815 Some(suggestion) => {
816 format!("did you mean --{suggestion}? otherwise define it locally")
817 }
818 None => {
819 "define it locally if it should not come from the theme".to_string()
820 }
821 };
822 self.locate_diagnostic(
823 Diagnostic::error("missing theme token")
824 .code(codes::MISSING_THEME_TOKEN)
825 .help(help),
826 &token,
827 )
828 .into()
829 }
830 other => ParserError::Generic(format!("Theme token validation failed: {other}")),
831 }
832 }
833
834 fn locate_diagnostic(&self, diag: Diagnostic, token: &str) -> Diagnostic {
837 let Some(site) = self.token_sites.get(token) else {
838 return diag.snippet(format!("--{token}"));
839 };
840 let mut diag = diag.component(site.owner.clone());
841 if let Some((line, column)) = site.position {
842 diag = diag.position(line, column);
843 }
844 match &site.snippet {
845 Some(snippet) => diag.snippet(snippet.clone()),
846 None => diag.snippet(format!("--{token}")),
847 }
848 }
849}
850
851#[cold]
854#[inline(never)]
855fn closest_theme_token<'a>(
856 target: &str,
857 themes: impl Iterator<Item = &'a std::collections::HashMap<String, String>>,
858) -> Option<String> {
859 let mut names: Vec<&str> = themes
860 .flat_map(|theme| theme.keys().map(String::as_str))
861 .collect();
862 names.sort_unstable();
863 names.dedup();
864 crate::suggest::closest_match(target, names.into_iter()).map(ToOwned::to_owned)
865}
866
867impl HtmlParser {
868 #[must_use]
870 pub fn new() -> Self {
871 Self::with_options(ParserOptions::default())
872 }
873
874 #[must_use]
876 pub fn with_options(options: impl Into<ParserOptions>) -> Self {
877 let options = options.into();
878
879 Self {
880 component_registry: ComponentRegistry::with_legal_comments(options.legal_comments),
881 css_parser: CssParser::new(),
882 id_counter: FragmentIdCounter::new(),
883 condition_parser: ConditionParser::new(),
884 handlebars_parser: HandlebarsParser::new(),
885 raw_buffer: String::new(),
886 fragment_records: WebUIFragmentRecords::new(),
887 options,
888 plugin: None,
889 token_roots: Vec::new(),
890 fragment_css_tokens: HashMap::new(),
891 in_progress_fragments: HashSet::new(),
892 current_fragment_id: String::new(),
893 boundary_sequence: 0,
894 boundary_names: HashSet::new(),
895 boundary_names_in_order: Vec::new(),
896 in_boundary: false,
897 body_depth: 0,
898 structural_scopes: Vec::new(),
899 foster_context_depth: 0,
900 boundary_parent_scope: None,
901 module_entry_srcs: Vec::new(),
902 }
903 }
904
905 #[must_use]
907 pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
908 Self::with_plugin_options(plugin, ParserOptions::default())
909 }
910
911 #[must_use]
913 pub fn with_plugin_options(
914 plugin: Box<dyn ParserPlugin>,
915 options: impl Into<ParserOptions>,
916 ) -> Self {
917 let mut p = Self::with_options(options);
918 p.plugin = Some(plugin);
919 p.configure_plugin();
920 p
921 }
922
923 fn configure_plugin(&mut self) {
924 if let Some(ref mut plugin) = self.plugin {
925 plugin.configure(&self.options);
926 }
927 }
928
929 pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
931 &mut self.component_registry
932 }
933
934 #[must_use]
936 pub fn component_registry(&self) -> &ComponentRegistry {
937 &self.component_registry
938 }
939
940 pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
941 std::mem::take(&mut self.fragment_records)
942 }
943
944 pub fn has_fragment(&self, fragment_id: &str) -> bool {
946 self.fragment_records.contains_key(fragment_id)
947 }
948
949 #[must_use]
955 pub fn boundary_count(&self) -> usize {
956 self.boundary_sequence as usize
957 }
958
959 #[must_use]
962 pub fn boundary_names(&self) -> &[String] {
963 &self.boundary_names_in_order
964 }
965
966 #[must_use]
977 pub fn module_entry_srcs(&self) -> &[String] {
978 &self.module_entry_srcs
979 }
980
981 pub fn take_plugin_artifacts(&mut self) -> Result<ParserPluginArtifacts> {
988 self.plugin
989 .take()
990 .map_or(Ok(ParserPluginArtifacts::None), |plugin| {
991 plugin.into_artifacts()
992 })
993 }
994
995 #[must_use]
999 pub fn take_tokens(&mut self) -> Vec<String> {
1000 self.token_analysis().protocol_tokens
1001 }
1002
1003 #[must_use]
1010 pub fn token_analysis(&self) -> CssTokenAnalysis {
1011 let (fallback_chains, token_sites) = self.collect_unresolved_fallback_chains();
1012 let mut protocol_token_set = HashSet::new();
1013 for chain in &fallback_chains {
1014 for token in &chain.tokens {
1015 protocol_token_set.insert(token.clone());
1016 }
1017 }
1018 let mut protocol_tokens: Vec<String> = protocol_token_set.into_iter().collect();
1019 protocol_tokens.sort();
1020 CssTokenAnalysis {
1021 protocol_tokens,
1022 fallback_chains,
1023 token_sites,
1024 }
1025 }
1026
1027 fn collect_unresolved_fallback_chains(
1028 &self,
1029 ) -> (Vec<CssFallbackChain>, HashMap<String, TokenSite>) {
1030 let mut out = UnresolvedTokens::default();
1031 let mut available_counts: HashMap<String, usize> = HashMap::new();
1032 let mut ops: Vec<TokenGraphOp<'_>> = Vec::with_capacity(self.token_roots.len());
1033 for root in self.token_roots.iter().rev() {
1034 ops.push(TokenGraphOp::EnterFragment(root.as_str()));
1035 }
1036
1037 while let Some(op) = ops.pop() {
1038 match op {
1039 TokenGraphOp::EnterFragment(fragment_id) => {
1040 self.enter_token_fragment(
1041 fragment_id,
1042 &mut available_counts,
1043 &mut out,
1044 &mut ops,
1045 );
1046 }
1047 TokenGraphOp::EnterComponent(tag_name) => {
1048 self.enter_token_component(tag_name, &mut available_counts, &mut out, &mut ops);
1049 }
1050 TokenGraphOp::EnterRoute(route) => {
1051 self.enter_token_route(route, &mut available_counts, &mut out, &mut ops);
1052 }
1053 TokenGraphOp::ExitDefinitions(definitions) => {
1054 remove_token_definitions(definitions, &mut available_counts);
1055 }
1056 }
1057 }
1058
1059 (out.chains, out.sites)
1060 }
1061
1062 fn enter_token_fragment<'a>(
1063 &'a self,
1064 fragment_id: &'a str,
1065 available_counts: &mut HashMap<String, usize>,
1066 out: &mut UnresolvedTokens,
1067 ops: &mut Vec<TokenGraphOp<'a>>,
1068 ) {
1069 if let Some(css) = self.fragment_css_tokens.get(fragment_id) {
1070 add_token_definitions(&css.definitions, available_counts);
1071 record_unresolved_requirements(
1075 &css.fallback_chains,
1076 available_counts,
1077 fragment_id,
1078 None,
1079 out,
1080 );
1081 ops.push(TokenGraphOp::ExitDefinitions(&css.definitions));
1082 }
1083
1084 let Some(fragments) = self.fragment_records.get(fragment_id) else {
1085 return;
1086 };
1087 for fragment in fragments.fragments.iter().rev() {
1088 match fragment.fragment.as_ref() {
1089 Some(web_ui_fragment::Fragment::Component(component)) => {
1090 ops.push(TokenGraphOp::EnterComponent(component.fragment_id.as_str()));
1091 }
1092 Some(web_ui_fragment::Fragment::ForLoop(for_loop)) => {
1093 ops.push(TokenGraphOp::EnterFragment(for_loop.fragment_id.as_str()));
1094 }
1095 Some(web_ui_fragment::Fragment::IfCond(if_cond)) => {
1096 ops.push(TokenGraphOp::EnterFragment(if_cond.fragment_id.as_str()));
1097 }
1098 Some(web_ui_fragment::Fragment::Route(route)) => {
1099 ops.push(TokenGraphOp::EnterRoute(route));
1100 }
1101 _ => {}
1102 }
1103 }
1104 }
1105
1106 fn enter_token_component<'a>(
1107 &'a self,
1108 tag_name: &'a str,
1109 available_counts: &mut HashMap<String, usize>,
1110 out: &mut UnresolvedTokens,
1111 ops: &mut Vec<TokenGraphOp<'a>>,
1112 ) {
1113 let Some(component) = self.component_registry.get(tag_name) else {
1114 return;
1115 };
1116 add_token_definitions(&component.css_definitions, available_counts);
1117 record_unresolved_requirements(
1118 &component.css_fallback_chains,
1119 available_counts,
1120 &css_owner_label(tag_name),
1121 component.css_content.as_deref(),
1122 out,
1123 );
1124 ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
1125 ops.push(TokenGraphOp::EnterFragment(tag_name));
1126 }
1127
1128 fn enter_token_route<'a>(
1129 &'a self,
1130 route: &'a WebUiFragmentRoute,
1131 available_counts: &mut HashMap<String, usize>,
1132 out: &mut UnresolvedTokens,
1133 ops: &mut Vec<TokenGraphOp<'a>>,
1134 ) {
1135 let Some(component) = self.component_registry.get(&route.fragment_id) else {
1136 return;
1137 };
1138 add_token_definitions(&component.css_definitions, available_counts);
1139 record_unresolved_requirements(
1140 &component.css_fallback_chains,
1141 available_counts,
1142 &css_owner_label(&route.fragment_id),
1143 component.css_content.as_deref(),
1144 out,
1145 );
1146 ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
1147 if !route.error_component.is_empty() {
1148 ops.push(TokenGraphOp::EnterComponent(route.error_component.as_str()));
1149 }
1150 if !route.pending_component.is_empty() {
1151 ops.push(TokenGraphOp::EnterComponent(
1152 route.pending_component.as_str(),
1153 ));
1154 }
1155 for child in route.children.iter().rev() {
1156 ops.push(TokenGraphOp::EnterRoute(child));
1157 }
1158 ops.push(TokenGraphOp::EnterFragment(route.fragment_id.as_str()));
1159 }
1160
1161 fn record_fragment_css_tokens(
1162 &mut self,
1163 definitions: HashSet<String>,
1164 fallback_chains: Vec<CssFallbackChain>,
1165 ) {
1166 let css = self
1167 .fragment_css_tokens
1168 .entry(self.current_fragment_id.clone())
1169 .or_default();
1170 css.definitions.extend(definitions);
1171 css.definitions.sort();
1172 css.definitions.dedup();
1173 css.fallback_chains.extend(fallback_chains);
1174 }
1175
1176 pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
1178 let fragment_key = fragment_id.to_string();
1179 let is_token_root = self.in_progress_fragments.is_empty();
1180 if is_token_root {
1181 self.boundary_sequence = 0;
1189 self.boundary_names.clear();
1190 self.boundary_names_in_order.clear();
1191 self.in_boundary = false;
1192 self.body_depth = 0;
1193 self.structural_scopes.clear();
1194 self.foster_context_depth = 0;
1195 self.boundary_parent_scope = None;
1196 self.module_entry_srcs.clear();
1197 }
1198 let previous_fragment_id =
1204 std::mem::replace(&mut self.current_fragment_id, fragment_key.clone());
1205 if !self.in_progress_fragments.insert(fragment_key.clone()) {
1206 let err = self
1207 .authoring_error(
1208 codes::RECURSIVE_TEMPLATE,
1209 format!("recursive template reference while parsing <{fragment_id}>"),
1210 )
1211 .help(
1212 "move the recursive usage behind runtime data, or split the component graph \
1213 so templates do not reference themselves at build time",
1214 )
1215 .into();
1216 self.current_fragment_id = previous_fragment_id;
1217 return Err(err);
1218 }
1219 if is_token_root && !self.token_roots.contains(&fragment_key) {
1220 self.token_roots.push(fragment_key.clone());
1221 }
1222
1223 let result = self.parse_inner(fragment_id, html_content);
1224 self.in_progress_fragments.remove(&fragment_key);
1225 self.current_fragment_id = previous_fragment_id;
1226 result
1227 }
1228
1229 fn parse_inner(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
1230 if html_content.len() > MAX_TEMPLATE_BYTES {
1231 return Err(ParserError::Html(format!(
1232 "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.",
1233 html_content.len()
1234 )));
1235 }
1236
1237 self.raw_buffer.clear();
1239 if let Some(ref mut plugin) = self.plugin {
1240 plugin.start_fragment(fragment_id);
1241 }
1242
1243 let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
1244 self.parse_range(html_content, 0..html_content.len(), &mut entry_fragment, 0)?;
1245
1246 self.flush_raw_buffer(&mut entry_fragment);
1247
1248 self.fragment_records.insert(
1249 fragment_id.to_string(),
1250 FragmentList {
1251 fragments: entry_fragment,
1252 },
1253 );
1254
1255 Ok(())
1256 }
1257
1258 fn add_raw_fragment(&mut self, content: &str) {
1260 if !content.is_empty() {
1261 self.raw_buffer.push_str(content);
1262 }
1263 }
1264
1265 fn add_for_fragment(
1267 &mut self,
1268 item: String,
1269 collection: String,
1270 fragment_id: String,
1271 fragments: &mut Vec<WebUIFragment>,
1272 ) {
1273 self.flush_raw_buffer(fragments);
1274 fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
1275 }
1276
1277 fn add_if_fragment(
1279 &mut self,
1280 condition: ConditionExpr,
1281 fragment_id: String,
1282 fragments: &mut Vec<WebUIFragment>,
1283 ) {
1284 self.flush_raw_buffer(fragments);
1285 fragments.push(WebUIFragment::if_cond(condition, fragment_id));
1286 }
1287
1288 fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
1290 self.flush_raw_buffer(fragments);
1291 fragments.push(fragment);
1292 }
1293
1294 fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
1296 if !self.raw_buffer.is_empty() {
1297 fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
1298 }
1299 }
1300
1301 fn should_emit_text_content(content: &str) -> bool {
1307 if content.is_empty() {
1308 return false;
1309 }
1310
1311 if !content.trim().is_empty() {
1312 return true;
1313 }
1314
1315 content.chars().all(char::is_whitespace)
1316 && !content.contains('\n')
1317 && !content.contains('\r')
1318 }
1319
1320 fn parse_range(
1321 &mut self,
1322 source: &str,
1323 range: Range<usize>,
1324 fragments: &mut Vec<WebUIFragment>,
1325 depth: usize,
1326 ) -> Result<()> {
1327 let mut ops = vec![ParseOp::Parse { range, depth }];
1328
1329 while let Some(op) = ops.pop() {
1330 match op {
1331 ParseOp::Parse { range, depth } => {
1332 if depth > MAX_TEMPLATE_DEPTH {
1333 return Err(self
1334 .html_error(
1335 codes::EXCESSIVE_NESTING,
1336 format!(
1337 "template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"
1338 ),
1339 source,
1340 range.start,
1341 )
1342 .help(
1343 "split deeply nested markup into components, or reduce generated nesting",
1344 )
1345 .into());
1346 }
1347
1348 let end = range.end;
1349 let mut index = range.start;
1350 while index < end {
1351 let remaining = &source[index..end];
1352 if remaining.starts_with("<!--") {
1353 let Some(close) = html::find_comment_close(remaining) else {
1354 return Err(self
1355 .html_error(
1356 codes::UNTERMINATED_HTML_COMMENT,
1357 "unterminated HTML comment",
1358 source,
1359 index,
1360 )
1361 .help("close the comment with `-->`")
1362 .into());
1363 };
1364 index += close;
1365 continue;
1366 }
1367
1368 if remaining.starts_with("<!") {
1369 let Some(close) = html::find_declaration_close(remaining) else {
1370 return Err(self
1371 .html_error(
1372 codes::UNTERMINATED_HTML_DECLARATION,
1373 "unterminated HTML declaration",
1374 source,
1375 index,
1376 )
1377 .help("close the declaration with `>`")
1378 .into());
1379 };
1380 self.add_raw_fragment(&remaining[..close]);
1381 index += close;
1382 continue;
1383 }
1384
1385 if remaining.starts_with('<') {
1386 let Some(tag) = html::parse_tag(remaining) else {
1387 return Err(self
1388 .html_error(
1389 codes::MALFORMED_HTML_TAG,
1390 "malformed HTML tag",
1391 source,
1392 index,
1393 )
1394 .help(
1395 "close the tag with `>`, or escape a literal `<` as `<`",
1396 )
1397 .into());
1398 };
1399 if tag.closing {
1400 return Err(self
1401 .html_error(
1402 codes::UNEXPECTED_CLOSING_TAG,
1403 format!("unexpected closing tag </{}>", tag.name),
1404 source,
1405 index,
1406 )
1407 .help("remove it, or add the matching opening tag before it")
1408 .into());
1409 }
1410
1411 let content_start = index + tag.close + 1;
1412 let (content_end, close_end) = if tag.self_closing
1413 || html::is_void_element(tag.name)
1414 {
1415 (content_start, content_start)
1416 } else if let Some((close_start, close_end)) =
1417 html::find_matching_end(remaining, tag.name, tag.close + 1)
1418 {
1419 (index + close_start, index + close_end)
1420 } else {
1421 return Err(self
1422 .html_error(
1423 codes::UNCLOSED_HTML_TAG,
1424 format!("unclosed <{}> tag", tag.name),
1425 source,
1426 index,
1427 )
1428 .help(format!(
1429 "add the matching </{}> closing tag, or make the element self-closing",
1430 tag.name
1431 ))
1432 .into());
1433 };
1434
1435 let element = Element {
1436 source,
1437 start: index,
1438 tag,
1439 content_start,
1440 content_end,
1441 close_end,
1442 };
1443
1444 if close_end < end {
1445 ops.push(ParseOp::Parse {
1446 range: close_end..end,
1447 depth,
1448 });
1449 }
1450
1451 match element.name() {
1452 "for" => {
1453 self.enter_for_directive(&element, fragments, depth, &mut ops)?;
1454 }
1455 "if" => {
1456 self.enter_if_directive(&element, fragments, depth, &mut ops)?;
1457 }
1458 "route" => {
1459 self.process_route_directive(&element, fragments)?;
1460 }
1461 "outlet" => {
1462 self.flush_raw_buffer(fragments);
1463 fragments.push(WebUIFragment::outlet());
1464 }
1465 "boundary" => {
1466 self.enter_boundary_directive(
1467 &element, fragments, depth, &mut ops,
1468 )?;
1469 }
1470 name if name.eq_ignore_ascii_case("body") => {
1471 self.enter_body_element(&element, fragments, depth, &mut ops)?;
1472 }
1473 name if name.eq_ignore_ascii_case("head") => {
1474 self.enter_head_element(&element, fragments, depth, &mut ops)?;
1475 }
1476 name if name.eq_ignore_ascii_case("webui-hydrate") => {
1477 return Err(self.authored_webui_hydrate_error(&element));
1478 }
1479 name if name.eq_ignore_ascii_case("style") => {
1480 self.process_style_element(&element, fragments)?;
1481 }
1482 _ if self.component_registry.contains(element.name()) => {
1483 self.enter_component_directive(
1484 &element, fragments, depth, &mut ops,
1485 )?;
1486 }
1487 _ => {
1488 self.check_component_typo(&element)?;
1489 self.enter_regular_element(
1490 &element, fragments, depth, &mut ops,
1491 )?;
1492 }
1493 }
1494
1495 break;
1496 }
1497
1498 let next = remaining.find('<').unwrap_or(remaining.len());
1499 let text_end = if next == 0 {
1500 remaining
1501 .chars()
1502 .next()
1503 .map_or(remaining.len(), char::len_utf8)
1504 } else {
1505 next
1506 };
1507 self.process_text(&remaining[..text_end], fragments)?;
1508 index += text_end;
1509 }
1510 }
1511 ParseOp::EmitClose(name) => {
1512 self.add_raw_fragment("</");
1513 self.add_raw_fragment(name);
1514 self.add_raw_fragment(">");
1515 }
1516 ParseOp::EndHead(name) => {
1517 self.flush_raw_buffer(fragments);
1518 fragments.push(structural_signal("head_end"));
1519 self.add_raw_fragment("</");
1520 self.add_raw_fragment(name);
1521 self.add_raw_fragment(">");
1522 }
1523 ParseOp::EndBody(name) => {
1524 self.flush_raw_buffer(fragments);
1525 fragments.push(structural_signal("body_end"));
1526 self.add_raw_fragment("</");
1527 self.add_raw_fragment(name);
1528 self.add_raw_fragment(">");
1529 self.body_depth = self.body_depth.saturating_sub(1);
1530 }
1531 ParseOp::EndBoundary(sequence) => {
1532 self.flush_raw_buffer(fragments);
1533 fragments.push(structural_signal(format!("boundary_end:{sequence}")));
1534 self.in_boundary = false;
1535 }
1536 ParseOp::RestoreFosterDepth(previous) => {
1537 self.foster_context_depth = previous;
1538 }
1539 ParseOp::RestoreBoundaryParentScope(previous) => {
1540 self.boundary_parent_scope = previous;
1541 }
1542 ParseOp::CompleteFor {
1543 parent,
1544 item,
1545 collection,
1546 fragment_id,
1547 keep_empty,
1548 } => {
1549 self.structural_scopes.pop();
1550 self.flush_raw_buffer(fragments);
1551 let for_fragment = std::mem::take(fragments);
1552 *fragments = parent.fragments;
1553 self.raw_buffer = parent.raw_buffer;
1554
1555 if !for_fragment.is_empty() {
1556 self.fragment_records.insert(
1557 fragment_id.clone(),
1558 FragmentList {
1559 fragments: for_fragment,
1560 },
1561 );
1562 } else if !keep_empty {
1563 continue;
1564 }
1565
1566 self.add_for_fragment(item, collection, fragment_id, fragments);
1567 }
1568 ParseOp::CompleteIf {
1569 parent,
1570 condition,
1571 fragment_id,
1572 } => {
1573 self.structural_scopes.pop();
1574 self.flush_raw_buffer(fragments);
1575 let if_fragment = std::mem::take(fragments);
1576 *fragments = parent.fragments;
1577 self.raw_buffer = parent.raw_buffer;
1578
1579 self.fragment_records.insert(
1580 fragment_id.clone(),
1581 FragmentList {
1582 fragments: if_fragment,
1583 },
1584 );
1585 self.add_if_fragment(condition, fragment_id, fragments);
1586 }
1587 }
1588 }
1589
1590 Ok(())
1591 }
1592
1593 fn enter_regular_element<'a>(
1594 &mut self,
1595 element: &Element<'a>,
1596 fragments: &mut Vec<WebUIFragment>,
1597 depth: usize,
1598 ops: &mut Vec<ParseOp<'a>>,
1599 ) -> Result<()> {
1600 if !self.in_boundary && element.name().eq_ignore_ascii_case("script") {
1601 self.record_module_entry(element);
1602 }
1603 self.add_raw_fragment("<");
1604 self.add_raw_fragment(element.name());
1605
1606 let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
1607 if let Some(ref mut p) = self.plugin {
1608 if let Some(data) = p.finish_element(binding_count) {
1609 self.add_fragment(WebUIFragment::plugin(data), fragments);
1610 }
1611 }
1612
1613 if element.self_closing() {
1614 self.add_raw_fragment("/>");
1615 return Ok(());
1616 }
1617
1618 self.add_raw_fragment(">");
1619 if !element.is_void() {
1620 if element.close_end() > element.content_end() {
1621 ops.push(ParseOp::EmitClose(element.name()));
1622 }
1623 self.enter_foster_context(element.name(), ops);
1624 self.enter_boundary_parent_scope(element.name(), ops);
1625 ops.push(ParseOp::Parse {
1626 range: element.inner(),
1627 depth: depth + 1,
1628 });
1629 }
1630 Ok(())
1631 }
1632
1633 fn record_module_entry(&mut self, element: &Element<'_>) {
1644 let Some(src) = element.attr("src") else {
1645 return;
1646 };
1647 if src.is_empty() || src.contains("{{") {
1648 return;
1649 }
1650 if !element
1651 .attr("type")
1652 .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("module"))
1653 {
1654 return;
1655 }
1656 if self.module_entry_srcs.iter().any(|seen| seen == src) {
1659 return;
1660 }
1661 self.module_entry_srcs.push(src.to_string());
1662 }
1663
1664 fn enter_head_element<'a>(
1665 &mut self,
1666 element: &Element<'a>,
1667 fragments: &mut Vec<WebUIFragment>,
1668 depth: usize,
1669 ops: &mut Vec<ParseOp<'a>>,
1670 ) -> Result<()> {
1671 if element.name() == "head" {
1672 self.add_raw_fragment("<head>");
1676 } else {
1677 self.add_raw_fragment("<");
1681 self.add_raw_fragment(element.name());
1682 let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
1683 if let Some(ref mut p) = self.plugin {
1684 if let Some(data) = p.finish_element(binding_count) {
1685 self.add_fragment(WebUIFragment::plugin(data), fragments);
1686 }
1687 }
1688 self.add_raw_fragment(">");
1689 }
1690 self.flush_raw_buffer(fragments);
1691 fragments.push(structural_signal("head_start"));
1697 ops.push(ParseOp::EndHead(element.name()));
1698 ops.push(ParseOp::Parse {
1699 range: element.inner(),
1700 depth: depth + 1,
1701 });
1702 Ok(())
1703 }
1704
1705 fn enter_body_element<'a>(
1706 &mut self,
1707 element: &Element<'a>,
1708 fragments: &mut Vec<WebUIFragment>,
1709 depth: usize,
1710 ops: &mut Vec<ParseOp<'a>>,
1711 ) -> Result<()> {
1712 self.add_raw_fragment("<");
1713 self.add_raw_fragment(element.name());
1714 let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
1715 if let Some(ref mut p) = self.plugin {
1716 if let Some(data) = p.finish_element(binding_count) {
1717 self.add_fragment(WebUIFragment::plugin(data), fragments);
1718 }
1719 }
1720 self.add_raw_fragment(">");
1721 self.flush_raw_buffer(fragments);
1722 fragments.push(structural_signal("body_start"));
1723 self.body_depth += 1;
1724 ops.push(ParseOp::EndBody(element.name()));
1725 ops.push(ParseOp::Parse {
1726 range: element.inner(),
1727 depth: depth + 1,
1728 });
1729 Ok(())
1730 }
1731
1732 #[cold]
1739 #[inline(never)]
1740 fn authoring_error(&self, code: &'static str, title: impl Into<String>) -> Diagnostic {
1741 let diag = Diagnostic::error(title).code(code);
1742 if self.current_fragment_id.is_empty() {
1743 diag
1744 } else {
1745 diag.component(self.current_fragment_id.clone())
1746 }
1747 }
1748
1749 #[cold]
1753 #[inline(never)]
1754 fn authoring_error_at(
1755 &self,
1756 code: &'static str,
1757 title: impl Into<String>,
1758 element: &Element<'_>,
1759 ) -> Diagnostic {
1760 self.authoring_error(code, title)
1761 .at_offset(element.source(), element.start)
1762 }
1763
1764 #[cold]
1768 #[inline(never)]
1769 fn html_error(
1770 &self,
1771 code: &'static str,
1772 title: impl Into<String>,
1773 source: &str,
1774 offset: usize,
1775 ) -> Diagnostic {
1776 self.authoring_error(code, title).at_offset(source, offset)
1777 }
1778
1779 #[cold]
1785 #[inline(never)]
1786 fn unknown_component_help(&self, name: &str) -> String {
1787 match suggest::closest_match(name, self.component_registry.names()) {
1788 Some(suggestion) => format!(
1789 "did you mean <{suggestion}>? otherwise register the component by adding a \
1790 matching .html file"
1791 ),
1792 None => "register the component (add a matching .html file) or check the tag name \
1793 for a typo"
1794 .to_string(),
1795 }
1796 }
1797
1798 fn suggest_component(&self, tag: &str) -> Option<&str> {
1807 let (prefix, _) = tag.split_once('-')?;
1808 let same_namespace = self
1809 .component_registry
1810 .names()
1811 .filter(|name| name.split_once('-').map(|(p, _)| p) == Some(prefix));
1812 suggest::closest_match(tag, same_namespace)
1813 }
1814
1815 fn check_component_typo(&self, element: &Element<'_>) -> Result<()> {
1820 if let Some(suggestion) = self.suggest_component(element.name()) {
1821 return Err(self
1822 .authoring_error_at(
1823 codes::UNKNOWN_COMPONENT,
1824 format!("unknown component <{}>", element.name()),
1825 element,
1826 )
1827 .help(format!(
1828 "did you mean <{suggestion}>? otherwise register <{}> by adding a matching \
1829 .html file",
1830 element.name()
1831 ))
1832 .into());
1833 }
1834 Ok(())
1835 }
1836
1837 #[cold]
1841 #[inline(never)]
1842 fn attr_typo_suggestion<'a>(element: &Element<'a>, expected: &str) -> Option<&'a str> {
1843 suggest::closest_match(expected, element.attrs().map(|attr| attr.name))
1844 .filter(|&name| name != expected)
1845 }
1846
1847 #[cold]
1853 #[inline(never)]
1854 fn css_diagnostic(&self, err: ParserError) -> ParserError {
1855 match err {
1856 ParserError::Css(message) => self.authoring_error(codes::INVALID_CSS, message).into(),
1857 other => other,
1858 }
1859 }
1860
1861 #[cold]
1863 #[inline(never)]
1864 fn for_each_missing_error(&self, element: &Element<'_>) -> ParserError {
1865 let diag = self
1866 .authoring_error_at(
1867 codes::MISSING_FOR_EACH,
1868 "missing each attribute on <for>",
1869 element,
1870 )
1871 .element("for");
1872 match Self::attr_typo_suggestion(element, "each") {
1873 Some(typo) => diag.help(format!(
1874 "found `{typo}` \u{2014} did you mean each=\"item in collection\"?"
1875 )),
1876 None => diag.help("add each=\"item in collection\", e.g. <for each=\"todo in todos\">"),
1877 }
1878 .into()
1879 }
1880
1881 #[cold]
1883 #[inline(never)]
1884 fn for_each_invalid_error(&self, element: &Element<'_>, each: &str) -> ParserError {
1885 self.authoring_error_at(
1886 codes::INVALID_FOR_EACH,
1887 "invalid <for> each expression",
1888 element,
1889 )
1890 .element("for")
1891 .snippet(format!("each=\"{each}\""))
1892 .help("use the form each=\"item in collection\", e.g. each=\"todo in todos\"")
1893 .into()
1894 }
1895
1896 #[cold]
1899 #[inline(never)]
1900 fn for_identifier_error(&self, element: &Element<'_>, each: &str) -> ParserError {
1901 self.authoring_error_at(
1902 codes::INVALID_FOR_IDENTIFIER,
1903 "invalid identifier in <for> each expression",
1904 element,
1905 )
1906 .element("for")
1907 .snippet(format!("each=\"{each}\""))
1908 .help("item and collection names may use only letters, digits, '_', '-', and '.'")
1909 .into()
1910 }
1911
1912 fn enter_for_directive<'a>(
1913 &mut self,
1914 element: &Element<'a>,
1915 fragments: &mut Vec<WebUIFragment>,
1916 depth: usize,
1917 ops: &mut Vec<ParseOp<'a>>,
1918 ) -> Result<()> {
1919 let each = element
1920 .attr("each")
1921 .map(ToString::to_string)
1922 .ok_or_else(|| self.for_each_missing_error(element))?;
1923
1924 let mut parts = each.split_whitespace();
1925 let (Some(item), Some(in_kw), Some(collection), None) =
1926 (parts.next(), parts.next(), parts.next(), parts.next())
1927 else {
1928 return Err(self.for_each_invalid_error(element, &each));
1929 };
1930 if in_kw != "in" {
1931 return Err(self.for_each_invalid_error(element, &each));
1932 }
1933
1934 let allowed = |s: &str| {
1935 s.chars()
1936 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1937 };
1938 if !allowed(item) || !allowed(collection) {
1939 return Err(self.for_identifier_error(element, &each));
1940 }
1941
1942 let custom_fragment_id = element.attr("template").map(ToString::to_string);
1943 let keep_empty = custom_fragment_id.is_some();
1944 let fragment_id = custom_fragment_id.unwrap_or_else(|| self.id_counter.next_id("for"));
1945 let parent = ParseContext {
1946 fragments: std::mem::take(fragments),
1947 raw_buffer: std::mem::take(&mut self.raw_buffer),
1948 };
1949
1950 self.structural_scopes.push("a <for> loop");
1951 ops.push(ParseOp::CompleteFor {
1952 parent,
1953 item: item.to_string(),
1954 collection: collection.to_string(),
1955 fragment_id,
1956 keep_empty,
1957 });
1958 ops.push(ParseOp::Parse {
1959 range: element.inner(),
1960 depth: depth + 1,
1961 });
1962 Ok(())
1963 }
1964
1965 #[cold]
1968 #[inline(never)]
1969 fn if_condition_missing_error(&self, element: &Element<'_>) -> ParserError {
1970 let diag = self
1971 .authoring_error_at(
1972 codes::MISSING_IF_CONDITION,
1973 "missing condition attribute on <if>",
1974 element,
1975 )
1976 .element("if");
1977 match Self::attr_typo_suggestion(element, "condition") {
1978 Some(typo) => diag.help(format!(
1979 "found `{typo}` \u{2014} did you mean condition=\"expression\"?"
1980 )),
1981 None => diag.help("add condition=\"expression\", e.g. <if condition=\"isActive\">"),
1982 }
1983 .into()
1984 }
1985
1986 #[cold]
1988 #[inline(never)]
1989 fn if_condition_invalid_error(&self, element: &Element<'_>, condition: &str) -> ParserError {
1990 self.authoring_error_at(
1991 codes::INVALID_IF_CONDITION,
1992 "invalid <if> condition expression",
1993 element,
1994 )
1995 .element("if")
1996 .snippet(format!("condition=\"{condition}\""))
1997 .help("use a simple expression like \"isActive\", \"count > 0\", or \"!hidden\"")
1998 .into()
1999 }
2000
2001 fn enter_if_directive<'a>(
2002 &mut self,
2003 element: &Element<'a>,
2004 fragments: &mut Vec<WebUIFragment>,
2005 depth: usize,
2006 ops: &mut Vec<ParseOp<'a>>,
2007 ) -> Result<()> {
2008 let condition_str = element
2009 .attr("condition")
2010 .map(ToString::to_string)
2011 .ok_or_else(|| self.if_condition_missing_error(element))?;
2012
2013 let condition = self
2014 .condition_parser
2015 .parse(&condition_str)
2016 .map_err(|_| self.if_condition_invalid_error(element, &condition_str))?;
2017
2018 self.flush_raw_buffer(fragments);
2019 let parent = ParseContext {
2020 fragments: std::mem::take(fragments),
2021 raw_buffer: std::mem::take(&mut self.raw_buffer),
2022 };
2023 let fragment_id = self.id_counter.next_id("if");
2024
2025 self.structural_scopes.push("an <if> block");
2026 ops.push(ParseOp::CompleteIf {
2027 parent,
2028 condition,
2029 fragment_id,
2030 });
2031 ops.push(ParseOp::Parse {
2032 range: element.inner(),
2033 depth: depth + 1,
2034 });
2035 Ok(())
2036 }
2037
2038 #[inline]
2046 fn enter_foster_context<'a>(&mut self, name: &str, ops: &mut Vec<ParseOp<'a>>) {
2047 match foster_context_of(name) {
2048 FosterContext::Transparent => {}
2049 FosterContext::Hostile => {
2050 ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
2051 self.foster_context_depth += 1;
2052 }
2053 FosterContext::Barrier => {
2054 if self.foster_context_depth != 0 {
2055 ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
2056 self.foster_context_depth = 0;
2057 }
2058 }
2059 }
2060 }
2061
2062 #[inline]
2063 fn enter_boundary_parent_scope<'a>(&mut self, name: &str, ops: &mut Vec<ParseOp<'a>>) {
2064 let Some(scope) = boundary_parent_scope(name) else {
2065 return;
2066 };
2067 ops.push(ParseOp::RestoreBoundaryParentScope(
2068 self.boundary_parent_scope,
2069 ));
2070 self.boundary_parent_scope = Some(scope);
2071 }
2072
2073 #[inline]
2074 fn enter_component_content_scope<'a>(&mut self, ops: &mut Vec<ParseOp<'a>>) {
2075 ops.push(ParseOp::RestoreBoundaryParentScope(
2076 self.boundary_parent_scope,
2077 ));
2078 self.boundary_parent_scope = Some("component host content");
2079 }
2080
2081 fn enter_boundary_directive<'a>(
2108 &mut self,
2109 element: &Element<'a>,
2110 fragments: &mut Vec<WebUIFragment>,
2111 depth: usize,
2112 ops: &mut Vec<ParseOp<'a>>,
2113 ) -> Result<()> {
2114 if self.in_progress_fragments.len() > 1 {
2120 return Err(self.boundary_scope_error(element, "a reusable component template"));
2121 }
2122 if let Some(scope) = self.structural_scopes.last() {
2123 return Err(self.boundary_scope_error(element, scope));
2124 }
2125 if let Some(scope) = self.boundary_parent_scope {
2126 return Err(self.boundary_scope_error(element, scope));
2127 }
2128 if self.in_boundary {
2129 return Err(self.nested_boundary_error(element));
2130 }
2131 if self.body_depth == 0 {
2132 return Err(self.boundary_outside_body_error(element));
2133 }
2134 if self.foster_context_depth != 0 {
2135 return Err(self.boundary_in_foster_context_error(element));
2136 }
2137
2138 let name = self.validate_boundary_name(element)?;
2139 if !self.boundary_names.insert(name.clone()) {
2140 return Err(self.duplicate_boundary_name_error(element, &name));
2141 }
2142 self.boundary_names_in_order.push(name);
2143
2144 let sequence = self.boundary_sequence;
2145 self.boundary_sequence += 1;
2146 self.in_boundary = true;
2147
2148 self.flush_raw_buffer(fragments);
2149 fragments.push(structural_signal(format!("boundary_start:{sequence}")));
2150
2151 ops.push(ParseOp::EndBoundary(sequence));
2152 ops.push(ParseOp::Parse {
2153 range: element.inner(),
2154 depth: depth + 1,
2155 });
2156 Ok(())
2157 }
2158
2159 fn validate_boundary_name(&self, element: &Element<'_>) -> Result<String> {
2162 let name = element.attr("name").unwrap_or_default();
2163 if name.is_empty() {
2164 return Err(self.missing_boundary_name_error(element));
2165 }
2166 if name.contains("{{") {
2167 return Err(self.invalid_boundary_name_error(element, name));
2168 }
2169 Ok(name.to_string())
2170 }
2171
2172 #[cold]
2175 #[inline(never)]
2176 fn missing_boundary_name_error(&self, element: &Element<'_>) -> ParserError {
2177 self.authoring_error_at(
2178 codes::MISSING_BOUNDARY_NAME,
2179 "missing name attribute on <boundary>",
2180 element,
2181 )
2182 .element("boundary")
2183 .help("add a unique static name, e.g. <boundary name=\"counter-ready\">")
2184 .into()
2185 }
2186
2187 #[cold]
2190 #[inline(never)]
2191 fn invalid_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
2192 self.authoring_error_at(
2193 codes::INVALID_BOUNDARY_NAME,
2194 "invalid name attribute on <boundary>",
2195 element,
2196 )
2197 .element("boundary")
2198 .snippet(format!("name=\"{name}\""))
2199 .help("boundary names must be a static string literal, e.g. name=\"counter-ready\"")
2200 .into()
2201 }
2202
2203 #[cold]
2206 #[inline(never)]
2207 fn duplicate_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
2208 self.authoring_error_at(
2209 codes::DUPLICATE_BOUNDARY_NAME,
2210 format!("duplicate <boundary> name \"{name}\""),
2211 element,
2212 )
2213 .element("boundary")
2214 .help("boundary names must be unique within the page; rename one of the boundaries")
2215 .into()
2216 }
2217
2218 #[cold]
2221 #[inline(never)]
2222 fn nested_boundary_error(&self, element: &Element<'_>) -> ParserError {
2223 self.authoring_error_at(
2224 codes::NESTED_BOUNDARY,
2225 "nested <boundary> is not allowed",
2226 element,
2227 )
2228 .element("boundary")
2229 .help("boundaries cannot be nested; close the enclosing <boundary> before opening another")
2230 .into()
2231 }
2232
2233 #[cold]
2241 #[inline(never)]
2242 fn boundary_scope_error(&self, element: &Element<'_>, scope: &str) -> ParserError {
2243 self.authoring_error_at(
2244 codes::BOUNDARY_CROSSES_SCOPE,
2245 format!("<boundary> cannot appear inside {scope}"),
2246 element,
2247 )
2248 .element("boundary")
2249 .help("move the <boundary> so it fully wraps this scope from the entry template, instead of appearing inside it")
2250 .into()
2251 }
2252
2253 #[cold]
2255 #[inline(never)]
2256 fn boundary_outside_body_error(&self, element: &Element<'_>) -> ParserError {
2257 self.authoring_error_at(
2258 codes::BOUNDARY_OUTSIDE_BODY,
2259 "<boundary> must appear inside <body>",
2260 element,
2261 )
2262 .element("boundary")
2263 .help("move the <boundary> between the opening <body> and its matching </body>")
2264 .into()
2265 }
2266
2267 #[cold]
2275 #[inline(never)]
2276 fn boundary_in_foster_context_error(&self, element: &Element<'_>) -> ParserError {
2277 self.authoring_error_at(
2278 codes::BOUNDARY_IN_FOSTER_CONTEXT,
2279 "<boundary> cannot appear inside a table or select context",
2280 element,
2281 )
2282 .element("boundary")
2283 .help(
2284 "the HTML parser moves unknown elements out of <table>/<tbody>/<tr>/<select>, which would split the hydration sentinel from its payload; wrap the whole <table> in the boundary, or move it inside a <td>, <th>, or <caption>",
2285 )
2286 .into()
2287 }
2288
2289 #[cold]
2293 #[inline(never)]
2294 fn authored_webui_hydrate_error(&self, element: &Element<'_>) -> ParserError {
2295 self.authoring_error_at(
2296 codes::AUTHORED_WEBUI_HYDRATE,
2297 "<webui-hydrate> is reserved for the generated runtime sentinel",
2298 element,
2299 )
2300 .element("webui-hydrate")
2301 .help("remove <webui-hydrate>; it is emitted only by the compiler/handler, never authored")
2302 .into()
2303 }
2304
2305 fn enter_component_directive<'a>(
2306 &mut self,
2307 element: &Element<'a>,
2308 fragments: &mut Vec<WebUIFragment>,
2309 depth: usize,
2310 ops: &mut Vec<ParseOp<'a>>,
2311 ) -> Result<()> {
2312 self.add_raw_fragment("<");
2313 self.add_raw_fragment(element.name());
2314
2315 let binding_count = self.process_tag_attributes(element.attrs(), fragments, true)?;
2316 if let Some(ref mut p) = self.plugin {
2317 if let Some(data) = p.finish_element(binding_count) {
2318 self.add_fragment(WebUIFragment::plugin(data), fragments);
2319 }
2320 }
2321
2322 self.flush_raw_buffer(fragments);
2329 fragments.push(structural_signal(format!(
2330 "streaming_root:{}",
2331 element.name()
2332 )));
2333
2334 if element.self_closing() {
2335 self.add_raw_fragment("/>");
2336 } else {
2337 self.add_raw_fragment(">");
2338 }
2339
2340 self.flush_raw_buffer(fragments);
2341
2342 let (html_content, css_content) = {
2343 let component = self.component_registry.get(element.name()).ok_or_else(|| {
2344 self.authoring_error_at(
2345 codes::UNKNOWN_COMPONENT,
2346 format!("unknown component <{}>", element.name()),
2347 element,
2348 )
2349 .help(self.unknown_component_help(element.name()))
2350 })?;
2351 (
2352 component.html_content.clone(),
2353 component.css_content.clone(),
2354 )
2355 };
2356
2357 if !self.fragment_records.contains_key(element.name()) {
2358 let component_data = self
2359 .component_registry
2360 .get(element.name())
2361 .ok_or_else(|| {
2362 self.authoring_error_at(
2363 codes::UNKNOWN_COMPONENT,
2364 format!("unknown component <{}>", element.name()),
2365 element,
2366 )
2367 .help(self.unknown_component_help(element.name()))
2368 })?
2369 .clone();
2370 let built = self.build_component_templates(
2371 element.name(),
2372 &html_content,
2373 css_content.as_deref(),
2374 self.plugin.is_some(),
2375 )?;
2376
2377 if let Some(ref mut p) = self.plugin {
2378 p.register_component_template(element.name(), &component_data, built.artifact())?;
2379 }
2380
2381 self.parse(element.name(), &built.ssr)?;
2382 }
2383
2384 fragments.push(WebUIFragment::component(element.name().to_string()));
2385
2386 if !element.self_closing() {
2387 ops.push(ParseOp::EmitClose(element.name()));
2388 self.enter_component_content_scope(ops);
2389 ops.push(ParseOp::Parse {
2390 range: element.inner(),
2391 depth: depth + 1,
2392 });
2393 }
2394
2395 Ok(())
2396 }
2397
2398 fn process_text(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
2399 if !Self::should_emit_text_content(content) {
2400 return Ok(());
2401 }
2402
2403 let parsed_fragments = self.handlebars_parser.parse(content)?;
2404 for fragment in parsed_fragments {
2405 if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
2406 if Self::should_emit_text_content(&raw.value) {
2407 self.add_raw_fragment(&raw.value);
2408 }
2409 } else {
2410 self.add_fragment(fragment, fragments);
2411 }
2412 }
2413 Ok(())
2414 }
2415
2416 fn process_style_content(
2417 &mut self,
2418 css: &str,
2419 comments: &[crate::css_parser::CssComment],
2420 fragments: &mut Vec<WebUIFragment>,
2421 ) {
2422 let mut last_end = 0usize;
2423
2424 for comment in comments {
2425 if comment.start_byte < last_end {
2426 if comment.end_byte > last_end {
2427 last_end = comment.end_byte;
2428 }
2429 continue;
2430 }
2431
2432 self.add_raw_fragment(&css[last_end..comment.start_byte]);
2433 let comment_text = &css[comment.start_byte..comment.end_byte];
2434 if let Some(fragment) = self.css_signal_comment_fragment(comment_text) {
2435 self.add_fragment(fragment, fragments);
2436 } else if comment.preserve {
2437 self.add_raw_fragment(comment_text);
2438 }
2439 last_end = comment.end_byte;
2440 }
2441
2442 self.add_raw_fragment(&css[last_end..]);
2443 }
2444
2445 fn css_signal_comment_fragment(&self, comment: &str) -> Option<WebUIFragment> {
2446 let signal = comment_policy::parse_css_signal_comment(comment)?;
2447 Some(WebUIFragment::signal(signal.path, signal.raw))
2448 }
2449
2450 fn extract_single_handlebars(value: &str) -> Option<&str> {
2453 let trimmed = value.trim();
2454 if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
2455 let inner = trimmed[2..trimmed.len() - 2].trim();
2456 if !inner.contains("{{") && !inner.is_empty() {
2458 return Some(inner);
2459 }
2460 }
2461 None
2462 }
2463
2464 fn contains_handlebars(value: &str) -> bool {
2466 value.contains("{{")
2467 }
2468
2469 fn process_tag_attributes(
2470 &mut self,
2471 attrs: Attrs<'_>,
2472 fragments: &mut Vec<WebUIFragment>,
2473 is_component: bool,
2474 ) -> Result<u32> {
2475 let mut first_dynamic_emitted = false;
2476 let mut binding_count: u32 = 0;
2477 for attr in attrs {
2478 let attr_name = attr.name;
2479
2480 if let Some(ref mut p) = self.plugin {
2481 match p.classify_attribute(attr_name) {
2482 AttributeAction::Keep => {}
2483 AttributeAction::Skip => continue,
2484 AttributeAction::SkipAndCountBinding => {
2485 binding_count += 1;
2486 continue;
2487 }
2488 }
2489 }
2490
2491 let attr_value = attr.value;
2492
2493 if let Some(bool_name) = attr_name.strip_prefix('?') {
2494 if is_component {
2495 if let Some(condition) = self.parse_boolean_condition(attr_value) {
2496 let frag = Self::maybe_mark_attr_start(
2497 WebUIFragment::attribute_boolean(bool_name, condition),
2498 &mut first_dynamic_emitted,
2499 );
2500 self.add_fragment(frag, fragments);
2501 binding_count += 1;
2502 }
2503 } else if self.process_boolean_attribute(bool_name, attr_value, fragments)? {
2504 binding_count += 1;
2505 }
2506 } else if let Some(prop_name) = attr_name.strip_prefix(':') {
2507 if !is_component {
2508 return Err(ParserError::Parse(format!(
2509 ":{prop_name} complex binding is only allowed on custom elements. \
2510 Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
2511 )));
2512 }
2513 if Self::is_blocked_complex_property(prop_name) {
2514 return Err(ParserError::Parse(format!(
2515 ":{prop_name} is not allowed as a complex attribute binding \
2516 because it enables arbitrary HTML injection. \
2517 Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
2518 )));
2519 }
2520 if let Some(val) = attr_value {
2521 if let Some(signal_name) = Self::extract_single_handlebars(val) {
2522 let frag = Self::maybe_mark_attr_start(
2523 WebUIFragment::attribute_complex(attr_name, signal_name),
2524 &mut first_dynamic_emitted,
2525 );
2526 self.add_fragment(frag, fragments);
2527 binding_count += 1;
2528 }
2529 }
2530 } else if is_component && Self::is_skipped_attribute(attr_name) {
2531 if let Some(val) = attr_value {
2532 if let Some(signal_name) = Self::extract_single_handlebars(val) {
2533 let frag = WebUIFragment {
2534 fragment: Some(web_ui_fragment::Fragment::Attribute(
2535 WebUIFragmentAttribute {
2536 name: attr_name.to_string(),
2537 value: signal_name.to_string(),
2538 attr_skip: true,
2539 ..Default::default()
2540 },
2541 )),
2542 };
2543 self.add_fragment(frag, fragments);
2544 binding_count += 1;
2545 } else if Self::contains_handlebars(val) {
2546 let template_id = self.id_counter.next_id("attr");
2547 let parsed = self.handlebars_parser.parse(val)?;
2548 self.fragment_records
2549 .insert(template_id.clone(), FragmentList { fragments: parsed });
2550 let frag = WebUIFragment {
2551 fragment: Some(web_ui_fragment::Fragment::Attribute(
2552 WebUIFragmentAttribute {
2553 name: attr_name.to_string(),
2554 template: template_id,
2555 attr_skip: true,
2556 ..Default::default()
2557 },
2558 )),
2559 };
2560 self.add_fragment(frag, fragments);
2561 binding_count += 1;
2562 } else {
2563 let frag = WebUIFragment {
2564 fragment: Some(web_ui_fragment::Fragment::Attribute(
2565 WebUIFragmentAttribute {
2566 name: attr_name.to_string(),
2567 value: val.to_string(),
2568 raw_value: true,
2569 attr_skip: true,
2570 ..Default::default()
2571 },
2572 )),
2573 };
2574 self.add_fragment(frag, fragments);
2575 }
2576 }
2577 } else if let Some(val) = attr_value {
2578 if Self::contains_handlebars(val) {
2579 if is_component {
2580 if let Some(signal_name) = Self::extract_single_handlebars(val) {
2581 let frag = Self::maybe_mark_attr_start(
2582 WebUIFragment::attribute(attr_name, signal_name),
2583 &mut first_dynamic_emitted,
2584 );
2585 self.add_fragment(frag, fragments);
2586 binding_count += 1;
2587 } else {
2588 let template_id = self.id_counter.next_id("attr");
2589 let parsed = self.handlebars_parser.parse(val)?;
2590 self.fragment_records
2591 .insert(template_id.clone(), FragmentList { fragments: parsed });
2592 let frag = Self::maybe_mark_attr_start(
2593 WebUIFragment::attribute_template(attr_name, template_id),
2594 &mut first_dynamic_emitted,
2595 );
2596 self.add_fragment(frag, fragments);
2597 binding_count += 1;
2598 }
2599 } else {
2600 self.process_dynamic_attribute(attr_name, val, fragments)?;
2601 binding_count += 1;
2602 }
2603 } else if is_component {
2604 let frag = Self::maybe_mark_attr_start(
2605 WebUIFragment {
2606 fragment: Some(web_ui_fragment::Fragment::Attribute(
2607 WebUIFragmentAttribute {
2608 name: attr_name.to_string(),
2609 value: val.to_string(),
2610 raw_value: true,
2611 ..Default::default()
2612 },
2613 )),
2614 },
2615 &mut first_dynamic_emitted,
2616 );
2617 self.add_fragment(frag, fragments);
2618 } else {
2619 self.add_raw_fragment(" ");
2620 self.add_raw_fragment(attr.raw);
2621 }
2622 } else {
2623 self.add_raw_fragment(" ");
2624 self.add_raw_fragment(attr_name);
2625 }
2626 }
2627 Ok(binding_count)
2628 }
2629
2630 fn maybe_mark_attr_start(
2633 mut frag: WebUIFragment,
2634 first_dynamic_emitted: &mut bool,
2635 ) -> WebUIFragment {
2636 if !*first_dynamic_emitted {
2637 if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
2638 a.attr_start = true;
2639 }
2640 *first_dynamic_emitted = true;
2641 }
2642 frag
2643 }
2644
2645 fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
2648 if let Some(val) = value {
2649 if let Some(expr_str) = Self::extract_single_handlebars(val) {
2650 return Some(
2651 self.condition_parser
2652 .parse(expr_str)
2653 .unwrap_or_else(|_| ConditionExpr::identifier(expr_str)),
2654 );
2655 }
2656 }
2657
2658 None
2659 }
2660
2661 fn process_boolean_attribute(
2662 &mut self,
2663 name: &str,
2664 value: Option<&str>,
2665 fragments: &mut Vec<WebUIFragment>,
2666 ) -> Result<bool> {
2667 if let Some(condition) = self.parse_boolean_condition(value) {
2668 self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
2669 return Ok(true);
2670 }
2671 Ok(false)
2673 }
2674
2675 fn process_dynamic_attribute(
2677 &mut self,
2678 name: &str,
2679 value: &str,
2680 fragments: &mut Vec<WebUIFragment>,
2681 ) -> Result<()> {
2682 if let Some(signal_name) = Self::extract_single_handlebars(value) {
2683 self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
2685 } else {
2686 let template_id = self.id_counter.next_id("attr");
2688 let parsed = self.handlebars_parser.parse(value)?;
2689
2690 self.fragment_records
2691 .insert(template_id.clone(), FragmentList { fragments: parsed });
2692
2693 self.add_fragment(
2694 WebUIFragment::attribute_template(name, template_id),
2695 fragments,
2696 );
2697 }
2698 Ok(())
2699 }
2700
2701 fn process_style_element(
2702 &mut self,
2703 element: &Element<'_>,
2704 fragments: &mut Vec<WebUIFragment>,
2705 ) -> Result<()> {
2706 self.add_raw_fragment(element.opening());
2707 let inner = element.inner();
2708 let style_content = &element.source()[inner.start..inner.end];
2709 let (_tokens, defs, requirements, comments) = self
2710 .css_parser
2711 .extract_tokens_definitions_requirements_and_comments(
2712 style_content,
2713 self.options.legal_comments,
2714 )
2715 .map_err(|e| self.css_diagnostic(e))?;
2716 self.record_fragment_css_tokens(defs, requirements);
2717 self.process_style_content(style_content, &comments, fragments);
2718 if element.close_end() > element.content_end() {
2719 self.add_raw_fragment("</");
2723 self.add_raw_fragment(element.name());
2724 self.add_raw_fragment(">");
2725 }
2726 Ok(())
2727 }
2728
2729 fn process_route_directive(
2735 &mut self,
2736 element: &Element<'_>,
2737 fragments: &mut Vec<WebUIFragment>,
2738 ) -> Result<()> {
2739 let attrs = Self::route_attrs_from_element(element);
2740 let path = attrs.path.clone();
2741 let component = attrs.component.clone();
2742 route_parser::validate_attributes(&attrs)?;
2743
2744 let route_params: std::collections::HashSet<String> =
2745 route_parser::extract_params(&path)?.into_iter().collect();
2746 if !attrs.cache_tags.is_empty() {
2747 route_parser::validate_tag_placeholders(
2748 &attrs.cache_tags,
2749 &route_params,
2750 "cache-tags",
2751 &path,
2752 )?;
2753 }
2754 if !attrs.invalidates.is_empty() {
2755 route_parser::validate_tag_placeholders(
2756 &attrs.invalidates,
2757 &route_params,
2758 "invalidates",
2759 &path,
2760 )?;
2761 }
2762
2763 if !attrs.pending_component.is_empty() {
2764 self.ensure_route_component_parsed(&attrs.pending_component)?;
2765 }
2766 if !attrs.error_component.is_empty() {
2767 self.ensure_route_component_parsed(&attrs.error_component)?;
2768 }
2769 self.ensure_route_component_parsed(&component)?;
2770
2771 let mut all_params = std::collections::HashSet::new();
2772 all_params.extend(route_params);
2773 self.validate_route_nesting_depth(element.source(), element.inner(), 1)?;
2774 let children =
2775 self.parse_child_routes(element.source(), element.inner(), &all_params, 1)?;
2776
2777 self.flush_raw_buffer(fragments);
2778 let route_fragment =
2779 route_parser::build_route_fragment(&attrs, component.clone(), children);
2780 fragments.push(WebUIFragment::route_from(route_fragment));
2781
2782 Ok(())
2783 }
2784
2785 fn parse_child_routes(
2792 &mut self,
2793 source: &str,
2794 range: Range<usize>,
2795 ancestor_params: &std::collections::HashSet<String>,
2796 depth: usize,
2797 ) -> Result<Vec<WebUiFragmentRoute>> {
2798 if depth > MAX_TEMPLATE_DEPTH {
2799 return Err(self
2800 .html_error(
2801 codes::EXCESSIVE_NESTING,
2802 format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
2803 source,
2804 range.start,
2805 )
2806 .help("flatten deeply nested <route> trees before building")
2807 .into());
2808 }
2809
2810 let mut children = Vec::new();
2811 for event in Walker::new_range(source, range.start, range.end) {
2812 match event {
2813 Event::Element(element) => {
2814 self.validate_closed_element(&element)?;
2815 self.reject_boundary_or_hydrate_in_route(&element)?;
2816 if element.name() == "route" {
2817 children.push(self.parse_route_as_fragment(
2818 &element,
2819 ancestor_params,
2820 depth,
2821 )?);
2822 } else if element.name().eq_ignore_ascii_case("style") {
2823 self.validate_style_element(&element)?;
2824 } else if !element.self_closing() && !element.is_void() {
2825 self.validate_ignored_route_html(element.source(), element.inner(), depth)?;
2826 }
2827 }
2828 Event::Text(text) => {
2829 if text.contains('<') {
2830 return Err(self
2831 .authoring_error(
2832 codes::MALFORMED_HTML_TAG,
2833 "malformed HTML tag in <route>",
2834 )
2835 .help("close the tag with `>`, or escape a literal `<` as `<`")
2836 .into());
2837 }
2838 }
2839 Event::Comment(comment_range) => {
2840 self.validate_comment_range(source, comment_range)?;
2841 }
2842 Event::Declaration(declaration_range) => {
2843 self.validate_declaration_range(source, declaration_range)?;
2844 }
2845 Event::ClosingTag(closing_range) => {
2846 return Err(self.unexpected_closing_tag_error(source, closing_range));
2847 }
2848 }
2849 }
2850
2851 Ok(children)
2852 }
2853
2854 fn validate_route_nesting_depth(
2855 &self,
2856 source: &str,
2857 range: Range<usize>,
2858 depth: usize,
2859 ) -> Result<()> {
2860 let mut stack = vec![(range, depth)];
2861 while let Some((range, depth)) = stack.pop() {
2862 if depth > MAX_TEMPLATE_DEPTH {
2863 return Err(self
2864 .html_error(
2865 codes::EXCESSIVE_NESTING,
2866 format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
2867 source,
2868 range.start,
2869 )
2870 .help("flatten deeply nested <route> trees before building")
2871 .into());
2872 }
2873
2874 for event in Walker::new_range(source, range.start, range.end) {
2875 if let Event::Element(element) = event {
2876 self.validate_closed_element(&element)?;
2877 if element.name() == "route" && !element.self_closing() {
2878 stack.push((element.inner(), depth + 1));
2879 }
2880 }
2881 }
2882 }
2883 Ok(())
2884 }
2885
2886 fn validate_ignored_route_html(
2887 &mut self,
2888 source: &str,
2889 range: Range<usize>,
2890 depth: usize,
2891 ) -> Result<()> {
2892 let mut stack = vec![(range, depth)];
2893 while let Some((range, depth)) = stack.pop() {
2894 if depth > MAX_TEMPLATE_DEPTH {
2895 return Err(self
2896 .html_error(
2897 codes::EXCESSIVE_NESTING,
2898 format!("template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
2899 source,
2900 range.start,
2901 )
2902 .help("split deeply nested markup into components, or reduce generated nesting")
2903 .into());
2904 }
2905
2906 for event in Walker::new_range(source, range.start, range.end) {
2907 match event {
2908 Event::Element(element) => {
2909 self.validate_closed_element(&element)?;
2910 self.reject_boundary_or_hydrate_in_route(&element)?;
2911 if element.name().eq_ignore_ascii_case("style") {
2912 self.validate_style_element(&element)?;
2913 } else if !element.self_closing() && !element.is_void() {
2914 stack.push((element.inner(), depth + 1));
2915 }
2916 }
2917 Event::Text(text) => {
2918 if text.contains('<') {
2919 return Err(self
2920 .authoring_error(
2921 codes::MALFORMED_HTML_TAG,
2922 "malformed HTML tag in <route>",
2923 )
2924 .help("close the tag with `>`, or escape a literal `<` as `<`")
2925 .into());
2926 }
2927 }
2928 Event::Comment(comment_range) => {
2929 self.validate_comment_range(source, comment_range)?;
2930 }
2931 Event::Declaration(declaration_range) => {
2932 self.validate_declaration_range(source, declaration_range)?;
2933 }
2934 Event::ClosingTag(closing_range) => {
2935 return Err(self.unexpected_closing_tag_error(source, closing_range));
2936 }
2937 }
2938 }
2939 }
2940 Ok(())
2941 }
2942
2943 fn reject_boundary_or_hydrate_in_route(&self, element: &Element<'_>) -> Result<()> {
2953 if element.name() == "boundary" {
2954 Err(self.boundary_scope_error(element, "a <route> element"))
2955 } else if element.name().eq_ignore_ascii_case("webui-hydrate") {
2956 Err(self.authored_webui_hydrate_error(element))
2957 } else {
2958 Ok(())
2959 }
2960 }
2961
2962 fn validate_closed_element(&self, element: &Element<'_>) -> Result<()> {
2963 if !element.self_closing()
2964 && !element.is_void()
2965 && element.close_end() == element.content_end()
2966 {
2967 return Err(self
2968 .html_error(
2969 codes::UNCLOSED_HTML_TAG,
2970 format!("unclosed <{}> tag", element.name()),
2971 element.source(),
2972 element.start,
2973 )
2974 .help(format!(
2975 "add the matching </{}> closing tag, or make the element self-closing",
2976 element.name()
2977 ))
2978 .into());
2979 }
2980 Ok(())
2981 }
2982
2983 fn validate_style_element(&mut self, element: &Element<'_>) -> Result<()> {
2984 let inner = element.inner();
2985 let style_content = &element.source()[inner.start..inner.end];
2986 self.css_parser
2987 .extract_tokens_definitions_requirements_and_comments(
2988 style_content,
2989 self.options.legal_comments,
2990 )
2991 .map_err(|e| self.css_diagnostic(e))?;
2992 Ok(())
2993 }
2994
2995 fn validate_comment_range(&self, source: &str, range: Range<usize>) -> Result<()> {
2996 let start = range.start;
2997 if source[range].ends_with("-->") {
2998 return Ok(());
2999 }
3000 Err(self
3001 .html_error(
3002 codes::UNTERMINATED_HTML_COMMENT,
3003 "unterminated HTML comment in <route>",
3004 source,
3005 start,
3006 )
3007 .help("close the comment with `-->`")
3008 .into())
3009 }
3010
3011 fn validate_declaration_range(&self, source: &str, range: Range<usize>) -> Result<()> {
3012 let start = range.start;
3013 if source[range].ends_with('>') {
3014 return Ok(());
3015 }
3016 Err(self
3017 .html_error(
3018 codes::UNTERMINATED_HTML_DECLARATION,
3019 "unterminated HTML declaration in <route>",
3020 source,
3021 start,
3022 )
3023 .help("close the declaration with `>`")
3024 .into())
3025 }
3026
3027 fn unexpected_closing_tag_error(&self, source: &str, range: Range<usize>) -> ParserError {
3028 let start = range.start;
3029 let title = match html::parse_tag(&source[range]) {
3030 Some(tag) => format!("unexpected closing tag </{}> in <route>", tag.name),
3031 None => "unexpected closing tag in <route>".to_string(),
3032 };
3033 self.html_error(codes::UNEXPECTED_CLOSING_TAG, title, source, start)
3034 .help("remove it, or add the matching opening tag before it")
3035 .into()
3036 }
3037
3038 fn route_attrs_from_element(element: &Element<'_>) -> route_parser::RouteAttributes {
3039 route_parser::RouteAttributes {
3040 path: element.attr("path").unwrap_or_default().to_string(),
3041 component: element.attr("component").unwrap_or_default().to_string(),
3042 exact: element.has_attr("exact"),
3043 query: element.attr("query").unwrap_or_default().to_string(),
3044 keep_alive: element.has_attr("keep-alive"),
3045 cache_tags: element
3046 .attr("cache-tags")
3047 .map(route_parser::parse_tag_list)
3048 .unwrap_or_default(),
3049 invalidates: element
3050 .attr("invalidates")
3051 .map(route_parser::parse_tag_list)
3052 .unwrap_or_default(),
3053 pending_component: element.attr("pending").unwrap_or_default().to_string(),
3054 error_component: element.attr("error").unwrap_or_default().to_string(),
3055 }
3056 }
3057
3058 fn parse_route_as_fragment(
3059 &mut self,
3060 element: &Element<'_>,
3061 ancestor_params: &std::collections::HashSet<String>,
3062 depth: usize,
3063 ) -> Result<WebUiFragmentRoute> {
3064 let attrs = Self::route_attrs_from_element(element);
3065 let path = attrs.path.clone();
3066 let component = attrs.component.clone();
3067
3068 route_parser::validate_attributes(&attrs)?;
3069
3070 let own_params: std::collections::HashSet<String> =
3071 route_parser::extract_params(&path)?.into_iter().collect();
3072 let mut all_params = ancestor_params.clone();
3073 all_params.extend(own_params.iter().cloned());
3074 if !attrs.cache_tags.is_empty() {
3075 route_parser::validate_tag_placeholders(
3076 &attrs.cache_tags,
3077 &all_params,
3078 "cache-tags",
3079 &path,
3080 )?;
3081 }
3082 if !attrs.invalidates.is_empty() {
3083 route_parser::validate_tag_placeholders(
3084 &attrs.invalidates,
3085 &all_params,
3086 "invalidates",
3087 &path,
3088 )?;
3089 }
3090
3091 if !attrs.pending_component.is_empty() {
3092 self.ensure_route_component_parsed(&attrs.pending_component)?;
3093 }
3094 if !attrs.error_component.is_empty() {
3095 self.ensure_route_component_parsed(&attrs.error_component)?;
3096 }
3097
3098 self.ensure_route_component_parsed(&component)?;
3099 let children =
3100 self.parse_child_routes(element.source(), element.inner(), &all_params, depth + 1)?;
3101
3102 Ok(route_parser::build_route_fragment(
3103 &attrs, component, children,
3104 ))
3105 }
3106
3107 fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
3109 if component.is_empty()
3110 || !self.component_registry.contains(component)
3111 || self.fragment_records.contains_key(component)
3112 {
3113 return Ok(());
3114 }
3115
3116 let component_data = self
3117 .component_registry
3118 .get(component)
3119 .ok_or_else(|| {
3120 self.authoring_error(
3121 codes::UNKNOWN_COMPONENT,
3122 format!("unknown component <{component}>"),
3123 )
3124 .help(self.unknown_component_help(component))
3125 })?
3126 .clone();
3127
3128 let built = self.build_component_templates(
3129 component,
3130 &component_data.html_content,
3131 component_data.css_content.as_deref(),
3132 self.plugin.is_some(),
3133 )?;
3134
3135 if let Some(ref mut p) = self.plugin {
3136 p.register_component_template(component, &component_data, built.artifact())?;
3137 }
3138
3139 let saved_buffer = std::mem::take(&mut self.raw_buffer);
3140 self.parse(component, &built.ssr)?;
3141 self.raw_buffer = saved_buffer;
3142
3143 Ok(())
3144 }
3145
3146 const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
3148 const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-", "aria-"];
3150 const ADOPTED_STYLESHEETS_ATTR: &str = "shadowrootadoptedstylesheets";
3151
3152 fn is_skipped_attribute(name: &str) -> bool {
3153 Self::SKIPPED_ATTRIBUTES.contains(&name)
3154 || Self::SKIPPED_ATTRIBUTE_PREFIXES
3155 .iter()
3156 .any(|prefix| name.starts_with(prefix))
3157 }
3158
3159 fn is_blocked_complex_property(name: &str) -> bool {
3162 matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
3163 }
3164
3165 fn build_component_templates(
3167 &mut self,
3168 tag_name: &str,
3169 html: &str,
3170 css_content: Option<&str>,
3171 artifact_needed: bool,
3172 ) -> Result<BuiltComponentTemplate> {
3173 let adopted_specifier = match self.options.css_strategy {
3174 CssStrategy::Module if css_content.is_some() => Some(tag_name),
3175 _ => None,
3176 };
3177 let css_injection = match self.options.css_strategy {
3178 CssStrategy::Link => {
3179 if let (Some(css), DomStrategy::Shadow) = (css_content, self.options.dom_strategy) {
3182 let href = self.options.css_link_options.resolve(tag_name, css);
3183 let mut link = String::with_capacity(31 + href.href.len());
3184 link.push_str("<link rel=\"stylesheet\" href=\"");
3185 link.push_str(&href.href);
3186 link.push_str("\">");
3187 Some(link)
3188 } else {
3189 None
3190 }
3191 }
3192 CssStrategy::Style => css_content.map(|css| {
3193 let trimmed = css.trim();
3194 let mut style = String::with_capacity(15 + trimmed.len());
3195 style.push_str("<style>");
3196 style.push_str(trimmed);
3197 style.push_str("</style>");
3198 style
3199 }),
3200 CssStrategy::Module => None,
3201 };
3202
3203 let artifact_differs = artifact_needed && Self::template_has_stripped_runtime_attrs(html);
3204 let ssr =
3205 self.process_component_template(html, css_injection.as_deref(), adopted_specifier)?;
3206 let artifact = if artifact_differs {
3207 Some(self.process_component_artifact_template(
3208 html,
3209 css_injection.as_deref(),
3210 adopted_specifier,
3211 )?)
3212 } else {
3213 None
3214 };
3215
3216 Ok(BuiltComponentTemplate { ssr, artifact })
3217 }
3218
3219 fn process_component_template(
3244 &mut self,
3245 html: &str,
3246 css_snippet: Option<&str>,
3247 adopted_specifier: Option<&str>,
3248 ) -> Result<String> {
3249 self.process_component_template_with_mode(html, css_snippet, adopted_specifier, false)
3250 }
3251
3252 fn process_component_artifact_template(
3253 &mut self,
3254 html: &str,
3255 css_snippet: Option<&str>,
3256 adopted_specifier: Option<&str>,
3257 ) -> Result<String> {
3258 self.process_component_template_with_mode(html, css_snippet, adopted_specifier, true)
3259 }
3260
3261 fn process_component_template_with_mode(
3262 &mut self,
3263 html: &str,
3264 css_snippet: Option<&str>,
3265 adopted_specifier: Option<&str>,
3266 preserve_runtime_attrs: bool,
3267 ) -> Result<String> {
3268 let trimmed = html.trim();
3269 let snippet = css_snippet.unwrap_or_default();
3270
3271 let processed = if trimmed.starts_with("<template") {
3272 let base = if preserve_runtime_attrs {
3273 trimmed.to_string()
3274 } else {
3275 self.strip_runtime_attrs_from_template(trimmed)
3276 };
3277 let with_adopted = Self::append_adopted_attr_if_missing(base, adopted_specifier);
3278 Self::inject_css_snippet_into_template(with_adopted, snippet)
3279 } else {
3280 match self.options.dom_strategy {
3281 DomStrategy::Shadow => {
3282 let adopted = adopted_specifier.unwrap_or_default();
3283 let adopted_extra = if adopted.is_empty() {
3284 0
3285 } else {
3286 Self::adopted_attr_len(adopted)
3287 };
3288 let mut result =
3289 String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
3290 result.push_str("<template shadowrootmode=\"open\"");
3291 if !adopted.is_empty() {
3292 Self::push_adopted_attr(&mut result, adopted);
3293 }
3294 result.push('>');
3295 result.push_str(snippet);
3296 result.push_str(trimmed);
3297 result.push_str("</template>");
3298 result
3299 }
3300 DomStrategy::Light => {
3301 if snippet.is_empty() {
3302 trimmed.to_string()
3303 } else {
3304 let mut result = String::with_capacity(snippet.len() + trimmed.len());
3305 result.push_str(snippet);
3306 result.push_str(trimmed);
3307 result
3308 }
3309 }
3310 }
3311 };
3312
3313 self.strip_template_comments(processed)
3314 }
3315
3316 fn inject_css_snippet_into_template(html: String, snippet: &str) -> String {
3317 if snippet.is_empty() {
3318 return html;
3319 }
3320
3321 match html::find_tag_close(&html) {
3322 Some(open_end) => {
3323 let mut result = String::with_capacity(html.len() + snippet.len());
3324 result.push_str(&html[..=open_end]);
3325 result.push_str(snippet);
3326 result.push_str(&html[open_end + 1..]);
3327 result
3328 }
3329 None => html,
3332 }
3333 }
3334
3335 fn append_adopted_attr_if_missing(html: String, adopted_specifier: Option<&str>) -> String {
3336 let Some(adopted) = adopted_specifier else {
3337 return html;
3338 };
3339 let Some(tag) = html::parse_tag(&html) else {
3340 return html;
3341 };
3342 if tag.name != "template" || tag.closing || tag.has_attr(Self::ADOPTED_STYLESHEETS_ATTR) {
3343 return html;
3344 }
3345
3346 let mut result = String::with_capacity(html.len() + Self::adopted_attr_len(adopted));
3347 result.push_str(&html[..tag.close]);
3348 Self::push_adopted_attr(&mut result, adopted);
3349 result.push_str(&html[tag.close..]);
3350 result
3351 }
3352
3353 fn push_adopted_attr(out: &mut String, adopted: &str) {
3354 out.push(' ');
3355 out.push_str(Self::ADOPTED_STYLESHEETS_ATTR);
3356 out.push_str("=\"");
3357 out.push_str(adopted);
3358 out.push('"');
3359 }
3360
3361 fn adopted_attr_len(adopted: &str) -> usize {
3362 4 + Self::ADOPTED_STYLESHEETS_ATTR.len() + adopted.len()
3363 }
3364
3365 fn template_has_stripped_runtime_attrs(html: &str) -> bool {
3366 let trimmed = html.trim_start();
3367 let Some(tag) = html::parse_tag(trimmed) else {
3368 return false;
3369 };
3370 if tag.name != "template" || tag.closing {
3371 return false;
3372 }
3373 tag.attrs().any(|attr| {
3374 attr.name.starts_with('@') || attr.name.starts_with(':') || attr.name.starts_with('?')
3375 })
3376 }
3377
3378 fn strip_template_comments(&mut self, html: String) -> Result<String> {
3379 let mut ranges = Vec::new();
3380 let mut style_ranges = Vec::new();
3381 Self::collect_html_comment_and_style_ranges(&html, &mut ranges, &mut style_ranges)?;
3382
3383 for (style_start, style_end) in style_ranges {
3384 let css = &html[style_start..style_end];
3385 let (_tokens, _defs, _requirements, comments) = self
3386 .css_parser
3387 .extract_tokens_definitions_requirements_and_comments(
3388 css,
3389 self.options.legal_comments,
3390 )
3391 .map_err(|e| self.css_diagnostic(e))?;
3392 for comment in comments {
3393 let comment_text = &css[comment.start_byte..comment.end_byte];
3394 if comment.preserve || self.css_signal_comment_fragment(comment_text).is_some() {
3395 continue;
3396 }
3397 ranges.push((
3398 style_start + comment.start_byte,
3399 style_start + comment.end_byte,
3400 ));
3401 }
3402 }
3403
3404 if ranges.is_empty() {
3405 return Ok(html);
3406 }
3407
3408 Ok(comment_policy::strip_ranges(&html, ranges.as_mut_slice()).into_owned())
3409 }
3410
3411 fn collect_html_comment_and_style_ranges(
3413 html: &str,
3414 comment_ranges: &mut Vec<(usize, usize)>,
3415 style_ranges: &mut Vec<(usize, usize)>,
3416 ) -> Result<()> {
3417 let mut index = 0usize;
3418 while index < html.len() {
3419 let remaining = &html[index..];
3420 if remaining.starts_with("<!--") {
3421 let Some(end) = html::find_comment_close(remaining) else {
3422 return Err(Diagnostic::error("unterminated HTML comment")
3423 .code(codes::UNTERMINATED_HTML_COMMENT)
3424 .at_offset(html, index)
3425 .help("close the comment with `-->`")
3426 .into());
3427 };
3428 comment_ranges.push((index, index + end));
3429 index += end;
3430 continue;
3431 }
3432
3433 if remaining.starts_with('<') {
3434 if let Some((style_start, style_end, close_end)) =
3435 html::style_element_bounds(remaining)
3436 {
3437 style_ranges.push((index + style_start, index + style_end));
3438 index += close_end;
3439 continue;
3440 }
3441
3442 if let Some(tag) = html::parse_tag(remaining) {
3443 index += tag.close + 1;
3444 continue;
3445 }
3446 }
3447
3448 let Some(ch) = remaining.chars().next() else {
3449 break;
3450 };
3451 index += ch.len_utf8();
3452 }
3453 Ok(())
3454 }
3455
3456 fn strip_runtime_attrs_from_template(&mut self, html: &str) -> String {
3461 let Some(tag) = html::parse_tag(html) else {
3462 return html.to_string();
3463 };
3464
3465 let mut removals: Vec<(usize, usize)> = Vec::new();
3466 for attr in tag.attrs() {
3467 if attr.name.starts_with('@')
3468 || attr.name.starts_with(':')
3469 || attr.name.starts_with('?')
3470 {
3471 let mut start = attr.raw_range.start;
3472 while start > 0 && html.as_bytes()[start - 1].is_ascii_whitespace() {
3473 start -= 1;
3474 }
3475 removals.push((start, attr.raw_range.end));
3476 }
3477 }
3478
3479 if removals.is_empty() {
3480 return html.to_string();
3481 }
3482
3483 let mut result = String::with_capacity(html.len());
3485 let mut pos = 0;
3486 for (start, end) in &removals {
3487 result.push_str(&html[pos..*start]);
3488 pos = *end;
3489 }
3490 result.push_str(&html[pos..]);
3491 result
3492 }
3493}
3494
3495#[cfg(test)]
3496mod tests {
3497 use webui_test_utils::*;
3498
3499 use super::*;
3500
3501 fn structural_matcher(value: &str) -> FragmentMatcher {
3502 signal_raw(&format!("{STRUCTURAL_SIGNAL_PREFIX}{value}"))
3503 }
3504
3505 #[test]
3506 fn test_plugin_display_names() {
3507 assert_eq!(Plugin::Fast.to_string(), "fast");
3508 assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
3509 assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
3510 assert_eq!(Plugin::WebUI.to_string(), "webui");
3511 }
3512
3513 #[test]
3514 fn test_plugin_from_str_names() {
3515 assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
3516 assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
3517 assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
3518 assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
3519 }
3520
3521 #[test]
3522 fn test_plugin_from_str_unknown_mentions_supported_names() {
3523 let err = "legacy".parse::<Plugin>().unwrap_err();
3524 assert!(
3525 err.contains("fast-v3"),
3526 "error should mention fast-v3: {err}"
3527 );
3528 assert!(
3529 err.contains("fast-v2"),
3530 "error should mention fast-v2: {err}"
3531 );
3532 assert!(err.contains("fast"), "error should mention fast: {err}");
3533 }
3534
3535 #[test]
3536 fn test_parse_signal() {
3537 let mut parser = HtmlParser::new();
3538 let html = "Hello, {{name}}!";
3539 let result = parser.parse("test.html", html);
3540
3541 assert!(result.is_ok());
3542 let fragment_records = parser.into_fragment_records();
3543
3544 assert_stream!(
3545 fragment_records,
3546 "test.html",
3547 [raw("Hello, "), signal("name"), raw("!"),]
3548 );
3549 }
3550
3551 #[test]
3552 fn test_parse_raw_signal() {
3553 let mut parser = HtmlParser::new();
3554 let html = "Hello, {{{html_content}}}!";
3555 let result = parser.parse("test.html", html);
3556
3557 assert!(result.is_ok());
3558 let fragment_records = parser.into_fragment_records();
3559
3560 assert_stream!(
3561 fragment_records,
3562 "test.html",
3563 [raw("Hello, "), signal_raw("html_content"), raw("!"),]
3564 );
3565 }
3566
3567 #[test]
3568 fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
3569 let mut parser = HtmlParser::new();
3570 let html = "<nav>{{sectionName}} > {{topicName}}</nav>";
3571 let result = parser.parse("test.html", html);
3572
3573 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3574 let fragment_records = parser.into_fragment_records();
3575
3576 assert_stream!(
3577 fragment_records,
3578 "test.html",
3579 [
3580 raw("<nav>"),
3581 signal("sectionName"),
3582 raw(" > "),
3583 signal("topicName"),
3584 raw("</nav>"),
3585 ]
3586 );
3587 }
3588
3589 #[test]
3590 fn test_invalid_for_each_is_template_diagnostic() {
3591 let mut parser = HtmlParser::new();
3592 let err = parser
3593 .parse(
3594 "index.html",
3595 "<div>\n <for each=\"person inpeople\"><p>x</p></for>\n</div>",
3596 )
3597 .expect_err("invalid for-each must error");
3598 assert!(matches!(err, ParserError::Template(_)));
3599 let msg = err.to_string();
3600 assert!(msg.contains("invalid <for> each expression"), "{msg}");
3601 assert!(msg.contains(r#"each="person inpeople""#), "{msg}");
3602 assert!(
3605 msg.contains("--> index.html:2:3"),
3606 "missing line:column — {msg}"
3607 );
3608 assert!(msg.contains("help:"), "{msg}");
3609 }
3610
3611 #[test]
3612 fn test_invalid_if_condition_is_template_diagnostic() {
3613 let mut parser = HtmlParser::new();
3614 let err = parser
3615 .parse("index.html", r#"<if condition="a +"><p>x</p></if>"#)
3616 .expect_err("invalid condition must error");
3617 assert!(matches!(err, ParserError::Template(_)));
3618 let msg = err.to_string();
3619 assert!(msg.contains("invalid <if> condition expression"), "{msg}");
3620 assert!(
3621 msg.contains("--> index.html:1:1"),
3622 "missing line:column — {msg}"
3623 );
3624 }
3625
3626 #[test]
3627 fn test_parse_for_directive() {
3628 let mut parser = HtmlParser::new();
3629 let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
3630
3631 let result = parser.parse("test.html", html);
3632 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3633 let fragment_records = parser.into_fragment_records();
3634
3635 assert_stream!(
3636 fragment_records,
3637 "test.html",
3638 [for_loop("item", "items", "for-1"),]
3639 );
3640
3641 assert_stream!(
3643 fragment_records,
3644 "for-1",
3645 [
3646 raw("<div class=\"item\">"),
3647 signal("item.name"),
3648 raw("</div>"),
3649 ]
3650 );
3651 }
3652
3653 #[test]
3654 fn test_parse_if_directive() {
3655 let mut parser = HtmlParser::new();
3656 let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
3657
3658 let result = parser.parse("test.html", html);
3659 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3660 let fragment_records = parser.into_fragment_records();
3661
3662 assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
3663
3664 assert_stream!(
3666 fragment_records,
3667 "if-1",
3668 [
3669 raw("<div>Welcome back, "),
3670 signal("username"),
3671 raw("!</div>"),
3672 ]
3673 );
3674 }
3675
3676 #[test]
3677 fn test_component_directive() {
3678 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3679 parser
3680 .component_registry
3681 .register_component(ComponentRegistration::new(
3682 "my-component",
3683 "<div>My Component</div>",
3684 Some("div { color: blue; }"),
3685 true,
3686 ))
3687 .expect("Failed to register component");
3688
3689 let result = parser.parse("test.html", "<my-component></my-component>");
3690 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3691 let records = parser.into_fragment_records();
3692
3693 assert_stream!(
3694 records,
3695 "test.html",
3696 [
3697 raw("<my-component"),
3698 structural_matcher("streaming_root:my-component"),
3699 raw(">"),
3700 component("my-component"),
3701 raw("</my-component>"),
3702 ]
3703 );
3704
3705 let comp = &records["my-component"].fragments;
3707 assert_eq!(comp.len(), 1);
3708 assert!(
3709 matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3710 !raw.value.contains("<template shadowrootmode") && raw.value.contains("<div>My Component</div>"))
3711 );
3712 }
3713
3714 #[test]
3715 fn streaming_root_signal_emitted_before_component_opening_tag_closes() {
3716 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3721 parser
3722 .component_registry
3723 .register_component(ComponentRegistration::new(
3724 "my-widget",
3725 "<div>w</div>",
3726 None,
3727 true,
3728 ))
3729 .expect("register");
3730
3731 let result = parser.parse("test.html", "<my-widget></my-widget>");
3732 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3733 let records = parser.into_fragment_records();
3734
3735 assert_stream!(
3736 records,
3737 "test.html",
3738 [
3739 raw("<my-widget"),
3740 structural_matcher("streaming_root:my-widget"),
3741 raw(">"),
3742 component("my-widget"),
3743 raw("</my-widget>"),
3744 ]
3745 );
3746 }
3747
3748 #[test]
3749 fn streaming_root_signal_emitted_per_component_when_nested_and_repeated() {
3750 use webui_protocol::web_ui_fragment::Fragment;
3753 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3754 for name in ["outer-box", "inner-pill"] {
3755 parser
3756 .component_registry
3757 .register_component(ComponentRegistration::new(
3758 name,
3759 "<slot></slot>",
3760 None,
3761 true,
3762 ))
3763 .expect("register");
3764 }
3765
3766 let result = parser.parse(
3767 "test.html",
3768 "<outer-box><inner-pill></inner-pill></outer-box><inner-pill></inner-pill>",
3769 );
3770 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3771 let records = parser.into_fragment_records();
3772 let fragments = &records["test.html"].fragments;
3773
3774 let signals: Vec<&str> = fragments
3775 .iter()
3776 .filter_map(|f| match f.fragment.as_ref() {
3777 Some(Fragment::Signal(s)) if s.raw => Some(s.value.as_str()),
3778 _ => None,
3779 })
3780 .collect();
3781 assert_eq!(
3782 signals,
3783 [
3784 "}}}webui:streaming_root:outer-box",
3785 "}}}webui:streaming_root:inner-pill",
3786 "}}}webui:streaming_root:inner-pill",
3787 ]
3788 );
3789
3790 for (i, frag) in fragments.iter().enumerate() {
3793 let Some(Fragment::Signal(s)) = frag.fragment.as_ref() else {
3794 continue;
3795 };
3796 if !s.raw || !s.value.starts_with("}}}webui:streaming_root:") {
3797 continue;
3798 }
3799 match fragments[i + 1].fragment.as_ref() {
3800 Some(Fragment::Raw(r)) => assert_eq!(r.value, ">"),
3801 other => panic!("expected raw(\">\") after signal, got {other:?}"),
3802 }
3803 }
3804 }
3805
3806 #[test]
3807 fn unknown_component_typo_in_same_namespace_errors_with_suggestion() {
3808 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3809 parser
3810 .component_registry
3811 .register_component(ComponentRegistration::new(
3812 "mp-button",
3813 "<button>b</button>",
3814 None,
3815 true,
3816 ))
3817 .expect("Failed to register component");
3818
3819 let err = parser
3821 .parse("test.html", "<mp-buton></mp-buton>")
3822 .expect_err("a near-typo component tag should error");
3823 let ParserError::Template(diag) = err else {
3824 panic!("expected ParserError::Template, got {err:?}");
3825 };
3826 assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
3827 let help = diag
3828 .help_text()
3829 .expect("diagnostic should carry a help line");
3830 assert!(
3831 help.contains("did you mean <mp-button>?"),
3832 "expected a suggestion, got: {help}"
3833 );
3834 }
3835
3836 #[test]
3837 fn external_custom_element_in_other_namespace_passes_through() {
3838 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3839 parser
3840 .component_registry
3841 .register_component(ComponentRegistration::new(
3842 "mp-button",
3843 "<button>b</button>",
3844 None,
3845 true,
3846 ))
3847 .expect("Failed to register component");
3848
3849 let result = parser.parse("test.html", "<md-button></md-button>");
3852 assert!(
3853 result.is_ok(),
3854 "external custom element should pass through, got: {:?}",
3855 result.err()
3856 );
3857 }
3858
3859 #[test]
3860 fn for_missing_each_suggests_typoed_attribute() {
3861 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3862 let err = parser
3864 .parse("test.html", "<for eahc=\"todo in todos\"></for>")
3865 .expect_err("a <for> without `each` should error");
3866 let ParserError::Template(diag) = err else {
3867 panic!("expected ParserError::Template, got {err:?}");
3868 };
3869 assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
3870 let help = diag
3871 .help_text()
3872 .expect("diagnostic should carry a help line");
3873 assert!(
3874 help.contains("found `eahc`") && help.contains("did you mean"),
3875 "expected an attribute-typo suggestion, got: {help}"
3876 );
3877 }
3878
3879 #[test]
3880 fn parent_diagnostic_owner_survives_nested_component_parse() {
3881 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3886 parser
3887 .component_registry
3888 .register_component(ComponentRegistration::new(
3889 "my-card",
3890 "<div>card</div>",
3891 None,
3892 true,
3893 ))
3894 .expect("register");
3895
3896 let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
3898 let err = parser
3899 .parse("index.html", html)
3900 .expect_err("the malformed <for> should error");
3901 let ParserError::Template(diag) = err else {
3902 panic!("expected ParserError::Template, got {err:?}");
3903 };
3904 assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
3905 assert_eq!(
3906 diag.component_name(),
3907 Some("index.html"),
3908 "parent diagnostic must be owned by index.html, not the nested component"
3909 );
3910 }
3911
3912 #[test]
3913 fn test_component_directive_with_slots() {
3914 let mut parser = HtmlParser::new();
3915 parser
3916 .component_registry
3917 .register_component(ComponentRegistration::new(
3918 "my-component",
3919 "<div>My Component</div>",
3920 Some("div { color: blue; }"),
3921 true,
3922 ))
3923 .expect("Failed to register component");
3924
3925 let result = parser.parse(
3926 "test.html",
3927 "Hello<my-component><p>World</p></my-component>",
3928 );
3929 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3930 let records = parser.into_fragment_records();
3931 let fragments = &records["test.html"].fragments;
3932
3933 assert!(fragments.len() >= 3);
3935 assert!(
3938 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component"))
3939 );
3940 assert!(fragments.iter().any(|f| matches!(
3942 f.fragment.as_ref(),
3943 Some(Fragment::Component(c)) if c.fragment_id == "my-component"
3944 )));
3945 let last = fragments.last().unwrap();
3947 assert!(
3948 matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
3949 );
3950 }
3951
3952 #[test]
3953 fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
3954 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
3959
3960 parser
3961 .component_registry
3962 .register_component(ComponentRegistration::new(
3963 "app-shell",
3964 r#"<template shadowrootmode="open">
3965 <my-navbar></my-navbar>
3966 <main><outlet /></main>
3967 <cart-panel></cart-panel>
3968 <my-footer></my-footer>
3969 </template>"#,
3970 Some(":host{display:flex}"),
3971 true,
3972 ))
3973 .expect("register app-shell");
3974 parser
3975 .component_registry
3976 .register_component(ComponentRegistration::new(
3977 "my-navbar",
3978 "<nav>Nav</nav>",
3979 Some("nav{color:red}"),
3980 true,
3981 ))
3982 .expect("register my-navbar");
3983 parser
3984 .component_registry
3985 .register_component(ComponentRegistration::new(
3986 "cart-panel",
3987 "<aside>Cart</aside>",
3988 Some("aside{color:green}"),
3989 true,
3990 ))
3991 .expect("register cart-panel");
3992 parser
3993 .component_registry
3994 .register_component(ComponentRegistration::new(
3995 "my-footer",
3996 "<footer>Footer</footer>",
3997 Some("footer{color:blue}"),
3998 true,
3999 ))
4000 .expect("register my-footer");
4001
4002 parser
4003 .parse("index.html", "<app-shell></app-shell>")
4004 .expect("parse index.html");
4005 let records = parser.into_fragment_records();
4006
4007 let app_frags = &records["app-shell"].fragments;
4009 let component_ids: Vec<&str> = app_frags
4010 .iter()
4011 .filter_map(|f| match f.fragment.as_ref() {
4012 Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
4013 _ => None,
4014 })
4015 .collect();
4016
4017 assert!(
4018 component_ids.contains(&"my-navbar"),
4019 "app-shell fragments should contain my-navbar: {component_ids:?}"
4020 );
4021 assert!(
4022 component_ids.contains(&"cart-panel"),
4023 "app-shell fragments should contain cart-panel: {component_ids:?}"
4024 );
4025 assert!(
4026 component_ids.contains(&"my-footer"),
4027 "app-shell fragments should contain my-footer: {component_ids:?}"
4028 );
4029 }
4030
4031 #[test]
4034 fn test_component_no_double_wrap_template() {
4035 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4039 parser
4040 .component_registry
4041 .register_component(ComponentRegistration::new(
4042 "custom-element",
4043 r#"<template foo="bar"><slot></slot></template>"#,
4044 None,
4045 true,
4046 ))
4047 .expect("register");
4048 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4049 assert!(result.is_ok());
4050 let records = parser.into_fragment_records();
4051
4052 assert_fragments!(
4053 records["index.html"].fragments,
4054 [
4055 raw("<custom-element"),
4056 structural_matcher("streaming_root:custom-element"),
4057 raw(">"),
4058 component("custom-element"),
4059 raw("Hello</custom-element>"),
4060 ]
4061 );
4062
4063 assert_stream!(
4064 records,
4065 "custom-element",
4066 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
4067 );
4068 }
4069
4070 #[test]
4071 fn test_component_styled_no_double_wrap() {
4072 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4077 parser
4078 .component_registry
4079 .register_component(ComponentRegistration::new(
4080 "custom-element",
4081 r#"<template foo="bar"><slot></slot></template>"#,
4082 Some("div { color: red; }"),
4083 true,
4084 ))
4085 .expect("register");
4086 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4087 assert!(result.is_ok());
4088 let records = parser.into_fragment_records();
4089
4090 assert_stream!(
4091 records,
4092 "custom-element",
4093 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
4094 );
4095 }
4096
4097 #[test]
4098 fn test_component_strip_runtime_attrs() {
4099 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4104 parser
4105 .component_registry
4106 .register_component(ComponentRegistration::new(
4107 "custom-element",
4108 r#"<template @click={foo} :bar="baz" ?bool="true"><slot></slot></template>"#,
4109 None,
4110 true,
4111 ))
4112 .expect("register");
4113 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4114 assert!(result.is_ok());
4115 let records = parser.into_fragment_records();
4116
4117 assert_stream!(
4118 records,
4119 "custom-element",
4120 [raw("<template><slot></slot></template>"),]
4121 );
4122 }
4123
4124 #[test]
4125 fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
4126 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4127 parser
4128 .component_registry
4129 .register_component(ComponentRegistration::new(
4130 "custom-element",
4131 r#"<template data-note="@click={foo}" @click={foo}><slot></slot></template>"#,
4132 None,
4133 true,
4134 ))
4135 .expect("register");
4136 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4137 assert!(result.is_ok());
4138 let records = parser.into_fragment_records();
4139
4140 assert_stream!(
4141 records,
4142 "custom-element",
4143 [raw(
4144 r#"<template data-note="@click={foo}"><slot></slot></template>"#
4145 ),]
4146 );
4147 }
4148
4149 #[test]
4150 fn test_component_strip_runtime_attrs_handles_duplicate_runtime_attrs() {
4151 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4152 parser
4153 .component_registry
4154 .register_component(ComponentRegistration::new(
4155 "custom-element",
4156 r#"<template @click={foo} @click={bar}><slot></slot></template>"#,
4157 None,
4158 true,
4159 ))
4160 .expect("register");
4161 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4162 assert!(result.is_ok());
4163 let records = parser.into_fragment_records();
4164
4165 assert_stream!(
4166 records,
4167 "custom-element",
4168 [raw("<template><slot></slot></template>"),]
4169 );
4170 }
4171
4172 #[test]
4173 fn test_component_with_slots_and_attrs() {
4174 let mut parser = HtmlParser::new();
4175 parser
4176 .component_registry
4177 .register_component(ComponentRegistration::new(
4178 "custom-element",
4179 "<slot></slot>",
4180 None,
4181 true,
4182 ))
4183 .expect("register");
4184 let result = parser.parse(
4185 "index.html",
4186 r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
4187 );
4188 assert!(result.is_ok());
4189 let records = parser.into_fragment_records();
4190 assert_fragments!(
4191 records["index.html"].fragments,
4192 [
4193 raw("<custom-element"),
4194 attr_raw_start("appearance", "subtle"),
4195 structural_matcher("streaming_root:custom-element"),
4196 raw(">"),
4197 component("custom-element"),
4198 raw("Hello World</custom-element>"),
4199 ]
4200 );
4201 }
4202
4203 #[test]
4204 fn test_component_legacy_no_styles() {
4205 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4206 parser
4207 .component_registry
4208 .register_component(ComponentRegistration::new(
4209 "custom-element",
4210 "<div>Custom Element</div>",
4211 None,
4212 true,
4213 ))
4214 .expect("register");
4215 let result = parser.parse("index.html", "<custom-element></custom-element>");
4216 assert!(result.is_ok());
4217 let records = parser.into_fragment_records();
4218
4219 assert_fragments!(
4220 records["index.html"].fragments,
4221 [
4222 raw("<custom-element"),
4223 structural_matcher("streaming_root:custom-element"),
4224 raw(">"),
4225 component("custom-element"),
4226 raw("</custom-element>"),
4227 ]
4228 );
4229
4230 assert_stream!(
4231 records,
4232 "custom-element",
4233 [raw("<div>Custom Element</div>"),]
4234 );
4235 }
4236
4237 #[test]
4238 fn test_component_self_closing() {
4239 let mut parser = HtmlParser::new();
4240 parser
4241 .component_registry
4242 .register_component(ComponentRegistration::new(
4243 "custom-widget",
4244 "<div>Widget Content</div>",
4245 None,
4246 true,
4247 ))
4248 .expect("register");
4249 let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
4250 assert!(result.is_ok());
4251 let records = parser.into_fragment_records();
4252
4253 assert_fragments!(
4254 records["index.html"].fragments,
4255 [
4256 raw("<custom-widget"),
4257 attr_start("config", "settings"),
4258 structural_matcher("streaming_root:custom-widget"),
4259 raw("/>"),
4260 component("custom-widget"),
4261 ]
4262 );
4263 }
4264
4265 #[test]
4266 fn test_component_nested_self_closing_in_slot() {
4267 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4268 parser
4269 .component_registry
4270 .register_component(ComponentRegistration::new(
4271 "custom-icon",
4272 "<svg><slot></slot></svg>",
4273 None,
4274 true,
4275 ))
4276 .expect("register");
4277 let result = parser.parse(
4278 "index.html",
4279 r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
4280 );
4281 assert!(result.is_ok());
4282 let records = parser.into_fragment_records();
4283
4284 assert_fragments!(
4285 records["index.html"].fragments,
4286 [
4287 raw("<custom-icon"),
4288 structural_matcher("streaming_root:custom-icon"),
4289 raw(">"),
4290 component("custom-icon"),
4291 raw("<use"),
4292 attr_template("href", "attr-1"),
4293 raw("/></custom-icon>"),
4294 ]
4295 );
4296
4297 assert_stream!(records, "custom-icon", [raw("<svg><slot></slot></svg>"),]);
4298 }
4299
4300 #[test]
4301 fn test_component_leading_boolean_attr_start() {
4302 let mut parser = HtmlParser::new();
4303 parser
4304 .component_registry
4305 .register_component(ComponentRegistration::new(
4306 "custom-element",
4307 "<slot></slot>",
4308 None,
4309 true,
4310 ))
4311 .expect("register");
4312 let result = parser.parse(
4313 "index.html",
4314 r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
4315 );
4316 assert!(result.is_ok());
4317 let records = parser.into_fragment_records();
4318
4319 assert_fragments!(
4320 records["index.html"].fragments,
4321 [
4322 raw("<custom-element"),
4323 bool_attr_start("disabled", "isDisabled"),
4325 attr_raw("title", "Hello"),
4327 structural_matcher("streaming_root:custom-element"),
4328 raw(">"),
4329 component("custom-element"),
4330 raw("</custom-element>"),
4331 ]
4332 );
4333 }
4334
4335 #[test]
4336 fn test_component_boolean_predicate_preserves_condition_tree() {
4337 let mut parser = HtmlParser::new();
4338 parser
4339 .component_registry
4340 .register_component(ComponentRegistration::new(
4341 "custom-element",
4342 "<slot></slot>",
4343 None,
4344 true,
4345 ))
4346 .expect("register");
4347 let result = parser.parse(
4348 "index.html",
4349 r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
4350 );
4351 assert!(result.is_ok());
4352
4353 let records = parser.into_fragment_records();
4354 let fragments = &records["index.html"].fragments;
4355 match fragments
4356 .get(1)
4357 .and_then(|fragment| fragment.fragment.as_ref())
4358 {
4359 Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
4360 assert_eq!(attr.name, "disabled");
4361 assert!(attr.attr_start);
4362 match attr
4363 .condition_tree
4364 .as_ref()
4365 .and_then(|condition| condition.expr.as_ref())
4366 {
4367 Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
4368 assert_eq!(pred.left, "page");
4369 assert_eq!(pred.operator, 3);
4370 assert_eq!(pred.right, "'dashboard'");
4371 }
4372 other => panic!("expected predicate condition tree, got {:?}", other),
4373 }
4374 }
4375 other => panic!("expected attribute fragment, got {:?}", other),
4376 }
4377 }
4378
4379 #[test]
4380 fn test_component_meta_link_tags() {
4381 let (fragments, _) = parse_and_get_fragments(
4382 r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
4383 );
4384 assert!(fragments.len() >= 7);
4385 assert!(
4386 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "<head>")
4387 );
4388 assert!(
4389 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
4390 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
4391 );
4392 assert!(
4393 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<meta charset=\"utf-8\"") && raw.value.contains("<link"))
4394 );
4395 assert!(
4396 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
4397 );
4398 assert!(
4399 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
4400 );
4401 assert!(
4402 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
4403 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
4404 );
4405 assert!(
4406 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
4407 );
4408 }
4409
4410 #[test]
4411 fn test_nested_directives() {
4412 let mut parser = HtmlParser::new();
4413 let html = r#"<for each="category in categories">
4414 <if condition="category.hasItems">
4415 <for each="item in category.items">
4416 {{item.title}}
4417 </for>
4418 </if>
4419 </for>"#;
4420
4421 let result = parser.parse("test.html", html);
4422
4423 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4424 let fragment_records = parser.into_fragment_records();
4425
4426 assert_stream!(
4427 fragment_records,
4428 "test.html",
4429 [for_loop("category", "categories", "for-1"),]
4430 );
4431
4432 assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
4433
4434 assert_stream!(
4435 fragment_records,
4436 "if-1",
4437 [for_loop("item", "category.items", "for-2"),]
4438 );
4439
4440 assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
4441 }
4442
4443 #[test]
4444 fn test_complex_directives() {
4445 let mut parser = HtmlParser::new();
4446 let html = r#"<for each="category in categories">
4447 <div class="category">
4448 <h2>{{category.name}}</h2>
4449 <if condition="category.hasItems">
4450 <ul>
4451 <for each="item in category.items">
4452 <li>{{item.title}}</li>
4453 </for>
4454 </ul>
4455 </if>
4456 </div>
4457 </for>"#;
4458
4459 let result = parser.parse("test.html", html);
4460
4461 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4462 let fragment_records = parser.into_fragment_records();
4463
4464 assert_stream!(
4465 fragment_records,
4466 "test.html",
4467 [for_loop("category", "categories", "for-1"),]
4468 );
4469
4470 assert_stream!(
4472 fragment_records,
4473 "for-1",
4474 [
4475 raw("<div class=\"category\"><h2>"),
4476 signal("category.name"),
4477 raw("</h2>"),
4478 if_cond("if-1"),
4479 raw("</div>"),
4480 ]
4481 );
4482
4483 assert_stream!(
4485 fragment_records,
4486 "if-1",
4487 [
4488 raw("<ul>"),
4489 for_loop("item", "category.items", "for-2"),
4490 raw("</ul>"),
4491 ]
4492 );
4493
4494 assert_stream!(
4496 fragment_records,
4497 "for-2",
4498 [raw("<li>"), signal("item.title"), raw("</li>"),]
4499 );
4500 }
4501
4502 fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
4506 let mut parser = HtmlParser::new();
4507 let result = parser.parse("index.html", html);
4508 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4509 let records = parser.into_fragment_records();
4510 let fragments = records
4511 .get("index.html")
4512 .expect("Failed to get index.html fragment")
4513 .fragments
4514 .clone();
4515 (fragments, records)
4516 }
4517
4518 fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
4520 let mut parser = HtmlParser::new();
4521 parser
4522 .component_registry
4523 .register_component(ComponentRegistration::new(tag, "<div></div>", None, true))
4524 .expect("register");
4525 let result = parser.parse("index.html", html);
4526 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4527 let records = parser.into_fragment_records();
4528 let fragments = records
4529 .get("index.html")
4530 .expect("Failed to get index.html fragment")
4531 .fragments
4532 .clone();
4533 (fragments, records)
4534 }
4535
4536 #[test]
4537 fn test_attribute_handlebars_in_href() {
4538 let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
4540 assert_fragments!(
4541 fragments,
4542 [
4543 raw("<a"),
4544 attr("href", "url"),
4545 raw(">"),
4546 signal("name"),
4547 raw("</a>"),
4548 ]
4549 );
4550 }
4551
4552 #[test]
4553 fn test_attribute_boolean_with_handlebars() {
4554 let (fragments, _) =
4556 parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
4557 assert_fragments!(
4558 fragments,
4559 [
4560 raw("<button"),
4561 bool_attr("disabled", "isDisabled"),
4562 raw(">Click</button>"),
4563 ]
4564 );
4565 }
4566
4567 #[test]
4568 fn test_attribute_multiple_boolean() {
4569 let (fragments, _) =
4572 parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
4573
4574 assert_fragments!(
4575 fragments,
4576 [
4577 raw("<input"),
4578 bool_attr("checked", "isChecked"),
4579 bool_attr("disabled", "isDisabled"),
4580 raw("/>"),
4581 ]
4582 );
4583 }
4584
4585 #[test]
4586 fn test_attribute_boolean_and_regular_together() {
4587 let (fragments, _) = parse_and_get_fragments(
4590 r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
4591 );
4592
4593 assert_fragments!(
4594 fragments,
4595 [
4596 raw("<button"),
4597 bool_attr("disabled", "isDisabled"),
4598 raw(" type=\"button\">Hi</button>"),
4599 ]
4600 );
4601 }
4602
4603 #[test]
4604 fn test_attribute_boolean_sandwiched() {
4605 let (fragments, _) = parse_and_get_fragments(
4608 r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
4609 );
4610
4611 assert_fragments!(
4612 fragments,
4613 [
4614 raw("<button"),
4615 attr("version", "edition"),
4616 bool_attr("disabled", "isDisabled"),
4617 raw(" type=\"button\">Hi</button>"),
4618 ]
4619 );
4620 }
4621
4622 #[test]
4623 fn test_attribute_boolean_ending() {
4624 let (fragments, _) = parse_and_get_fragments(
4627 r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
4628 );
4629
4630 assert_fragments!(
4631 fragments,
4632 [
4633 raw("<button"),
4634 attr("version", "edition"),
4635 bool_attr("disabled", "isDisabled"),
4636 raw(">Hi</button>"),
4637 ]
4638 );
4639 }
4640
4641 #[test]
4642 fn test_attribute_boolean_dotted_path() {
4643 let (fragments, _) =
4646 parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
4647
4648 assert_fragments!(
4649 fragments,
4650 [
4651 raw("<div"),
4652 bool_attr("checked", "layout.isPinned"),
4653 raw(">Content</div>"),
4654 ]
4655 );
4656 }
4657
4658 #[test]
4659 fn test_attribute_colon_prefixed_complex() {
4660 let (fragments, _) = parse_with_component(
4663 "my-component",
4664 r#"<my-component :config="{{settings}}"></my-component>"#,
4665 );
4666
4667 assert_fragments!(
4668 fragments,
4669 [
4670 raw("<my-component"),
4671 attr_complex_start(":config", "settings"),
4672 structural_matcher("streaming_root:my-component"),
4673 raw(">"),
4674 component("my-component"),
4675 raw("</my-component>"),
4676 ]
4677 );
4678 }
4679
4680 #[test]
4681 fn test_attribute_multiple_colon_prefixed() {
4682 let (fragments, _) = parse_with_component(
4685 "my-component",
4686 r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
4687 );
4688
4689 assert_fragments!(
4690 fragments,
4691 [
4692 raw("<my-component"),
4693 attr_complex_start(":prop1", "val1"),
4694 attr_complex(":prop2", "val2"),
4695 structural_matcher("streaming_root:my-component"),
4696 raw(">"),
4697 component("my-component"),
4698 raw("</my-component>"),
4699 ]
4700 );
4701 }
4702
4703 #[test]
4704 fn test_blocked_complex_property_innerhtml() {
4705 let mut parser = HtmlParser::new();
4706 let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
4707 assert!(
4708 result.is_err(),
4709 "Expected error for :innerHTML on native element"
4710 );
4711 let err = result.unwrap_err().to_string();
4712 assert!(
4713 err.contains("only allowed on custom elements"),
4714 "Error: {err}"
4715 );
4716 }
4717
4718 #[test]
4719 fn test_blocked_complex_on_native_element() {
4720 let mut parser = HtmlParser::new();
4721 let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
4722 assert!(
4723 result.is_err(),
4724 "Expected error for :data on native element"
4725 );
4726 let err = result.unwrap_err().to_string();
4727 assert!(
4728 err.contains("only allowed on custom elements"),
4729 "Error: {err}"
4730 );
4731 }
4732
4733 #[test]
4734 fn test_blocked_complex_property_on_component() {
4735 let mut parser = HtmlParser::new();
4736 parser
4737 .component_registry
4738 .register_component(ComponentRegistration::new(
4739 "my-widget",
4740 "<div></div>",
4741 None,
4742 true,
4743 ))
4744 .expect("register");
4745 let result = parser.parse(
4746 "index.html",
4747 r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
4748 );
4749 assert!(
4750 result.is_err(),
4751 "Expected error for :innerHTML on component"
4752 );
4753 let err = result.unwrap_err().to_string();
4754 assert!(err.contains("HTML injection"), "Error: {err}");
4755 }
4756
4757 #[test]
4758 fn test_allowed_complex_property() {
4759 let (fragments, _) = parse_with_component(
4761 "my-component",
4762 r#"<my-component :config="{{settings}}"></my-component>"#,
4763 );
4764 assert_fragments!(
4765 fragments,
4766 [
4767 raw("<my-component"),
4768 attr_complex_start(":config", "settings"),
4769 structural_matcher("streaming_root:my-component"),
4770 raw(">"),
4771 component("my-component"),
4772 raw("</my-component>"),
4773 ]
4774 );
4775 }
4776
4777 #[test]
4778 fn test_attribute_mixed_normal_boolean_colon() {
4779 let (fragments, _) = parse_with_component(
4782 "my-component",
4783 r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
4784 );
4785
4786 assert_fragments!(
4787 fragments,
4788 [
4789 raw("<my-component"),
4790 attr_raw_start("id", "comp"),
4791 attr_complex(":config", "settings"),
4792 bool_attr("enabled", "isEnabled"),
4793 structural_matcher("streaming_root:my-component"),
4794 raw(">"),
4795 component("my-component"),
4796 raw("</my-component>"),
4797 ]
4798 );
4799 }
4800
4801 #[test]
4802 fn test_attribute_reject_boolean_without_handlebars() {
4803 let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
4806
4807 assert_fragments!(fragments, [raw("<button></button>"),]);
4809 }
4810
4811 #[test]
4812 fn test_attribute_reject_boolean_with_partial_handlebars() {
4813 let (fragments, _) =
4816 parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
4817
4818 assert_fragments!(fragments, [raw("<button></button>"),]);
4820 }
4821
4822 #[test]
4823 fn test_attribute_reject_boolean_with_plain_value() {
4824 let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
4827
4828 assert_fragments!(fragments, [raw("<button>Click</button>"),]);
4830 }
4831
4832 #[test]
4833 fn test_attribute_boolean_predicate_equal() {
4834 let (fragments, _) =
4836 parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
4837 assert_fragments!(
4838 fragments,
4839 [
4840 raw("<div"),
4841 bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
4842 raw(">X</div>"),
4843 ]
4844 );
4845 }
4846
4847 #[test]
4848 fn test_attribute_boolean_predicate_greater_than() {
4849 let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
4851 assert_fragments!(
4852 fragments,
4853 [
4854 raw("<span"),
4855 bool_attr_predicate("hidden", "num", 1, "9"),
4856 raw(">X</span>"),
4857 ]
4858 );
4859 }
4860
4861 #[test]
4862 fn test_attribute_boolean_predicate_not_equal() {
4863 let (fragments, _) =
4865 parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
4866 assert_fragments!(
4867 fragments,
4868 [
4869 raw("<a"),
4870 bool_attr_predicate("data-active", "status", 4, "'inactive'"),
4871 raw(">X</a>"),
4872 ]
4873 );
4874 }
4875
4876 #[test]
4877 fn test_attribute_boolean_negation() {
4878 let (fragments, _) =
4880 parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
4881 assert_fragments!(
4882 fragments,
4883 [
4884 raw("<button"),
4885 bool_attr_not("disabled", "isReady"),
4886 raw(">X</button>"),
4887 ]
4888 );
4889 }
4890
4891 #[test]
4892 fn test_attribute_mixed_static_dynamic() {
4893 let (fragments, records) =
4896 parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
4897
4898 assert_fragments!(
4899 fragments,
4900 [
4901 raw("<textarea"),
4902 attr_template("value", "attr-1"),
4903 raw(">Hi</textarea>"),
4904 ]
4905 );
4906
4907 assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
4909 }
4910
4911 #[test]
4914 fn test_body_signals() {
4915 let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
4916 assert_fragments!(
4917 fragments,
4918 [
4919 raw("<body>"),
4920 structural_matcher("body_start"),
4921 raw("<app-shell></app-shell>"),
4922 structural_matcher("body_end"),
4923 raw("</body>"),
4924 ]
4925 );
4926 }
4927
4928 #[test]
4931 fn test_empty_for_produces_nothing() {
4932 let (fragments, records) =
4933 parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
4934 assert_fragments!(fragments, [raw("<div></div>"),]);
4935 assert!(!records.contains_key("for-1"));
4936 }
4937
4938 #[test]
4941 fn test_self_closing_svg_path() {
4942 let (fragments, _) =
4943 parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
4944 assert_fragments!(
4945 fragments,
4946 [raw(
4947 r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
4948 ),]
4949 );
4950 }
4951
4952 #[test]
4953 fn test_html5_void_elements() {
4954 let (fragments, _) = parse_and_get_fragments(
4955 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
4956 );
4957 assert_fragments!(
4958 fragments,
4959 [raw(
4960 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
4961 ),]
4962 );
4963 }
4964
4965 #[test]
4966 fn test_self_closing_with_dynamic_attributes() {
4967 let (fragments, _) =
4968 parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
4969 assert_fragments!(
4970 fragments,
4971 [
4972 raw("<img"),
4973 attr("src", "imageUrl"),
4974 attr("alt", "imageAlt"),
4975 raw("/>"),
4976 ]
4977 );
4978 }
4979
4980 #[test]
4981 fn test_self_closing_with_boolean_attributes() {
4982 let (fragments, _) = parse_and_get_fragments(
4983 r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
4984 );
4985 assert_fragments!(
4986 fragments,
4987 [
4988 raw("<input type=\"checkbox\""),
4989 bool_attr("checked", "isSelected"),
4990 bool_attr("disabled", "isDisabled"),
4991 raw("/>"),
4992 ]
4993 );
4994 }
4995
4996 #[test]
4997 fn test_multiple_self_closing_in_sequence() {
4998 let (fragments, _) =
4999 parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
5000 assert_fragments!(
5001 fragments,
5002 [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
5003 );
5004 }
5005
5006 #[test]
5007 fn test_self_closing_with_mixed_content() {
5008 let (fragments, _) =
5009 parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
5010 assert_fragments!(
5011 fragments,
5012 [
5013 raw("<div>Text before<img"),
5014 attr("src", "url"),
5015 raw("/>Text after</div>"),
5016 ]
5017 );
5018 }
5019
5020 #[test]
5021 fn test_self_closing_svg_elements() {
5022 let (fragments, _) = parse_and_get_fragments(
5023 r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
5024 );
5025 assert_fragments!(
5026 fragments,
5027 [
5028 raw("<svg><circle"),
5029 attr("cx", "x"),
5030 attr("cy", "y"),
5031 raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
5032 ]
5033 );
5034 }
5035
5036 #[test]
5037 fn test_self_closing_inside_for_loop() {
5038 let (fragments, records) = parse_and_get_fragments(
5039 r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
5040 );
5041 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
5042 assert_stream!(
5043 records,
5044 "for-1",
5045 [raw("<img"), attr("src", "item.url"), raw("/>"),]
5046 );
5047 }
5048
5049 #[test]
5050 fn test_self_closing_whitespace_variations() {
5051 let (fragments, _) =
5052 parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
5053 assert_fragments!(
5054 fragments,
5055 [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
5056 );
5057 }
5058
5059 #[test]
5060 fn test_deeply_nested_self_closing() {
5061 let (fragments, _) = parse_and_get_fragments(
5062 r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
5063 );
5064 assert_fragments!(
5065 fragments,
5066 [raw(
5067 r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
5068 ),]
5069 );
5070 }
5071
5072 #[test]
5073 fn test_self_closing_vs_empty_regular_tags() {
5074 let (fragments, _) =
5075 parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
5076 assert_fragments!(
5077 fragments,
5078 [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
5079 );
5080 }
5081
5082 #[test]
5085 fn test_for_custom_template_attribute() {
5086 let (fragments, records) = parse_and_get_fragments(
5088 r#"<for each="item in items" template="static"><span>Item</span></for>"#,
5089 );
5090 assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
5091 assert_stream!(records, "static", [raw("<span>Item</span>"),]);
5092 }
5093
5094 #[test]
5095 fn test_for_recursive_template() {
5096 let mut parser = HtmlParser::new();
5098 let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
5099 let result = parser.parse("index.html", html);
5100 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5101 let records = parser.into_fragment_records();
5102
5103 assert_fragments!(
5104 records["index.html"].fragments,
5105 [for_loop("outerItem", "outerItems", "static"),]
5106 );
5107
5108 assert_stream!(
5109 records,
5110 "static",
5111 [
5112 raw("<div><span>"),
5113 signal("outerItem.name"),
5114 raw("</span>"),
5115 for_loop("innerItem", "innerItems", "static"),
5116 raw("</div>"),
5117 ]
5118 );
5119 }
5120
5121 #[test]
5124 fn test_if_multiple_children() {
5125 let (fragments, records) =
5127 parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
5128 assert_fragments!(fragments, [if_cond("if-1"),]);
5129 assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
5130 }
5131
5132 #[test]
5133 fn test_for_multiple_children() {
5134 let (fragments, records) =
5136 parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
5137 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
5138 assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
5139 }
5140
5141 #[test]
5144 fn test_handlebars_at_beginning() {
5145 let (fragments, _) = parse_and_get_fragments("{{first}}");
5147 assert_fragments!(fragments, [signal("first"),]);
5148 }
5149
5150 #[test]
5151 fn test_handlebars_at_beginning_and_raw() {
5152 let (fragments, _) = parse_and_get_fragments("{{first}}test");
5154 assert_fragments!(fragments, [signal("first"), raw("test"),]);
5155 }
5156
5157 #[test]
5158 fn test_handlebars_raw_and_end() {
5159 let (fragments, _) = parse_and_get_fragments("test{{first}}");
5161 assert_fragments!(fragments, [raw("test"), signal("first"),]);
5162 }
5163
5164 #[test]
5167 fn test_handlebars_invalid_triple_open() {
5168 let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
5170 assert_fragments!(fragments, [raw("{{{invalid}}"),]);
5171 }
5172
5173 #[test]
5174 fn test_handlebars_four_open_braces() {
5175 let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
5177 assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
5178 }
5179
5180 #[test]
5181 fn test_handlebars_five_open_with_valid_double() {
5182 let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
5184 assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
5185 }
5186
5187 #[test]
5188 fn test_entities_preserved() {
5189 let (fragments, _) = parse_and_get_fragments("<p>Hello}World</p>");
5191 assert_fragments!(fragments, [raw("<p>Hello}World</p>"),]);
5192 }
5193
5194 #[test]
5197 fn test_doctype_preserved() {
5198 let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
5200 assert!(!fragments.is_empty());
5201 assert!(
5202 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
5203 );
5204 }
5205
5206 #[test]
5209 fn test_component_attr_skip() {
5210 let mut parser = HtmlParser::new();
5212 parser
5213 .component_registry
5214 .register_component(ComponentRegistration::new(
5215 "custom-element",
5216 "<slot></slot>",
5217 None,
5218 true,
5219 ))
5220 .expect("register");
5221 let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
5222 let result = parser.parse("index.html", html);
5223 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5224 let records = parser.into_fragment_records();
5225
5226 assert_fragments!(
5229 records["index.html"].fragments,
5230 [
5231 raw("<custom-element"),
5232 attr_complex_start(":config", "config"),
5234 attr_skip("class", "value0"),
5236 attr_skip("style", "value1"),
5237 attr_skip("role", "value2"),
5238 attr_skip("data-test", "value3"),
5239 attr_skip("aria-test", "value4"),
5240 structural_matcher("streaming_root:custom-element"),
5241 raw(">"),
5242 component("custom-element"),
5243 raw("</custom-element>"),
5244 ]
5245 );
5246 }
5247
5248 #[test]
5249 fn test_component_attr_skip_static_and_embedded() {
5250 let mut parser = HtmlParser::new();
5253 parser
5254 .component_registry
5255 .register_component(ComponentRegistration::new(
5256 "item-group",
5257 "<slot></slot>",
5258 None,
5259 true,
5260 ))
5261 .expect("register");
5262
5263 let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
5264 let result = parser.parse("index.html", html);
5265 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5266 let records = parser.into_fragment_records();
5267
5268 assert_fragments!(
5269 records["index.html"].fragments,
5270 [
5271 raw("<item-group"),
5272 attr_skip_raw("role", "list"),
5273 attr_skip_template("aria-labelledby", "attr-1"),
5274 attr_skip_template("data-testid", "attr-2"),
5275 attr_skip_raw("class", "fixed-class"),
5276 structural_matcher("streaming_root:item-group"),
5277 raw(">"),
5278 component("item-group"),
5279 raw("</item-group>"),
5280 ]
5281 );
5282
5283 assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
5286 assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
5287 }
5288
5289 #[test]
5290 fn test_component_multiple_nested() {
5291 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5293 parser
5294 .component_registry
5295 .register_component(ComponentRegistration::new(
5296 "custom-element",
5297 "<custom-child></custom-child><slot></slot>",
5298 None,
5299 true,
5300 ))
5301 .expect("register");
5302 parser
5303 .component_registry
5304 .register_component(ComponentRegistration::new(
5305 "custom-button",
5306 "<slot></slot>",
5307 None,
5308 true,
5309 ))
5310 .expect("register");
5311 parser
5312 .component_registry
5313 .register_component(ComponentRegistration::new(
5314 "custom-child",
5315 "<h1>Hello World!</h1>",
5316 None,
5317 true,
5318 ))
5319 .expect("register");
5320
5321 let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
5322 let result = parser.parse("index.html", html);
5323 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5324 let records = parser.into_fragment_records();
5325
5326 assert_fragments!(
5328 records["index.html"].fragments,
5329 [for_loop("item", "items", "for-1"),]
5330 );
5331
5332 assert_stream!(
5334 records,
5335 "for-1",
5336 [
5337 raw("<custom-element"),
5338 structural_matcher("streaming_root:custom-element"),
5339 raw(">"),
5340 component("custom-element"),
5341 raw("<custom-button"),
5342 structural_matcher("streaming_root:custom-button"),
5343 raw(">"),
5344 component("custom-button"),
5345 raw("Ok</custom-button></custom-element>"),
5346 ]
5347 );
5348
5349 let ce = &records["custom-element"].fragments;
5351 assert_eq!(ce.len(), 5);
5352 assert!(
5353 matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child"))
5354 );
5355 assert!(
5356 matches!(ce[3].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
5357 );
5358 assert!(
5359 matches!(ce[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><slot></slot>"))
5360 );
5361
5362 assert_stream!(records, "custom-button", [raw("<slot></slot>"),]);
5363
5364 assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
5365 }
5366
5367 #[test]
5370 fn test_invalid_markup_returns_error() {
5371 let mut parser = HtmlParser::new();
5373 let result = parser.parse("index.html", "<div><span>Unclosed div");
5374 assert!(matches!(result, Err(ParserError::Template(diag)) if
5375 diag.to_string().contains("unclosed <div> tag")
5376 ));
5377 }
5378
5379 #[test]
5380 fn test_unterminated_opening_tag_returns_error() {
5381 let mut parser = HtmlParser::new();
5382 let result = parser.parse("index.html", "before <div");
5383 assert!(matches!(result, Err(ParserError::Template(diag)) if
5384 diag.to_string().contains("malformed HTML tag")
5385 ));
5386 }
5387
5388 #[test]
5389 fn test_unterminated_html_comment_returns_error() {
5390 let mut parser = HtmlParser::new();
5391 let result = parser.parse("index.html", "<div><!-- missing close</div>");
5392 assert!(matches!(result, Err(ParserError::Template(diag)) if
5393 diag.to_string().contains("unclosed <div> tag")
5394 || diag.to_string().contains("unterminated HTML comment")
5395 ));
5396 }
5397
5398 #[test]
5399 fn test_unexpected_closing_tag_returns_error() {
5400 let mut parser = HtmlParser::new();
5401 let result = parser.parse("index.html", "</span>");
5402 assert!(matches!(result, Err(ParserError::Template(diag)) if
5403 diag.to_string().contains("unexpected closing tag </span>")
5404 ));
5405 }
5406
5407 #[test]
5408 fn test_rejects_templates_over_size_limit() {
5409 let mut parser = HtmlParser::new();
5410 let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
5411 let result = parser.parse("index.html", &html);
5412
5413 assert!(matches!(result, Err(ParserError::Html(message)) if
5414 message.contains("exceeds") && message.contains("parser limit")
5415 ));
5416 }
5417
5418 #[test]
5419 fn test_rejects_templates_over_depth_limit() {
5420 let mut parser = HtmlParser::new();
5421 let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
5422 for _ in 0..=MAX_TEMPLATE_DEPTH {
5423 html.push_str("<div>");
5424 }
5425 for _ in 0..=MAX_TEMPLATE_DEPTH {
5426 html.push_str("</div>");
5427 }
5428
5429 let result = parser.parse("index.html", &html);
5430
5431 assert!(matches!(result, Err(ParserError::Template(diag)) if
5432 diag.to_string().contains("nesting exceeds")
5433 ));
5434 }
5435
5436 #[test]
5437 fn test_rejects_routes_over_depth_limit() {
5438 let mut parser = HtmlParser::new();
5439 parser
5440 .component_registry
5441 .register_component(ComponentRegistration::new(
5442 "route-page",
5443 "<slot></slot>",
5444 None,
5445 true,
5446 ))
5447 .expect("register");
5448
5449 let depth = MAX_TEMPLATE_DEPTH + 2;
5450 let mut html = String::with_capacity(depth * 48);
5451 for _ in 0..depth {
5452 html.push_str(r#"<route path="x" component="route-page">"#);
5453 }
5454 for _ in 0..depth {
5455 html.push_str("</route>");
5456 }
5457
5458 let result = parser.parse("index.html", &html);
5459
5460 assert!(matches!(result, Err(ParserError::Template(diag)) if
5461 diag.error_code() == Some(codes::EXCESSIVE_NESTING)
5462 && diag.to_string().contains("route nesting exceeds")
5463 ));
5464 }
5465
5466 #[test]
5467 fn test_rejects_recursive_component_templates() {
5468 let mut parser = HtmlParser::new();
5469 parser
5470 .component_registry
5471 .register_component(ComponentRegistration::new(
5472 "self-card",
5473 "<self-card></self-card>",
5474 None,
5475 true,
5476 ))
5477 .expect("register");
5478
5479 let result = parser.parse("index.html", "<self-card></self-card>");
5480
5481 assert!(matches!(result, Err(ParserError::Template(diag)) if
5482 diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
5483 && diag.to_string().contains("recursive template reference")
5484 ));
5485 }
5486
5487 #[test]
5490 fn test_complex_raw_text_full_page() {
5491 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>"#;
5493 let (fragments, _) = parse_and_get_fragments(html);
5494
5495 assert!(fragments.len() >= 9);
5498 assert!(
5499 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5500 raw.value.contains("<!DOCTYPE HTML>") && raw.value.ends_with("<head>"))
5501 );
5502 assert!(
5503 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
5504 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
5505 );
5506 assert!(
5507 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5508 raw.value.contains("<title>Test</title>"))
5509 );
5510 assert!(
5511 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
5512 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
5513 );
5514 assert!(
5515 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5516 raw.value.contains("</head>") && raw.value.ends_with("<body>"))
5517 );
5518 assert!(
5519 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
5520 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start"))
5521 );
5522 assert!(
5523 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5524 raw.value.contains("<app-shell>"))
5525 );
5526 assert!(
5527 matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
5528 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end"))
5529 );
5530 assert!(
5531 matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5532 raw.value.contains("</body>") && raw.value.contains("</html>"))
5533 );
5534 }
5535
5536 #[test]
5537 fn test_css_strategy_external_emits_link_tag() {
5538 let mut parser = HtmlParser::new();
5539 parser
5540 .component_registry_mut()
5541 .register_component(ComponentRegistration::new(
5542 "my-card",
5543 "<p><slot></slot></p>",
5544 Some("p { color: red; }"),
5545 true,
5546 ))
5547 .ok();
5548 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
5549 let records = parser.into_fragment_records();
5550 let my_card = &records["my-card"].fragments;
5551 let raw_text: String = my_card
5552 .iter()
5553 .filter_map(|f| match &f.fragment {
5554 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5555 _ => None,
5556 })
5557 .collect();
5558 assert!(
5559 raw_text.contains(r#"<link rel="stylesheet" href="my-card.css">"#),
5560 "Expected external <link> tag in: {}",
5561 raw_text
5562 );
5563 }
5564
5565 #[test]
5566 fn test_uppercase_style_element_is_processed_as_css() {
5567 let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
5572 assert!(
5573 fragments.iter().any(|f| matches!(
5574 f.fragment.as_ref(),
5575 Some(Fragment::Signal(s)) if s.value == "tokens" && !s.raw
5576 )),
5577 "uppercase <STYLE> should be CSS-processed, got: {:?}",
5578 fragments
5579 );
5580 let raw: String = fragments
5581 .iter()
5582 .filter_map(|f| match f.fragment.as_ref() {
5583 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5584 _ => None,
5585 })
5586 .collect();
5587 assert!(
5588 raw.contains("</STYLE>"),
5589 "closing tag should preserve source case, got: {raw}"
5590 );
5591 }
5592
5593 #[test]
5594 fn test_css_strategy_inline_emits_style_tag() {
5595 let mut parser = HtmlParser::with_options(CssStrategy::Style);
5596 parser
5597 .component_registry_mut()
5598 .register_component(ComponentRegistration::new(
5599 "my-card",
5600 "<p><slot></slot></p>",
5601 Some("p { color: red; }"),
5602 true,
5603 ))
5604 .ok();
5605 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
5606 let records = parser.into_fragment_records();
5607 let my_card = &records["my-card"].fragments;
5608 let raw_text: String = my_card
5609 .iter()
5610 .filter_map(|f| match &f.fragment {
5611 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5612 _ => None,
5613 })
5614 .collect();
5615 assert!(
5616 raw_text.contains("<style>p { color: red; }</style>"),
5617 "Expected inline <style> tag in: {}",
5618 raw_text
5619 );
5620 assert!(
5621 !raw_text.contains("<link"),
5622 "Should not have <link> tag in inline mode: {}",
5623 raw_text
5624 );
5625 }
5626
5627 #[test]
5628 fn test_css_strategy_module_emits_adopted_stylesheets() {
5629 let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
5630 parser
5631 .component_registry_mut()
5632 .register_component(ComponentRegistration::new(
5633 "my-card",
5634 "<p><slot></slot></p>",
5635 Some("p { color: red; }"),
5636 true,
5637 ))
5638 .ok();
5639 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
5640 let records = parser.into_fragment_records();
5641
5642 let my_card = &records["my-card"].fragments;
5645 let template_text: String = my_card
5646 .iter()
5647 .filter_map(|f| match &f.fragment {
5648 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5649 _ => None,
5650 })
5651 .collect();
5652 assert!(
5653 !template_text.contains("shadowrootadoptedstylesheets"),
5654 "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
5655 );
5656 assert!(
5657 !template_text.contains("<link"),
5658 "Should not have <link> in module mode: {template_text}"
5659 );
5660 assert!(
5664 !template_text.contains(r#"<style type="module""#),
5665 "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
5666 );
5667 assert!(
5668 !template_text.contains(r#"<script type="importmap""#),
5669 "CSS module importmap should NOT be in raw fragments: {template_text}"
5670 );
5671 }
5672
5673 #[test]
5674 fn test_css_strategy_module_no_css_no_adopted_attr() {
5675 let mut parser = HtmlParser::with_options(CssStrategy::Module);
5676 parser
5677 .component_registry_mut()
5678 .register_component(ComponentRegistration::new(
5679 "my-card",
5680 "<p><slot></slot></p>",
5681 None,
5682 true,
5683 ))
5684 .ok();
5685 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
5686 let records = parser.into_fragment_records();
5687 let my_card = &records["my-card"].fragments;
5688 let template_text: String = my_card
5689 .iter()
5690 .filter_map(|f| match &f.fragment {
5691 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5692 _ => None,
5693 })
5694 .collect();
5695 assert!(
5697 !template_text.contains("shadowrootadoptedstylesheets"),
5698 "Should not have adopted attr without CSS: {template_text}"
5699 );
5700 }
5701
5702 #[test]
5721 fn light_preserves_dev_template_with_static_attrs() {
5722 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5723 let processed = parser
5724 .process_component_template(
5725 r#"<template foo="bar"><div>hi</div></template>"#,
5726 None,
5727 None,
5728 )
5729 .expect("process failed");
5730 assert!(
5731 processed.contains(r#"<template foo="bar">"#),
5732 "[--dom=light] expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
5733 );
5734 assert!(
5735 processed.contains("</template>"),
5736 "[--dom=light] expected closing </template> preserved, got: {processed}"
5737 );
5738 }
5739
5740 #[test]
5741 fn light_preserves_dev_template_with_signal_fragment_attrs() {
5742 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5743 let processed = parser
5744 .process_component_template(
5745 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
5746 None,
5747 None,
5748 )
5749 .expect("process failed");
5750 assert!(
5751 processed.contains(r#"<template foo="{{foo}}">"#),
5752 "[--dom=light] expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
5753 );
5754 }
5755
5756 #[test]
5757 fn light_preserves_dev_template_with_multiple_attrs() {
5758 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5759 let processed = parser.process_component_template(
5760 r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
5761 None,
5762 None,
5763 )
5764 .expect("process failed");
5765 assert!(
5766 processed.contains("autofocus")
5767 && processed.contains(r#"tabindex="0""#)
5768 && processed.contains(r#"role="region""#)
5769 && processed.contains(r#"data-x="y""#),
5770 "[--dom=light] expected ALL dev template attrs preserved, got: {processed}"
5771 );
5772 }
5773
5774 #[test]
5775 fn light_no_template_no_wrapper_added() {
5776 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5777 let processed = parser
5778 .process_component_template("<div>hi</div>", None, None)
5779 .expect("process failed");
5780 assert!(
5781 !processed.contains("<template"),
5782 "[--dom=light] framework must NOT add <template> wrapper when dev omits one, got: {processed}"
5783 );
5784 assert!(
5785 processed.contains("<div>hi</div>"),
5786 "[--dom=light] expected inner content emitted as-is, got: {processed}"
5787 );
5788 }
5789
5790 #[test]
5791 fn shadow_preserves_dev_template_with_static_attrs() {
5792 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5793 let processed = parser
5794 .process_component_template(
5795 r#"<template foo="bar"><div>hi</div></template>"#,
5796 None,
5797 None,
5798 )
5799 .expect("process failed");
5800 assert!(
5801 processed.contains(r#"<template foo="bar">"#),
5802 "[--dom=shadow] dev <template foo=\"bar\"> must be preserved verbatim, got: {processed}"
5803 );
5804 assert!(
5805 !processed.contains(r#"shadowrootmode="open""#),
5806 "[--dom=shadow] framework must NOT inject shadowrootmode when dev already supplied a <template>, got: {processed}"
5807 );
5808 }
5809
5810 #[test]
5811 fn shadow_preserves_dev_template_with_shadowrootmode_closed() {
5812 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5813 let processed = parser
5814 .process_component_template(
5815 r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
5816 None,
5817 None,
5818 )
5819 .expect("process failed");
5820 assert!(
5821 processed.contains(r#"shadowrootmode="closed""#),
5822 "[--dom=shadow] framework must respect dev's shadowrootmode=\"closed\" (developer is managing), got: {processed}"
5823 );
5824 assert!(
5825 !processed.contains(r#"shadowrootmode="open""#),
5826 "[--dom=shadow] framework must not overwrite dev's shadowrootmode with \"open\", got: {processed}"
5827 );
5828 }
5829
5830 #[test]
5831 fn shadow_preserves_dev_template_with_signal_fragment_attrs() {
5832 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5833 let processed = parser
5834 .process_component_template(
5835 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
5836 None,
5837 None,
5838 )
5839 .expect("process failed");
5840 assert!(
5841 processed.contains(r#"<template foo="{{foo}}">"#),
5842 "[--dom=shadow] dev <template> with signal-fragment attr must be preserved, got: {processed}"
5843 );
5844 }
5845
5846 #[test]
5847 fn shadow_adds_template_wrapper_when_dev_omits_it() {
5848 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5849 let processed = parser
5850 .process_component_template("<div>hi</div>", None, None)
5851 .expect("process failed");
5852 assert!(
5853 processed.contains(r#"<template shadowrootmode="open""#),
5854 "[--dom=shadow] framework MUST add <template shadowrootmode=\"open\"> when dev omits a wrapper, got: {processed}"
5855 );
5856 assert!(
5857 processed.contains("<div>hi</div>"),
5858 "[--dom=shadow] inner content must survive wrapping, got: {processed}"
5859 );
5860 assert!(
5861 processed.contains("</template>"),
5862 "[--dom=shadow] framework-added wrapper must be closed, got: {processed}"
5863 );
5864 }
5865
5866 #[test]
5876 fn dev_template_module_strategy_appends_adopted_attr_when_missing() {
5877 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5878 let processed = parser
5879 .process_component_template(
5880 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
5881 None,
5882 Some("my-comp"),
5883 )
5884 .expect("process failed");
5885 assert!(
5886 processed.contains(r#"shadowrootmode="open""#),
5887 "dev-authored shadowrootmode must be preserved, got: {processed}"
5888 );
5889 assert!(
5890 processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5891 "module CSS should append adopted stylesheets, got: {processed}"
5892 );
5893 }
5894
5895 #[test]
5896 fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
5897 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5898 let processed = parser
5899 .process_component_template(
5900 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
5901 None,
5902 Some("my-comp"),
5903 )
5904 .unwrap();
5905 assert!(
5906 processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5907 "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
5908 );
5909 assert_eq!(
5910 processed.matches("shadowrootadoptedstylesheets").count(),
5911 1,
5912 "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
5913 );
5914 }
5915
5916 #[test]
5917 fn dev_template_module_strategy_appends_adopted_attr_and_preserves_root_attrs() {
5918 for dom_strategy in [DomStrategy::Shadow, DomStrategy::Light] {
5919 let mut parser = HtmlParser::with_options((CssStrategy::Module, dom_strategy));
5920 let built = match parser.build_component_templates(
5921 "my-comp",
5922 r#"<template shadowrootmode="open" @click="{onClick()}">Hello</template>"#,
5923 Some(":host { color: red; }"),
5924 true,
5925 ) {
5926 Ok(built) => built,
5927 Err(err) => panic!(
5928 "dev-authored <template> should be accepted under {dom_strategy:?} with module CSS, got: {err}"
5929 ),
5930 };
5931 let artifact = built.artifact();
5932
5933 assert!(
5934 artifact.contains(r#"shadowrootmode="open""#),
5935 "dev-authored shadowrootmode must be preserved under {dom_strategy:?}, got: {artifact}"
5936 );
5937 assert!(
5938 artifact.contains(r#"@click="{onClick()}""#),
5939 "dev-authored root event must be preserved under {dom_strategy:?}, got: {artifact}"
5940 );
5941 assert!(
5942 artifact.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5943 "module CSS should append adopted stylesheets under {dom_strategy:?}, got: {artifact}"
5944 );
5945 assert_eq!(
5946 artifact.matches("shadowrootadoptedstylesheets").count(),
5947 1,
5948 "module CSS should append adopted stylesheets once under {dom_strategy:?}, got: {artifact}"
5949 );
5950 assert!(
5951 !built.ssr.contains("@click"),
5952 "SSR template must still strip runtime attrs, got: {}",
5953 built.ssr
5954 );
5955 }
5956 }
5957
5958 #[test]
5959 fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
5960 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5965 let processed = parser
5966 .process_component_template(
5967 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
5968 None,
5969 Some("my-comp"),
5970 )
5971 .unwrap();
5972 assert!(
5973 processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
5974 "dev's multi-specifier list must be preserved verbatim, got: {processed}"
5975 );
5976 }
5977
5978 #[test]
5979 fn dev_template_no_adopted_specifier_does_not_validate() {
5980 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5984 let processed = parser
5985 .process_component_template(
5986 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
5987 None,
5988 None,
5989 )
5990 .unwrap();
5991 assert!(
5992 !processed.contains("shadowrootadoptedstylesheets"),
5993 "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
5994 );
5995 }
5996
5997 #[test]
6009 fn test_body_start_end_injection() {
6010 let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
6014 let (fragments, _) = parse_and_get_fragments(html);
6015
6016 assert_fragments!(
6017 fragments,
6018 [
6019 raw("<html><head>"),
6020 structural_matcher("head_start"),
6021 raw("<title>Test</title>"),
6022 structural_matcher("head_end"),
6023 raw("</head><body>"),
6024 structural_matcher("body_start"),
6025 raw("<div>Content</div><p>More</p>"),
6026 structural_matcher("body_end"),
6027 raw("</body></html>"),
6028 ]
6029 );
6030 }
6031
6032 #[test]
6033 fn test_body_preserves_static_attributes() {
6034 let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
6039 let (fragments, _) = parse_and_get_fragments(html);
6040
6041 assert_fragments!(
6042 fragments,
6043 [
6044 raw("<html><head>"),
6045 structural_matcher("head_start"),
6046 raw("<title>T</title>"),
6047 structural_matcher("head_end"),
6048 raw(r#"</head><body data-layout="doc" class="page">"#),
6049 structural_matcher("body_start"),
6050 raw("<p>x</p>"),
6051 structural_matcher("body_end"),
6052 raw("</body></html>"),
6053 ]
6054 );
6055 }
6056
6057 #[test]
6058 fn test_body_preserves_dynamic_attributes() {
6059 let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
6063 let (fragments, _) = parse_and_get_fragments(html);
6064
6065 assert_fragments!(
6066 fragments,
6067 [
6068 raw("<html><head>"),
6069 structural_matcher("head_start"),
6070 raw("<title>T</title>"),
6071 structural_matcher("head_end"),
6072 raw("</head><body"),
6073 attr("data-layout", "layout"),
6074 raw(">"),
6075 structural_matcher("body_start"),
6076 raw("<p>x</p>"),
6077 structural_matcher("body_end"),
6078 raw("</body></html>"),
6079 ]
6080 );
6081 }
6082
6083 #[test]
6084 fn test_head_start_signal_ordering_with_authored_script() {
6085 let html = r#"<html><head><script async src="./analytics.js"></script></head><body></body></html>"#;
6090 let (fragments, _) = parse_and_get_fragments(html);
6091
6092 assert_fragments!(
6093 fragments,
6094 [
6095 raw("<html><head>"),
6096 structural_matcher("head_start"),
6097 raw(r#"<script async src="./analytics.js"></script>"#),
6098 structural_matcher("head_end"),
6099 raw("</head><body>"),
6100 structural_matcher("body_start"),
6101 structural_matcher("body_end"),
6102 raw("</body></html>"),
6103 ]
6104 );
6105 }
6106
6107 #[test]
6108 fn test_head_static_attributes_preserve_legacy_opening_bytes() {
6109 let html = r#"<html><head data-theme="dark" class="app-head"><title>T</title></head><body></body></html>"#;
6113 let (fragments, _) = parse_and_get_fragments(html);
6114
6115 assert_fragments!(
6116 fragments,
6117 [
6118 raw("<html><head>"),
6119 structural_matcher("head_start"),
6120 raw("<title>T</title>"),
6121 structural_matcher("head_end"),
6122 raw("</head><body>"),
6123 structural_matcher("body_start"),
6124 structural_matcher("body_end"),
6125 raw("</body></html>"),
6126 ]
6127 );
6128 }
6129
6130 #[test]
6131 fn test_head_bound_attributes_preserve_legacy_opening_bytes() {
6132 let html =
6136 r#"<html><head data-theme="{{theme}}"><title>T</title></head><body></body></html>"#;
6137 let (fragments, _) = parse_and_get_fragments(html);
6138
6139 assert_fragments!(
6140 fragments,
6141 [
6142 raw("<html><head>"),
6143 structural_matcher("head_start"),
6144 raw("<title>T</title>"),
6145 structural_matcher("head_end"),
6146 raw("</head><body>"),
6147 structural_matcher("body_start"),
6148 structural_matcher("body_end"),
6149 raw("</body></html>"),
6150 ]
6151 );
6152 }
6153
6154 #[test]
6155 fn native_head_and_body_are_ascii_case_insensitive() {
6156 let html = r#"<html><HEAD data-theme="ignored"><title>T</title></HEAD><BODY data-layout="{{layout}}"><p>x</p></BODY></html>"#;
6157 let (fragments, _) = parse_and_get_fragments(html);
6158
6159 assert_fragments!(
6160 fragments,
6161 [
6162 raw(r#"<html><HEAD data-theme="ignored">"#),
6163 structural_matcher("head_start"),
6164 raw("<title>T</title>"),
6165 structural_matcher("head_end"),
6166 raw("</HEAD><BODY"),
6167 attr("data-layout", "layout"),
6168 raw(">"),
6169 structural_matcher("body_start"),
6170 raw("<p>x</p>"),
6171 structural_matcher("body_end"),
6172 raw("</BODY></html>"),
6173 ]
6174 );
6175 }
6176
6177 #[test]
6181 fn boundary_emits_no_wrapper_and_brackets_children_with_signals() {
6182 let html = r#"<body><div><boundary name="counter-ready"><my-counter></my-counter></boundary></div></body>"#;
6183 let (fragments, _) = parse_and_get_fragments(html);
6184
6185 assert_fragments!(
6189 fragments,
6190 [
6191 raw("<body>"),
6192 structural_matcher("body_start"),
6193 raw("<div>"),
6194 structural_matcher("boundary_start:0"),
6195 raw("<my-counter></my-counter>"),
6196 structural_matcher("boundary_end:0"),
6197 raw("</div>"),
6198 structural_matcher("body_end"),
6199 raw("</body>"),
6200 ]
6201 );
6202 }
6203
6204 #[test]
6205 fn boundary_sequence_increments_in_document_order_and_resets_per_entry() {
6206 let html = concat!(
6207 "<body>",
6208 r#"<boundary name="a"><p>1</p></boundary>"#,
6209 r#"<boundary name="b"><p>2</p></boundary>"#,
6210 "</body>",
6211 );
6212 let (fragments, _) = parse_and_get_fragments(html);
6213
6214 assert_fragments!(
6215 fragments,
6216 [
6217 raw("<body>"),
6218 structural_matcher("body_start"),
6219 structural_matcher("boundary_start:0"),
6220 raw("<p>1</p>"),
6221 structural_matcher("boundary_end:0"),
6222 structural_matcher("boundary_start:1"),
6223 raw("<p>2</p>"),
6224 structural_matcher("boundary_end:1"),
6225 structural_matcher("body_end"),
6226 raw("</body>"),
6227 ]
6228 );
6229
6230 let mut parser = HtmlParser::new();
6234 parser
6235 .parse(
6236 "first.html",
6237 r#"<body><boundary name="a"><p>1</p></boundary></body>"#,
6238 )
6239 .expect("first entry parses");
6240 parser
6241 .parse(
6242 "second.html",
6243 r#"<body><boundary name="a"><p>2</p></boundary></body>"#,
6244 )
6245 .expect("second entry reuses the name and sequence 0");
6246 let records = parser.into_fragment_records();
6247 assert_stream!(
6248 records,
6249 "second.html",
6250 [
6251 raw("<body>"),
6252 structural_matcher("body_start"),
6253 structural_matcher("boundary_start:0"),
6254 raw("<p>2</p>"),
6255 structural_matcher("boundary_end:0"),
6256 structural_matcher("body_end"),
6257 raw("</body>"),
6258 ]
6259 );
6260 }
6261
6262 #[test]
6263 fn boundary_missing_name_errors() {
6264 let mut parser = HtmlParser::new();
6265 let err = parser
6266 .parse("index.html", "<body><boundary><p>x</p></boundary></body>")
6267 .expect_err("a boundary without a name must error");
6268 let ParserError::Template(diag) = err else {
6269 panic!("expected ParserError::Template, got {err:?}");
6270 };
6271 assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_NAME));
6272 }
6273
6274 #[test]
6275 fn boundary_dynamic_name_errors() {
6276 let mut parser = HtmlParser::new();
6277 let err = parser
6278 .parse(
6279 "index.html",
6280 r#"<body><boundary name="{{sectionName}}"><p>x</p></boundary></body>"#,
6281 )
6282 .expect_err("a dynamic boundary name must error");
6283 let ParserError::Template(diag) = err else {
6284 panic!("expected ParserError::Template, got {err:?}");
6285 };
6286 assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_NAME));
6287 }
6288
6289 #[test]
6293 fn boundary_names_are_free_form_ordered_and_absent_from_fragments() {
6294 let mut parser = HtmlParser::new();
6295 let html = concat!(
6296 "<body>",
6297 r#"<boundary name="above the fold"><p>1</p></boundary>"#,
6298 r#"<boundary name="feed/items #2"><p>2</p></boundary>"#,
6299 r#"<boundary name="ダッシュボード"><p>3</p></boundary>"#,
6300 "</body>",
6301 );
6302 parser
6303 .parse("index.html", html)
6304 .expect("free-form boundary names must be accepted");
6305 assert_eq!(parser.boundary_count(), 3);
6306 assert_eq!(
6307 parser.boundary_names(),
6308 ["above the fold", "feed/items #2", "ダッシュボード"]
6309 );
6310
6311 let records = parser.into_fragment_records();
6312 let fragments = &records
6313 .get("index.html")
6314 .expect("index.html fragment record")
6315 .fragments;
6316
6317 let mut starts = Vec::new();
6318 for fragment in fragments {
6319 let Some(Fragment::Signal(signal)) = fragment.fragment.as_ref() else {
6320 continue;
6321 };
6322 let value = signal.value.as_str();
6323 assert!(
6324 !value.contains("above the fold")
6325 && !value.contains("feed/items")
6326 && !value.contains("ダッシュボード"),
6327 "boundary names must never reach a fragment: {value}"
6328 );
6329 if let Some(sequence) = value
6330 .strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
6331 .and_then(|signal| signal.strip_prefix("boundary_start:"))
6332 {
6333 starts.push(sequence.to_string());
6334 }
6335 }
6336 assert_eq!(
6337 starts,
6338 ["0", "1", "2"],
6339 "only integer sequences are emitted"
6340 );
6341 }
6342
6343 #[test]
6344 fn module_entry_srcs_records_only_preloadable_critical_entries() {
6345 let mut parser = HtmlParser::new();
6346 let html = concat!(
6347 "<head>",
6348 r#"<script type="module" async src="/index.js"></script>"#,
6350 r#"<SCRIPT type=" Module " src="/late.js"></SCRIPT>"#,
6354 r#"<script src="/legacy.js"></script>"#,
6356 r#"<script type="module">import "./x.js";</script>"#,
6358 r#"<script type="module" src="/{{bundle}}.js"></script>"#,
6360 "</head>",
6361 "<body>",
6362 r#"<boundary name="weather">"#,
6364 r#"<script type="module" async src="/weather-panel.js"></script>"#,
6365 "</boundary>",
6366 r#"<script type="module" src="/index.js"></script>"#,
6369 "</body>",
6370 );
6371 parser.parse("index.html", html).expect("parse");
6372
6373 assert_eq!(
6374 parser.module_entry_srcs(),
6375 ["/index.js", "/late.js"],
6376 "only static module entries outside boundaries are preloadable"
6377 );
6378 }
6379
6380 #[test]
6381 fn module_entry_srcs_reset_between_top_level_parses() {
6382 let mut parser = HtmlParser::new();
6383 parser
6384 .parse(
6385 "a.html",
6386 r#"<head><script type="module" src="/a.js"></script></head>"#,
6387 )
6388 .expect("parse a");
6389 assert_eq!(parser.module_entry_srcs(), ["/a.js"]);
6390
6391 parser
6392 .parse(
6393 "b.html",
6394 r#"<head><script type="module" src="/b.js"></script></head>"#,
6395 )
6396 .expect("parse b");
6397 assert_eq!(
6398 parser.module_entry_srcs(),
6399 ["/b.js"],
6400 "each entry owns its own critical modules"
6401 );
6402 }
6403
6404 #[test]
6405 fn boundary_duplicate_name_errors() {
6406 let mut parser = HtmlParser::new();
6407 let html = concat!(
6408 "<body>",
6409 r#"<boundary name="dup"><p>1</p></boundary>"#,
6410 r#"<boundary name="dup"><p>2</p></boundary>"#,
6411 "</body>",
6412 );
6413 let err = parser
6414 .parse("index.html", html)
6415 .expect_err("a duplicate boundary name must error");
6416 let ParserError::Template(diag) = err else {
6417 panic!("expected ParserError::Template, got {err:?}");
6418 };
6419 assert_eq!(diag.error_code(), Some(codes::DUPLICATE_BOUNDARY_NAME));
6420 }
6421
6422 #[test]
6423 fn boundary_nested_boundary_errors() {
6424 let mut parser = HtmlParser::new();
6425 let html = r#"<body><boundary name="outer"><boundary name="inner"><p>x</p></boundary></boundary></body>"#;
6426 let err = parser
6427 .parse("index.html", html)
6428 .expect_err("a nested boundary must error");
6429 let ParserError::Template(diag) = err else {
6430 panic!("expected ParserError::Template, got {err:?}");
6431 };
6432 assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
6433 }
6434
6435 #[test]
6436 fn boundary_inside_if_errors() {
6437 let mut parser = HtmlParser::new();
6438 let html =
6439 r#"<body><if condition="ready"><boundary name="x"><p>x</p></boundary></if></body>"#;
6440 let err = parser
6441 .parse("index.html", html)
6442 .expect_err("a boundary inside <if> must error");
6443 let ParserError::Template(diag) = err else {
6444 panic!("expected ParserError::Template, got {err:?}");
6445 };
6446 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6447 assert!(diag.to_string().contains("<if>"), "{diag}");
6448 }
6449
6450 #[test]
6451 fn boundary_inside_for_errors() {
6452 let mut parser = HtmlParser::new();
6453 let html = r#"<body><for each="item in items"><boundary name="x"><p>x</p></boundary></for></body>"#;
6454 let err = parser
6455 .parse("index.html", html)
6456 .expect_err("a boundary inside <for> must error");
6457 let ParserError::Template(diag) = err else {
6458 panic!("expected ParserError::Template, got {err:?}");
6459 };
6460 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6461 assert!(diag.to_string().contains("<for>"), "{diag}");
6462 }
6463
6464 #[test]
6465 fn boundary_inside_reusable_component_template_errors() {
6466 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6470 parser
6471 .component_registry
6472 .register_component(ComponentRegistration::new(
6473 "my-widget",
6474 r#"<boundary name="x"><p>x</p></boundary>"#,
6475 None,
6476 true,
6477 ))
6478 .expect("register");
6479
6480 let err = parser
6481 .parse("index.html", "<body><my-widget></my-widget></body>")
6482 .expect_err("a boundary inside a component template must error");
6483 let ParserError::Template(diag) = err else {
6484 panic!("expected ParserError::Template, got {err:?}");
6485 };
6486 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6487 }
6488
6489 #[test]
6490 fn boundary_inside_component_host_content_errors() {
6491 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6492 parser
6493 .component_registry
6494 .register_component(ComponentRegistration::new(
6495 "my-widget",
6496 "<slot></slot>",
6497 None,
6498 true,
6499 ))
6500 .expect("register");
6501
6502 let err = parser
6503 .parse(
6504 "index.html",
6505 r#"<body><my-widget><boundary name="x"><p>x</p></boundary></my-widget></body>"#,
6506 )
6507 .expect_err("a boundary inside component host content must error");
6508 let ParserError::Template(diag) = err else {
6509 panic!("expected ParserError::Template, got {err:?}");
6510 };
6511 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6512 assert!(
6513 diag.to_string().contains("component host content"),
6514 "{diag}"
6515 );
6516 }
6517
6518 #[test]
6519 fn boundary_inside_raw_or_inert_html_context_errors() {
6520 for (element, expected_scope) in [
6521 ("textarea", "<textarea> text content"),
6522 ("title", "<title> text content"),
6523 ("script", "<script> raw-text content"),
6524 ("xmp", "<xmp> raw-text content"),
6525 ("iframe", "<iframe> raw-text content"),
6526 ("noembed", "<noembed> raw-text content"),
6527 ("noframes", "<noframes> raw-text content"),
6528 ("noscript", "<noscript> inert content"),
6529 ("plaintext", "<plaintext> text content"),
6530 ("template", "<template> inert content"),
6531 ] {
6532 let mut parser = HtmlParser::new();
6533 let html = format!(
6534 r#"<body><{element}><boundary name="x"><p>x</p></boundary></{element}></body>"#
6535 );
6536 let err = parser
6537 .parse("index.html", &html)
6538 .expect_err("a boundary in raw or inert content must error");
6539 let ParserError::Template(diag) = err else {
6540 panic!("expected ParserError::Template, got {err:?}");
6541 };
6542 assert_eq!(
6543 diag.error_code(),
6544 Some(codes::BOUNDARY_CROSSES_SCOPE),
6545 "unexpected code for <{element}>"
6546 );
6547 assert!(
6548 diag.to_string().contains(expected_scope),
6549 "unexpected scope for <{element}>: {diag}"
6550 );
6551 }
6552 }
6553
6554 #[test]
6555 fn boundary_inside_route_element_errors() {
6556 let mut parser = HtmlParser::new();
6557 let err = parser
6559 .parse(
6560 "index.html",
6561 r#"<body><route path="/" component="home"><boundary name="x"><p>x</p></boundary></route></body>"#,
6562 )
6563 .expect_err("a boundary directly inside <route> must error");
6564 let ParserError::Template(diag) = err else {
6565 panic!("expected ParserError::Template, got {err:?}");
6566 };
6567 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6568
6569 let mut parser = HtmlParser::new();
6571 let err = parser
6572 .parse(
6573 "index.html",
6574 r#"<body><route path="/" component="home"><div><boundary name="x"><p>x</p></boundary></div></route></body>"#,
6575 )
6576 .expect_err("a boundary nested inside <route> markup must error");
6577 let ParserError::Template(diag) = err else {
6578 panic!("expected ParserError::Template, got {err:?}");
6579 };
6580 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6581 }
6582
6583 #[test]
6584 fn boundary_must_be_inside_an_open_body() {
6585 for html in [
6586 r#"<boundary name="before"><p>x</p></boundary><body></body>"#,
6587 r#"<body></body><boundary name="after"><p>x</p></boundary>"#,
6588 ] {
6589 let mut parser = HtmlParser::new();
6590 let err = parser
6591 .parse("index.html", html)
6592 .expect_err("a boundary outside body must error");
6593 let ParserError::Template(diag) = err else {
6594 panic!("expected ParserError::Template, got {err:?}");
6595 };
6596 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_OUTSIDE_BODY));
6597 assert_eq!(
6598 diag.help_text(),
6599 Some("move the <boundary> between the opening <body> and its matching </body>")
6600 );
6601 }
6602
6603 let mut parser = HtmlParser::new();
6604 parser
6605 .parse(
6606 "index.html",
6607 r#"<BODY><boundary name="inside"></boundary></BODY>"#,
6608 )
6609 .expect("mixed-case native body keeps the boundary inside an open body");
6610 }
6611
6612 #[test]
6613 fn boundary_inside_foster_parenting_context_errors() {
6614 for html in [
6618 r#"<body><table><boundary name="t"><p>x</p></boundary></table></body>"#,
6619 r#"<body><table><tbody><boundary name="t"><p>x</p></boundary></tbody></table></body>"#,
6620 r#"<body><table><thead><boundary name="t"><p>x</p></boundary></thead></table></body>"#,
6621 r#"<body><table><tfoot><boundary name="t"><p>x</p></boundary></tfoot></table></body>"#,
6622 r#"<body><table><tbody><tr><boundary name="t"><p>x</p></boundary></tr></tbody></table></body>"#,
6623 r#"<body><table><colgroup><boundary name="t"><p>x</p></boundary></colgroup></table></body>"#,
6624 r#"<body><select><boundary name="t"><option>x</option></boundary></select></body>"#,
6625 r#"<body><select><optgroup><boundary name="t"><option>x</option></boundary></optgroup></select></body>"#,
6626 r#"<body><TABLE><TBODY><boundary name="t"><p>x</p></boundary></TBODY></TABLE></body>"#,
6629 ] {
6630 let mut parser = HtmlParser::new();
6631 let err = parser
6632 .parse("index.html", html)
6633 .expect_err("a boundary in a foster-parenting context must error");
6634 let ParserError::Template(diag) = err else {
6635 panic!("expected ParserError::Template, got {err:?}");
6636 };
6637 assert_eq!(
6638 diag.error_code(),
6639 Some(codes::BOUNDARY_IN_FOSTER_CONTEXT),
6640 "unexpected code for {html}"
6641 );
6642 assert!(
6643 diag.help_text()
6644 .is_some_and(|help| help.contains("<td>, <th>, or <caption>")),
6645 "help should suggest a safe placement for {html}"
6646 );
6647 }
6648 }
6649
6650 #[test]
6651 fn boundary_is_allowed_where_insertion_mode_returns_to_in_body() {
6652 for html in [
6656 r#"<body><table><tbody><tr><td><boundary name="cell"><p>x</p></boundary></td></tr></tbody></table></body>"#,
6657 r#"<body><table><tbody><tr><th><boundary name="head"><p>x</p></boundary></th></tr></tbody></table></body>"#,
6658 r#"<body><table><caption><boundary name="cap"><p>x</p></boundary></caption></table></body>"#,
6659 r#"<body><boundary name="whole"><table><tbody><tr><td>x</td></tr></tbody></table></boundary></body>"#,
6661 concat!(
6665 "<body><table><tbody><tr><td>",
6666 "<table><tbody><tr><td>inner</td></tr></tbody></table>",
6667 r#"<boundary name="after-inner"><p>x</p></boundary>"#,
6668 "</td></tr></tbody></table></body>",
6669 ),
6670 r#"<body><table><tbody><tr><td><img src="a"><br><input></td></tr></tbody></table><boundary name="v"><p>x</p></boundary></body>"#,
6672 ] {
6673 let mut parser = HtmlParser::new();
6674 parser
6675 .parse("index.html", html)
6676 .unwrap_or_else(|err| panic!("{html} should parse, got {err:?}"));
6677 }
6678 }
6679
6680 #[test]
6681 fn foster_context_depth_is_restored_after_leaving_a_table() {
6682 let mut parser = HtmlParser::new();
6685 parser
6686 .parse(
6687 "index.html",
6688 concat!(
6689 "<body>",
6690 "<table><tbody><tr><td>x</td></tr></tbody></table>",
6691 r#"<boundary name="after-table"><p>y</p></boundary>"#,
6692 "</body>",
6693 ),
6694 )
6695 .expect("a boundary after a closed table must be accepted");
6696 }
6697
6698 #[test]
6699 fn authored_raw_bindings_cannot_forge_structural_signals() {
6700 let html = concat!(
6701 "<html><head></head><body>",
6702 "{{{head_start}}}{{{head_end}}}{{{body_start}}}{{{body_end}}}",
6703 "{{{boundary_start:0}}}{{{boundary_end:0}}}",
6704 "{{{streaming_root:forged}}}",
6705 "</body></html>",
6706 );
6707 let (fragments, _) = parse_and_get_fragments(html);
6708 let raw_signals: Vec<&str> = fragments
6709 .iter()
6710 .filter_map(|fragment| match fragment.fragment.as_ref() {
6711 Some(Fragment::Signal(signal)) if signal.raw => Some(signal.value.as_str()),
6712 _ => None,
6713 })
6714 .collect();
6715
6716 assert_eq!(
6717 raw_signals,
6718 [
6719 "}}}webui:head_start",
6720 "}}}webui:head_end",
6721 "}}}webui:body_start",
6722 "head_start",
6723 "head_end",
6724 "body_start",
6725 "body_end",
6726 "boundary_start:0",
6727 "boundary_end:0",
6728 "streaming_root:forged",
6729 "}}}webui:body_end",
6730 ]
6731 );
6732 }
6733
6734 #[test]
6735 fn authored_webui_hydrate_errors_at_top_level() {
6736 for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
6737 let mut parser = HtmlParser::new();
6738 let html = format!("<div><{tag}></{tag}></div>");
6739 let err = parser
6740 .parse("index.html", &html)
6741 .expect_err("an authored <webui-hydrate> must error in any ASCII casing");
6742 let ParserError::Template(diag) = err else {
6743 panic!("expected ParserError::Template, got {err:?}");
6744 };
6745 assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
6746 }
6747 }
6748
6749 #[test]
6750 fn authored_webui_hydrate_errors_inside_route() {
6751 for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
6752 let mut parser = HtmlParser::new();
6753 let html = format!(r#"<route path="/" component="home"><{tag}></{tag}></route>"#);
6754 let err = parser
6755 .parse("index.html", &html)
6756 .expect_err("an authored <webui-hydrate> inside <route> must error");
6757 let ParserError::Template(diag) = err else {
6758 panic!("expected ParserError::Template, got {err:?}");
6759 };
6760 assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
6761 }
6762 }
6763
6764 #[test]
6765 fn boundary_directive_remains_case_sensitive() {
6766 let (fragments, _) =
6767 parse_and_get_fragments(r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#);
6768
6769 assert_fragments!(
6770 fragments,
6771 [raw(
6772 r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#
6773 ),]
6774 );
6775 }
6776
6777 #[test]
6778 fn templates_without_webui_boundary_are_unaffected() {
6779 let html = r#"<div><my-counter count="{{count}}"></my-counter></div>"#;
6783 let (fragments, _) = parse_and_get_fragments(html);
6784
6785 assert_fragments!(
6786 fragments,
6787 [
6788 raw("<div><my-counter"),
6789 attr("count", "count"),
6790 raw("></my-counter></div>"),
6791 ]
6792 );
6793 }
6794
6795 #[test]
6796 fn test_fail_with_invalid_markup() {
6797 let mut parser = HtmlParser::new();
6799 let result = parser.parse("index.html", "<div><span></div></span>");
6800
6801 assert!(matches!(result, Err(ParserError::Template(diag)) if
6802 diag.to_string().contains("unclosed <span> tag")
6803 ));
6804 }
6805
6806 #[test]
6807 fn test_complex_raw_text_page() {
6808 let html = concat!(
6811 "<!DOCTYPE html>",
6812 "<html lang=\"en\">",
6813 "<head>",
6814 "<meta charset=\"utf-8\">",
6815 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
6816 "<title>Complex Page</title>",
6817 "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
6818 "<link rel=\"stylesheet\" href=\"styles.css\">",
6819 "</head>",
6820 "<body>",
6821 "<h1>Hello World</h1>",
6822 "<script type=\"module\" src=\"./app.js\"></script>",
6823 "</body>",
6824 "</html>",
6825 );
6826 let (fragments, _) = parse_and_get_fragments(html);
6827
6828 assert!(
6832 fragments.len() >= 9,
6833 "Expected at least 9 fragments, got {}",
6834 fragments.len()
6835 );
6836
6837 assert!(
6839 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
6840 raw.value.contains("<!DOCTYPE html>") && raw.value.ends_with("<head>")),
6841 "First fragment should contain DOCTYPE and end with <head>, got: {:?}",
6842 fragments[0]
6843 );
6844
6845 assert!(
6847 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
6848 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start")),
6849 "Second fragment should be head_start signal"
6850 );
6851
6852 assert!(
6854 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
6855 raw.value.contains("<meta charset=\"utf-8\">") &&
6856 raw.value.contains("<meta name=\"viewport\"") &&
6857 raw.value.contains("<title>Complex Page</title>") &&
6858 raw.value.contains("<style>") &&
6859 raw.value.contains("body { margin: 0; padding: 0; }")),
6860 "Third fragment should contain all head content, got: {:?}",
6861 fragments[2]
6862 );
6863
6864 assert!(
6866 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
6867 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end")),
6868 "Fourth fragment should be head_end signal"
6869 );
6870
6871 assert!(
6873 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
6874 raw.value.contains("</head>") && raw.value.ends_with("<body>")),
6875 "Fifth fragment should contain </head><body>"
6876 );
6877
6878 assert!(
6880 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
6881 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start")),
6882 "Sixth fragment should be body_start signal"
6883 );
6884
6885 assert!(
6887 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
6888 raw.value.contains("<h1>Hello World</h1>") &&
6889 raw.value.contains("<script")),
6890 "Seventh fragment should contain body content"
6891 );
6892
6893 assert!(
6895 matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
6896 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end")),
6897 "Eighth fragment should be body_end signal"
6898 );
6899
6900 assert!(
6902 matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
6903 raw.value.contains("</body>") && raw.value.contains("</html>")),
6904 "Ninth fragment should contain closing tags"
6905 );
6906 }
6907
6908 struct BindingCountPlugin {
6912 counts: Vec<u32>,
6913 }
6914
6915 impl BindingCountPlugin {
6916 fn new() -> Self {
6917 Self { counts: Vec::new() }
6918 }
6919 }
6920
6921 impl crate::plugin::ParserPlugin for BindingCountPlugin {
6922 fn register_component_template(
6923 &mut self,
6924 _tag_name: &str,
6925 _component: &Component,
6926 _processed_template: &str,
6927 ) -> Result<()> {
6928 Ok(())
6929 }
6930
6931 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
6932 if attr_name.starts_with('@') || attr_name == "f-ref" {
6933 AttributeAction::SkipAndCountBinding
6934 } else {
6935 AttributeAction::Keep
6936 }
6937 }
6938
6939 fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
6940 self.counts.push(binding_attribute_count);
6941 if binding_attribute_count > 0 {
6942 Some(binding_attribute_count.to_le_bytes().to_vec())
6943 } else {
6944 None
6945 }
6946 }
6947 }
6948
6949 struct TemplateCapturePlugin {
6950 template: Option<String>,
6951 }
6952
6953 impl TemplateCapturePlugin {
6954 fn new() -> Self {
6955 Self { template: None }
6956 }
6957 }
6958
6959 impl crate::plugin::ParserPlugin for TemplateCapturePlugin {
6960 fn register_component_template(
6961 &mut self,
6962 _tag_name: &str,
6963 _component: &Component,
6964 processed_template: &str,
6965 ) -> Result<()> {
6966 self.template = Some(processed_template.to_string());
6967 Ok(())
6968 }
6969
6970 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
6971 if attr_name.starts_with('@') || attr_name == "f-ref" {
6972 AttributeAction::SkipAndCountBinding
6973 } else {
6974 AttributeAction::Keep
6975 }
6976 }
6977
6978 fn finish_element(&mut self, _binding_attribute_count: u32) -> Option<Vec<u8>> {
6979 None
6980 }
6981
6982 fn into_artifacts(self: Box<Self>) -> Result<ParserPluginArtifacts> {
6983 match self.template {
6984 Some(template) => Ok(ParserPluginArtifacts::ComponentTemplates(vec![
6985 crate::plugin::ComponentTemplateArtifact::template(
6986 "todo-app".to_string(),
6987 template,
6988 ),
6989 ])),
6990 None => Ok(ParserPluginArtifacts::None),
6991 }
6992 }
6993 }
6994
6995 #[test]
6996 fn parser_passes_user_template_root_attrs_to_plugin_without_css_modules() {
6997 let mut parser = HtmlParser::with_plugin_options(
6998 Box::new(TemplateCapturePlugin::new()),
6999 CssStrategy::Link,
7000 );
7001 parser
7002 .component_registry_mut()
7003 .register_component(ComponentRegistration::new(
7004 "todo-app",
7005 r#"<template shadowrootmode="open" @toggle-item="{onToggleItem($e)}" @delete-item="{onDeleteItem($e)}" f-ref="{root}"><div>items</div></template>"#,
7006 Some(":host { display: block; }"),
7007 true,))
7008 .expect("register todo-app");
7009
7010 parser
7011 .parse("index.html", "<todo-app></todo-app>")
7012 .expect("parse failed");
7013
7014 let artifacts = parser.take_plugin_artifacts().expect("artifacts");
7015 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
7016 panic!("expected captured component template");
7017 };
7018 let template = &templates[0].template;
7019
7020 assert!(
7021 template.contains(r#"shadowrootmode="open""#),
7022 "parser must pass dev-authored root template attrs to plugins, got: {template}"
7023 );
7024 assert!(
7025 template.contains(r#"@toggle-item="{onToggleItem($e)}""#),
7026 "parser must pass dev-authored root event attrs to plugins, got: {template}"
7027 );
7028 assert!(
7029 template.contains(r#"@delete-item="{onDeleteItem($e)}""#),
7030 "parser must pass dev-authored root event attrs to plugins, got: {template}"
7031 );
7032 assert!(
7033 template.contains(r#"f-ref="{root}""#),
7034 "parser must pass dev-authored root FAST attrs to plugins, got: {template}"
7035 );
7036 assert!(
7037 !template.contains("shadowrootadoptedstylesheets"),
7038 "link CSS must not add CSS module adoption attrs, got: {template}"
7039 );
7040 }
7041
7042 #[test]
7043 fn test_component_static_attrs_not_counted_as_bindings() {
7044 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
7045 parser
7046 .component_registry
7047 .register_component(ComponentRegistration::new(
7048 "my-btn",
7049 "<button><slot></slot></button>",
7050 None,
7051 true,
7052 ))
7053 .expect("register");
7054
7055 let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
7057 let result = parser.parse("index.html", html);
7058 assert!(result.is_ok());
7059
7060 let records = parser.into_fragment_records();
7061
7062 let frags = &records["index.html"].fragments;
7064 let plugin_count = frags
7065 .iter()
7066 .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
7067 .count();
7068 assert_eq!(
7069 plugin_count, 0,
7070 "Static-only component attributes should not emit a Plugin fragment"
7071 );
7072 }
7073
7074 #[test]
7075 fn test_component_dynamic_attr_counted_as_binding() {
7076 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
7077 parser
7078 .component_registry
7079 .register_component(ComponentRegistration::new(
7080 "my-btn",
7081 "<button><slot></slot></button>",
7082 None,
7083 true,
7084 ))
7085 .expect("register");
7086
7087 let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
7089 let result = parser.parse("index.html", html);
7090 assert!(result.is_ok());
7091
7092 let records = parser.into_fragment_records();
7093 let frags = &records["index.html"].fragments;
7094
7095 let plugin_frags: Vec<_> = frags
7097 .iter()
7098 .filter_map(|f| match f.fragment.as_ref() {
7099 Some(Fragment::Plugin(p)) => Some(&p.data),
7100 _ => None,
7101 })
7102 .collect();
7103 assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
7104 let count = u32::from_le_bytes([
7105 plugin_frags[0][0],
7106 plugin_frags[0][1],
7107 plugin_frags[0][2],
7108 plugin_frags[0][3],
7109 ]);
7110 assert_eq!(count, 1, "Binding attribute count should be 1");
7111 }
7112
7113 #[test]
7114 fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
7115 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
7116 parser
7117 .component_registry
7118 .register_component(ComponentRegistration::new(
7119 "my-btn",
7120 "<button><slot></slot></button>",
7121 None,
7122 true,
7123 ))
7124 .expect("register");
7125
7126 let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
7128 let result = parser.parse("index.html", html);
7129 assert!(result.is_ok());
7130
7131 let records = parser.into_fragment_records();
7132 let frags = &records["index.html"].fragments;
7133
7134 let plugin_frags: Vec<_> = frags
7135 .iter()
7136 .filter_map(|f| match f.fragment.as_ref() {
7137 Some(Fragment::Plugin(p)) => Some(&p.data),
7138 _ => None,
7139 })
7140 .collect();
7141 assert_eq!(plugin_frags.len(), 1);
7142 let count = u32::from_le_bytes([
7143 plugin_frags[0][0],
7144 plugin_frags[0][1],
7145 plugin_frags[0][2],
7146 plugin_frags[0][3],
7147 ]);
7148 assert_eq!(
7150 count, 2,
7151 "Binding count should include dynamic + plugin-skipped attrs, not static"
7152 );
7153 }
7154
7155 #[test]
7156 fn test_component_only_skipped_plugin_attrs_counted() {
7157 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
7158 parser
7159 .component_registry
7160 .register_component(ComponentRegistration::new(
7161 "my-btn",
7162 "<button><slot></slot></button>",
7163 None,
7164 true,
7165 ))
7166 .expect("register");
7167
7168 let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
7170 let result = parser.parse("index.html", html);
7171 assert!(result.is_ok());
7172
7173 let records = parser.into_fragment_records();
7174 let frags = &records["index.html"].fragments;
7175
7176 let plugin_frags: Vec<_> = frags
7177 .iter()
7178 .filter_map(|f| match f.fragment.as_ref() {
7179 Some(Fragment::Plugin(p)) => Some(&p.data),
7180 _ => None,
7181 })
7182 .collect();
7183 assert_eq!(plugin_frags.len(), 1);
7184 let count = u32::from_le_bytes([
7185 plugin_frags[0][0],
7186 plugin_frags[0][1],
7187 plugin_frags[0][2],
7188 plugin_frags[0][3],
7189 ]);
7190 assert_eq!(
7192 count, 2,
7193 "Only plugin-skipped attrs should be counted, not static title"
7194 );
7195 }
7196
7197 #[test]
7200 fn test_comment_handlebars_signal_is_stripped() {
7201 let mut parser = HtmlParser::new();
7202 let html = "<!--{{tokens}}-->";
7203 parser.parse("test.html", html).expect("parse failed");
7204 let records = parser.into_fragment_records();
7205
7206 assert_stream!(records, "test.html", []);
7207 }
7208
7209 #[test]
7210 fn test_comment_triple_brace_raw_signal_is_stripped() {
7211 let mut parser = HtmlParser::new();
7212 let html = "<!--{{{tokens}}}-->";
7213 parser.parse("test.html", html).expect("parse failed");
7214 let records = parser.into_fragment_records();
7215
7216 assert_stream!(records, "test.html", []);
7217 }
7218
7219 #[test]
7220 fn test_comment_regular_stripped() {
7221 let mut parser = HtmlParser::new();
7222 let html = "<!-- regular comment -->";
7223 parser.parse("test.html", html).expect("parse failed");
7224 let records = parser.into_fragment_records();
7225
7226 assert_stream!(records, "test.html", []);
7227 }
7228
7229 #[test]
7230 fn test_comment_dotted_signal_is_stripped() {
7231 let mut parser = HtmlParser::new();
7232 let html = "<!--{{tokens.light}}-->";
7233 parser.parse("test.html", html).expect("parse failed");
7234 let records = parser.into_fragment_records();
7235
7236 assert_stream!(records, "test.html", []);
7237 }
7238
7239 #[test]
7240 fn test_comment_arbitrary_identifier_is_stripped() {
7241 let mut parser = HtmlParser::new();
7242 let html = "<!--{{someOtherBinding}}-->";
7243 parser.parse("test.html", html).expect("parse failed");
7244 let records = parser.into_fragment_records();
7245
7246 assert_stream!(records, "test.html", []);
7247 }
7248
7249 #[test]
7250 fn test_comment_with_surrounding_content_is_stripped() {
7251 let mut parser = HtmlParser::new();
7252 let html = "<div><!--{{tokens}}--></div>";
7253 parser.parse("test.html", html).expect("parse failed");
7254 let records = parser.into_fragment_records();
7255
7256 assert_stream!(records, "test.html", [raw("<div></div>")]);
7257 }
7258
7259 #[test]
7260 fn test_component_template_unterminated_comment_returns_error() {
7261 let mut parser = HtmlParser::new();
7262 parser
7263 .component_registry_mut()
7264 .register_component(ComponentRegistration::new(
7265 "x-bad",
7266 "<span>ok</span><!-- missing close",
7267 None,
7268 true,
7269 ))
7270 .expect("register failed");
7271
7272 let result = parser.parse("index.html", "<x-bad></x-bad>");
7273
7274 assert!(matches!(result, Err(ParserError::Template(diag)) if
7275 diag.to_string().contains("unterminated HTML comment")
7276 ));
7277 }
7278
7279 #[test]
7280 fn test_webui_plugin_strips_component_comments_before_metadata() {
7281 let mut parser =
7282 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
7283 parser
7284 .component_registry_mut()
7285 .register_component(ComponentRegistration::new(
7286 "x-bleed",
7287 r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
7288 None,
7289 true,
7290 ))
7291 .expect("register failed");
7292
7293 parser
7294 .parse("index.html", "<x-bleed></x-bleed>")
7295 .expect("parse failed");
7296
7297 let artifacts = parser.take_plugin_artifacts().unwrap();
7298 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
7299 panic!("expected component templates");
7300 };
7301 let template = &templates[0].template_json;
7302 assert!(template.contains("<div>hello</div>"));
7303 assert!(!template.contains("{{path}}"));
7304 assert!(!template.contains("@click"));
7305 assert!(!template.contains("?hidden"));
7306 assert!(!template.contains("-->"));
7307 }
7308
7309 #[test]
7310 fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
7311 let mut parser =
7312 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
7313 parser
7314 .component_registry_mut()
7315 .register_component(ComponentRegistration::new(
7316 "x-style",
7317 "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
7318 None,
7319 true,
7320 ))
7321 .expect("register failed");
7322
7323 parser
7324 .parse("index.html", "<x-style></x-style>")
7325 .expect("parse failed");
7326
7327 let artifacts = parser.take_plugin_artifacts().unwrap();
7328 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
7329 panic!("expected component templates");
7330 };
7331 let template = &templates[0].template_json;
7332 assert!(template.contains(".x { color: red; }"));
7333 assert!(!template.contains("ignored"));
7334 assert!(!template.contains("//"));
7335 }
7336
7337 #[test]
7340 fn test_tokens_from_style_tag() {
7341 let mut parser = HtmlParser::new();
7342 let html = r#"<style>
7343 .btn { color: var(--colorPrimary); background: var(--bgColor); }
7344 </style>"#;
7345 parser.parse("test.html", html).expect("parse failed");
7346 let tokens = parser.take_tokens();
7347
7348 assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
7349 }
7350
7351 #[test]
7352 fn test_tokens_from_malformed_style_error_on_unclosed_var() {
7353 let mut parser = HtmlParser::new();
7354 let html = r#"<style>
7355 .bad { color: var(--dangling; }
7356 .ok { color: var(--valid); }
7357 </style>"#;
7358 let result = parser.parse("test.html", html);
7359
7360 assert!(matches!(result, Err(ParserError::Template(diag)) if
7361 diag.error_code() == Some(codes::INVALID_CSS)
7362 && diag.to_string().contains("Unterminated CSS var() call")
7363 ));
7364 }
7365
7366 #[test]
7367 fn test_tokens_from_component_css() {
7368 let mut parser = HtmlParser::new();
7369 parser
7370 .component_registry_mut()
7371 .register_component(ComponentRegistration::new(
7372 "my-button",
7373 "<button>Click</button>",
7374 Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
7375 true,
7376 ))
7377 .expect("register failed");
7378
7379 let html = "<my-button></my-button>";
7380 parser.parse("test.html", html).expect("parse failed");
7381 let tokens = parser.take_tokens();
7382
7383 assert_eq!(tokens, vec!["borderWidth", "textColor"]);
7384 }
7385
7386 #[test]
7387 fn test_token_requirements_preserve_fallback_chains() {
7388 let mut parser = HtmlParser::new();
7389 parser
7390 .component_registry_mut()
7391 .register_component(ComponentRegistration::new(
7392 "my-card",
7393 "<div>Card</div>",
7394 Some(":host { color: var(--token-a, var(--token-b, var(--token-c), true)); }"),
7395 true,
7396 ))
7397 .expect("register failed");
7398
7399 parser
7400 .parse("test.html", "<my-card></my-card>")
7401 .expect("parse failed");
7402 let analysis = parser.token_analysis();
7403
7404 assert_eq!(analysis.fallback_chains.len(), 1);
7405 assert_eq!(
7406 analysis.fallback_chains[0].tokens,
7407 vec!["token-a", "token-b", "token-c"]
7408 );
7409 assert!(!analysis.fallback_chains[0].has_literal_fallback);
7410 }
7411
7412 #[test]
7413 fn test_token_requirements_remove_locally_defined_candidates_from_fallback_chain() {
7414 let mut parser = HtmlParser::new();
7415 parser
7416 .component_registry_mut()
7417 .register_component(ComponentRegistration::new(
7418 "my-card",
7419 "<div>Card</div>",
7420 Some(
7421 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
7422 ),
7423 true,))
7424 .expect("register failed");
7425
7426 parser
7427 .parse("test.html", "<my-card></my-card>")
7428 .expect("parse failed");
7429 let analysis = parser.token_analysis();
7430
7431 assert_eq!(analysis.protocol_tokens, vec!["token-b", "token-c"]);
7432 assert_eq!(analysis.fallback_chains.len(), 1);
7433 assert_eq!(
7434 analysis.fallback_chains[0].tokens,
7435 vec!["token-b", "token-c"]
7436 );
7437 }
7438
7439 #[test]
7440 fn test_token_analysis_theme_validation_reports_missing_unresolved_token() {
7441 let mut parser = HtmlParser::new();
7442 parser
7443 .component_registry_mut()
7444 .register_component(ComponentRegistration::new(
7445 "my-card",
7446 "<div>Card</div>",
7447 Some(
7448 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
7449 ),
7450 true,))
7451 .expect("register failed");
7452 parser
7453 .parse("test.html", "<my-card></my-card>")
7454 .expect("parse failed");
7455
7456 let analysis = parser.token_analysis();
7457 let theme = webui_tokens::TokenFile {
7458 themes: HashMap::from([(
7459 "light".to_string(),
7460 HashMap::from([("token-b".to_string(), "green".to_string())]),
7461 )]),
7462 };
7463
7464 let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
7465 panic!("missing --token-c in the theme must fail parser validation");
7466 };
7467 assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
7468 let help = diagnostic.help_text().expect("help text");
7471 assert!(help.contains("did you mean --token-b?"), "help: {help}");
7472 assert!(help.contains("define it locally"), "help: {help}");
7473 assert!(!help.contains("add --token"), "help: {help}");
7474 }
7475
7476 #[test]
7477 fn test_token_analysis_literal_fallback_token_exempt_from_theme_validation() {
7478 let mut parser = HtmlParser::new();
7479 parser
7480 .component_registry_mut()
7481 .register_component(ComponentRegistration::new(
7482 "my-card",
7483 "<div>Card</div>",
7484 Some(":host { color: var(--brand, #000); }"),
7485 true,
7486 ))
7487 .expect("register failed");
7488 parser
7489 .parse("test.html", "<my-card></my-card>")
7490 .expect("parse failed");
7491
7492 let analysis = parser.token_analysis();
7493 assert_eq!(analysis.protocol_tokens, vec!["brand"]);
7496
7497 let theme = webui_tokens::TokenFile {
7500 themes: HashMap::from([("light".to_string(), HashMap::new())]),
7501 };
7502 analysis
7503 .validate_theme_tokens(&theme)
7504 .expect("literal fallback must exempt --brand from theme validation");
7505 }
7506
7507 #[test]
7508 fn test_token_analysis_mixed_literal_and_bare_usage_requires_token() {
7509 let mut parser = HtmlParser::new();
7510 parser
7511 .component_registry_mut()
7512 .register_component(ComponentRegistration::new(
7513 "my-card",
7514 "<div>Card</div>",
7515 Some(":host { color: var(--brand, #000); background: var(--brand); }"),
7518 true,
7519 ))
7520 .expect("register failed");
7521 parser
7522 .parse("test.html", "<my-card></my-card>")
7523 .expect("parse failed");
7524
7525 let analysis = parser.token_analysis();
7526 let theme = webui_tokens::TokenFile {
7527 themes: HashMap::from([("light".to_string(), HashMap::new())]),
7528 };
7529
7530 let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
7531 panic!("a bare var(--brand) usage must require --brand in the theme");
7532 };
7533 assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
7534 assert!(diagnostic.to_string().contains("--brand"));
7535 }
7536
7537 #[test]
7538 fn test_unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
7539 let mut parser = HtmlParser::new();
7540 parser
7541 .component_registry_mut()
7542 .register_component(ComponentRegistration::new(
7543 "my-card",
7544 "<div>Card</div>",
7545 Some(
7546 ":host { \
7547 color: var(--colr-brand, #000); \
7548 border: var(--present, 1px); \
7549 margin: var(--required); \
7550 }",
7551 ),
7552 true,
7553 ))
7554 .expect("register failed");
7555 parser
7556 .parse("test.html", "<my-card></my-card>")
7557 .expect("parse failed");
7558
7559 let analysis = parser.token_analysis();
7560 let theme = webui_tokens::TokenFile {
7561 themes: HashMap::from([(
7562 "light".to_string(),
7563 HashMap::from([
7564 ("present".to_string(), "2px".to_string()),
7565 ("required".to_string(), "8px".to_string()),
7566 ]),
7567 )]),
7568 };
7569
7570 assert_eq!(
7574 analysis.unthemed_literal_fallback_tokens(&theme),
7575 vec!["colr-brand".to_string()]
7576 );
7577 }
7578
7579 #[test]
7580 fn test_theme_token_error_reports_location_and_suggestion() {
7581 let mut parser = HtmlParser::new();
7582 parser
7583 .component_registry_mut()
7584 .register_component(ComponentRegistration::new(
7585 "my-card",
7586 "<div>Card</div>",
7587 ":host {\n color: var(--color-neutral-2000);\n}".into(),
7588 true,
7589 ))
7590 .expect("register failed");
7591 parser
7592 .parse("test.html", "<my-card></my-card>")
7593 .expect("parse failed");
7594
7595 let analysis = parser.token_analysis();
7596 let theme = webui_tokens::TokenFile {
7597 themes: HashMap::from([(
7598 "dark".to_string(),
7599 HashMap::from([("color-neutral-200".to_string(), "#222".to_string())]),
7600 )]),
7601 };
7602
7603 let Err(ParserError::Template(diag)) = analysis.validate_theme_tokens(&theme) else {
7604 panic!("missing --color-neutral-2000 must fail validation");
7605 };
7606 assert_eq!(diag.error_code(), Some(codes::MISSING_THEME_TOKEN));
7607 let location = diag.location().expect("a source location");
7609 assert!(location.contains("my-card.css:2:"), "location: {location}");
7610 assert!(
7612 diag.snippet_text()
7613 .is_some_and(|s| s.contains("--color-neutral-2000")),
7614 "snippet: {:?}",
7615 diag.snippet_text()
7616 );
7617 assert!(
7619 diag.help_text()
7620 .is_some_and(|h| h.contains("did you mean --color-neutral-200?")),
7621 "help: {:?}",
7622 diag.help_text()
7623 );
7624 }
7625
7626 #[test]
7627 fn test_theme_token_warning_reports_location_and_suggestion() {
7628 let mut parser = HtmlParser::new();
7629 parser
7630 .component_registry_mut()
7631 .register_component(ComponentRegistration::new(
7632 "my-card",
7633 "<div>Card</div>",
7634 ":host {\n color: var(--colr-brand, #000);\n}".into(),
7635 true,
7636 ))
7637 .expect("register failed");
7638 parser
7639 .parse("test.html", "<my-card></my-card>")
7640 .expect("parse failed");
7641
7642 let analysis = parser.token_analysis();
7643 let theme = webui_tokens::TokenFile {
7644 themes: HashMap::from([(
7645 "light".to_string(),
7646 HashMap::from([("color-brand".to_string(), "#abc".to_string())]),
7647 )]),
7648 };
7649
7650 let warnings = analysis.theme_token_warnings(&theme);
7651 assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
7652 let body = warnings[0].body();
7653 assert!(body.contains("my-card.css:2:"), "warning: {body}");
7654 assert!(body.contains("--colr-brand"), "warning: {body}");
7655 assert!(
7656 body.contains("did you mean --color-brand?"),
7657 "warning: {body}"
7658 );
7659 }
7660
7661 #[test]
7662 fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
7663 let mut parser = HtmlParser::new();
7664 let result =
7665 parser
7666 .component_registry_mut()
7667 .register_component(ComponentRegistration::new(
7668 "my-card",
7669 "<div>Card</div>",
7670 Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
7671 true,
7672 ));
7673
7674 assert!(matches!(result, Err(ParserError::Css(message)) if
7675 message.contains("Unterminated CSS var() call")
7676 ));
7677 }
7678
7679 #[test]
7680 fn test_tokens_merged_from_style_and_components() {
7681 let mut parser = HtmlParser::new();
7682 parser
7683 .component_registry_mut()
7684 .register_component(ComponentRegistration::new(
7685 "my-widget",
7686 "<div>Widget</div>",
7687 Some(".w { padding: var(--spacingM); }"),
7688 true,
7689 ))
7690 .expect("register failed");
7691
7692 let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
7693 parser.parse("test.html", html).expect("parse failed");
7694 let tokens = parser.take_tokens();
7695
7696 assert_eq!(tokens, vec!["spacingM", "textColor"]);
7697 }
7698
7699 #[test]
7700 fn test_tokens_deduplicated() {
7701 let mut parser = HtmlParser::new();
7702 parser
7703 .component_registry_mut()
7704 .register_component(ComponentRegistration::new(
7705 "my-btn",
7706 "<button>B</button>",
7707 Some(".b { color: var(--shared); }"),
7708 true,
7709 ))
7710 .expect("register failed");
7711
7712 let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
7713 parser.parse("test.html", html).expect("parse failed");
7714 let tokens = parser.take_tokens();
7715
7716 assert_eq!(tokens, vec!["shared"]);
7717 }
7718
7719 #[test]
7720 fn test_tokens_empty_when_no_vars() {
7721 let mut parser = HtmlParser::new();
7722 let html = "<div>Hello</div>";
7723 parser.parse("test.html", html).expect("parse failed");
7724 let tokens = parser.take_tokens();
7725
7726 assert!(tokens.is_empty());
7727 }
7728
7729 #[test]
7730 fn test_tokens_exclude_locally_defined_in_component() {
7731 let mut parser = HtmlParser::new();
7732 parser
7733 .component_registry_mut()
7734 .register_component(ComponentRegistration::new(
7735 "my-card",
7736 "<div>Card</div>",
7737 Some(":host { --local: 5px; width: var(--external); }"),
7738 true,
7739 ))
7740 .expect("register failed");
7741
7742 let html = "<my-card></my-card>";
7743 parser.parse("test.html", html).expect("parse failed");
7744 let tokens = parser.take_tokens();
7745
7746 assert_eq!(tokens, vec!["external"]);
7747 }
7748
7749 #[test]
7750 fn test_tokens_exclude_entry_root_definitions() {
7751 let mut parser = HtmlParser::new();
7752 parser
7753 .component_registry_mut()
7754 .register_component(ComponentRegistration::new(
7755 "my-btn",
7756 "<button>B</button>",
7757 Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
7758 true,
7759 ))
7760 .expect("register failed");
7761
7762 let html = r#"<style>
7765 :root {
7766 --color-primary: #0078d4;
7767 --radius-m: 6px;
7768 }
7769 body { color: var(--color-primary); }
7770 </style>
7771 <my-btn></my-btn>"#;
7772 parser.parse("test.html", html).expect("parse failed");
7773 let tokens = parser.take_tokens();
7774
7775 assert!(
7777 tokens.is_empty(),
7778 "Tokens defined in entry :root should be excluded: {tokens:?}"
7779 );
7780 }
7781
7782 #[test]
7783 fn test_tokens_entry_defs_exclude_but_external_kept() {
7784 let mut parser = HtmlParser::new();
7785 parser
7786 .component_registry_mut()
7787 .register_component(ComponentRegistration::new(
7788 "my-card",
7789 "<div>Card</div>",
7790 Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
7791 true,
7792 ))
7793 .expect("register failed");
7794
7795 let html = r#"<style>
7797 :root { --color-primary: #0078d4; }
7798 </style>
7799 <my-card></my-card>"#;
7800 parser.parse("test.html", html).expect("parse failed");
7801 let tokens = parser.take_tokens();
7802
7803 assert_eq!(tokens, vec!["external-spacing"]);
7805 }
7806
7807 #[test]
7810 fn test_parse_simple_route() {
7811 let mut parser = HtmlParser::new();
7812 let html = r#"<route path="/profile" component="profile-page" exact />"#;
7813 parser.parse("test.html", html).expect("parse failed");
7814
7815 let records = parser.into_fragment_records();
7817 let frags = &records["test.html"].fragments;
7818 assert_eq!(frags.len(), 1);
7819 match frags[0].fragment.as_ref() {
7820 Some(web_ui_fragment::Fragment::Route(r)) => {
7821 assert_eq!(r.path, "/profile");
7822 assert_eq!(r.fragment_id, "profile-page");
7823 assert!(r.exact);
7824 }
7825 other => panic!("expected Fragment::Route, got {:?}", other),
7826 }
7827 }
7828
7829 #[test]
7830 fn test_parse_route_with_params() {
7831 let mut parser = HtmlParser::new();
7832 let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
7833 parser.parse("test.html", html).expect("parse failed");
7834
7835 let records = parser.into_fragment_records();
7837 let frags = &records["test.html"].fragments;
7838 match frags[0].fragment.as_ref() {
7839 Some(web_ui_fragment::Fragment::Route(r)) => {
7840 assert_eq!(r.path, "/profile/:id/view/:section");
7841 }
7842 other => panic!("expected Fragment::Route, got {:?}", other),
7843 }
7844 }
7845
7846 #[test]
7847 fn test_parse_route_requires_component() {
7848 let mut parser = HtmlParser::new();
7849 let html = r#"<route path="/old" />"#;
7850 let result = parser.parse("test.html", html);
7851 assert!(result.is_err());
7852 }
7853
7854 #[test]
7855 fn test_parse_multiple_routes() {
7856 let mut parser = HtmlParser::new();
7857 let html = r#"<route path="/" component="app-layout" />
7858 <route path="/dashboard" component="dash-page" exact />
7859 <route path="/contacts" component="contacts-page" exact />"#;
7860 parser.parse("test.html", html).expect("parse failed");
7861
7862 let records = parser.into_fragment_records();
7863 let frags = &records["test.html"].fragments;
7864 assert_eq!(frags.len(), 3);
7865 for frag in frags {
7867 assert!(matches!(
7868 frag.fragment.as_ref(),
7869 Some(web_ui_fragment::Fragment::Route(_))
7870 ));
7871 }
7872 }
7873
7874 #[test]
7875 fn test_parse_route_requires_component_with_body() {
7876 let mut parser = HtmlParser::new();
7877 let html = r#"<route path="/error">
7878 <div class="error"><h1>Not Found</h1></div>
7879 </route>"#;
7880 let result = parser.parse("test.html", html);
7881 assert!(
7882 result.is_err(),
7883 "Route without component attribute should fail"
7884 );
7885 }
7886
7887 #[test]
7888 fn test_parse_multiple_routes_with_fragments() {
7889 let mut parser = HtmlParser::new();
7890 let html = r#"<route path="/" component="home-page" exact />
7891 <route path="/about" component="about-page" exact />
7892 <route path="/contact/:id" component="contact-page" />"#;
7893 parser.parse("test.html", html).expect("parse failed");
7894
7895 let records = parser.into_fragment_records();
7896 let frags = &records["test.html"].fragments;
7897 assert_eq!(frags.len(), 3);
7898
7899 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
7901 assert_eq!(r.path, "/");
7902 }
7903 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
7904 assert_eq!(r.path, "/about");
7905 }
7906 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
7907 assert_eq!(r.path, "/contact/:id");
7908 }
7909 }
7910
7911 #[test]
7912 fn test_parse_route_with_query_allowlist() {
7913 let mut parser = HtmlParser::new();
7914 let html =
7915 r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
7916 parser.parse("test.html", html).expect("parse failed");
7917
7918 let records = parser.into_fragment_records();
7919 let frags = &records["test.html"].fragments;
7920 assert_eq!(frags.len(), 1);
7921 match frags[0].fragment.as_ref() {
7922 Some(web_ui_fragment::Fragment::Route(r)) => {
7923 assert_eq!(r.path, "/compose");
7924 assert_eq!(r.fragment_id, "compose-page");
7925 assert!(r.exact);
7926 assert_eq!(r.allowed_query, "action,to,subject");
7927 }
7928 other => panic!("expected Fragment::Route, got {:?}", other),
7929 }
7930 }
7931
7932 #[test]
7933 fn test_parse_route_without_query_has_empty_allowed_query() {
7934 let mut parser = HtmlParser::new();
7935 let html = r#"<route path="/profile" component="profile-page" exact />"#;
7936 parser.parse("test.html", html).expect("parse failed");
7937
7938 let records = parser.into_fragment_records();
7939 let frags = &records["test.html"].fragments;
7940 match frags[0].fragment.as_ref() {
7941 Some(web_ui_fragment::Fragment::Route(r)) => {
7942 assert!(r.allowed_query.is_empty());
7943 }
7944 other => panic!("expected Fragment::Route, got {:?}", other),
7945 }
7946 }
7947
7948 #[test]
7949 fn test_route_body_rejects_malformed_non_route_html() {
7950 let mut parser = HtmlParser::new();
7951 let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
7952 let result = parser.parse("test.html", html);
7953
7954 assert!(matches!(result, Err(ParserError::Template(diag)) if
7955 diag.to_string().contains("unclosed <span> tag")
7956 ));
7957 }
7958
7959 #[test]
7960 fn test_route_body_rejects_malformed_style_css() {
7961 let mut parser = HtmlParser::new();
7962 let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
7963 let result = parser.parse("test.html", html);
7964
7965 assert!(matches!(result, Err(ParserError::Template(diag)) if
7966 diag.error_code() == Some(codes::INVALID_CSS)
7967 && diag.to_string().contains("Unterminated CSS var() call")
7968 ));
7969 }
7970
7971 #[test]
7972 fn test_outlet_not_captured_by_for_loop() {
7973 let mut parser = HtmlParser::new();
7974 let html = r#"<template shadowrootmode="open">
7975 <ul>
7976 <for each="item in items">
7977 <li>{{item.name}}</li>
7978 </for>
7979 </ul>
7980 <main>
7981 <outlet />
7982 </main>
7983</template>"#;
7984 parser.parse("comp.html", html).expect("parse failed");
7985
7986 let records = parser.into_fragment_records();
7987 let frags = &records["comp.html"].fragments;
7988
7989 let outlet_count = frags
7991 .iter()
7992 .filter(|f| {
7993 matches!(
7994 f.fragment.as_ref(),
7995 Some(web_ui_fragment::Fragment::Outlet(_))
7996 )
7997 })
7998 .count();
7999 assert_eq!(
8000 outlet_count, 1,
8001 "expected exactly 1 outlet in top-level fragments, got {outlet_count}"
8002 );
8003
8004 let outlet_idx = frags
8006 .iter()
8007 .position(|f| {
8008 matches!(
8009 f.fragment.as_ref(),
8010 Some(web_ui_fragment::Fragment::Outlet(_))
8011 )
8012 })
8013 .expect("no outlet found");
8014 let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
8015 Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
8016 _ => false,
8017 });
8018 if let Some(ul_idx) = close_ul_idx {
8019 assert!(
8020 outlet_idx > ul_idx,
8021 "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
8022 );
8023 }
8024 }
8025
8026 #[test]
8027 fn test_outlet_position_after_for_not_inside() {
8028 let mut parser = HtmlParser::new();
8029 let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
8030 parser.parse("test.html", html).expect("parse failed");
8031
8032 let records = parser.into_fragment_records();
8033 let frags = &records["test.html"].fragments;
8034
8035 let has_outlet = frags.iter().any(|f| {
8037 matches!(
8038 f.fragment.as_ref(),
8039 Some(web_ui_fragment::Fragment::Outlet(_))
8040 )
8041 });
8042 assert!(
8043 has_outlet,
8044 "outlet should be in top-level fragments: {frags:?}"
8045 );
8046
8047 let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
8049 Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
8050 _ => None,
8051 });
8052 if let Some(id) = for_id {
8053 let for_frags = &records[&id].fragments;
8054 let outlet_in_for = for_frags.iter().any(|f| {
8055 matches!(
8056 f.fragment.as_ref(),
8057 Some(web_ui_fragment::Fragment::Outlet(_))
8058 )
8059 });
8060 assert!(
8061 !outlet_in_for,
8062 "outlet should NOT be inside for-loop body: {for_frags:?}"
8063 );
8064 }
8065 }
8066
8067 #[test]
8068 fn test_style_element_with_handlebars_signal_comment_emits_signal() {
8069 let mut parser = HtmlParser::new();
8070 let html = r#"<html><head><style>
8071:root {
8072 /*{{{tokens.light}}}*/
8073}
8074</style></head><body></body></html>"#;
8075
8076 let result = parser.parse("test.html", html);
8077 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8078 let fragment_records = parser.into_fragment_records();
8079
8080 assert_stream!(
8081 fragment_records,
8082 "test.html",
8083 [
8084 raw("<html><head>"),
8085 structural_matcher("head_start"),
8086 raw("<style>\n:root {\n "),
8087 signal_raw("tokens.light"),
8088 raw("\n}\n</style>"),
8089 structural_matcher("head_end"),
8090 raw("</head><body>"),
8091 structural_matcher("body_start"),
8092 structural_matcher("body_end"),
8093 raw("</body></html>"),
8094 ]
8095 );
8096 }
8097
8098 #[test]
8099 fn test_style_comment_signal_with_spaces_emits_signal() {
8100 let mut parser = HtmlParser::new();
8101 let html = r#"<html><head><style>
8102:root {
8103 /* {{{tokens.light}}} */
8104}
8105</style></head><body></body></html>"#;
8106
8107 let result = parser.parse("test.html", html);
8108 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8109 let fragment_records = parser.into_fragment_records();
8110
8111 assert_stream!(
8112 fragment_records,
8113 "test.html",
8114 [
8115 raw("<html><head>"),
8116 structural_matcher("head_start"),
8117 raw("<style>\n:root {\n "),
8118 signal_raw("tokens.light"),
8119 raw("\n}\n</style>"),
8120 structural_matcher("head_end"),
8121 raw("</head><body>"),
8122 structural_matcher("body_start"),
8123 structural_matcher("body_end"),
8124 raw("</body></html>"),
8125 ]
8126 );
8127 }
8128
8129 #[test]
8130 fn test_style_comment_signal_double_brace_emits_signal() {
8131 let mut parser = HtmlParser::new();
8132 let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
8133
8134 let result = parser.parse("test.html", html);
8135 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8136 let fragment_records = parser.into_fragment_records();
8137
8138 assert_stream!(
8139 fragment_records,
8140 "test.html",
8141 [
8142 raw("<html><head>"),
8143 structural_matcher("head_start"),
8144 raw("<style>"),
8145 signal("themeCss"),
8146 raw("</style>"),
8147 structural_matcher("head_end"),
8148 raw("</head><body>"),
8149 structural_matcher("body_start"),
8150 structural_matcher("body_end"),
8151 raw("</body></html>"),
8152 ]
8153 );
8154 }
8155
8156 #[test]
8157 fn test_style_comment_with_extra_text_is_stripped() {
8158 let mut parser = HtmlParser::new();
8159 let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
8160
8161 let result = parser.parse("test.html", html);
8162 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8163 let fragment_records = parser.into_fragment_records();
8164
8165 assert_stream!(
8166 fragment_records,
8167 "test.html",
8168 [
8169 raw("<html><head>"),
8170 structural_matcher("head_start"),
8171 raw("<style></style>"),
8172 structural_matcher("head_end"),
8173 raw("</head><body>"),
8174 structural_matcher("body_start"),
8175 structural_matcher("body_end"),
8176 raw("</body></html>"),
8177 ]
8178 );
8179 }
8180
8181 #[test]
8182 fn test_style_comment_with_multiple_signals_is_stripped() {
8183 let mut parser = HtmlParser::new();
8184 let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
8185
8186 let result = parser.parse("test.html", html);
8187 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8188 let fragment_records = parser.into_fragment_records();
8189
8190 assert_stream!(
8191 fragment_records,
8192 "test.html",
8193 [
8194 raw("<html><head>"),
8195 structural_matcher("head_start"),
8196 raw("<style></style>"),
8197 structural_matcher("head_end"),
8198 raw("</head><body>"),
8199 structural_matcher("body_start"),
8200 structural_matcher("body_end"),
8201 raw("</body></html>"),
8202 ]
8203 );
8204 }
8205
8206 #[test]
8207 fn test_style_bare_handlebars_stays_raw() {
8208 let mut parser = HtmlParser::new();
8209 let html =
8211 "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
8212
8213 let result = parser.parse("test.html", html);
8214 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8215 let fragment_records = parser.into_fragment_records();
8216
8217 assert_stream!(
8218 fragment_records,
8219 "test.html",
8220 [
8221 raw("<html><head>"),
8222 structural_matcher("head_start"),
8223 raw("<style>body { color: {{textColor}}; }</style>"),
8224 structural_matcher("head_end"),
8225 raw("</head><body>"),
8226 structural_matcher("body_start"),
8227 structural_matcher("body_end"),
8228 raw("</body></html>"),
8229 ]
8230 );
8231 }
8232
8233 #[test]
8234 fn test_style_mixed_css_and_comment_signal_emits_signal() {
8235 let mut parser = HtmlParser::new();
8236 let html = r#"<html><head><style>
8237 .a { color: red; }
8238 /*{{themeCss}}*/
8239 .b { color: blue; }
8240</style></head><body></body></html>"#;
8241
8242 let result = parser.parse("test.html", html);
8243 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8244 let fragment_records = parser.into_fragment_records();
8245
8246 assert_stream!(
8247 fragment_records,
8248 "test.html",
8249 [
8250 raw("<html><head>"),
8251 structural_matcher("head_start"),
8252 raw("<style>\n .a { color: red; }\n "),
8253 signal("themeCss"),
8254 raw("\n .b { color: blue; }\n</style>"),
8255 structural_matcher("head_end"),
8256 raw("</head><body>"),
8257 structural_matcher("body_start"),
8258 structural_matcher("body_end"),
8259 raw("</body></html>"),
8260 ]
8261 );
8262 }
8263
8264 #[test]
8265 fn test_style_legal_comment_preserved_inline_by_default() {
8266 let mut parser = HtmlParser::new();
8267 let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
8268
8269 parser.parse("test.html", html).expect("parse failed");
8270 let fragment_records = parser.into_fragment_records();
8271
8272 assert_stream!(
8273 fragment_records,
8274 "test.html",
8275 [
8276 raw("<html><head>"),
8277 structural_matcher("head_start"),
8278 raw("<style>/*! @license MIT */ .x { color: red; } </style>"),
8279 structural_matcher("head_end"),
8280 raw("</head><body>"),
8281 structural_matcher("body_start"),
8282 structural_matcher("body_end"),
8283 raw("</body></html>"),
8284 ]
8285 );
8286 }
8287
8288 #[test]
8289 fn test_style_legal_comments_none_strips_legal_comment() {
8290 let mut parser = HtmlParser::with_options(ParserOptions {
8291 legal_comments: LegalComments::None,
8292 ..ParserOptions::default()
8293 });
8294 let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
8295
8296 parser.parse("test.html", html).expect("parse failed");
8297 let fragment_records = parser.into_fragment_records();
8298
8299 assert_stream!(
8300 fragment_records,
8301 "test.html",
8302 [
8303 raw("<html><head>"),
8304 structural_matcher("head_start"),
8305 raw("<style> .x { color: red; }</style>"),
8306 structural_matcher("head_end"),
8307 raw("</head><body>"),
8308 structural_matcher("body_start"),
8309 structural_matcher("body_end"),
8310 raw("</body></html>"),
8311 ]
8312 );
8313 }
8314
8315 #[test]
8316 fn test_style_element_plain_css_unchanged() {
8317 let mut parser = HtmlParser::new();
8318 let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
8319
8320 let result = parser.parse("test.html", html);
8321 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8322 let fragment_records = parser.into_fragment_records();
8323
8324 assert_stream!(
8326 fragment_records,
8327 "test.html",
8328 [
8329 raw("<html><head>"),
8330 structural_matcher("head_start"),
8331 raw("<style>body { margin: 0; }</style>"),
8332 structural_matcher("head_end"),
8333 raw("</head><body>"),
8334 structural_matcher("body_start"),
8335 structural_matcher("body_end"),
8336 raw("</body></html>"),
8337 ]
8338 );
8339 }
8340
8341 #[test]
8342 fn test_unclosed_style_element_returns_error() {
8343 let mut parser = HtmlParser::new();
8344 let result = parser.parse("index.html", "<style>.x { color: red; ");
8345 assert!(matches!(result, Err(ParserError::Template(diag)) if
8346 diag.to_string().contains("unclosed <style> tag")
8347 ));
8348 }
8349
8350 #[test]
8351 fn test_text_with_binding_after_void_element() {
8352 let (fragments, _) = parse_and_get_fragments(
8355 "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
8356 );
8357 assert_fragments!(
8358 fragments,
8359 [
8360 raw("Part A: "),
8361 signal("value1"),
8362 raw("<button>Click</button><br>Part B: "),
8363 signal("value2"),
8364 raw("<button>Click2</button>"),
8365 ]
8366 );
8367 }
8368}