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