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 = html.trim();
3300 let snippet = css_snippet.unwrap_or_default();
3301
3302 let processed = if trimmed.starts_with("<template") {
3303 let authored_shadow =
3304 html::parse_tag(trimmed).is_some_and(|tag| tag.has_attr("shadowrootmode"));
3305 let base = self.strip_template_build_attrs(trimmed, mode.preserve_runtime_attrs);
3306 let with_shadow = if mode.policy_wrapper
3307 && matches!(self.options.dom_strategy, DomStrategy::Shadow)
3308 {
3309 Self::append_shadow_mode_if_missing(base)
3310 } else {
3311 base
3312 };
3313 let with_adopted = Self::append_adopted_attr_if_missing(with_shadow, adopted_specifier);
3314 let injected = Self::inject_css_snippet_into_template(with_adopted, snippet);
3315 if mode.policy_wrapper
3316 && matches!(self.options.dom_strategy, DomStrategy::Light)
3317 && !authored_shadow
3318 {
3319 Self::unwrap_component_policy_template(injected)
3320 } else {
3321 injected
3322 }
3323 } else {
3324 match self.options.dom_strategy {
3325 DomStrategy::Shadow => {
3326 let adopted = adopted_specifier.unwrap_or_default();
3327 let adopted_extra = if adopted.is_empty() {
3328 0
3329 } else {
3330 Self::adopted_attr_len(adopted)
3331 };
3332 let mut result =
3333 String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
3334 result.push_str("<template shadowrootmode=\"open\"");
3335 if !adopted.is_empty() {
3336 Self::push_adopted_attr(&mut result, adopted);
3337 }
3338 result.push('>');
3339 result.push_str(snippet);
3340 result.push_str(trimmed);
3341 result.push_str("</template>");
3342 result
3343 }
3344 DomStrategy::Light => {
3345 if snippet.is_empty() {
3346 trimmed.to_string()
3347 } else {
3348 let mut result = String::with_capacity(snippet.len() + trimmed.len());
3349 result.push_str(snippet);
3350 result.push_str(trimmed);
3351 result
3352 }
3353 }
3354 }
3355 };
3356
3357 self.strip_template_comments(processed)
3358 }
3359
3360 fn inject_css_snippet_into_template(html: String, snippet: &str) -> String {
3361 if snippet.is_empty() {
3362 return html;
3363 }
3364
3365 match html::find_tag_close(&html) {
3366 Some(open_end) => {
3367 let mut result = String::with_capacity(html.len() + snippet.len());
3368 result.push_str(&html[..=open_end]);
3369 result.push_str(snippet);
3370 result.push_str(&html[open_end + 1..]);
3371 result
3372 }
3373 None => html,
3376 }
3377 }
3378
3379 fn append_adopted_attr_if_missing(html: String, adopted_specifier: Option<&str>) -> String {
3380 let Some(adopted) = adopted_specifier else {
3381 return html;
3382 };
3383 let Some(tag) = html::parse_tag(&html) else {
3384 return html;
3385 };
3386 if tag.name != "template" || tag.closing || tag.has_attr(Self::ADOPTED_STYLESHEETS_ATTR) {
3387 return html;
3388 }
3389
3390 let mut result = String::with_capacity(html.len() + Self::adopted_attr_len(adopted));
3391 result.push_str(&html[..tag.close]);
3392 Self::push_adopted_attr(&mut result, adopted);
3393 result.push_str(&html[tag.close..]);
3394 result
3395 }
3396
3397 fn append_shadow_mode_if_missing(html: String) -> String {
3398 let Some(tag) = html::parse_tag(&html) else {
3399 return html;
3400 };
3401 if tag.name != "template" || tag.closing || tag.has_attr("shadowrootmode") {
3402 return html;
3403 }
3404
3405 const SHADOW_MODE: &str = " shadowrootmode=\"open\"";
3406 let mut result = String::with_capacity(html.len() + SHADOW_MODE.len());
3407 result.push_str(&html[..tag.close]);
3408 result.push_str(SHADOW_MODE);
3409 result.push_str(&html[tag.close..]);
3410 result
3411 }
3412
3413 fn unwrap_component_policy_template(html: String) -> String {
3414 let Some(tag) = html::parse_tag(&html) else {
3415 return html;
3416 };
3417 let inner_start = tag.close + 1;
3418 let inner_end = html.rfind("</template>").unwrap_or(html.len());
3419 if inner_start >= inner_end {
3420 return String::new();
3421 }
3422 html[inner_start..inner_end].to_string()
3423 }
3424
3425 fn push_adopted_attr(out: &mut String, adopted: &str) {
3426 out.push(' ');
3427 out.push_str(Self::ADOPTED_STYLESHEETS_ATTR);
3428 out.push_str("=\"");
3429 out.push_str(adopted);
3430 out.push('"');
3431 }
3432
3433 fn adopted_attr_len(adopted: &str) -> usize {
3434 4 + Self::ADOPTED_STYLESHEETS_ATTR.len() + adopted.len()
3435 }
3436
3437 fn template_has_stripped_runtime_attrs(html: &str) -> bool {
3438 let trimmed = html.trim_start();
3439 let Some(tag) = html::parse_tag(trimmed) else {
3440 return false;
3441 };
3442 if tag.name != "template" || tag.closing {
3443 return false;
3444 }
3445 tag.attrs().any(|attr| {
3446 attr.name.starts_with('@') || attr.name.starts_with(':') || attr.name.starts_with('?')
3447 })
3448 }
3449
3450 fn strip_template_comments(&mut self, html: String) -> Result<String> {
3451 let mut ranges = Vec::new();
3452 let mut style_ranges = Vec::new();
3453 Self::collect_html_comment_and_style_ranges(&html, &mut ranges, &mut style_ranges)?;
3454
3455 for (style_start, style_end) in style_ranges {
3456 let css = &html[style_start..style_end];
3457 let (_tokens, _defs, _requirements, comments) = self
3458 .css_parser
3459 .extract_tokens_definitions_requirements_and_comments(
3460 css,
3461 self.options.legal_comments,
3462 )
3463 .map_err(|e| self.css_diagnostic(e))?;
3464 for comment in comments {
3465 let comment_text = &css[comment.start_byte..comment.end_byte];
3466 if comment.preserve || self.css_signal_comment_fragment(comment_text).is_some() {
3467 continue;
3468 }
3469 ranges.push((
3470 style_start + comment.start_byte,
3471 style_start + comment.end_byte,
3472 ));
3473 }
3474 }
3475
3476 if ranges.is_empty() {
3477 return Ok(html);
3478 }
3479
3480 Ok(comment_policy::strip_ranges(&html, ranges.as_mut_slice()).into_owned())
3481 }
3482
3483 fn collect_html_comment_and_style_ranges(
3485 html: &str,
3486 comment_ranges: &mut Vec<(usize, usize)>,
3487 style_ranges: &mut Vec<(usize, usize)>,
3488 ) -> Result<()> {
3489 let mut index = 0usize;
3490 while index < html.len() {
3491 let remaining = &html[index..];
3492 if remaining.starts_with("<!--") {
3493 let Some(end) = html::find_comment_close(remaining) else {
3494 return Err(Diagnostic::error("unterminated HTML comment")
3495 .code(codes::UNTERMINATED_HTML_COMMENT)
3496 .at_offset(html, index)
3497 .help("close the comment with `-->`")
3498 .into());
3499 };
3500 comment_ranges.push((index, index + end));
3501 index += end;
3502 continue;
3503 }
3504
3505 if remaining.starts_with('<') {
3506 if let Some((style_start, style_end, close_end)) =
3507 html::style_element_bounds(remaining)
3508 {
3509 style_ranges.push((index + style_start, index + style_end));
3510 index += close_end;
3511 continue;
3512 }
3513
3514 if let Some(tag) = html::parse_tag(remaining) {
3515 index += tag.close + 1;
3516 continue;
3517 }
3518 }
3519
3520 let Some(ch) = remaining.chars().next() else {
3521 break;
3522 };
3523 index += ch.len_utf8();
3524 }
3525 Ok(())
3526 }
3527
3528 fn strip_template_build_attrs(&mut self, html: &str, preserve_runtime_attrs: bool) -> String {
3534 let Some(tag) = html::parse_tag(html) else {
3535 return html.to_string();
3536 };
3537
3538 let mut removals: Vec<(usize, usize)> = Vec::new();
3539 for attr in tag.attrs() {
3540 let runtime_attr = attr.name.starts_with('@')
3541 || attr.name.starts_with(':')
3542 || attr.name.starts_with('?');
3543 let policy_attr = matches!(
3544 attr.name,
3545 COMPONENT_RENDER_ATTR | COMPONENT_HYDRATE_ATTR | COMPONENT_RESERVE_BLOCK_SIZE_ATTR
3546 );
3547 if policy_attr || (!preserve_runtime_attrs && runtime_attr) {
3548 let mut start = attr.raw_range.start;
3549 while start > 0 && html.as_bytes()[start - 1].is_ascii_whitespace() {
3550 start -= 1;
3551 }
3552 removals.push((start, attr.raw_range.end));
3553 }
3554 }
3555
3556 if removals.is_empty() {
3557 return html.to_string();
3558 }
3559
3560 let mut result = String::with_capacity(html.len());
3562 let mut pos = 0;
3563 for (start, end) in &removals {
3564 result.push_str(&html[pos..*start]);
3565 pos = *end;
3566 }
3567 result.push_str(&html[pos..]);
3568 result
3569 }
3570}
3571
3572#[cfg(test)]
3573mod tests {
3574 use webui_test_utils::*;
3575
3576 use super::*;
3577
3578 fn structural_matcher(value: &str) -> FragmentMatcher {
3579 signal_raw(&format!("{STRUCTURAL_SIGNAL_PREFIX}{value}"))
3580 }
3581
3582 #[test]
3583 fn test_plugin_display_names() {
3584 assert_eq!(Plugin::Fast.to_string(), "fast");
3585 assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
3586 assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
3587 assert_eq!(Plugin::WebUI.to_string(), "webui");
3588 }
3589
3590 #[test]
3591 fn test_plugin_from_str_names() {
3592 assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
3593 assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
3594 assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
3595 assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
3596 }
3597
3598 #[test]
3599 fn test_plugin_from_str_unknown_mentions_supported_names() {
3600 let err = "legacy".parse::<Plugin>().unwrap_err();
3601 assert!(
3602 err.contains("fast-v3"),
3603 "error should mention fast-v3: {err}"
3604 );
3605 assert!(
3606 err.contains("fast-v2"),
3607 "error should mention fast-v2: {err}"
3608 );
3609 assert!(err.contains("fast"), "error should mention fast: {err}");
3610 }
3611
3612 #[test]
3613 fn test_parse_signal() {
3614 let mut parser = HtmlParser::new();
3615 let html = "Hello, {{name}}!";
3616 let result = parser.parse("test.html", html);
3617
3618 assert!(result.is_ok());
3619 let fragment_records = parser.into_fragment_records();
3620
3621 assert_stream!(
3622 fragment_records,
3623 "test.html",
3624 [raw("Hello, "), signal("name"), raw("!"),]
3625 );
3626 }
3627
3628 #[test]
3629 fn test_parse_raw_signal() {
3630 let mut parser = HtmlParser::new();
3631 let html = "Hello, {{{html_content}}}!";
3632 let result = parser.parse("test.html", html);
3633
3634 assert!(result.is_ok());
3635 let fragment_records = parser.into_fragment_records();
3636
3637 assert_stream!(
3638 fragment_records,
3639 "test.html",
3640 [raw("Hello, "), signal_raw("html_content"), raw("!"),]
3641 );
3642 }
3643
3644 #[test]
3645 fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
3646 let mut parser = HtmlParser::new();
3647 let html = "<nav>{{sectionName}} > {{topicName}}</nav>";
3648 let result = parser.parse("test.html", html);
3649
3650 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3651 let fragment_records = parser.into_fragment_records();
3652
3653 assert_stream!(
3654 fragment_records,
3655 "test.html",
3656 [
3657 raw("<nav>"),
3658 signal("sectionName"),
3659 raw(" > "),
3660 signal("topicName"),
3661 raw("</nav>"),
3662 ]
3663 );
3664 }
3665
3666 #[test]
3667 fn test_invalid_for_each_is_template_diagnostic() {
3668 let mut parser = HtmlParser::new();
3669 let err = parser
3670 .parse(
3671 "index.html",
3672 "<div>\n <for each=\"person inpeople\"><p>x</p></for>\n</div>",
3673 )
3674 .expect_err("invalid for-each must error");
3675 assert!(matches!(err, ParserError::Template(_)));
3676 let msg = err.to_string();
3677 assert!(msg.contains("invalid <for> each expression"), "{msg}");
3678 assert!(msg.contains(r#"each="person inpeople""#), "{msg}");
3679 assert!(
3682 msg.contains("--> index.html:2:3"),
3683 "missing line:column — {msg}"
3684 );
3685 assert!(msg.contains("help:"), "{msg}");
3686 }
3687
3688 #[test]
3689 fn test_invalid_if_condition_is_template_diagnostic() {
3690 let mut parser = HtmlParser::new();
3691 let err = parser
3692 .parse("index.html", r#"<if condition="a +"><p>x</p></if>"#)
3693 .expect_err("invalid condition must error");
3694 assert!(matches!(err, ParserError::Template(_)));
3695 let msg = err.to_string();
3696 assert!(msg.contains("invalid <if> condition expression"), "{msg}");
3697 assert!(
3698 msg.contains("--> index.html:1:1"),
3699 "missing line:column — {msg}"
3700 );
3701 }
3702
3703 #[test]
3704 fn test_parse_for_directive() {
3705 let mut parser = HtmlParser::new();
3706 let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
3707
3708 let result = parser.parse("test.html", html);
3709 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3710 let fragment_records = parser.into_fragment_records();
3711
3712 assert_stream!(
3713 fragment_records,
3714 "test.html",
3715 [for_loop("item", "items", "for-1"),]
3716 );
3717
3718 assert_stream!(
3720 fragment_records,
3721 "for-1",
3722 [
3723 raw("<div class=\"item\">"),
3724 signal("item.name"),
3725 raw("</div>"),
3726 ]
3727 );
3728 }
3729
3730 #[test]
3731 fn test_parse_if_directive() {
3732 let mut parser = HtmlParser::new();
3733 let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
3734
3735 let result = parser.parse("test.html", html);
3736 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3737 let fragment_records = parser.into_fragment_records();
3738
3739 assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
3740
3741 assert_stream!(
3743 fragment_records,
3744 "if-1",
3745 [
3746 raw("<div>Welcome back, "),
3747 signal("username"),
3748 raw("!</div>"),
3749 ]
3750 );
3751 }
3752
3753 #[test]
3754 fn test_component_directive() {
3755 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3756 parser
3757 .component_registry
3758 .register_component(ComponentRegistration::new(
3759 "my-component",
3760 "<div>My Component</div>",
3761 Some("div { color: blue; }"),
3762 true,
3763 ))
3764 .expect("Failed to register component");
3765
3766 let result = parser.parse("test.html", "<my-component></my-component>");
3767 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3768 let records = parser.into_fragment_records();
3769
3770 assert_stream!(
3771 records,
3772 "test.html",
3773 [
3774 raw("<my-component"),
3775 structural_matcher("streaming_root:my-component"),
3776 raw(">"),
3777 component("my-component"),
3778 raw("</my-component>"),
3779 ]
3780 );
3781
3782 let comp = &records["my-component"].fragments;
3784 assert_eq!(comp.len(), 1);
3785 assert!(
3786 matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3787 !raw.value.contains("<template shadowrootmode") && raw.value.contains("<div>My Component</div>"))
3788 );
3789 }
3790
3791 #[test]
3792 fn streaming_root_signal_emitted_before_component_opening_tag_closes() {
3793 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3798 parser
3799 .component_registry
3800 .register_component(ComponentRegistration::new(
3801 "my-widget",
3802 "<div>w</div>",
3803 None,
3804 true,
3805 ))
3806 .expect("register");
3807
3808 let result = parser.parse("test.html", "<my-widget></my-widget>");
3809 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3810 let records = parser.into_fragment_records();
3811
3812 assert_stream!(
3813 records,
3814 "test.html",
3815 [
3816 raw("<my-widget"),
3817 structural_matcher("streaming_root:my-widget"),
3818 raw(">"),
3819 component("my-widget"),
3820 raw("</my-widget>"),
3821 ]
3822 );
3823 }
3824
3825 #[test]
3826 fn streaming_root_signal_emitted_per_component_when_nested_and_repeated() {
3827 use webui_protocol::web_ui_fragment::Fragment;
3830 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3831 for name in ["outer-box", "inner-pill"] {
3832 parser
3833 .component_registry
3834 .register_component(ComponentRegistration::new(
3835 name,
3836 "<slot></slot>",
3837 None,
3838 true,
3839 ))
3840 .expect("register");
3841 }
3842
3843 let result = parser.parse(
3844 "test.html",
3845 "<outer-box><inner-pill></inner-pill></outer-box><inner-pill></inner-pill>",
3846 );
3847 assert!(result.is_ok(), "Parse error: {:?}", result.err());
3848 let records = parser.into_fragment_records();
3849 let fragments = &records["test.html"].fragments;
3850
3851 let signals: Vec<&str> = fragments
3852 .iter()
3853 .filter_map(|f| match f.fragment.as_ref() {
3854 Some(Fragment::Signal(s)) if s.raw => Some(s.value.as_str()),
3855 _ => None,
3856 })
3857 .collect();
3858 assert_eq!(
3859 signals,
3860 [
3861 "}}}webui:streaming_root:outer-box",
3862 "}}}webui:streaming_root:inner-pill",
3863 "}}}webui:streaming_root:inner-pill",
3864 ]
3865 );
3866
3867 for (i, frag) in fragments.iter().enumerate() {
3870 let Some(Fragment::Signal(s)) = frag.fragment.as_ref() else {
3871 continue;
3872 };
3873 if !s.raw || !s.value.starts_with("}}}webui:streaming_root:") {
3874 continue;
3875 }
3876 match fragments[i + 1].fragment.as_ref() {
3877 Some(Fragment::Raw(r)) => assert_eq!(r.value, ">"),
3878 other => panic!("expected raw(\">\") after signal, got {other:?}"),
3879 }
3880 }
3881 }
3882
3883 #[test]
3884 fn unknown_component_typo_in_same_namespace_errors_with_suggestion() {
3885 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3886 parser
3887 .component_registry
3888 .register_component(ComponentRegistration::new(
3889 "mp-button",
3890 "<button>b</button>",
3891 None,
3892 true,
3893 ))
3894 .expect("Failed to register component");
3895
3896 let err = parser
3898 .parse("test.html", "<mp-buton></mp-buton>")
3899 .expect_err("a near-typo component tag should error");
3900 let ParserError::Template(diag) = err else {
3901 panic!("expected ParserError::Template, got {err:?}");
3902 };
3903 assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
3904 let help = diag
3905 .help_text()
3906 .expect("diagnostic should carry a help line");
3907 assert!(
3908 help.contains("did you mean <mp-button>?"),
3909 "expected a suggestion, got: {help}"
3910 );
3911 }
3912
3913 #[test]
3914 fn external_custom_element_in_other_namespace_passes_through() {
3915 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3916 parser
3917 .component_registry
3918 .register_component(ComponentRegistration::new(
3919 "mp-button",
3920 "<button>b</button>",
3921 None,
3922 true,
3923 ))
3924 .expect("Failed to register component");
3925
3926 let result = parser.parse("test.html", "<md-button></md-button>");
3929 assert!(
3930 result.is_ok(),
3931 "external custom element should pass through, got: {:?}",
3932 result.err()
3933 );
3934 }
3935
3936 #[test]
3937 fn for_missing_each_suggests_typoed_attribute() {
3938 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3939 let err = parser
3941 .parse("test.html", "<for eahc=\"todo in todos\"></for>")
3942 .expect_err("a <for> without `each` should error");
3943 let ParserError::Template(diag) = err else {
3944 panic!("expected ParserError::Template, got {err:?}");
3945 };
3946 assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
3947 let help = diag
3948 .help_text()
3949 .expect("diagnostic should carry a help line");
3950 assert!(
3951 help.contains("found `eahc`") && help.contains("did you mean"),
3952 "expected an attribute-typo suggestion, got: {help}"
3953 );
3954 }
3955
3956 #[test]
3957 fn parent_diagnostic_owner_survives_nested_component_parse() {
3958 let mut parser = HtmlParser::with_options(DomStrategy::Light);
3963 parser
3964 .component_registry
3965 .register_component(ComponentRegistration::new(
3966 "my-card",
3967 "<div>card</div>",
3968 None,
3969 true,
3970 ))
3971 .expect("register");
3972
3973 let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
3975 let err = parser
3976 .parse("index.html", html)
3977 .expect_err("the malformed <for> should error");
3978 let ParserError::Template(diag) = err else {
3979 panic!("expected ParserError::Template, got {err:?}");
3980 };
3981 assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
3982 assert_eq!(
3983 diag.component_name(),
3984 Some("index.html"),
3985 "parent diagnostic must be owned by index.html, not the nested component"
3986 );
3987 }
3988
3989 #[test]
3990 fn test_component_directive_with_slots() {
3991 let mut parser = HtmlParser::new();
3992 parser
3993 .component_registry
3994 .register_component(ComponentRegistration::new(
3995 "my-component",
3996 "<div>My Component</div>",
3997 Some("div { color: blue; }"),
3998 true,
3999 ))
4000 .expect("Failed to register component");
4001
4002 let result = parser.parse(
4003 "test.html",
4004 "Hello<my-component><p>World</p></my-component>",
4005 );
4006 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4007 let records = parser.into_fragment_records();
4008 let fragments = &records["test.html"].fragments;
4009
4010 assert!(fragments.len() >= 3);
4012 assert!(
4015 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component"))
4016 );
4017 assert!(fragments.iter().any(|f| matches!(
4019 f.fragment.as_ref(),
4020 Some(Fragment::Component(c)) if c.fragment_id == "my-component"
4021 )));
4022 let last = fragments.last().unwrap();
4024 assert!(
4025 matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
4026 );
4027 }
4028
4029 #[test]
4030 fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
4031 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
4036
4037 parser
4038 .component_registry
4039 .register_component(ComponentRegistration::new(
4040 "app-shell",
4041 r#"<template shadowrootmode="open">
4042 <my-navbar></my-navbar>
4043 <main><outlet /></main>
4044 <cart-panel></cart-panel>
4045 <my-footer></my-footer>
4046 </template>"#,
4047 Some(":host{display:flex}"),
4048 true,
4049 ))
4050 .expect("register app-shell");
4051 parser
4052 .component_registry
4053 .register_component(ComponentRegistration::new(
4054 "my-navbar",
4055 "<nav>Nav</nav>",
4056 Some("nav{color:red}"),
4057 true,
4058 ))
4059 .expect("register my-navbar");
4060 parser
4061 .component_registry
4062 .register_component(ComponentRegistration::new(
4063 "cart-panel",
4064 "<aside>Cart</aside>",
4065 Some("aside{color:green}"),
4066 true,
4067 ))
4068 .expect("register cart-panel");
4069 parser
4070 .component_registry
4071 .register_component(ComponentRegistration::new(
4072 "my-footer",
4073 "<footer>Footer</footer>",
4074 Some("footer{color:blue}"),
4075 true,
4076 ))
4077 .expect("register my-footer");
4078
4079 parser
4080 .parse("index.html", "<app-shell></app-shell>")
4081 .expect("parse index.html");
4082 let records = parser.into_fragment_records();
4083
4084 let app_frags = &records["app-shell"].fragments;
4086 let component_ids: Vec<&str> = app_frags
4087 .iter()
4088 .filter_map(|f| match f.fragment.as_ref() {
4089 Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
4090 _ => None,
4091 })
4092 .collect();
4093
4094 assert!(
4095 component_ids.contains(&"my-navbar"),
4096 "app-shell fragments should contain my-navbar: {component_ids:?}"
4097 );
4098 assert!(
4099 component_ids.contains(&"cart-panel"),
4100 "app-shell fragments should contain cart-panel: {component_ids:?}"
4101 );
4102 assert!(
4103 component_ids.contains(&"my-footer"),
4104 "app-shell fragments should contain my-footer: {component_ids:?}"
4105 );
4106 }
4107
4108 #[test]
4111 fn test_component_no_double_wrap_template() {
4112 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4116 parser
4117 .component_registry
4118 .register_component(ComponentRegistration::new(
4119 "custom-element",
4120 r#"<template foo="bar"><slot></slot></template>"#,
4121 None,
4122 true,
4123 ))
4124 .expect("register");
4125 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4126 assert!(result.is_ok());
4127 let records = parser.into_fragment_records();
4128
4129 assert_fragments!(
4130 records["index.html"].fragments,
4131 [
4132 raw("<custom-element"),
4133 structural_matcher("streaming_root:custom-element"),
4134 raw(">"),
4135 component("custom-element"),
4136 raw("Hello</custom-element>"),
4137 ]
4138 );
4139
4140 assert_stream!(
4141 records,
4142 "custom-element",
4143 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
4144 );
4145 }
4146
4147 #[test]
4148 fn test_component_styled_no_double_wrap() {
4149 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4154 parser
4155 .component_registry
4156 .register_component(ComponentRegistration::new(
4157 "custom-element",
4158 r#"<template foo="bar"><slot></slot></template>"#,
4159 Some("div { color: red; }"),
4160 true,
4161 ))
4162 .expect("register");
4163 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4164 assert!(result.is_ok());
4165 let records = parser.into_fragment_records();
4166
4167 assert_stream!(
4168 records,
4169 "custom-element",
4170 [raw(r#"<template foo="bar"><slot></slot></template>"#),]
4171 );
4172 }
4173
4174 #[test]
4175 fn test_component_strip_runtime_attrs() {
4176 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4181 parser
4182 .component_registry
4183 .register_component(ComponentRegistration::new(
4184 "custom-element",
4185 r#"<template @click={foo} :bar="baz" ?bool="true"><slot></slot></template>"#,
4186 None,
4187 true,
4188 ))
4189 .expect("register");
4190 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4191 assert!(result.is_ok());
4192 let records = parser.into_fragment_records();
4193
4194 assert_stream!(
4195 records,
4196 "custom-element",
4197 [raw("<template><slot></slot></template>"),]
4198 );
4199 }
4200
4201 #[test]
4202 fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
4203 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4204 parser
4205 .component_registry
4206 .register_component(ComponentRegistration::new(
4207 "custom-element",
4208 r#"<template data-note="@click={foo}" @click={foo}><slot></slot></template>"#,
4209 None,
4210 true,
4211 ))
4212 .expect("register");
4213 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4214 assert!(result.is_ok());
4215 let records = parser.into_fragment_records();
4216
4217 assert_stream!(
4218 records,
4219 "custom-element",
4220 [raw(
4221 r#"<template data-note="@click={foo}"><slot></slot></template>"#
4222 ),]
4223 );
4224 }
4225
4226 #[test]
4227 fn test_component_strip_runtime_attrs_handles_duplicate_runtime_attrs() {
4228 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4229 parser
4230 .component_registry
4231 .register_component(ComponentRegistration::new(
4232 "custom-element",
4233 r#"<template @click={foo} @click={bar}><slot></slot></template>"#,
4234 None,
4235 true,
4236 ))
4237 .expect("register");
4238 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4239 assert!(result.is_ok());
4240 let records = parser.into_fragment_records();
4241
4242 assert_stream!(
4243 records,
4244 "custom-element",
4245 [raw("<template><slot></slot></template>"),]
4246 );
4247 }
4248
4249 #[test]
4250 fn test_component_with_slots_and_attrs() {
4251 let mut parser = HtmlParser::new();
4252 parser
4253 .component_registry
4254 .register_component(ComponentRegistration::new(
4255 "custom-element",
4256 "<slot></slot>",
4257 None,
4258 true,
4259 ))
4260 .expect("register");
4261 let result = parser.parse(
4262 "index.html",
4263 r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
4264 );
4265 assert!(result.is_ok());
4266 let records = parser.into_fragment_records();
4267 assert_fragments!(
4268 records["index.html"].fragments,
4269 [
4270 raw("<custom-element"),
4271 attr_raw_start("appearance", "subtle"),
4272 structural_matcher("streaming_root:custom-element"),
4273 raw(">"),
4274 component("custom-element"),
4275 raw("Hello World</custom-element>"),
4276 ]
4277 );
4278 }
4279
4280 #[test]
4281 fn test_component_legacy_no_styles() {
4282 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4283 parser
4284 .component_registry
4285 .register_component(ComponentRegistration::new(
4286 "custom-element",
4287 "<div>Custom Element</div>",
4288 None,
4289 true,
4290 ))
4291 .expect("register");
4292 let result = parser.parse("index.html", "<custom-element></custom-element>");
4293 assert!(result.is_ok());
4294 let records = parser.into_fragment_records();
4295
4296 assert_fragments!(
4297 records["index.html"].fragments,
4298 [
4299 raw("<custom-element"),
4300 structural_matcher("streaming_root:custom-element"),
4301 raw(">"),
4302 component("custom-element"),
4303 raw("</custom-element>"),
4304 ]
4305 );
4306
4307 assert_stream!(
4308 records,
4309 "custom-element",
4310 [raw("<div>Custom Element</div>"),]
4311 );
4312 }
4313
4314 #[test]
4315 fn test_component_self_closing() {
4316 let mut parser = HtmlParser::new();
4317 parser
4318 .component_registry
4319 .register_component(ComponentRegistration::new(
4320 "custom-widget",
4321 "<div>Widget Content</div>",
4322 None,
4323 true,
4324 ))
4325 .expect("register");
4326 let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
4327 assert!(result.is_ok());
4328 let records = parser.into_fragment_records();
4329
4330 assert_fragments!(
4331 records["index.html"].fragments,
4332 [
4333 raw("<custom-widget"),
4334 attr_start("config", "settings"),
4335 structural_matcher("streaming_root:custom-widget"),
4336 raw("/>"),
4337 component("custom-widget"),
4338 ]
4339 );
4340 }
4341
4342 #[test]
4343 fn test_component_nested_self_closing_in_slot() {
4344 let mut parser = HtmlParser::with_options(DomStrategy::Light);
4345 parser
4346 .component_registry
4347 .register_component(ComponentRegistration::new(
4348 "custom-icon",
4349 "<svg><slot></slot></svg>",
4350 None,
4351 true,
4352 ))
4353 .expect("register");
4354 let result = parser.parse(
4355 "index.html",
4356 r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
4357 );
4358 assert!(result.is_ok());
4359 let records = parser.into_fragment_records();
4360
4361 assert_fragments!(
4362 records["index.html"].fragments,
4363 [
4364 raw("<custom-icon"),
4365 structural_matcher("streaming_root:custom-icon"),
4366 raw(">"),
4367 component("custom-icon"),
4368 raw("<use"),
4369 attr_template("href", "attr-1"),
4370 raw("/></custom-icon>"),
4371 ]
4372 );
4373
4374 assert_stream!(records, "custom-icon", [raw("<svg><slot></slot></svg>"),]);
4375 }
4376
4377 #[test]
4378 fn test_component_leading_boolean_attr_start() {
4379 let mut parser = HtmlParser::new();
4380 parser
4381 .component_registry
4382 .register_component(ComponentRegistration::new(
4383 "custom-element",
4384 "<slot></slot>",
4385 None,
4386 true,
4387 ))
4388 .expect("register");
4389 let result = parser.parse(
4390 "index.html",
4391 r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
4392 );
4393 assert!(result.is_ok());
4394 let records = parser.into_fragment_records();
4395
4396 assert_fragments!(
4397 records["index.html"].fragments,
4398 [
4399 raw("<custom-element"),
4400 bool_attr_start("disabled", "isDisabled"),
4402 attr_raw("title", "Hello"),
4404 structural_matcher("streaming_root:custom-element"),
4405 raw(">"),
4406 component("custom-element"),
4407 raw("</custom-element>"),
4408 ]
4409 );
4410 }
4411
4412 #[test]
4413 fn test_component_boolean_predicate_preserves_condition_tree() {
4414 let mut parser = HtmlParser::new();
4415 parser
4416 .component_registry
4417 .register_component(ComponentRegistration::new(
4418 "custom-element",
4419 "<slot></slot>",
4420 None,
4421 true,
4422 ))
4423 .expect("register");
4424 let result = parser.parse(
4425 "index.html",
4426 r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
4427 );
4428 assert!(result.is_ok());
4429
4430 let records = parser.into_fragment_records();
4431 let fragments = &records["index.html"].fragments;
4432 match fragments
4433 .get(1)
4434 .and_then(|fragment| fragment.fragment.as_ref())
4435 {
4436 Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
4437 assert_eq!(attr.name, "disabled");
4438 assert!(attr.attr_start);
4439 match attr
4440 .condition_tree
4441 .as_ref()
4442 .and_then(|condition| condition.expr.as_ref())
4443 {
4444 Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
4445 assert_eq!(pred.left, "page");
4446 assert_eq!(pred.operator, 3);
4447 assert_eq!(pred.right, "'dashboard'");
4448 }
4449 other => panic!("expected predicate condition tree, got {:?}", other),
4450 }
4451 }
4452 other => panic!("expected attribute fragment, got {:?}", other),
4453 }
4454 }
4455
4456 #[test]
4457 fn test_component_meta_link_tags() {
4458 let (fragments, _) = parse_and_get_fragments(
4459 r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
4460 );
4461 assert!(fragments.len() >= 7);
4462 assert!(
4463 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "<head>")
4464 );
4465 assert!(
4466 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
4467 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
4468 );
4469 assert!(
4470 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<meta charset=\"utf-8\"") && raw.value.contains("<link"))
4471 );
4472 assert!(
4473 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
4474 );
4475 assert!(
4476 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
4477 );
4478 assert!(
4479 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
4480 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
4481 );
4482 assert!(
4483 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
4484 );
4485 }
4486
4487 #[test]
4488 fn test_nested_directives() {
4489 let mut parser = HtmlParser::new();
4490 let html = r#"<for each="category in categories">
4491 <if condition="category.hasItems">
4492 <for each="item in category.items">
4493 {{item.title}}
4494 </for>
4495 </if>
4496 </for>"#;
4497
4498 let result = parser.parse("test.html", html);
4499
4500 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4501 let fragment_records = parser.into_fragment_records();
4502
4503 assert_stream!(
4504 fragment_records,
4505 "test.html",
4506 [for_loop("category", "categories", "for-1"),]
4507 );
4508
4509 assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
4510
4511 assert_stream!(
4512 fragment_records,
4513 "if-1",
4514 [for_loop("item", "category.items", "for-2"),]
4515 );
4516
4517 assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
4518 }
4519
4520 #[test]
4521 fn test_complex_directives() {
4522 let mut parser = HtmlParser::new();
4523 let html = r#"<for each="category in categories">
4524 <div class="category">
4525 <h2>{{category.name}}</h2>
4526 <if condition="category.hasItems">
4527 <ul>
4528 <for each="item in category.items">
4529 <li>{{item.title}}</li>
4530 </for>
4531 </ul>
4532 </if>
4533 </div>
4534 </for>"#;
4535
4536 let result = parser.parse("test.html", html);
4537
4538 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4539 let fragment_records = parser.into_fragment_records();
4540
4541 assert_stream!(
4542 fragment_records,
4543 "test.html",
4544 [for_loop("category", "categories", "for-1"),]
4545 );
4546
4547 assert_stream!(
4549 fragment_records,
4550 "for-1",
4551 [
4552 raw("<div class=\"category\"><h2>"),
4553 signal("category.name"),
4554 raw("</h2>"),
4555 if_cond("if-1"),
4556 raw("</div>"),
4557 ]
4558 );
4559
4560 assert_stream!(
4562 fragment_records,
4563 "if-1",
4564 [
4565 raw("<ul>"),
4566 for_loop("item", "category.items", "for-2"),
4567 raw("</ul>"),
4568 ]
4569 );
4570
4571 assert_stream!(
4573 fragment_records,
4574 "for-2",
4575 [raw("<li>"), signal("item.title"), raw("</li>"),]
4576 );
4577 }
4578
4579 fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
4583 let mut parser = HtmlParser::new();
4584 let result = parser.parse("index.html", html);
4585 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4586 let records = parser.into_fragment_records();
4587 let fragments = records
4588 .get("index.html")
4589 .expect("Failed to get index.html fragment")
4590 .fragments
4591 .clone();
4592 (fragments, records)
4593 }
4594
4595 fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
4597 let mut parser = HtmlParser::new();
4598 parser
4599 .component_registry
4600 .register_component(ComponentRegistration::new(tag, "<div></div>", None, true))
4601 .expect("register");
4602 let result = parser.parse("index.html", html);
4603 assert!(result.is_ok(), "Parse error: {:?}", result.err());
4604 let records = parser.into_fragment_records();
4605 let fragments = records
4606 .get("index.html")
4607 .expect("Failed to get index.html fragment")
4608 .fragments
4609 .clone();
4610 (fragments, records)
4611 }
4612
4613 #[test]
4614 fn test_attribute_handlebars_in_href() {
4615 let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
4617 assert_fragments!(
4618 fragments,
4619 [
4620 raw("<a"),
4621 attr("href", "url"),
4622 raw(">"),
4623 signal("name"),
4624 raw("</a>"),
4625 ]
4626 );
4627 }
4628
4629 #[test]
4630 fn test_attribute_boolean_with_handlebars() {
4631 let (fragments, _) =
4633 parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
4634 assert_fragments!(
4635 fragments,
4636 [
4637 raw("<button"),
4638 bool_attr("disabled", "isDisabled"),
4639 raw(">Click</button>"),
4640 ]
4641 );
4642 }
4643
4644 #[test]
4645 fn test_attribute_multiple_boolean() {
4646 let (fragments, _) =
4649 parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
4650
4651 assert_fragments!(
4652 fragments,
4653 [
4654 raw("<input"),
4655 bool_attr("checked", "isChecked"),
4656 bool_attr("disabled", "isDisabled"),
4657 raw("/>"),
4658 ]
4659 );
4660 }
4661
4662 #[test]
4663 fn test_attribute_boolean_and_regular_together() {
4664 let (fragments, _) = parse_and_get_fragments(
4667 r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
4668 );
4669
4670 assert_fragments!(
4671 fragments,
4672 [
4673 raw("<button"),
4674 bool_attr("disabled", "isDisabled"),
4675 raw(" type=\"button\">Hi</button>"),
4676 ]
4677 );
4678 }
4679
4680 #[test]
4681 fn test_attribute_boolean_sandwiched() {
4682 let (fragments, _) = parse_and_get_fragments(
4685 r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
4686 );
4687
4688 assert_fragments!(
4689 fragments,
4690 [
4691 raw("<button"),
4692 attr("version", "edition"),
4693 bool_attr("disabled", "isDisabled"),
4694 raw(" type=\"button\">Hi</button>"),
4695 ]
4696 );
4697 }
4698
4699 #[test]
4700 fn test_attribute_boolean_ending() {
4701 let (fragments, _) = parse_and_get_fragments(
4704 r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
4705 );
4706
4707 assert_fragments!(
4708 fragments,
4709 [
4710 raw("<button"),
4711 attr("version", "edition"),
4712 bool_attr("disabled", "isDisabled"),
4713 raw(">Hi</button>"),
4714 ]
4715 );
4716 }
4717
4718 #[test]
4719 fn test_attribute_boolean_dotted_path() {
4720 let (fragments, _) =
4723 parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
4724
4725 assert_fragments!(
4726 fragments,
4727 [
4728 raw("<div"),
4729 bool_attr("checked", "layout.isPinned"),
4730 raw(">Content</div>"),
4731 ]
4732 );
4733 }
4734
4735 #[test]
4736 fn test_attribute_colon_prefixed_complex() {
4737 let (fragments, _) = parse_with_component(
4740 "my-component",
4741 r#"<my-component :config="{{settings}}"></my-component>"#,
4742 );
4743
4744 assert_fragments!(
4745 fragments,
4746 [
4747 raw("<my-component"),
4748 attr_complex_start(":config", "settings"),
4749 structural_matcher("streaming_root:my-component"),
4750 raw(">"),
4751 component("my-component"),
4752 raw("</my-component>"),
4753 ]
4754 );
4755 }
4756
4757 #[test]
4758 fn test_attribute_multiple_colon_prefixed() {
4759 let (fragments, _) = parse_with_component(
4762 "my-component",
4763 r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
4764 );
4765
4766 assert_fragments!(
4767 fragments,
4768 [
4769 raw("<my-component"),
4770 attr_complex_start(":prop1", "val1"),
4771 attr_complex(":prop2", "val2"),
4772 structural_matcher("streaming_root:my-component"),
4773 raw(">"),
4774 component("my-component"),
4775 raw("</my-component>"),
4776 ]
4777 );
4778 }
4779
4780 #[test]
4781 fn test_blocked_complex_property_innerhtml() {
4782 let mut parser = HtmlParser::new();
4783 let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
4784 assert!(
4785 result.is_err(),
4786 "Expected error for :innerHTML on native element"
4787 );
4788 let err = result.unwrap_err().to_string();
4789 assert!(
4790 err.contains("only allowed on custom elements"),
4791 "Error: {err}"
4792 );
4793 }
4794
4795 #[test]
4796 fn test_blocked_complex_on_native_element() {
4797 let mut parser = HtmlParser::new();
4798 let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
4799 assert!(
4800 result.is_err(),
4801 "Expected error for :data on native element"
4802 );
4803 let err = result.unwrap_err().to_string();
4804 assert!(
4805 err.contains("only allowed on custom elements"),
4806 "Error: {err}"
4807 );
4808 }
4809
4810 #[test]
4811 fn test_blocked_complex_property_on_component() {
4812 let mut parser = HtmlParser::new();
4813 parser
4814 .component_registry
4815 .register_component(ComponentRegistration::new(
4816 "my-widget",
4817 "<div></div>",
4818 None,
4819 true,
4820 ))
4821 .expect("register");
4822 let result = parser.parse(
4823 "index.html",
4824 r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
4825 );
4826 assert!(
4827 result.is_err(),
4828 "Expected error for :innerHTML on component"
4829 );
4830 let err = result.unwrap_err().to_string();
4831 assert!(err.contains("HTML injection"), "Error: {err}");
4832 }
4833
4834 #[test]
4835 fn test_allowed_complex_property() {
4836 let (fragments, _) = parse_with_component(
4838 "my-component",
4839 r#"<my-component :config="{{settings}}"></my-component>"#,
4840 );
4841 assert_fragments!(
4842 fragments,
4843 [
4844 raw("<my-component"),
4845 attr_complex_start(":config", "settings"),
4846 structural_matcher("streaming_root:my-component"),
4847 raw(">"),
4848 component("my-component"),
4849 raw("</my-component>"),
4850 ]
4851 );
4852 }
4853
4854 #[test]
4855 fn test_attribute_mixed_normal_boolean_colon() {
4856 let (fragments, _) = parse_with_component(
4859 "my-component",
4860 r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
4861 );
4862
4863 assert_fragments!(
4864 fragments,
4865 [
4866 raw("<my-component"),
4867 attr_raw_start("id", "comp"),
4868 attr_complex(":config", "settings"),
4869 bool_attr("enabled", "isEnabled"),
4870 structural_matcher("streaming_root:my-component"),
4871 raw(">"),
4872 component("my-component"),
4873 raw("</my-component>"),
4874 ]
4875 );
4876 }
4877
4878 #[test]
4879 fn test_attribute_reject_boolean_without_handlebars() {
4880 let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
4883
4884 assert_fragments!(fragments, [raw("<button></button>"),]);
4886 }
4887
4888 #[test]
4889 fn test_attribute_reject_boolean_with_partial_handlebars() {
4890 let (fragments, _) =
4893 parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
4894
4895 assert_fragments!(fragments, [raw("<button></button>"),]);
4897 }
4898
4899 #[test]
4900 fn test_attribute_reject_boolean_with_plain_value() {
4901 let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
4904
4905 assert_fragments!(fragments, [raw("<button>Click</button>"),]);
4907 }
4908
4909 #[test]
4910 fn test_attribute_boolean_predicate_equal() {
4911 let (fragments, _) =
4913 parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
4914 assert_fragments!(
4915 fragments,
4916 [
4917 raw("<div"),
4918 bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
4919 raw(">X</div>"),
4920 ]
4921 );
4922 }
4923
4924 #[test]
4925 fn test_attribute_boolean_predicate_greater_than() {
4926 let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
4928 assert_fragments!(
4929 fragments,
4930 [
4931 raw("<span"),
4932 bool_attr_predicate("hidden", "num", 1, "9"),
4933 raw(">X</span>"),
4934 ]
4935 );
4936 }
4937
4938 #[test]
4939 fn test_attribute_boolean_predicate_not_equal() {
4940 let (fragments, _) =
4942 parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
4943 assert_fragments!(
4944 fragments,
4945 [
4946 raw("<a"),
4947 bool_attr_predicate("data-active", "status", 4, "'inactive'"),
4948 raw(">X</a>"),
4949 ]
4950 );
4951 }
4952
4953 #[test]
4954 fn test_attribute_boolean_negation() {
4955 let (fragments, _) =
4957 parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
4958 assert_fragments!(
4959 fragments,
4960 [
4961 raw("<button"),
4962 bool_attr_not("disabled", "isReady"),
4963 raw(">X</button>"),
4964 ]
4965 );
4966 }
4967
4968 #[test]
4969 fn test_attribute_mixed_static_dynamic() {
4970 let (fragments, records) =
4973 parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
4974
4975 assert_fragments!(
4976 fragments,
4977 [
4978 raw("<textarea"),
4979 attr_template("value", "attr-1"),
4980 raw(">Hi</textarea>"),
4981 ]
4982 );
4983
4984 assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
4986 }
4987
4988 #[test]
4991 fn test_body_signals() {
4992 let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
4993 assert_fragments!(
4994 fragments,
4995 [
4996 raw("<body>"),
4997 structural_matcher("body_start"),
4998 raw("<app-shell></app-shell>"),
4999 structural_matcher("body_end"),
5000 raw("</body>"),
5001 ]
5002 );
5003 }
5004
5005 #[test]
5008 fn test_empty_for_produces_nothing() {
5009 let (fragments, records) =
5010 parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
5011 assert_fragments!(fragments, [raw("<div></div>"),]);
5012 assert!(!records.contains_key("for-1"));
5013 }
5014
5015 #[test]
5018 fn test_self_closing_svg_path() {
5019 let (fragments, _) =
5020 parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
5021 assert_fragments!(
5022 fragments,
5023 [raw(
5024 r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
5025 ),]
5026 );
5027 }
5028
5029 #[test]
5030 fn test_html5_void_elements() {
5031 let (fragments, _) = parse_and_get_fragments(
5032 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
5033 );
5034 assert_fragments!(
5035 fragments,
5036 [raw(
5037 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
5038 ),]
5039 );
5040 }
5041
5042 #[test]
5043 fn test_self_closing_with_dynamic_attributes() {
5044 let (fragments, _) =
5045 parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
5046 assert_fragments!(
5047 fragments,
5048 [
5049 raw("<img"),
5050 attr("src", "imageUrl"),
5051 attr("alt", "imageAlt"),
5052 raw("/>"),
5053 ]
5054 );
5055 }
5056
5057 #[test]
5058 fn test_self_closing_with_boolean_attributes() {
5059 let (fragments, _) = parse_and_get_fragments(
5060 r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
5061 );
5062 assert_fragments!(
5063 fragments,
5064 [
5065 raw("<input type=\"checkbox\""),
5066 bool_attr("checked", "isSelected"),
5067 bool_attr("disabled", "isDisabled"),
5068 raw("/>"),
5069 ]
5070 );
5071 }
5072
5073 #[test]
5074 fn test_multiple_self_closing_in_sequence() {
5075 let (fragments, _) =
5076 parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
5077 assert_fragments!(
5078 fragments,
5079 [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
5080 );
5081 }
5082
5083 #[test]
5084 fn test_self_closing_with_mixed_content() {
5085 let (fragments, _) =
5086 parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
5087 assert_fragments!(
5088 fragments,
5089 [
5090 raw("<div>Text before<img"),
5091 attr("src", "url"),
5092 raw("/>Text after</div>"),
5093 ]
5094 );
5095 }
5096
5097 #[test]
5098 fn test_self_closing_svg_elements() {
5099 let (fragments, _) = parse_and_get_fragments(
5100 r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
5101 );
5102 assert_fragments!(
5103 fragments,
5104 [
5105 raw("<svg><circle"),
5106 attr("cx", "x"),
5107 attr("cy", "y"),
5108 raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
5109 ]
5110 );
5111 }
5112
5113 #[test]
5114 fn test_self_closing_inside_for_loop() {
5115 let (fragments, records) = parse_and_get_fragments(
5116 r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
5117 );
5118 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
5119 assert_stream!(
5120 records,
5121 "for-1",
5122 [raw("<img"), attr("src", "item.url"), raw("/>"),]
5123 );
5124 }
5125
5126 #[test]
5127 fn test_self_closing_whitespace_variations() {
5128 let (fragments, _) =
5129 parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
5130 assert_fragments!(
5131 fragments,
5132 [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
5133 );
5134 }
5135
5136 #[test]
5137 fn test_deeply_nested_self_closing() {
5138 let (fragments, _) = parse_and_get_fragments(
5139 r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
5140 );
5141 assert_fragments!(
5142 fragments,
5143 [raw(
5144 r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
5145 ),]
5146 );
5147 }
5148
5149 #[test]
5150 fn test_self_closing_vs_empty_regular_tags() {
5151 let (fragments, _) =
5152 parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
5153 assert_fragments!(
5154 fragments,
5155 [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
5156 );
5157 }
5158
5159 #[test]
5162 fn test_for_custom_template_attribute() {
5163 let (fragments, records) = parse_and_get_fragments(
5165 r#"<for each="item in items" template="static"><span>Item</span></for>"#,
5166 );
5167 assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
5168 assert_stream!(records, "static", [raw("<span>Item</span>"),]);
5169 }
5170
5171 #[test]
5172 fn test_for_recursive_template() {
5173 let mut parser = HtmlParser::new();
5175 let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
5176 let result = parser.parse("index.html", html);
5177 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5178 let records = parser.into_fragment_records();
5179
5180 assert_fragments!(
5181 records["index.html"].fragments,
5182 [for_loop("outerItem", "outerItems", "static"),]
5183 );
5184
5185 assert_stream!(
5186 records,
5187 "static",
5188 [
5189 raw("<div><span>"),
5190 signal("outerItem.name"),
5191 raw("</span>"),
5192 for_loop("innerItem", "innerItems", "static"),
5193 raw("</div>"),
5194 ]
5195 );
5196 }
5197
5198 #[test]
5201 fn test_if_multiple_children() {
5202 let (fragments, records) =
5204 parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
5205 assert_fragments!(fragments, [if_cond("if-1"),]);
5206 assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
5207 }
5208
5209 #[test]
5210 fn test_for_multiple_children() {
5211 let (fragments, records) =
5213 parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
5214 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
5215 assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
5216 }
5217
5218 #[test]
5221 fn test_handlebars_at_beginning() {
5222 let (fragments, _) = parse_and_get_fragments("{{first}}");
5224 assert_fragments!(fragments, [signal("first"),]);
5225 }
5226
5227 #[test]
5228 fn test_handlebars_at_beginning_and_raw() {
5229 let (fragments, _) = parse_and_get_fragments("{{first}}test");
5231 assert_fragments!(fragments, [signal("first"), raw("test"),]);
5232 }
5233
5234 #[test]
5235 fn test_handlebars_raw_and_end() {
5236 let (fragments, _) = parse_and_get_fragments("test{{first}}");
5238 assert_fragments!(fragments, [raw("test"), signal("first"),]);
5239 }
5240
5241 #[test]
5244 fn test_handlebars_invalid_triple_open() {
5245 let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
5247 assert_fragments!(fragments, [raw("{{{invalid}}"),]);
5248 }
5249
5250 #[test]
5251 fn test_handlebars_four_open_braces() {
5252 let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
5254 assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
5255 }
5256
5257 #[test]
5258 fn test_handlebars_five_open_with_valid_double() {
5259 let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
5261 assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
5262 }
5263
5264 #[test]
5265 fn test_entities_preserved() {
5266 let (fragments, _) = parse_and_get_fragments("<p>Hello}World</p>");
5268 assert_fragments!(fragments, [raw("<p>Hello}World</p>"),]);
5269 }
5270
5271 #[test]
5274 fn test_doctype_preserved() {
5275 let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
5277 assert!(!fragments.is_empty());
5278 assert!(
5279 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
5280 );
5281 }
5282
5283 #[test]
5286 fn test_component_attr_skip() {
5287 let mut parser = HtmlParser::new();
5289 parser
5290 .component_registry
5291 .register_component(ComponentRegistration::new(
5292 "custom-element",
5293 "<slot></slot>",
5294 None,
5295 true,
5296 ))
5297 .expect("register");
5298 let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
5299 let result = parser.parse("index.html", html);
5300 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5301 let records = parser.into_fragment_records();
5302
5303 assert_fragments!(
5306 records["index.html"].fragments,
5307 [
5308 raw("<custom-element"),
5309 attr_complex_start(":config", "config"),
5311 attr_skip("class", "value0"),
5313 attr_skip("style", "value1"),
5314 attr_skip("role", "value2"),
5315 attr_skip("data-test", "value3"),
5316 attr_skip("aria-test", "value4"),
5317 structural_matcher("streaming_root:custom-element"),
5318 raw(">"),
5319 component("custom-element"),
5320 raw("</custom-element>"),
5321 ]
5322 );
5323 }
5324
5325 #[test]
5326 fn test_component_attr_skip_static_and_embedded() {
5327 let mut parser = HtmlParser::new();
5330 parser
5331 .component_registry
5332 .register_component(ComponentRegistration::new(
5333 "item-group",
5334 "<slot></slot>",
5335 None,
5336 true,
5337 ))
5338 .expect("register");
5339
5340 let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
5341 let result = parser.parse("index.html", html);
5342 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5343 let records = parser.into_fragment_records();
5344
5345 assert_fragments!(
5346 records["index.html"].fragments,
5347 [
5348 raw("<item-group"),
5349 attr_skip_raw("role", "list"),
5350 attr_skip_template("aria-labelledby", "attr-1"),
5351 attr_skip_template("data-testid", "attr-2"),
5352 attr_skip_raw("class", "fixed-class"),
5353 structural_matcher("streaming_root:item-group"),
5354 raw(">"),
5355 component("item-group"),
5356 raw("</item-group>"),
5357 ]
5358 );
5359
5360 assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
5363 assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
5364 }
5365
5366 #[test]
5367 fn test_component_multiple_nested() {
5368 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5370 parser
5371 .component_registry
5372 .register_component(ComponentRegistration::new(
5373 "custom-element",
5374 "<custom-child></custom-child><slot></slot>",
5375 None,
5376 true,
5377 ))
5378 .expect("register");
5379 parser
5380 .component_registry
5381 .register_component(ComponentRegistration::new(
5382 "custom-button",
5383 "<slot></slot>",
5384 None,
5385 true,
5386 ))
5387 .expect("register");
5388 parser
5389 .component_registry
5390 .register_component(ComponentRegistration::new(
5391 "custom-child",
5392 "<h1>Hello World!</h1>",
5393 None,
5394 true,
5395 ))
5396 .expect("register");
5397
5398 let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
5399 let result = parser.parse("index.html", html);
5400 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5401 let records = parser.into_fragment_records();
5402
5403 assert_fragments!(
5405 records["index.html"].fragments,
5406 [for_loop("item", "items", "for-1"),]
5407 );
5408
5409 assert_stream!(
5411 records,
5412 "for-1",
5413 [
5414 raw("<custom-element"),
5415 structural_matcher("streaming_root:custom-element"),
5416 raw(">"),
5417 component("custom-element"),
5418 raw("<custom-button"),
5419 structural_matcher("streaming_root:custom-button"),
5420 raw(">"),
5421 component("custom-button"),
5422 raw("Ok</custom-button></custom-element>"),
5423 ]
5424 );
5425
5426 let ce = &records["custom-element"].fragments;
5428 assert_eq!(ce.len(), 5);
5429 assert!(
5430 matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child"))
5431 );
5432 assert!(
5433 matches!(ce[3].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
5434 );
5435 assert!(
5436 matches!(ce[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><slot></slot>"))
5437 );
5438
5439 assert_stream!(records, "custom-button", [raw("<slot></slot>"),]);
5440
5441 assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
5442 }
5443
5444 #[test]
5447 fn test_invalid_markup_returns_error() {
5448 let mut parser = HtmlParser::new();
5450 let result = parser.parse("index.html", "<div><span>Unclosed div");
5451 assert!(matches!(result, Err(ParserError::Template(diag)) if
5452 diag.to_string().contains("unclosed <div> tag")
5453 ));
5454 }
5455
5456 #[test]
5457 fn test_unterminated_opening_tag_returns_error() {
5458 let mut parser = HtmlParser::new();
5459 let result = parser.parse("index.html", "before <div");
5460 assert!(matches!(result, Err(ParserError::Template(diag)) if
5461 diag.to_string().contains("malformed HTML tag")
5462 ));
5463 }
5464
5465 #[test]
5466 fn test_unterminated_html_comment_returns_error() {
5467 let mut parser = HtmlParser::new();
5468 let result = parser.parse("index.html", "<div><!-- missing close</div>");
5469 assert!(matches!(result, Err(ParserError::Template(diag)) if
5470 diag.to_string().contains("unclosed <div> tag")
5471 || diag.to_string().contains("unterminated HTML comment")
5472 ));
5473 }
5474
5475 #[test]
5476 fn test_unexpected_closing_tag_returns_error() {
5477 let mut parser = HtmlParser::new();
5478 let result = parser.parse("index.html", "</span>");
5479 assert!(matches!(result, Err(ParserError::Template(diag)) if
5480 diag.to_string().contains("unexpected closing tag </span>")
5481 ));
5482 }
5483
5484 #[test]
5485 fn test_rejects_templates_over_size_limit() {
5486 let mut parser = HtmlParser::new();
5487 let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
5488 let result = parser.parse("index.html", &html);
5489
5490 assert!(matches!(result, Err(ParserError::Html(message)) if
5491 message.contains("exceeds") && message.contains("parser limit")
5492 ));
5493 }
5494
5495 #[test]
5496 fn test_rejects_templates_over_depth_limit() {
5497 let mut parser = HtmlParser::new();
5498 let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
5499 for _ in 0..=MAX_TEMPLATE_DEPTH {
5500 html.push_str("<div>");
5501 }
5502 for _ in 0..=MAX_TEMPLATE_DEPTH {
5503 html.push_str("</div>");
5504 }
5505
5506 let result = parser.parse("index.html", &html);
5507
5508 assert!(matches!(result, Err(ParserError::Template(diag)) if
5509 diag.to_string().contains("nesting exceeds")
5510 ));
5511 }
5512
5513 #[test]
5514 fn test_rejects_routes_over_depth_limit() {
5515 let mut parser = HtmlParser::new();
5516 parser
5517 .component_registry
5518 .register_component(ComponentRegistration::new(
5519 "route-page",
5520 "<slot></slot>",
5521 None,
5522 true,
5523 ))
5524 .expect("register");
5525
5526 let depth = MAX_TEMPLATE_DEPTH + 2;
5527 let mut html = String::with_capacity(depth * 48);
5528 for _ in 0..depth {
5529 html.push_str(r#"<route path="x" component="route-page">"#);
5530 }
5531 for _ in 0..depth {
5532 html.push_str("</route>");
5533 }
5534
5535 let result = parser.parse("index.html", &html);
5536
5537 assert!(matches!(result, Err(ParserError::Template(diag)) if
5538 diag.error_code() == Some(codes::EXCESSIVE_NESTING)
5539 && diag.to_string().contains("route nesting exceeds")
5540 ));
5541 }
5542
5543 #[test]
5544 fn test_rejects_recursive_component_templates() {
5545 let mut parser = HtmlParser::new();
5546 parser
5547 .component_registry
5548 .register_component(ComponentRegistration::new(
5549 "self-card",
5550 "<self-card></self-card>",
5551 None,
5552 true,
5553 ))
5554 .expect("register");
5555
5556 let result = parser.parse("index.html", "<self-card></self-card>");
5557
5558 assert!(matches!(result, Err(ParserError::Template(diag)) if
5559 diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
5560 && diag.to_string().contains("recursive template reference")
5561 ));
5562 }
5563
5564 #[test]
5567 fn test_complex_raw_text_full_page() {
5568 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>"#;
5570 let (fragments, _) = parse_and_get_fragments(html);
5571
5572 assert!(fragments.len() >= 9);
5575 assert!(
5576 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5577 raw.value.contains("<!DOCTYPE HTML>") && raw.value.ends_with("<head>"))
5578 );
5579 assert!(
5580 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
5581 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
5582 );
5583 assert!(
5584 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5585 raw.value.contains("<title>Test</title>"))
5586 );
5587 assert!(
5588 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
5589 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
5590 );
5591 assert!(
5592 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5593 raw.value.contains("</head>") && raw.value.ends_with("<body>"))
5594 );
5595 assert!(
5596 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
5597 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start"))
5598 );
5599 assert!(
5600 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5601 raw.value.contains("<app-shell>"))
5602 );
5603 assert!(
5604 matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
5605 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end"))
5606 );
5607 assert!(
5608 matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5609 raw.value.contains("</body>") && raw.value.contains("</html>"))
5610 );
5611 }
5612
5613 #[test]
5614 fn test_css_strategy_external_emits_link_tag() {
5615 let mut parser = HtmlParser::new();
5616 parser
5617 .component_registry_mut()
5618 .register_component(ComponentRegistration::new(
5619 "my-card",
5620 "<p><slot></slot></p>",
5621 Some("p { color: red; }"),
5622 true,
5623 ))
5624 .ok();
5625 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
5626 let records = parser.into_fragment_records();
5627 let my_card = &records["my-card"].fragments;
5628 let raw_text: String = my_card
5629 .iter()
5630 .filter_map(|f| match &f.fragment {
5631 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5632 _ => None,
5633 })
5634 .collect();
5635 assert!(
5636 raw_text.contains(r#"<link rel="stylesheet" href="my-card.css">"#),
5637 "Expected external <link> tag in: {}",
5638 raw_text
5639 );
5640 }
5641
5642 #[test]
5643 fn test_uppercase_style_element_is_processed_as_css() {
5644 let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
5649 assert!(
5650 fragments.iter().any(|f| matches!(
5651 f.fragment.as_ref(),
5652 Some(Fragment::Signal(s)) if s.value == "tokens" && !s.raw
5653 )),
5654 "uppercase <STYLE> should be CSS-processed, got: {:?}",
5655 fragments
5656 );
5657 let raw: String = fragments
5658 .iter()
5659 .filter_map(|f| match f.fragment.as_ref() {
5660 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5661 _ => None,
5662 })
5663 .collect();
5664 assert!(
5665 raw.contains("</STYLE>"),
5666 "closing tag should preserve source case, got: {raw}"
5667 );
5668 }
5669
5670 #[test]
5671 fn test_css_strategy_inline_emits_style_tag() {
5672 let mut parser = HtmlParser::with_options(CssStrategy::Style);
5673 parser
5674 .component_registry_mut()
5675 .register_component(ComponentRegistration::new(
5676 "my-card",
5677 "<p><slot></slot></p>",
5678 Some("p { color: red; }"),
5679 true,
5680 ))
5681 .ok();
5682 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
5683 let records = parser.into_fragment_records();
5684 let my_card = &records["my-card"].fragments;
5685 let raw_text: String = my_card
5686 .iter()
5687 .filter_map(|f| match &f.fragment {
5688 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5689 _ => None,
5690 })
5691 .collect();
5692 assert!(
5693 raw_text.contains("<style>p { color: red; }</style>"),
5694 "Expected inline <style> tag in: {}",
5695 raw_text
5696 );
5697 assert!(
5698 !raw_text.contains("<link"),
5699 "Should not have <link> tag in inline mode: {}",
5700 raw_text
5701 );
5702 }
5703
5704 #[test]
5705 fn test_css_strategy_module_emits_adopted_stylesheets() {
5706 let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
5707 parser
5708 .component_registry_mut()
5709 .register_component(ComponentRegistration::new(
5710 "my-card",
5711 "<p><slot></slot></p>",
5712 Some("p { color: red; }"),
5713 true,
5714 ))
5715 .ok();
5716 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
5717 let records = parser.into_fragment_records();
5718
5719 let my_card = &records["my-card"].fragments;
5722 let template_text: String = my_card
5723 .iter()
5724 .filter_map(|f| match &f.fragment {
5725 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5726 _ => None,
5727 })
5728 .collect();
5729 assert!(
5730 !template_text.contains("shadowrootadoptedstylesheets"),
5731 "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
5732 );
5733 assert!(
5734 !template_text.contains("<link"),
5735 "Should not have <link> in module mode: {template_text}"
5736 );
5737 assert!(
5741 !template_text.contains(r#"<style type="module""#),
5742 "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
5743 );
5744 assert!(
5745 !template_text.contains(r#"<script type="importmap""#),
5746 "CSS module importmap should NOT be in raw fragments: {template_text}"
5747 );
5748 }
5749
5750 #[test]
5751 fn test_css_strategy_module_no_css_no_adopted_attr() {
5752 let mut parser = HtmlParser::with_options(CssStrategy::Module);
5753 parser
5754 .component_registry_mut()
5755 .register_component(ComponentRegistration::new(
5756 "my-card",
5757 "<p><slot></slot></p>",
5758 None,
5759 true,
5760 ))
5761 .ok();
5762 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
5763 let records = parser.into_fragment_records();
5764 let my_card = &records["my-card"].fragments;
5765 let template_text: String = my_card
5766 .iter()
5767 .filter_map(|f| match &f.fragment {
5768 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5769 _ => None,
5770 })
5771 .collect();
5772 assert!(
5774 !template_text.contains("shadowrootadoptedstylesheets"),
5775 "Should not have adopted attr without CSS: {template_text}"
5776 );
5777 }
5778
5779 #[test]
5798 fn light_preserves_dev_template_with_static_attrs() {
5799 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5800 let processed = parser
5801 .process_component_template(
5802 r#"<template foo="bar"><div>hi</div></template>"#,
5803 None,
5804 None,
5805 )
5806 .expect("process failed");
5807 assert!(
5808 processed.contains(r#"<template foo="bar">"#),
5809 "[--dom=light] expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
5810 );
5811 assert!(
5812 processed.contains("</template>"),
5813 "[--dom=light] expected closing </template> preserved, got: {processed}"
5814 );
5815 }
5816
5817 #[test]
5818 fn light_preserves_dev_template_with_signal_fragment_attrs() {
5819 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5820 let processed = parser
5821 .process_component_template(
5822 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
5823 None,
5824 None,
5825 )
5826 .expect("process failed");
5827 assert!(
5828 processed.contains(r#"<template foo="{{foo}}">"#),
5829 "[--dom=light] expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
5830 );
5831 }
5832
5833 #[test]
5834 fn light_preserves_dev_template_with_multiple_attrs() {
5835 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5836 let processed = parser.process_component_template(
5837 r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
5838 None,
5839 None,
5840 )
5841 .expect("process failed");
5842 assert!(
5843 processed.contains("autofocus")
5844 && processed.contains(r#"tabindex="0""#)
5845 && processed.contains(r#"role="region""#)
5846 && processed.contains(r#"data-x="y""#),
5847 "[--dom=light] expected ALL dev template attrs preserved, got: {processed}"
5848 );
5849 }
5850
5851 #[test]
5852 fn light_no_template_no_wrapper_added() {
5853 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5854 let processed = parser
5855 .process_component_template("<div>hi</div>", None, None)
5856 .expect("process failed");
5857 assert!(
5858 !processed.contains("<template"),
5859 "[--dom=light] framework must NOT add <template> wrapper when dev omits one, got: {processed}"
5860 );
5861 assert!(
5862 processed.contains("<div>hi</div>"),
5863 "[--dom=light] expected inner content emitted as-is, got: {processed}"
5864 );
5865 }
5866
5867 #[test]
5868 fn shadow_preserves_dev_template_with_static_attrs() {
5869 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5870 let processed = parser
5871 .process_component_template(
5872 r#"<template foo="bar"><div>hi</div></template>"#,
5873 None,
5874 None,
5875 )
5876 .expect("process failed");
5877 assert!(
5878 processed.contains(r#"<template foo="bar">"#),
5879 "[--dom=shadow] dev <template foo=\"bar\"> must be preserved verbatim, got: {processed}"
5880 );
5881 assert!(
5882 !processed.contains(r#"shadowrootmode="open""#),
5883 "[--dom=shadow] framework must NOT inject shadowrootmode when dev already supplied a <template>, got: {processed}"
5884 );
5885 }
5886
5887 #[test]
5888 fn shadow_preserves_dev_template_with_shadowrootmode_closed() {
5889 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5890 let processed = parser
5891 .process_component_template(
5892 r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
5893 None,
5894 None,
5895 )
5896 .expect("process failed");
5897 assert!(
5898 processed.contains(r#"shadowrootmode="closed""#),
5899 "[--dom=shadow] framework must respect dev's shadowrootmode=\"closed\" (developer is managing), got: {processed}"
5900 );
5901 assert!(
5902 !processed.contains(r#"shadowrootmode="open""#),
5903 "[--dom=shadow] framework must not overwrite dev's shadowrootmode with \"open\", got: {processed}"
5904 );
5905 }
5906
5907 #[test]
5908 fn shadow_preserves_dev_template_with_signal_fragment_attrs() {
5909 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5910 let processed = parser
5911 .process_component_template(
5912 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
5913 None,
5914 None,
5915 )
5916 .expect("process failed");
5917 assert!(
5918 processed.contains(r#"<template foo="{{foo}}">"#),
5919 "[--dom=shadow] dev <template> with signal-fragment attr must be preserved, got: {processed}"
5920 );
5921 }
5922
5923 #[test]
5924 fn shadow_adds_template_wrapper_when_dev_omits_it() {
5925 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5926 let processed = parser
5927 .process_component_template("<div>hi</div>", None, None)
5928 .expect("process failed");
5929 assert!(
5930 processed.contains(r#"<template shadowrootmode="open""#),
5931 "[--dom=shadow] framework MUST add <template shadowrootmode=\"open\"> when dev omits a wrapper, got: {processed}"
5932 );
5933 assert!(
5934 processed.contains("<div>hi</div>"),
5935 "[--dom=shadow] inner content must survive wrapping, got: {processed}"
5936 );
5937 assert!(
5938 processed.contains("</template>"),
5939 "[--dom=shadow] framework-added wrapper must be closed, got: {processed}"
5940 );
5941 }
5942
5943 #[test]
5944 fn component_policy_wrapper_uses_selected_dom_strategy_and_is_build_only() {
5945 let html = r#"<template w-hydrate="lazy"><div>hi</div></template>"#;
5946
5947 let mut shadow = HtmlParser::with_options(DomStrategy::Shadow);
5948 let shadow_built = shadow
5949 .build_component_templates("my-comp", html, None, true)
5950 .expect("shadow policy wrapper should compile");
5951 assert_eq!(
5952 shadow_built.ssr,
5953 r#"<template shadowrootmode="open"><div>hi</div></template>"#
5954 );
5955 assert!(!shadow_built.artifact().contains("w-hydrate"));
5956
5957 let mut light = HtmlParser::with_options(DomStrategy::Light);
5958 let light_built = light
5959 .build_component_templates("my-comp", html, None, true)
5960 .expect("light policy wrapper should compile");
5961 assert_eq!(light_built.ssr, "<div>hi</div>");
5962 assert_eq!(light_built.artifact(), "<div>hi</div>");
5963 }
5964
5965 #[test]
5975 fn dev_template_module_strategy_appends_adopted_attr_when_missing() {
5976 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5977 let processed = parser
5978 .process_component_template(
5979 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
5980 None,
5981 Some("my-comp"),
5982 )
5983 .expect("process failed");
5984 assert!(
5985 processed.contains(r#"shadowrootmode="open""#),
5986 "dev-authored shadowrootmode must be preserved, got: {processed}"
5987 );
5988 assert!(
5989 processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5990 "module CSS should append adopted stylesheets, got: {processed}"
5991 );
5992 }
5993
5994 #[test]
5995 fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
5996 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5997 let processed = parser
5998 .process_component_template(
5999 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
6000 None,
6001 Some("my-comp"),
6002 )
6003 .unwrap();
6004 assert!(
6005 processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
6006 "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
6007 );
6008 assert_eq!(
6009 processed.matches("shadowrootadoptedstylesheets").count(),
6010 1,
6011 "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
6012 );
6013 }
6014
6015 #[test]
6016 fn dev_template_module_strategy_appends_adopted_attr_and_preserves_root_attrs() {
6017 for dom_strategy in [DomStrategy::Shadow, DomStrategy::Light] {
6018 let mut parser = HtmlParser::with_options((CssStrategy::Module, dom_strategy));
6019 let built = match parser.build_component_templates(
6020 "my-comp",
6021 r#"<template shadowrootmode="open" @click="{onClick()}">Hello</template>"#,
6022 Some(":host { color: red; }"),
6023 true,
6024 ) {
6025 Ok(built) => built,
6026 Err(err) => panic!(
6027 "dev-authored <template> should be accepted under {dom_strategy:?} with module CSS, got: {err}"
6028 ),
6029 };
6030 let artifact = built.artifact();
6031
6032 assert!(
6033 artifact.contains(r#"shadowrootmode="open""#),
6034 "dev-authored shadowrootmode must be preserved under {dom_strategy:?}, got: {artifact}"
6035 );
6036 assert!(
6037 artifact.contains(r#"@click="{onClick()}""#),
6038 "dev-authored root event must be preserved under {dom_strategy:?}, got: {artifact}"
6039 );
6040 assert!(
6041 artifact.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
6042 "module CSS should append adopted stylesheets under {dom_strategy:?}, got: {artifact}"
6043 );
6044 assert_eq!(
6045 artifact.matches("shadowrootadoptedstylesheets").count(),
6046 1,
6047 "module CSS should append adopted stylesheets once under {dom_strategy:?}, got: {artifact}"
6048 );
6049 assert!(
6050 !built.ssr.contains("@click"),
6051 "SSR template must still strip runtime attrs, got: {}",
6052 built.ssr
6053 );
6054 }
6055 }
6056
6057 #[test]
6058 fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
6059 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
6064 let processed = parser
6065 .process_component_template(
6066 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
6067 None,
6068 Some("my-comp"),
6069 )
6070 .unwrap();
6071 assert!(
6072 processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
6073 "dev's multi-specifier list must be preserved verbatim, got: {processed}"
6074 );
6075 }
6076
6077 #[test]
6078 fn dev_template_no_adopted_specifier_does_not_validate() {
6079 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
6083 let processed = parser
6084 .process_component_template(
6085 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
6086 None,
6087 None,
6088 )
6089 .unwrap();
6090 assert!(
6091 !processed.contains("shadowrootadoptedstylesheets"),
6092 "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
6093 );
6094 }
6095
6096 #[test]
6108 fn test_body_start_end_injection() {
6109 let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
6113 let (fragments, _) = parse_and_get_fragments(html);
6114
6115 assert_fragments!(
6116 fragments,
6117 [
6118 raw("<html><head>"),
6119 structural_matcher("head_start"),
6120 raw("<title>Test</title>"),
6121 structural_matcher("head_end"),
6122 raw("</head><body>"),
6123 structural_matcher("body_start"),
6124 raw("<div>Content</div><p>More</p>"),
6125 structural_matcher("body_end"),
6126 raw("</body></html>"),
6127 ]
6128 );
6129 }
6130
6131 #[test]
6132 fn test_body_preserves_static_attributes() {
6133 let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
6138 let (fragments, _) = parse_and_get_fragments(html);
6139
6140 assert_fragments!(
6141 fragments,
6142 [
6143 raw("<html><head>"),
6144 structural_matcher("head_start"),
6145 raw("<title>T</title>"),
6146 structural_matcher("head_end"),
6147 raw(r#"</head><body data-layout="doc" class="page">"#),
6148 structural_matcher("body_start"),
6149 raw("<p>x</p>"),
6150 structural_matcher("body_end"),
6151 raw("</body></html>"),
6152 ]
6153 );
6154 }
6155
6156 #[test]
6157 fn test_body_preserves_dynamic_attributes() {
6158 let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
6162 let (fragments, _) = parse_and_get_fragments(html);
6163
6164 assert_fragments!(
6165 fragments,
6166 [
6167 raw("<html><head>"),
6168 structural_matcher("head_start"),
6169 raw("<title>T</title>"),
6170 structural_matcher("head_end"),
6171 raw("</head><body"),
6172 attr("data-layout", "layout"),
6173 raw(">"),
6174 structural_matcher("body_start"),
6175 raw("<p>x</p>"),
6176 structural_matcher("body_end"),
6177 raw("</body></html>"),
6178 ]
6179 );
6180 }
6181
6182 #[test]
6183 fn test_head_start_signal_ordering_with_authored_script() {
6184 let html = r#"<html><head><script async src="./analytics.js"></script></head><body></body></html>"#;
6189 let (fragments, _) = parse_and_get_fragments(html);
6190
6191 assert_fragments!(
6192 fragments,
6193 [
6194 raw("<html><head>"),
6195 structural_matcher("head_start"),
6196 raw(r#"<script async src="./analytics.js"></script>"#),
6197 structural_matcher("head_end"),
6198 raw("</head><body>"),
6199 structural_matcher("body_start"),
6200 structural_matcher("body_end"),
6201 raw("</body></html>"),
6202 ]
6203 );
6204 }
6205
6206 #[test]
6207 fn test_head_static_attributes_preserve_legacy_opening_bytes() {
6208 let html = r#"<html><head data-theme="dark" class="app-head"><title>T</title></head><body></body></html>"#;
6212 let (fragments, _) = parse_and_get_fragments(html);
6213
6214 assert_fragments!(
6215 fragments,
6216 [
6217 raw("<html><head>"),
6218 structural_matcher("head_start"),
6219 raw("<title>T</title>"),
6220 structural_matcher("head_end"),
6221 raw("</head><body>"),
6222 structural_matcher("body_start"),
6223 structural_matcher("body_end"),
6224 raw("</body></html>"),
6225 ]
6226 );
6227 }
6228
6229 #[test]
6230 fn test_head_bound_attributes_preserve_legacy_opening_bytes() {
6231 let html =
6235 r#"<html><head data-theme="{{theme}}"><title>T</title></head><body></body></html>"#;
6236 let (fragments, _) = parse_and_get_fragments(html);
6237
6238 assert_fragments!(
6239 fragments,
6240 [
6241 raw("<html><head>"),
6242 structural_matcher("head_start"),
6243 raw("<title>T</title>"),
6244 structural_matcher("head_end"),
6245 raw("</head><body>"),
6246 structural_matcher("body_start"),
6247 structural_matcher("body_end"),
6248 raw("</body></html>"),
6249 ]
6250 );
6251 }
6252
6253 #[test]
6254 fn native_head_and_body_are_ascii_case_insensitive() {
6255 let html = r#"<html><HEAD data-theme="ignored"><title>T</title></HEAD><BODY data-layout="{{layout}}"><p>x</p></BODY></html>"#;
6256 let (fragments, _) = parse_and_get_fragments(html);
6257
6258 assert_fragments!(
6259 fragments,
6260 [
6261 raw(r#"<html><HEAD data-theme="ignored">"#),
6262 structural_matcher("head_start"),
6263 raw("<title>T</title>"),
6264 structural_matcher("head_end"),
6265 raw("</HEAD><BODY"),
6266 attr("data-layout", "layout"),
6267 raw(">"),
6268 structural_matcher("body_start"),
6269 raw("<p>x</p>"),
6270 structural_matcher("body_end"),
6271 raw("</BODY></html>"),
6272 ]
6273 );
6274 }
6275
6276 #[test]
6280 fn boundary_emits_no_wrapper_and_brackets_children_with_signals() {
6281 let html = r#"<body><div><boundary name="counter-ready"><my-counter></my-counter></boundary></div></body>"#;
6282 let (fragments, _) = parse_and_get_fragments(html);
6283
6284 assert_fragments!(
6288 fragments,
6289 [
6290 raw("<body>"),
6291 structural_matcher("body_start"),
6292 raw("<div>"),
6293 structural_matcher("boundary_start:0"),
6294 raw("<my-counter></my-counter>"),
6295 structural_matcher("boundary_end:0"),
6296 raw("</div>"),
6297 structural_matcher("body_end"),
6298 raw("</body>"),
6299 ]
6300 );
6301 }
6302
6303 #[test]
6304 fn boundary_sequence_increments_in_document_order_and_resets_per_entry() {
6305 let html = concat!(
6306 "<body>",
6307 r#"<boundary name="a"><p>1</p></boundary>"#,
6308 r#"<boundary name="b"><p>2</p></boundary>"#,
6309 "</body>",
6310 );
6311 let (fragments, _) = parse_and_get_fragments(html);
6312
6313 assert_fragments!(
6314 fragments,
6315 [
6316 raw("<body>"),
6317 structural_matcher("body_start"),
6318 structural_matcher("boundary_start:0"),
6319 raw("<p>1</p>"),
6320 structural_matcher("boundary_end:0"),
6321 structural_matcher("boundary_start:1"),
6322 raw("<p>2</p>"),
6323 structural_matcher("boundary_end:1"),
6324 structural_matcher("body_end"),
6325 raw("</body>"),
6326 ]
6327 );
6328
6329 let mut parser = HtmlParser::new();
6333 parser
6334 .parse(
6335 "first.html",
6336 r#"<body><boundary name="a"><p>1</p></boundary></body>"#,
6337 )
6338 .expect("first entry parses");
6339 parser
6340 .parse(
6341 "second.html",
6342 r#"<body><boundary name="a"><p>2</p></boundary></body>"#,
6343 )
6344 .expect("second entry reuses the name and sequence 0");
6345 let records = parser.into_fragment_records();
6346 assert_stream!(
6347 records,
6348 "second.html",
6349 [
6350 raw("<body>"),
6351 structural_matcher("body_start"),
6352 structural_matcher("boundary_start:0"),
6353 raw("<p>2</p>"),
6354 structural_matcher("boundary_end:0"),
6355 structural_matcher("body_end"),
6356 raw("</body>"),
6357 ]
6358 );
6359 }
6360
6361 #[test]
6362 fn boundary_missing_name_errors() {
6363 let mut parser = HtmlParser::new();
6364 let err = parser
6365 .parse("index.html", "<body><boundary><p>x</p></boundary></body>")
6366 .expect_err("a boundary without a name must error");
6367 let ParserError::Template(diag) = err else {
6368 panic!("expected ParserError::Template, got {err:?}");
6369 };
6370 assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_NAME));
6371 }
6372
6373 #[test]
6374 fn boundary_dynamic_name_errors() {
6375 let mut parser = HtmlParser::new();
6376 let err = parser
6377 .parse(
6378 "index.html",
6379 r#"<body><boundary name="{{sectionName}}"><p>x</p></boundary></body>"#,
6380 )
6381 .expect_err("a dynamic boundary name must error");
6382 let ParserError::Template(diag) = err else {
6383 panic!("expected ParserError::Template, got {err:?}");
6384 };
6385 assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_NAME));
6386 }
6387
6388 #[test]
6392 fn boundary_names_are_free_form_ordered_and_absent_from_fragments() {
6393 let mut parser = HtmlParser::new();
6394 let html = concat!(
6395 "<body>",
6396 r#"<boundary name="above the fold"><p>1</p></boundary>"#,
6397 r#"<boundary name="feed/items #2"><p>2</p></boundary>"#,
6398 r#"<boundary name="ダッシュボード"><p>3</p></boundary>"#,
6399 "</body>",
6400 );
6401 parser
6402 .parse("index.html", html)
6403 .expect("free-form boundary names must be accepted");
6404 assert_eq!(parser.boundary_count(), 3);
6405 assert_eq!(
6406 parser.boundary_names(),
6407 ["above the fold", "feed/items #2", "ダッシュボード"]
6408 );
6409
6410 let records = parser.into_fragment_records();
6411 let fragments = &records
6412 .get("index.html")
6413 .expect("index.html fragment record")
6414 .fragments;
6415
6416 let mut starts = Vec::new();
6417 for fragment in fragments {
6418 let Some(Fragment::Signal(signal)) = fragment.fragment.as_ref() else {
6419 continue;
6420 };
6421 let value = signal.value.as_str();
6422 assert!(
6423 !value.contains("above the fold")
6424 && !value.contains("feed/items")
6425 && !value.contains("ダッシュボード"),
6426 "boundary names must never reach a fragment: {value}"
6427 );
6428 if let Some(sequence) = value
6429 .strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
6430 .and_then(|signal| signal.strip_prefix("boundary_start:"))
6431 {
6432 starts.push(sequence.to_string());
6433 }
6434 }
6435 assert_eq!(
6436 starts,
6437 ["0", "1", "2"],
6438 "only integer sequences are emitted"
6439 );
6440 }
6441
6442 #[test]
6443 fn module_entry_srcs_records_only_preloadable_critical_entries() {
6444 let mut parser = HtmlParser::new();
6445 let html = concat!(
6446 "<head>",
6447 r#"<script type="module" async src="/index.js"></script>"#,
6449 r#"<SCRIPT type=" Module " src="/late.js"></SCRIPT>"#,
6453 r#"<script src="/legacy.js"></script>"#,
6455 r#"<script type="module">import "./x.js";</script>"#,
6457 r#"<script type="module" src="/{{bundle}}.js"></script>"#,
6459 "</head>",
6460 "<body>",
6461 r#"<boundary name="weather">"#,
6463 r#"<script type="module" async src="/weather-panel.js"></script>"#,
6464 "</boundary>",
6465 r#"<script type="module" src="/index.js"></script>"#,
6468 "</body>",
6469 );
6470 parser.parse("index.html", html).expect("parse");
6471
6472 assert_eq!(
6473 parser.module_entry_srcs(),
6474 ["/index.js", "/late.js"],
6475 "only static module entries outside boundaries are preloadable"
6476 );
6477 }
6478
6479 #[test]
6480 fn module_entry_srcs_reset_between_top_level_parses() {
6481 let mut parser = HtmlParser::new();
6482 parser
6483 .parse(
6484 "a.html",
6485 r#"<head><script type="module" src="/a.js"></script></head>"#,
6486 )
6487 .expect("parse a");
6488 assert_eq!(parser.module_entry_srcs(), ["/a.js"]);
6489
6490 parser
6491 .parse(
6492 "b.html",
6493 r#"<head><script type="module" src="/b.js"></script></head>"#,
6494 )
6495 .expect("parse b");
6496 assert_eq!(
6497 parser.module_entry_srcs(),
6498 ["/b.js"],
6499 "each entry owns its own critical modules"
6500 );
6501 }
6502
6503 #[test]
6504 fn boundary_duplicate_name_errors() {
6505 let mut parser = HtmlParser::new();
6506 let html = concat!(
6507 "<body>",
6508 r#"<boundary name="dup"><p>1</p></boundary>"#,
6509 r#"<boundary name="dup"><p>2</p></boundary>"#,
6510 "</body>",
6511 );
6512 let err = parser
6513 .parse("index.html", html)
6514 .expect_err("a duplicate boundary name must error");
6515 let ParserError::Template(diag) = err else {
6516 panic!("expected ParserError::Template, got {err:?}");
6517 };
6518 assert_eq!(diag.error_code(), Some(codes::DUPLICATE_BOUNDARY_NAME));
6519 }
6520
6521 #[test]
6522 fn boundary_nested_boundary_errors() {
6523 let mut parser = HtmlParser::new();
6524 let html = r#"<body><boundary name="outer"><boundary name="inner"><p>x</p></boundary></boundary></body>"#;
6525 let err = parser
6526 .parse("index.html", html)
6527 .expect_err("a nested boundary must error");
6528 let ParserError::Template(diag) = err else {
6529 panic!("expected ParserError::Template, got {err:?}");
6530 };
6531 assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
6532 }
6533
6534 #[test]
6535 fn boundary_inside_if_errors() {
6536 let mut parser = HtmlParser::new();
6537 let html =
6538 r#"<body><if condition="ready"><boundary name="x"><p>x</p></boundary></if></body>"#;
6539 let err = parser
6540 .parse("index.html", html)
6541 .expect_err("a boundary inside <if> must error");
6542 let ParserError::Template(diag) = err else {
6543 panic!("expected ParserError::Template, got {err:?}");
6544 };
6545 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6546 assert!(diag.to_string().contains("<if>"), "{diag}");
6547 }
6548
6549 #[test]
6550 fn boundary_inside_for_errors() {
6551 let mut parser = HtmlParser::new();
6552 let html = r#"<body><for each="item in items"><boundary name="x"><p>x</p></boundary></for></body>"#;
6553 let err = parser
6554 .parse("index.html", html)
6555 .expect_err("a boundary inside <for> must error");
6556 let ParserError::Template(diag) = err else {
6557 panic!("expected ParserError::Template, got {err:?}");
6558 };
6559 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6560 assert!(diag.to_string().contains("<for>"), "{diag}");
6561 }
6562
6563 #[test]
6564 fn boundary_inside_reusable_component_template_errors() {
6565 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6569 parser
6570 .component_registry
6571 .register_component(ComponentRegistration::new(
6572 "my-widget",
6573 r#"<boundary name="x"><p>x</p></boundary>"#,
6574 None,
6575 true,
6576 ))
6577 .expect("register");
6578
6579 let err = parser
6580 .parse("index.html", "<body><my-widget></my-widget></body>")
6581 .expect_err("a boundary inside a component template must error");
6582 let ParserError::Template(diag) = err else {
6583 panic!("expected ParserError::Template, got {err:?}");
6584 };
6585 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6586 }
6587
6588 #[test]
6589 fn boundary_inside_component_host_content_errors() {
6590 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6591 parser
6592 .component_registry
6593 .register_component(ComponentRegistration::new(
6594 "my-widget",
6595 "<slot></slot>",
6596 None,
6597 true,
6598 ))
6599 .expect("register");
6600
6601 let err = parser
6602 .parse(
6603 "index.html",
6604 r#"<body><my-widget><boundary name="x"><p>x</p></boundary></my-widget></body>"#,
6605 )
6606 .expect_err("a boundary inside component host content must error");
6607 let ParserError::Template(diag) = err else {
6608 panic!("expected ParserError::Template, got {err:?}");
6609 };
6610 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6611 assert!(
6612 diag.to_string().contains("component host content"),
6613 "{diag}"
6614 );
6615 }
6616
6617 #[test]
6618 fn boundary_inside_raw_or_inert_html_context_errors() {
6619 for (element, expected_scope) in [
6620 ("textarea", "<textarea> text content"),
6621 ("title", "<title> text content"),
6622 ("script", "<script> raw-text content"),
6623 ("xmp", "<xmp> raw-text content"),
6624 ("iframe", "<iframe> raw-text content"),
6625 ("noembed", "<noembed> raw-text content"),
6626 ("noframes", "<noframes> raw-text content"),
6627 ("noscript", "<noscript> inert content"),
6628 ("plaintext", "<plaintext> text content"),
6629 ("template", "<template> inert content"),
6630 ] {
6631 let mut parser = HtmlParser::new();
6632 let html = format!(
6633 r#"<body><{element}><boundary name="x"><p>x</p></boundary></{element}></body>"#
6634 );
6635 let err = parser
6636 .parse("index.html", &html)
6637 .expect_err("a boundary in raw or inert content must error");
6638 let ParserError::Template(diag) = err else {
6639 panic!("expected ParserError::Template, got {err:?}");
6640 };
6641 assert_eq!(
6642 diag.error_code(),
6643 Some(codes::BOUNDARY_CROSSES_SCOPE),
6644 "unexpected code for <{element}>"
6645 );
6646 assert!(
6647 diag.to_string().contains(expected_scope),
6648 "unexpected scope for <{element}>: {diag}"
6649 );
6650 }
6651 }
6652
6653 #[test]
6654 fn boundary_inside_route_element_errors() {
6655 let mut parser = HtmlParser::new();
6656 let err = parser
6658 .parse(
6659 "index.html",
6660 r#"<body><route path="/" component="home"><boundary name="x"><p>x</p></boundary></route></body>"#,
6661 )
6662 .expect_err("a boundary directly inside <route> must error");
6663 let ParserError::Template(diag) = err else {
6664 panic!("expected ParserError::Template, got {err:?}");
6665 };
6666 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6667
6668 let mut parser = HtmlParser::new();
6670 let err = parser
6671 .parse(
6672 "index.html",
6673 r#"<body><route path="/" component="home"><div><boundary name="x"><p>x</p></boundary></div></route></body>"#,
6674 )
6675 .expect_err("a boundary nested inside <route> markup must error");
6676 let ParserError::Template(diag) = err else {
6677 panic!("expected ParserError::Template, got {err:?}");
6678 };
6679 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6680 }
6681
6682 #[test]
6683 fn boundary_must_be_inside_an_open_body() {
6684 for html in [
6685 r#"<boundary name="before"><p>x</p></boundary><body></body>"#,
6686 r#"<body></body><boundary name="after"><p>x</p></boundary>"#,
6687 ] {
6688 let mut parser = HtmlParser::new();
6689 let err = parser
6690 .parse("index.html", html)
6691 .expect_err("a boundary outside body must error");
6692 let ParserError::Template(diag) = err else {
6693 panic!("expected ParserError::Template, got {err:?}");
6694 };
6695 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_OUTSIDE_BODY));
6696 assert_eq!(
6697 diag.help_text(),
6698 Some("move the <boundary> between the opening <body> and its matching </body>")
6699 );
6700 }
6701
6702 let mut parser = HtmlParser::new();
6703 parser
6704 .parse(
6705 "index.html",
6706 r#"<BODY><boundary name="inside"></boundary></BODY>"#,
6707 )
6708 .expect("mixed-case native body keeps the boundary inside an open body");
6709 }
6710
6711 #[test]
6712 fn boundary_inside_foster_parenting_context_errors() {
6713 for html in [
6717 r#"<body><table><boundary name="t"><p>x</p></boundary></table></body>"#,
6718 r#"<body><table><tbody><boundary name="t"><p>x</p></boundary></tbody></table></body>"#,
6719 r#"<body><table><thead><boundary name="t"><p>x</p></boundary></thead></table></body>"#,
6720 r#"<body><table><tfoot><boundary name="t"><p>x</p></boundary></tfoot></table></body>"#,
6721 r#"<body><table><tbody><tr><boundary name="t"><p>x</p></boundary></tr></tbody></table></body>"#,
6722 r#"<body><table><colgroup><boundary name="t"><p>x</p></boundary></colgroup></table></body>"#,
6723 r#"<body><select><boundary name="t"><option>x</option></boundary></select></body>"#,
6724 r#"<body><select><optgroup><boundary name="t"><option>x</option></boundary></optgroup></select></body>"#,
6725 r#"<body><TABLE><TBODY><boundary name="t"><p>x</p></boundary></TBODY></TABLE></body>"#,
6728 ] {
6729 let mut parser = HtmlParser::new();
6730 let err = parser
6731 .parse("index.html", html)
6732 .expect_err("a boundary in a foster-parenting context must error");
6733 let ParserError::Template(diag) = err else {
6734 panic!("expected ParserError::Template, got {err:?}");
6735 };
6736 assert_eq!(
6737 diag.error_code(),
6738 Some(codes::BOUNDARY_IN_FOSTER_CONTEXT),
6739 "unexpected code for {html}"
6740 );
6741 assert!(
6742 diag.help_text()
6743 .is_some_and(|help| help.contains("<td>, <th>, or <caption>")),
6744 "help should suggest a safe placement for {html}"
6745 );
6746 }
6747 }
6748
6749 #[test]
6750 fn boundary_is_allowed_where_insertion_mode_returns_to_in_body() {
6751 for html in [
6755 r#"<body><table><tbody><tr><td><boundary name="cell"><p>x</p></boundary></td></tr></tbody></table></body>"#,
6756 r#"<body><table><tbody><tr><th><boundary name="head"><p>x</p></boundary></th></tr></tbody></table></body>"#,
6757 r#"<body><table><caption><boundary name="cap"><p>x</p></boundary></caption></table></body>"#,
6758 r#"<body><boundary name="whole"><table><tbody><tr><td>x</td></tr></tbody></table></boundary></body>"#,
6760 concat!(
6764 "<body><table><tbody><tr><td>",
6765 "<table><tbody><tr><td>inner</td></tr></tbody></table>",
6766 r#"<boundary name="after-inner"><p>x</p></boundary>"#,
6767 "</td></tr></tbody></table></body>",
6768 ),
6769 r#"<body><table><tbody><tr><td><img src="a"><br><input></td></tr></tbody></table><boundary name="v"><p>x</p></boundary></body>"#,
6771 ] {
6772 let mut parser = HtmlParser::new();
6773 parser
6774 .parse("index.html", html)
6775 .unwrap_or_else(|err| panic!("{html} should parse, got {err:?}"));
6776 }
6777 }
6778
6779 #[test]
6780 fn foster_context_depth_is_restored_after_leaving_a_table() {
6781 let mut parser = HtmlParser::new();
6784 parser
6785 .parse(
6786 "index.html",
6787 concat!(
6788 "<body>",
6789 "<table><tbody><tr><td>x</td></tr></tbody></table>",
6790 r#"<boundary name="after-table"><p>y</p></boundary>"#,
6791 "</body>",
6792 ),
6793 )
6794 .expect("a boundary after a closed table must be accepted");
6795 }
6796
6797 #[test]
6798 fn authored_raw_bindings_cannot_forge_structural_signals() {
6799 let html = concat!(
6800 "<html><head></head><body>",
6801 "{{{head_start}}}{{{head_end}}}{{{body_start}}}{{{body_end}}}",
6802 "{{{boundary_start:0}}}{{{boundary_end:0}}}",
6803 "{{{streaming_root:forged}}}",
6804 "</body></html>",
6805 );
6806 let (fragments, _) = parse_and_get_fragments(html);
6807 let raw_signals: Vec<&str> = fragments
6808 .iter()
6809 .filter_map(|fragment| match fragment.fragment.as_ref() {
6810 Some(Fragment::Signal(signal)) if signal.raw => Some(signal.value.as_str()),
6811 _ => None,
6812 })
6813 .collect();
6814
6815 assert_eq!(
6816 raw_signals,
6817 [
6818 "}}}webui:head_start",
6819 "}}}webui:head_end",
6820 "}}}webui:body_start",
6821 "head_start",
6822 "head_end",
6823 "body_start",
6824 "body_end",
6825 "boundary_start:0",
6826 "boundary_end:0",
6827 "streaming_root:forged",
6828 "}}}webui:body_end",
6829 ]
6830 );
6831 }
6832
6833 #[test]
6834 fn authored_webui_hydrate_errors_at_top_level() {
6835 for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
6836 let mut parser = HtmlParser::new();
6837 let html = format!("<div><{tag}></{tag}></div>");
6838 let err = parser
6839 .parse("index.html", &html)
6840 .expect_err("an authored <webui-hydrate> must error in any ASCII casing");
6841 let ParserError::Template(diag) = err else {
6842 panic!("expected ParserError::Template, got {err:?}");
6843 };
6844 assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
6845 }
6846 }
6847
6848 #[test]
6849 fn authored_webui_hydrate_errors_inside_route() {
6850 for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
6851 let mut parser = HtmlParser::new();
6852 let html = format!(r#"<route path="/" component="home"><{tag}></{tag}></route>"#);
6853 let err = parser
6854 .parse("index.html", &html)
6855 .expect_err("an authored <webui-hydrate> inside <route> must error");
6856 let ParserError::Template(diag) = err else {
6857 panic!("expected ParserError::Template, got {err:?}");
6858 };
6859 assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
6860 }
6861 }
6862
6863 #[test]
6864 fn boundary_directive_remains_case_sensitive() {
6865 let (fragments, _) =
6866 parse_and_get_fragments(r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#);
6867
6868 assert_fragments!(
6869 fragments,
6870 [raw(
6871 r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#
6872 ),]
6873 );
6874 }
6875
6876 #[test]
6877 fn templates_without_webui_boundary_are_unaffected() {
6878 let html = r#"<div><my-counter count="{{count}}"></my-counter></div>"#;
6882 let (fragments, _) = parse_and_get_fragments(html);
6883
6884 assert_fragments!(
6885 fragments,
6886 [
6887 raw("<div><my-counter"),
6888 attr("count", "count"),
6889 raw("></my-counter></div>"),
6890 ]
6891 );
6892 }
6893
6894 #[test]
6895 fn test_fail_with_invalid_markup() {
6896 let mut parser = HtmlParser::new();
6898 let result = parser.parse("index.html", "<div><span></div></span>");
6899
6900 assert!(matches!(result, Err(ParserError::Template(diag)) if
6901 diag.to_string().contains("unclosed <span> tag")
6902 ));
6903 }
6904
6905 #[test]
6906 fn test_complex_raw_text_page() {
6907 let html = concat!(
6910 "<!DOCTYPE html>",
6911 "<html lang=\"en\">",
6912 "<head>",
6913 "<meta charset=\"utf-8\">",
6914 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
6915 "<title>Complex Page</title>",
6916 "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
6917 "<link rel=\"stylesheet\" href=\"styles.css\">",
6918 "</head>",
6919 "<body>",
6920 "<h1>Hello World</h1>",
6921 "<script type=\"module\" src=\"./app.js\"></script>",
6922 "</body>",
6923 "</html>",
6924 );
6925 let (fragments, _) = parse_and_get_fragments(html);
6926
6927 assert!(
6931 fragments.len() >= 9,
6932 "Expected at least 9 fragments, got {}",
6933 fragments.len()
6934 );
6935
6936 assert!(
6938 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
6939 raw.value.contains("<!DOCTYPE html>") && raw.value.ends_with("<head>")),
6940 "First fragment should contain DOCTYPE and end with <head>, got: {:?}",
6941 fragments[0]
6942 );
6943
6944 assert!(
6946 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
6947 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start")),
6948 "Second fragment should be head_start signal"
6949 );
6950
6951 assert!(
6953 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
6954 raw.value.contains("<meta charset=\"utf-8\">") &&
6955 raw.value.contains("<meta name=\"viewport\"") &&
6956 raw.value.contains("<title>Complex Page</title>") &&
6957 raw.value.contains("<style>") &&
6958 raw.value.contains("body { margin: 0; padding: 0; }")),
6959 "Third fragment should contain all head content, got: {:?}",
6960 fragments[2]
6961 );
6962
6963 assert!(
6965 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
6966 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end")),
6967 "Fourth fragment should be head_end signal"
6968 );
6969
6970 assert!(
6972 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
6973 raw.value.contains("</head>") && raw.value.ends_with("<body>")),
6974 "Fifth fragment should contain </head><body>"
6975 );
6976
6977 assert!(
6979 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
6980 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start")),
6981 "Sixth fragment should be body_start signal"
6982 );
6983
6984 assert!(
6986 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
6987 raw.value.contains("<h1>Hello World</h1>") &&
6988 raw.value.contains("<script")),
6989 "Seventh fragment should contain body content"
6990 );
6991
6992 assert!(
6994 matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
6995 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end")),
6996 "Eighth fragment should be body_end signal"
6997 );
6998
6999 assert!(
7001 matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
7002 raw.value.contains("</body>") && raw.value.contains("</html>")),
7003 "Ninth fragment should contain closing tags"
7004 );
7005 }
7006
7007 struct BindingCountPlugin {
7011 counts: Vec<u32>,
7012 }
7013
7014 impl BindingCountPlugin {
7015 fn new() -> Self {
7016 Self { counts: Vec::new() }
7017 }
7018 }
7019
7020 impl crate::plugin::ParserPlugin for BindingCountPlugin {
7021 fn register_component_template(
7022 &mut self,
7023 _tag_name: &str,
7024 _component: &Component,
7025 _processed_template: &str,
7026 ) -> Result<()> {
7027 Ok(())
7028 }
7029
7030 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
7031 if attr_name.starts_with('@') || attr_name == "f-ref" {
7032 AttributeAction::SkipAndCountBinding
7033 } else {
7034 AttributeAction::Keep
7035 }
7036 }
7037
7038 fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
7039 self.counts.push(binding_attribute_count);
7040 if binding_attribute_count > 0 {
7041 Some(binding_attribute_count.to_le_bytes().to_vec())
7042 } else {
7043 None
7044 }
7045 }
7046 }
7047
7048 struct TemplateCapturePlugin {
7049 template: Option<String>,
7050 }
7051
7052 impl TemplateCapturePlugin {
7053 fn new() -> Self {
7054 Self { template: None }
7055 }
7056 }
7057
7058 impl crate::plugin::ParserPlugin for TemplateCapturePlugin {
7059 fn register_component_template(
7060 &mut self,
7061 _tag_name: &str,
7062 _component: &Component,
7063 processed_template: &str,
7064 ) -> Result<()> {
7065 self.template = Some(processed_template.to_string());
7066 Ok(())
7067 }
7068
7069 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
7070 if attr_name.starts_with('@') || attr_name == "f-ref" {
7071 AttributeAction::SkipAndCountBinding
7072 } else {
7073 AttributeAction::Keep
7074 }
7075 }
7076
7077 fn finish_element(&mut self, _binding_attribute_count: u32) -> Option<Vec<u8>> {
7078 None
7079 }
7080
7081 fn into_artifacts(self: Box<Self>) -> Result<ParserPluginArtifacts> {
7082 match self.template {
7083 Some(template) => Ok(ParserPluginArtifacts::ComponentTemplates(vec![
7084 crate::plugin::ComponentTemplateArtifact::template(
7085 "todo-app".to_string(),
7086 template,
7087 ),
7088 ])),
7089 None => Ok(ParserPluginArtifacts::None),
7090 }
7091 }
7092 }
7093
7094 #[test]
7095 fn parser_passes_user_template_root_attrs_to_plugin_without_css_modules() {
7096 let mut parser = HtmlParser::with_plugin_options(
7097 Box::new(TemplateCapturePlugin::new()),
7098 CssStrategy::Link,
7099 );
7100 parser
7101 .component_registry_mut()
7102 .register_component(ComponentRegistration::new(
7103 "todo-app",
7104 r#"<template shadowrootmode="open" @toggle-item="{onToggleItem($e)}" @delete-item="{onDeleteItem($e)}" f-ref="{root}"><div>items</div></template>"#,
7105 Some(":host { display: block; }"),
7106 true,))
7107 .expect("register todo-app");
7108
7109 parser
7110 .parse("index.html", "<todo-app></todo-app>")
7111 .expect("parse failed");
7112
7113 let artifacts = parser.take_plugin_artifacts().expect("artifacts");
7114 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
7115 panic!("expected captured component template");
7116 };
7117 let template = &templates[0].template;
7118
7119 assert!(
7120 template.contains(r#"shadowrootmode="open""#),
7121 "parser must pass dev-authored root template attrs to plugins, got: {template}"
7122 );
7123 assert!(
7124 template.contains(r#"@toggle-item="{onToggleItem($e)}""#),
7125 "parser must pass dev-authored root event attrs to plugins, got: {template}"
7126 );
7127 assert!(
7128 template.contains(r#"@delete-item="{onDeleteItem($e)}""#),
7129 "parser must pass dev-authored root event attrs to plugins, got: {template}"
7130 );
7131 assert!(
7132 template.contains(r#"f-ref="{root}""#),
7133 "parser must pass dev-authored root FAST attrs to plugins, got: {template}"
7134 );
7135 assert!(
7136 !template.contains("shadowrootadoptedstylesheets"),
7137 "link CSS must not add CSS module adoption attrs, got: {template}"
7138 );
7139 }
7140
7141 #[test]
7142 fn test_component_static_attrs_not_counted_as_bindings() {
7143 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
7144 parser
7145 .component_registry
7146 .register_component(ComponentRegistration::new(
7147 "my-btn",
7148 "<button><slot></slot></button>",
7149 None,
7150 true,
7151 ))
7152 .expect("register");
7153
7154 let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
7156 let result = parser.parse("index.html", html);
7157 assert!(result.is_ok());
7158
7159 let records = parser.into_fragment_records();
7160
7161 let frags = &records["index.html"].fragments;
7163 let plugin_count = frags
7164 .iter()
7165 .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
7166 .count();
7167 assert_eq!(
7168 plugin_count, 0,
7169 "Static-only component attributes should not emit a Plugin fragment"
7170 );
7171 }
7172
7173 #[test]
7174 fn test_component_dynamic_attr_counted_as_binding() {
7175 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
7176 parser
7177 .component_registry
7178 .register_component(ComponentRegistration::new(
7179 "my-btn",
7180 "<button><slot></slot></button>",
7181 None,
7182 true,
7183 ))
7184 .expect("register");
7185
7186 let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
7188 let result = parser.parse("index.html", html);
7189 assert!(result.is_ok());
7190
7191 let records = parser.into_fragment_records();
7192 let frags = &records["index.html"].fragments;
7193
7194 let plugin_frags: Vec<_> = frags
7196 .iter()
7197 .filter_map(|f| match f.fragment.as_ref() {
7198 Some(Fragment::Plugin(p)) => Some(&p.data),
7199 _ => None,
7200 })
7201 .collect();
7202 assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
7203 let count = u32::from_le_bytes([
7204 plugin_frags[0][0],
7205 plugin_frags[0][1],
7206 plugin_frags[0][2],
7207 plugin_frags[0][3],
7208 ]);
7209 assert_eq!(count, 1, "Binding attribute count should be 1");
7210 }
7211
7212 #[test]
7213 fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
7214 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
7215 parser
7216 .component_registry
7217 .register_component(ComponentRegistration::new(
7218 "my-btn",
7219 "<button><slot></slot></button>",
7220 None,
7221 true,
7222 ))
7223 .expect("register");
7224
7225 let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
7227 let result = parser.parse("index.html", html);
7228 assert!(result.is_ok());
7229
7230 let records = parser.into_fragment_records();
7231 let frags = &records["index.html"].fragments;
7232
7233 let plugin_frags: Vec<_> = frags
7234 .iter()
7235 .filter_map(|f| match f.fragment.as_ref() {
7236 Some(Fragment::Plugin(p)) => Some(&p.data),
7237 _ => None,
7238 })
7239 .collect();
7240 assert_eq!(plugin_frags.len(), 1);
7241 let count = u32::from_le_bytes([
7242 plugin_frags[0][0],
7243 plugin_frags[0][1],
7244 plugin_frags[0][2],
7245 plugin_frags[0][3],
7246 ]);
7247 assert_eq!(
7249 count, 2,
7250 "Binding count should include dynamic + plugin-skipped attrs, not static"
7251 );
7252 }
7253
7254 #[test]
7255 fn test_component_only_skipped_plugin_attrs_counted() {
7256 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
7257 parser
7258 .component_registry
7259 .register_component(ComponentRegistration::new(
7260 "my-btn",
7261 "<button><slot></slot></button>",
7262 None,
7263 true,
7264 ))
7265 .expect("register");
7266
7267 let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
7269 let result = parser.parse("index.html", html);
7270 assert!(result.is_ok());
7271
7272 let records = parser.into_fragment_records();
7273 let frags = &records["index.html"].fragments;
7274
7275 let plugin_frags: Vec<_> = frags
7276 .iter()
7277 .filter_map(|f| match f.fragment.as_ref() {
7278 Some(Fragment::Plugin(p)) => Some(&p.data),
7279 _ => None,
7280 })
7281 .collect();
7282 assert_eq!(plugin_frags.len(), 1);
7283 let count = u32::from_le_bytes([
7284 plugin_frags[0][0],
7285 plugin_frags[0][1],
7286 plugin_frags[0][2],
7287 plugin_frags[0][3],
7288 ]);
7289 assert_eq!(
7291 count, 2,
7292 "Only plugin-skipped attrs should be counted, not static title"
7293 );
7294 }
7295
7296 #[test]
7299 fn test_comment_handlebars_signal_is_stripped() {
7300 let mut parser = HtmlParser::new();
7301 let html = "<!--{{tokens}}-->";
7302 parser.parse("test.html", html).expect("parse failed");
7303 let records = parser.into_fragment_records();
7304
7305 assert_stream!(records, "test.html", []);
7306 }
7307
7308 #[test]
7309 fn test_comment_triple_brace_raw_signal_is_stripped() {
7310 let mut parser = HtmlParser::new();
7311 let html = "<!--{{{tokens}}}-->";
7312 parser.parse("test.html", html).expect("parse failed");
7313 let records = parser.into_fragment_records();
7314
7315 assert_stream!(records, "test.html", []);
7316 }
7317
7318 #[test]
7319 fn test_comment_regular_stripped() {
7320 let mut parser = HtmlParser::new();
7321 let html = "<!-- regular comment -->";
7322 parser.parse("test.html", html).expect("parse failed");
7323 let records = parser.into_fragment_records();
7324
7325 assert_stream!(records, "test.html", []);
7326 }
7327
7328 #[test]
7329 fn test_comment_dotted_signal_is_stripped() {
7330 let mut parser = HtmlParser::new();
7331 let html = "<!--{{tokens.light}}-->";
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_arbitrary_identifier_is_stripped() {
7340 let mut parser = HtmlParser::new();
7341 let html = "<!--{{someOtherBinding}}-->";
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_with_surrounding_content_is_stripped() {
7350 let mut parser = HtmlParser::new();
7351 let html = "<div><!--{{tokens}}--></div>";
7352 parser.parse("test.html", html).expect("parse failed");
7353 let records = parser.into_fragment_records();
7354
7355 assert_stream!(records, "test.html", [raw("<div></div>")]);
7356 }
7357
7358 #[test]
7359 fn test_component_template_unterminated_comment_returns_error() {
7360 let mut parser = HtmlParser::new();
7361 parser
7362 .component_registry_mut()
7363 .register_component(ComponentRegistration::new(
7364 "x-bad",
7365 "<span>ok</span><!-- missing close",
7366 None,
7367 true,
7368 ))
7369 .expect("register failed");
7370
7371 let result = parser.parse("index.html", "<x-bad></x-bad>");
7372
7373 assert!(matches!(result, Err(ParserError::Template(diag)) if
7374 diag.to_string().contains("unterminated HTML comment")
7375 ));
7376 }
7377
7378 #[test]
7379 fn test_webui_plugin_strips_component_comments_before_metadata() {
7380 let mut parser =
7381 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
7382 parser
7383 .component_registry_mut()
7384 .register_component(ComponentRegistration::new(
7385 "x-bleed",
7386 r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
7387 None,
7388 true,
7389 ))
7390 .expect("register failed");
7391
7392 parser
7393 .parse("index.html", "<x-bleed></x-bleed>")
7394 .expect("parse failed");
7395
7396 let artifacts = parser.take_plugin_artifacts().unwrap();
7397 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
7398 panic!("expected component templates");
7399 };
7400 let template = &templates[0].template_json;
7401 assert!(template.contains("<div>hello</div>"));
7402 assert!(!template.contains("{{path}}"));
7403 assert!(!template.contains("@click"));
7404 assert!(!template.contains("?hidden"));
7405 assert!(!template.contains("-->"));
7406 }
7407
7408 #[test]
7409 fn test_webui_plugin_strips_component_style_line_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-style",
7416 "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
7417 None,
7418 true,
7419 ))
7420 .expect("register failed");
7421
7422 parser
7423 .parse("index.html", "<x-style></x-style>")
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(".x { color: red; }"));
7432 assert!(!template.contains("ignored"));
7433 assert!(!template.contains("//"));
7434 }
7435
7436 #[test]
7439 fn test_tokens_from_style_tag() {
7440 let mut parser = HtmlParser::new();
7441 let html = r#"<style>
7442 .btn { color: var(--colorPrimary); background: var(--bgColor); }
7443 </style>"#;
7444 parser.parse("test.html", html).expect("parse failed");
7445 let tokens = parser.take_tokens();
7446
7447 assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
7448 }
7449
7450 #[test]
7451 fn test_tokens_from_malformed_style_error_on_unclosed_var() {
7452 let mut parser = HtmlParser::new();
7453 let html = r#"<style>
7454 .bad { color: var(--dangling; }
7455 .ok { color: var(--valid); }
7456 </style>"#;
7457 let result = parser.parse("test.html", html);
7458
7459 assert!(matches!(result, Err(ParserError::Template(diag)) if
7460 diag.error_code() == Some(codes::INVALID_CSS)
7461 && diag.to_string().contains("Unterminated CSS var() call")
7462 ));
7463 }
7464
7465 #[test]
7466 fn test_tokens_from_component_css() {
7467 let mut parser = HtmlParser::new();
7468 parser
7469 .component_registry_mut()
7470 .register_component(ComponentRegistration::new(
7471 "my-button",
7472 "<button>Click</button>",
7473 Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
7474 true,
7475 ))
7476 .expect("register failed");
7477
7478 let html = "<my-button></my-button>";
7479 parser.parse("test.html", html).expect("parse failed");
7480 let tokens = parser.take_tokens();
7481
7482 assert_eq!(tokens, vec!["borderWidth", "textColor"]);
7483 }
7484
7485 #[test]
7486 fn test_token_requirements_preserve_fallback_chains() {
7487 let mut parser = HtmlParser::new();
7488 parser
7489 .component_registry_mut()
7490 .register_component(ComponentRegistration::new(
7491 "my-card",
7492 "<div>Card</div>",
7493 Some(":host { color: var(--token-a, var(--token-b, var(--token-c), true)); }"),
7494 true,
7495 ))
7496 .expect("register failed");
7497
7498 parser
7499 .parse("test.html", "<my-card></my-card>")
7500 .expect("parse failed");
7501 let analysis = parser.token_analysis();
7502
7503 assert_eq!(analysis.fallback_chains.len(), 1);
7504 assert_eq!(
7505 analysis.fallback_chains[0].tokens,
7506 vec!["token-a", "token-b", "token-c"]
7507 );
7508 assert!(!analysis.fallback_chains[0].has_literal_fallback);
7509 }
7510
7511 #[test]
7512 fn test_token_requirements_remove_locally_defined_candidates_from_fallback_chain() {
7513 let mut parser = HtmlParser::new();
7514 parser
7515 .component_registry_mut()
7516 .register_component(ComponentRegistration::new(
7517 "my-card",
7518 "<div>Card</div>",
7519 Some(
7520 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
7521 ),
7522 true,))
7523 .expect("register failed");
7524
7525 parser
7526 .parse("test.html", "<my-card></my-card>")
7527 .expect("parse failed");
7528 let analysis = parser.token_analysis();
7529
7530 assert_eq!(analysis.protocol_tokens, vec!["token-b", "token-c"]);
7531 assert_eq!(analysis.fallback_chains.len(), 1);
7532 assert_eq!(
7533 analysis.fallback_chains[0].tokens,
7534 vec!["token-b", "token-c"]
7535 );
7536 }
7537
7538 #[test]
7539 fn test_token_analysis_theme_validation_reports_missing_unresolved_token() {
7540 let mut parser = HtmlParser::new();
7541 parser
7542 .component_registry_mut()
7543 .register_component(ComponentRegistration::new(
7544 "my-card",
7545 "<div>Card</div>",
7546 Some(
7547 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
7548 ),
7549 true,))
7550 .expect("register failed");
7551 parser
7552 .parse("test.html", "<my-card></my-card>")
7553 .expect("parse failed");
7554
7555 let analysis = parser.token_analysis();
7556 let theme = webui_tokens::TokenFile {
7557 themes: HashMap::from([(
7558 "light".to_string(),
7559 HashMap::from([("token-b".to_string(), "green".to_string())]),
7560 )]),
7561 };
7562
7563 let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
7564 panic!("missing --token-c in the theme must fail parser validation");
7565 };
7566 assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
7567 let help = diagnostic.help_text().expect("help text");
7570 assert!(help.contains("did you mean --token-b?"), "help: {help}");
7571 assert!(help.contains("define it locally"), "help: {help}");
7572 assert!(!help.contains("add --token"), "help: {help}");
7573 }
7574
7575 #[test]
7576 fn test_token_analysis_literal_fallback_token_exempt_from_theme_validation() {
7577 let mut parser = HtmlParser::new();
7578 parser
7579 .component_registry_mut()
7580 .register_component(ComponentRegistration::new(
7581 "my-card",
7582 "<div>Card</div>",
7583 Some(":host { color: var(--brand, #000); }"),
7584 true,
7585 ))
7586 .expect("register failed");
7587 parser
7588 .parse("test.html", "<my-card></my-card>")
7589 .expect("parse failed");
7590
7591 let analysis = parser.token_analysis();
7592 assert_eq!(analysis.protocol_tokens, vec!["brand"]);
7595
7596 let theme = webui_tokens::TokenFile {
7599 themes: HashMap::from([("light".to_string(), HashMap::new())]),
7600 };
7601 analysis
7602 .validate_theme_tokens(&theme)
7603 .expect("literal fallback must exempt --brand from theme validation");
7604 }
7605
7606 #[test]
7607 fn test_token_analysis_mixed_literal_and_bare_usage_requires_token() {
7608 let mut parser = HtmlParser::new();
7609 parser
7610 .component_registry_mut()
7611 .register_component(ComponentRegistration::new(
7612 "my-card",
7613 "<div>Card</div>",
7614 Some(":host { color: var(--brand, #000); background: var(--brand); }"),
7617 true,
7618 ))
7619 .expect("register failed");
7620 parser
7621 .parse("test.html", "<my-card></my-card>")
7622 .expect("parse failed");
7623
7624 let analysis = parser.token_analysis();
7625 let theme = webui_tokens::TokenFile {
7626 themes: HashMap::from([("light".to_string(), HashMap::new())]),
7627 };
7628
7629 let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
7630 panic!("a bare var(--brand) usage must require --brand in the theme");
7631 };
7632 assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
7633 assert!(diagnostic.to_string().contains("--brand"));
7634 }
7635
7636 #[test]
7637 fn test_unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
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(
7645 ":host { \
7646 color: var(--colr-brand, #000); \
7647 border: var(--present, 1px); \
7648 margin: var(--required); \
7649 }",
7650 ),
7651 true,
7652 ))
7653 .expect("register failed");
7654 parser
7655 .parse("test.html", "<my-card></my-card>")
7656 .expect("parse failed");
7657
7658 let analysis = parser.token_analysis();
7659 let theme = webui_tokens::TokenFile {
7660 themes: HashMap::from([(
7661 "light".to_string(),
7662 HashMap::from([
7663 ("present".to_string(), "2px".to_string()),
7664 ("required".to_string(), "8px".to_string()),
7665 ]),
7666 )]),
7667 };
7668
7669 assert_eq!(
7673 analysis.unthemed_literal_fallback_tokens(&theme),
7674 vec!["colr-brand".to_string()]
7675 );
7676 }
7677
7678 #[test]
7679 fn test_theme_token_error_reports_location_and_suggestion() {
7680 let mut parser = HtmlParser::new();
7681 parser
7682 .component_registry_mut()
7683 .register_component(ComponentRegistration::new(
7684 "my-card",
7685 "<div>Card</div>",
7686 ":host {\n color: var(--color-neutral-2000);\n}".into(),
7687 true,
7688 ))
7689 .expect("register failed");
7690 parser
7691 .parse("test.html", "<my-card></my-card>")
7692 .expect("parse failed");
7693
7694 let analysis = parser.token_analysis();
7695 let theme = webui_tokens::TokenFile {
7696 themes: HashMap::from([(
7697 "dark".to_string(),
7698 HashMap::from([("color-neutral-200".to_string(), "#222".to_string())]),
7699 )]),
7700 };
7701
7702 let Err(ParserError::Template(diag)) = analysis.validate_theme_tokens(&theme) else {
7703 panic!("missing --color-neutral-2000 must fail validation");
7704 };
7705 assert_eq!(diag.error_code(), Some(codes::MISSING_THEME_TOKEN));
7706 let location = diag.location().expect("a source location");
7708 assert!(location.contains("my-card.css:2:"), "location: {location}");
7709 assert!(
7711 diag.snippet_text()
7712 .is_some_and(|s| s.contains("--color-neutral-2000")),
7713 "snippet: {:?}",
7714 diag.snippet_text()
7715 );
7716 assert!(
7718 diag.help_text()
7719 .is_some_and(|h| h.contains("did you mean --color-neutral-200?")),
7720 "help: {:?}",
7721 diag.help_text()
7722 );
7723 }
7724
7725 #[test]
7726 fn test_theme_token_warning_reports_location_and_suggestion() {
7727 let mut parser = HtmlParser::new();
7728 parser
7729 .component_registry_mut()
7730 .register_component(ComponentRegistration::new(
7731 "my-card",
7732 "<div>Card</div>",
7733 ":host {\n color: var(--colr-brand, #000);\n}".into(),
7734 true,
7735 ))
7736 .expect("register failed");
7737 parser
7738 .parse("test.html", "<my-card></my-card>")
7739 .expect("parse failed");
7740
7741 let analysis = parser.token_analysis();
7742 let theme = webui_tokens::TokenFile {
7743 themes: HashMap::from([(
7744 "light".to_string(),
7745 HashMap::from([("color-brand".to_string(), "#abc".to_string())]),
7746 )]),
7747 };
7748
7749 let warnings = analysis.theme_token_warnings(&theme);
7750 assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
7751 let body = warnings[0].body();
7752 assert!(body.contains("my-card.css:2:"), "warning: {body}");
7753 assert!(body.contains("--colr-brand"), "warning: {body}");
7754 assert!(
7755 body.contains("did you mean --color-brand?"),
7756 "warning: {body}"
7757 );
7758 }
7759
7760 #[test]
7761 fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
7762 let mut parser = HtmlParser::new();
7763 let result =
7764 parser
7765 .component_registry_mut()
7766 .register_component(ComponentRegistration::new(
7767 "my-card",
7768 "<div>Card</div>",
7769 Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
7770 true,
7771 ));
7772
7773 assert!(matches!(result, Err(ParserError::Css(message)) if
7774 message.contains("Unterminated CSS var() call")
7775 ));
7776 }
7777
7778 #[test]
7779 fn test_tokens_merged_from_style_and_components() {
7780 let mut parser = HtmlParser::new();
7781 parser
7782 .component_registry_mut()
7783 .register_component(ComponentRegistration::new(
7784 "my-widget",
7785 "<div>Widget</div>",
7786 Some(".w { padding: var(--spacingM); }"),
7787 true,
7788 ))
7789 .expect("register failed");
7790
7791 let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
7792 parser.parse("test.html", html).expect("parse failed");
7793 let tokens = parser.take_tokens();
7794
7795 assert_eq!(tokens, vec!["spacingM", "textColor"]);
7796 }
7797
7798 #[test]
7799 fn test_tokens_deduplicated() {
7800 let mut parser = HtmlParser::new();
7801 parser
7802 .component_registry_mut()
7803 .register_component(ComponentRegistration::new(
7804 "my-btn",
7805 "<button>B</button>",
7806 Some(".b { color: var(--shared); }"),
7807 true,
7808 ))
7809 .expect("register failed");
7810
7811 let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
7812 parser.parse("test.html", html).expect("parse failed");
7813 let tokens = parser.take_tokens();
7814
7815 assert_eq!(tokens, vec!["shared"]);
7816 }
7817
7818 #[test]
7819 fn test_tokens_empty_when_no_vars() {
7820 let mut parser = HtmlParser::new();
7821 let html = "<div>Hello</div>";
7822 parser.parse("test.html", html).expect("parse failed");
7823 let tokens = parser.take_tokens();
7824
7825 assert!(tokens.is_empty());
7826 }
7827
7828 #[test]
7829 fn test_tokens_exclude_locally_defined_in_component() {
7830 let mut parser = HtmlParser::new();
7831 parser
7832 .component_registry_mut()
7833 .register_component(ComponentRegistration::new(
7834 "my-card",
7835 "<div>Card</div>",
7836 Some(":host { --local: 5px; width: var(--external); }"),
7837 true,
7838 ))
7839 .expect("register failed");
7840
7841 let html = "<my-card></my-card>";
7842 parser.parse("test.html", html).expect("parse failed");
7843 let tokens = parser.take_tokens();
7844
7845 assert_eq!(tokens, vec!["external"]);
7846 }
7847
7848 #[test]
7849 fn test_tokens_exclude_entry_root_definitions() {
7850 let mut parser = HtmlParser::new();
7851 parser
7852 .component_registry_mut()
7853 .register_component(ComponentRegistration::new(
7854 "my-btn",
7855 "<button>B</button>",
7856 Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
7857 true,
7858 ))
7859 .expect("register failed");
7860
7861 let html = r#"<style>
7864 :root {
7865 --color-primary: #0078d4;
7866 --radius-m: 6px;
7867 }
7868 body { color: var(--color-primary); }
7869 </style>
7870 <my-btn></my-btn>"#;
7871 parser.parse("test.html", html).expect("parse failed");
7872 let tokens = parser.take_tokens();
7873
7874 assert!(
7876 tokens.is_empty(),
7877 "Tokens defined in entry :root should be excluded: {tokens:?}"
7878 );
7879 }
7880
7881 #[test]
7882 fn test_tokens_entry_defs_exclude_but_external_kept() {
7883 let mut parser = HtmlParser::new();
7884 parser
7885 .component_registry_mut()
7886 .register_component(ComponentRegistration::new(
7887 "my-card",
7888 "<div>Card</div>",
7889 Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
7890 true,
7891 ))
7892 .expect("register failed");
7893
7894 let html = r#"<style>
7896 :root { --color-primary: #0078d4; }
7897 </style>
7898 <my-card></my-card>"#;
7899 parser.parse("test.html", html).expect("parse failed");
7900 let tokens = parser.take_tokens();
7901
7902 assert_eq!(tokens, vec!["external-spacing"]);
7904 }
7905
7906 #[test]
7909 fn test_parse_simple_route() {
7910 let mut parser = HtmlParser::new();
7911 let html = r#"<route path="/profile" component="profile-page" exact />"#;
7912 parser.parse("test.html", html).expect("parse failed");
7913
7914 let records = parser.into_fragment_records();
7916 let frags = &records["test.html"].fragments;
7917 assert_eq!(frags.len(), 1);
7918 match frags[0].fragment.as_ref() {
7919 Some(web_ui_fragment::Fragment::Route(r)) => {
7920 assert_eq!(r.path, "/profile");
7921 assert_eq!(r.fragment_id, "profile-page");
7922 assert!(r.exact);
7923 }
7924 other => panic!("expected Fragment::Route, got {:?}", other),
7925 }
7926 }
7927
7928 #[test]
7929 fn test_parse_route_with_params() {
7930 let mut parser = HtmlParser::new();
7931 let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
7932 parser.parse("test.html", html).expect("parse failed");
7933
7934 let records = parser.into_fragment_records();
7936 let frags = &records["test.html"].fragments;
7937 match frags[0].fragment.as_ref() {
7938 Some(web_ui_fragment::Fragment::Route(r)) => {
7939 assert_eq!(r.path, "/profile/:id/view/:section");
7940 }
7941 other => panic!("expected Fragment::Route, got {:?}", other),
7942 }
7943 }
7944
7945 #[test]
7946 fn test_parse_route_requires_component() {
7947 let mut parser = HtmlParser::new();
7948 let html = r#"<route path="/old" />"#;
7949 let result = parser.parse("test.html", html);
7950 assert!(result.is_err());
7951 }
7952
7953 #[test]
7954 fn test_parse_multiple_routes() {
7955 let mut parser = HtmlParser::new();
7956 let html = r#"<route path="/" component="app-layout" />
7957 <route path="/dashboard" component="dash-page" exact />
7958 <route path="/contacts" component="contacts-page" exact />"#;
7959 parser.parse("test.html", html).expect("parse failed");
7960
7961 let records = parser.into_fragment_records();
7962 let frags = &records["test.html"].fragments;
7963 assert_eq!(frags.len(), 3);
7964 for frag in frags {
7966 assert!(matches!(
7967 frag.fragment.as_ref(),
7968 Some(web_ui_fragment::Fragment::Route(_))
7969 ));
7970 }
7971 }
7972
7973 #[test]
7974 fn test_parse_route_requires_component_with_body() {
7975 let mut parser = HtmlParser::new();
7976 let html = r#"<route path="/error">
7977 <div class="error"><h1>Not Found</h1></div>
7978 </route>"#;
7979 let result = parser.parse("test.html", html);
7980 assert!(
7981 result.is_err(),
7982 "Route without component attribute should fail"
7983 );
7984 }
7985
7986 #[test]
7987 fn test_parse_multiple_routes_with_fragments() {
7988 let mut parser = HtmlParser::new();
7989 let html = r#"<route path="/" component="home-page" exact />
7990 <route path="/about" component="about-page" exact />
7991 <route path="/contact/:id" component="contact-page" />"#;
7992 parser.parse("test.html", html).expect("parse failed");
7993
7994 let records = parser.into_fragment_records();
7995 let frags = &records["test.html"].fragments;
7996 assert_eq!(frags.len(), 3);
7997
7998 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
8000 assert_eq!(r.path, "/");
8001 }
8002 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
8003 assert_eq!(r.path, "/about");
8004 }
8005 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
8006 assert_eq!(r.path, "/contact/:id");
8007 }
8008 }
8009
8010 #[test]
8011 fn test_parse_route_with_query_allowlist() {
8012 let mut parser = HtmlParser::new();
8013 let html =
8014 r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
8015 parser.parse("test.html", html).expect("parse failed");
8016
8017 let records = parser.into_fragment_records();
8018 let frags = &records["test.html"].fragments;
8019 assert_eq!(frags.len(), 1);
8020 match frags[0].fragment.as_ref() {
8021 Some(web_ui_fragment::Fragment::Route(r)) => {
8022 assert_eq!(r.path, "/compose");
8023 assert_eq!(r.fragment_id, "compose-page");
8024 assert!(r.exact);
8025 assert_eq!(r.allowed_query, "action,to,subject");
8026 }
8027 other => panic!("expected Fragment::Route, got {:?}", other),
8028 }
8029 }
8030
8031 #[test]
8032 fn test_parse_route_without_query_has_empty_allowed_query() {
8033 let mut parser = HtmlParser::new();
8034 let html = r#"<route path="/profile" component="profile-page" exact />"#;
8035 parser.parse("test.html", html).expect("parse failed");
8036
8037 let records = parser.into_fragment_records();
8038 let frags = &records["test.html"].fragments;
8039 match frags[0].fragment.as_ref() {
8040 Some(web_ui_fragment::Fragment::Route(r)) => {
8041 assert!(r.allowed_query.is_empty());
8042 }
8043 other => panic!("expected Fragment::Route, got {:?}", other),
8044 }
8045 }
8046
8047 #[test]
8048 fn test_route_body_rejects_malformed_non_route_html() {
8049 let mut parser = HtmlParser::new();
8050 let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
8051 let result = parser.parse("test.html", html);
8052
8053 assert!(matches!(result, Err(ParserError::Template(diag)) if
8054 diag.to_string().contains("unclosed <span> tag")
8055 ));
8056 }
8057
8058 #[test]
8059 fn test_route_body_rejects_malformed_style_css() {
8060 let mut parser = HtmlParser::new();
8061 let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
8062 let result = parser.parse("test.html", html);
8063
8064 assert!(matches!(result, Err(ParserError::Template(diag)) if
8065 diag.error_code() == Some(codes::INVALID_CSS)
8066 && diag.to_string().contains("Unterminated CSS var() call")
8067 ));
8068 }
8069
8070 #[test]
8071 fn test_outlet_not_captured_by_for_loop() {
8072 let mut parser = HtmlParser::new();
8073 let html = r#"<template shadowrootmode="open">
8074 <ul>
8075 <for each="item in items">
8076 <li>{{item.name}}</li>
8077 </for>
8078 </ul>
8079 <main>
8080 <outlet />
8081 </main>
8082</template>"#;
8083 parser.parse("comp.html", html).expect("parse failed");
8084
8085 let records = parser.into_fragment_records();
8086 let frags = &records["comp.html"].fragments;
8087
8088 let outlet_count = frags
8090 .iter()
8091 .filter(|f| {
8092 matches!(
8093 f.fragment.as_ref(),
8094 Some(web_ui_fragment::Fragment::Outlet(_))
8095 )
8096 })
8097 .count();
8098 assert_eq!(
8099 outlet_count, 1,
8100 "expected exactly 1 outlet in top-level fragments, got {outlet_count}"
8101 );
8102
8103 let outlet_idx = frags
8105 .iter()
8106 .position(|f| {
8107 matches!(
8108 f.fragment.as_ref(),
8109 Some(web_ui_fragment::Fragment::Outlet(_))
8110 )
8111 })
8112 .expect("no outlet found");
8113 let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
8114 Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
8115 _ => false,
8116 });
8117 if let Some(ul_idx) = close_ul_idx {
8118 assert!(
8119 outlet_idx > ul_idx,
8120 "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
8121 );
8122 }
8123 }
8124
8125 #[test]
8126 fn test_outlet_position_after_for_not_inside() {
8127 let mut parser = HtmlParser::new();
8128 let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
8129 parser.parse("test.html", html).expect("parse failed");
8130
8131 let records = parser.into_fragment_records();
8132 let frags = &records["test.html"].fragments;
8133
8134 let has_outlet = frags.iter().any(|f| {
8136 matches!(
8137 f.fragment.as_ref(),
8138 Some(web_ui_fragment::Fragment::Outlet(_))
8139 )
8140 });
8141 assert!(
8142 has_outlet,
8143 "outlet should be in top-level fragments: {frags:?}"
8144 );
8145
8146 let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
8148 Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
8149 _ => None,
8150 });
8151 if let Some(id) = for_id {
8152 let for_frags = &records[&id].fragments;
8153 let outlet_in_for = for_frags.iter().any(|f| {
8154 matches!(
8155 f.fragment.as_ref(),
8156 Some(web_ui_fragment::Fragment::Outlet(_))
8157 )
8158 });
8159 assert!(
8160 !outlet_in_for,
8161 "outlet should NOT be inside for-loop body: {for_frags:?}"
8162 );
8163 }
8164 }
8165
8166 #[test]
8167 fn test_style_element_with_handlebars_signal_comment_emits_signal() {
8168 let mut parser = HtmlParser::new();
8169 let html = r#"<html><head><style>
8170:root {
8171 /*{{{tokens.light}}}*/
8172}
8173</style></head><body></body></html>"#;
8174
8175 let result = parser.parse("test.html", html);
8176 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8177 let fragment_records = parser.into_fragment_records();
8178
8179 assert_stream!(
8180 fragment_records,
8181 "test.html",
8182 [
8183 raw("<html><head>"),
8184 structural_matcher("head_start"),
8185 raw("<style>\n:root {\n "),
8186 signal_raw("tokens.light"),
8187 raw("\n}\n</style>"),
8188 structural_matcher("head_end"),
8189 raw("</head><body>"),
8190 structural_matcher("body_start"),
8191 structural_matcher("body_end"),
8192 raw("</body></html>"),
8193 ]
8194 );
8195 }
8196
8197 #[test]
8198 fn test_style_comment_signal_with_spaces_emits_signal() {
8199 let mut parser = HtmlParser::new();
8200 let html = r#"<html><head><style>
8201:root {
8202 /* {{{tokens.light}}} */
8203}
8204</style></head><body></body></html>"#;
8205
8206 let result = parser.parse("test.html", html);
8207 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8208 let fragment_records = parser.into_fragment_records();
8209
8210 assert_stream!(
8211 fragment_records,
8212 "test.html",
8213 [
8214 raw("<html><head>"),
8215 structural_matcher("head_start"),
8216 raw("<style>\n:root {\n "),
8217 signal_raw("tokens.light"),
8218 raw("\n}\n</style>"),
8219 structural_matcher("head_end"),
8220 raw("</head><body>"),
8221 structural_matcher("body_start"),
8222 structural_matcher("body_end"),
8223 raw("</body></html>"),
8224 ]
8225 );
8226 }
8227
8228 #[test]
8229 fn test_style_comment_signal_double_brace_emits_signal() {
8230 let mut parser = HtmlParser::new();
8231 let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
8232
8233 let result = parser.parse("test.html", html);
8234 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8235 let fragment_records = parser.into_fragment_records();
8236
8237 assert_stream!(
8238 fragment_records,
8239 "test.html",
8240 [
8241 raw("<html><head>"),
8242 structural_matcher("head_start"),
8243 raw("<style>"),
8244 signal("themeCss"),
8245 raw("</style>"),
8246 structural_matcher("head_end"),
8247 raw("</head><body>"),
8248 structural_matcher("body_start"),
8249 structural_matcher("body_end"),
8250 raw("</body></html>"),
8251 ]
8252 );
8253 }
8254
8255 #[test]
8256 fn test_style_comment_with_extra_text_is_stripped() {
8257 let mut parser = HtmlParser::new();
8258 let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
8259
8260 let result = parser.parse("test.html", html);
8261 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8262 let fragment_records = parser.into_fragment_records();
8263
8264 assert_stream!(
8265 fragment_records,
8266 "test.html",
8267 [
8268 raw("<html><head>"),
8269 structural_matcher("head_start"),
8270 raw("<style></style>"),
8271 structural_matcher("head_end"),
8272 raw("</head><body>"),
8273 structural_matcher("body_start"),
8274 structural_matcher("body_end"),
8275 raw("</body></html>"),
8276 ]
8277 );
8278 }
8279
8280 #[test]
8281 fn test_style_comment_with_multiple_signals_is_stripped() {
8282 let mut parser = HtmlParser::new();
8283 let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
8284
8285 let result = parser.parse("test.html", html);
8286 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8287 let fragment_records = parser.into_fragment_records();
8288
8289 assert_stream!(
8290 fragment_records,
8291 "test.html",
8292 [
8293 raw("<html><head>"),
8294 structural_matcher("head_start"),
8295 raw("<style></style>"),
8296 structural_matcher("head_end"),
8297 raw("</head><body>"),
8298 structural_matcher("body_start"),
8299 structural_matcher("body_end"),
8300 raw("</body></html>"),
8301 ]
8302 );
8303 }
8304
8305 #[test]
8306 fn test_style_bare_handlebars_stays_raw() {
8307 let mut parser = HtmlParser::new();
8308 let html =
8310 "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
8311
8312 let result = parser.parse("test.html", html);
8313 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8314 let fragment_records = parser.into_fragment_records();
8315
8316 assert_stream!(
8317 fragment_records,
8318 "test.html",
8319 [
8320 raw("<html><head>"),
8321 structural_matcher("head_start"),
8322 raw("<style>body { color: {{textColor}}; }</style>"),
8323 structural_matcher("head_end"),
8324 raw("</head><body>"),
8325 structural_matcher("body_start"),
8326 structural_matcher("body_end"),
8327 raw("</body></html>"),
8328 ]
8329 );
8330 }
8331
8332 #[test]
8333 fn test_style_mixed_css_and_comment_signal_emits_signal() {
8334 let mut parser = HtmlParser::new();
8335 let html = r#"<html><head><style>
8336 .a { color: red; }
8337 /*{{themeCss}}*/
8338 .b { color: blue; }
8339</style></head><body></body></html>"#;
8340
8341 let result = parser.parse("test.html", html);
8342 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8343 let fragment_records = parser.into_fragment_records();
8344
8345 assert_stream!(
8346 fragment_records,
8347 "test.html",
8348 [
8349 raw("<html><head>"),
8350 structural_matcher("head_start"),
8351 raw("<style>\n .a { color: red; }\n "),
8352 signal("themeCss"),
8353 raw("\n .b { color: blue; }\n</style>"),
8354 structural_matcher("head_end"),
8355 raw("</head><body>"),
8356 structural_matcher("body_start"),
8357 structural_matcher("body_end"),
8358 raw("</body></html>"),
8359 ]
8360 );
8361 }
8362
8363 #[test]
8364 fn test_style_legal_comment_preserved_inline_by_default() {
8365 let mut parser = HtmlParser::new();
8366 let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
8367
8368 parser.parse("test.html", html).expect("parse failed");
8369 let fragment_records = parser.into_fragment_records();
8370
8371 assert_stream!(
8372 fragment_records,
8373 "test.html",
8374 [
8375 raw("<html><head>"),
8376 structural_matcher("head_start"),
8377 raw("<style>/*! @license MIT */ .x { color: red; } </style>"),
8378 structural_matcher("head_end"),
8379 raw("</head><body>"),
8380 structural_matcher("body_start"),
8381 structural_matcher("body_end"),
8382 raw("</body></html>"),
8383 ]
8384 );
8385 }
8386
8387 #[test]
8388 fn test_style_legal_comments_none_strips_legal_comment() {
8389 let mut parser = HtmlParser::with_options(ParserOptions {
8390 legal_comments: LegalComments::None,
8391 ..ParserOptions::default()
8392 });
8393 let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
8394
8395 parser.parse("test.html", html).expect("parse failed");
8396 let fragment_records = parser.into_fragment_records();
8397
8398 assert_stream!(
8399 fragment_records,
8400 "test.html",
8401 [
8402 raw("<html><head>"),
8403 structural_matcher("head_start"),
8404 raw("<style> .x { color: red; }</style>"),
8405 structural_matcher("head_end"),
8406 raw("</head><body>"),
8407 structural_matcher("body_start"),
8408 structural_matcher("body_end"),
8409 raw("</body></html>"),
8410 ]
8411 );
8412 }
8413
8414 #[test]
8415 fn test_style_element_plain_css_unchanged() {
8416 let mut parser = HtmlParser::new();
8417 let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
8418
8419 let result = parser.parse("test.html", html);
8420 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8421 let fragment_records = parser.into_fragment_records();
8422
8423 assert_stream!(
8425 fragment_records,
8426 "test.html",
8427 [
8428 raw("<html><head>"),
8429 structural_matcher("head_start"),
8430 raw("<style>body { margin: 0; }</style>"),
8431 structural_matcher("head_end"),
8432 raw("</head><body>"),
8433 structural_matcher("body_start"),
8434 structural_matcher("body_end"),
8435 raw("</body></html>"),
8436 ]
8437 );
8438 }
8439
8440 #[test]
8441 fn test_unclosed_style_element_returns_error() {
8442 let mut parser = HtmlParser::new();
8443 let result = parser.parse("index.html", "<style>.x { color: red; ");
8444 assert!(matches!(result, Err(ParserError::Template(diag)) if
8445 diag.to_string().contains("unclosed <style> tag")
8446 ));
8447 }
8448
8449 #[test]
8450 fn test_text_with_binding_after_void_element() {
8451 let (fragments, _) = parse_and_get_fragments(
8454 "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
8455 );
8456 assert_fragments!(
8457 fragments,
8458 [
8459 raw("Part A: "),
8460 signal("value1"),
8461 raw("<button>Click</button><br>Part B: "),
8462 signal("value2"),
8463 raw("<button>Click2</button>"),
8464 ]
8465 );
8466 }
8467}