1mod asset_filename;
8mod comment_policy;
9mod component_policy;
10mod component_registry;
11mod condition_parser;
12mod css_light;
13mod css_link;
14mod css_parser;
15mod css_scan;
16mod diagnostic;
17mod error;
18mod handlebars_parser;
19mod html_parser;
20pub mod plugin;
21mod route_parser;
22mod suggest;
23
24pub use asset_filename::{
25 AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
26};
27pub use component_registry::{Component, ComponentRegistration, ComponentRegistry};
28pub use condition_parser::ConditionParser;
29pub use css_link::{CssLinkHref, CssLinkOptions, DEFAULT_CSS_FILE_NAME_TEMPLATE};
30pub use css_parser::CssParser;
31pub use diagnostic::{codes, Diagnostic, Severity};
32pub use error::{ParserError, Result};
33pub use handlebars_parser::HandlebarsParser;
34pub use webui_tokens::CssFallbackChain;
35
36use crate::component_policy::{
37 parse_component_render_policy, ComponentRenderPolicy, HYDRATE_ATTR as COMPONENT_HYDRATE_ATTR,
38 RENDER_ATTR as COMPONENT_RENDER_ATTR,
39 RESERVE_BLOCK_SIZE_ATTR as COMPONENT_RESERVE_BLOCK_SIZE_ATTR,
40};
41use crate::html_parser::{self as html, Attrs, Element, Event, Walker};
42use crate::plugin::{
43 AttributeAction, AttributeContext, ComponentBuildContext, ComponentProcessing,
44 ElementStartContext, FragmentContext, ParserPlugin, ParserPluginArtifacts,
45};
46use std::collections::{HashMap, HashSet};
47use std::ops::Range;
48use webui_protocol::{
49 web_ui_fragment, web_ui_fragment::Fragment, BoundaryPhase, ConditionExpr, FragmentList,
50 WebUIFragment, WebUIFragmentAttribute, WebUIFragmentRecords, WebUiFragmentBoundary,
51 WebUiFragmentRoute,
52};
53
54const MAX_TEMPLATE_BYTES: usize = 16 * 1024 * 1024;
60
61const MAX_TEMPLATE_DEPTH: usize = 512;
67
68const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
74
75fn structural_signal(value: impl AsRef<str>) -> WebUIFragment {
76 WebUIFragment::signal(
77 format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
78 true,
79 )
80}
81
82#[derive(Clone, Copy, PartialEq, Eq)]
86enum FosterContext {
87 Transparent,
89 Hostile,
92 Barrier,
95}
96
97#[inline]
103fn foster_context_of(name: &str) -> FosterContext {
104 match name.len() {
105 2 => {
106 if name.eq_ignore_ascii_case("tr") {
107 FosterContext::Hostile
108 } else if name.eq_ignore_ascii_case("td") || name.eq_ignore_ascii_case("th") {
109 FosterContext::Barrier
110 } else {
111 FosterContext::Transparent
112 }
113 }
114 5 => {
115 if name.eq_ignore_ascii_case("table")
116 || name.eq_ignore_ascii_case("tbody")
117 || name.eq_ignore_ascii_case("thead")
118 || name.eq_ignore_ascii_case("tfoot")
119 {
120 FosterContext::Hostile
121 } else {
122 FosterContext::Transparent
123 }
124 }
125 6 => {
126 if name.eq_ignore_ascii_case("select") {
127 FosterContext::Hostile
128 } else {
129 FosterContext::Transparent
130 }
131 }
132 7 => {
133 if name.eq_ignore_ascii_case("caption") {
134 FosterContext::Barrier
135 } else {
136 FosterContext::Transparent
137 }
138 }
139 8 => {
140 if name.eq_ignore_ascii_case("optgroup") || name.eq_ignore_ascii_case("colgroup") {
141 FosterContext::Hostile
142 } else {
143 FosterContext::Transparent
144 }
145 }
146 _ => FosterContext::Transparent,
147 }
148}
149
150#[inline]
153fn boundary_parent_scope(name: &str) -> Option<&'static str> {
154 match name.len() {
155 3 if name.eq_ignore_ascii_case("xmp") => Some("<xmp> raw-text content"),
156 5 if name.eq_ignore_ascii_case("title") => Some("<title> text content"),
157 6 if name.eq_ignore_ascii_case("iframe") => Some("<iframe> raw-text content"),
158 6 if name.eq_ignore_ascii_case("script") => Some("<script> raw-text content"),
159 7 if name.eq_ignore_ascii_case("noembed") => Some("<noembed> raw-text content"),
160 8 if name.eq_ignore_ascii_case("template") => Some("<template> inert content"),
161 8 if name.eq_ignore_ascii_case("textarea") => Some("<textarea> text content"),
162 8 if name.eq_ignore_ascii_case("noframes") => Some("<noframes> raw-text content"),
163 8 if name.eq_ignore_ascii_case("noscript") => Some("<noscript> inert content"),
164 9 if name.eq_ignore_ascii_case("plaintext") => Some("<plaintext> text content"),
165 _ => None,
166 }
167}
168
169#[inline]
170fn is_html_text_only_scope(scope: Option<&str>) -> bool {
171 matches!(
177 scope,
178 Some(
179 "<xmp> raw-text content"
180 | "<title> text content"
181 | "<iframe> raw-text content"
182 | "<script> raw-text content"
183 | "<noembed> raw-text content"
184 | "<textarea> text content"
185 | "<noframes> raw-text content"
186 | "<noscript> inert content"
187 | "<plaintext> text content"
188 )
189 )
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
194#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
195pub enum CssStrategy {
196 #[default]
198 Link,
199 Style,
201 Module,
205}
206
207impl std::fmt::Display for CssStrategy {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 match self {
210 CssStrategy::Link => write!(f, "link"),
211 CssStrategy::Style => write!(f, "style"),
212 CssStrategy::Module => write!(f, "module"),
213 }
214 }
215}
216
217impl std::str::FromStr for CssStrategy {
218 type Err = String;
219
220 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
221 match s {
222 "link" => Ok(CssStrategy::Link),
223 "style" => Ok(CssStrategy::Style),
224 "module" => Ok(CssStrategy::Module),
225 other => Err(format!(
226 "Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
227 )),
228 }
229 }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
234#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
235pub enum DomStrategy {
236 #[default]
238 Shadow,
239 Light,
241}
242
243impl std::fmt::Display for DomStrategy {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self {
246 Self::Shadow => write!(f, "shadow"),
247 Self::Light => write!(f, "light"),
248 }
249 }
250}
251
252impl std::str::FromStr for DomStrategy {
253 type Err = String;
254
255 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
256 match value {
257 "shadow" => Ok(Self::Shadow),
258 "light" => Ok(Self::Light),
259 other => Err(format!(
260 "Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
261 )),
262 }
263 }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
268#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
269pub enum LegalComments {
270 None,
272 #[default]
274 Inline,
275}
276
277impl std::fmt::Display for LegalComments {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 match self {
280 LegalComments::None => write!(f, "none"),
281 LegalComments::Inline => write!(f, "inline"),
282 }
283 }
284}
285
286impl std::str::FromStr for LegalComments {
287 type Err = String;
288
289 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
290 match s {
291 "none" => Ok(LegalComments::None),
292 "inline" => Ok(LegalComments::Inline),
293 other => Err(format!(
294 "Unknown legal comments strategy: {other}. Use \"none\" or \"inline\"."
295 )),
296 }
297 }
298}
299
300#[derive(Debug, Clone, Default)]
302pub struct ParserOptions {
303 pub css_strategy: CssStrategy,
305 pub dom_strategy: DomStrategy,
307 pub css_link_options: CssLinkOptions,
309 pub legal_comments: LegalComments,
311}
312
313impl ParserOptions {
314 pub fn try_new(
320 css_strategy: CssStrategy,
321 dom_strategy: DomStrategy,
322 css_file_name_template: &str,
323 css_public_base: Option<&str>,
324 legal_comments: LegalComments,
325 ) -> Result<Self> {
326 let css_link_options = if css_strategy == CssStrategy::Link {
327 CssLinkOptions::try_new(
328 css_file_name_template.to_string(),
329 css_public_base.map(std::string::ToString::to_string),
330 )?
331 } else {
332 CssLinkOptions::default()
333 };
334
335 Ok(Self {
336 css_strategy,
337 dom_strategy,
338 css_link_options,
339 legal_comments,
340 })
341 }
342}
343
344impl From<DomStrategy> for ParserOptions {
345 fn from(dom_strategy: DomStrategy) -> Self {
346 Self {
347 dom_strategy,
348 ..Self::default()
349 }
350 }
351}
352
353impl From<CssStrategy> for ParserOptions {
354 fn from(css_strategy: CssStrategy) -> Self {
355 Self {
356 css_strategy,
357 ..Self::default()
358 }
359 }
360}
361
362impl From<(CssStrategy, DomStrategy)> for ParserOptions {
363 fn from((css_strategy, dom_strategy): (CssStrategy, DomStrategy)) -> Self {
364 Self {
365 css_strategy,
366 dom_strategy,
367 ..Self::default()
368 }
369 }
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
375pub enum Plugin {
376 #[cfg_attr(feature = "cli", value(name = "fast"))]
378 Fast,
379 #[cfg_attr(feature = "cli", value(name = "fast-v2"))]
381 FastV2,
382 #[cfg_attr(feature = "cli", value(name = "fast-v3"))]
384 FastV3,
385 #[cfg_attr(feature = "cli", value(name = "webui"))]
387 WebUI,
388}
389
390impl std::fmt::Display for Plugin {
391 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392 match self {
393 Plugin::Fast => write!(f, "fast"),
394 Plugin::FastV2 => write!(f, "fast-v2"),
395 Plugin::FastV3 => write!(f, "fast-v3"),
396 Plugin::WebUI => write!(f, "webui"),
397 }
398 }
399}
400
401impl std::str::FromStr for Plugin {
402 type Err = String;
403
404 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
405 match s {
406 "fast" => Ok(Plugin::Fast),
407 "fast-v2" => Ok(Plugin::FastV2),
408 "fast-v3" => Ok(Plugin::FastV3),
409 "webui" => Ok(Plugin::WebUI),
410 other => Err(format!(
411 "Unknown plugin: {other}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
412 )),
413 }
414 }
415}
416
417struct FragmentIdCounter {
419 counters: HashMap<String, usize>,
421}
422
423impl FragmentIdCounter {
424 fn new() -> Self {
426 Self {
427 counters: HashMap::new(),
428 }
429 }
430
431 fn next_id(&mut self, prefix: &str) -> String {
433 let count = self.counters.entry(prefix.to_string()).or_insert(0);
434 *count += 1;
435 format!("{}-{}", prefix, count)
436 }
437}
438
439struct ParseContext {
440 fragments: Vec<WebUIFragment>,
441 raw_buffer: String,
442}
443
444struct PendingBoundary {
445 declaration_id: u32,
446}
447
448struct BoundaryGraphEdge {
449 target: String,
450}
451
452struct RepeatSite<'a> {
454 owner: &'a str,
455 item: &'a str,
456 collection: &'a str,
457}
458
459struct RepeatBoundaryViolation {
461 repeat_owner: String,
462 each: String,
463 boundary_owner: String,
464 boundary_name: String,
465}
466
467fn push_route_records<'a>(targets: &mut Vec<&'a str>, root: &'a WebUiFragmentRoute) {
469 let mut pending = vec![root];
470 while let Some(route) = pending.pop() {
471 targets.push(route.fragment_id.as_str());
472 targets.push(route.content_fragment_id.as_str());
473 targets.push(route.pending_component.as_str());
474 targets.push(route.error_component.as_str());
475 pending.extend(route.children.iter());
476 }
477}
478
479fn push_child_records<'a>(list: &'a FragmentList, targets: &mut Vec<&'a str>) {
481 for fragment in &list.fragments {
482 match fragment.fragment.as_ref() {
483 Some(Fragment::Component(component)) => targets.push(&component.fragment_id),
484 Some(Fragment::IfCond(if_cond)) => targets.push(&if_cond.fragment_id),
485 Some(Fragment::ForLoop(for_loop)) => targets.push(&for_loop.fragment_id),
486 Some(Fragment::Route(route)) => push_route_records(targets, route),
487 _ => {}
488 }
489 }
490}
491
492#[derive(Default)]
493struct FragmentCssTokens {
494 definitions: Vec<String>,
495 fallback_chains: Vec<CssFallbackChain>,
496}
497
498#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct CssTokenAnalysis {
501 pub protocol_tokens: Vec<String>,
503 pub fallback_chains: Vec<CssFallbackChain>,
506 pub(crate) token_sites: HashMap<String, TokenSite>,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq)]
518pub(crate) struct TokenSite {
519 owner: String,
520 position: Option<(usize, usize)>,
521 snippet: Option<String>,
522}
523
524enum ParseOp<'a> {
525 Parse {
526 range: Range<usize>,
527 depth: usize,
528 },
529 EmitClose(&'a str),
530 EndComponent(&'a str),
531 EndHead(&'a str),
532 EndBody(&'a str),
533 CompleteBoundary {
534 boundary: PendingBoundary,
535 },
536 RestoreFosterDepth(u32),
542 RestoreBoundaryParentScope(Option<&'static str>),
545 CompleteFor {
546 parent: ParseContext,
547 item: String,
548 collection: String,
549 fragment_id: String,
550 keep_empty: bool,
551 previous_for_depth: usize,
552 },
553 CompleteIf {
554 parent: ParseContext,
555 condition: ConditionExpr,
556 fragment_id: String,
557 },
558}
559
560enum TokenGraphOp<'a> {
561 EnterFragment(&'a str),
562 EnterComponent(&'a str),
563 EnterRoute(&'a WebUiFragmentRoute),
564 ExitDefinitions(&'a [String]),
565}
566
567impl Default for HtmlParser {
568 fn default() -> Self {
569 Self::new()
570 }
571}
572
573pub struct HtmlParser {
575 css_parser: CssParser,
577
578 id_counter: FragmentIdCounter,
580
581 condition_parser: ConditionParser,
583
584 handlebars_parser: HandlebarsParser,
586
587 component_registry: ComponentRegistry,
589
590 fragment_records: WebUIFragmentRecords,
592
593 raw_buffer: String,
595
596 options: ParserOptions,
598
599 plugin: Option<Box<dyn ParserPlugin>>,
601
602 component_processing: ComponentProcessing,
604
605 component_dom_analyses: HashMap<String, ComponentDomAnalysis>,
607
608 token_roots: Vec<String>,
611
612 fragment_css_tokens: HashMap<String, FragmentCssTokens>,
615
616 in_progress_fragments: HashSet<String>,
619
620 current_fragment_id: String,
623
624 next_boundary_declaration_id: u32,
626
627 boundary_names_by_owner: HashMap<String, HashSet<String>>,
629
630 in_boundary: bool,
633
634 boundary_ancestor_depth: usize,
639
640 body_depth: usize,
644
645 for_depth: usize,
648
649 route_depth: usize,
651
652 foster_context_depth: u32,
661
662 boundary_parent_scope: Option<&'static str>,
667
668 module_entry_srcs: Vec<String>,
680}
681
682struct BuiltComponentTemplate {
683 ssr: String,
684 artifact: Option<String>,
685 uses_shadow_dom: bool,
686 style: Option<OwnedComponentStyle>,
687}
688
689enum OwnedComponentStyle {
692 Link(String),
693 Inline(String),
694 Adopted(String),
695}
696
697#[derive(Clone, Copy)]
698struct ComponentStyleInjection<'a> {
699 css_snippet: Option<&'a str>,
700 adopted_specifier: Option<&'a str>,
701}
702
703#[derive(Clone, Copy)]
704struct ComponentTemplateMode {
705 preserve_runtime_attrs: bool,
706 policy_wrapper: bool,
707 styles_at_end: bool,
709}
710
711impl BuiltComponentTemplate {
712 fn artifact(&self) -> &str {
713 self.artifact.as_deref().unwrap_or(&self.ssr)
714 }
715
716 fn plugin_context<'a>(&'a self, component: &'a Component) -> ComponentBuildContext<'a> {
717 ComponentBuildContext {
718 component,
719 template: self.artifact(),
720 uses_shadow_dom: self.uses_shadow_dom,
721 style: self.style.as_ref().map(|style| match style {
722 OwnedComponentStyle::Link(href) => plugin::ComponentStyleDelivery::Link { href },
723 OwnedComponentStyle::Inline(css) => plugin::ComponentStyleDelivery::Inline { css },
724 OwnedComponentStyle::Adopted(specifier) => {
725 plugin::ComponentStyleDelivery::Adopted { specifier }
726 }
727 }),
728 }
729 }
730}
731
732#[derive(Debug, Clone, Copy)]
733struct ComponentDomAnalysis {
734 uses_shadow_dom: bool,
735 authored_shadow_root: Option<(usize, usize)>,
737 authored_light_root: Option<(usize, usize)>,
740}
741
742struct ShadowRootModeOccurrence<'a> {
743 element_start: usize,
744 element_end: usize,
745 element_name: &'a str,
746 has_closing_tag: bool,
747 dynamically_bound: bool,
748 attr_offset: usize,
749 value: Option<&'a str>,
750}
751
752#[cfg(test)]
753fn analyze_component_dom(
754 tag_name: &str,
755 source: &str,
756 fallback: DomStrategy,
757) -> Result<ComponentDomAnalysis> {
758 analyze_component_dom_with_diagnostic_source(tag_name, source, fallback, None)
759}
760
761fn analyze_component_dom_with_diagnostic_source(
762 tag_name: &str,
763 source: &str,
764 fallback: DomStrategy,
765 diagnostic_source: Option<&str>,
766) -> Result<ComponentDomAnalysis> {
767 let mut ranges = Vec::with_capacity(8);
768 ranges.push((0..source.len(), true));
769 let mut top_level_element_count = 0usize;
770 let mut top_level_root_start = 0usize;
771 let mut has_top_level_content = false;
772 let mut bare_template_root = None;
773 let mut shadow_mode = None;
774 let mut slot_offset = None;
775
776 while let Some((range, top_level)) = ranges.pop() {
777 for event in Walker::new_range(source, range.start, range.end) {
778 match event {
779 Event::Text(text) if top_level && !text.trim().is_empty() => {
780 has_top_level_content = true;
781 }
782 Event::Declaration(_) if top_level => {
783 has_top_level_content = true;
784 }
785 Event::Element(element) => {
786 if top_level {
787 top_level_element_count += 1;
788 top_level_root_start = element.start;
789 bare_template_root = (element.name().eq_ignore_ascii_case("template")
790 && !element.self_closing()
791 && element.attrs().next().is_none())
792 .then_some((element.content_start, element.content_end));
793 }
794
795 for attr in element.attrs() {
796 let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
797 if attr_name.eq_ignore_ascii_case("shadowrootmode") {
798 if shadow_mode.is_some() {
799 return Err(invalid_shadow_root_mode_error(
800 tag_name,
801 DiagnosticSite::new(
802 source,
803 diagnostic_source,
804 element.start + attr.raw_range.start,
805 ),
806 "a component may declare only one `shadowrootmode`",
807 "keep one `shadowrootmode=\"open\"` attribute on the sole top-level `<template>`",
808 ));
809 }
810 shadow_mode = Some(ShadowRootModeOccurrence {
811 element_start: element.start,
812 element_end: element.close_end(),
813 element_name: element.name(),
814 has_closing_tag: !element.self_closing()
815 && element.close_end() > element.content_end(),
816 dynamically_bound: attr.name != attr_name,
817 attr_offset: element.start + attr.raw_range.start,
818 value: attr.value,
819 });
820 }
821 }
822
823 if slot_offset.is_none() && element.name().eq_ignore_ascii_case("slot") {
824 slot_offset = Some(element.start);
825 }
826
827 let name = element.name();
828 let raw_text = name.eq_ignore_ascii_case("script")
829 || name.eq_ignore_ascii_case("style")
830 || name.eq_ignore_ascii_case("textarea")
831 || name.eq_ignore_ascii_case("title");
832 if element.content_end() > element.inner().start && !raw_text {
833 ranges.push((element.inner(), false));
834 }
835 }
836 Event::Text(_)
837 | Event::Comment(_)
838 | Event::Declaration(_)
839 | Event::ClosingTag(_) => {}
840 }
841 }
842 }
843
844 let authored_shadow_root = match shadow_mode {
845 Some(mode) => {
846 let value = mode.value;
847 if mode.dynamically_bound {
848 return Err(invalid_shadow_root_mode_error(
849 tag_name,
850 DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
851 "`shadowrootmode` must be a static attribute",
852 "replace the binding with `shadowrootmode=\"open\"` on the sole top-level `<template>`",
853 ));
854 }
855 if value.is_some_and(|value| value.eq_ignore_ascii_case("closed")) {
856 return Err(invalid_shadow_root_mode_error(
857 tag_name,
858 DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
859 "`shadowrootmode=\"closed\"` is not supported",
860 "use `shadowrootmode=\"open\"`; WebUI requires an open root for hydration and style delivery",
861 ));
862 }
863 if !value.is_some_and(|value| value.eq_ignore_ascii_case("open")) {
864 return Err(invalid_shadow_root_mode_error(
865 tag_name,
866 DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
867 "`shadowrootmode` must be `open`",
868 "set the attribute to `shadowrootmode=\"open\"`",
869 ));
870 }
871 if top_level_element_count != 1
872 || has_top_level_content
873 || mode.element_start != top_level_root_start
874 || !mode.element_name.eq_ignore_ascii_case("template")
875 || !mode.has_closing_tag
876 {
877 return Err(invalid_shadow_root_mode_error(
878 tag_name,
879 DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
880 "the declarative Shadow DOM wrapper must be the sole top-level element",
881 "wrap the complete component in one top-level `<template shadowrootmode=\"open\">`",
882 ));
883 }
884 Some((mode.element_start, mode.element_end))
885 }
886 None => None,
887 };
888
889 let authored_light_root =
890 if authored_shadow_root.is_none() && top_level_element_count == 1 && !has_top_level_content
891 {
892 bare_template_root
893 } else {
894 None
895 };
896 let uses_shadow_dom = authored_shadow_root.is_some()
897 || (authored_light_root.is_none() && fallback == DomStrategy::Shadow);
898 if !uses_shadow_dom {
899 if let Some(offset) = slot_offset {
900 return Err(light_dom_slot_error(
901 tag_name,
902 DiagnosticSite::new(source, diagnostic_source, offset),
903 ));
904 }
905 }
906
907 Ok(ComponentDomAnalysis {
908 uses_shadow_dom,
909 authored_shadow_root,
910 authored_light_root,
911 })
912}
913
914#[cold]
915#[inline(never)]
916fn invalid_shadow_root_mode_error(
917 tag_name: &str,
918 site: DiagnosticSite<'_>,
919 title: &str,
920 help: &str,
921) -> ParserError {
922 let (source, offset) = site.resolve(DiagnosticTarget::Attribute("shadowrootmode"));
923 Diagnostic::error(title)
924 .code(codes::INVALID_SHADOW_ROOT_MODE)
925 .component(tag_name)
926 .at_offset(source, offset)
927 .snippet(source_line_snippet(source, offset))
928 .help(help)
929 .into()
930}
931
932#[cold]
933#[inline(never)]
934fn light_dom_slot_error(tag_name: &str, site: DiagnosticSite<'_>) -> ParserError {
935 let (source, offset) = site.resolve(DiagnosticTarget::Element("slot"));
936 Diagnostic::error("native `<slot>` projection requires Shadow DOM")
937 .code(codes::LIGHT_DOM_SLOT)
938 .component(tag_name)
939 .element("slot")
940 .at_offset(source, offset)
941 .snippet(source_line_snippet(source, offset))
942 .help(
943 "wrap the complete component template in `<template shadowrootmode=\"open\">` to use `<slot>`",
944 )
945 .into()
946}
947
948#[derive(Clone, Copy)]
949struct DiagnosticSite<'a> {
950 source: &'a str,
951 authored_source: Option<&'a str>,
952 offset: usize,
953}
954
955impl<'a> DiagnosticSite<'a> {
956 fn new(source: &'a str, authored_source: Option<&'a str>, offset: usize) -> Self {
957 Self {
958 source,
959 authored_source,
960 offset,
961 }
962 }
963
964 fn resolve(self, target: DiagnosticTarget) -> (&'a str, usize) {
965 let Some(authored_source) = self.authored_source else {
966 return (self.source, self.offset);
967 };
968 let transformed_offsets = diagnostic_target_offsets(self.source, target);
969 let occurrence = transformed_offsets
970 .iter()
971 .position(|offset| *offset == self.offset)
972 .unwrap_or(0);
973 let authored_offsets = diagnostic_target_offsets(authored_source, target);
974 authored_offsets
975 .get(occurrence)
976 .copied()
977 .map_or((self.source, self.offset), |offset| {
978 (authored_source, offset)
979 })
980 }
981}
982
983#[derive(Clone, Copy)]
984enum DiagnosticTarget {
985 Element(&'static str),
986 Attribute(&'static str),
987}
988
989#[cold]
990#[inline(never)]
991fn diagnostic_target_offsets(source: &str, target: DiagnosticTarget) -> Vec<usize> {
992 let mut offsets = Vec::new();
993 let mut ranges = Vec::with_capacity(4);
994 ranges.push(0..source.len());
995
996 while let Some(range) = ranges.pop() {
997 for event in Walker::new_range(source, range.start, range.end) {
998 let Event::Element(element) = event else {
999 continue;
1000 };
1001 match target {
1002 DiagnosticTarget::Element(name) if element.name().eq_ignore_ascii_case(name) => {
1003 offsets.push(element.start);
1004 }
1005 DiagnosticTarget::Attribute(name) => {
1006 for attr in element.attrs() {
1007 let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
1008 if attr_name.eq_ignore_ascii_case(name) {
1009 offsets.push(element.start + attr.raw_range.start);
1010 }
1011 }
1012 }
1013 DiagnosticTarget::Element(_) => {}
1014 }
1015
1016 let name = element.name();
1017 let raw_text = name.eq_ignore_ascii_case("script")
1018 || name.eq_ignore_ascii_case("style")
1019 || name.eq_ignore_ascii_case("textarea")
1020 || name.eq_ignore_ascii_case("title");
1021 if element.content_end() > element.inner().start && !raw_text {
1022 ranges.push(element.inner());
1023 }
1024 }
1025 }
1026 offsets.sort_unstable();
1027 offsets
1028}
1029
1030struct ComponentTemplateInput<'a> {
1031 tag_name: &'a str,
1032 html: &'a str,
1033 artifact_html: Option<&'a str>,
1034 authored_html: Option<&'a str>,
1035 css_content: Option<&'a str>,
1036 artifact_needed: bool,
1037}
1038
1039fn add_token_definitions(definitions: &[String], available_counts: &mut HashMap<String, usize>) {
1040 for definition in definitions {
1041 let count = available_counts.entry(definition.clone()).or_insert(0);
1042 *count += 1;
1043 }
1044}
1045
1046fn remove_token_definitions(definitions: &[String], available_counts: &mut HashMap<String, usize>) {
1047 for definition in definitions {
1048 if let Some(count) = available_counts.get_mut(definition) {
1049 if *count == 1 {
1050 available_counts.remove(definition);
1051 } else {
1052 *count -= 1;
1053 }
1054 }
1055 }
1056}
1057
1058#[derive(Default)]
1062struct UnresolvedTokens {
1063 chains: Vec<CssFallbackChain>,
1064 sites: HashMap<String, TokenSite>,
1065}
1066
1067fn record_unresolved_requirements(
1068 source: &[CssFallbackChain],
1069 available_counts: &HashMap<String, usize>,
1070 owner: &str,
1071 css_source: Option<&str>,
1072 out: &mut UnresolvedTokens,
1073) {
1074 for requirement in source {
1075 let tokens: Vec<String> = requirement
1076 .tokens
1077 .iter()
1078 .filter(|token| !available_counts.contains_key(*token))
1079 .cloned()
1080 .collect();
1081 if tokens.is_empty() {
1082 continue;
1083 }
1084 for token in &tokens {
1085 if !out.sites.contains_key(token) {
1086 out.sites
1087 .insert(token.clone(), token_site(owner, css_source, token));
1088 }
1089 }
1090 out.chains.push(CssFallbackChain {
1091 tokens,
1092 has_literal_fallback: requirement.has_literal_fallback,
1093 });
1094 }
1095}
1096
1097fn token_site(owner: &str, css_source: Option<&str>, token: &str) -> TokenSite {
1101 let (position, snippet) = match css_source.and_then(|css| locate_css_token(css, token)) {
1102 Some((line, column, snippet)) => (Some((line, column)), Some(snippet)),
1103 None => (None, None),
1104 };
1105 TokenSite {
1106 owner: owner.to_string(),
1107 position,
1108 snippet,
1109 }
1110}
1111
1112fn locate_css_token(css: &str, token: &str) -> Option<(usize, usize, String)> {
1118 let bytes = css.as_bytes();
1119 let name = token.as_bytes();
1120 let mut index = 0;
1121 while index + 2 + name.len() <= bytes.len() {
1122 let is_prefixed = bytes[index] == b'-'
1123 && bytes[index + 1] == b'-'
1124 && bytes.get(index + 2..index + 2 + name.len()) == Some(name);
1125 if is_prefixed {
1126 let after = index + 2 + name.len();
1127 let is_exact_name = bytes
1128 .get(after)
1129 .is_none_or(|b| !(b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_'));
1130 if is_exact_name {
1131 let mut cursor = after;
1132 while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
1133 cursor += 1;
1134 }
1135 if bytes.get(cursor) != Some(&b':') {
1137 let (line, column) = diagnostic::line_column(css, index);
1138 return Some((line, column, source_line_snippet(css, index)));
1139 }
1140 }
1141 }
1142 index += 1;
1143 }
1144 None
1145}
1146
1147fn source_line_snippet(source: &str, offset: usize) -> String {
1150 const MAX_SNIPPET: usize = 80;
1151 let start = source[..offset].rfind('\n').map_or(0, |n| n + 1);
1152 let end = source[offset..]
1153 .find('\n')
1154 .map_or(source.len(), |n| offset + n);
1155 let line = source[start..end].trim();
1156 if line.chars().count() > MAX_SNIPPET {
1157 let truncated: String = line.chars().take(MAX_SNIPPET).collect();
1158 format!("{truncated}…")
1159 } else {
1160 line.to_string()
1161 }
1162}
1163
1164fn css_owner_label(tag_name: &str) -> String {
1167 let mut label = String::with_capacity(tag_name.len() + 4);
1168 label.push_str(tag_name);
1169 label.push_str(".css");
1170 label
1171}
1172
1173impl CssTokenAnalysis {
1174 pub fn validate_theme_tokens(&self, theme: &webui_tokens::TokenFile) -> Result<()> {
1191 webui_tokens::validate_chain_tokens(&self.fallback_chains, theme)
1192 .map_err(|source| self.theme_token_error(source, theme))
1193 }
1194
1195 #[must_use]
1200 pub fn unthemed_literal_fallback_tokens(&self, theme: &webui_tokens::TokenFile) -> Vec<String> {
1201 webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme)
1202 }
1203
1204 #[must_use]
1212 pub fn theme_token_warnings(&self, theme: &webui_tokens::TokenFile) -> Vec<Diagnostic> {
1213 let mut warnings = Vec::new();
1214 for token in webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme) {
1215 let help = match closest_theme_token(&token, theme.themes.values()) {
1216 Some(suggestion) => {
1217 format!("did you mean --{suggestion}? otherwise the literal fallback is used")
1218 }
1219 None => format!(
1220 "define --{token} in the theme, or keep its literal fallback if intentional"
1221 ),
1222 };
1223 let diag = self.locate_diagnostic(
1224 Diagnostic::warning(format!("unthemed CSS token --{token}"))
1225 .code(codes::UNTHEMED_TOKEN)
1226 .help(help),
1227 &token,
1228 );
1229 warnings.push(diag);
1230 }
1231 warnings
1232 }
1233
1234 #[cold]
1238 #[inline(never)]
1239 fn theme_token_error(
1240 &self,
1241 source: webui_tokens::TokenError,
1242 theme: &webui_tokens::TokenFile,
1243 ) -> ParserError {
1244 match source {
1245 webui_tokens::TokenError::MissingToken {
1246 theme: theme_name,
1247 token,
1248 } => {
1249 let help =
1253 match closest_theme_token(&token, theme.themes.get(&theme_name).into_iter()) {
1254 Some(suggestion) => {
1255 format!("did you mean --{suggestion}? otherwise define it locally")
1256 }
1257 None => {
1258 "define it locally if it should not come from the theme".to_string()
1259 }
1260 };
1261 self.locate_diagnostic(
1262 Diagnostic::error("missing theme token")
1263 .code(codes::MISSING_THEME_TOKEN)
1264 .help(help),
1265 &token,
1266 )
1267 .into()
1268 }
1269 other => ParserError::Generic(format!("Theme token validation failed: {other}")),
1270 }
1271 }
1272
1273 fn locate_diagnostic(&self, diag: Diagnostic, token: &str) -> Diagnostic {
1276 let Some(site) = self.token_sites.get(token) else {
1277 return diag.snippet(format!("--{token}"));
1278 };
1279 let mut diag = diag.component(site.owner.clone());
1280 if let Some((line, column)) = site.position {
1281 diag = diag.position(line, column);
1282 }
1283 match &site.snippet {
1284 Some(snippet) => diag.snippet(snippet.clone()),
1285 None => diag.snippet(format!("--{token}")),
1286 }
1287 }
1288}
1289
1290#[cold]
1293#[inline(never)]
1294fn closest_theme_token<'a>(
1295 target: &str,
1296 themes: impl Iterator<Item = &'a std::collections::HashMap<String, String>>,
1297) -> Option<String> {
1298 let mut names: Vec<&str> = themes
1299 .flat_map(|theme| theme.keys().map(String::as_str))
1300 .collect();
1301 names.sort_unstable();
1302 names.dedup();
1303 crate::suggest::closest_match(target, names.into_iter()).map(ToOwned::to_owned)
1304}
1305
1306impl HtmlParser {
1307 #[must_use]
1309 pub fn new() -> Self {
1310 Self::with_options(ParserOptions::default())
1311 }
1312
1313 #[must_use]
1315 pub fn with_options(options: impl Into<ParserOptions>) -> Self {
1316 let options = options.into();
1317
1318 Self {
1319 component_registry: ComponentRegistry::with_legal_comments(options.legal_comments),
1320 css_parser: CssParser::new(),
1321 id_counter: FragmentIdCounter::new(),
1322 condition_parser: ConditionParser::new(),
1323 handlebars_parser: HandlebarsParser::new(),
1324 raw_buffer: String::new(),
1325 fragment_records: WebUIFragmentRecords::new(),
1326 options,
1327 plugin: None,
1328 component_processing: ComponentProcessing::default(),
1329 component_dom_analyses: HashMap::new(),
1330 token_roots: Vec::new(),
1331 fragment_css_tokens: HashMap::new(),
1332 in_progress_fragments: HashSet::new(),
1333 current_fragment_id: String::new(),
1334 next_boundary_declaration_id: 0,
1335 boundary_names_by_owner: HashMap::new(),
1336 in_boundary: false,
1337 boundary_ancestor_depth: 0,
1338 body_depth: 0,
1339 for_depth: 0,
1340 route_depth: 0,
1341 foster_context_depth: 0,
1342 boundary_parent_scope: None,
1343 module_entry_srcs: Vec::new(),
1344 }
1345 }
1346
1347 #[must_use]
1349 pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
1350 Self::with_plugin_options(plugin, ParserOptions::default())
1351 }
1352
1353 #[must_use]
1355 pub fn with_plugin_options(
1356 mut plugin: Box<dyn ParserPlugin>,
1357 options: impl Into<ParserOptions>,
1358 ) -> Self {
1359 let mut p = Self::with_options(options);
1360 plugin.configure_parser(&p.options);
1361 let component_processing = plugin.component_processing();
1362 p.component_registry
1363 .set_component_source_transform(component_processing.source_transform);
1364 p.component_processing = component_processing;
1365 p.plugin = Some(plugin);
1366 p
1367 }
1368
1369 pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
1371 &mut self.component_registry
1372 }
1373
1374 #[must_use]
1376 pub fn component_registry(&self) -> &ComponentRegistry {
1377 &self.component_registry
1378 }
1379
1380 pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
1381 std::mem::take(&mut self.fragment_records)
1382 }
1383
1384 pub fn has_fragment(&self, fragment_id: &str) -> bool {
1386 self.fragment_records.contains_key(fragment_id)
1387 }
1388
1389 #[cold]
1399 #[inline(never)]
1400 fn finalize_boundary_metadata(&mut self) -> Result<()> {
1401 if self.next_boundary_declaration_id != 0 {
1402 let (outgoing, direct_boundary_records) = self.boundary_graph();
1403 self.mark_boundary_records(&outgoing, direct_boundary_records);
1404 if let Some(violation) = self.find_repeat_reachable_boundary() {
1405 return Err(Self::repeat_reachable_boundary_error(&violation));
1406 }
1407 let occurrence_counts = self.boundary_occurrence_counts(&outgoing);
1408
1409 let mut unkeyed_repeat: Option<(u32, String, String)> = None;
1410 for (fragment_id, list) in &mut self.fragment_records {
1411 let repeated = occurrence_counts
1412 .get(fragment_id)
1413 .is_some_and(|count| *count > 1);
1414 if !repeated {
1415 continue;
1416 }
1417 for fragment in &mut list.fragments {
1418 if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_mut() {
1419 if boundary.phase() == BoundaryPhase::Start {
1420 boundary.may_repeat = true;
1421 if boundary.key.is_none()
1422 && unkeyed_repeat
1423 .as_ref()
1424 .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1425 {
1426 unkeyed_repeat = Some((
1427 boundary.declaration_id,
1428 boundary.owner_fragment_id.clone(),
1429 boundary.name.clone(),
1430 ));
1431 }
1432 }
1433 }
1434 }
1435 }
1436 if let Some((_, owner, name)) = unkeyed_repeat {
1437 return Err(Self::missing_boundary_key_error(&owner, &name));
1438 }
1439
1440 if let Some((owner, name)) = self.find_transitively_nested_boundary() {
1441 return Err(Self::transitively_nested_boundary_error(&owner, &name));
1442 }
1443 }
1444 self.finalize_component_span_signals();
1445 Ok(())
1446 }
1447
1448 fn finalize_component_span_signals(&mut self) {
1449 const START: &str = "streaming_span_start:";
1450 const END: &str = "streaming_span_end:";
1451
1452 let spanning: HashSet<String> = self
1453 .fragment_records
1454 .iter()
1455 .filter_map(|(id, list)| list.contains_boundary.then_some(id.clone()))
1456 .collect();
1457 for list in self.fragment_records.values_mut() {
1458 let mut normalized = Vec::with_capacity(list.fragments.len());
1459 for fragment in list.fragments.drain(..) {
1460 let remove = match fragment.fragment.as_ref() {
1461 Some(Fragment::Signal(signal)) => signal
1462 .value
1463 .strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1464 .and_then(|value| {
1465 value
1466 .strip_prefix(START)
1467 .or_else(|| value.strip_prefix(END))
1468 })
1469 .is_some_and(|tag| !spanning.contains(tag)),
1470 _ => false,
1471 };
1472 if remove {
1473 continue;
1474 }
1475 if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
1476 if let Some(WebUIFragment {
1477 fragment: Some(Fragment::Raw(previous)),
1478 }) = normalized.last_mut()
1479 {
1480 previous.value.push_str(&raw.value);
1481 continue;
1482 }
1483 }
1484 normalized.push(fragment);
1485 }
1486 list.fragments = normalized;
1487 }
1488 }
1489
1490 fn boundary_graph(&self) -> (HashMap<String, Vec<BoundaryGraphEdge>>, Vec<String>) {
1491 let mut outgoing: HashMap<String, Vec<BoundaryGraphEdge>> =
1492 HashMap::with_capacity(self.fragment_records.len());
1493 let mut direct_boundary_records = Vec::new();
1494
1495 for (fragment_id, list) in &self.fragment_records {
1496 let edges = outgoing.entry(fragment_id.clone()).or_default();
1497 for fragment in &list.fragments {
1498 match fragment.fragment.as_ref() {
1499 Some(Fragment::Component(component)) => {
1500 Self::push_boundary_edge(edges, &component.fragment_id);
1501 }
1502 Some(Fragment::ForLoop(for_loop)) => {
1503 Self::push_boundary_edge(edges, &for_loop.fragment_id);
1504 }
1505 Some(Fragment::IfCond(if_cond)) => {
1506 Self::push_boundary_edge(edges, &if_cond.fragment_id);
1507 }
1508 Some(Fragment::Boundary(boundary)) => {
1509 if boundary.phase() == BoundaryPhase::Start {
1510 direct_boundary_records.push(fragment_id.clone());
1511 }
1512 }
1513 Some(Fragment::Route(route)) => {
1514 Self::push_route_boundary_edges(edges, route);
1515 }
1516 _ => {}
1517 }
1518 }
1519 }
1520 (outgoing, direct_boundary_records)
1521 }
1522
1523 fn push_boundary_edge(edges: &mut Vec<BoundaryGraphEdge>, target: &str) {
1524 if !target.is_empty() {
1525 edges.push(BoundaryGraphEdge {
1526 target: target.to_string(),
1527 });
1528 }
1529 }
1530
1531 fn push_route_boundary_edges(edges: &mut Vec<BoundaryGraphEdge>, root: &WebUiFragmentRoute) {
1532 let mut pending = vec![root];
1533 while let Some(route) = pending.pop() {
1534 Self::push_boundary_edge(edges, &route.fragment_id);
1535 Self::push_boundary_edge(edges, &route.content_fragment_id);
1536 Self::push_boundary_edge(edges, &route.pending_component);
1537 Self::push_boundary_edge(edges, &route.error_component);
1538 pending.extend(route.children.iter());
1539 }
1540 }
1541
1542 fn mark_boundary_records(
1543 &mut self,
1544 outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1545 direct_boundary_records: Vec<String>,
1546 ) {
1547 for list in self.fragment_records.values_mut() {
1548 list.contains_boundary = false;
1549 }
1550
1551 let mut reverse: HashMap<&str, Vec<&str>> = HashMap::new();
1552 for (parent, edges) in outgoing {
1553 for edge in edges {
1554 reverse
1555 .entry(edge.target.as_str())
1556 .or_default()
1557 .push(parent.as_str());
1558 }
1559 }
1560
1561 let mut marked = HashSet::with_capacity(self.fragment_records.len());
1562 let mut pending = direct_boundary_records;
1563 while let Some(fragment_id) = pending.pop() {
1564 if !marked.insert(fragment_id.clone()) {
1565 continue;
1566 }
1567 if let Some(list) = self.fragment_records.get_mut(&fragment_id) {
1568 list.contains_boundary = true;
1569 }
1570 if let Some(parents) = reverse.get(fragment_id.as_str()) {
1571 pending.extend(parents.iter().map(|parent| (*parent).to_string()));
1572 }
1573 }
1574 }
1575
1576 fn boundary_occurrence_counts(
1577 &self,
1578 outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1579 ) -> HashMap<String, u8> {
1580 let mut incoming: HashMap<&str, Vec<&str>> = HashMap::new();
1581 for (parent, edges) in outgoing {
1582 for edge in edges {
1583 incoming
1584 .entry(edge.target.as_str())
1585 .or_default()
1586 .push(parent.as_str());
1587 }
1588 }
1589
1590 let mut aggregate = HashMap::with_capacity(self.fragment_records.len());
1591 for root in &self.token_roots {
1592 if !self.fragment_records.contains_key(root) {
1593 continue;
1594 }
1595 let counts = self.boundary_occurrences_from_root(root.as_str(), outgoing, &incoming);
1596 for (fragment_id, count) in counts {
1597 let current = aggregate.entry(fragment_id).or_insert(0);
1598 *current = (*current).max(count);
1599 }
1600 }
1601 aggregate
1602 }
1603
1604 fn boundary_occurrences_from_root(
1605 &self,
1606 root: &str,
1607 outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1608 incoming: &HashMap<&str, Vec<&str>>,
1609 ) -> HashMap<String, u8> {
1610 let mut counts: HashMap<String, u8> = HashMap::with_capacity(self.fragment_records.len());
1611 counts.insert(root.to_string(), 1);
1612 let mut pending = vec![root.to_string()];
1613 while let Some(parent) = pending.pop() {
1614 let Some(edges) = outgoing.get(&parent) else {
1615 continue;
1616 };
1617 for edge in edges {
1618 let next = Self::recompute_boundary_occurrences(
1619 edge.target.as_str(),
1620 root,
1621 incoming,
1622 &counts,
1623 );
1624 let current = counts.get(&edge.target).copied().unwrap_or(0);
1625 if next > current {
1626 counts.insert(edge.target.clone(), next);
1627 pending.push(edge.target.clone());
1628 }
1629 }
1630 }
1631 counts
1632 }
1633
1634 fn recompute_boundary_occurrences(
1635 fragment_id: &str,
1636 root: &str,
1637 incoming: &HashMap<&str, Vec<&str>>,
1638 counts: &HashMap<String, u8>,
1639 ) -> u8 {
1640 let mut count = u8::from(fragment_id == root);
1641 let Some(parents) = incoming.get(fragment_id) else {
1642 return count;
1643 };
1644 for parent in parents {
1645 let parent_count = counts.get(*parent).copied().unwrap_or(0);
1646 count = count.saturating_add(parent_count).min(2);
1647 if count == 2 {
1648 break;
1649 }
1650 }
1651 count
1652 }
1653
1654 fn find_repeat_reachable_boundary(&self) -> Option<RepeatBoundaryViolation> {
1663 let mut origin: HashMap<&str, &str> = HashMap::new();
1664 let mut pending: Vec<&str> = Vec::new();
1665 let mut repeats: HashMap<&str, RepeatSite<'_>> = HashMap::new();
1666 let mut seeds: Vec<(&str, RepeatSite<'_>)> = Vec::new();
1667
1668 for (owner, list) in &self.fragment_records {
1669 for fragment in &list.fragments {
1670 let Some(Fragment::ForLoop(for_loop)) = fragment.fragment.as_ref() else {
1671 continue;
1672 };
1673 if for_loop.fragment_id.is_empty() {
1674 continue;
1675 }
1676 let body = for_loop.fragment_id.as_str();
1677 seeds.push((
1678 body,
1679 RepeatSite {
1680 owner: owner.as_str(),
1681 item: for_loop.item.as_str(),
1682 collection: for_loop.collection.as_str(),
1683 },
1684 ));
1685 }
1686 }
1687 seeds.sort_unstable_by(|(left_body, left), (right_body, right)| {
1688 (left.owner, left.item, left.collection, *left_body).cmp(&(
1689 right.owner,
1690 right.item,
1691 right.collection,
1692 *right_body,
1693 ))
1694 });
1695 for (body, site) in seeds {
1696 if !origin.contains_key(body) {
1697 repeats.insert(body, site);
1698 origin.insert(body, body);
1699 pending.push(body);
1700 }
1701 }
1702 pending.reverse();
1705 if pending.is_empty() {
1706 return None;
1707 }
1708
1709 let mounts = self.route_mount_targets();
1710 let mut found: Option<(u32, &str, &WebUiFragmentBoundary)> = None;
1711 let mut targets: Vec<&str> = Vec::new();
1712 while let Some(id) = pending.pop() {
1713 let Some(seed) = origin.get(id).copied() else {
1714 continue;
1715 };
1716 let Some(list) = self.fragment_records.get(id) else {
1717 continue;
1718 };
1719 for fragment in &list.fragments {
1720 if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
1721 if boundary.phase() == BoundaryPhase::Start
1722 && found
1723 .as_ref()
1724 .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1725 {
1726 found = Some((boundary.declaration_id, seed, boundary));
1727 }
1728 }
1729 }
1730 targets.clear();
1731 push_child_records(list, &mut targets);
1732 if let Some(mounted) = mounts.get(id) {
1733 targets.extend(mounted.iter().copied());
1734 }
1735 for target in targets.drain(..) {
1736 if target.is_empty() {
1737 continue;
1738 }
1739 if !origin.contains_key(target) {
1740 origin.insert(target, seed);
1741 pending.push(target);
1742 }
1743 }
1744 }
1745
1746 found.map(|(_, seed, boundary)| {
1747 let site = repeats.get(seed);
1748 RepeatBoundaryViolation {
1749 repeat_owner: site.map_or_else(String::new, |site| site.owner.to_string()),
1750 each: site.map_or_else(String::new, |site| {
1751 format!("{} in {}", site.item, site.collection)
1752 }),
1753 boundary_owner: boundary.owner_fragment_id.clone(),
1754 boundary_name: boundary.name.clone(),
1755 }
1756 })
1757 }
1758
1759 fn route_mount_targets(&self) -> HashMap<&str, Vec<&str>> {
1768 let outlet_hosts = self.outlet_host_records();
1769 if outlet_hosts.is_empty() {
1770 return HashMap::new();
1771 }
1772
1773 let mut mounts: HashMap<&str, Vec<&str>> = HashMap::new();
1774 let mut children: Vec<&str> = Vec::new();
1775 let mut closure: HashSet<&str> = HashSet::new();
1776 let mut pending: Vec<&str> = Vec::new();
1777 let mut targets: Vec<&str> = Vec::new();
1778 for list in self.fragment_records.values() {
1779 for fragment in &list.fragments {
1780 let Some(Fragment::Route(root)) = fragment.fragment.as_ref() else {
1781 continue;
1782 };
1783 let mut routes = vec![root];
1784 while let Some(route) = routes.pop() {
1785 routes.extend(route.children.iter());
1786 if route.children.is_empty() {
1787 continue;
1788 }
1789 children.clear();
1790 for child in &route.children {
1791 push_route_records(&mut children, child);
1792 }
1793 closure.clear();
1794 pending.clear();
1795 for host in [
1796 route.fragment_id.as_str(),
1797 route.content_fragment_id.as_str(),
1798 ] {
1799 if !host.is_empty() && closure.insert(host) {
1800 pending.push(host);
1801 }
1802 }
1803 while let Some(id) = pending.pop() {
1804 if outlet_hosts.contains(id) {
1805 mounts
1806 .entry(id)
1807 .or_default()
1808 .extend(children.iter().copied());
1809 }
1810 let Some(list) = self.fragment_records.get(id) else {
1811 continue;
1812 };
1813 targets.clear();
1814 push_child_records(list, &mut targets);
1815 for target in targets.drain(..) {
1816 if !target.is_empty() && closure.insert(target) {
1817 pending.push(target);
1818 }
1819 }
1820 }
1821 }
1822 }
1823 }
1824 mounts
1825 }
1826
1827 fn outlet_host_records(&self) -> HashSet<&str> {
1829 self.fragment_records
1830 .iter()
1831 .filter(|(_, list)| {
1832 list.fragments
1833 .iter()
1834 .any(|fragment| matches!(fragment.fragment.as_ref(), Some(Fragment::Outlet(_))))
1835 })
1836 .map(|(id, _)| id.as_str())
1837 .collect()
1838 }
1839
1840 fn find_transitively_nested_boundary(&self) -> Option<(String, String)> {
1846 let mut nested: Option<(u32, &str, &str)> = None;
1847 for list in self.fragment_records.values() {
1848 let mut active: Option<&WebUiFragmentBoundary> = None;
1849 for fragment in &list.fragments {
1850 let Some(boundary) = active else {
1851 if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
1852 if boundary.phase() == BoundaryPhase::Start {
1853 active = Some(boundary);
1854 }
1855 }
1856 continue;
1857 };
1858 if let Some(Fragment::Boundary(marker)) = fragment.fragment.as_ref() {
1859 if marker.phase() == BoundaryPhase::End {
1860 active = None;
1861 }
1862 continue;
1863 }
1864 if !self.fragment_reaches_boundary(fragment) {
1865 continue;
1866 }
1867 if nested
1868 .as_ref()
1869 .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1870 {
1871 nested = Some((
1872 boundary.declaration_id,
1873 boundary.owner_fragment_id.as_str(),
1874 boundary.name.as_str(),
1875 ));
1876 }
1877 }
1878 }
1879 nested.map(|(_, owner, name)| (owner.to_string(), name.to_string()))
1880 }
1881
1882 fn fragment_reaches_boundary(&self, fragment: &WebUIFragment) -> bool {
1885 let reaches = |id: &str| {
1886 !id.is_empty()
1887 && self
1888 .fragment_records
1889 .get(id)
1890 .is_some_and(|list| list.contains_boundary)
1891 };
1892 match fragment.fragment.as_ref() {
1893 Some(Fragment::Component(component)) => reaches(&component.fragment_id),
1894 Some(Fragment::ForLoop(for_loop)) => reaches(&for_loop.fragment_id),
1895 Some(Fragment::IfCond(if_cond)) => reaches(&if_cond.fragment_id),
1896 Some(Fragment::Route(route)) => {
1897 let mut pending = vec![route];
1898 while let Some(current) = pending.pop() {
1899 if reaches(¤t.fragment_id)
1900 || reaches(¤t.content_fragment_id)
1901 || reaches(¤t.pending_component)
1902 || reaches(¤t.error_component)
1903 {
1904 return true;
1905 }
1906 pending.extend(current.children.iter());
1907 }
1908 false
1909 }
1910 _ => false,
1911 }
1912 }
1913
1914 #[cold]
1915 #[inline(never)]
1916 fn transitively_nested_boundary_error(owner: &str, name: &str) -> ParserError {
1917 Diagnostic::error(format!(
1918 "boundary \"{name}\" transitively contains another boundary"
1919 ))
1920 .code(codes::NESTED_BOUNDARY)
1921 .component(owner)
1922 .element("boundary")
1923 .snippet(format!("name=\"{name}\""))
1924 .help("remove the inner declaration or move it outside the enclosing boundary, including declarations reached through components and runtime branches")
1925 .into()
1926 }
1927
1928 #[cold]
1931 #[inline(never)]
1932 fn repeat_reachable_boundary_error(violation: &RepeatBoundaryViolation) -> ParserError {
1933 let RepeatBoundaryViolation {
1934 repeat_owner,
1935 each,
1936 boundary_owner,
1937 boundary_name,
1938 } = violation;
1939 Diagnostic::error(format!(
1940 "boundary \"{boundary_name}\" in {boundary_owner} is reachable from a <for> repeat body"
1941 ))
1942 .code(codes::BOUNDARY_IN_REPEAT)
1943 .component(repeat_owner)
1944 .element("for")
1945 .snippet(format!("each=\"{each}\""))
1946 .help(
1947 "a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
1948 whole <for> in one <boundary> to render the list as a single independently paced \
1949 region — this includes declarations reached through components, runtime branches, \
1950 routes, and outlets",
1951 )
1952 .into()
1953 }
1954
1955 #[must_use]
1966 pub fn module_entry_srcs(&self) -> &[String] {
1967 &self.module_entry_srcs
1968 }
1969
1970 pub fn component_shadow_dom_usage(&self) -> impl Iterator<Item = (&str, bool)> {
1975 self.component_dom_analyses
1976 .iter()
1977 .map(|(tag_name, analysis)| (tag_name.as_str(), analysis.uses_shadow_dom))
1978 }
1979
1980 pub fn component_work_policies(&self) -> impl Iterator<Item = (&str, u8)> {
1982 self.component_registry.work_policies()
1983 }
1984
1985 pub fn take_plugin_artifacts(&mut self) -> Result<ParserPluginArtifacts> {
1992 self.plugin
1993 .take()
1994 .map_or(Ok(ParserPluginArtifacts::None), |plugin| plugin.finish())
1995 }
1996
1997 #[must_use]
2001 pub fn take_tokens(&mut self) -> Vec<String> {
2002 self.token_analysis().protocol_tokens
2003 }
2004
2005 #[must_use]
2012 pub fn token_analysis(&self) -> CssTokenAnalysis {
2013 let (fallback_chains, token_sites) = self.collect_unresolved_fallback_chains();
2014 let mut protocol_token_set = HashSet::new();
2015 for chain in &fallback_chains {
2016 for token in &chain.tokens {
2017 protocol_token_set.insert(token.clone());
2018 }
2019 }
2020 let mut protocol_tokens: Vec<String> = protocol_token_set.into_iter().collect();
2021 protocol_tokens.sort();
2022 CssTokenAnalysis {
2023 protocol_tokens,
2024 fallback_chains,
2025 token_sites,
2026 }
2027 }
2028
2029 fn collect_unresolved_fallback_chains(
2030 &self,
2031 ) -> (Vec<CssFallbackChain>, HashMap<String, TokenSite>) {
2032 let mut out = UnresolvedTokens::default();
2033 let mut available_counts: HashMap<String, usize> = HashMap::new();
2034 let mut ops: Vec<TokenGraphOp<'_>> = Vec::with_capacity(self.token_roots.len());
2035 for root in self.token_roots.iter().rev() {
2036 ops.push(TokenGraphOp::EnterFragment(root.as_str()));
2037 }
2038
2039 while let Some(op) = ops.pop() {
2040 match op {
2041 TokenGraphOp::EnterFragment(fragment_id) => {
2042 self.enter_token_fragment(
2043 fragment_id,
2044 &mut available_counts,
2045 &mut out,
2046 &mut ops,
2047 );
2048 }
2049 TokenGraphOp::EnterComponent(tag_name) => {
2050 self.enter_token_component(tag_name, &mut available_counts, &mut out, &mut ops);
2051 }
2052 TokenGraphOp::EnterRoute(route) => {
2053 self.enter_token_route(route, &mut available_counts, &mut out, &mut ops);
2054 }
2055 TokenGraphOp::ExitDefinitions(definitions) => {
2056 remove_token_definitions(definitions, &mut available_counts);
2057 }
2058 }
2059 }
2060
2061 (out.chains, out.sites)
2062 }
2063
2064 fn enter_token_fragment<'a>(
2065 &'a self,
2066 fragment_id: &'a str,
2067 available_counts: &mut HashMap<String, usize>,
2068 out: &mut UnresolvedTokens,
2069 ops: &mut Vec<TokenGraphOp<'a>>,
2070 ) {
2071 if let Some(css) = self.fragment_css_tokens.get(fragment_id) {
2072 add_token_definitions(&css.definitions, available_counts);
2073 record_unresolved_requirements(
2077 &css.fallback_chains,
2078 available_counts,
2079 fragment_id,
2080 None,
2081 out,
2082 );
2083 ops.push(TokenGraphOp::ExitDefinitions(&css.definitions));
2084 }
2085
2086 let Some(fragments) = self.fragment_records.get(fragment_id) else {
2087 return;
2088 };
2089 for fragment in fragments.fragments.iter().rev() {
2090 match fragment.fragment.as_ref() {
2091 Some(web_ui_fragment::Fragment::Component(component)) => {
2092 ops.push(TokenGraphOp::EnterComponent(component.fragment_id.as_str()));
2093 }
2094 Some(web_ui_fragment::Fragment::ForLoop(for_loop)) => {
2095 ops.push(TokenGraphOp::EnterFragment(for_loop.fragment_id.as_str()));
2096 }
2097 Some(web_ui_fragment::Fragment::IfCond(if_cond)) => {
2098 ops.push(TokenGraphOp::EnterFragment(if_cond.fragment_id.as_str()));
2099 }
2100 Some(web_ui_fragment::Fragment::Route(route)) => {
2101 ops.push(TokenGraphOp::EnterRoute(route));
2102 }
2103 _ => {}
2104 }
2105 }
2106 }
2107
2108 fn enter_token_component<'a>(
2109 &'a self,
2110 tag_name: &'a str,
2111 available_counts: &mut HashMap<String, usize>,
2112 out: &mut UnresolvedTokens,
2113 ops: &mut Vec<TokenGraphOp<'a>>,
2114 ) {
2115 let Some(component) = self.component_registry.get(tag_name) else {
2116 return;
2117 };
2118 add_token_definitions(&component.css_definitions, available_counts);
2119 record_unresolved_requirements(
2120 &component.css_fallback_chains,
2121 available_counts,
2122 &css_owner_label(tag_name),
2123 self.component_registry.diagnostic_css_content(tag_name),
2124 out,
2125 );
2126 ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
2127 ops.push(TokenGraphOp::EnterFragment(tag_name));
2128 }
2129
2130 fn enter_token_route<'a>(
2131 &'a self,
2132 route: &'a WebUiFragmentRoute,
2133 available_counts: &mut HashMap<String, usize>,
2134 out: &mut UnresolvedTokens,
2135 ops: &mut Vec<TokenGraphOp<'a>>,
2136 ) {
2137 if !route.content_fragment_id.is_empty() {
2138 ops.push(TokenGraphOp::EnterFragment(
2139 route.content_fragment_id.as_str(),
2140 ));
2141 }
2142 let Some(component) = self.component_registry.get(&route.fragment_id) else {
2143 return;
2144 };
2145 add_token_definitions(&component.css_definitions, available_counts);
2146 record_unresolved_requirements(
2147 &component.css_fallback_chains,
2148 available_counts,
2149 &css_owner_label(&route.fragment_id),
2150 self.component_registry
2151 .diagnostic_css_content(&route.fragment_id),
2152 out,
2153 );
2154 ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
2155 if !route.error_component.is_empty() {
2156 ops.push(TokenGraphOp::EnterComponent(route.error_component.as_str()));
2157 }
2158 if !route.pending_component.is_empty() {
2159 ops.push(TokenGraphOp::EnterComponent(
2160 route.pending_component.as_str(),
2161 ));
2162 }
2163 for child in route.children.iter().rev() {
2164 ops.push(TokenGraphOp::EnterRoute(child));
2165 }
2166 ops.push(TokenGraphOp::EnterFragment(route.fragment_id.as_str()));
2167 }
2168
2169 fn record_fragment_css_tokens(
2170 &mut self,
2171 definitions: HashSet<String>,
2172 fallback_chains: Vec<CssFallbackChain>,
2173 ) {
2174 let css = self
2175 .fragment_css_tokens
2176 .entry(self.current_fragment_id.clone())
2177 .or_default();
2178 css.definitions.extend(definitions);
2179 css.definitions.sort();
2180 css.definitions.dedup();
2181 css.fallback_chains.extend(fallback_chains);
2182 }
2183
2184 pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
2186 let fragment_key = fragment_id.to_string();
2187 let is_token_root = self.in_progress_fragments.is_empty();
2188 if is_token_root {
2189 self.module_entry_srcs.clear();
2190 }
2191 self.boundary_names_by_owner.remove(&fragment_key);
2192 let previous_fragment_id =
2198 std::mem::replace(&mut self.current_fragment_id, fragment_key.clone());
2199 if !self.in_progress_fragments.insert(fragment_key.clone()) {
2200 let err = self
2201 .authoring_error(
2202 codes::RECURSIVE_TEMPLATE,
2203 format!("recursive template reference while parsing <{fragment_id}>"),
2204 )
2205 .help(
2206 "move the recursive usage behind runtime data, or split the component graph \
2207 so templates do not reference themselves at build time",
2208 )
2209 .into();
2210 self.current_fragment_id = previous_fragment_id;
2211 return Err(err);
2212 }
2213 if is_token_root && !self.token_roots.contains(&fragment_key) {
2214 self.token_roots.push(fragment_key.clone());
2215 }
2216
2217 let previous_in_boundary = std::mem::replace(&mut self.in_boundary, false);
2223 let previous_boundary_ancestor_depth = self.boundary_ancestor_depth;
2224 if previous_in_boundary {
2225 self.boundary_ancestor_depth += 1;
2226 }
2227 let previous_body_depth = std::mem::replace(&mut self.body_depth, 0);
2228 let previous_for_depth = std::mem::replace(&mut self.for_depth, 0);
2229 let previous_route_depth = std::mem::replace(&mut self.route_depth, 0);
2230 let previous_foster_depth = std::mem::replace(&mut self.foster_context_depth, 0);
2231 let previous_parent_scope = self.boundary_parent_scope.take();
2232
2233 let mut result = self.parse_inner(fragment_id, html_content);
2234 if is_token_root && result.is_ok() {
2235 result = self.finalize_boundary_metadata();
2236 }
2237
2238 self.in_boundary = previous_in_boundary;
2239 self.boundary_ancestor_depth = previous_boundary_ancestor_depth;
2240 self.body_depth = previous_body_depth;
2241 self.for_depth = previous_for_depth;
2242 self.route_depth = previous_route_depth;
2243 self.foster_context_depth = previous_foster_depth;
2244 self.boundary_parent_scope = previous_parent_scope;
2245 self.in_progress_fragments.remove(&fragment_key);
2246 self.current_fragment_id = previous_fragment_id;
2247 result
2248 }
2249
2250 fn parse_inner(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
2251 if html_content.len() > MAX_TEMPLATE_BYTES {
2252 return Err(ParserError::Html(format!(
2253 "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.",
2254 html_content.len()
2255 )));
2256 }
2257
2258 self.raw_buffer.clear();
2260 if let Some(ref mut plugin) = self.plugin {
2261 plugin.begin_fragment(FragmentContext { id: fragment_id });
2262 }
2263
2264 let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
2265 self.parse_range(html_content, 0..html_content.len(), &mut entry_fragment, 0)?;
2266
2267 self.flush_raw_buffer(&mut entry_fragment);
2268
2269 self.fragment_records.insert(
2270 fragment_id.to_string(),
2271 FragmentList {
2272 fragments: entry_fragment,
2273 contains_boundary: false,
2274 },
2275 );
2276
2277 Ok(())
2278 }
2279
2280 fn add_raw_fragment(&mut self, content: &str) {
2282 if !content.is_empty() {
2283 self.raw_buffer.push_str(content);
2284 }
2285 }
2286
2287 fn add_for_fragment(
2289 &mut self,
2290 item: String,
2291 collection: String,
2292 fragment_id: String,
2293 fragments: &mut Vec<WebUIFragment>,
2294 ) {
2295 self.flush_raw_buffer(fragments);
2296 fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
2297 }
2298
2299 fn add_if_fragment(
2301 &mut self,
2302 condition: ConditionExpr,
2303 fragment_id: String,
2304 fragments: &mut Vec<WebUIFragment>,
2305 ) {
2306 self.flush_raw_buffer(fragments);
2307 fragments.push(WebUIFragment::if_cond(condition, fragment_id));
2308 }
2309
2310 fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
2312 self.flush_raw_buffer(fragments);
2313 fragments.push(fragment);
2314 }
2315
2316 fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
2318 if !self.raw_buffer.is_empty() {
2319 fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
2320 }
2321 }
2322
2323 fn should_emit_text_content(content: &str) -> bool {
2329 if content.is_empty() {
2330 return false;
2331 }
2332
2333 if !content.trim().is_empty() {
2334 return true;
2335 }
2336
2337 content.chars().all(char::is_whitespace)
2338 && !content.contains('\n')
2339 && !content.contains('\r')
2340 }
2341
2342 fn parse_range(
2343 &mut self,
2344 source: &str,
2345 range: Range<usize>,
2346 fragments: &mut Vec<WebUIFragment>,
2347 depth: usize,
2348 ) -> Result<()> {
2349 let mut ops = vec![ParseOp::Parse { range, depth }];
2350
2351 while let Some(op) = ops.pop() {
2352 match op {
2353 ParseOp::Parse { range, depth } => {
2354 if depth > MAX_TEMPLATE_DEPTH {
2355 return Err(self
2356 .html_error(
2357 codes::EXCESSIVE_NESTING,
2358 format!(
2359 "template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"
2360 ),
2361 source,
2362 range.start,
2363 )
2364 .help(
2365 "split deeply nested markup into components, or reduce generated nesting",
2366 )
2367 .into());
2368 }
2369
2370 let end = range.end;
2371 let mut index = range.start;
2372 while index < end {
2373 let remaining = &source[index..end];
2374 if remaining.starts_with("<!--") {
2375 let Some(close) = html::find_comment_close(remaining) else {
2376 return Err(self
2377 .html_error(
2378 codes::UNTERMINATED_HTML_COMMENT,
2379 "unterminated HTML comment",
2380 source,
2381 index,
2382 )
2383 .help("close the comment with `-->`")
2384 .into());
2385 };
2386 index += close;
2387 continue;
2388 }
2389
2390 if remaining.starts_with("<!") {
2391 let Some(close) = html::find_declaration_close(remaining) else {
2392 return Err(self
2393 .html_error(
2394 codes::UNTERMINATED_HTML_DECLARATION,
2395 "unterminated HTML declaration",
2396 source,
2397 index,
2398 )
2399 .help("close the declaration with `>`")
2400 .into());
2401 };
2402 self.add_raw_fragment(&remaining[..close]);
2403 index += close;
2404 continue;
2405 }
2406
2407 if remaining.starts_with('<') {
2408 let Some(tag) = html::parse_tag(remaining) else {
2409 return Err(self
2410 .html_error(
2411 codes::MALFORMED_HTML_TAG,
2412 "malformed HTML tag",
2413 source,
2414 index,
2415 )
2416 .help(
2417 "close the tag with `>`, or escape a literal `<` as `<`",
2418 )
2419 .into());
2420 };
2421 if tag.closing {
2422 return Err(self
2423 .html_error(
2424 codes::UNEXPECTED_CLOSING_TAG,
2425 format!("unexpected closing tag </{}>", tag.name),
2426 source,
2427 index,
2428 )
2429 .help("remove it, or add the matching opening tag before it")
2430 .into());
2431 }
2432
2433 let content_start = index + tag.close + 1;
2434 let (content_end, close_end) = if tag.self_closing
2435 || html::is_void_element(tag.name)
2436 {
2437 (content_start, content_start)
2438 } else if let Some((close_start, close_end)) =
2439 html::find_matching_end(remaining, tag.name, tag.close + 1)
2440 {
2441 (index + close_start, index + close_end)
2442 } else {
2443 return Err(self
2444 .html_error(
2445 codes::UNCLOSED_HTML_TAG,
2446 format!("unclosed <{}> tag", tag.name),
2447 source,
2448 index,
2449 )
2450 .help(format!(
2451 "add the matching </{}> closing tag, or make the element self-closing",
2452 tag.name
2453 ))
2454 .into());
2455 };
2456
2457 let element = Element {
2458 source,
2459 start: index,
2460 tag,
2461 content_start,
2462 content_end,
2463 close_end,
2464 };
2465 if close_end < end {
2466 ops.push(ParseOp::Parse {
2467 range: close_end..end,
2468 depth,
2469 });
2470 }
2471
2472 match element.name() {
2473 "for" => {
2474 self.enter_for_directive(&element, fragments, depth, &mut ops)?;
2475 }
2476 "if" => {
2477 self.enter_if_directive(&element, fragments, depth, &mut ops)?;
2478 }
2479 "route" => {
2480 self.process_route_directive(&element, fragments, depth)?;
2481 }
2482 "outlet" => {
2483 self.flush_raw_buffer(fragments);
2484 fragments.push(WebUIFragment::outlet());
2485 }
2486 "boundary" => {
2487 self.enter_boundary_directive(
2488 &element, fragments, depth, &mut ops,
2489 )?;
2490 }
2491 name if name.eq_ignore_ascii_case("body") => {
2492 self.enter_body_element(&element, fragments, depth, &mut ops)?;
2493 }
2494 name if name.eq_ignore_ascii_case("head") => {
2495 self.enter_head_element(&element, fragments, depth, &mut ops)?;
2496 }
2497 name if name.eq_ignore_ascii_case("webui-hydrate") => {
2498 return Err(self.authored_webui_hydrate_error(&element));
2499 }
2500 name if name.eq_ignore_ascii_case("style") => {
2501 self.process_style_element(&element, fragments)?;
2502 }
2503 _ if self.component_registry.contains(element.name()) => {
2504 self.enter_component_directive(
2505 &element, fragments, depth, &mut ops,
2506 )?;
2507 }
2508 _ => {
2509 self.check_component_typo(&element)?;
2510 self.enter_regular_element(
2511 &element, fragments, depth, &mut ops,
2512 )?;
2513 }
2514 }
2515
2516 break;
2517 }
2518
2519 let next = remaining.find('<').unwrap_or(remaining.len());
2520 let text_end = if next == 0 {
2521 remaining
2522 .chars()
2523 .next()
2524 .map_or(remaining.len(), char::len_utf8)
2525 } else {
2526 next
2527 };
2528 self.process_text(&remaining[..text_end], fragments)?;
2529 index += text_end;
2530 }
2531 }
2532 ParseOp::EmitClose(name) => {
2533 self.add_raw_fragment("</");
2534 self.add_raw_fragment(name);
2535 self.add_raw_fragment(">");
2536 }
2537 ParseOp::EndComponent(name) => {
2538 self.add_raw_fragment("</");
2539 self.add_raw_fragment(name);
2540 self.add_raw_fragment(">");
2541 self.flush_raw_buffer(fragments);
2542 fragments.push(structural_signal(format!("streaming_span_end:{name}")));
2543 }
2544 ParseOp::EndHead(name) => {
2545 self.flush_raw_buffer(fragments);
2546 fragments.push(structural_signal("head_end"));
2547 self.add_raw_fragment("</");
2548 self.add_raw_fragment(name);
2549 self.add_raw_fragment(">");
2550 }
2551 ParseOp::EndBody(name) => {
2552 self.flush_raw_buffer(fragments);
2553 fragments.push(structural_signal("body_end"));
2554 self.add_raw_fragment("</");
2555 self.add_raw_fragment(name);
2556 self.add_raw_fragment(">");
2557 self.body_depth = self.body_depth.saturating_sub(1);
2558 }
2559 ParseOp::CompleteBoundary { boundary } => {
2560 self.flush_raw_buffer(fragments);
2561 fragments.push(WebUIFragment::boundary_end(boundary.declaration_id));
2562 self.in_boundary = false;
2563 }
2564 ParseOp::RestoreFosterDepth(previous) => {
2565 self.foster_context_depth = previous;
2566 }
2567 ParseOp::RestoreBoundaryParentScope(previous) => {
2568 self.boundary_parent_scope = previous;
2569 }
2570 ParseOp::CompleteFor {
2571 parent,
2572 item,
2573 collection,
2574 fragment_id,
2575 keep_empty,
2576 previous_for_depth,
2577 } => {
2578 self.for_depth = previous_for_depth;
2579 self.flush_raw_buffer(fragments);
2580 let for_fragment = std::mem::take(fragments);
2581 *fragments = parent.fragments;
2582 self.raw_buffer = parent.raw_buffer;
2583
2584 if !for_fragment.is_empty() {
2585 self.fragment_records.insert(
2586 fragment_id.clone(),
2587 FragmentList {
2588 fragments: for_fragment,
2589 contains_boundary: false,
2590 },
2591 );
2592 } else if !keep_empty {
2593 continue;
2594 }
2595
2596 self.add_for_fragment(item, collection, fragment_id, fragments);
2597 }
2598 ParseOp::CompleteIf {
2599 parent,
2600 condition,
2601 fragment_id,
2602 } => {
2603 self.flush_raw_buffer(fragments);
2604 let if_fragment = std::mem::take(fragments);
2605 *fragments = parent.fragments;
2606 self.raw_buffer = parent.raw_buffer;
2607
2608 self.fragment_records.insert(
2609 fragment_id.clone(),
2610 FragmentList {
2611 fragments: if_fragment,
2612 contains_boundary: false,
2613 },
2614 );
2615 self.add_if_fragment(condition, fragment_id, fragments);
2616 }
2617 }
2618 }
2619
2620 Ok(())
2621 }
2622
2623 fn enter_regular_element<'a>(
2624 &mut self,
2625 element: &Element<'a>,
2626 fragments: &mut Vec<WebUIFragment>,
2627 depth: usize,
2628 ops: &mut Vec<ParseOp<'a>>,
2629 ) -> Result<()> {
2630 if !self.in_boundary
2631 && self.boundary_ancestor_depth == 0
2632 && element.name().eq_ignore_ascii_case("script")
2633 {
2634 self.record_module_entry(element);
2635 }
2636 self.add_raw_fragment("<");
2637 self.add_raw_fragment(element.name());
2638
2639 let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2640 if let Some(ref mut p) = self.plugin {
2641 if let Some(data) = p.finish_opening_tag(ElementStartContext {
2642 tag_name: element.name(),
2643 binding_count,
2644 }) {
2645 self.add_fragment(WebUIFragment::plugin(data), fragments);
2646 }
2647 }
2648
2649 if element.self_closing() {
2650 self.add_raw_fragment("/>");
2651 return Ok(());
2652 }
2653
2654 self.add_raw_fragment(">");
2655 if element.name().eq_ignore_ascii_case("template")
2656 && element
2657 .attrs()
2658 .find(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
2659 .and_then(|attr| attr.value)
2660 .is_some_and(|mode| mode.eq_ignore_ascii_case("open"))
2661 && self
2662 .component_dom_analyses
2663 .get(&self.current_fragment_id)
2664 .is_some_and(|analysis| analysis.uses_shadow_dom)
2665 {
2666 self.flush_raw_buffer(fragments);
2671 fragments.push(structural_signal(format!(
2672 "shadow_styles:{}",
2673 self.current_fragment_id
2674 )));
2675 }
2676 if !element.is_void() {
2677 if element.close_end() > element.content_end() {
2678 ops.push(ParseOp::EmitClose(element.name()));
2679 }
2680 self.enter_foster_context(element.name(), ops);
2681 self.enter_boundary_parent_scope(element, ops);
2682 ops.push(ParseOp::Parse {
2683 range: element.inner(),
2684 depth: depth + 1,
2685 });
2686 }
2687 Ok(())
2688 }
2689
2690 fn record_module_entry(&mut self, element: &Element<'_>) {
2701 let Some(src) = element.attr("src") else {
2702 return;
2703 };
2704 if src.is_empty() || src.contains("{{") {
2705 return;
2706 }
2707 if !element
2708 .attr("type")
2709 .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("module"))
2710 {
2711 return;
2712 }
2713 if self.module_entry_srcs.iter().any(|seen| seen == src) {
2716 return;
2717 }
2718 self.module_entry_srcs.push(src.to_string());
2719 }
2720
2721 fn enter_head_element<'a>(
2722 &mut self,
2723 element: &Element<'a>,
2724 fragments: &mut Vec<WebUIFragment>,
2725 depth: usize,
2726 ops: &mut Vec<ParseOp<'a>>,
2727 ) -> Result<()> {
2728 if element.name() == "head" {
2729 self.add_raw_fragment("<head>");
2733 } else {
2734 self.add_raw_fragment("<");
2738 self.add_raw_fragment(element.name());
2739 let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2740 if let Some(ref mut p) = self.plugin {
2741 if let Some(data) = p.finish_opening_tag(ElementStartContext {
2742 tag_name: element.name(),
2743 binding_count,
2744 }) {
2745 self.add_fragment(WebUIFragment::plugin(data), fragments);
2746 }
2747 }
2748 self.add_raw_fragment(">");
2749 }
2750 self.flush_raw_buffer(fragments);
2751 fragments.push(structural_signal("head_start"));
2757 ops.push(ParseOp::EndHead(element.name()));
2758 ops.push(ParseOp::Parse {
2759 range: element.inner(),
2760 depth: depth + 1,
2761 });
2762 Ok(())
2763 }
2764
2765 fn enter_body_element<'a>(
2766 &mut self,
2767 element: &Element<'a>,
2768 fragments: &mut Vec<WebUIFragment>,
2769 depth: usize,
2770 ops: &mut Vec<ParseOp<'a>>,
2771 ) -> Result<()> {
2772 self.add_raw_fragment("<");
2773 self.add_raw_fragment(element.name());
2774 let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2775 if let Some(ref mut p) = self.plugin {
2776 if let Some(data) = p.finish_opening_tag(ElementStartContext {
2777 tag_name: element.name(),
2778 binding_count,
2779 }) {
2780 self.add_fragment(WebUIFragment::plugin(data), fragments);
2781 }
2782 }
2783 self.add_raw_fragment(">");
2784 self.flush_raw_buffer(fragments);
2785 fragments.push(structural_signal("body_start"));
2786 self.body_depth += 1;
2787 ops.push(ParseOp::EndBody(element.name()));
2788 ops.push(ParseOp::Parse {
2789 range: element.inner(),
2790 depth: depth + 1,
2791 });
2792 Ok(())
2793 }
2794
2795 #[cold]
2802 #[inline(never)]
2803 fn authoring_error(&self, code: &'static str, title: impl Into<String>) -> Diagnostic {
2804 let diag = Diagnostic::error(title).code(code);
2805 if self.current_fragment_id.is_empty() {
2806 diag
2807 } else {
2808 diag.component(self.current_fragment_id.clone())
2809 }
2810 }
2811
2812 #[cold]
2816 #[inline(never)]
2817 fn authoring_error_at(
2818 &self,
2819 code: &'static str,
2820 title: impl Into<String>,
2821 element: &Element<'_>,
2822 ) -> Diagnostic {
2823 self.authoring_error(code, title)
2824 .at_offset(element.source(), element.start)
2825 }
2826
2827 #[cold]
2831 #[inline(never)]
2832 fn html_error(
2833 &self,
2834 code: &'static str,
2835 title: impl Into<String>,
2836 source: &str,
2837 offset: usize,
2838 ) -> Diagnostic {
2839 self.authoring_error(code, title).at_offset(source, offset)
2840 }
2841
2842 #[cold]
2848 #[inline(never)]
2849 fn unknown_component_help(&self, name: &str) -> String {
2850 match suggest::closest_match(name, self.component_registry.names()) {
2851 Some(suggestion) => format!(
2852 "did you mean <{suggestion}>? otherwise register the component by adding a \
2853 matching .html file"
2854 ),
2855 None => "register the component (add a matching .html file) or check the tag name \
2856 for a typo"
2857 .to_string(),
2858 }
2859 }
2860
2861 fn suggest_component(&self, tag: &str) -> Option<&str> {
2870 let (prefix, _) = tag.split_once('-')?;
2871 let same_namespace = self
2872 .component_registry
2873 .names()
2874 .filter(|name| name.split_once('-').map(|(p, _)| p) == Some(prefix));
2875 suggest::closest_match(tag, same_namespace)
2876 }
2877
2878 fn check_component_typo(&self, element: &Element<'_>) -> Result<()> {
2883 if let Some(suggestion) = self.suggest_component(element.name()) {
2884 return Err(self
2885 .authoring_error_at(
2886 codes::UNKNOWN_COMPONENT,
2887 format!("unknown component <{}>", element.name()),
2888 element,
2889 )
2890 .help(format!(
2891 "did you mean <{suggestion}>? otherwise register <{}> by adding a matching \
2892 .html file",
2893 element.name()
2894 ))
2895 .into());
2896 }
2897 Ok(())
2898 }
2899
2900 #[cold]
2904 #[inline(never)]
2905 fn attr_typo_suggestion<'a>(element: &Element<'a>, expected: &str) -> Option<&'a str> {
2906 suggest::closest_match(expected, element.attrs().map(|attr| attr.name))
2907 .filter(|&name| name != expected)
2908 }
2909
2910 #[cold]
2916 #[inline(never)]
2917 fn css_diagnostic(&self, err: ParserError) -> ParserError {
2918 match err {
2919 ParserError::Css(message) => self.authoring_error(codes::INVALID_CSS, message).into(),
2920 other => other,
2921 }
2922 }
2923
2924 #[cold]
2926 #[inline(never)]
2927 fn for_each_missing_error(&self, element: &Element<'_>) -> ParserError {
2928 let diag = self
2929 .authoring_error_at(
2930 codes::MISSING_FOR_EACH,
2931 "missing each attribute on <for>",
2932 element,
2933 )
2934 .element("for");
2935 match Self::attr_typo_suggestion(element, "each") {
2936 Some(typo) => diag.help(format!(
2937 "found `{typo}` \u{2014} did you mean each=\"item in collection\"?"
2938 )),
2939 None => diag.help("add each=\"item in collection\", e.g. <for each=\"todo in todos\">"),
2940 }
2941 .into()
2942 }
2943
2944 #[cold]
2946 #[inline(never)]
2947 fn for_each_invalid_error(&self, element: &Element<'_>, each: &str) -> ParserError {
2948 self.authoring_error_at(
2949 codes::INVALID_FOR_EACH,
2950 "invalid <for> each expression",
2951 element,
2952 )
2953 .element("for")
2954 .snippet(format!("each=\"{each}\""))
2955 .help("use the form each=\"item in collection\", e.g. each=\"todo in todos\"")
2956 .into()
2957 }
2958
2959 #[cold]
2962 #[inline(never)]
2963 fn for_identifier_error(&self, element: &Element<'_>, each: &str) -> ParserError {
2964 self.authoring_error_at(
2965 codes::INVALID_FOR_IDENTIFIER,
2966 "invalid identifier in <for> each expression",
2967 element,
2968 )
2969 .element("for")
2970 .snippet(format!("each=\"{each}\""))
2971 .help("item and collection names may use only letters, digits, '_', '-', and '.'")
2972 .into()
2973 }
2974
2975 fn enter_for_directive<'a>(
2976 &mut self,
2977 element: &Element<'a>,
2978 fragments: &mut Vec<WebUIFragment>,
2979 depth: usize,
2980 ops: &mut Vec<ParseOp<'a>>,
2981 ) -> Result<()> {
2982 let each = element
2983 .attr("each")
2984 .map(ToString::to_string)
2985 .ok_or_else(|| self.for_each_missing_error(element))?;
2986
2987 let mut parts = each.split_whitespace();
2988 let (Some(item), Some(in_kw), Some(collection), None) =
2989 (parts.next(), parts.next(), parts.next(), parts.next())
2990 else {
2991 return Err(self.for_each_invalid_error(element, &each));
2992 };
2993 if in_kw != "in" {
2994 return Err(self.for_each_invalid_error(element, &each));
2995 }
2996
2997 let allowed = |s: &str| {
2998 s.chars()
2999 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
3000 };
3001 if !allowed(item) || !allowed(collection) {
3002 return Err(self.for_identifier_error(element, &each));
3003 }
3004
3005 let custom_fragment_id = element.attr("template").map(ToString::to_string);
3006 let keep_empty = custom_fragment_id.is_some();
3007 let fragment_id = custom_fragment_id.unwrap_or_else(|| self.id_counter.next_id("for"));
3008 let parent = ParseContext {
3009 fragments: std::mem::take(fragments),
3010 raw_buffer: std::mem::take(&mut self.raw_buffer),
3011 };
3012
3013 let previous_for_depth = self.for_depth;
3014 self.for_depth += 1;
3015 ops.push(ParseOp::CompleteFor {
3016 parent,
3017 item: item.to_string(),
3018 collection: collection.to_string(),
3019 fragment_id,
3020 keep_empty,
3021 previous_for_depth,
3022 });
3023 ops.push(ParseOp::Parse {
3024 range: element.inner(),
3025 depth: depth + 1,
3026 });
3027 Ok(())
3028 }
3029
3030 #[cold]
3033 #[inline(never)]
3034 fn if_condition_missing_error(&self, element: &Element<'_>) -> ParserError {
3035 let diag = self
3036 .authoring_error_at(
3037 codes::MISSING_IF_CONDITION,
3038 "missing condition attribute on <if>",
3039 element,
3040 )
3041 .element("if");
3042 match Self::attr_typo_suggestion(element, "condition") {
3043 Some(typo) => diag.help(format!(
3044 "found `{typo}` \u{2014} did you mean condition=\"expression\"?"
3045 )),
3046 None => diag.help("add condition=\"expression\", e.g. <if condition=\"isActive\">"),
3047 }
3048 .into()
3049 }
3050
3051 #[cold]
3053 #[inline(never)]
3054 fn if_condition_invalid_error(&self, element: &Element<'_>, condition: &str) -> ParserError {
3055 self.authoring_error_at(
3056 codes::INVALID_IF_CONDITION,
3057 "invalid <if> condition expression",
3058 element,
3059 )
3060 .element("if")
3061 .snippet(format!("condition=\"{condition}\""))
3062 .help("use a simple expression like \"isActive\", \"count > 0\", or \"!hidden\"")
3063 .into()
3064 }
3065
3066 fn enter_if_directive<'a>(
3067 &mut self,
3068 element: &Element<'a>,
3069 fragments: &mut Vec<WebUIFragment>,
3070 depth: usize,
3071 ops: &mut Vec<ParseOp<'a>>,
3072 ) -> Result<()> {
3073 let condition_str = element
3074 .attr("condition")
3075 .map(ToString::to_string)
3076 .ok_or_else(|| self.if_condition_missing_error(element))?;
3077
3078 let condition = self
3079 .condition_parser
3080 .parse(&condition_str)
3081 .map_err(|_| self.if_condition_invalid_error(element, &condition_str))?;
3082
3083 self.flush_raw_buffer(fragments);
3084 let parent = ParseContext {
3085 fragments: std::mem::take(fragments),
3086 raw_buffer: std::mem::take(&mut self.raw_buffer),
3087 };
3088 let fragment_id = self.id_counter.next_id("if");
3089
3090 ops.push(ParseOp::CompleteIf {
3091 parent,
3092 condition,
3093 fragment_id,
3094 });
3095 ops.push(ParseOp::Parse {
3096 range: element.inner(),
3097 depth: depth + 1,
3098 });
3099 Ok(())
3100 }
3101
3102 #[inline]
3110 fn enter_foster_context<'a>(&mut self, name: &str, ops: &mut Vec<ParseOp<'a>>) {
3111 match foster_context_of(name) {
3112 FosterContext::Transparent => {}
3113 FosterContext::Hostile => {
3114 ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
3115 self.foster_context_depth += 1;
3116 }
3117 FosterContext::Barrier => {
3118 if self.foster_context_depth != 0 {
3119 ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
3120 self.foster_context_depth = 0;
3121 }
3122 }
3123 }
3124 }
3125
3126 #[inline]
3127 fn enter_boundary_parent_scope<'a>(
3128 &mut self,
3129 element: &Element<'a>,
3130 ops: &mut Vec<ParseOp<'a>>,
3131 ) {
3132 if element.name().eq_ignore_ascii_case("template") && element.has_attr("shadowrootmode") {
3133 return;
3134 }
3135 let Some(scope) = boundary_parent_scope(element.name()) else {
3136 return;
3137 };
3138 ops.push(ParseOp::RestoreBoundaryParentScope(
3139 self.boundary_parent_scope,
3140 ));
3141 self.boundary_parent_scope = Some(scope);
3142 }
3143
3144 #[inline]
3145 fn enter_component_content_scope<'a>(&mut self, ops: &mut Vec<ParseOp<'a>>) {
3146 ops.push(ParseOp::RestoreBoundaryParentScope(
3147 self.boundary_parent_scope,
3148 ));
3149 self.boundary_parent_scope = Some("component host content");
3150 }
3151
3152 fn enter_boundary_directive<'a>(
3178 &mut self,
3179 element: &Element<'a>,
3180 fragments: &mut Vec<WebUIFragment>,
3181 depth: usize,
3182 ops: &mut Vec<ParseOp<'a>>,
3183 ) -> Result<()> {
3184 if let Some(scope) = self.boundary_parent_scope {
3185 return Err(self.boundary_scope_error(element, scope));
3186 }
3187 if self.in_boundary {
3188 return Err(self.nested_boundary_error(element));
3189 }
3190 if self.for_depth != 0 {
3191 return Err(self.boundary_in_repeat_error(element));
3192 }
3193 let owner_fragment_id = self.current_fragment_id.clone();
3194 let owner_is_component = self.component_registry.contains(&owner_fragment_id);
3195 if !owner_is_component && self.body_depth == 0 && self.route_depth == 0 {
3196 return Err(self.boundary_outside_body_error(element));
3197 }
3198 if self.foster_context_depth != 0 {
3199 return Err(self.boundary_in_foster_context_error(element));
3200 }
3201
3202 let name = self.validate_boundary_name(element)?;
3203 let inserted = self
3204 .boundary_names_by_owner
3205 .entry(owner_fragment_id.clone())
3206 .or_default()
3207 .insert(name.clone());
3208 if !inserted {
3209 return Err(self.duplicate_boundary_name_error(element, &name));
3210 }
3211 let key = self.validate_boundary_key(element)?;
3212 let declaration_id = self.allocate_boundary_declaration_id(element)?;
3213 self.in_boundary = true;
3214 self.add_fragment(
3215 WebUIFragment {
3216 fragment: Some(Fragment::Boundary(WebUiFragmentBoundary {
3217 declaration_id,
3218 owner_fragment_id,
3219 name,
3220 key,
3221 may_repeat: false,
3222 phase: BoundaryPhase::Start as i32,
3223 })),
3224 },
3225 fragments,
3226 );
3227
3228 ops.push(ParseOp::CompleteBoundary {
3229 boundary: PendingBoundary { declaration_id },
3230 });
3231 ops.push(ParseOp::Parse {
3232 range: element.inner(),
3233 depth: depth + 1,
3234 });
3235 Ok(())
3236 }
3237
3238 fn validate_boundary_name(&self, element: &Element<'_>) -> Result<String> {
3241 let name = element.attr("name").unwrap_or_default();
3242 if name.trim().is_empty() {
3243 return Err(self.missing_boundary_name_error(element));
3244 }
3245 if name.contains("{{") {
3246 return Err(self.invalid_boundary_name_error(element, name));
3247 }
3248 Ok(name.to_string())
3249 }
3250
3251 fn validate_boundary_key(&self, element: &Element<'_>) -> Result<Option<String>> {
3252 if !element.has_attr("key") {
3253 return Ok(None);
3254 }
3255 let Some(raw) = element.attr("key") else {
3256 return Err(self.invalid_boundary_key_error(element, ""));
3257 };
3258 let trimmed = raw.trim();
3259 let braced = trimmed.starts_with("{{") || trimmed.ends_with("}}");
3260 let valid = if braced {
3261 trimmed
3262 .strip_prefix("{{")
3263 .and_then(|value| value.strip_suffix("}}"))
3264 .filter(|value| !value.starts_with('{') && !value.ends_with('}'))
3265 .is_some_and(|value| !value.trim().is_empty())
3266 } else {
3267 !trimmed.is_empty()
3268 };
3269 if !valid {
3270 return Err(self.invalid_boundary_key_error(element, raw));
3271 }
3272 Ok(Some(raw.to_string()))
3273 }
3274
3275 fn allocate_boundary_declaration_id(&mut self, element: &Element<'_>) -> Result<u32> {
3276 let declaration_id = self.next_boundary_declaration_id;
3277 let Some(next) = declaration_id.checked_add(1) else {
3278 return Err(self.too_many_boundaries_error(element));
3279 };
3280 self.next_boundary_declaration_id = next;
3281 Ok(declaration_id)
3282 }
3283
3284 #[cold]
3287 #[inline(never)]
3288 fn missing_boundary_name_error(&self, element: &Element<'_>) -> ParserError {
3289 self.authoring_error_at(
3290 codes::MISSING_BOUNDARY_NAME,
3291 "missing name attribute on <boundary>",
3292 element,
3293 )
3294 .element("boundary")
3295 .help("add a unique static name, e.g. <boundary name=\"counter-ready\">")
3296 .into()
3297 }
3298
3299 #[cold]
3302 #[inline(never)]
3303 fn invalid_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
3304 self.authoring_error_at(
3305 codes::INVALID_BOUNDARY_NAME,
3306 "invalid name attribute on <boundary>",
3307 element,
3308 )
3309 .element("boundary")
3310 .snippet(format!("name=\"{name}\""))
3311 .help("boundary names must be a static string literal, e.g. name=\"counter-ready\"")
3312 .into()
3313 }
3314
3315 #[cold]
3317 #[inline(never)]
3318 fn duplicate_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
3319 self.authoring_error_at(
3320 codes::DUPLICATE_BOUNDARY_NAME,
3321 format!("duplicate <boundary> name \"{name}\""),
3322 element,
3323 )
3324 .element("boundary")
3325 .help("boundary names must be unique within their entry or component template; rename one of the declarations")
3326 .into()
3327 }
3328
3329 #[cold]
3330 #[inline(never)]
3331 fn missing_boundary_key_error(owner: &str, name: &str) -> ParserError {
3332 Diagnostic::error(format!(
3333 "boundary \"{name}\" renders more than once and has no key"
3334 ))
3335 .code(codes::MISSING_BOUNDARY_KEY)
3336 .component(owner)
3337 .element("boundary")
3338 .snippet(format!("name=\"{name}\""))
3339 .help(
3340 "the owning template is rendered from more than one callsite, so every occurrence \
3341 needs a distinct key, e.g. key=\"{{sectionId}}\" — or render the declaration from a \
3342 single callsite",
3343 )
3344 .into()
3345 }
3346
3347 #[cold]
3349 #[inline(never)]
3350 fn boundary_in_repeat_error(&self, element: &Element<'_>) -> ParserError {
3351 let name = element.attr("name").unwrap_or_default();
3352 self.authoring_error_at(
3353 codes::BOUNDARY_IN_REPEAT,
3354 "<boundary> is not valid inside a <for> repeat",
3355 element,
3356 )
3357 .element("boundary")
3358 .snippet(format!("name=\"{name}\""))
3359 .help(
3360 "a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
3361 whole <for> in one <boundary> to render the list as a single independently paced \
3362 region",
3363 )
3364 .into()
3365 }
3366
3367 #[cold]
3368 #[inline(never)]
3369 fn invalid_boundary_key_error(&self, element: &Element<'_>, key: &str) -> ParserError {
3370 self.authoring_error_at(
3371 codes::INVALID_BOUNDARY_KEY,
3372 "invalid key expression on <boundary>",
3373 element,
3374 )
3375 .element("boundary")
3376 .snippet(format!("key=\"{key}\""))
3377 .help("use a non-empty expression such as key=\"item.id\" or key=\"{{item.id}}\"")
3378 .into()
3379 }
3380
3381 #[cold]
3382 #[inline(never)]
3383 fn too_many_boundaries_error(&self, element: &Element<'_>) -> ParserError {
3384 self.authoring_error_at(
3385 codes::TOO_MANY_BOUNDARIES,
3386 "too many boundary declarations",
3387 element,
3388 )
3389 .element("boundary")
3390 .help("split this build into smaller protocols so each declaration has a unique u32 ID")
3391 .into()
3392 }
3393
3394 #[cold]
3397 #[inline(never)]
3398 fn nested_boundary_error(&self, element: &Element<'_>) -> ParserError {
3399 self.authoring_error_at(
3400 codes::NESTED_BOUNDARY,
3401 "nested <boundary> is not allowed",
3402 element,
3403 )
3404 .element("boundary")
3405 .help("boundaries cannot be nested; close the enclosing <boundary> before opening another")
3406 .into()
3407 }
3408
3409 #[cold]
3412 #[inline(never)]
3413 fn boundary_scope_error(&self, element: &Element<'_>, scope: &str) -> ParserError {
3414 self.authoring_error_at(
3415 codes::BOUNDARY_CROSSES_SCOPE,
3416 format!("<boundary> cannot appear inside {scope}"),
3417 element,
3418 )
3419 .element("boundary")
3420 .help("move the <boundary> outside this raw, inert, or component-host content scope")
3421 .into()
3422 }
3423
3424 #[cold]
3426 #[inline(never)]
3427 fn boundary_outside_body_error(&self, element: &Element<'_>) -> ParserError {
3428 self.authoring_error_at(
3429 codes::BOUNDARY_OUTSIDE_BODY,
3430 "<boundary> must appear inside <body>",
3431 element,
3432 )
3433 .element("boundary")
3434 .help("move the <boundary> between the opening <body> and its matching </body>")
3435 .into()
3436 }
3437
3438 #[cold]
3446 #[inline(never)]
3447 fn boundary_in_foster_context_error(&self, element: &Element<'_>) -> ParserError {
3448 self.authoring_error_at(
3449 codes::BOUNDARY_IN_FOSTER_CONTEXT,
3450 "<boundary> cannot appear inside a table or select context",
3451 element,
3452 )
3453 .element("boundary")
3454 .help(
3455 "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>",
3456 )
3457 .into()
3458 }
3459
3460 #[cold]
3461 #[inline(never)]
3462 fn route_boundary_placement_error(&self, element: &Element<'_>) -> ParserError {
3463 self.authoring_error_at(
3464 codes::INVALID_ROUTE_BOUNDARY_PLACEMENT,
3465 "route boundary must be a direct <route> child",
3466 element,
3467 )
3468 .element("boundary")
3469 .help("move the <boundary> directly under <route>, or place it in the route component template")
3470 .into()
3471 }
3472
3473 #[cold]
3477 #[inline(never)]
3478 fn authored_webui_hydrate_error(&self, element: &Element<'_>) -> ParserError {
3479 self.authoring_error_at(
3480 codes::AUTHORED_WEBUI_HYDRATE,
3481 "<webui-hydrate> is reserved for the generated runtime sentinel",
3482 element,
3483 )
3484 .element("webui-hydrate")
3485 .help("remove <webui-hydrate>; it is emitted only by the compiler/handler, never authored")
3486 .into()
3487 }
3488
3489 fn enter_component_directive<'a>(
3490 &mut self,
3491 element: &Element<'a>,
3492 fragments: &mut Vec<WebUIFragment>,
3493 depth: usize,
3494 ops: &mut Vec<ParseOp<'a>>,
3495 ) -> Result<()> {
3496 let needs_template_build = !self.fragment_records.contains_key(element.name());
3497 let cached_dom_analysis = self.component_dom_analyses.get(element.name()).copied();
3498 let has_plugin_artifact = self
3499 .component_registry
3500 .component_artifact_source(element.name())
3501 .is_some();
3502 let authored_html = if needs_template_build || cached_dom_analysis.is_none() {
3503 self.component_registry
3504 .component_authored_source(element.name())
3505 } else {
3506 None
3507 };
3508 let (dom_analysis, template_source) = {
3509 let component = self.component_registry.get(element.name()).ok_or_else(|| {
3510 self.authoring_error_at(
3511 codes::UNKNOWN_COMPONENT,
3512 format!("unknown component <{}>", element.name()),
3513 element,
3514 )
3515 .help(self.unknown_component_help(element.name()))
3516 })?;
3517 (
3518 cached_dom_analysis.map_or_else(
3519 || {
3520 let fast_html = (has_plugin_artifact
3521 && self.options.dom_strategy == DomStrategy::Shadow)
3522 .then(|| {
3523 Self::append_shadow_mode_if_missing(component.html_content.clone())
3524 });
3525 analyze_component_dom_with_diagnostic_source(
3526 element.name(),
3527 fast_html.as_deref().unwrap_or(&component.html_content),
3528 self.options.dom_strategy,
3529 authored_html.as_deref(),
3530 )
3531 },
3532 Ok,
3533 )?,
3534 needs_template_build.then(|| {
3535 (
3536 component.html_content.clone(),
3537 component.css_content.clone(),
3538 )
3539 }),
3540 )
3541 };
3542 if cached_dom_analysis.is_none() {
3543 self.component_dom_analyses
3544 .insert(element.name().to_string(), dom_analysis);
3545 }
3546
3547 self.flush_raw_buffer(fragments);
3548 fragments.push(structural_signal(format!(
3549 "streaming_span_start:{}",
3550 element.name()
3551 )));
3552 self.add_raw_fragment("<");
3553 self.add_raw_fragment(element.name());
3554 if self
3555 .component_registry
3556 .render_policy(element.name())
3557 .is_some_and(ComponentRenderPolicy::is_interaction_hydration)
3558 {
3559 self.add_raw_fragment(" data-webui-interaction");
3560 }
3561
3562 let binding_count = self.process_tag_attributes(element.attrs(), fragments, true)?;
3563 if let Some(ref mut p) = self.plugin {
3564 if let Some(data) = p.finish_opening_tag(ElementStartContext {
3565 tag_name: element.name(),
3566 binding_count,
3567 }) {
3568 self.add_fragment(WebUIFragment::plugin(data), fragments);
3569 }
3570 }
3571
3572 self.flush_raw_buffer(fragments);
3579 fragments.push(structural_signal(format!(
3580 "streaming_root:{}",
3581 element.name()
3582 )));
3583
3584 if element.self_closing() {
3585 self.add_raw_fragment("/>");
3586 } else {
3587 self.add_raw_fragment(">");
3588 }
3589
3590 self.flush_raw_buffer(fragments);
3591
3592 if let Some((html_content, css_content)) = template_source {
3593 let artifact_html = self
3594 .component_registry
3595 .component_artifact_source(element.name())
3596 .map(str::to_string);
3597 let built = self.build_component_templates(ComponentTemplateInput {
3598 tag_name: element.name(),
3599 html: &html_content,
3600 artifact_html: artifact_html.as_deref(),
3601 authored_html: authored_html.as_deref(),
3602 css_content: css_content.as_deref(),
3603 artifact_needed: self.plugin.is_some(),
3604 })?;
3605
3606 if let Some(ref mut p) = self.plugin {
3607 let component_data = self
3608 .component_registry
3609 .get(element.name())
3610 .ok_or_else(|| {
3611 ParserError::NotFound(format!(
3612 "component <{}> disappeared during CSS compilation",
3613 element.name()
3614 ))
3615 })?
3616 .clone();
3617 p.component_built(built.plugin_context(&component_data))?;
3618 }
3619
3620 self.parse(element.name(), &built.ssr)?;
3621 }
3622
3623 fragments.push(WebUIFragment::component(element.name().to_string()));
3624
3625 if element.self_closing() {
3626 fragments.push(structural_signal(format!(
3627 "streaming_span_end:{}",
3628 element.name()
3629 )));
3630 } else {
3631 ops.push(ParseOp::EndComponent(element.name()));
3632 self.enter_component_content_scope(ops);
3633 ops.push(ParseOp::Parse {
3634 range: element.inner(),
3635 depth: depth + 1,
3636 });
3637 }
3638
3639 Ok(())
3640 }
3641
3642 fn analyze_component_dom(
3643 &mut self,
3644 tag_name: &str,
3645 source: &str,
3646 authored_source: Option<&str>,
3647 ) -> Result<ComponentDomAnalysis> {
3648 if let Some(analysis) = self.component_dom_analyses.get(tag_name) {
3649 return Ok(*analysis);
3650 }
3651 let analysis = analyze_component_dom_with_diagnostic_source(
3652 tag_name,
3653 source,
3654 self.options.dom_strategy,
3655 authored_source,
3656 )?;
3657 self.component_dom_analyses
3658 .insert(tag_name.to_string(), analysis);
3659 Ok(analysis)
3660 }
3661
3662 fn process_text(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
3663 if !Self::should_emit_text_content(content) {
3664 return Ok(());
3665 }
3666
3667 let parsed_fragments = self.handlebars_parser.parse(content)?;
3668 for fragment in parsed_fragments {
3669 if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
3670 if Self::should_emit_text_content(&raw.value) {
3671 self.add_raw_fragment(&raw.value);
3672 }
3673 } else if let Some(Fragment::Signal(signal)) = fragment.fragment.as_ref() {
3674 if signal.raw && is_html_text_only_scope(self.boundary_parent_scope) {
3675 self.add_fragment(
3676 WebUIFragment::raw_text_signal(signal.value.as_str(), true),
3677 fragments,
3678 );
3679 } else {
3680 self.add_fragment(fragment, fragments);
3681 }
3682 } else {
3683 self.add_fragment(fragment, fragments);
3684 }
3685 }
3686 Ok(())
3687 }
3688
3689 fn process_style_content(
3690 &mut self,
3691 css: &str,
3692 comments: &[crate::css_parser::CssComment],
3693 fragments: &mut Vec<WebUIFragment>,
3694 ) {
3695 let mut last_end = 0usize;
3696
3697 for comment in comments {
3698 if comment.start_byte < last_end {
3699 if comment.end_byte > last_end {
3700 last_end = comment.end_byte;
3701 }
3702 continue;
3703 }
3704
3705 self.add_raw_fragment(&css[last_end..comment.start_byte]);
3706 let comment_text = &css[comment.start_byte..comment.end_byte];
3707 if let Some(fragment) = self.css_signal_comment_fragment(comment_text) {
3708 self.add_fragment(fragment, fragments);
3709 } else if comment.preserve {
3710 self.add_raw_fragment(comment_text);
3711 }
3712 last_end = comment.end_byte;
3713 }
3714
3715 self.add_raw_fragment(&css[last_end..]);
3716 }
3717
3718 fn css_signal_comment_fragment(&self, comment: &str) -> Option<WebUIFragment> {
3719 let signal = comment_policy::parse_css_signal_comment(comment)?;
3720 Some(WebUIFragment::raw_text_signal(signal.path, signal.raw))
3721 }
3722
3723 fn extract_single_handlebars(value: &str) -> Option<&str> {
3726 let trimmed = value.trim();
3727 if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
3728 let inner = trimmed[2..trimmed.len() - 2].trim();
3729 if !inner.contains("{{") && !inner.is_empty() {
3731 return Some(inner);
3732 }
3733 }
3734 None
3735 }
3736
3737 fn contains_handlebars(value: &str) -> bool {
3739 value.contains("{{")
3740 }
3741
3742 fn process_tag_attributes(
3743 &mut self,
3744 attrs: Attrs<'_>,
3745 fragments: &mut Vec<WebUIFragment>,
3746 is_component: bool,
3747 ) -> Result<u32> {
3748 let mut first_dynamic_emitted = false;
3749 let mut binding_count: u32 = 0;
3750 for attr in attrs {
3751 let attr_name = attr.name;
3752 let attr_value = attr.value;
3753 let is_property_binding = attr_name.starts_with(':')
3754 && attr_value
3755 .and_then(Self::extract_single_handlebars)
3756 .is_some();
3757
3758 if !is_property_binding {
3762 if let Some(ref mut p) = self.plugin {
3763 match p.process_attribute(AttributeContext { name: attr_name }) {
3764 AttributeAction::Keep => {}
3765 AttributeAction::Skip => continue,
3766 AttributeAction::SkipAndCountBinding => {
3767 binding_count += 1;
3768 continue;
3769 }
3770 }
3771 }
3772 }
3773
3774 if let Some(bool_name) = attr_name.strip_prefix('?') {
3775 if is_component {
3776 if let Some(condition) = self.parse_boolean_condition(attr_value) {
3777 let frag = Self::maybe_mark_attr_start(
3778 WebUIFragment::attribute_boolean(bool_name, condition),
3779 &mut first_dynamic_emitted,
3780 );
3781 self.add_fragment(frag, fragments);
3782 binding_count += 1;
3783 }
3784 } else if self.process_boolean_attribute(bool_name, attr_value, fragments)? {
3785 binding_count += 1;
3786 }
3787 } else if let Some(prop_name) = attr_name.strip_prefix(':') {
3788 if !is_component {
3789 return Err(ParserError::Parse(format!(
3790 ":{prop_name} complex binding is only allowed on custom elements. \
3791 Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
3792 )));
3793 }
3794 if Self::is_blocked_complex_property(prop_name) {
3795 return Err(ParserError::Parse(format!(
3796 ":{prop_name} is not allowed as a complex attribute binding \
3797 because it enables arbitrary HTML injection. \
3798 Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
3799 )));
3800 }
3801 if let Some(val) = attr_value {
3802 if let Some(signal_name) = Self::extract_single_handlebars(val) {
3803 let frag = Self::maybe_mark_attr_start(
3804 WebUIFragment::attribute_complex(attr_name, signal_name),
3805 &mut first_dynamic_emitted,
3806 );
3807 self.add_fragment(frag, fragments);
3808 binding_count += 1;
3809 }
3810 }
3811 } else if is_component && Self::is_skipped_attribute(attr_name) {
3812 if let Some(val) = attr_value {
3813 if let Some(signal_name) = Self::extract_single_handlebars(val) {
3814 let frag = WebUIFragment {
3815 fragment: Some(web_ui_fragment::Fragment::Attribute(
3816 WebUIFragmentAttribute {
3817 name: attr_name.to_string(),
3818 value: signal_name.to_string(),
3819 attr_skip: true,
3820 ..Default::default()
3821 },
3822 )),
3823 };
3824 self.add_fragment(frag, fragments);
3825 binding_count += 1;
3826 } else if Self::contains_handlebars(val) {
3827 let template_id = self.id_counter.next_id("attr");
3828 let parsed = self.handlebars_parser.parse(val)?;
3829 self.fragment_records.insert(
3830 template_id.clone(),
3831 FragmentList {
3832 fragments: parsed,
3833 contains_boundary: false,
3834 },
3835 );
3836 let frag = WebUIFragment {
3837 fragment: Some(web_ui_fragment::Fragment::Attribute(
3838 WebUIFragmentAttribute {
3839 name: attr_name.to_string(),
3840 template: template_id,
3841 attr_skip: true,
3842 ..Default::default()
3843 },
3844 )),
3845 };
3846 self.add_fragment(frag, fragments);
3847 binding_count += 1;
3848 } else {
3849 let frag = WebUIFragment {
3850 fragment: Some(web_ui_fragment::Fragment::Attribute(
3851 WebUIFragmentAttribute {
3852 name: attr_name.to_string(),
3853 value: val.to_string(),
3854 raw_value: true,
3855 attr_skip: true,
3856 ..Default::default()
3857 },
3858 )),
3859 };
3860 self.add_fragment(frag, fragments);
3861 }
3862 }
3863 } else if let Some(val) = attr_value {
3864 if Self::contains_handlebars(val) {
3865 if is_component {
3866 if let Some(signal_name) = Self::extract_single_handlebars(val) {
3867 let frag = Self::maybe_mark_attr_start(
3868 WebUIFragment::attribute(attr_name, signal_name),
3869 &mut first_dynamic_emitted,
3870 );
3871 self.add_fragment(frag, fragments);
3872 binding_count += 1;
3873 } else {
3874 let template_id = self.id_counter.next_id("attr");
3875 let parsed = self.handlebars_parser.parse(val)?;
3876 self.fragment_records.insert(
3877 template_id.clone(),
3878 FragmentList {
3879 fragments: parsed,
3880 contains_boundary: false,
3881 },
3882 );
3883 let frag = Self::maybe_mark_attr_start(
3884 WebUIFragment::attribute_template(attr_name, template_id),
3885 &mut first_dynamic_emitted,
3886 );
3887 self.add_fragment(frag, fragments);
3888 binding_count += 1;
3889 }
3890 } else {
3891 self.process_dynamic_attribute(attr_name, val, fragments)?;
3892 binding_count += 1;
3893 }
3894 } else if is_component {
3895 let frag = Self::maybe_mark_attr_start(
3896 WebUIFragment {
3897 fragment: Some(web_ui_fragment::Fragment::Attribute(
3898 WebUIFragmentAttribute {
3899 name: attr_name.to_string(),
3900 value: val.to_string(),
3901 raw_value: true,
3902 ..Default::default()
3903 },
3904 )),
3905 },
3906 &mut first_dynamic_emitted,
3907 );
3908 self.add_fragment(frag, fragments);
3909 } else {
3910 self.add_raw_fragment(" ");
3911 self.add_raw_fragment(attr.raw);
3912 }
3913 } else {
3914 self.add_raw_fragment(" ");
3915 self.add_raw_fragment(attr_name);
3916 }
3917 }
3918 Ok(binding_count)
3919 }
3920
3921 fn maybe_mark_attr_start(
3924 mut frag: WebUIFragment,
3925 first_dynamic_emitted: &mut bool,
3926 ) -> WebUIFragment {
3927 if !*first_dynamic_emitted {
3928 if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
3929 a.attr_start = true;
3930 }
3931 *first_dynamic_emitted = true;
3932 }
3933 frag
3934 }
3935
3936 fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
3939 if let Some(val) = value {
3940 if let Some(expr_str) = Self::extract_single_handlebars(val) {
3941 return Some(
3942 self.condition_parser
3943 .parse(expr_str)
3944 .unwrap_or_else(|_| ConditionExpr::identifier(expr_str)),
3945 );
3946 }
3947 }
3948
3949 None
3950 }
3951
3952 fn process_boolean_attribute(
3953 &mut self,
3954 name: &str,
3955 value: Option<&str>,
3956 fragments: &mut Vec<WebUIFragment>,
3957 ) -> Result<bool> {
3958 if let Some(condition) = self.parse_boolean_condition(value) {
3959 self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
3960 return Ok(true);
3961 }
3962 Ok(false)
3964 }
3965
3966 fn process_dynamic_attribute(
3968 &mut self,
3969 name: &str,
3970 value: &str,
3971 fragments: &mut Vec<WebUIFragment>,
3972 ) -> Result<()> {
3973 if let Some(signal_name) = Self::extract_single_handlebars(value) {
3974 self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
3976 } else {
3977 let template_id = self.id_counter.next_id("attr");
3979 let parsed = self.handlebars_parser.parse(value)?;
3980
3981 self.fragment_records.insert(
3982 template_id.clone(),
3983 FragmentList {
3984 fragments: parsed,
3985 contains_boundary: false,
3986 },
3987 );
3988
3989 self.add_fragment(
3990 WebUIFragment::attribute_template(name, template_id),
3991 fragments,
3992 );
3993 }
3994 Ok(())
3995 }
3996
3997 fn process_style_element(
3998 &mut self,
3999 element: &Element<'_>,
4000 fragments: &mut Vec<WebUIFragment>,
4001 ) -> Result<()> {
4002 self.add_raw_fragment(element.opening());
4003 let inner = element.inner();
4004 let style_content = &element.source()[inner.start..inner.end];
4005 let (_tokens, defs, requirements, comments) = self
4006 .css_parser
4007 .extract_tokens_definitions_requirements_and_comments(
4008 style_content,
4009 self.options.legal_comments,
4010 )
4011 .map_err(|e| self.css_diagnostic(e))?;
4012 self.record_fragment_css_tokens(defs, requirements);
4013 self.process_style_content(style_content, &comments, fragments);
4014 if element.close_end() > element.content_end() {
4015 self.add_raw_fragment("</");
4019 self.add_raw_fragment(element.name());
4020 self.add_raw_fragment(">");
4021 }
4022 Ok(())
4023 }
4024
4025 fn process_route_directive(
4031 &mut self,
4032 element: &Element<'_>,
4033 fragments: &mut Vec<WebUIFragment>,
4034 depth: usize,
4035 ) -> Result<()> {
4036 let attrs = Self::route_attrs_from_element(element);
4037 let path = attrs.path.clone();
4038 let component = attrs.component.clone();
4039 route_parser::validate_attributes(&attrs)?;
4040
4041 let route_params: std::collections::HashSet<String> =
4042 route_parser::extract_params(&path)?.into_iter().collect();
4043 if !attrs.cache_tags.is_empty() {
4044 route_parser::validate_tag_placeholders(
4045 &attrs.cache_tags,
4046 &route_params,
4047 "cache-tags",
4048 &path,
4049 )?;
4050 }
4051 if !attrs.invalidates.is_empty() {
4052 route_parser::validate_tag_placeholders(
4053 &attrs.invalidates,
4054 &route_params,
4055 "invalidates",
4056 &path,
4057 )?;
4058 }
4059
4060 if !attrs.pending_component.is_empty() {
4061 self.ensure_route_component_parsed(&attrs.pending_component)?;
4062 }
4063 if !attrs.error_component.is_empty() {
4064 self.ensure_route_component_parsed(&attrs.error_component)?;
4065 }
4066 self.ensure_route_component_parsed(&component)?;
4067 self.flush_raw_buffer(fragments);
4068 let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
4069
4070 let mut all_params = std::collections::HashSet::new();
4071 all_params.extend(route_params);
4072 self.validate_route_nesting_depth(element.source(), element.inner(), 1)?;
4073 let children =
4074 self.parse_child_routes(element.source(), element.inner(), &all_params, 1)?;
4075
4076 let mut route_fragment =
4077 route_parser::build_route_fragment(&attrs, component.clone(), children);
4078 route_fragment.content_fragment_id = content_fragment_id;
4079 fragments.push(WebUIFragment::route_from(route_fragment));
4080
4081 Ok(())
4082 }
4083
4084 fn parse_route_boundary_content(
4085 &mut self,
4086 route: &Element<'_>,
4087 depth: usize,
4088 ) -> Result<String> {
4089 let inner = route.inner();
4090 if !route.source()[inner.clone()].contains("<boundary") {
4091 return Ok(String::new());
4092 }
4093 let saved_buffer = std::mem::take(&mut self.raw_buffer);
4094 let previous_route_depth = self.route_depth;
4095 self.route_depth += 1;
4096 let mut content = Vec::new();
4097 let parse_result: Result<()> = (|| {
4098 for event in Walker::new_range(route.source(), inner.start, inner.end) {
4099 if let Event::Element(element) = event {
4100 if element.name() == "boundary" {
4101 self.parse_range(
4102 element.source(),
4103 element.start..element.close_end(),
4104 &mut content,
4105 depth + 1,
4106 )?;
4107 }
4108 }
4109 }
4110 self.flush_raw_buffer(&mut content);
4111 Ok(())
4112 })();
4113 self.route_depth = previous_route_depth;
4114 self.raw_buffer = saved_buffer;
4115 parse_result?;
4116
4117 if content.is_empty() {
4118 return Ok(String::new());
4119 }
4120 let fragment_id = self.id_counter.next_id("route-content");
4121 self.fragment_records.insert(
4122 fragment_id.clone(),
4123 FragmentList {
4124 fragments: content,
4125 contains_boundary: false,
4126 },
4127 );
4128 Ok(fragment_id)
4129 }
4130
4131 fn parse_child_routes(
4138 &mut self,
4139 source: &str,
4140 range: Range<usize>,
4141 ancestor_params: &std::collections::HashSet<String>,
4142 depth: usize,
4143 ) -> Result<Vec<WebUiFragmentRoute>> {
4144 if depth > MAX_TEMPLATE_DEPTH {
4145 return Err(self
4146 .html_error(
4147 codes::EXCESSIVE_NESTING,
4148 format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4149 source,
4150 range.start,
4151 )
4152 .help("flatten deeply nested <route> trees before building")
4153 .into());
4154 }
4155
4156 let mut children = Vec::new();
4157 for event in Walker::new_range(source, range.start, range.end) {
4158 match event {
4159 Event::Element(element) => {
4160 self.validate_closed_element(&element)?;
4161 self.reject_boundary_or_hydrate_in_route(&element, true)?;
4162 if element.name() == "route" {
4163 children.push(self.parse_route_as_fragment(
4164 &element,
4165 ancestor_params,
4166 depth,
4167 )?);
4168 } else if element.name().eq_ignore_ascii_case("style") {
4169 self.validate_style_element(&element)?;
4170 } else if !element.self_closing() && !element.is_void() {
4171 self.validate_ignored_route_html(element.source(), element.inner(), depth)?;
4172 }
4173 }
4174 Event::Text(text) => {
4175 if text.contains('<') {
4176 return Err(self
4177 .authoring_error(
4178 codes::MALFORMED_HTML_TAG,
4179 "malformed HTML tag in <route>",
4180 )
4181 .help("close the tag with `>`, or escape a literal `<` as `<`")
4182 .into());
4183 }
4184 }
4185 Event::Comment(comment_range) => {
4186 self.validate_comment_range(source, comment_range)?;
4187 }
4188 Event::Declaration(declaration_range) => {
4189 self.validate_declaration_range(source, declaration_range)?;
4190 }
4191 Event::ClosingTag(closing_range) => {
4192 return Err(self.unexpected_closing_tag_error(source, closing_range));
4193 }
4194 }
4195 }
4196
4197 Ok(children)
4198 }
4199
4200 fn validate_route_nesting_depth(
4201 &self,
4202 source: &str,
4203 range: Range<usize>,
4204 depth: usize,
4205 ) -> Result<()> {
4206 let mut stack = vec![(range, depth)];
4207 while let Some((range, depth)) = stack.pop() {
4208 if depth > MAX_TEMPLATE_DEPTH {
4209 return Err(self
4210 .html_error(
4211 codes::EXCESSIVE_NESTING,
4212 format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4213 source,
4214 range.start,
4215 )
4216 .help("flatten deeply nested <route> trees before building")
4217 .into());
4218 }
4219
4220 for event in Walker::new_range(source, range.start, range.end) {
4221 if let Event::Element(element) = event {
4222 self.validate_closed_element(&element)?;
4223 if element.name() == "route" && !element.self_closing() {
4224 stack.push((element.inner(), depth + 1));
4225 }
4226 }
4227 }
4228 }
4229 Ok(())
4230 }
4231
4232 fn validate_ignored_route_html(
4233 &mut self,
4234 source: &str,
4235 range: Range<usize>,
4236 depth: usize,
4237 ) -> Result<()> {
4238 let mut stack = vec![(range, depth)];
4239 while let Some((range, depth)) = stack.pop() {
4240 if depth > MAX_TEMPLATE_DEPTH {
4241 return Err(self
4242 .html_error(
4243 codes::EXCESSIVE_NESTING,
4244 format!("template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4245 source,
4246 range.start,
4247 )
4248 .help("split deeply nested markup into components, or reduce generated nesting")
4249 .into());
4250 }
4251
4252 for event in Walker::new_range(source, range.start, range.end) {
4253 match event {
4254 Event::Element(element) => {
4255 self.validate_closed_element(&element)?;
4256 self.reject_boundary_or_hydrate_in_route(&element, false)?;
4257 if element.name().eq_ignore_ascii_case("style") {
4258 self.validate_style_element(&element)?;
4259 } else if !element.self_closing() && !element.is_void() {
4260 stack.push((element.inner(), depth + 1));
4261 }
4262 }
4263 Event::Text(text) => {
4264 if text.contains('<') {
4265 return Err(self
4266 .authoring_error(
4267 codes::MALFORMED_HTML_TAG,
4268 "malformed HTML tag in <route>",
4269 )
4270 .help("close the tag with `>`, or escape a literal `<` as `<`")
4271 .into());
4272 }
4273 }
4274 Event::Comment(comment_range) => {
4275 self.validate_comment_range(source, comment_range)?;
4276 }
4277 Event::Declaration(declaration_range) => {
4278 self.validate_declaration_range(source, declaration_range)?;
4279 }
4280 Event::ClosingTag(closing_range) => {
4281 return Err(self.unexpected_closing_tag_error(source, closing_range));
4282 }
4283 }
4284 }
4285 }
4286 Ok(())
4287 }
4288
4289 fn reject_boundary_or_hydrate_in_route(
4299 &self,
4300 element: &Element<'_>,
4301 allow_boundary: bool,
4302 ) -> Result<()> {
4303 if element.name() == "boundary" && !allow_boundary {
4304 Err(self.route_boundary_placement_error(element))
4305 } else if element.name().eq_ignore_ascii_case("webui-hydrate") {
4306 Err(self.authored_webui_hydrate_error(element))
4307 } else {
4308 Ok(())
4309 }
4310 }
4311
4312 fn validate_closed_element(&self, element: &Element<'_>) -> Result<()> {
4313 if !element.self_closing()
4314 && !element.is_void()
4315 && element.close_end() == element.content_end()
4316 {
4317 return Err(self
4318 .html_error(
4319 codes::UNCLOSED_HTML_TAG,
4320 format!("unclosed <{}> tag", element.name()),
4321 element.source(),
4322 element.start,
4323 )
4324 .help(format!(
4325 "add the matching </{}> closing tag, or make the element self-closing",
4326 element.name()
4327 ))
4328 .into());
4329 }
4330 Ok(())
4331 }
4332
4333 fn validate_style_element(&mut self, element: &Element<'_>) -> Result<()> {
4334 let inner = element.inner();
4335 let style_content = &element.source()[inner.start..inner.end];
4336 self.css_parser
4337 .extract_tokens_definitions_requirements_and_comments(
4338 style_content,
4339 self.options.legal_comments,
4340 )
4341 .map_err(|e| self.css_diagnostic(e))?;
4342 Ok(())
4343 }
4344
4345 fn validate_comment_range(&self, source: &str, range: Range<usize>) -> Result<()> {
4346 let start = range.start;
4347 if source[range].ends_with("-->") {
4348 return Ok(());
4349 }
4350 Err(self
4351 .html_error(
4352 codes::UNTERMINATED_HTML_COMMENT,
4353 "unterminated HTML comment in <route>",
4354 source,
4355 start,
4356 )
4357 .help("close the comment with `-->`")
4358 .into())
4359 }
4360
4361 fn validate_declaration_range(&self, source: &str, range: Range<usize>) -> Result<()> {
4362 let start = range.start;
4363 if source[range].ends_with('>') {
4364 return Ok(());
4365 }
4366 Err(self
4367 .html_error(
4368 codes::UNTERMINATED_HTML_DECLARATION,
4369 "unterminated HTML declaration in <route>",
4370 source,
4371 start,
4372 )
4373 .help("close the declaration with `>`")
4374 .into())
4375 }
4376
4377 fn unexpected_closing_tag_error(&self, source: &str, range: Range<usize>) -> ParserError {
4378 let start = range.start;
4379 let title = match html::parse_tag(&source[range]) {
4380 Some(tag) => format!("unexpected closing tag </{}> in <route>", tag.name),
4381 None => "unexpected closing tag in <route>".to_string(),
4382 };
4383 self.html_error(codes::UNEXPECTED_CLOSING_TAG, title, source, start)
4384 .help("remove it, or add the matching opening tag before it")
4385 .into()
4386 }
4387
4388 fn route_attrs_from_element(element: &Element<'_>) -> route_parser::RouteAttributes {
4389 route_parser::RouteAttributes {
4390 path: element.attr("path").unwrap_or_default().to_string(),
4391 component: element.attr("component").unwrap_or_default().to_string(),
4392 exact: element.has_attr("exact"),
4393 query: element.attr("query").unwrap_or_default().to_string(),
4394 keep_alive: element.has_attr("keep-alive"),
4395 cache_tags: element
4396 .attr("cache-tags")
4397 .map(route_parser::parse_tag_list)
4398 .unwrap_or_default(),
4399 invalidates: element
4400 .attr("invalidates")
4401 .map(route_parser::parse_tag_list)
4402 .unwrap_or_default(),
4403 pending_component: element.attr("pending").unwrap_or_default().to_string(),
4404 error_component: element.attr("error").unwrap_or_default().to_string(),
4405 }
4406 }
4407
4408 fn parse_route_as_fragment(
4409 &mut self,
4410 element: &Element<'_>,
4411 ancestor_params: &std::collections::HashSet<String>,
4412 depth: usize,
4413 ) -> Result<WebUiFragmentRoute> {
4414 let attrs = Self::route_attrs_from_element(element);
4415 let path = attrs.path.clone();
4416 let component = attrs.component.clone();
4417
4418 route_parser::validate_attributes(&attrs)?;
4419
4420 let own_params: std::collections::HashSet<String> =
4421 route_parser::extract_params(&path)?.into_iter().collect();
4422 let mut all_params = ancestor_params.clone();
4423 all_params.extend(own_params.iter().cloned());
4424 if !attrs.cache_tags.is_empty() {
4425 route_parser::validate_tag_placeholders(
4426 &attrs.cache_tags,
4427 &all_params,
4428 "cache-tags",
4429 &path,
4430 )?;
4431 }
4432 if !attrs.invalidates.is_empty() {
4433 route_parser::validate_tag_placeholders(
4434 &attrs.invalidates,
4435 &all_params,
4436 "invalidates",
4437 &path,
4438 )?;
4439 }
4440
4441 if !attrs.pending_component.is_empty() {
4442 self.ensure_route_component_parsed(&attrs.pending_component)?;
4443 }
4444 if !attrs.error_component.is_empty() {
4445 self.ensure_route_component_parsed(&attrs.error_component)?;
4446 }
4447
4448 self.ensure_route_component_parsed(&component)?;
4449 let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
4450 let children =
4451 self.parse_child_routes(element.source(), element.inner(), &all_params, depth + 1)?;
4452
4453 let mut route = route_parser::build_route_fragment(&attrs, component, children);
4454 route.content_fragment_id = content_fragment_id;
4455 Ok(route)
4456 }
4457
4458 fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
4460 if component.is_empty()
4461 || !self.component_registry.contains(component)
4462 || self.fragment_records.contains_key(component)
4463 {
4464 return Ok(());
4465 }
4466
4467 let component_data = self
4468 .component_registry
4469 .get(component)
4470 .ok_or_else(|| {
4471 self.authoring_error(
4472 codes::UNKNOWN_COMPONENT,
4473 format!("unknown component <{component}>"),
4474 )
4475 .help(self.unknown_component_help(component))
4476 })?
4477 .clone();
4478 let artifact_html = self
4479 .component_registry
4480 .component_artifact_source(component)
4481 .map(str::to_string);
4482 let authored_html = self.component_registry.component_authored_source(component);
4483
4484 let built = self.build_component_templates(ComponentTemplateInput {
4485 tag_name: component,
4486 html: &component_data.html_content,
4487 artifact_html: artifact_html.as_deref(),
4488 authored_html: authored_html.as_deref(),
4489 css_content: component_data.css_content.as_deref(),
4490 artifact_needed: self.plugin.is_some(),
4491 })?;
4492
4493 if let Some(ref mut p) = self.plugin {
4494 let component_data = self
4495 .component_registry
4496 .get(component)
4497 .ok_or_else(|| {
4498 ParserError::NotFound(format!(
4499 "component <{component}> disappeared during CSS compilation"
4500 ))
4501 })?
4502 .clone();
4503 p.component_built(built.plugin_context(&component_data))?;
4504 }
4505
4506 let saved_buffer = std::mem::take(&mut self.raw_buffer);
4507 self.parse(component, &built.ssr)?;
4508 self.raw_buffer = saved_buffer;
4509
4510 Ok(())
4511 }
4512
4513 const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
4515 const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-", "aria-"];
4517 const ADOPTED_STYLESHEETS_ATTR: &str = "shadowrootadoptedstylesheets";
4518
4519 fn is_skipped_attribute(name: &str) -> bool {
4520 Self::SKIPPED_ATTRIBUTES.contains(&name)
4521 || Self::SKIPPED_ATTRIBUTE_PREFIXES
4522 .iter()
4523 .any(|prefix| name.starts_with(prefix))
4524 }
4525
4526 fn is_blocked_complex_property(name: &str) -> bool {
4529 matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
4530 }
4531
4532 fn build_component_templates(
4534 &mut self,
4535 input: ComponentTemplateInput<'_>,
4536 ) -> Result<BuiltComponentTemplate> {
4537 let tag_name = input.tag_name;
4538 let synthesize_fast_shadow =
4539 input.artifact_html.is_some() && self.options.dom_strategy == DomStrategy::Shadow;
4540 let fast_html = synthesize_fast_shadow
4541 .then(|| Self::append_shadow_mode_if_missing(input.html.to_string()));
4542 let html = fast_html.as_deref().unwrap_or(input.html);
4543 let css_content = input.css_content;
4544 let dom_analysis = self.analyze_component_dom(tag_name, html, input.authored_html)?;
4545 let is_light = !dom_analysis.uses_shadow_dom;
4546 let mut inline_style_ranges = Vec::new();
4547 if is_light {
4548 let mut comment_ranges = Vec::new();
4549 Self::collect_html_comment_and_style_ranges(
4550 html,
4551 &mut comment_ranges,
4552 &mut inline_style_ranges,
4553 )?;
4554 if let Some(css) = css_content {
4555 css_light::validate_global_css(tag_name, css)?;
4556 }
4557 for (start, end) in &inline_style_ranges {
4558 css_light::validate_global_css(tag_name, &html[*start..*end])?;
4559 }
4560 }
4561 if self.component_registry.contains(tag_name) {
4562 self.component_registry
4563 .prepare_policy_css(tag_name, dom_analysis.uses_shadow_dom)?;
4564 }
4565 let css_content = self
4566 .component_registry
4567 .get(tag_name)
4568 .and_then(|component| component.css_content.as_deref())
4569 .or(css_content);
4570 let adopted_specifier = match self.options.css_strategy {
4571 CssStrategy::Module if css_content.is_some() => Some(tag_name),
4572 _ => None,
4573 };
4574 let style = if dom_analysis.uses_shadow_dom && self.plugin.is_some() {
4584 self.component_style_delivery(tag_name, css_content)
4585 } else {
4586 None
4587 };
4588
4589 let runtime_attr_source = match dom_analysis.authored_shadow_root {
4590 Some((start, end)) => &html[start..end],
4591 None => html,
4592 };
4593 let artifact_source = if input.artifact_needed {
4594 input.artifact_html.or_else(|| {
4595 Self::template_has_stripped_runtime_attrs(runtime_attr_source).then_some(html)
4596 })
4597 } else {
4598 None
4599 };
4600 let policy_wrapper = parse_component_render_policy(tag_name, html)?.is_authored();
4605 let preserve_root_bindings = self.component_processing.process_root_template_attributes;
4607 let styles_at_end = matches!(self.options.css_strategy, CssStrategy::Style)
4609 && self.component_processing.inline_styles_after_content;
4610 let ssr = self.process_component_template_with_mode(
4611 html,
4612 ComponentStyleInjection {
4613 css_snippet: None,
4614 adopted_specifier,
4615 },
4616 dom_analysis,
4617 ComponentTemplateMode {
4618 preserve_runtime_attrs: preserve_root_bindings,
4619 policy_wrapper,
4620 styles_at_end,
4621 },
4622 )?;
4623 let artifact = match artifact_source {
4624 Some(source) => {
4625 let fast_artifact = synthesize_fast_shadow
4626 .then(|| Self::append_shadow_mode_if_missing(source.to_string()));
4627 let source = fast_artifact.as_deref().unwrap_or(source);
4628 let artifact_dom_analysis = analyze_component_dom_with_diagnostic_source(
4629 tag_name,
4630 source,
4631 self.options.dom_strategy,
4632 input.authored_html,
4633 )?;
4634 Some(self.process_component_template_with_mode(
4635 source,
4636 ComponentStyleInjection {
4637 css_snippet: None,
4638 adopted_specifier,
4639 },
4640 artifact_dom_analysis,
4641 ComponentTemplateMode {
4642 preserve_runtime_attrs: true,
4645 policy_wrapper,
4646 styles_at_end,
4647 },
4648 )?)
4649 }
4650 None => None,
4651 };
4652 Ok(BuiltComponentTemplate {
4653 ssr,
4654 artifact,
4655 uses_shadow_dom: dom_analysis.uses_shadow_dom,
4656 style,
4657 })
4658 }
4659
4660 fn component_style_delivery(
4665 &self,
4666 tag_name: &str,
4667 css_content: Option<&str>,
4668 ) -> Option<OwnedComponentStyle> {
4669 let css = css_content?;
4670 Some(match self.options.css_strategy {
4671 CssStrategy::Link => {
4672 OwnedComponentStyle::Link(self.options.css_link_options.resolve(tag_name, css).href)
4673 }
4674 CssStrategy::Style => OwnedComponentStyle::Inline(css.trim().to_string()),
4675 CssStrategy::Module => OwnedComponentStyle::Adopted(tag_name.to_string()),
4676 })
4677 }
4678
4679 #[cfg(test)]
4704 fn process_component_template(
4705 &mut self,
4706 html: &str,
4707 css_snippet: Option<&str>,
4708 adopted_specifier: Option<&str>,
4709 ) -> Result<String> {
4710 let dom_analysis = analyze_component_dom("component", html, self.options.dom_strategy)?;
4711 self.process_component_template_with_mode(
4712 html,
4713 ComponentStyleInjection {
4714 css_snippet,
4715 adopted_specifier,
4716 },
4717 dom_analysis,
4718 ComponentTemplateMode {
4719 preserve_runtime_attrs: false,
4720 policy_wrapper: false,
4721 styles_at_end: false,
4722 },
4723 )
4724 }
4725
4726 fn process_component_template_with_mode(
4727 &mut self,
4728 html: &str,
4729 style: ComponentStyleInjection<'_>,
4730 dom_analysis: ComponentDomAnalysis,
4731 mode: ComponentTemplateMode,
4732 ) -> Result<String> {
4733 let trimmed_end = html.trim_end();
4734 let (trimmed, content_start) = html::leading_content(trimmed_end);
4735 let snippet = style.css_snippet.unwrap_or_default();
4736
4737 let processed = if let Some((root_start, root_end)) = dom_analysis.authored_shadow_root {
4738 let trim_start = content_start;
4739 let trim_end = html.trim_end().len();
4740 if root_start < trim_start || root_end > trim_end || root_start >= root_end {
4741 return Err(ParserError::Html(
4742 "invalid declarative Shadow DOM source range".to_string(),
4743 ));
4744 }
4745 let root = &html[root_start..root_end];
4746 let base = self.strip_template_build_attrs(root, mode.preserve_runtime_attrs);
4749 let with_adopted = Self::append_adopted_attr_if_missing(base, style.adopted_specifier);
4750 let root =
4751 Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end);
4752 let mut result =
4753 String::with_capacity(trimmed.len() + root.len() - (root_end - root_start));
4754 result.push_str(&html[trim_start..root_start]);
4755 result.push_str(&root);
4756 result.push_str(&html[root_end..trim_end]);
4757 result
4758 } else if let Some((content_start, content_end)) = dom_analysis.authored_light_root {
4759 let base = html[content_start..content_end].to_string();
4760 if snippet.is_empty() {
4761 base
4762 } else {
4763 let mut result = String::with_capacity(snippet.len() + base.len());
4764 result.push_str(snippet);
4765 result.push_str(&base);
4766 result
4767 }
4768 } else if dom_analysis.uses_shadow_dom {
4769 if mode.policy_wrapper {
4770 let base = self.strip_template_build_attrs(trimmed, mode.preserve_runtime_attrs);
4771 let with_shadow = Self::append_shadow_mode_if_missing(base);
4772 let with_adopted =
4773 Self::append_adopted_attr_if_missing(with_shadow, style.adopted_specifier);
4774 Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end)
4775 } else {
4776 let adopted = style.adopted_specifier.unwrap_or_default();
4777 let adopted_extra = if adopted.is_empty() {
4778 0
4779 } else {
4780 Self::adopted_attr_len(adopted)
4781 };
4782 let mut result =
4783 String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
4784 result.push_str("<template shadowrootmode=\"open\"");
4785 if !adopted.is_empty() {
4786 Self::push_adopted_attr(&mut result, adopted);
4787 }
4788 result.push('>');
4789 if mode.styles_at_end {
4790 result.push_str(trimmed);
4791 result.push_str(snippet);
4792 } else {
4793 result.push_str(snippet);
4794 result.push_str(trimmed);
4795 }
4796 result.push_str("</template>");
4797 result
4798 }
4799 } else {
4800 let base = if mode.policy_wrapper {
4805 Self::unwrap_component_template(trimmed)
4806 } else {
4807 trimmed.to_string()
4808 };
4809 if snippet.is_empty() {
4810 base
4811 } else {
4812 let mut result = String::with_capacity(snippet.len() + base.len());
4813 result.push_str(snippet);
4814 result.push_str(&base);
4815 result
4816 }
4817 };
4818
4819 self.strip_template_comments(processed)
4820 }
4821
4822 fn inject_css_snippet_into_template(html: String, snippet: &str, at_end: bool) -> String {
4823 if snippet.is_empty() {
4824 return html;
4825 }
4826
4827 if at_end {
4834 if let Some(close_start) = html.rfind("</template>") {
4835 let mut result = String::with_capacity(html.len() + snippet.len());
4836 result.push_str(&html[..close_start]);
4837 result.push_str(snippet);
4838 result.push_str(&html[close_start..]);
4839 return result;
4840 }
4841 }
4842
4843 match html::find_tag_close(&html) {
4844 Some(open_end) => {
4845 let mut result = String::with_capacity(html.len() + snippet.len());
4846 result.push_str(&html[..=open_end]);
4847 result.push_str(snippet);
4848 result.push_str(&html[open_end + 1..]);
4849 result
4850 }
4851 None => html,
4854 }
4855 }
4856
4857 fn append_adopted_attr_if_missing(html: String, adopted_specifier: Option<&str>) -> String {
4858 let Some(adopted) = adopted_specifier else {
4859 return html;
4860 };
4861 let Some(tag) = html::parse_tag(&html) else {
4862 return html;
4863 };
4864 if !tag.name.eq_ignore_ascii_case("template")
4865 || tag.closing
4866 || tag.attrs().any(|attr| {
4867 attr.name
4868 .eq_ignore_ascii_case(Self::ADOPTED_STYLESHEETS_ATTR)
4869 })
4870 {
4871 return html;
4872 }
4873
4874 let mut result = String::with_capacity(html.len() + Self::adopted_attr_len(adopted));
4875 result.push_str(&html[..tag.close]);
4876 Self::push_adopted_attr(&mut result, adopted);
4877 result.push_str(&html[tag.close..]);
4878 result
4879 }
4880
4881 fn append_shadow_mode_if_missing(html: String) -> String {
4882 let Some(tag) = html::parse_tag(&html) else {
4883 return html;
4884 };
4885 if !tag.name.eq_ignore_ascii_case("template")
4886 || tag.closing
4887 || tag
4888 .attrs()
4889 .any(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
4890 {
4891 return html;
4892 }
4893
4894 let mut result = String::with_capacity(html.len() + 22);
4895 result.push_str(&html[..tag.close]);
4896 result.push_str(" shadowrootmode=\"open\"");
4897 result.push_str(&html[tag.close..]);
4898 result
4899 }
4900
4901 fn unwrap_component_template(html: &str) -> String {
4902 let Some(tag) = html::parse_tag(html) else {
4903 return html.to_string();
4904 };
4905 let inner_start = tag.close + 1;
4906 let inner_end = html.rfind("</template>").unwrap_or(html.len());
4907 if inner_start >= inner_end {
4908 return String::new();
4909 }
4910 html[inner_start..inner_end].to_string()
4911 }
4912
4913 fn push_adopted_attr(out: &mut String, adopted: &str) {
4914 out.push(' ');
4915 out.push_str(Self::ADOPTED_STYLESHEETS_ATTR);
4916 out.push_str("=\"");
4917 out.push_str(adopted);
4918 out.push('"');
4919 }
4920
4921 fn adopted_attr_len(adopted: &str) -> usize {
4922 4 + Self::ADOPTED_STYLESHEETS_ATTR.len() + adopted.len()
4923 }
4924
4925 fn template_has_stripped_runtime_attrs(html: &str) -> bool {
4926 let (trimmed, _) = html::leading_content(html);
4927 let Some(tag) = html::parse_tag(trimmed) else {
4928 return false;
4929 };
4930 if !tag.name.eq_ignore_ascii_case("template") || tag.closing {
4931 return false;
4932 }
4933 tag.attrs().any(|attr| {
4934 attr.name.starts_with('@') || attr.name.starts_with(':') || attr.name.starts_with('?')
4935 })
4936 }
4937
4938 fn strip_template_comments(&mut self, html: String) -> Result<String> {
4939 let mut ranges = Vec::new();
4940 let mut style_ranges = Vec::new();
4941 Self::collect_html_comment_and_style_ranges(&html, &mut ranges, &mut style_ranges)?;
4942
4943 for (style_start, style_end) in style_ranges {
4944 let css = &html[style_start..style_end];
4945 let (_tokens, _defs, _requirements, comments) = self
4946 .css_parser
4947 .extract_tokens_definitions_requirements_and_comments(
4948 css,
4949 self.options.legal_comments,
4950 )
4951 .map_err(|e| self.css_diagnostic(e))?;
4952 for comment in comments {
4953 let comment_text = &css[comment.start_byte..comment.end_byte];
4954 if comment.preserve || self.css_signal_comment_fragment(comment_text).is_some() {
4955 continue;
4956 }
4957 ranges.push((
4958 style_start + comment.start_byte,
4959 style_start + comment.end_byte,
4960 ));
4961 }
4962 }
4963
4964 if ranges.is_empty() {
4965 return Ok(html);
4966 }
4967
4968 Ok(comment_policy::strip_ranges(&html, ranges.as_mut_slice()).into_owned())
4969 }
4970
4971 fn collect_html_comment_and_style_ranges(
4973 html: &str,
4974 comment_ranges: &mut Vec<(usize, usize)>,
4975 style_ranges: &mut Vec<(usize, usize)>,
4976 ) -> Result<()> {
4977 let mut index = 0usize;
4978 while index < html.len() {
4979 let remaining = &html[index..];
4980 if remaining.starts_with("<!--") {
4981 let Some(end) = html::find_comment_close(remaining) else {
4982 return Err(Diagnostic::error("unterminated HTML comment")
4983 .code(codes::UNTERMINATED_HTML_COMMENT)
4984 .at_offset(html, index)
4985 .help("close the comment with `-->`")
4986 .into());
4987 };
4988 comment_ranges.push((index, index + end));
4989 index += end;
4990 continue;
4991 }
4992
4993 if remaining.starts_with('<') {
4994 if let Some((style_start, style_end, close_end)) =
4995 html::style_element_bounds(remaining)
4996 {
4997 style_ranges.push((index + style_start, index + style_end));
4998 index += close_end;
4999 continue;
5000 }
5001
5002 if let Some(tag) = html::parse_tag(remaining) {
5003 index += tag.close + 1;
5004 continue;
5005 }
5006 }
5007
5008 let Some(ch) = remaining.chars().next() else {
5009 break;
5010 };
5011 index += ch.len_utf8();
5012 }
5013 Ok(())
5014 }
5015
5016 fn strip_template_build_attrs(&mut self, html: &str, preserve_runtime_attrs: bool) -> String {
5022 let Some(tag) = html::parse_tag(html) else {
5023 return html.to_string();
5024 };
5025
5026 let mut removals: Vec<(usize, usize)> = Vec::new();
5027 for attr in tag.attrs() {
5028 let runtime_attr = attr.name.starts_with('@')
5029 || attr.name.starts_with(':')
5030 || attr.name.starts_with('?');
5031 let policy_attr = matches!(
5032 attr.name,
5033 COMPONENT_RENDER_ATTR | COMPONENT_HYDRATE_ATTR | COMPONENT_RESERVE_BLOCK_SIZE_ATTR
5034 );
5035 if policy_attr || (!preserve_runtime_attrs && runtime_attr) {
5036 let mut start = attr.raw_range.start;
5037 while start > 0 && html.as_bytes()[start - 1].is_ascii_whitespace() {
5038 start -= 1;
5039 }
5040 removals.push((start, attr.raw_range.end));
5041 }
5042 }
5043
5044 if removals.is_empty() {
5045 return html.to_string();
5046 }
5047
5048 let mut result = String::with_capacity(html.len());
5050 let mut pos = 0;
5051 for (start, end) in &removals {
5052 result.push_str(&html[pos..*start]);
5053 pos = *end;
5054 }
5055 result.push_str(&html[pos..]);
5056 result
5057 }
5058}
5059
5060#[cfg(test)]
5061mod tests {
5062 use webui_test_utils::*;
5063
5064 use super::*;
5065
5066 #[derive(Default)]
5067 struct ContextArtifactPlugin {
5068 artifacts: Vec<crate::plugin::ComponentTemplateArtifact>,
5069 }
5070
5071 impl ParserPlugin for ContextArtifactPlugin {
5072 fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
5073 self.artifacts
5074 .push(crate::plugin::ComponentTemplateArtifact::template(
5075 context.component.tag_name.clone(),
5076 context.template.to_string(),
5077 context.uses_shadow_dom,
5078 ));
5079 Ok(())
5080 }
5081
5082 fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
5083 Ok(ParserPluginArtifacts::ComponentTemplates(self.artifacts))
5084 }
5085 }
5086
5087 fn structural_matcher(value: &str) -> FragmentMatcher {
5088 signal_raw(&format!("{STRUCTURAL_SIGNAL_PREFIX}{value}"))
5089 }
5090
5091 mod fast_parser;
5092
5093 fn boundary_matcher(name: &str, declaration: u32) -> FragmentMatcher {
5094 webui_test_utils::boundary(name, declaration)
5095 }
5096
5097 fn boundary_end_matcher(declaration: u32) -> FragmentMatcher {
5098 webui_test_utils::boundary_end(declaration)
5099 }
5100
5101 #[test]
5102 fn unwrapped_component_uses_configured_dom_strategy() {
5103 assert!(
5104 analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Shadow)
5105 .expect("valid component")
5106 .uses_shadow_dom
5107 );
5108 assert!(
5109 !analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Light)
5110 .expect("valid component")
5111 .uses_shadow_dom
5112 );
5113 }
5114
5115 #[test]
5116 fn bare_template_explicitly_selects_light_dom_over_shadow_fallback() {
5117 let analysis = analyze_component_dom(
5118 "x-card",
5119 "<template><div>content</div></template>",
5120 DomStrategy::Shadow,
5121 )
5122 .expect("valid explicit Light wrapper");
5123 assert!(!analysis.uses_shadow_dom);
5124 assert!(analysis.authored_light_root.is_some());
5125
5126 let attributed = analyze_component_dom(
5127 "x-card",
5128 r#"<template data-purpose="content"><div>content</div></template>"#,
5129 DomStrategy::Shadow,
5130 )
5131 .expect("ordinary template remains valid");
5132 assert!(attributed.uses_shadow_dom);
5133 assert!(attributed.authored_light_root.is_none());
5134 }
5135
5136 #[test]
5137 fn plugin_artifacts_preserve_resolved_shadow_mode() {
5138 let component_html = r#"<template shadowrootmode="open"><p>shadow</p></template>"#;
5139 {
5140 let mut parser = HtmlParser::with_plugin_options(
5141 Box::new(ContextArtifactPlugin::default()),
5142 CssStrategy::Style,
5143 );
5144 parser
5145 .component_registry_mut()
5146 .register_component(ComponentRegistration::new(
5147 "x-card",
5148 component_html,
5149 None,
5150 false,
5151 ))
5152 .expect("register component");
5153 parser
5154 .parse("index.html", "<x-card></x-card>")
5155 .expect("parse component");
5156
5157 let ParserPluginArtifacts::ComponentTemplates(artifacts) =
5158 parser.take_plugin_artifacts().expect("consume artifacts")
5159 else {
5160 panic!("expected component artifacts");
5161 };
5162 assert!(artifacts[0].uses_shadow_dom);
5163 }
5164 }
5165
5166 #[test]
5167 fn open_shadow_wrapper_marks_component_shadow() {
5168 let html =
5169 "<!-- lead --><template shadowrootmode='open'><slot></slot></template><!-- tail -->";
5170 assert!(
5171 analyze_component_dom("x-card", html, DomStrategy::Light)
5172 .expect("valid Shadow wrapper")
5173 .uses_shadow_dom
5174 );
5175 }
5176
5177 #[test]
5178 fn closed_shadow_wrapper_is_rejected() {
5179 let error = analyze_component_dom(
5180 "x-card",
5181 "<template shadowrootmode=\"closed\"><p>content</p></template>",
5182 DomStrategy::Shadow,
5183 )
5184 .expect_err("closed mode must fail");
5185 assert!(matches!(
5186 error,
5187 ParserError::Template(ref diagnostic)
5188 if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5189 && diagnostic.component_name() == Some("x-card")
5190 ));
5191 }
5192
5193 #[test]
5194 fn shadow_wrapper_must_be_the_only_top_level_element() {
5195 let error = analyze_component_dom(
5196 "x-card",
5197 "<template shadowrootmode=\"open\"><p>content</p></template><span>extra</span>",
5198 DomStrategy::Light,
5199 )
5200 .expect_err("multiple roots must fail");
5201 assert!(matches!(
5202 error,
5203 ParserError::Template(ref diagnostic)
5204 if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5205 ));
5206 }
5207
5208 #[test]
5209 fn shadowrootmode_rejects_invalid_value_and_placement() {
5210 for html in [
5211 "<template shadowrootmode=\"invalid\"><p>content</p></template>",
5212 "<template shadowrootmode=\" open \"><p>content</p></template>",
5213 "<template :shadowrootmode=\"{{mode}}\"><p>content</p></template>",
5214 "<template shadowrootmode=\"open\" />",
5215 "<div><template shadowrootmode=\"open\"><p>content</p></template></div>",
5216 "<div shadowrootmode=\"open\">content</div>",
5217 ] {
5218 let error = analyze_component_dom("x-card", html, DomStrategy::Shadow)
5219 .expect_err("invalid shadowrootmode must fail");
5220 assert!(matches!(
5221 error,
5222 ParserError::Template(ref diagnostic)
5223 if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5224 && diagnostic.component_name() == Some("x-card")
5225 && diagnostic.help_text().is_some()
5226 && diagnostic.snippet_text().is_some()
5227 ));
5228 }
5229 }
5230
5231 #[test]
5232 fn light_component_slot_is_rejected() {
5233 let source = "<div><slot name=\"label\"></slot></div>";
5234 let error = analyze_component_dom("x-card", source, DomStrategy::Light)
5235 .expect_err("Light slot must fail");
5236 assert!(matches!(
5237 error,
5238 ParserError::Template(ref diagnostic)
5239 if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
5240 && diagnostic.help_text().is_some_and(|help| help.contains("shadowrootmode"))
5241 ));
5242 assert!(
5243 analyze_component_dom("x-card", source, DomStrategy::Shadow)
5244 .expect("implicit Shadow components support slots")
5245 .uses_shadow_dom
5246 );
5247
5248 let explicit_light = "<template><slot name=\"label\"></slot></template>";
5249 let error = analyze_component_dom("x-card", explicit_light, DomStrategy::Shadow)
5250 .expect_err("bare template explicitly selects Light");
5251 assert!(matches!(
5252 error,
5253 ParserError::Template(ref diagnostic)
5254 if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
5255 ));
5256 }
5257
5258 #[test]
5259 fn webui_plugin_records_authored_shadow_wrapper() {
5260 let mut parser =
5261 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5262 parser
5263 .component_registry_mut()
5264 .register_component(ComponentRegistration::new(
5265 "x-card",
5266 "<!-- lead --><template shadowrootmode=\"open\" @click=\"{onClick()}\"><slot></slot></template><!-- tail -->",
5267 None,
5268 true,
5269 ))
5270 .expect("register component");
5271 parser
5272 .parse("index.html", "<x-card>projected</x-card>")
5273 .expect("parse component");
5274
5275 let ParserPluginArtifacts::ComponentTemplates(templates) =
5276 parser.take_plugin_artifacts().expect("plugin artifacts")
5277 else {
5278 panic!("expected component templates");
5279 };
5280 assert!(templates[0].uses_shadow_dom);
5281 assert!(templates[0].template_json.contains("\"sd\":1"));
5282 assert!(templates[0]
5283 .template_json
5284 .contains(r#""re":[["click","onClick",[]]]"#));
5285 }
5286
5287 struct StyleContextPlugin {
5290 captured: std::rc::Rc<std::cell::RefCell<Vec<(bool, Option<String>, String)>>>,
5291 }
5292
5293 impl crate::plugin::ParserPlugin for StyleContextPlugin {
5294 fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
5295 self.captured.borrow_mut().push((
5296 context.uses_shadow_dom,
5297 context.style.map(|style| format!("{style:?}")),
5298 context.template.to_string(),
5299 ));
5300 Ok(())
5301 }
5302 }
5303
5304 fn capture_style_context(
5305 strategy: CssStrategy,
5306 dom_strategy: DomStrategy,
5307 template: &str,
5308 css: Option<&str>,
5309 ) -> (bool, Option<String>, String) {
5310 let captured = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
5311 let mut parser = HtmlParser::with_plugin_options(
5312 Box::new(StyleContextPlugin {
5313 captured: std::rc::Rc::clone(&captured),
5314 }),
5315 ParserOptions {
5316 css_strategy: strategy,
5317 dom_strategy,
5318 ..ParserOptions::default()
5319 },
5320 );
5321 parser
5322 .component_registry_mut()
5323 .register_component(ComponentRegistration::new("x-card", template, css, true))
5324 .expect("register component");
5325 parser
5326 .parse("index.html", "<x-card></x-card>")
5327 .expect("parse component");
5328 let captured = captured.borrow();
5329 captured.first().cloned().expect("component registered")
5330 }
5331
5332 #[test]
5336 fn parser_reports_shadow_style_delivery_without_injecting_it() {
5337 for (strategy, expected) in [
5338 (CssStrategy::Link, "Link { href: \"x-card.css\" }"),
5339 (CssStrategy::Style, "Inline { css: \".card{color:red}\" }"),
5340 (CssStrategy::Module, "Adopted { specifier: \"x-card\" }"),
5341 ] {
5342 let (uses_shadow_dom, style, template) = capture_style_context(
5343 strategy,
5344 DomStrategy::Shadow,
5345 "<template shadowrootmode=\"open\" shadowrootadoptedstylesheets=\"x-card\"><div class=\"card\"></div></template>",
5346 Some(".card{color:red}"),
5347 );
5348 assert!(uses_shadow_dom, "{strategy:?} should be shadow");
5349 assert_eq!(style.as_deref(), Some(expected), "{strategy:?} delivery");
5350 assert!(
5351 !template.contains("<style>") && !template.contains("<link"),
5352 "{strategy:?} template must stay injection-free: {template}"
5353 );
5354 }
5355 }
5356
5357 #[test]
5360 fn parser_reports_no_style_delivery_for_light_components() {
5361 let (uses_shadow_dom, style, _) = capture_style_context(
5362 CssStrategy::Style,
5363 DomStrategy::Light,
5364 "<div class=\"card\"></div>",
5365 Some(".card{color:red}"),
5366 );
5367 assert!(!uses_shadow_dom);
5368 assert_eq!(style, None);
5369 }
5370
5371 #[test]
5373 fn parser_reports_no_style_delivery_without_css() {
5374 let (uses_shadow_dom, style, _) = capture_style_context(
5375 CssStrategy::Style,
5376 DomStrategy::Shadow,
5377 "<template shadowrootmode=\"open\"><div></div></template>",
5378 None,
5379 );
5380 assert!(uses_shadow_dom);
5381 assert_eq!(style, None);
5382 }
5383
5384 #[test]
5385 fn test_plugin_display_names() {
5386 assert_eq!(Plugin::Fast.to_string(), "fast");
5387 assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
5388 assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
5389 assert_eq!(Plugin::WebUI.to_string(), "webui");
5390 }
5391
5392 #[test]
5393 fn test_plugin_from_str_names() {
5394 assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
5395 assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
5396 assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
5397 assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
5398 }
5399
5400 #[test]
5401 fn test_plugin_from_str_unknown_mentions_supported_names() {
5402 let err = "legacy".parse::<Plugin>().unwrap_err();
5403 assert!(
5404 err.contains("fast-v3"),
5405 "error should mention fast-v3: {err}"
5406 );
5407 assert!(
5408 err.contains("fast-v2"),
5409 "error should mention fast-v2: {err}"
5410 );
5411 assert!(err.contains("fast"), "error should mention fast: {err}");
5412 }
5413
5414 #[test]
5415 fn test_parse_signal() {
5416 let mut parser = HtmlParser::new();
5417 let html = "Hello, {{name}}!";
5418 let result = parser.parse("test.html", html);
5419
5420 assert!(result.is_ok());
5421 let fragment_records = parser.into_fragment_records();
5422
5423 assert_stream!(
5424 fragment_records,
5425 "test.html",
5426 [raw("Hello, "), signal("name"), raw("!"),]
5427 );
5428 }
5429
5430 #[test]
5431 fn test_parse_raw_signal() {
5432 let mut parser = HtmlParser::new();
5433 let html = "Hello, {{{html_content}}}!";
5434 let result = parser.parse("test.html", html);
5435
5436 assert!(result.is_ok());
5437 let fragment_records = parser.into_fragment_records();
5438
5439 assert_stream!(
5440 fragment_records,
5441 "test.html",
5442 [raw("Hello, "), signal_raw("html_content"), raw("!"),]
5443 );
5444 }
5445
5446 #[test]
5447 fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
5448 let mut parser = HtmlParser::new();
5449 let html = "<nav>{{sectionName}} > {{topicName}}</nav>";
5450 let result = parser.parse("test.html", html);
5451
5452 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5453 let fragment_records = parser.into_fragment_records();
5454
5455 assert_stream!(
5456 fragment_records,
5457 "test.html",
5458 [
5459 raw("<nav>"),
5460 signal("sectionName"),
5461 raw(" > "),
5462 signal("topicName"),
5463 raw("</nav>"),
5464 ]
5465 );
5466 }
5467
5468 #[test]
5469 fn test_invalid_for_each_is_template_diagnostic() {
5470 let mut parser = HtmlParser::new();
5471 let err = parser
5472 .parse(
5473 "index.html",
5474 "<div>\n <for each=\"person inpeople\"><p>x</p></for>\n</div>",
5475 )
5476 .expect_err("invalid for-each must error");
5477 assert!(matches!(err, ParserError::Template(_)));
5478 let msg = err.to_string();
5479 assert!(msg.contains("invalid <for> each expression"), "{msg}");
5480 assert!(msg.contains(r#"each="person inpeople""#), "{msg}");
5481 assert!(
5484 msg.contains("--> index.html:2:3"),
5485 "missing line:column — {msg}"
5486 );
5487 assert!(msg.contains("help:"), "{msg}");
5488 }
5489
5490 #[test]
5491 fn test_invalid_if_condition_is_template_diagnostic() {
5492 let mut parser = HtmlParser::new();
5493 let err = parser
5494 .parse("index.html", r#"<if condition="a +"><p>x</p></if>"#)
5495 .expect_err("invalid condition must error");
5496 assert!(matches!(err, ParserError::Template(_)));
5497 let msg = err.to_string();
5498 assert!(msg.contains("invalid <if> condition expression"), "{msg}");
5499 assert!(
5500 msg.contains("--> index.html:1:1"),
5501 "missing line:column — {msg}"
5502 );
5503 }
5504
5505 #[test]
5506 fn test_parse_for_directive() {
5507 let mut parser = HtmlParser::new();
5508 let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
5509
5510 let result = parser.parse("test.html", html);
5511 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5512 let fragment_records = parser.into_fragment_records();
5513
5514 assert_stream!(
5515 fragment_records,
5516 "test.html",
5517 [for_loop("item", "items", "for-1"),]
5518 );
5519
5520 assert_stream!(
5522 fragment_records,
5523 "for-1",
5524 [
5525 raw("<div class=\"item\">"),
5526 signal("item.name"),
5527 raw("</div>"),
5528 ]
5529 );
5530 }
5531
5532 #[test]
5533 fn test_parse_if_directive() {
5534 let mut parser = HtmlParser::new();
5535 let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
5536
5537 let result = parser.parse("test.html", html);
5538 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5539 let fragment_records = parser.into_fragment_records();
5540
5541 assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
5542
5543 assert_stream!(
5545 fragment_records,
5546 "if-1",
5547 [
5548 raw("<div>Welcome back, "),
5549 signal("username"),
5550 raw("!</div>"),
5551 ]
5552 );
5553 }
5554
5555 #[test]
5556 fn test_component_directive() {
5557 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5558 parser
5559 .component_registry
5560 .register_component(ComponentRegistration::new(
5561 "my-component",
5562 "<div>My Component</div>",
5563 Some("div { color: blue; }"),
5564 true,
5565 ))
5566 .expect("Failed to register component");
5567
5568 let result = parser.parse("test.html", "<my-component></my-component>");
5569 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5570 let records = parser.into_fragment_records();
5571
5572 assert_stream!(
5573 records,
5574 "test.html",
5575 [
5576 raw("<my-component"),
5577 structural_matcher("streaming_root:my-component"),
5578 raw(">"),
5579 component("my-component"),
5580 raw("</my-component>"),
5581 ]
5582 );
5583
5584 let comp = &records["my-component"].fragments;
5587 assert_eq!(comp.len(), 1);
5588 assert!(
5589 matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5590 !raw.value.contains("<template shadowrootmode")
5591 && raw.value.contains("<div")
5592 && raw.value.contains(">My Component</div>"))
5593 );
5594 }
5595
5596 #[test]
5597 fn shadow_component_emits_style_hook_inside_declarative_root() {
5598 let mut parser = HtmlParser::new();
5599 parser
5600 .component_registry
5601 .register_component(ComponentRegistration::new(
5602 "my-component",
5603 r#"<template shadowrootmode="open"><div>My Component</div></template>"#,
5604 Some("div { color: blue; }"),
5605 true,
5606 ))
5607 .expect("register");
5608
5609 parser
5610 .parse("test.html", "<my-component></my-component>")
5611 .expect("parse");
5612 let records = parser.into_fragment_records();
5613 assert_stream!(
5614 records,
5615 "my-component",
5616 [
5617 raw("<template shadowrootmode=\"open\">"),
5618 structural_matcher("shadow_styles:my-component"),
5619 raw("<div>My Component</div></template>"),
5620 ]
5621 );
5622 assert!(
5623 records["my-component"]
5624 .fragments
5625 .iter()
5626 .all(|fragment| !matches!(
5627 fragment.fragment.as_ref(),
5628 Some(Fragment::Raw(raw)) if raw.value.contains("color: blue")
5629 )),
5630 "component CSS is delivered by the handler closure"
5631 );
5632 }
5633
5634 #[test]
5635 fn authored_shadow_attribute_name_is_ascii_case_insensitive_for_style_hook() {
5636 let mut parser = HtmlParser::new();
5637 parser
5638 .component_registry
5639 .register_component(ComponentRegistration::new(
5640 "my-component",
5641 r#"<template shadowRootMode="open"><slot></slot></template>"#,
5642 None,
5643 true,
5644 ))
5645 .expect("register");
5646
5647 parser
5648 .parse("test.html", "<my-component></my-component>")
5649 .expect("parse");
5650 let records = parser.into_fragment_records();
5651 assert_stream!(
5652 records,
5653 "my-component",
5654 [
5655 raw(r#"<template shadowRootMode="open">"#),
5656 structural_matcher("shadow_styles:my-component"),
5657 raw("<slot></slot></template>"),
5658 ]
5659 );
5660 }
5661
5662 #[test]
5663 fn streaming_root_signal_emitted_before_component_opening_tag_closes() {
5664 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5669 parser
5670 .component_registry
5671 .register_component(ComponentRegistration::new(
5672 "my-widget",
5673 "<div>w</div>",
5674 None,
5675 true,
5676 ))
5677 .expect("register");
5678
5679 let result = parser.parse("test.html", "<my-widget></my-widget>");
5680 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5681 let records = parser.into_fragment_records();
5682
5683 assert_stream!(
5684 records,
5685 "test.html",
5686 [
5687 raw("<my-widget"),
5688 structural_matcher("streaming_root:my-widget"),
5689 raw(">"),
5690 component("my-widget"),
5691 raw("</my-widget>"),
5692 ]
5693 );
5694 }
5695
5696 #[test]
5697 fn streaming_root_signal_emitted_per_component_when_nested_and_repeated() {
5698 use webui_protocol::web_ui_fragment::Fragment;
5701 let mut parser = HtmlParser::new();
5702 for name in ["outer-box", "inner-pill"] {
5703 parser
5704 .component_registry
5705 .register_component(ComponentRegistration::new(
5706 name,
5707 "<div>content</div>",
5708 None,
5709 true,
5710 ))
5711 .expect("register");
5712 }
5713
5714 let result = parser.parse(
5715 "test.html",
5716 "<outer-box><inner-pill></inner-pill></outer-box><inner-pill></inner-pill>",
5717 );
5718 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5719 let records = parser.into_fragment_records();
5720 let fragments = &records["test.html"].fragments;
5721
5722 let signals: Vec<&str> = fragments
5723 .iter()
5724 .filter_map(|f| match f.fragment.as_ref() {
5725 Some(Fragment::Signal(s)) if s.raw => Some(s.value.as_str()),
5726 _ => None,
5727 })
5728 .collect();
5729 assert_eq!(
5730 signals,
5731 [
5732 "}}}webui:streaming_root:outer-box",
5733 "}}}webui:streaming_root:inner-pill",
5734 "}}}webui:streaming_root:inner-pill",
5735 ]
5736 );
5737
5738 for (i, frag) in fragments.iter().enumerate() {
5741 let Some(Fragment::Signal(s)) = frag.fragment.as_ref() else {
5742 continue;
5743 };
5744 if !s.raw || !s.value.starts_with("}}}webui:streaming_root:") {
5745 continue;
5746 }
5747 match fragments[i + 1].fragment.as_ref() {
5748 Some(Fragment::Raw(r)) => assert_eq!(r.value, ">"),
5749 other => panic!("expected raw(\">\") after signal, got {other:?}"),
5750 }
5751 }
5752 }
5753
5754 #[test]
5755 fn unknown_component_typo_in_same_namespace_errors_with_suggestion() {
5756 let mut parser = HtmlParser::new();
5757 parser
5758 .component_registry
5759 .register_component(ComponentRegistration::new(
5760 "mp-button",
5761 "<button>b</button>",
5762 None,
5763 true,
5764 ))
5765 .expect("Failed to register component");
5766
5767 let err = parser
5769 .parse("test.html", "<mp-buton></mp-buton>")
5770 .expect_err("a near-typo component tag should error");
5771 let ParserError::Template(diag) = err else {
5772 panic!("expected ParserError::Template, got {err:?}");
5773 };
5774 assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
5775 let help = diag
5776 .help_text()
5777 .expect("diagnostic should carry a help line");
5778 assert!(
5779 help.contains("did you mean <mp-button>?"),
5780 "expected a suggestion, got: {help}"
5781 );
5782 }
5783
5784 #[test]
5785 fn external_custom_element_in_other_namespace_passes_through() {
5786 let mut parser = HtmlParser::new();
5787 parser
5788 .component_registry
5789 .register_component(ComponentRegistration::new(
5790 "mp-button",
5791 "<button>b</button>",
5792 None,
5793 true,
5794 ))
5795 .expect("Failed to register component");
5796
5797 let result = parser.parse("test.html", "<md-button></md-button>");
5800 assert!(
5801 result.is_ok(),
5802 "external custom element should pass through, got: {:?}",
5803 result.err()
5804 );
5805 }
5806
5807 #[test]
5808 fn for_missing_each_suggests_typoed_attribute() {
5809 let mut parser = HtmlParser::new();
5810 let err = parser
5812 .parse("test.html", "<for eahc=\"todo in todos\"></for>")
5813 .expect_err("a <for> without `each` should error");
5814 let ParserError::Template(diag) = err else {
5815 panic!("expected ParserError::Template, got {err:?}");
5816 };
5817 assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
5818 let help = diag
5819 .help_text()
5820 .expect("diagnostic should carry a help line");
5821 assert!(
5822 help.contains("found `eahc`") && help.contains("did you mean"),
5823 "expected an attribute-typo suggestion, got: {help}"
5824 );
5825 }
5826
5827 #[test]
5828 fn parent_diagnostic_owner_survives_nested_component_parse() {
5829 let mut parser = HtmlParser::new();
5834 parser
5835 .component_registry
5836 .register_component(ComponentRegistration::new(
5837 "my-card",
5838 "<div>card</div>",
5839 None,
5840 true,
5841 ))
5842 .expect("register");
5843
5844 let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
5846 let err = parser
5847 .parse("index.html", html)
5848 .expect_err("the malformed <for> should error");
5849 let ParserError::Template(diag) = err else {
5850 panic!("expected ParserError::Template, got {err:?}");
5851 };
5852 assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
5853 assert_eq!(
5854 diag.component_name(),
5855 Some("index.html"),
5856 "parent diagnostic must be owned by index.html, not the nested component"
5857 );
5858 }
5859
5860 #[test]
5861 fn test_component_directive_with_slots() {
5862 let mut parser = HtmlParser::new();
5863 parser
5864 .component_registry
5865 .register_component(ComponentRegistration::new(
5866 "my-component",
5867 "<div>My Component</div>",
5868 Some("div { color: blue; }"),
5869 true,
5870 ))
5871 .expect("Failed to register component");
5872
5873 let result = parser.parse(
5874 "test.html",
5875 "Hello<my-component><p>World</p></my-component>",
5876 );
5877 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5878 let records = parser.into_fragment_records();
5879 let fragments = &records["test.html"].fragments;
5880
5881 assert!(fragments.len() >= 3);
5883 assert!(
5886 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component"))
5887 );
5888 assert!(fragments.iter().any(|f| matches!(
5890 f.fragment.as_ref(),
5891 Some(Fragment::Component(c)) if c.fragment_id == "my-component"
5892 )));
5893 let last = fragments.last().unwrap();
5895 assert!(
5896 matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
5897 );
5898 }
5899
5900 #[test]
5901 fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
5902 let mut parser = HtmlParser::new();
5907
5908 parser
5909 .component_registry
5910 .register_component(ComponentRegistration::new(
5911 "app-shell",
5912 r#"<template shadowrootmode="open">
5913 <my-navbar></my-navbar>
5914 <main><outlet /></main>
5915 <cart-panel></cart-panel>
5916 <my-footer></my-footer>
5917 </template>"#,
5918 Some(":host{display:flex}"),
5919 true,
5920 ))
5921 .expect("register app-shell");
5922 parser
5923 .component_registry
5924 .register_component(ComponentRegistration::new(
5925 "my-navbar",
5926 "<nav>Nav</nav>",
5927 Some("nav{color:red}"),
5928 true,
5929 ))
5930 .expect("register my-navbar");
5931 parser
5932 .component_registry
5933 .register_component(ComponentRegistration::new(
5934 "cart-panel",
5935 "<aside>Cart</aside>",
5936 Some("aside{color:green}"),
5937 true,
5938 ))
5939 .expect("register cart-panel");
5940 parser
5941 .component_registry
5942 .register_component(ComponentRegistration::new(
5943 "my-footer",
5944 "<footer>Footer</footer>",
5945 Some("footer{color:blue}"),
5946 true,
5947 ))
5948 .expect("register my-footer");
5949
5950 parser
5951 .parse("index.html", "<app-shell></app-shell>")
5952 .expect("parse index.html");
5953 let records = parser.into_fragment_records();
5954
5955 let app_frags = &records["app-shell"].fragments;
5957 let component_ids: Vec<&str> = app_frags
5958 .iter()
5959 .filter_map(|f| match f.fragment.as_ref() {
5960 Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
5961 _ => None,
5962 })
5963 .collect();
5964
5965 assert!(
5966 component_ids.contains(&"my-navbar"),
5967 "app-shell fragments should contain my-navbar: {component_ids:?}"
5968 );
5969 assert!(
5970 component_ids.contains(&"cart-panel"),
5971 "app-shell fragments should contain cart-panel: {component_ids:?}"
5972 );
5973 assert!(
5974 component_ids.contains(&"my-footer"),
5975 "app-shell fragments should contain my-footer: {component_ids:?}"
5976 );
5977 }
5978
5979 #[test]
5982 fn test_component_no_double_wrap_template() {
5983 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5987 parser
5988 .component_registry
5989 .register_component(ComponentRegistration::new(
5990 "custom-element",
5991 r#"<template foo="bar"><div>content</div></template>"#,
5992 None,
5993 true,
5994 ))
5995 .expect("register");
5996 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
5997 assert!(result.is_ok());
5998 let records = parser.into_fragment_records();
5999
6000 assert_fragments!(
6001 records["index.html"].fragments,
6002 [
6003 raw("<custom-element"),
6004 structural_matcher("streaming_root:custom-element"),
6005 raw(">"),
6006 component("custom-element"),
6007 raw("Hello</custom-element>"),
6008 ]
6009 );
6010
6011 assert_stream!(
6012 records,
6013 "custom-element",
6014 [raw(r#"<template foo="bar"><div>content</div></template>"#),]
6015 );
6016 }
6017
6018 #[test]
6019 fn test_component_styled_no_double_wrap() {
6020 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6024 parser
6025 .component_registry
6026 .register_component(ComponentRegistration::new(
6027 "custom-element",
6028 r#"<template foo="bar"><div>content</div></template>"#,
6029 Some("div { color: red; }"),
6030 true,
6031 ))
6032 .expect("register");
6033 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6034 assert!(result.is_ok());
6035 let records = parser.into_fragment_records();
6036
6037 assert_stream!(
6038 records,
6039 "custom-element",
6040 [raw(r#"<template foo="bar"><div>content</div></template>"#),]
6041 );
6042 }
6043
6044 #[test]
6045 fn test_component_strip_runtime_attrs() {
6046 let mut parser = HtmlParser::new();
6050 parser
6051 .component_registry
6052 .register_component(ComponentRegistration::new(
6053 "custom-element",
6054 r#"<template shadowrootmode="open" @click={foo} :bar="baz" ?bool="true"><div>content</div></template>"#,
6055 None,
6056 true,
6057 ))
6058 .expect("register");
6059 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6060 assert!(result.is_ok());
6061 let records = parser.into_fragment_records();
6062
6063 assert_stream!(
6064 records,
6065 "custom-element",
6066 [
6067 raw(r#"<template shadowrootmode="open">"#),
6068 structural_matcher("shadow_styles:custom-element"),
6069 raw("<div>content</div></template>"),
6070 ]
6071 );
6072 }
6073
6074 #[test]
6075 fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
6076 let mut parser = HtmlParser::new();
6077 parser
6078 .component_registry
6079 .register_component(ComponentRegistration::new(
6080 "custom-element",
6081 r#"<template shadowrootmode="open" data-note="@click={foo}" @click={foo}><div>content</div></template>"#,
6082 None,
6083 true,
6084 ))
6085 .expect("register");
6086 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6087 assert!(result.is_ok());
6088 let records = parser.into_fragment_records();
6089
6090 assert_stream!(
6091 records,
6092 "custom-element",
6093 [
6094 raw(r#"<template shadowrootmode="open" data-note="@click={foo}">"#),
6095 structural_matcher("shadow_styles:custom-element"),
6096 raw("<div>content</div></template>"),
6097 ]
6098 );
6099 }
6100
6101 #[test]
6102 fn test_component_strip_runtime_attrs_handles_duplicate_runtime_attrs() {
6103 let mut parser = HtmlParser::new();
6104 parser
6105 .component_registry
6106 .register_component(ComponentRegistration::new(
6107 "custom-element",
6108 r#"<template shadowrootmode="open" @click={foo} @click={bar}><div>content</div></template>"#,
6109 None,
6110 true,
6111 ))
6112 .expect("register");
6113 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6114 assert!(result.is_ok());
6115 let records = parser.into_fragment_records();
6116
6117 assert_stream!(
6118 records,
6119 "custom-element",
6120 [
6121 raw(r#"<template shadowrootmode="open">"#),
6122 structural_matcher("shadow_styles:custom-element"),
6123 raw("<div>content</div></template>"),
6124 ]
6125 );
6126 }
6127
6128 #[test]
6129 fn test_component_with_slots_and_attrs() {
6130 let mut parser = HtmlParser::new();
6131 parser
6132 .component_registry
6133 .register_component(ComponentRegistration::new(
6134 "custom-element",
6135 r#"<template shadowrootmode="open"><slot></slot></template>"#,
6136 None,
6137 true,
6138 ))
6139 .expect("register");
6140 let result = parser.parse(
6141 "index.html",
6142 r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
6143 );
6144 assert!(result.is_ok());
6145 let records = parser.into_fragment_records();
6146 assert_fragments!(
6147 records["index.html"].fragments,
6148 [
6149 raw("<custom-element"),
6150 attr_raw_start("appearance", "subtle"),
6151 structural_matcher("streaming_root:custom-element"),
6152 raw(">"),
6153 component("custom-element"),
6154 raw("Hello World</custom-element>"),
6155 ]
6156 );
6157 }
6158
6159 #[test]
6160 fn test_component_legacy_no_styles() {
6161 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6162 parser
6163 .component_registry
6164 .register_component(ComponentRegistration::new(
6165 "custom-element",
6166 "<div>Custom Element</div>",
6167 None,
6168 true,
6169 ))
6170 .expect("register");
6171 let result = parser.parse("index.html", "<custom-element></custom-element>");
6172 assert!(result.is_ok());
6173 let records = parser.into_fragment_records();
6174
6175 assert_fragments!(
6176 records["index.html"].fragments,
6177 [
6178 raw("<custom-element"),
6179 structural_matcher("streaming_root:custom-element"),
6180 raw(">"),
6181 component("custom-element"),
6182 raw("</custom-element>"),
6183 ]
6184 );
6185
6186 assert_stream!(
6187 records,
6188 "custom-element",
6189 [raw("<div>Custom Element</div>"),]
6190 );
6191 }
6192
6193 #[test]
6194 fn test_component_self_closing() {
6195 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6196 parser
6197 .component_registry
6198 .register_component(ComponentRegistration::new(
6199 "custom-widget",
6200 "<div>Widget Content</div>",
6201 None,
6202 true,
6203 ))
6204 .expect("register");
6205 let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
6206 assert!(result.is_ok());
6207 let records = parser.into_fragment_records();
6208
6209 assert_fragments!(
6210 records["index.html"].fragments,
6211 [
6212 raw("<custom-widget"),
6213 attr_start("config", "settings"),
6214 structural_matcher("streaming_root:custom-widget"),
6215 raw("/>"),
6216 component("custom-widget"),
6217 ]
6218 );
6219 }
6220
6221 #[test]
6222 fn test_component_nested_self_closing_in_slot() {
6223 let mut parser = HtmlParser::new();
6224 parser
6225 .component_registry
6226 .register_component(ComponentRegistration::new(
6227 "custom-icon",
6228 "<template shadowrootmode=\"open\"><svg><slot></slot></svg></template>",
6229 None,
6230 true,
6231 ))
6232 .expect("register");
6233 let result = parser.parse(
6234 "index.html",
6235 r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
6236 );
6237 assert!(result.is_ok());
6238 let records = parser.into_fragment_records();
6239
6240 assert_fragments!(
6241 records["index.html"].fragments,
6242 [
6243 raw("<custom-icon"),
6244 structural_matcher("streaming_root:custom-icon"),
6245 raw(">"),
6246 component("custom-icon"),
6247 raw("<use"),
6248 attr_template("href", "attr-1"),
6249 raw("/></custom-icon>"),
6250 ]
6251 );
6252
6253 assert_stream!(
6254 records,
6255 "custom-icon",
6256 [
6257 raw("<template shadowrootmode=\"open\">"),
6258 structural_matcher("shadow_styles:custom-icon"),
6259 raw("<svg><slot></slot></svg></template>"),
6260 ]
6261 );
6262 }
6263
6264 #[test]
6265 fn test_component_leading_boolean_attr_start() {
6266 let mut parser = HtmlParser::new();
6267 parser
6268 .component_registry
6269 .register_component(ComponentRegistration::new(
6270 "custom-element",
6271 r#"<template shadowrootmode="open"><slot></slot></template>"#,
6272 None,
6273 true,
6274 ))
6275 .expect("register");
6276 let result = parser.parse(
6277 "index.html",
6278 r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
6279 );
6280 assert!(result.is_ok());
6281 let records = parser.into_fragment_records();
6282
6283 assert_fragments!(
6284 records["index.html"].fragments,
6285 [
6286 raw("<custom-element"),
6287 bool_attr_start("disabled", "isDisabled"),
6289 attr_raw("title", "Hello"),
6291 structural_matcher("streaming_root:custom-element"),
6292 raw(">"),
6293 component("custom-element"),
6294 raw("</custom-element>"),
6295 ]
6296 );
6297 }
6298
6299 #[test]
6300 fn test_component_boolean_predicate_preserves_condition_tree() {
6301 let mut parser = HtmlParser::new();
6302 parser
6303 .component_registry
6304 .register_component(ComponentRegistration::new(
6305 "custom-element",
6306 r#"<template shadowrootmode="open"><slot></slot></template>"#,
6307 None,
6308 true,
6309 ))
6310 .expect("register");
6311 let result = parser.parse(
6312 "index.html",
6313 r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
6314 );
6315 assert!(result.is_ok());
6316
6317 let records = parser.into_fragment_records();
6318 let fragments = &records["index.html"].fragments;
6319 match fragments
6320 .get(1)
6321 .and_then(|fragment| fragment.fragment.as_ref())
6322 {
6323 Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
6324 assert_eq!(attr.name, "disabled");
6325 assert!(attr.attr_start);
6326 match attr
6327 .condition_tree
6328 .as_ref()
6329 .and_then(|condition| condition.expr.as_ref())
6330 {
6331 Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
6332 assert_eq!(pred.left, "page");
6333 assert_eq!(pred.operator, 3);
6334 assert_eq!(pred.right, "'dashboard'");
6335 }
6336 other => panic!("expected predicate condition tree, got {:?}", other),
6337 }
6338 }
6339 other => panic!("expected attribute fragment, got {:?}", other),
6340 }
6341 }
6342
6343 #[test]
6344 fn test_component_meta_link_tags() {
6345 let (fragments, _) = parse_and_get_fragments(
6346 r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
6347 );
6348 assert!(fragments.len() >= 7);
6349 assert!(
6350 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "<head>")
6351 );
6352 assert!(
6353 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
6354 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
6355 );
6356 assert!(
6357 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<meta charset=\"utf-8\"") && raw.value.contains("<link"))
6358 );
6359 assert!(
6360 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
6361 );
6362 assert!(
6363 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
6364 );
6365 assert!(
6366 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
6367 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
6368 );
6369 assert!(
6370 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
6371 );
6372 }
6373
6374 #[test]
6375 fn test_nested_directives() {
6376 let mut parser = HtmlParser::new();
6377 let html = r#"<for each="category in categories">
6378 <if condition="category.hasItems">
6379 <for each="item in category.items">
6380 {{item.title}}
6381 </for>
6382 </if>
6383 </for>"#;
6384
6385 let result = parser.parse("test.html", html);
6386
6387 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6388 let fragment_records = parser.into_fragment_records();
6389
6390 assert_stream!(
6391 fragment_records,
6392 "test.html",
6393 [for_loop("category", "categories", "for-1"),]
6394 );
6395
6396 assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
6397
6398 assert_stream!(
6399 fragment_records,
6400 "if-1",
6401 [for_loop("item", "category.items", "for-2"),]
6402 );
6403
6404 assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
6405 }
6406
6407 #[test]
6408 fn test_complex_directives() {
6409 let mut parser = HtmlParser::new();
6410 let html = r#"<for each="category in categories">
6411 <div class="category">
6412 <h2>{{category.name}}</h2>
6413 <if condition="category.hasItems">
6414 <ul>
6415 <for each="item in category.items">
6416 <li>{{item.title}}</li>
6417 </for>
6418 </ul>
6419 </if>
6420 </div>
6421 </for>"#;
6422
6423 let result = parser.parse("test.html", html);
6424
6425 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6426 let fragment_records = parser.into_fragment_records();
6427
6428 assert_stream!(
6429 fragment_records,
6430 "test.html",
6431 [for_loop("category", "categories", "for-1"),]
6432 );
6433
6434 assert_stream!(
6436 fragment_records,
6437 "for-1",
6438 [
6439 raw("<div class=\"category\"><h2>"),
6440 signal("category.name"),
6441 raw("</h2>"),
6442 if_cond("if-1"),
6443 raw("</div>"),
6444 ]
6445 );
6446
6447 assert_stream!(
6449 fragment_records,
6450 "if-1",
6451 [
6452 raw("<ul>"),
6453 for_loop("item", "category.items", "for-2"),
6454 raw("</ul>"),
6455 ]
6456 );
6457
6458 assert_stream!(
6460 fragment_records,
6461 "for-2",
6462 [raw("<li>"), signal("item.title"), raw("</li>"),]
6463 );
6464 }
6465
6466 fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
6470 let mut parser = HtmlParser::new();
6471 let result = parser.parse("index.html", html);
6472 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6473 let records = parser.into_fragment_records();
6474 let fragments = records
6475 .get("index.html")
6476 .expect("Failed to get index.html fragment")
6477 .fragments
6478 .clone();
6479 (fragments, records)
6480 }
6481
6482 fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
6484 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6485 parser
6486 .component_registry
6487 .register_component(ComponentRegistration::new(tag, "<div></div>", None, true))
6488 .expect("register");
6489 let result = parser.parse("index.html", html);
6490 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6491 let records = parser.into_fragment_records();
6492 let fragments = records
6493 .get("index.html")
6494 .expect("Failed to get index.html fragment")
6495 .fragments
6496 .clone();
6497 (fragments, records)
6498 }
6499
6500 #[test]
6501 fn test_attribute_handlebars_in_href() {
6502 let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
6504 assert_fragments!(
6505 fragments,
6506 [
6507 raw("<a"),
6508 attr("href", "url"),
6509 raw(">"),
6510 signal("name"),
6511 raw("</a>"),
6512 ]
6513 );
6514 }
6515
6516 #[test]
6517 fn test_attribute_boolean_with_handlebars() {
6518 let (fragments, _) =
6520 parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
6521 assert_fragments!(
6522 fragments,
6523 [
6524 raw("<button"),
6525 bool_attr("disabled", "isDisabled"),
6526 raw(">Click</button>"),
6527 ]
6528 );
6529 }
6530
6531 #[test]
6532 fn test_attribute_multiple_boolean() {
6533 let (fragments, _) =
6536 parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
6537
6538 assert_fragments!(
6539 fragments,
6540 [
6541 raw("<input"),
6542 bool_attr("checked", "isChecked"),
6543 bool_attr("disabled", "isDisabled"),
6544 raw("/>"),
6545 ]
6546 );
6547 }
6548
6549 #[test]
6550 fn test_attribute_boolean_and_regular_together() {
6551 let (fragments, _) = parse_and_get_fragments(
6554 r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
6555 );
6556
6557 assert_fragments!(
6558 fragments,
6559 [
6560 raw("<button"),
6561 bool_attr("disabled", "isDisabled"),
6562 raw(" type=\"button\">Hi</button>"),
6563 ]
6564 );
6565 }
6566
6567 #[test]
6568 fn test_attribute_boolean_sandwiched() {
6569 let (fragments, _) = parse_and_get_fragments(
6572 r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
6573 );
6574
6575 assert_fragments!(
6576 fragments,
6577 [
6578 raw("<button"),
6579 attr("version", "edition"),
6580 bool_attr("disabled", "isDisabled"),
6581 raw(" type=\"button\">Hi</button>"),
6582 ]
6583 );
6584 }
6585
6586 #[test]
6587 fn test_attribute_boolean_ending() {
6588 let (fragments, _) = parse_and_get_fragments(
6591 r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
6592 );
6593
6594 assert_fragments!(
6595 fragments,
6596 [
6597 raw("<button"),
6598 attr("version", "edition"),
6599 bool_attr("disabled", "isDisabled"),
6600 raw(">Hi</button>"),
6601 ]
6602 );
6603 }
6604
6605 #[test]
6606 fn test_attribute_boolean_dotted_path() {
6607 let (fragments, _) =
6610 parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
6611
6612 assert_fragments!(
6613 fragments,
6614 [
6615 raw("<div"),
6616 bool_attr("checked", "layout.isPinned"),
6617 raw(">Content</div>"),
6618 ]
6619 );
6620 }
6621
6622 #[test]
6623 fn test_attribute_colon_prefixed_complex() {
6624 let (fragments, _) = parse_with_component(
6627 "my-component",
6628 r#"<my-component :config="{{settings}}"></my-component>"#,
6629 );
6630
6631 assert_fragments!(
6632 fragments,
6633 [
6634 raw("<my-component"),
6635 attr_complex_start(":config", "settings"),
6636 structural_matcher("streaming_root:my-component"),
6637 raw(">"),
6638 component("my-component"),
6639 raw("</my-component>"),
6640 ]
6641 );
6642 }
6643
6644 #[test]
6645 fn test_attribute_multiple_colon_prefixed() {
6646 let (fragments, _) = parse_with_component(
6649 "my-component",
6650 r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
6651 );
6652
6653 assert_fragments!(
6654 fragments,
6655 [
6656 raw("<my-component"),
6657 attr_complex_start(":prop1", "val1"),
6658 attr_complex(":prop2", "val2"),
6659 structural_matcher("streaming_root:my-component"),
6660 raw(">"),
6661 component("my-component"),
6662 raw("</my-component>"),
6663 ]
6664 );
6665 }
6666
6667 #[test]
6668 fn test_blocked_complex_property_innerhtml() {
6669 let mut parser = HtmlParser::new();
6670 let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
6671 assert!(
6672 result.is_err(),
6673 "Expected error for :innerHTML on native element"
6674 );
6675 let err = result.unwrap_err().to_string();
6676 assert!(
6677 err.contains("only allowed on custom elements"),
6678 "Error: {err}"
6679 );
6680 }
6681
6682 #[test]
6683 fn test_blocked_complex_on_native_element() {
6684 let mut parser = HtmlParser::new();
6685 let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
6686 assert!(
6687 result.is_err(),
6688 "Expected error for :data on native element"
6689 );
6690 let err = result.unwrap_err().to_string();
6691 assert!(
6692 err.contains("only allowed on custom elements"),
6693 "Error: {err}"
6694 );
6695 }
6696
6697 #[test]
6698 fn test_blocked_complex_property_on_component() {
6699 let mut parser = HtmlParser::new();
6700 parser
6701 .component_registry
6702 .register_component(ComponentRegistration::new(
6703 "my-widget",
6704 "<div></div>",
6705 None,
6706 true,
6707 ))
6708 .expect("register");
6709 let result = parser.parse(
6710 "index.html",
6711 r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
6712 );
6713 assert!(
6714 result.is_err(),
6715 "Expected error for :innerHTML on component"
6716 );
6717 let err = result.unwrap_err().to_string();
6718 assert!(err.contains("HTML injection"), "Error: {err}");
6719 }
6720
6721 #[test]
6722 fn test_allowed_complex_property() {
6723 let (fragments, _) = parse_with_component(
6725 "my-component",
6726 r#"<my-component :config="{{settings}}"></my-component>"#,
6727 );
6728 assert_fragments!(
6729 fragments,
6730 [
6731 raw("<my-component"),
6732 attr_complex_start(":config", "settings"),
6733 structural_matcher("streaming_root:my-component"),
6734 raw(">"),
6735 component("my-component"),
6736 raw("</my-component>"),
6737 ]
6738 );
6739 }
6740
6741 #[test]
6742 fn test_attribute_mixed_normal_boolean_colon() {
6743 let (fragments, _) = parse_with_component(
6746 "my-component",
6747 r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
6748 );
6749
6750 assert_fragments!(
6751 fragments,
6752 [
6753 raw("<my-component"),
6754 attr_raw_start("id", "comp"),
6755 attr_complex(":config", "settings"),
6756 bool_attr("enabled", "isEnabled"),
6757 structural_matcher("streaming_root:my-component"),
6758 raw(">"),
6759 component("my-component"),
6760 raw("</my-component>"),
6761 ]
6762 );
6763 }
6764
6765 #[test]
6766 fn test_attribute_reject_boolean_without_handlebars() {
6767 let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
6770
6771 assert_fragments!(fragments, [raw("<button></button>"),]);
6773 }
6774
6775 #[test]
6776 fn test_attribute_reject_boolean_with_partial_handlebars() {
6777 let (fragments, _) =
6780 parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
6781
6782 assert_fragments!(fragments, [raw("<button></button>"),]);
6784 }
6785
6786 #[test]
6787 fn test_attribute_reject_boolean_with_plain_value() {
6788 let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
6791
6792 assert_fragments!(fragments, [raw("<button>Click</button>"),]);
6794 }
6795
6796 #[test]
6797 fn test_attribute_boolean_predicate_equal() {
6798 let (fragments, _) =
6800 parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
6801 assert_fragments!(
6802 fragments,
6803 [
6804 raw("<div"),
6805 bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
6806 raw(">X</div>"),
6807 ]
6808 );
6809 }
6810
6811 #[test]
6812 fn test_attribute_boolean_predicate_greater_than() {
6813 let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
6815 assert_fragments!(
6816 fragments,
6817 [
6818 raw("<span"),
6819 bool_attr_predicate("hidden", "num", 1, "9"),
6820 raw(">X</span>"),
6821 ]
6822 );
6823 }
6824
6825 #[test]
6826 fn test_attribute_boolean_predicate_not_equal() {
6827 let (fragments, _) =
6829 parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
6830 assert_fragments!(
6831 fragments,
6832 [
6833 raw("<a"),
6834 bool_attr_predicate("data-active", "status", 4, "'inactive'"),
6835 raw(">X</a>"),
6836 ]
6837 );
6838 }
6839
6840 #[test]
6841 fn test_attribute_boolean_negation() {
6842 let (fragments, _) =
6844 parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
6845 assert_fragments!(
6846 fragments,
6847 [
6848 raw("<button"),
6849 bool_attr_not("disabled", "isReady"),
6850 raw(">X</button>"),
6851 ]
6852 );
6853 }
6854
6855 #[test]
6856 fn test_attribute_mixed_static_dynamic() {
6857 let (fragments, records) =
6860 parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
6861
6862 assert_fragments!(
6863 fragments,
6864 [
6865 raw("<textarea"),
6866 attr_template("value", "attr-1"),
6867 raw(">Hi</textarea>"),
6868 ]
6869 );
6870
6871 assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
6873 }
6874
6875 #[test]
6878 fn test_body_signals() {
6879 let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
6880 assert_fragments!(
6881 fragments,
6882 [
6883 raw("<body>"),
6884 structural_matcher("body_start"),
6885 raw("<app-shell></app-shell>"),
6886 structural_matcher("body_end"),
6887 raw("</body>"),
6888 ]
6889 );
6890 }
6891
6892 #[test]
6895 fn test_empty_for_produces_nothing() {
6896 let (fragments, records) =
6897 parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
6898 assert_fragments!(fragments, [raw("<div></div>"),]);
6899 assert!(!records.contains_key("for-1"));
6900 }
6901
6902 #[test]
6905 fn test_self_closing_svg_path() {
6906 let (fragments, _) =
6907 parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
6908 assert_fragments!(
6909 fragments,
6910 [raw(
6911 r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
6912 ),]
6913 );
6914 }
6915
6916 #[test]
6917 fn test_html5_void_elements() {
6918 let (fragments, _) = parse_and_get_fragments(
6919 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
6920 );
6921 assert_fragments!(
6922 fragments,
6923 [raw(
6924 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
6925 ),]
6926 );
6927 }
6928
6929 #[test]
6930 fn test_self_closing_with_dynamic_attributes() {
6931 let (fragments, _) =
6932 parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
6933 assert_fragments!(
6934 fragments,
6935 [
6936 raw("<img"),
6937 attr("src", "imageUrl"),
6938 attr("alt", "imageAlt"),
6939 raw("/>"),
6940 ]
6941 );
6942 }
6943
6944 #[test]
6945 fn test_self_closing_with_boolean_attributes() {
6946 let (fragments, _) = parse_and_get_fragments(
6947 r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
6948 );
6949 assert_fragments!(
6950 fragments,
6951 [
6952 raw("<input type=\"checkbox\""),
6953 bool_attr("checked", "isSelected"),
6954 bool_attr("disabled", "isDisabled"),
6955 raw("/>"),
6956 ]
6957 );
6958 }
6959
6960 #[test]
6961 fn test_multiple_self_closing_in_sequence() {
6962 let (fragments, _) =
6963 parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
6964 assert_fragments!(
6965 fragments,
6966 [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
6967 );
6968 }
6969
6970 #[test]
6971 fn test_self_closing_with_mixed_content() {
6972 let (fragments, _) =
6973 parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
6974 assert_fragments!(
6975 fragments,
6976 [
6977 raw("<div>Text before<img"),
6978 attr("src", "url"),
6979 raw("/>Text after</div>"),
6980 ]
6981 );
6982 }
6983
6984 #[test]
6985 fn test_self_closing_svg_elements() {
6986 let (fragments, _) = parse_and_get_fragments(
6987 r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
6988 );
6989 assert_fragments!(
6990 fragments,
6991 [
6992 raw("<svg><circle"),
6993 attr("cx", "x"),
6994 attr("cy", "y"),
6995 raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
6996 ]
6997 );
6998 }
6999
7000 #[test]
7001 fn test_self_closing_inside_for_loop() {
7002 let (fragments, records) = parse_and_get_fragments(
7003 r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
7004 );
7005 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
7006 assert_stream!(
7007 records,
7008 "for-1",
7009 [raw("<img"), attr("src", "item.url"), raw("/>"),]
7010 );
7011 }
7012
7013 #[test]
7014 fn test_self_closing_whitespace_variations() {
7015 let (fragments, _) =
7016 parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
7017 assert_fragments!(
7018 fragments,
7019 [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
7020 );
7021 }
7022
7023 #[test]
7024 fn test_deeply_nested_self_closing() {
7025 let (fragments, _) = parse_and_get_fragments(
7026 r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
7027 );
7028 assert_fragments!(
7029 fragments,
7030 [raw(
7031 r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
7032 ),]
7033 );
7034 }
7035
7036 #[test]
7037 fn test_self_closing_vs_empty_regular_tags() {
7038 let (fragments, _) =
7039 parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
7040 assert_fragments!(
7041 fragments,
7042 [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
7043 );
7044 }
7045
7046 #[test]
7049 fn test_for_custom_template_attribute() {
7050 let (fragments, records) = parse_and_get_fragments(
7052 r#"<for each="item in items" template="static"><span>Item</span></for>"#,
7053 );
7054 assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
7055 assert_stream!(records, "static", [raw("<span>Item</span>"),]);
7056 }
7057
7058 #[test]
7059 fn test_for_recursive_template() {
7060 let mut parser = HtmlParser::new();
7062 let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
7063 let result = parser.parse("index.html", html);
7064 assert!(result.is_ok(), "Parse error: {:?}", result.err());
7065 let records = parser.into_fragment_records();
7066
7067 assert_fragments!(
7068 records["index.html"].fragments,
7069 [for_loop("outerItem", "outerItems", "static"),]
7070 );
7071
7072 assert_stream!(
7073 records,
7074 "static",
7075 [
7076 raw("<div><span>"),
7077 signal("outerItem.name"),
7078 raw("</span>"),
7079 for_loop("innerItem", "innerItems", "static"),
7080 raw("</div>"),
7081 ]
7082 );
7083 }
7084
7085 #[test]
7088 fn test_if_multiple_children() {
7089 let (fragments, records) =
7091 parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
7092 assert_fragments!(fragments, [if_cond("if-1"),]);
7093 assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
7094 }
7095
7096 #[test]
7097 fn if_condition_with_double_quoted_literal_round_trips_through_parser() {
7098 let (fragments, _) =
7103 parse_and_get_fragments(r#"<if condition='status == "ready"'><span>ok</span></if>"#);
7104 let condition = fragments
7105 .iter()
7106 .find_map(|fragment| match fragment.fragment.as_ref() {
7107 Some(webui_protocol::web_ui_fragment::Fragment::IfCond(if_cond)) => {
7108 if_cond.condition.as_ref()
7109 }
7110 _ => None,
7111 })
7112 .expect("if-condition fragment with a parsed condition");
7113 match condition.expr.as_ref() {
7114 Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
7115 assert_eq!(pred.left, "status");
7116 assert_eq!(pred.right, "\"ready\"");
7117 }
7118 other => panic!("expected a predicate condition, got {other:?}"),
7119 }
7120 }
7121
7122 #[test]
7123 fn test_for_multiple_children() {
7124 let (fragments, records) =
7126 parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
7127 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
7128 assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
7129 }
7130
7131 #[test]
7134 fn test_handlebars_at_beginning() {
7135 let (fragments, _) = parse_and_get_fragments("{{first}}");
7137 assert_fragments!(fragments, [signal("first"),]);
7138 }
7139
7140 #[test]
7141 fn test_handlebars_at_beginning_and_raw() {
7142 let (fragments, _) = parse_and_get_fragments("{{first}}test");
7144 assert_fragments!(fragments, [signal("first"), raw("test"),]);
7145 }
7146
7147 #[test]
7148 fn test_handlebars_raw_and_end() {
7149 let (fragments, _) = parse_and_get_fragments("test{{first}}");
7151 assert_fragments!(fragments, [raw("test"), signal("first"),]);
7152 }
7153
7154 #[test]
7157 fn test_handlebars_invalid_triple_open() {
7158 let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
7160 assert_fragments!(fragments, [raw("{{{invalid}}"),]);
7161 }
7162
7163 #[test]
7164 fn test_handlebars_four_open_braces() {
7165 let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
7167 assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
7168 }
7169
7170 #[test]
7171 fn test_handlebars_five_open_with_valid_double() {
7172 let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
7174 assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
7175 }
7176
7177 #[test]
7178 fn test_entities_preserved() {
7179 let (fragments, _) = parse_and_get_fragments("<p>Hello}World</p>");
7181 assert_fragments!(fragments, [raw("<p>Hello}World</p>"),]);
7182 }
7183
7184 #[test]
7187 fn test_doctype_preserved() {
7188 let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
7190 assert!(!fragments.is_empty());
7191 assert!(
7192 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
7193 );
7194 }
7195
7196 #[test]
7199 fn test_component_attr_skip() {
7200 let mut parser = HtmlParser::new();
7202 parser
7203 .component_registry
7204 .register_component(ComponentRegistration::new(
7205 "custom-element",
7206 r#"<template shadowrootmode="open"><slot></slot></template>"#,
7207 None,
7208 true,
7209 ))
7210 .expect("register");
7211 let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
7212 let result = parser.parse("index.html", html);
7213 assert!(result.is_ok(), "Parse error: {:?}", result.err());
7214 let records = parser.into_fragment_records();
7215
7216 assert_fragments!(
7219 records["index.html"].fragments,
7220 [
7221 raw("<custom-element"),
7222 attr_complex_start(":config", "config"),
7224 attr_skip("class", "value0"),
7226 attr_skip("style", "value1"),
7227 attr_skip("role", "value2"),
7228 attr_skip("data-test", "value3"),
7229 attr_skip("aria-test", "value4"),
7230 structural_matcher("streaming_root:custom-element"),
7231 raw(">"),
7232 component("custom-element"),
7233 raw("</custom-element>"),
7234 ]
7235 );
7236 }
7237
7238 #[test]
7239 fn test_component_attr_skip_static_and_embedded() {
7240 let mut parser = HtmlParser::new();
7243 parser
7244 .component_registry
7245 .register_component(ComponentRegistration::new(
7246 "item-group",
7247 r#"<template shadowrootmode="open"><slot></slot></template>"#,
7248 None,
7249 true,
7250 ))
7251 .expect("register");
7252
7253 let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
7254 let result = parser.parse("index.html", html);
7255 assert!(result.is_ok(), "Parse error: {:?}", result.err());
7256 let records = parser.into_fragment_records();
7257
7258 assert_fragments!(
7259 records["index.html"].fragments,
7260 [
7261 raw("<item-group"),
7262 attr_skip_raw("role", "list"),
7263 attr_skip_template("aria-labelledby", "attr-1"),
7264 attr_skip_template("data-testid", "attr-2"),
7265 attr_skip_raw("class", "fixed-class"),
7266 structural_matcher("streaming_root:item-group"),
7267 raw(">"),
7268 component("item-group"),
7269 raw("</item-group>"),
7270 ]
7271 );
7272
7273 assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
7276 assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
7277 }
7278
7279 #[test]
7280 fn test_component_multiple_nested() {
7281 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7283 parser
7284 .component_registry
7285 .register_component(ComponentRegistration::new(
7286 "custom-element",
7287 "<custom-child></custom-child><span></span>",
7288 None,
7289 true,
7290 ))
7291 .expect("register");
7292 parser
7293 .component_registry
7294 .register_component(ComponentRegistration::new(
7295 "custom-button",
7296 "<span></span>",
7297 None,
7298 true,
7299 ))
7300 .expect("register");
7301 parser
7302 .component_registry
7303 .register_component(ComponentRegistration::new(
7304 "custom-child",
7305 "<h1>Hello World!</h1>",
7306 None,
7307 true,
7308 ))
7309 .expect("register");
7310
7311 let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
7312 let result = parser.parse("index.html", html);
7313 assert!(result.is_ok(), "Parse error: {:?}", result.err());
7314 let records = parser.into_fragment_records();
7315
7316 assert_fragments!(
7318 records["index.html"].fragments,
7319 [for_loop("item", "items", "for-1"),]
7320 );
7321
7322 assert_stream!(
7324 records,
7325 "for-1",
7326 [
7327 raw("<custom-element"),
7328 structural_matcher("streaming_root:custom-element"),
7329 raw(">"),
7330 component("custom-element"),
7331 raw("<custom-button"),
7332 structural_matcher("streaming_root:custom-button"),
7333 raw(">"),
7334 component("custom-button"),
7335 raw("Ok</custom-button></custom-element>"),
7336 ]
7337 );
7338
7339 let ce = &records["custom-element"].fragments;
7341 assert_eq!(ce.len(), 5);
7342 assert!(
7343 matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child"))
7344 );
7345 assert!(
7346 matches!(ce[3].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
7347 );
7348 assert!(
7349 matches!(ce[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><span></span>"))
7350 );
7351
7352 assert_stream!(records, "custom-button", [raw("<span></span>"),]);
7353
7354 assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
7355 }
7356
7357 #[test]
7360 fn test_invalid_markup_returns_error() {
7361 let mut parser = HtmlParser::new();
7363 let result = parser.parse("index.html", "<div><span>Unclosed div");
7364 assert!(matches!(result, Err(ParserError::Template(diag)) if
7365 diag.to_string().contains("unclosed <div> tag")
7366 ));
7367 }
7368
7369 #[test]
7370 fn test_unterminated_opening_tag_returns_error() {
7371 let mut parser = HtmlParser::new();
7372 let result = parser.parse("index.html", "before <div");
7373 assert!(matches!(result, Err(ParserError::Template(diag)) if
7374 diag.to_string().contains("malformed HTML tag")
7375 ));
7376 }
7377
7378 #[test]
7379 fn test_unterminated_html_comment_returns_error() {
7380 let mut parser = HtmlParser::new();
7381 let result = parser.parse("index.html", "<div><!-- missing close</div>");
7382 assert!(matches!(result, Err(ParserError::Template(diag)) if
7383 diag.to_string().contains("unclosed <div> tag")
7384 || diag.to_string().contains("unterminated HTML comment")
7385 ));
7386 }
7387
7388 #[test]
7389 fn test_unexpected_closing_tag_returns_error() {
7390 let mut parser = HtmlParser::new();
7391 let result = parser.parse("index.html", "</span>");
7392 assert!(matches!(result, Err(ParserError::Template(diag)) if
7393 diag.to_string().contains("unexpected closing tag </span>")
7394 ));
7395 }
7396
7397 #[test]
7398 fn test_rejects_templates_over_size_limit() {
7399 let mut parser = HtmlParser::new();
7400 let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
7401 let result = parser.parse("index.html", &html);
7402
7403 assert!(matches!(result, Err(ParserError::Html(message)) if
7404 message.contains("exceeds") && message.contains("parser limit")
7405 ));
7406 }
7407
7408 #[test]
7409 fn test_rejects_templates_over_depth_limit() {
7410 let mut parser = HtmlParser::new();
7411 let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
7412 for _ in 0..=MAX_TEMPLATE_DEPTH {
7413 html.push_str("<div>");
7414 }
7415 for _ in 0..=MAX_TEMPLATE_DEPTH {
7416 html.push_str("</div>");
7417 }
7418
7419 let result = parser.parse("index.html", &html);
7420
7421 assert!(matches!(result, Err(ParserError::Template(diag)) if
7422 diag.to_string().contains("nesting exceeds")
7423 ));
7424 }
7425
7426 #[test]
7427 fn test_rejects_routes_over_depth_limit() {
7428 let mut parser = HtmlParser::new();
7429 parser
7430 .component_registry
7431 .register_component(ComponentRegistration::new(
7432 "route-page",
7433 r#"<template shadowrootmode="open"><slot></slot></template>"#,
7434 None,
7435 true,
7436 ))
7437 .expect("register");
7438
7439 let depth = MAX_TEMPLATE_DEPTH + 2;
7440 let mut html = String::with_capacity(depth * 48);
7441 for _ in 0..depth {
7442 html.push_str(r#"<route path="x" component="route-page">"#);
7443 }
7444 for _ in 0..depth {
7445 html.push_str("</route>");
7446 }
7447
7448 let result = parser.parse("index.html", &html);
7449
7450 assert!(matches!(result, Err(ParserError::Template(diag)) if
7451 diag.error_code() == Some(codes::EXCESSIVE_NESTING)
7452 && diag.to_string().contains("route nesting exceeds")
7453 ));
7454 }
7455
7456 #[test]
7457 fn test_rejects_recursive_component_templates() {
7458 let mut parser = HtmlParser::new();
7459 parser
7460 .component_registry
7461 .register_component(ComponentRegistration::new(
7462 "self-card",
7463 "<self-card></self-card>",
7464 None,
7465 true,
7466 ))
7467 .expect("register");
7468
7469 let result = parser.parse("index.html", "<self-card></self-card>");
7470
7471 assert!(matches!(result, Err(ParserError::Template(diag)) if
7472 diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
7473 && diag.to_string().contains("recursive template reference")
7474 ));
7475 }
7476
7477 #[test]
7480 fn test_complex_raw_text_full_page() {
7481 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>"#;
7483 let (fragments, _) = parse_and_get_fragments(html);
7484
7485 assert!(fragments.len() >= 9);
7488 assert!(
7489 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
7490 raw.value.contains("<!DOCTYPE HTML>") && raw.value.ends_with("<head>"))
7491 );
7492 assert!(
7493 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
7494 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
7495 );
7496 assert!(
7497 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
7498 raw.value.contains("<title>Test</title>"))
7499 );
7500 assert!(
7501 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
7502 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
7503 );
7504 assert!(
7505 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
7506 raw.value.contains("</head>") && raw.value.ends_with("<body>"))
7507 );
7508 assert!(
7509 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
7510 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start"))
7511 );
7512 assert!(
7513 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
7514 raw.value.contains("<app-shell>"))
7515 );
7516 assert!(
7517 matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
7518 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end"))
7519 );
7520 assert!(
7521 matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
7522 raw.value.contains("</body>") && raw.value.contains("</html>"))
7523 );
7524 }
7525
7526 #[test]
7527 fn raw_bindings_in_html_text_contexts_do_not_own_html_ranges() {
7528 for element in ["title", "textarea", "script", "xmp"] {
7529 let html = format!("<{element}>{{{{{{value}}}}}}</{element}>");
7530 let (fragments, _) = parse_and_get_fragments(&html);
7531 let signal = fragments
7532 .iter()
7533 .find_map(|fragment| match fragment.fragment.as_ref() {
7534 Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
7535 _ => None,
7536 });
7537 let signal = signal
7538 .unwrap_or_else(|| panic!("expected raw signal inside <{element}>: {fragments:?}"));
7539 assert!(
7540 signal.raw,
7541 "<{element}> should preserve raw brace semantics"
7542 );
7543 assert!(
7544 signal.raw_text_context,
7545 "<{element}> should suppress HTML ownership markers"
7546 );
7547 }
7548 }
7549
7550 #[test]
7551 fn raw_bindings_in_template_content_own_html_ranges() {
7552 let (fragments, _) = parse_and_get_fragments("<template>{{{value}}}</template>");
7553 let signal = fragments
7554 .iter()
7555 .find_map(|fragment| match fragment.fragment.as_ref() {
7556 Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
7557 _ => None,
7558 });
7559 let signal =
7560 signal.unwrap_or_else(|| panic!("expected raw signal inside template: {fragments:?}"));
7561 assert!(signal.raw);
7562 assert!(!signal.raw_text_context);
7563 }
7564
7565 #[test]
7566 fn test_css_strategy_external_defers_link_to_server_closure() {
7567 let mut parser = HtmlParser::new();
7568 parser
7569 .component_registry_mut()
7570 .register_component(ComponentRegistration::new(
7571 "my-card",
7572 r#"<template shadowrootmode="open"><p>content</p></template>"#,
7573 Some("p { color: red; }"),
7574 true,
7575 ))
7576 .ok();
7577 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
7578 let records = parser.into_fragment_records();
7579 let my_card = &records["my-card"].fragments;
7580 let raw_text: String = my_card
7581 .iter()
7582 .filter_map(|f| match &f.fragment {
7583 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
7584 _ => None,
7585 })
7586 .collect();
7587 assert!(
7588 !raw_text.contains(r#"<link rel="stylesheet""#),
7589 "component templates must not own tree-local links: {}",
7590 raw_text
7591 );
7592 assert!(my_card.iter().any(|fragment| matches!(
7593 fragment.fragment.as_ref(),
7594 Some(Fragment::Signal(signal))
7595 if signal.value == "}}}webui:shadow_styles:my-card"
7596 )));
7597 }
7598
7599 #[test]
7600 fn test_uppercase_style_element_is_processed_as_css() {
7601 let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
7606 assert!(
7607 fragments.iter().any(|f| matches!(
7608 f.fragment.as_ref(),
7609 Some(Fragment::Signal(s))
7610 if s.value == "tokens" && !s.raw && s.raw_text_context
7611 )),
7612 "uppercase <STYLE> should be CSS-processed, got: {:?}",
7613 fragments
7614 );
7615 let raw: String = fragments
7616 .iter()
7617 .filter_map(|f| match f.fragment.as_ref() {
7618 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
7619 _ => None,
7620 })
7621 .collect();
7622 assert!(
7623 raw.contains("</STYLE>"),
7624 "closing tag should preserve source case, got: {raw}"
7625 );
7626 }
7627
7628 #[test]
7629 fn test_css_strategy_inline_defers_style_to_server_closure() {
7630 let mut parser = HtmlParser::with_options(CssStrategy::Style);
7631 parser
7632 .component_registry_mut()
7633 .register_component(ComponentRegistration::new(
7634 "my-card",
7635 "<p>content</p>",
7636 Some("p { color: red; }"),
7637 true,
7638 ))
7639 .expect("register component");
7640 parser
7641 .parse("index.html", "<my-card>Hello</my-card>")
7642 .expect("parse component");
7643 let records = parser.into_fragment_records();
7644 let my_card = &records["my-card"].fragments;
7645 let raw_text: String = my_card
7646 .iter()
7647 .filter_map(|f| match &f.fragment {
7648 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
7649 _ => None,
7650 })
7651 .collect();
7652 assert!(
7653 !raw_text.contains("<style>"),
7654 "component templates must not own tree-local styles: {}",
7655 raw_text
7656 );
7657 assert!(
7658 !raw_text.contains("<link"),
7659 "Should not have <link> tag in inline mode: {}",
7660 raw_text
7661 );
7662 }
7663
7664 #[test]
7665 fn test_css_strategy_module_emits_adopted_stylesheets() {
7666 let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
7667 parser
7668 .component_registry_mut()
7669 .register_component(ComponentRegistration::new(
7670 "my-card",
7671 "<p>content</p>",
7672 Some("p { color: red; }"),
7673 true,
7674 ))
7675 .expect("register component");
7676 parser
7677 .parse("index.html", "<my-card>Hello</my-card>")
7678 .expect("parse component");
7679 let records = parser.into_fragment_records();
7680
7681 let my_card = &records["my-card"].fragments;
7684 let template_text: String = my_card
7685 .iter()
7686 .filter_map(|f| match &f.fragment {
7687 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
7688 _ => None,
7689 })
7690 .collect();
7691 assert!(
7692 !template_text.contains("shadowrootadoptedstylesheets"),
7693 "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
7694 );
7695 assert!(
7696 !template_text.contains("<link"),
7697 "Should not have <link> in module mode: {template_text}"
7698 );
7699 assert!(
7703 !template_text.contains(r#"<style type="module""#),
7704 "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
7705 );
7706 assert!(
7707 !template_text.contains(r#"<script type="importmap""#),
7708 "CSS module importmap should NOT be in raw fragments: {template_text}"
7709 );
7710 }
7711
7712 #[test]
7713 fn light_component_css_remains_authored_and_unscoped() {
7714 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7715 parser
7716 .component_registry_mut()
7717 .register_component(ComponentRegistration::new(
7718 "my-card",
7719 "<p class=\"label\">content</p>",
7720 Some(".label{color:red}"),
7721 false,
7722 ))
7723 .expect("register component");
7724 parser
7725 .parse("index.html", "<my-card></my-card>")
7726 .expect("parse component");
7727
7728 let css = parser
7729 .component_registry()
7730 .get("my-card")
7731 .and_then(|component| component.css_content.as_deref())
7732 .expect("authored CSS");
7733 assert_eq!(css, ".label{color:red}");
7734 }
7735
7736 #[test]
7737 fn light_component_inline_styles_remain_unscoped_and_unmarked() {
7738 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7739 let built = parser
7740 .build_component_templates(ComponentTemplateInput {
7741 tag_name: "my-card",
7742 html: "<style>.label{color:red}</style><p class=\"label\">content</p>",
7743 artifact_html: None,
7744 authored_html: None,
7745 css_content: None,
7746 artifact_needed: false,
7747 })
7748 .expect("build Light component");
7749
7750 assert_eq!(
7751 built.ssr,
7752 "<style>.label{color:red}</style><p class=\"label\">content</p>"
7753 );
7754 assert!(!built.ssr.contains("data-wl"));
7755 }
7756
7757 #[test]
7758 fn light_component_rejects_shadow_only_selectors() {
7759 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7760 for selector in [":host", ":host-context(body.dark)", "::slotted(*)"] {
7761 let html = format!("<style>{selector}{{color:red}}</style><p>content</p>");
7762 let result = parser.build_component_templates(ComponentTemplateInput {
7763 tag_name: "my-card",
7764 html: &html,
7765 artifact_html: None,
7766 authored_html: None,
7767 css_content: None,
7768 artifact_needed: false,
7769 });
7770 let error = match result {
7771 Ok(_) => panic!("Shadow-only selector must fail in Light DOM"),
7772 Err(error) => error,
7773 };
7774 assert!(matches!(
7775 error,
7776 ParserError::Template(ref diagnostic)
7777 if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
7778 ));
7779 }
7780 }
7781
7782 #[test]
7783 fn light_component_external_css_rejects_host_selector() {
7784 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7785 parser
7786 .component_registry_mut()
7787 .register_component(ComponentRegistration::new(
7788 "my-card",
7789 "<p>content</p>",
7790 Some(":host{display:block}"),
7791 false,
7792 ))
7793 .expect("register component");
7794 let error = parser
7795 .parse("index.html", "<my-card></my-card>")
7796 .expect_err("external :host must fail in Light DOM");
7797 assert!(matches!(
7798 error,
7799 ParserError::Template(ref diagnostic)
7800 if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
7801 ));
7802 }
7803
7804 #[test]
7805 fn shadow_component_inline_styles_remain_tree_local_and_unchanged() {
7806 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
7807 let built = parser
7808 .build_component_templates(ComponentTemplateInput {
7809 tag_name: "my-card",
7810 html: "<style>:host{display:block}.label{color:red}</style><p class=\"label\">content</p>",
7811 artifact_html: None,
7812 authored_html: None,
7813 css_content: None,
7814 artifact_needed: false,
7815 })
7816 .expect("build Shadow component");
7817
7818 assert!(built.ssr.contains(
7819 "<template shadowrootmode=\"open\"><style>:host{display:block}.label{color:red}</style>"
7820 ));
7821 assert!(!built.ssr.contains("data-wl"));
7822 }
7823
7824 #[test]
7825 fn nested_light_hosts_are_unmarked() {
7826 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7827 parser
7828 .component_registry_mut()
7829 .register_component(ComponentRegistration::new(
7830 "my-child",
7831 "<p class=\"inner\">child</p>",
7832 Some(".inner{color:green}"),
7833 false,
7834 ))
7835 .expect("register child");
7836 parser
7837 .component_registry_mut()
7838 .register_component(ComponentRegistration::new(
7839 "my-card",
7840 "<my-child></my-child>",
7841 Some("my-child{display:block}"),
7842 false,
7843 ))
7844 .expect("register parent");
7845 parser
7846 .parse("index.html", "<my-card></my-card>")
7847 .expect("parse component");
7848
7849 let records = parser.into_fragment_records();
7850 let raw: String = records["my-card"]
7851 .fragments
7852 .iter()
7853 .filter_map(|fragment| match &fragment.fragment {
7854 Some(web_ui_fragment::Fragment::Raw(raw)) => Some(raw.value.as_str()),
7855 _ => None,
7856 })
7857 .collect();
7858 assert!(raw.contains("<my-child>"), "nested host was changed: {raw}");
7859 assert!(!raw.contains("data-wl"), "Light markers remain: {raw}");
7860 }
7861
7862 #[test]
7863 fn raw_html_binding_does_not_change_global_css() {
7864 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7865 parser
7866 .component_registry_mut()
7867 .register_component(ComponentRegistration::new(
7868 "my-card",
7869 "<div class=\"body\">{{{descriptionHtml}}}</div>",
7870 Some(".body p{margin:0}"),
7871 false,
7872 ))
7873 .expect("register component");
7874 parser
7875 .parse("index.html", "<my-card></my-card>")
7876 .expect("parse component");
7877
7878 let css = parser
7879 .component_registry()
7880 .get("my-card")
7881 .and_then(|component| component.css_content.as_deref())
7882 .expect("authored CSS");
7883 assert_eq!(css, ".body p{margin:0}");
7884 }
7885
7886 #[test]
7887 fn test_css_strategy_module_no_css_no_adopted_attr() {
7888 let mut parser = HtmlParser::with_options(CssStrategy::Module);
7889 parser
7890 .component_registry_mut()
7891 .register_component(ComponentRegistration::new(
7892 "my-card",
7893 r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
7894 None,
7895 true,
7896 ))
7897 .ok();
7898 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
7899 let records = parser.into_fragment_records();
7900 let my_card = &records["my-card"].fragments;
7901 let template_text: String = my_card
7902 .iter()
7903 .filter_map(|f| match &f.fragment {
7904 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
7905 _ => None,
7906 })
7907 .collect();
7908 assert!(
7910 !template_text.contains("shadowrootadoptedstylesheets"),
7911 "Should not have adopted attr without CSS: {template_text}"
7912 );
7913 }
7914
7915 #[test]
7928 fn light_preserves_dev_template_with_static_attrs() {
7929 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7930 let processed = parser
7931 .process_component_template(
7932 r#"<template foo="bar"><div>hi</div></template>"#,
7933 None,
7934 None,
7935 )
7936 .expect("process failed");
7937 assert!(
7938 processed.contains(r#"<template foo="bar">"#),
7939 "expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
7940 );
7941 assert!(
7942 processed.contains("</template>"),
7943 "expected closing </template> preserved, got: {processed}"
7944 );
7945 }
7946
7947 #[test]
7948 fn light_preserves_dev_template_with_signal_fragment_attrs() {
7949 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7950 let processed = parser
7951 .process_component_template(
7952 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
7953 None,
7954 None,
7955 )
7956 .expect("process failed");
7957 assert!(
7958 processed.contains(r#"<template foo="{{foo}}">"#),
7959 "expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
7960 );
7961 }
7962
7963 #[test]
7964 fn light_preserves_dev_template_with_multiple_attrs() {
7965 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7966 let processed = parser.process_component_template(
7967 r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
7968 None,
7969 None,
7970 )
7971 .expect("process failed");
7972 assert!(
7973 processed.contains("autofocus")
7974 && processed.contains(r#"tabindex="0""#)
7975 && processed.contains(r#"role="region""#)
7976 && processed.contains(r#"data-x="y""#),
7977 "expected all dev template attrs preserved, got: {processed}"
7978 );
7979 }
7980
7981 #[test]
7982 fn light_no_template_no_wrapper_added() {
7983 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7984 let processed = parser
7985 .process_component_template("<div>hi</div>", None, None)
7986 .expect("process failed");
7987 assert!(
7988 !processed.contains("<template"),
7989 "framework must not add a <template> wrapper when dev omits one, got: {processed}"
7990 );
7991 assert!(
7992 processed.contains("<div>hi</div>"),
7993 "expected inner content emitted as-is, got: {processed}"
7994 );
7995 }
7996
7997 #[test]
7998 fn ordinary_template_remains_light_dom_content() {
7999 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8000 let processed = parser
8001 .process_component_template(
8002 r#"<template foo="bar"><div>hi</div></template>"#,
8003 None,
8004 None,
8005 )
8006 .expect("process failed");
8007 assert!(
8008 processed.contains(r#"<template foo="bar">"#),
8009 "ordinary template content must be preserved, got: {processed}"
8010 );
8011 assert!(
8012 !processed.contains("shadowrootmode"),
8013 "ordinary template must not gain a generated Shadow root, got: {processed}"
8014 );
8015 }
8016
8017 #[test]
8018 fn bare_template_is_unwrapped_as_explicit_light_dom() {
8019 let mut parser = HtmlParser::new();
8020 let built = parser
8021 .build_component_templates(ComponentTemplateInput {
8022 tag_name: "my-card",
8023 html: "<template><div class=\"label\">hi</div></template>",
8024 artifact_html: None,
8025 authored_html: None,
8026 css_content: Some(".label{color:red}"),
8027 artifact_needed: false,
8028 })
8029 .expect("bare template should select Light DOM");
8030 assert!(!built.uses_shadow_dom);
8031 assert_eq!(built.ssr, r#"<div class="label">hi</div>"#);
8032 }
8033
8034 #[test]
8035 fn attributed_template_keeps_the_shadow_fallback() {
8036 let mut parser = HtmlParser::new();
8037 let built = parser
8038 .build_component_templates(ComponentTemplateInput {
8039 tag_name: "my-card",
8040 html: r#"<template data-purpose="content"><div>hi</div></template>"#,
8041 artifact_html: None,
8042 authored_html: None,
8043 css_content: None,
8044 artifact_needed: false,
8045 })
8046 .expect("attributed template should remain ordinary content");
8047 assert!(built.uses_shadow_dom);
8048 assert_eq!(
8049 built.ssr,
8050 r#"<template shadowrootmode="open"><template data-purpose="content"><div>hi</div></template></template>"#
8051 );
8052 }
8053
8054 #[test]
8055 fn closed_shadow_wrapper_is_rejected_during_template_processing() {
8056 let mut parser = HtmlParser::new();
8057 let error = parser
8058 .process_component_template(
8059 r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
8060 None,
8061 None,
8062 )
8063 .expect_err("closed roots must fail");
8064 assert!(matches!(
8065 error,
8066 ParserError::Template(ref diagnostic)
8067 if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
8068 ));
8069 }
8070
8071 #[test]
8072 fn ordinary_template_preserves_signal_fragment_attrs() {
8073 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8074 let processed = parser
8075 .process_component_template(
8076 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
8077 None,
8078 None,
8079 )
8080 .expect("process failed");
8081 assert!(
8082 processed.starts_with(r#"<template foo="{{foo}}">"#),
8083 "ordinary template must remain Light content, got: {processed}"
8084 );
8085 }
8086
8087 #[test]
8088 fn unwrapped_component_never_gains_shadow_wrapper() {
8089 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8090 let processed = parser
8091 .process_component_template("<div>hi</div>", None, None)
8092 .expect("process failed");
8093 assert!(
8094 !processed.contains("shadowrootmode"),
8095 "framework must not generate a Shadow root, got: {processed}"
8096 );
8097 assert!(
8098 processed.contains("<div>hi</div>"),
8099 "Light content must survive processing, got: {processed}"
8100 );
8101 }
8102
8103 #[test]
8104 fn default_unwrapped_component_gains_open_shadow_wrapper() {
8105 let mut parser = HtmlParser::new();
8106 let processed = parser
8107 .process_component_template("<div>hi</div>", None, None)
8108 .expect("process failed");
8109 assert_eq!(
8110 processed,
8111 r#"<template shadowrootmode="open"><div>hi</div></template>"#
8112 );
8113 }
8114
8115 #[test]
8116 fn leading_comments_do_not_change_root_template_detection() {
8117 const COMMENT: &str = "<!-- Copyright (C) Corporation. All rights reserved. -->";
8118 let fixtures = [
8119 (
8120 format!(
8121 "{COMMENT}\n<template shadowrootmode=\"open\">\n <h1>Hello</h1>\n</template>"
8122 ),
8123 "<template shadowrootmode=\"open\">\n <h1>Hello</h1>\n</template>",
8124 ),
8125 (format!("{COMMENT}\n<h1>Hello</h1>"), "<h1>Hello</h1>"),
8126 (format!("{COMMENT}\nHello"), "Hello"),
8127 ];
8128
8129 for (input, expected) in fixtures {
8130 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8131 let processed = parser
8132 .process_component_template(&input, None, None)
8133 .expect("leading-comment fixture should process");
8134 assert_eq!(processed, expected);
8135 }
8136 }
8137
8138 #[test]
8139 fn component_policy_wrapper_is_build_only_and_unwrapped_for_light() {
8140 let html = r#"<template w-hydrate="lazy"><div>hi</div></template>"#;
8144 let mut shadow = HtmlParser::with_options(DomStrategy::Shadow);
8145 let shadow_built = shadow
8146 .build_component_templates(ComponentTemplateInput {
8147 tag_name: "my-comp",
8148 html,
8149 artifact_html: None,
8150 authored_html: None,
8151 css_content: None,
8152 artifact_needed: true,
8153 })
8154 .expect("shadow policy wrapper should compile");
8155 assert_eq!(
8156 shadow_built.ssr,
8157 r#"<template shadowrootmode="open"><div>hi</div></template>"#
8158 );
8159 assert!(!shadow_built.artifact().contains("w-hydrate"));
8160 let mut light = HtmlParser::with_options(DomStrategy::Light);
8161 let light_built = light
8162 .build_component_templates(ComponentTemplateInput {
8163 tag_name: "my-comp",
8164 html,
8165 artifact_html: None,
8166 authored_html: None,
8167 css_content: None,
8168 artifact_needed: true,
8169 })
8170 .expect("light policy wrapper should compile");
8171 assert_eq!(light_built.ssr, "<div>hi</div>");
8172 assert_eq!(light_built.artifact(), "<div>hi</div>");
8173
8174 let mut implicit_shadow = HtmlParser::new();
8175 let shadow_built = implicit_shadow
8176 .build_component_templates(ComponentTemplateInput {
8177 tag_name: "my-comp",
8178 html,
8179 artifact_html: None,
8180 authored_html: None,
8181 css_content: None,
8182 artifact_needed: true,
8183 })
8184 .expect("default policy wrapper should compile as Shadow");
8185 assert_eq!(
8186 shadow_built.ssr,
8187 r#"<template shadowrootmode="open"><div>hi</div></template>"#
8188 );
8189 assert!(!shadow_built.artifact().contains("w-hydrate"));
8190
8191 let shadow_html =
8194 r#"<template shadowrootmode="open" w-hydrate="lazy"><div>hi</div></template>"#;
8195 let mut shadow = HtmlParser::new();
8196 let authored_shadow_built = shadow
8197 .build_component_templates(ComponentTemplateInput {
8198 tag_name: "my-comp",
8199 html: shadow_html,
8200 artifact_html: None,
8201 authored_html: None,
8202 css_content: None,
8203 artifact_needed: true,
8204 })
8205 .expect("shadow policy wrapper should compile");
8206 assert_eq!(
8207 authored_shadow_built.ssr,
8208 r#"<template shadowrootmode="open"><div>hi</div></template>"#
8209 );
8210 assert!(!authored_shadow_built.artifact().contains("w-hydrate"));
8211 }
8212
8213 #[test]
8214 fn interaction_policy_marks_component_hosts_in_ssr_fragments() {
8215 let mut parser = HtmlParser::new();
8216 parser
8217 .component_registry
8218 .register_component(ComponentRegistration::new(
8219 "interaction-shell",
8220 r#"<template w-hydrate="interaction"><button>Open</button></template>"#,
8221 None,
8222 true,
8223 ))
8224 .expect("component registration");
8225 parser
8226 .parse(
8227 "index.html",
8228 "<body><interaction-shell></interaction-shell></body>",
8229 )
8230 .expect("entry parse");
8231 let records = parser.into_fragment_records();
8232 let entry_html: String = records["index.html"]
8233 .fragments
8234 .iter()
8235 .filter_map(|fragment| match fragment.fragment.as_ref() {
8236 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
8237 _ => None,
8238 })
8239 .collect();
8240 assert!(entry_html.contains("<interaction-shell data-webui-interaction"));
8241 }
8242
8243 #[test]
8244 fn combined_interaction_policy_marks_hosts_and_emits_render_css() {
8245 let mut parser = HtmlParser::new();
8246 parser
8247 .component_registry
8248 .register_component(ComponentRegistration::new(
8249 "interaction-panel",
8250 concat!(
8251 r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
8252 r#"w-hydrate="interaction"><button>Open</button></template>"#,
8253 ),
8254 None,
8255 true,
8256 ))
8257 .expect("component registration");
8258 parser
8259 .parse(
8260 "index.html",
8261 "<body><interaction-panel></interaction-panel></body>",
8262 )
8263 .expect("entry parse");
8264 assert!(parser
8265 .component_registry
8266 .render_policy_css(["interaction-panel"])
8267 .contains("content-visibility:auto"));
8268 let records = parser.into_fragment_records();
8269 let entry_html: String = records["index.html"]
8270 .fragments
8271 .iter()
8272 .filter_map(|fragment| match fragment.fragment.as_ref() {
8273 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
8274 _ => None,
8275 })
8276 .collect();
8277 assert!(entry_html.contains("<interaction-panel data-webui-interaction"));
8278 }
8279
8280 #[test]
8290 fn dev_template_module_strategy_appends_adopted_attr_when_missing() {
8291 let mut parser = HtmlParser::new();
8292 let processed = parser
8293 .process_component_template(
8294 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
8295 None,
8296 Some("my-comp"),
8297 )
8298 .expect("process failed");
8299 assert!(
8300 processed.contains(r#"shadowrootmode="open""#),
8301 "dev-authored shadowrootmode must be preserved, got: {processed}"
8302 );
8303 assert!(
8304 processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
8305 "module CSS should append adopted stylesheets, got: {processed}"
8306 );
8307 }
8308
8309 #[test]
8310 fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
8311 let mut parser = HtmlParser::new();
8312 let processed = parser
8313 .process_component_template(
8314 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
8315 None,
8316 Some("my-comp"),
8317 )
8318 .unwrap();
8319 assert!(
8320 processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
8321 "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
8322 );
8323 assert_eq!(
8324 processed.matches("shadowrootadoptedstylesheets").count(),
8325 1,
8326 "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
8327 );
8328 }
8329
8330 #[test]
8331 fn dev_template_module_strategy_appends_adopted_attr_and_preserves_root_attrs() {
8332 for dom_strategy in [DomStrategy::Shadow, DomStrategy::Light] {
8333 let mut parser = HtmlParser::with_options((CssStrategy::Module, dom_strategy));
8334 let built = match parser.build_component_templates(ComponentTemplateInput {
8335 tag_name: "my-comp",
8336 html: r#"<template shadowrootmode="open" @click="{onClick()}">Hello</template>"#,
8337 artifact_html: None,
8338 authored_html: None,
8339 css_content: Some(":host { color: red; }"),
8340 artifact_needed: true,
8341 }) {
8342 Ok(built) => built,
8343 Err(err) => panic!(
8344 "dev-authored <template> should be accepted under {dom_strategy:?} with module CSS, got: {err}"
8345 ),
8346 };
8347 let artifact = built.artifact();
8348
8349 assert!(artifact.contains(r#"shadowrootmode="open""#));
8350 assert!(artifact.contains(r#"@click="{onClick()}""#));
8351 assert!(artifact.contains(r#"shadowrootadoptedstylesheets="my-comp""#));
8352 assert_eq!(artifact.matches("shadowrootadoptedstylesheets").count(), 1);
8353 assert!(!built.ssr.contains("@click"));
8354 }
8355 }
8356
8357 #[test]
8358 fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
8359 let mut parser = HtmlParser::new();
8364 let processed = parser
8365 .process_component_template(
8366 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
8367 None,
8368 Some("my-comp"),
8369 )
8370 .unwrap();
8371 assert!(
8372 processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
8373 "dev's multi-specifier list must be preserved verbatim, got: {processed}"
8374 );
8375 }
8376
8377 #[test]
8378 fn dev_template_no_adopted_specifier_does_not_validate() {
8379 let mut parser = HtmlParser::new();
8383 let processed = parser
8384 .process_component_template(
8385 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
8386 None,
8387 None,
8388 )
8389 .unwrap();
8390 assert!(
8391 !processed.contains("shadowrootadoptedstylesheets"),
8392 "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
8393 );
8394 }
8395
8396 #[test]
8408 fn test_body_start_end_injection() {
8409 let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
8413 let (fragments, _) = parse_and_get_fragments(html);
8414
8415 assert_fragments!(
8416 fragments,
8417 [
8418 raw("<html><head>"),
8419 structural_matcher("head_start"),
8420 raw("<title>Test</title>"),
8421 structural_matcher("head_end"),
8422 raw("</head><body>"),
8423 structural_matcher("body_start"),
8424 raw("<div>Content</div><p>More</p>"),
8425 structural_matcher("body_end"),
8426 raw("</body></html>"),
8427 ]
8428 );
8429 }
8430
8431 #[test]
8432 fn test_body_preserves_static_attributes() {
8433 let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
8438 let (fragments, _) = parse_and_get_fragments(html);
8439
8440 assert_fragments!(
8441 fragments,
8442 [
8443 raw("<html><head>"),
8444 structural_matcher("head_start"),
8445 raw("<title>T</title>"),
8446 structural_matcher("head_end"),
8447 raw(r#"</head><body data-layout="doc" class="page">"#),
8448 structural_matcher("body_start"),
8449 raw("<p>x</p>"),
8450 structural_matcher("body_end"),
8451 raw("</body></html>"),
8452 ]
8453 );
8454 }
8455
8456 #[test]
8457 fn test_body_preserves_dynamic_attributes() {
8458 let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
8462 let (fragments, _) = parse_and_get_fragments(html);
8463
8464 assert_fragments!(
8465 fragments,
8466 [
8467 raw("<html><head>"),
8468 structural_matcher("head_start"),
8469 raw("<title>T</title>"),
8470 structural_matcher("head_end"),
8471 raw("</head><body"),
8472 attr("data-layout", "layout"),
8473 raw(">"),
8474 structural_matcher("body_start"),
8475 raw("<p>x</p>"),
8476 structural_matcher("body_end"),
8477 raw("</body></html>"),
8478 ]
8479 );
8480 }
8481
8482 #[test]
8483 fn test_head_start_signal_ordering_with_authored_script() {
8484 let html = r#"<html><head><script async src="./analytics.js"></script></head><body></body></html>"#;
8489 let (fragments, _) = parse_and_get_fragments(html);
8490
8491 assert_fragments!(
8492 fragments,
8493 [
8494 raw("<html><head>"),
8495 structural_matcher("head_start"),
8496 raw(r#"<script async src="./analytics.js"></script>"#),
8497 structural_matcher("head_end"),
8498 raw("</head><body>"),
8499 structural_matcher("body_start"),
8500 structural_matcher("body_end"),
8501 raw("</body></html>"),
8502 ]
8503 );
8504 }
8505
8506 #[test]
8507 fn test_head_static_attributes_preserve_legacy_opening_bytes() {
8508 let html = r#"<html><head data-theme="dark" class="app-head"><title>T</title></head><body></body></html>"#;
8512 let (fragments, _) = parse_and_get_fragments(html);
8513
8514 assert_fragments!(
8515 fragments,
8516 [
8517 raw("<html><head>"),
8518 structural_matcher("head_start"),
8519 raw("<title>T</title>"),
8520 structural_matcher("head_end"),
8521 raw("</head><body>"),
8522 structural_matcher("body_start"),
8523 structural_matcher("body_end"),
8524 raw("</body></html>"),
8525 ]
8526 );
8527 }
8528
8529 #[test]
8530 fn test_head_bound_attributes_preserve_legacy_opening_bytes() {
8531 let html =
8535 r#"<html><head data-theme="{{theme}}"><title>T</title></head><body></body></html>"#;
8536 let (fragments, _) = parse_and_get_fragments(html);
8537
8538 assert_fragments!(
8539 fragments,
8540 [
8541 raw("<html><head>"),
8542 structural_matcher("head_start"),
8543 raw("<title>T</title>"),
8544 structural_matcher("head_end"),
8545 raw("</head><body>"),
8546 structural_matcher("body_start"),
8547 structural_matcher("body_end"),
8548 raw("</body></html>"),
8549 ]
8550 );
8551 }
8552
8553 #[test]
8554 fn native_head_and_body_are_ascii_case_insensitive() {
8555 let html = r#"<html><HEAD data-theme="ignored"><title>T</title></HEAD><BODY data-layout="{{layout}}"><p>x</p></BODY></html>"#;
8556 let (fragments, _) = parse_and_get_fragments(html);
8557
8558 assert_fragments!(
8559 fragments,
8560 [
8561 raw(r#"<html><HEAD data-theme="ignored">"#),
8562 structural_matcher("head_start"),
8563 raw("<title>T</title>"),
8564 structural_matcher("head_end"),
8565 raw("</HEAD><BODY"),
8566 attr("data-layout", "layout"),
8567 raw(">"),
8568 structural_matcher("body_start"),
8569 raw("<p>x</p>"),
8570 structural_matcher("body_end"),
8571 raw("</BODY></html>"),
8572 ]
8573 );
8574 }
8575
8576 #[test]
8580 fn entry_boundary_is_an_inline_start_end_tape() {
8581 let html = r#"<body><div><boundary name="counter-ready"><my-counter></my-counter></boundary></div></body>"#;
8582 let (fragments, records) = parse_and_get_fragments(html);
8583
8584 let boundary = match fragments[3].fragment.as_ref() {
8585 Some(Fragment::Boundary(boundary)) => boundary,
8586 other => panic!("expected typed boundary start, got {other:?}"),
8587 };
8588 assert_eq!(boundary.phase(), BoundaryPhase::Start);
8589 assert_eq!(boundary.declaration_id, 0);
8590 assert_eq!(boundary.owner_fragment_id, "index.html");
8591 assert_eq!(boundary.name, "counter-ready");
8592 assert_eq!(boundary.key, None);
8593 assert!(!boundary.may_repeat);
8594 assert_stream!(
8597 records,
8598 "index.html",
8599 [
8600 raw("<body>"),
8601 structural_matcher("body_start"),
8602 raw("<div>"),
8603 boundary_matcher("counter-ready", 0),
8604 raw("<my-counter></my-counter>"),
8605 boundary_end_matcher(0),
8606 raw("</div>"),
8607 structural_matcher("body_end"),
8608 raw("</body>"),
8609 ]
8610 );
8611 assert!(records["index.html"].contains_boundary);
8612 assert!(
8613 !records.contains_key("boundary-1"),
8614 "an inline tape must not create a body record"
8615 );
8616 }
8617
8618 #[test]
8619 fn boundary_names_are_scoped_per_owner_and_declaration_ids_are_protocol_wide() {
8620 let mut parser = HtmlParser::new();
8621 parser
8622 .parse(
8623 "first.html",
8624 r#"<body><boundary name="a"><p>1</p></boundary></body>"#,
8625 )
8626 .expect("first entry parses");
8627 parser
8628 .parse(
8629 "second.html",
8630 r#"<body><boundary name="a"><p>2</p></boundary></body>"#,
8631 )
8632 .expect("a second owner may reuse the name");
8633 let records = parser.into_fragment_records();
8634 let first = records["first.html"]
8635 .fragments
8636 .iter()
8637 .find_map(|fragment| match fragment.fragment.as_ref() {
8638 Some(Fragment::Boundary(boundary)) => Some(boundary),
8639 _ => None,
8640 })
8641 .expect("first boundary");
8642 let second = records["second.html"]
8643 .fragments
8644 .iter()
8645 .find_map(|fragment| match fragment.fragment.as_ref() {
8646 Some(Fragment::Boundary(boundary)) => Some(boundary),
8647 _ => None,
8648 })
8649 .expect("second boundary");
8650 assert_eq!((first.declaration_id, first.name.as_str()), (0, "a"));
8651 assert_eq!((second.declaration_id, second.name.as_str()), (1, "a"));
8652 }
8653
8654 #[test]
8655 fn boundary_missing_name_errors() {
8656 for html in [
8657 "<body><boundary><p>x</p></boundary></body>",
8658 r#"<body><boundary name=""><p>x</p></boundary></body>"#,
8659 r#"<body><boundary name=" "><p>x</p></boundary></body>"#,
8660 ] {
8661 let mut parser = HtmlParser::new();
8662 let err = parser
8663 .parse("index.html", html)
8664 .expect_err("a boundary without a non-empty name must error");
8665 let ParserError::Template(diag) = err else {
8666 panic!("expected ParserError::Template, got {err:?}");
8667 };
8668 assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_NAME));
8669 }
8670 }
8671
8672 #[test]
8673 fn boundary_dynamic_name_errors() {
8674 let mut parser = HtmlParser::new();
8675 let err = parser
8676 .parse(
8677 "index.html",
8678 r#"<body><boundary name="{{sectionName}}"><p>x</p></boundary></body>"#,
8679 )
8680 .expect_err("a dynamic boundary name must error");
8681 let ParserError::Template(diag) = err else {
8682 panic!("expected ParserError::Template, got {err:?}");
8683 };
8684 assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_NAME));
8685 }
8686
8687 #[test]
8688 fn boundary_names_are_free_form_and_live_on_typed_fragments() {
8689 let mut parser = HtmlParser::new();
8690 let html = concat!(
8691 "<body>",
8692 r#"<boundary name="above the fold"><p>1</p></boundary>"#,
8693 r#"<boundary name="feed/items #2"><p>2</p></boundary>"#,
8694 r#"<boundary name="ダッシュボード"><p>3</p></boundary>"#,
8695 "</body>",
8696 );
8697 parser
8698 .parse("index.html", html)
8699 .expect("free-form boundary names must be accepted");
8700 let records = parser.into_fragment_records();
8701 let names: Vec<&str> = records["index.html"]
8702 .fragments
8703 .iter()
8704 .filter_map(|fragment| match fragment.fragment.as_ref() {
8705 Some(Fragment::Boundary(boundary)) if boundary.phase() == BoundaryPhase::Start => {
8706 Some(boundary.name.as_str())
8707 }
8708 _ => None,
8709 })
8710 .collect();
8711 assert_eq!(names, ["above the fold", "feed/items #2", "ダッシュボード"]);
8712 }
8713
8714 #[test]
8715 fn module_entry_srcs_records_only_preloadable_critical_entries() {
8716 let mut parser = HtmlParser::new();
8717 parser
8718 .component_registry
8719 .register_component(ComponentRegistration::new(
8720 "weather-island",
8721 r#"<script type="module" src="/nested-weather.js"></script>"#,
8722 None,
8723 true,
8724 ))
8725 .expect("register");
8726 let html = concat!(
8727 "<head>",
8728 r#"<script type="module" async src="/index.js"></script>"#,
8730 r#"<SCRIPT type=" Module " src="/late.js"></SCRIPT>"#,
8734 r#"<script src="/legacy.js"></script>"#,
8736 r#"<script type="module">import "./x.js";</script>"#,
8738 r#"<script type="module" src="/{{bundle}}.js"></script>"#,
8740 "</head>",
8741 "<body>",
8742 r#"<boundary name="weather">"#,
8744 r#"<script type="module" async src="/weather-panel.js"></script>"#,
8745 "<weather-island></weather-island>",
8746 "</boundary>",
8747 r#"<script type="module" src="/index.js"></script>"#,
8750 "</body>",
8751 );
8752 parser.parse("index.html", html).expect("parse");
8753
8754 assert_eq!(
8755 parser.module_entry_srcs(),
8756 ["/index.js", "/late.js"],
8757 "only static module entries outside boundaries are preloadable"
8758 );
8759 }
8760
8761 #[test]
8762 fn module_entry_srcs_reset_between_top_level_parses() {
8763 let mut parser = HtmlParser::new();
8764 parser
8765 .parse(
8766 "a.html",
8767 r#"<head><script type="module" src="/a.js"></script></head>"#,
8768 )
8769 .expect("parse a");
8770 assert_eq!(parser.module_entry_srcs(), ["/a.js"]);
8771
8772 parser
8773 .parse(
8774 "b.html",
8775 r#"<head><script type="module" src="/b.js"></script></head>"#,
8776 )
8777 .expect("parse b");
8778 assert_eq!(
8779 parser.module_entry_srcs(),
8780 ["/b.js"],
8781 "each entry owns its own critical modules"
8782 );
8783 }
8784
8785 #[test]
8786 fn boundary_duplicate_name_errors() {
8787 let mut parser = HtmlParser::new();
8788 let html = concat!(
8789 "<body>",
8790 r#"<boundary name="dup"><p>1</p></boundary>"#,
8791 r#"<boundary name="dup"><p>2</p></boundary>"#,
8792 "</body>",
8793 );
8794 let err = parser
8795 .parse("index.html", html)
8796 .expect_err("a duplicate boundary name must error");
8797 let ParserError::Template(diag) = err else {
8798 panic!("expected ParserError::Template, got {err:?}");
8799 };
8800 assert_eq!(diag.error_code(), Some(codes::DUPLICATE_BOUNDARY_NAME));
8801 }
8802
8803 #[test]
8804 fn boundary_nested_boundary_errors() {
8805 let mut parser = HtmlParser::new();
8806 let html = r#"<body><boundary name="outer"><boundary name="inner"><p>x</p></boundary></boundary></body>"#;
8807 let err = parser
8808 .parse("index.html", html)
8809 .expect_err("a nested boundary must error");
8810 let ParserError::Template(diag) = err else {
8811 panic!("expected ParserError::Template, got {err:?}");
8812 };
8813 assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
8814 }
8815
8816 #[test]
8817 fn boundary_transitively_nested_through_component_errors() {
8818 let mut parser = HtmlParser::new();
8819 parser
8820 .component_registry
8821 .register_component(ComponentRegistration::new(
8822 "inner-panel",
8823 r#"<boundary name="inner"><p>x</p></boundary>"#,
8824 None,
8825 true,
8826 ))
8827 .expect("register");
8828 let err = parser
8829 .parse(
8830 "index.html",
8831 r#"<body><boundary name="outer"><inner-panel></inner-panel></boundary></body>"#,
8832 )
8833 .expect_err("transitively nested boundaries must error");
8834 let ParserError::Template(diag) = err else {
8835 panic!("expected ParserError::Template, got {err:?}");
8836 };
8837 assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
8838 assert!(diag
8839 .help_text()
8840 .is_some_and(|help| help.contains("runtime branches")));
8841 }
8842
8843 #[test]
8844 fn boundary_inside_if_preserves_false_branch_structure() {
8845 let mut parser = HtmlParser::new();
8846 let html =
8847 r#"<body><if condition="ready"><boundary name="x"><p>x</p></boundary></if></body>"#;
8848 parser.parse("index.html", html).expect("parse");
8849 let records = parser.into_fragment_records();
8850 let if_fragment_id = records["index.html"]
8851 .fragments
8852 .iter()
8853 .find_map(|fragment| match fragment.fragment.as_ref() {
8854 Some(Fragment::IfCond(if_cond)) => Some(if_cond.fragment_id.as_str()),
8855 _ => None,
8856 })
8857 .expect("if reference");
8858 let boundary = records[if_fragment_id]
8859 .fragments
8860 .iter()
8861 .find_map(|fragment| match fragment.fragment.as_ref() {
8862 Some(Fragment::Boundary(boundary)) => Some(boundary),
8863 _ => None,
8864 })
8865 .expect("boundary remains behind the runtime if");
8866 assert_eq!(boundary.owner_fragment_id, "index.html");
8867 assert!(!boundary.may_repeat);
8868 assert!(records["index.html"].contains_boundary);
8869 assert!(records[if_fragment_id].contains_boundary);
8870 assert_stream!(
8871 records,
8872 if_fragment_id,
8873 [
8874 boundary_matcher("x", 0),
8875 raw("<p>x</p>"),
8876 boundary_end_matcher(0),
8877 ]
8878 );
8879 }
8880
8881 #[test]
8882 fn boundary_directly_inside_for_is_rejected() {
8883 let mut parser = HtmlParser::new();
8884 let err = parser
8885 .parse(
8886 "index.html",
8887 r#"<body><for each="item in items"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></for></body>"#,
8888 )
8889 .expect_err("a boundary inside a repeat must error");
8890 let ParserError::Template(diag) = err else {
8891 panic!("expected ParserError::Template, got {err:?}");
8892 };
8893 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
8894 assert!(
8895 diag.help_text()
8896 .is_some_and(|help| help.contains("wrap the whole <for>")),
8897 "{diag}"
8898 );
8899 }
8900
8901 #[test]
8902 fn boundary_under_if_inside_for_is_rejected() {
8903 let mut parser = HtmlParser::new();
8904 let err = parser
8905 .parse(
8906 "index.html",
8907 r#"<body><for each="item in items"><if condition="item.ready"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></if></for></body>"#,
8908 )
8909 .expect_err("a boundary behind a runtime branch in a repeat must error");
8910 let ParserError::Template(diag) = err else {
8911 panic!("expected ParserError::Template, got {err:?}");
8912 };
8913 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
8914 }
8915
8916 #[test]
8917 fn component_boundary_used_inside_for_is_rejected() {
8918 let mut parser = HtmlParser::new();
8919 parser
8920 .component_registry
8921 .register_component(ComponentRegistration::new(
8922 "row-item",
8923 r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
8924 None,
8925 true,
8926 ))
8927 .expect("register");
8928 let err = parser
8929 .parse(
8930 "index.html",
8931 r#"<body><for each="item in items"><row-item></row-item></for></body>"#,
8932 )
8933 .expect_err("a component-local boundary reached from a repeat must error");
8934 let ParserError::Template(diag) = err else {
8935 panic!("expected ParserError::Template, got {err:?}");
8936 };
8937 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
8938 assert!(diag.to_string().contains("row-ready"), "{diag}");
8941 assert_eq!(diag.component_name(), Some("index.html"));
8942 assert!(diag.to_string().contains("item in items"), "{diag}");
8943 }
8944
8945 #[test]
8946 fn transitive_repeat_boundary_diagnostic_uses_lowest_repeat_site() {
8947 for _ in 0..20 {
8948 let mut parser = HtmlParser::new();
8949 for (tag, template) in [
8950 (
8951 "left-list",
8952 r#"<for each="a in xs"><row-card></row-card></for>"#,
8953 ),
8954 (
8955 "right-list",
8956 r#"<for each="b in ys"><row-card></row-card></for>"#,
8957 ),
8958 (
8959 "row-card",
8960 r#"<boundary name="row-ready"><p>row</p></boundary>"#,
8961 ),
8962 ] {
8963 parser
8964 .component_registry
8965 .register_component(ComponentRegistration::new(tag, template, None, true))
8966 .expect("register");
8967 }
8968 let err = parser
8969 .parse(
8970 "index.html",
8971 r#"<body><left-list></left-list><right-list></right-list></body>"#,
8972 )
8973 .expect_err("both repeats reach the same boundary declaration");
8974 let ParserError::Template(diag) = err else {
8975 panic!("expected ParserError::Template, got {err:?}");
8976 };
8977 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
8978 assert_eq!(diag.component_name(), Some("left-list"));
8979 assert!(diag.to_string().contains("a in xs"), "{diag}");
8980 }
8981 }
8982
8983 #[test]
8984 fn route_boundary_reached_from_for_is_rejected() {
8985 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8986 parser
8987 .component_registry
8988 .register_component(ComponentRegistration::new(
8989 "row-page",
8990 r#"<boundary name="row-ready"><p>row</p></boundary>"#,
8991 None,
8992 true,
8993 ))
8994 .expect("register");
8995 let err = parser
8996 .parse(
8997 "index.html",
8998 r#"<body><for each="item in items"><route path="/" component="row-page"></route></for></body>"#,
8999 )
9000 .expect_err("a route-mounted boundary inside a repeat must error");
9001 let ParserError::Template(diag) = err else {
9002 panic!("expected ParserError::Template, got {err:?}");
9003 };
9004 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9005 }
9006
9007 #[test]
9008 fn outlet_mounted_boundary_reached_from_for_is_rejected() {
9009 let mut parser = HtmlParser::with_options(DomStrategy::Light);
9013 for (tag, template) in [
9014 (
9015 "shell-page",
9016 r#"<div><for each="item in items"><outlet></outlet></for></div>"#,
9017 ),
9018 (
9019 "child-page",
9020 r#"<boundary name="child-ready"><p>child</p></boundary>"#,
9021 ),
9022 ] {
9023 parser
9024 .component_registry
9025 .register_component(ComponentRegistration::new(tag, template, None, true))
9026 .expect("register");
9027 }
9028 let err = parser
9029 .parse(
9030 "index.html",
9031 r#"<body><route path="/" component="shell-page"><route path="child" component="child-page"></route></route></body>"#,
9032 )
9033 .expect_err("an outlet-mounted boundary inside a repeat must error");
9034 let ParserError::Template(diag) = err else {
9035 panic!("expected ParserError::Template, got {err:?}");
9036 };
9037 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9038 assert!(diag.to_string().contains("child-ready"), "{diag}");
9039 }
9040
9041 #[test]
9042 fn for_inside_one_boundary_is_allowed_and_stays_atomic() {
9043 let mut parser = HtmlParser::new();
9046 parser
9047 .parse(
9048 "index.html",
9049 r#"<body><boundary name="feed"><for each="item in items"><p>{{item.label}}</p></for></boundary></body>"#,
9050 )
9051 .expect("a repeat inside one boundary parses");
9052 let records = parser.into_fragment_records();
9053 assert!(records["index.html"].contains_boundary);
9054 assert!(
9055 !records["for-1"].contains_boundary,
9056 "the repeat body itself declares no boundary"
9057 );
9058 let boundary = records["index.html"]
9059 .fragments
9060 .iter()
9061 .find_map(|fragment| match fragment.fragment.as_ref() {
9062 Some(Fragment::Boundary(boundary)) => Some(boundary),
9063 _ => None,
9064 })
9065 .expect("boundary");
9066 assert!(!boundary.may_repeat);
9067 }
9068
9069 #[test]
9070 fn boundary_after_for_is_allowed() {
9071 let mut parser = HtmlParser::new();
9072 parser
9073 .parse(
9074 "index.html",
9075 r#"<body><for each="item in items"><p>{{item.label}}</p></for><boundary name="tail"><p>tail</p></boundary></body>"#,
9076 )
9077 .expect("a boundary following a repeat parses");
9078 let records = parser.into_fragment_records();
9079 assert!(records["index.html"].contains_boundary);
9080 assert!(!records["for-1"].contains_boundary);
9081 }
9082
9083 #[test]
9084 fn boundary_free_for_still_renders_without_boundary_metadata() {
9085 let mut parser = HtmlParser::new();
9086 parser
9087 .parse(
9088 "index.html",
9089 r#"<body><for each="item in items"><p>{{item.label}}</p></for></body>"#,
9090 )
9091 .expect("an ordinary repeat parses");
9092 let records = parser.into_fragment_records();
9093 assert!(!records["index.html"].contains_boundary);
9094 assert!(!records["for-1"].contains_boundary);
9095 }
9096
9097 #[test]
9098 fn empty_boundary_key_is_still_rejected() {
9099 let mut parser = HtmlParser::new();
9100 let err = parser
9101 .parse(
9102 "invalid.html",
9103 r#"<body><boundary name="x" key=""><p>x</p></boundary></body>"#,
9104 )
9105 .expect_err("an empty key must error");
9106 let ParserError::Template(diag) = err else {
9107 panic!("expected ParserError::Template, got {err:?}");
9108 };
9109 assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_KEY));
9110 }
9111
9112 #[test]
9113 fn boundary_inside_reusable_component_is_component_local() {
9114 let mut parser = HtmlParser::new();
9115 parser
9116 .component_registry
9117 .register_component(ComponentRegistration::new(
9118 "my-widget",
9119 r#"<boundary name="x"><p>x</p></boundary>"#,
9120 None,
9121 true,
9122 ))
9123 .expect("register");
9124
9125 parser
9126 .parse(
9127 "index.html",
9128 r#"<body><my-widget></my-widget><boundary name="x"><p>entry</p></boundary></body>"#,
9129 )
9130 .expect("component-local boundary parses");
9131 let records = parser.into_fragment_records();
9132 let boundary = records["my-widget"]
9133 .fragments
9134 .iter()
9135 .find_map(|fragment| match fragment.fragment.as_ref() {
9136 Some(Fragment::Boundary(boundary)) => Some(boundary),
9137 _ => None,
9138 })
9139 .expect("component boundary");
9140 assert_eq!(boundary.owner_fragment_id, "my-widget");
9141 assert_eq!(boundary.name, "x");
9142 assert!(records["my-widget"].contains_boundary);
9143 assert!(records["index.html"].contains_boundary);
9144 let owners: HashSet<&str> = records
9145 .values()
9146 .flat_map(|list| list.fragments.iter())
9147 .filter_map(|fragment| match fragment.fragment.as_ref() {
9148 Some(Fragment::Boundary(boundary)) if boundary.name == "x" => {
9149 Some(boundary.owner_fragment_id.as_str())
9150 }
9151 _ => None,
9152 })
9153 .collect();
9154 assert_eq!(owners, HashSet::from(["index.html", "my-widget"]));
9155 }
9156
9157 #[test]
9158 fn component_boundary_repeated_from_static_callsites_requires_a_key() {
9159 let mut parser = HtmlParser::new();
9163 parser
9164 .component_registry
9165 .register_component(ComponentRegistration::new(
9166 "row-item",
9167 r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
9168 None,
9169 true,
9170 ))
9171 .expect("register");
9172 let err = parser
9173 .parse(
9174 "index.html",
9175 r#"<body><row-item></row-item><row-item></row-item></body>"#,
9176 )
9177 .expect_err("a repeated declaration without a key must error");
9178 let ParserError::Template(diag) = err else {
9179 panic!("expected ParserError::Template, got {err:?}");
9180 };
9181 assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_KEY));
9182 assert_eq!(diag.component_name(), Some("row-item"));
9183
9184 let mut parser = HtmlParser::new();
9185 parser
9186 .component_registry
9187 .register_component(ComponentRegistration::new(
9188 "row-item",
9189 r#"<boundary name="row-ready" key="{{rowId}}"><span>{{label}}</span></boundary>"#,
9190 None,
9191 true,
9192 ))
9193 .expect("register");
9194 parser
9195 .parse(
9196 "index.html",
9197 r#"<body><row-item row-id="a"></row-item><row-item row-id="b"></row-item></body>"#,
9198 )
9199 .expect("a keyed repeated declaration parses");
9200 let records = parser.into_fragment_records();
9201 let boundary = records["row-item"]
9202 .fragments
9203 .iter()
9204 .find_map(|fragment| match fragment.fragment.as_ref() {
9205 Some(Fragment::Boundary(boundary)) => Some(boundary),
9206 _ => None,
9207 })
9208 .expect("component boundary");
9209 assert!(boundary.may_repeat);
9210 assert_eq!(boundary.key.as_deref(), Some("{{rowId}}"));
9211 }
9212
9213 #[test]
9214 fn component_boundary_once_per_independent_entry_is_not_repeatable() {
9215 let mut parser = HtmlParser::new();
9216 parser
9217 .component_registry
9218 .register_component(ComponentRegistration::new(
9219 "shared-panel",
9220 r#"<boundary name="ready"><span>x</span></boundary>"#,
9221 None,
9222 true,
9223 ))
9224 .expect("register");
9225 for entry in ["first.html", "second.html"] {
9226 parser
9227 .parse(entry, "<body><shared-panel></shared-panel></body>")
9228 .expect("entry parses");
9229 }
9230 let records = parser.into_fragment_records();
9231 let boundary = records["shared-panel"]
9232 .fragments
9233 .iter()
9234 .find_map(|fragment| match fragment.fragment.as_ref() {
9235 Some(Fragment::Boundary(boundary)) => Some(boundary),
9236 _ => None,
9237 })
9238 .expect("component boundary");
9239 assert!(!boundary.may_repeat);
9240 }
9241
9242 #[test]
9243 fn boundary_inside_component_host_content_errors() {
9244 let mut parser = HtmlParser::new();
9245 parser
9246 .component_registry
9247 .register_component(ComponentRegistration::new(
9248 "my-widget",
9249 "<div>content</div>",
9250 None,
9251 true,
9252 ))
9253 .expect("register");
9254
9255 let err = parser
9256 .parse(
9257 "index.html",
9258 r#"<body><my-widget><boundary name="x"><p>x</p></boundary></my-widget></body>"#,
9259 )
9260 .expect_err("a boundary inside component host content must error");
9261 let ParserError::Template(diag) = err else {
9262 panic!("expected ParserError::Template, got {err:?}");
9263 };
9264 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
9265 assert!(
9266 diag.to_string().contains("component host content"),
9267 "{diag}"
9268 );
9269 }
9270
9271 #[test]
9272 fn boundary_inside_raw_or_inert_html_context_errors() {
9273 for (element, expected_scope) in [
9274 ("textarea", "<textarea> text content"),
9275 ("title", "<title> text content"),
9276 ("script", "<script> raw-text content"),
9277 ("xmp", "<xmp> raw-text content"),
9278 ("iframe", "<iframe> raw-text content"),
9279 ("noembed", "<noembed> raw-text content"),
9280 ("noframes", "<noframes> raw-text content"),
9281 ("noscript", "<noscript> inert content"),
9282 ("plaintext", "<plaintext> text content"),
9283 ("template", "<template> inert content"),
9284 ] {
9285 let mut parser = HtmlParser::new();
9286 let html = format!(
9287 r#"<body><{element}><boundary name="x"><p>x</p></boundary></{element}></body>"#
9288 );
9289 let err = parser
9290 .parse("index.html", &html)
9291 .expect_err("a boundary in raw or inert content must error");
9292 let ParserError::Template(diag) = err else {
9293 panic!("expected ParserError::Template, got {err:?}");
9294 };
9295 assert_eq!(
9296 diag.error_code(),
9297 Some(codes::BOUNDARY_CROSSES_SCOPE),
9298 "unexpected code for <{element}>"
9299 );
9300 assert!(
9301 diag.to_string().contains(expected_scope),
9302 "unexpected scope for <{element}>: {diag}"
9303 );
9304 }
9305 }
9306
9307 #[test]
9308 fn route_contained_boundary_has_runtime_content_fragment() {
9309 let mut parser = HtmlParser::with_options(DomStrategy::Light);
9310 parser
9311 .component_registry
9312 .register_component(ComponentRegistration::new(
9313 "home-page",
9314 "<h1>Home</h1>",
9315 None,
9316 true,
9317 ))
9318 .expect("register");
9319 parser
9320 .parse(
9321 "index.html",
9322 r#"<route path="/" component="home-page"><boundary name="route-ready"><aside>Ready</aside></boundary></route>"#,
9323 )
9324 .expect("route-contained boundary parses");
9325 let records = parser.into_fragment_records();
9326 assert!(records["index.html"].contains_boundary);
9327 let route = records["index.html"]
9328 .fragments
9329 .iter()
9330 .find_map(|fragment| match fragment.fragment.as_ref() {
9331 Some(Fragment::Route(route)) => Some(route),
9332 _ => None,
9333 })
9334 .expect("route");
9335 assert!(!route.content_fragment_id.is_empty());
9336 let boundary = records[&route.content_fragment_id]
9337 .fragments
9338 .iter()
9339 .find_map(|fragment| match fragment.fragment.as_ref() {
9340 Some(Fragment::Boundary(boundary)) => Some(boundary),
9341 _ => None,
9342 })
9343 .expect("route boundary");
9344 assert_eq!(boundary.owner_fragment_id, "index.html");
9345 assert_eq!(boundary.name, "route-ready");
9346 assert_stream!(
9347 records,
9348 route.content_fragment_id.as_str(),
9349 [
9350 boundary_matcher("route-ready", 0),
9351 raw("<aside>Ready</aside>"),
9352 boundary_end_matcher(0),
9353 ]
9354 );
9355 }
9356
9357 #[test]
9358 fn route_boundary_nested_in_ignored_markup_errors() {
9359 let mut parser = HtmlParser::new();
9360 let err = parser
9361 .parse(
9362 "index.html",
9363 r#"<route path="/" component="home-page"><div><boundary name="late"><p>x</p></boundary></div></route>"#,
9364 )
9365 .expect_err("route boundaries must be direct children");
9366 let ParserError::Template(diag) = err else {
9367 panic!("expected ParserError::Template, got {err:?}");
9368 };
9369 assert_eq!(
9370 diag.error_code(),
9371 Some(codes::INVALID_ROUTE_BOUNDARY_PLACEMENT)
9372 );
9373 }
9374
9375 #[test]
9376 fn boundary_must_be_inside_an_open_body() {
9377 for html in [
9378 r#"<boundary name="before"><p>x</p></boundary><body></body>"#,
9379 r#"<body></body><boundary name="after"><p>x</p></boundary>"#,
9380 ] {
9381 let mut parser = HtmlParser::new();
9382 let err = parser
9383 .parse("index.html", html)
9384 .expect_err("a boundary outside body must error");
9385 let ParserError::Template(diag) = err else {
9386 panic!("expected ParserError::Template, got {err:?}");
9387 };
9388 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_OUTSIDE_BODY));
9389 assert_eq!(
9390 diag.help_text(),
9391 Some("move the <boundary> between the opening <body> and its matching </body>")
9392 );
9393 }
9394
9395 let mut parser = HtmlParser::new();
9396 parser
9397 .parse(
9398 "index.html",
9399 r#"<BODY><boundary name="inside"></boundary></BODY>"#,
9400 )
9401 .expect("mixed-case native body keeps the boundary inside an open body");
9402 }
9403
9404 #[test]
9405 fn boundary_inside_foster_parenting_context_errors() {
9406 for html in [
9410 r#"<body><table><boundary name="t"><p>x</p></boundary></table></body>"#,
9411 r#"<body><table><tbody><boundary name="t"><p>x</p></boundary></tbody></table></body>"#,
9412 r#"<body><table><thead><boundary name="t"><p>x</p></boundary></thead></table></body>"#,
9413 r#"<body><table><tfoot><boundary name="t"><p>x</p></boundary></tfoot></table></body>"#,
9414 r#"<body><table><tbody><tr><boundary name="t"><p>x</p></boundary></tr></tbody></table></body>"#,
9415 r#"<body><table><colgroup><boundary name="t"><p>x</p></boundary></colgroup></table></body>"#,
9416 r#"<body><select><boundary name="t"><option>x</option></boundary></select></body>"#,
9417 r#"<body><select><optgroup><boundary name="t"><option>x</option></boundary></optgroup></select></body>"#,
9418 r#"<body><TABLE><TBODY><boundary name="t"><p>x</p></boundary></TBODY></TABLE></body>"#,
9421 ] {
9422 let mut parser = HtmlParser::new();
9423 let err = parser
9424 .parse("index.html", html)
9425 .expect_err("a boundary in a foster-parenting context must error");
9426 let ParserError::Template(diag) = err else {
9427 panic!("expected ParserError::Template, got {err:?}");
9428 };
9429 assert_eq!(
9430 diag.error_code(),
9431 Some(codes::BOUNDARY_IN_FOSTER_CONTEXT),
9432 "unexpected code for {html}"
9433 );
9434 assert!(
9435 diag.help_text()
9436 .is_some_and(|help| help.contains("<td>, <th>, or <caption>")),
9437 "help should suggest a safe placement for {html}"
9438 );
9439 }
9440 }
9441
9442 #[test]
9443 fn boundary_is_allowed_where_insertion_mode_returns_to_in_body() {
9444 for html in [
9448 r#"<body><table><tbody><tr><td><boundary name="cell"><p>x</p></boundary></td></tr></tbody></table></body>"#,
9449 r#"<body><table><tbody><tr><th><boundary name="head"><p>x</p></boundary></th></tr></tbody></table></body>"#,
9450 r#"<body><table><caption><boundary name="cap"><p>x</p></boundary></caption></table></body>"#,
9451 r#"<body><boundary name="whole"><table><tbody><tr><td>x</td></tr></tbody></table></boundary></body>"#,
9453 concat!(
9457 "<body><table><tbody><tr><td>",
9458 "<table><tbody><tr><td>inner</td></tr></tbody></table>",
9459 r#"<boundary name="after-inner"><p>x</p></boundary>"#,
9460 "</td></tr></tbody></table></body>",
9461 ),
9462 r#"<body><table><tbody><tr><td><img src="a"><br><input></td></tr></tbody></table><boundary name="v"><p>x</p></boundary></body>"#,
9464 ] {
9465 let mut parser = HtmlParser::new();
9466 parser
9467 .parse("index.html", html)
9468 .unwrap_or_else(|err| panic!("{html} should parse, got {err:?}"));
9469 }
9470 }
9471
9472 #[test]
9473 fn foster_context_depth_is_restored_after_leaving_a_table() {
9474 let mut parser = HtmlParser::new();
9477 parser
9478 .parse(
9479 "index.html",
9480 concat!(
9481 "<body>",
9482 "<table><tbody><tr><td>x</td></tr></tbody></table>",
9483 r#"<boundary name="after-table"><p>y</p></boundary>"#,
9484 "</body>",
9485 ),
9486 )
9487 .expect("a boundary after a closed table must be accepted");
9488 }
9489
9490 #[test]
9491 fn authored_raw_bindings_cannot_forge_structural_signals() {
9492 let html = concat!(
9493 "<html><head></head><body>",
9494 "{{{head_start}}}{{{head_end}}}{{{body_start}}}{{{body_end}}}",
9495 "{{{boundary_start:0}}}{{{boundary_end:0}}}",
9496 "{{{streaming_root:forged}}}",
9497 "</body></html>",
9498 );
9499 let (fragments, _) = parse_and_get_fragments(html);
9500 let raw_signals: Vec<&str> = fragments
9501 .iter()
9502 .filter_map(|fragment| match fragment.fragment.as_ref() {
9503 Some(Fragment::Signal(signal)) if signal.raw => Some(signal.value.as_str()),
9504 _ => None,
9505 })
9506 .collect();
9507
9508 assert_eq!(
9509 raw_signals,
9510 [
9511 "}}}webui:head_start",
9512 "}}}webui:head_end",
9513 "}}}webui:body_start",
9514 "head_start",
9515 "head_end",
9516 "body_start",
9517 "body_end",
9518 "boundary_start:0",
9519 "boundary_end:0",
9520 "streaming_root:forged",
9521 "}}}webui:body_end",
9522 ]
9523 );
9524 }
9525
9526 #[test]
9527 fn authored_webui_hydrate_errors_at_top_level() {
9528 for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
9529 let mut parser = HtmlParser::new();
9530 let html = format!("<div><{tag}></{tag}></div>");
9531 let err = parser
9532 .parse("index.html", &html)
9533 .expect_err("an authored <webui-hydrate> must error in any ASCII casing");
9534 let ParserError::Template(diag) = err else {
9535 panic!("expected ParserError::Template, got {err:?}");
9536 };
9537 assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
9538 }
9539 }
9540
9541 #[test]
9542 fn authored_webui_hydrate_errors_inside_route() {
9543 for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
9544 let mut parser = HtmlParser::new();
9545 let html = format!(r#"<route path="/" component="home"><{tag}></{tag}></route>"#);
9546 let err = parser
9547 .parse("index.html", &html)
9548 .expect_err("an authored <webui-hydrate> inside <route> must error");
9549 let ParserError::Template(diag) = err else {
9550 panic!("expected ParserError::Template, got {err:?}");
9551 };
9552 assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
9553 }
9554 }
9555
9556 #[test]
9557 fn boundary_directive_remains_case_sensitive() {
9558 let (fragments, _) =
9559 parse_and_get_fragments(r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#);
9560
9561 assert_fragments!(
9562 fragments,
9563 [raw(
9564 r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#
9565 ),]
9566 );
9567 }
9568
9569 #[test]
9570 fn templates_without_webui_boundary_are_unaffected() {
9571 let html = r#"<div><my-counter count="{{count}}"></my-counter></div>"#;
9575 let (fragments, _) = parse_and_get_fragments(html);
9576
9577 assert_fragments!(
9578 fragments,
9579 [
9580 raw("<div><my-counter"),
9581 attr("count", "count"),
9582 raw("></my-counter></div>"),
9583 ]
9584 );
9585 }
9586
9587 #[test]
9588 fn test_fail_with_invalid_markup() {
9589 let mut parser = HtmlParser::new();
9591 let result = parser.parse("index.html", "<div><span></div></span>");
9592
9593 assert!(matches!(result, Err(ParserError::Template(diag)) if
9594 diag.to_string().contains("unclosed <span> tag")
9595 ));
9596 }
9597
9598 #[test]
9599 fn test_complex_raw_text_page() {
9600 let html = concat!(
9603 "<!DOCTYPE html>",
9604 "<html lang=\"en\">",
9605 "<head>",
9606 "<meta charset=\"utf-8\">",
9607 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
9608 "<title>Complex Page</title>",
9609 "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
9610 "<link rel=\"stylesheet\" href=\"styles.css\">",
9611 "</head>",
9612 "<body>",
9613 "<h1>Hello World</h1>",
9614 "<script type=\"module\" src=\"./app.js\"></script>",
9615 "</body>",
9616 "</html>",
9617 );
9618 let (fragments, _) = parse_and_get_fragments(html);
9619
9620 assert!(
9624 fragments.len() >= 9,
9625 "Expected at least 9 fragments, got {}",
9626 fragments.len()
9627 );
9628
9629 assert!(
9631 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
9632 raw.value.contains("<!DOCTYPE html>") && raw.value.ends_with("<head>")),
9633 "First fragment should contain DOCTYPE and end with <head>, got: {:?}",
9634 fragments[0]
9635 );
9636
9637 assert!(
9639 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
9640 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start")),
9641 "Second fragment should be head_start signal"
9642 );
9643
9644 assert!(
9646 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
9647 raw.value.contains("<meta charset=\"utf-8\">") &&
9648 raw.value.contains("<meta name=\"viewport\"") &&
9649 raw.value.contains("<title>Complex Page</title>") &&
9650 raw.value.contains("<style>") &&
9651 raw.value.contains("body { margin: 0; padding: 0; }")),
9652 "Third fragment should contain all head content, got: {:?}",
9653 fragments[2]
9654 );
9655
9656 assert!(
9658 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
9659 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end")),
9660 "Fourth fragment should be head_end signal"
9661 );
9662
9663 assert!(
9665 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
9666 raw.value.contains("</head>") && raw.value.ends_with("<body>")),
9667 "Fifth fragment should contain </head><body>"
9668 );
9669
9670 assert!(
9672 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
9673 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start")),
9674 "Sixth fragment should be body_start signal"
9675 );
9676
9677 assert!(
9679 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
9680 raw.value.contains("<h1>Hello World</h1>") &&
9681 raw.value.contains("<script")),
9682 "Seventh fragment should contain body content"
9683 );
9684
9685 assert!(
9687 matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
9688 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end")),
9689 "Eighth fragment should be body_end signal"
9690 );
9691
9692 assert!(
9694 matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
9695 raw.value.contains("</body>") && raw.value.contains("</html>")),
9696 "Ninth fragment should contain closing tags"
9697 );
9698 }
9699
9700 struct BindingCountPlugin {
9704 counts: Vec<u32>,
9705 }
9706
9707 impl BindingCountPlugin {
9708 fn new() -> Self {
9709 Self { counts: Vec::new() }
9710 }
9711 }
9712
9713 impl crate::plugin::ParserPlugin for BindingCountPlugin {
9714 fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
9715 let attr_name = context.name;
9716 if attr_name.starts_with('@') || attr_name == "f-ref" {
9717 AttributeAction::SkipAndCountBinding
9718 } else {
9719 AttributeAction::Keep
9720 }
9721 }
9722
9723 fn finish_opening_tag(&mut self, context: ElementStartContext<'_>) -> Option<Vec<u8>> {
9724 let binding_attribute_count = context.binding_count;
9725 self.counts.push(binding_attribute_count);
9726 if binding_attribute_count > 0 {
9727 Some(binding_attribute_count.to_le_bytes().to_vec())
9728 } else {
9729 None
9730 }
9731 }
9732 }
9733
9734 struct TemplateCapturePlugin {
9735 template: Option<String>,
9736 uses_shadow_dom: bool,
9737 }
9738
9739 impl TemplateCapturePlugin {
9740 fn new() -> Self {
9741 Self {
9742 template: None,
9743 uses_shadow_dom: false,
9744 }
9745 }
9746 }
9747
9748 impl crate::plugin::ParserPlugin for TemplateCapturePlugin {
9749 fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
9750 self.template = Some(context.template.to_string());
9751 self.uses_shadow_dom = context.uses_shadow_dom;
9752 Ok(())
9753 }
9754
9755 fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
9756 let attr_name = context.name;
9757 if attr_name.starts_with('@') || attr_name == "f-ref" {
9758 AttributeAction::SkipAndCountBinding
9759 } else {
9760 AttributeAction::Keep
9761 }
9762 }
9763
9764 fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
9765 match self.template {
9766 Some(template) => Ok(ParserPluginArtifacts::ComponentTemplates(vec![
9767 crate::plugin::ComponentTemplateArtifact::template(
9768 "todo-app".to_string(),
9769 template,
9770 self.uses_shadow_dom,
9771 ),
9772 ])),
9773 None => Ok(ParserPluginArtifacts::None),
9774 }
9775 }
9776 }
9777
9778 #[test]
9779 fn parser_passes_user_template_root_attrs_to_plugin_without_css_modules() {
9780 let mut parser = HtmlParser::with_plugin_options(
9781 Box::new(TemplateCapturePlugin::new()),
9782 CssStrategy::Link,
9783 );
9784 parser
9785 .component_registry_mut()
9786 .register_component(ComponentRegistration::new(
9787 "todo-app",
9788 r#"<template shadowrootmode="open" @toggle-item="{onToggleItem($e)}" @delete-item="{onDeleteItem($e)}" f-ref="{root}"><div>items</div></template>"#,
9789 Some(":host { display: block; }"),
9790 true,))
9791 .expect("register todo-app");
9792
9793 parser
9794 .parse("index.html", "<todo-app></todo-app>")
9795 .expect("parse failed");
9796
9797 let artifacts = parser.take_plugin_artifacts().expect("artifacts");
9798 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
9799 panic!("expected captured component template");
9800 };
9801 let template = &templates[0].template;
9802
9803 assert!(
9804 template.contains(r#"shadowrootmode="open""#),
9805 "parser must pass dev-authored root template attrs to plugins, got: {template}"
9806 );
9807 assert!(
9808 template.contains(r#"@toggle-item="{onToggleItem($e)}""#),
9809 "parser must pass dev-authored root event attrs to plugins, got: {template}"
9810 );
9811 assert!(
9812 template.contains(r#"@delete-item="{onDeleteItem($e)}""#),
9813 "parser must pass dev-authored root event attrs to plugins, got: {template}"
9814 );
9815 assert!(
9816 template.contains(r#"f-ref="{root}""#),
9817 "parser must pass dev-authored root FAST attrs to plugins, got: {template}"
9818 );
9819 assert!(
9820 !template.contains("shadowrootadoptedstylesheets"),
9821 "link CSS must not add CSS module adoption attrs, got: {template}"
9822 );
9823 }
9824
9825 #[test]
9826 fn test_component_static_attrs_not_counted_as_bindings() {
9827 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
9828 parser
9829 .component_registry
9830 .register_component(ComponentRegistration::new(
9831 "my-btn",
9832 r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
9833 None,
9834 true,
9835 ))
9836 .expect("register");
9837
9838 let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
9840 let result = parser.parse("index.html", html);
9841 assert!(result.is_ok());
9842
9843 let records = parser.into_fragment_records();
9844
9845 let frags = &records["index.html"].fragments;
9847 let plugin_count = frags
9848 .iter()
9849 .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
9850 .count();
9851 assert_eq!(
9852 plugin_count, 0,
9853 "Static-only component attributes should not emit a Plugin fragment"
9854 );
9855 }
9856
9857 #[test]
9858 fn test_component_dynamic_attr_counted_as_binding() {
9859 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
9860 parser
9861 .component_registry
9862 .register_component(ComponentRegistration::new(
9863 "my-btn",
9864 r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
9865 None,
9866 true,
9867 ))
9868 .expect("register");
9869
9870 let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
9872 let result = parser.parse("index.html", html);
9873 assert!(result.is_ok());
9874
9875 let records = parser.into_fragment_records();
9876 let frags = &records["index.html"].fragments;
9877
9878 let plugin_frags: Vec<_> = frags
9880 .iter()
9881 .filter_map(|f| match f.fragment.as_ref() {
9882 Some(Fragment::Plugin(p)) => Some(&p.data),
9883 _ => None,
9884 })
9885 .collect();
9886 assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
9887 let count = u32::from_le_bytes([
9888 plugin_frags[0][0],
9889 plugin_frags[0][1],
9890 plugin_frags[0][2],
9891 plugin_frags[0][3],
9892 ]);
9893 assert_eq!(count, 1, "Binding attribute count should be 1");
9894 }
9895
9896 #[test]
9897 fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
9898 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
9899 parser
9900 .component_registry
9901 .register_component(ComponentRegistration::new(
9902 "my-btn",
9903 r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
9904 None,
9905 true,
9906 ))
9907 .expect("register");
9908
9909 let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
9911 let result = parser.parse("index.html", html);
9912 assert!(result.is_ok());
9913
9914 let records = parser.into_fragment_records();
9915 let frags = &records["index.html"].fragments;
9916
9917 let plugin_frags: Vec<_> = frags
9918 .iter()
9919 .filter_map(|f| match f.fragment.as_ref() {
9920 Some(Fragment::Plugin(p)) => Some(&p.data),
9921 _ => None,
9922 })
9923 .collect();
9924 assert_eq!(plugin_frags.len(), 1);
9925 let count = u32::from_le_bytes([
9926 plugin_frags[0][0],
9927 plugin_frags[0][1],
9928 plugin_frags[0][2],
9929 plugin_frags[0][3],
9930 ]);
9931 assert_eq!(
9933 count, 2,
9934 "Binding count should include dynamic + plugin-skipped attrs, not static"
9935 );
9936 }
9937
9938 #[test]
9939 fn test_component_only_skipped_plugin_attrs_counted() {
9940 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
9941 parser
9942 .component_registry
9943 .register_component(ComponentRegistration::new(
9944 "my-btn",
9945 r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
9946 None,
9947 true,
9948 ))
9949 .expect("register");
9950
9951 let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
9953 let result = parser.parse("index.html", html);
9954 assert!(result.is_ok());
9955
9956 let records = parser.into_fragment_records();
9957 let frags = &records["index.html"].fragments;
9958
9959 let plugin_frags: Vec<_> = frags
9960 .iter()
9961 .filter_map(|f| match f.fragment.as_ref() {
9962 Some(Fragment::Plugin(p)) => Some(&p.data),
9963 _ => None,
9964 })
9965 .collect();
9966 assert_eq!(plugin_frags.len(), 1);
9967 let count = u32::from_le_bytes([
9968 plugin_frags[0][0],
9969 plugin_frags[0][1],
9970 plugin_frags[0][2],
9971 plugin_frags[0][3],
9972 ]);
9973 assert_eq!(
9975 count, 2,
9976 "Only plugin-skipped attrs should be counted, not static title"
9977 );
9978 }
9979
9980 #[test]
9983 fn test_comment_handlebars_signal_is_stripped() {
9984 let mut parser = HtmlParser::new();
9985 let html = "<!--{{tokens}}-->";
9986 parser.parse("test.html", html).expect("parse failed");
9987 let records = parser.into_fragment_records();
9988
9989 assert_stream!(records, "test.html", []);
9990 }
9991
9992 #[test]
9993 fn test_comment_triple_brace_raw_signal_is_stripped() {
9994 let mut parser = HtmlParser::new();
9995 let html = "<!--{{{tokens}}}-->";
9996 parser.parse("test.html", html).expect("parse failed");
9997 let records = parser.into_fragment_records();
9998
9999 assert_stream!(records, "test.html", []);
10000 }
10001
10002 #[test]
10003 fn test_comment_regular_stripped() {
10004 let mut parser = HtmlParser::new();
10005 let html = "<!-- regular comment -->";
10006 parser.parse("test.html", html).expect("parse failed");
10007 let records = parser.into_fragment_records();
10008
10009 assert_stream!(records, "test.html", []);
10010 }
10011
10012 #[test]
10013 fn test_comment_dotted_signal_is_stripped() {
10014 let mut parser = HtmlParser::new();
10015 let html = "<!--{{tokens.light}}-->";
10016 parser.parse("test.html", html).expect("parse failed");
10017 let records = parser.into_fragment_records();
10018
10019 assert_stream!(records, "test.html", []);
10020 }
10021
10022 #[test]
10023 fn test_comment_arbitrary_identifier_is_stripped() {
10024 let mut parser = HtmlParser::new();
10025 let html = "<!--{{someOtherBinding}}-->";
10026 parser.parse("test.html", html).expect("parse failed");
10027 let records = parser.into_fragment_records();
10028
10029 assert_stream!(records, "test.html", []);
10030 }
10031
10032 #[test]
10033 fn test_comment_with_surrounding_content_is_stripped() {
10034 let mut parser = HtmlParser::new();
10035 let html = "<div><!--{{tokens}}--></div>";
10036 parser.parse("test.html", html).expect("parse failed");
10037 let records = parser.into_fragment_records();
10038
10039 assert_stream!(records, "test.html", [raw("<div></div>")]);
10040 }
10041
10042 #[test]
10043 fn test_component_template_unterminated_comment_returns_error() {
10044 let mut parser = HtmlParser::new();
10045 parser
10046 .component_registry_mut()
10047 .register_component(ComponentRegistration::new(
10048 "x-bad",
10049 "<span>ok</span><!-- missing close",
10050 None,
10051 true,
10052 ))
10053 .expect("register failed");
10054
10055 let result = parser.parse("index.html", "<x-bad></x-bad>");
10056
10057 assert!(matches!(result, Err(ParserError::Template(diag)) if
10058 diag.to_string().contains("unterminated HTML comment")
10059 ));
10060 }
10061
10062 #[test]
10063 fn test_webui_plugin_strips_component_comments_before_metadata() {
10064 let mut parser =
10065 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
10066 parser
10067 .component_registry_mut()
10068 .register_component(ComponentRegistration::new(
10069 "x-bleed",
10070 r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
10071 None,
10072 true,
10073 ))
10074 .expect("register failed");
10075
10076 parser
10077 .parse("index.html", "<x-bleed></x-bleed>")
10078 .expect("parse failed");
10079
10080 let artifacts = parser.take_plugin_artifacts().unwrap();
10081 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10082 panic!("expected component templates");
10083 };
10084 let template = &templates[0].template_json;
10085 assert!(template.contains("<div>hello</div>"));
10086 assert!(!template.contains("{{path}}"));
10087 assert!(!template.contains("@click"));
10088 assert!(!template.contains("?hidden"));
10089 assert!(!template.contains("-->"));
10090 }
10091
10092 #[test]
10093 fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
10094 let mut parser =
10095 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
10096 parser
10097 .component_registry_mut()
10098 .register_component(ComponentRegistration::new(
10099 "x-style",
10100 "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
10101 None,
10102 true,
10103 ))
10104 .expect("register failed");
10105
10106 parser
10107 .parse("index.html", "<x-style></x-style>")
10108 .expect("parse failed");
10109
10110 let artifacts = parser.take_plugin_artifacts().unwrap();
10111 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10112 panic!("expected component templates");
10113 };
10114 let template = &templates[0].template_json;
10115 assert!(template.contains(".x { color: red; }"));
10116 assert!(!template.contains("ignored"));
10117 assert!(!template.contains("//"));
10118 }
10119
10120 #[test]
10123 fn test_tokens_from_style_tag() {
10124 let mut parser = HtmlParser::new();
10125 let html = r#"<style>
10126 .btn { color: var(--colorPrimary); background: var(--bgColor); }
10127 </style>"#;
10128 parser.parse("test.html", html).expect("parse failed");
10129 let tokens = parser.take_tokens();
10130
10131 assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
10132 }
10133
10134 #[test]
10135 fn test_tokens_from_malformed_style_error_on_unclosed_var() {
10136 let mut parser = HtmlParser::new();
10137 let html = r#"<style>
10138 .bad { color: var(--dangling; }
10139 .ok { color: var(--valid); }
10140 </style>"#;
10141 let result = parser.parse("test.html", html);
10142
10143 assert!(matches!(result, Err(ParserError::Template(diag)) if
10144 diag.error_code() == Some(codes::INVALID_CSS)
10145 && diag.to_string().contains("Unterminated CSS var() call")
10146 ));
10147 }
10148
10149 #[test]
10150 fn test_tokens_from_component_css() {
10151 let mut parser = HtmlParser::new();
10152 parser
10153 .component_registry_mut()
10154 .register_component(ComponentRegistration::new(
10155 "my-button",
10156 "<button>Click</button>",
10157 Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
10158 true,
10159 ))
10160 .expect("register failed");
10161
10162 let html = "<my-button></my-button>";
10163 parser.parse("test.html", html).expect("parse failed");
10164 let tokens = parser.take_tokens();
10165
10166 assert_eq!(tokens, vec!["borderWidth", "textColor"]);
10167 }
10168
10169 #[test]
10170 fn test_token_requirements_preserve_fallback_chains() {
10171 let mut parser = HtmlParser::new();
10172 parser
10173 .component_registry_mut()
10174 .register_component(ComponentRegistration::new(
10175 "my-card",
10176 "<div>Card</div>",
10177 Some(":host { color: var(--token-a, var(--token-b, var(--token-c), true)); }"),
10178 true,
10179 ))
10180 .expect("register failed");
10181
10182 parser
10183 .parse("test.html", "<my-card></my-card>")
10184 .expect("parse failed");
10185 let analysis = parser.token_analysis();
10186
10187 assert_eq!(analysis.fallback_chains.len(), 1);
10188 assert_eq!(
10189 analysis.fallback_chains[0].tokens,
10190 vec!["token-a", "token-b", "token-c"]
10191 );
10192 assert!(!analysis.fallback_chains[0].has_literal_fallback);
10193 }
10194
10195 #[test]
10196 fn test_token_requirements_remove_locally_defined_candidates_from_fallback_chain() {
10197 let mut parser = HtmlParser::new();
10198 parser
10199 .component_registry_mut()
10200 .register_component(ComponentRegistration::new(
10201 "my-card",
10202 "<div>Card</div>",
10203 Some(
10204 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
10205 ),
10206 true,))
10207 .expect("register failed");
10208
10209 parser
10210 .parse("test.html", "<my-card></my-card>")
10211 .expect("parse failed");
10212 let analysis = parser.token_analysis();
10213
10214 assert_eq!(analysis.protocol_tokens, vec!["token-b", "token-c"]);
10215 assert_eq!(analysis.fallback_chains.len(), 1);
10216 assert_eq!(
10217 analysis.fallback_chains[0].tokens,
10218 vec!["token-b", "token-c"]
10219 );
10220 }
10221
10222 #[test]
10223 fn test_token_analysis_theme_validation_reports_missing_unresolved_token() {
10224 let mut parser = HtmlParser::new();
10225 parser
10226 .component_registry_mut()
10227 .register_component(ComponentRegistration::new(
10228 "my-card",
10229 "<div>Card</div>",
10230 Some(
10231 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
10232 ),
10233 true,))
10234 .expect("register failed");
10235 parser
10236 .parse("test.html", "<my-card></my-card>")
10237 .expect("parse failed");
10238
10239 let analysis = parser.token_analysis();
10240 let theme = webui_tokens::TokenFile {
10241 themes: HashMap::from([(
10242 "light".to_string(),
10243 HashMap::from([("token-b".to_string(), "green".to_string())]),
10244 )]),
10245 };
10246
10247 let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
10248 panic!("missing --token-c in the theme must fail parser validation");
10249 };
10250 assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
10251 let help = diagnostic.help_text().expect("help text");
10254 assert!(help.contains("did you mean --token-b?"), "help: {help}");
10255 assert!(help.contains("define it locally"), "help: {help}");
10256 assert!(!help.contains("add --token"), "help: {help}");
10257 }
10258
10259 #[test]
10260 fn test_token_analysis_literal_fallback_token_exempt_from_theme_validation() {
10261 let mut parser = HtmlParser::new();
10262 parser
10263 .component_registry_mut()
10264 .register_component(ComponentRegistration::new(
10265 "my-card",
10266 "<div>Card</div>",
10267 Some(":host { color: var(--brand, #000); }"),
10268 true,
10269 ))
10270 .expect("register failed");
10271 parser
10272 .parse("test.html", "<my-card></my-card>")
10273 .expect("parse failed");
10274
10275 let analysis = parser.token_analysis();
10276 assert_eq!(analysis.protocol_tokens, vec!["brand"]);
10279
10280 let theme = webui_tokens::TokenFile {
10283 themes: HashMap::from([("light".to_string(), HashMap::new())]),
10284 };
10285 analysis
10286 .validate_theme_tokens(&theme)
10287 .expect("literal fallback must exempt --brand from theme validation");
10288 }
10289
10290 #[test]
10291 fn test_token_analysis_mixed_literal_and_bare_usage_requires_token() {
10292 let mut parser = HtmlParser::new();
10293 parser
10294 .component_registry_mut()
10295 .register_component(ComponentRegistration::new(
10296 "my-card",
10297 "<div>Card</div>",
10298 Some(":host { color: var(--brand, #000); background: var(--brand); }"),
10301 true,
10302 ))
10303 .expect("register failed");
10304 parser
10305 .parse("test.html", "<my-card></my-card>")
10306 .expect("parse failed");
10307
10308 let analysis = parser.token_analysis();
10309 let theme = webui_tokens::TokenFile {
10310 themes: HashMap::from([("light".to_string(), HashMap::new())]),
10311 };
10312
10313 let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
10314 panic!("a bare var(--brand) usage must require --brand in the theme");
10315 };
10316 assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
10317 assert!(diagnostic.to_string().contains("--brand"));
10318 }
10319
10320 #[test]
10321 fn test_unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
10322 let mut parser = HtmlParser::new();
10323 parser
10324 .component_registry_mut()
10325 .register_component(ComponentRegistration::new(
10326 "my-card",
10327 "<div>Card</div>",
10328 Some(
10329 ":host { \
10330 color: var(--colr-brand, #000); \
10331 border: var(--present, 1px); \
10332 margin: var(--required); \
10333 }",
10334 ),
10335 true,
10336 ))
10337 .expect("register failed");
10338 parser
10339 .parse("test.html", "<my-card></my-card>")
10340 .expect("parse failed");
10341
10342 let analysis = parser.token_analysis();
10343 let theme = webui_tokens::TokenFile {
10344 themes: HashMap::from([(
10345 "light".to_string(),
10346 HashMap::from([
10347 ("present".to_string(), "2px".to_string()),
10348 ("required".to_string(), "8px".to_string()),
10349 ]),
10350 )]),
10351 };
10352
10353 assert_eq!(
10357 analysis.unthemed_literal_fallback_tokens(&theme),
10358 vec!["colr-brand".to_string()]
10359 );
10360 }
10361
10362 #[test]
10363 fn test_theme_token_error_reports_location_and_suggestion() {
10364 let mut parser = HtmlParser::new();
10365 parser
10366 .component_registry_mut()
10367 .register_component(ComponentRegistration::new(
10368 "my-card",
10369 "<div>Card</div>",
10370 ":host {\n color: var(--color-neutral-2000);\n}".into(),
10371 true,
10372 ))
10373 .expect("register failed");
10374 parser
10375 .parse("test.html", "<my-card></my-card>")
10376 .expect("parse failed");
10377
10378 let analysis = parser.token_analysis();
10379 let theme = webui_tokens::TokenFile {
10380 themes: HashMap::from([(
10381 "dark".to_string(),
10382 HashMap::from([("color-neutral-200".to_string(), "#222".to_string())]),
10383 )]),
10384 };
10385
10386 let Err(ParserError::Template(diag)) = analysis.validate_theme_tokens(&theme) else {
10387 panic!("missing --color-neutral-2000 must fail validation");
10388 };
10389 assert_eq!(diag.error_code(), Some(codes::MISSING_THEME_TOKEN));
10390 let location = diag.location().expect("a source location");
10392 assert!(
10393 location.contains("my-card.css:2:14"),
10394 "location: {location}"
10395 );
10396 assert!(
10398 diag.snippet_text()
10399 .is_some_and(|s| s.contains("--color-neutral-2000")),
10400 "snippet: {:?}",
10401 diag.snippet_text()
10402 );
10403 assert!(
10405 diag.help_text()
10406 .is_some_and(|h| h.contains("did you mean --color-neutral-200?")),
10407 "help: {:?}",
10408 diag.help_text()
10409 );
10410 }
10411
10412 #[test]
10413 fn test_theme_token_warning_reports_location_and_suggestion() {
10414 let mut parser = HtmlParser::new();
10415 parser
10416 .component_registry_mut()
10417 .register_component(ComponentRegistration::new(
10418 "my-card",
10419 "<div>Card</div>",
10420 ":host {\n color: var(--colr-brand, #000);\n}".into(),
10421 true,
10422 ))
10423 .expect("register failed");
10424 parser
10425 .parse("test.html", "<my-card></my-card>")
10426 .expect("parse failed");
10427
10428 let analysis = parser.token_analysis();
10429 let theme = webui_tokens::TokenFile {
10430 themes: HashMap::from([(
10431 "light".to_string(),
10432 HashMap::from([("color-brand".to_string(), "#abc".to_string())]),
10433 )]),
10434 };
10435
10436 let warnings = analysis.theme_token_warnings(&theme);
10437 assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
10438 let body = warnings[0].body();
10439 assert!(body.contains("my-card.css:2:"), "warning: {body}");
10440 assert!(body.contains("--colr-brand"), "warning: {body}");
10441 assert!(
10442 body.contains("did you mean --color-brand?"),
10443 "warning: {body}"
10444 );
10445 }
10446
10447 #[test]
10448 fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
10449 let mut parser = HtmlParser::new();
10450 let result =
10451 parser
10452 .component_registry_mut()
10453 .register_component(ComponentRegistration::new(
10454 "my-card",
10455 "<div>Card</div>",
10456 Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
10457 true,
10458 ));
10459
10460 assert!(matches!(result, Err(ParserError::Css(message)) if
10461 message.contains("Unterminated CSS var() call")
10462 ));
10463 }
10464
10465 #[test]
10466 fn test_tokens_merged_from_style_and_components() {
10467 let mut parser = HtmlParser::new();
10468 parser
10469 .component_registry_mut()
10470 .register_component(ComponentRegistration::new(
10471 "my-widget",
10472 "<div>Widget</div>",
10473 Some(".w { padding: var(--spacingM); }"),
10474 true,
10475 ))
10476 .expect("register failed");
10477
10478 let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
10479 parser.parse("test.html", html).expect("parse failed");
10480 let tokens = parser.take_tokens();
10481
10482 assert_eq!(tokens, vec!["spacingM", "textColor"]);
10483 }
10484
10485 #[test]
10486 fn test_tokens_deduplicated() {
10487 let mut parser = HtmlParser::new();
10488 parser
10489 .component_registry_mut()
10490 .register_component(ComponentRegistration::new(
10491 "my-btn",
10492 "<button>B</button>",
10493 Some(".b { color: var(--shared); }"),
10494 true,
10495 ))
10496 .expect("register failed");
10497
10498 let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
10499 parser.parse("test.html", html).expect("parse failed");
10500 let tokens = parser.take_tokens();
10501
10502 assert_eq!(tokens, vec!["shared"]);
10503 }
10504
10505 #[test]
10506 fn test_tokens_empty_when_no_vars() {
10507 let mut parser = HtmlParser::new();
10508 let html = "<div>Hello</div>";
10509 parser.parse("test.html", html).expect("parse failed");
10510 let tokens = parser.take_tokens();
10511
10512 assert!(tokens.is_empty());
10513 }
10514
10515 #[test]
10516 fn test_tokens_exclude_locally_defined_in_component() {
10517 let mut parser = HtmlParser::new();
10518 parser
10519 .component_registry_mut()
10520 .register_component(ComponentRegistration::new(
10521 "my-card",
10522 "<div>Card</div>",
10523 Some(":host { --local: 5px; width: var(--external); }"),
10524 true,
10525 ))
10526 .expect("register failed");
10527
10528 let html = "<my-card></my-card>";
10529 parser.parse("test.html", html).expect("parse failed");
10530 let tokens = parser.take_tokens();
10531
10532 assert_eq!(tokens, vec!["external"]);
10533 }
10534
10535 #[test]
10536 fn test_tokens_exclude_entry_root_definitions() {
10537 let mut parser = HtmlParser::new();
10538 parser
10539 .component_registry_mut()
10540 .register_component(ComponentRegistration::new(
10541 "my-btn",
10542 "<button>B</button>",
10543 Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
10544 true,
10545 ))
10546 .expect("register failed");
10547
10548 let html = r#"<style>
10551 :root {
10552 --color-primary: #0078d4;
10553 --radius-m: 6px;
10554 }
10555 body { color: var(--color-primary); }
10556 </style>
10557 <my-btn></my-btn>"#;
10558 parser.parse("test.html", html).expect("parse failed");
10559 let tokens = parser.take_tokens();
10560
10561 assert!(
10563 tokens.is_empty(),
10564 "Tokens defined in entry :root should be excluded: {tokens:?}"
10565 );
10566 }
10567
10568 #[test]
10569 fn test_tokens_entry_defs_exclude_but_external_kept() {
10570 let mut parser = HtmlParser::new();
10571 parser
10572 .component_registry_mut()
10573 .register_component(ComponentRegistration::new(
10574 "my-card",
10575 "<div>Card</div>",
10576 Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
10577 true,
10578 ))
10579 .expect("register failed");
10580
10581 let html = r#"<style>
10583 :root { --color-primary: #0078d4; }
10584 </style>
10585 <my-card></my-card>"#;
10586 parser.parse("test.html", html).expect("parse failed");
10587 let tokens = parser.take_tokens();
10588
10589 assert_eq!(tokens, vec!["external-spacing"]);
10591 }
10592
10593 #[test]
10596 fn test_parse_simple_route() {
10597 let mut parser = HtmlParser::new();
10598 let html = r#"<route path="/profile" component="profile-page" exact />"#;
10599 parser.parse("test.html", html).expect("parse failed");
10600
10601 let records = parser.into_fragment_records();
10603 let frags = &records["test.html"].fragments;
10604 assert_eq!(frags.len(), 1);
10605 match frags[0].fragment.as_ref() {
10606 Some(web_ui_fragment::Fragment::Route(r)) => {
10607 assert_eq!(r.path, "/profile");
10608 assert_eq!(r.fragment_id, "profile-page");
10609 assert!(r.exact);
10610 }
10611 other => panic!("expected Fragment::Route, got {:?}", other),
10612 }
10613 }
10614
10615 #[test]
10616 fn test_parse_route_with_params() {
10617 let mut parser = HtmlParser::new();
10618 let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
10619 parser.parse("test.html", html).expect("parse failed");
10620
10621 let records = parser.into_fragment_records();
10623 let frags = &records["test.html"].fragments;
10624 match frags[0].fragment.as_ref() {
10625 Some(web_ui_fragment::Fragment::Route(r)) => {
10626 assert_eq!(r.path, "/profile/:id/view/:section");
10627 }
10628 other => panic!("expected Fragment::Route, got {:?}", other),
10629 }
10630 }
10631
10632 #[test]
10633 fn test_parse_route_requires_component() {
10634 let mut parser = HtmlParser::new();
10635 let html = r#"<route path="/old" />"#;
10636 let result = parser.parse("test.html", html);
10637 assert!(result.is_err());
10638 }
10639
10640 #[test]
10641 fn test_parse_multiple_routes() {
10642 let mut parser = HtmlParser::new();
10643 let html = r#"<route path="/" component="app-layout" />
10644 <route path="/dashboard" component="dash-page" exact />
10645 <route path="/contacts" component="contacts-page" exact />"#;
10646 parser.parse("test.html", html).expect("parse failed");
10647
10648 let records = parser.into_fragment_records();
10649 let frags = &records["test.html"].fragments;
10650 assert_eq!(frags.len(), 3);
10651 for frag in frags {
10653 assert!(matches!(
10654 frag.fragment.as_ref(),
10655 Some(web_ui_fragment::Fragment::Route(_))
10656 ));
10657 }
10658 }
10659
10660 #[test]
10661 fn test_parse_route_requires_component_with_body() {
10662 let mut parser = HtmlParser::new();
10663 let html = r#"<route path="/error">
10664 <div class="error"><h1>Not Found</h1></div>
10665 </route>"#;
10666 let result = parser.parse("test.html", html);
10667 assert!(
10668 result.is_err(),
10669 "Route without component attribute should fail"
10670 );
10671 }
10672
10673 #[test]
10674 fn test_parse_multiple_routes_with_fragments() {
10675 let mut parser = HtmlParser::new();
10676 let html = r#"<route path="/" component="home-page" exact />
10677 <route path="/about" component="about-page" exact />
10678 <route path="/contact/:id" component="contact-page" />"#;
10679 parser.parse("test.html", html).expect("parse failed");
10680
10681 let records = parser.into_fragment_records();
10682 let frags = &records["test.html"].fragments;
10683 assert_eq!(frags.len(), 3);
10684
10685 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
10687 assert_eq!(r.path, "/");
10688 }
10689 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
10690 assert_eq!(r.path, "/about");
10691 }
10692 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
10693 assert_eq!(r.path, "/contact/:id");
10694 }
10695 }
10696
10697 #[test]
10698 fn test_parse_route_with_query_allowlist() {
10699 let mut parser = HtmlParser::new();
10700 let html =
10701 r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
10702 parser.parse("test.html", html).expect("parse failed");
10703
10704 let records = parser.into_fragment_records();
10705 let frags = &records["test.html"].fragments;
10706 assert_eq!(frags.len(), 1);
10707 match frags[0].fragment.as_ref() {
10708 Some(web_ui_fragment::Fragment::Route(r)) => {
10709 assert_eq!(r.path, "/compose");
10710 assert_eq!(r.fragment_id, "compose-page");
10711 assert!(r.exact);
10712 assert_eq!(r.allowed_query, "action,to,subject");
10713 }
10714 other => panic!("expected Fragment::Route, got {:?}", other),
10715 }
10716 }
10717
10718 #[test]
10719 fn test_parse_route_without_query_has_empty_allowed_query() {
10720 let mut parser = HtmlParser::new();
10721 let html = r#"<route path="/profile" component="profile-page" exact />"#;
10722 parser.parse("test.html", html).expect("parse failed");
10723
10724 let records = parser.into_fragment_records();
10725 let frags = &records["test.html"].fragments;
10726 match frags[0].fragment.as_ref() {
10727 Some(web_ui_fragment::Fragment::Route(r)) => {
10728 assert!(r.allowed_query.is_empty());
10729 }
10730 other => panic!("expected Fragment::Route, got {:?}", other),
10731 }
10732 }
10733
10734 #[test]
10735 fn test_route_body_rejects_malformed_non_route_html() {
10736 let mut parser = HtmlParser::new();
10737 let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
10738 let result = parser.parse("test.html", html);
10739
10740 assert!(matches!(result, Err(ParserError::Template(diag)) if
10741 diag.to_string().contains("unclosed <span> tag")
10742 ));
10743 }
10744
10745 #[test]
10746 fn test_route_body_rejects_malformed_style_css() {
10747 let mut parser = HtmlParser::new();
10748 let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
10749 let result = parser.parse("test.html", html);
10750
10751 assert!(matches!(result, Err(ParserError::Template(diag)) if
10752 diag.error_code() == Some(codes::INVALID_CSS)
10753 && diag.to_string().contains("Unterminated CSS var() call")
10754 ));
10755 }
10756
10757 #[test]
10758 fn test_outlet_not_captured_by_for_loop() {
10759 let mut parser = HtmlParser::new();
10760 let html = r#"<template shadowrootmode="open">
10761 <ul>
10762 <for each="item in items">
10763 <li>{{item.name}}</li>
10764 </for>
10765 </ul>
10766 <main>
10767 <outlet />
10768 </main>
10769</template>"#;
10770 parser.parse("comp.html", html).expect("parse failed");
10771
10772 let records = parser.into_fragment_records();
10773 let frags = &records["comp.html"].fragments;
10774
10775 let outlet_count = frags
10777 .iter()
10778 .filter(|f| {
10779 matches!(
10780 f.fragment.as_ref(),
10781 Some(web_ui_fragment::Fragment::Outlet(_))
10782 )
10783 })
10784 .count();
10785 assert_eq!(
10786 outlet_count, 1,
10787 "expected exactly 1 outlet in top-level fragments, got {outlet_count}"
10788 );
10789
10790 let outlet_idx = frags
10792 .iter()
10793 .position(|f| {
10794 matches!(
10795 f.fragment.as_ref(),
10796 Some(web_ui_fragment::Fragment::Outlet(_))
10797 )
10798 })
10799 .expect("no outlet found");
10800 let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
10801 Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
10802 _ => false,
10803 });
10804 if let Some(ul_idx) = close_ul_idx {
10805 assert!(
10806 outlet_idx > ul_idx,
10807 "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
10808 );
10809 }
10810 }
10811
10812 #[test]
10813 fn test_outlet_position_after_for_not_inside() {
10814 let mut parser = HtmlParser::new();
10815 let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
10816 parser.parse("test.html", html).expect("parse failed");
10817
10818 let records = parser.into_fragment_records();
10819 let frags = &records["test.html"].fragments;
10820
10821 let has_outlet = frags.iter().any(|f| {
10823 matches!(
10824 f.fragment.as_ref(),
10825 Some(web_ui_fragment::Fragment::Outlet(_))
10826 )
10827 });
10828 assert!(
10829 has_outlet,
10830 "outlet should be in top-level fragments: {frags:?}"
10831 );
10832
10833 let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
10835 Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
10836 _ => None,
10837 });
10838 if let Some(id) = for_id {
10839 let for_frags = &records[&id].fragments;
10840 let outlet_in_for = for_frags.iter().any(|f| {
10841 matches!(
10842 f.fragment.as_ref(),
10843 Some(web_ui_fragment::Fragment::Outlet(_))
10844 )
10845 });
10846 assert!(
10847 !outlet_in_for,
10848 "outlet should NOT be inside for-loop body: {for_frags:?}"
10849 );
10850 }
10851 }
10852
10853 #[test]
10854 fn test_style_element_with_handlebars_signal_comment_emits_signal() {
10855 let mut parser = HtmlParser::new();
10856 let html = r#"<html><head><style>
10857:root {
10858 /*{{{tokens.light}}}*/
10859}
10860</style></head><body></body></html>"#;
10861
10862 let result = parser.parse("test.html", html);
10863 assert!(result.is_ok(), "Parse error: {:?}", result.err());
10864 let fragment_records = parser.into_fragment_records();
10865
10866 assert_stream!(
10867 fragment_records,
10868 "test.html",
10869 [
10870 raw("<html><head>"),
10871 structural_matcher("head_start"),
10872 raw("<style>\n:root {\n "),
10873 raw_text_signal("tokens.light", true),
10874 raw("\n}\n</style>"),
10875 structural_matcher("head_end"),
10876 raw("</head><body>"),
10877 structural_matcher("body_start"),
10878 structural_matcher("body_end"),
10879 raw("</body></html>"),
10880 ]
10881 );
10882 }
10883
10884 #[test]
10885 fn test_style_comment_signal_with_spaces_emits_signal() {
10886 let mut parser = HtmlParser::new();
10887 let html = r#"<html><head><style>
10888:root {
10889 /* {{{tokens.light}}} */
10890}
10891</style></head><body></body></html>"#;
10892
10893 let result = parser.parse("test.html", html);
10894 assert!(result.is_ok(), "Parse error: {:?}", result.err());
10895 let fragment_records = parser.into_fragment_records();
10896
10897 assert_stream!(
10898 fragment_records,
10899 "test.html",
10900 [
10901 raw("<html><head>"),
10902 structural_matcher("head_start"),
10903 raw("<style>\n:root {\n "),
10904 raw_text_signal("tokens.light", true),
10905 raw("\n}\n</style>"),
10906 structural_matcher("head_end"),
10907 raw("</head><body>"),
10908 structural_matcher("body_start"),
10909 structural_matcher("body_end"),
10910 raw("</body></html>"),
10911 ]
10912 );
10913 }
10914
10915 #[test]
10916 fn test_style_comment_signal_double_brace_emits_signal() {
10917 let mut parser = HtmlParser::new();
10918 let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
10919
10920 let result = parser.parse("test.html", html);
10921 assert!(result.is_ok(), "Parse error: {:?}", result.err());
10922 let fragment_records = parser.into_fragment_records();
10923
10924 assert_stream!(
10925 fragment_records,
10926 "test.html",
10927 [
10928 raw("<html><head>"),
10929 structural_matcher("head_start"),
10930 raw("<style>"),
10931 raw_text_signal("themeCss", false),
10932 raw("</style>"),
10933 structural_matcher("head_end"),
10934 raw("</head><body>"),
10935 structural_matcher("body_start"),
10936 structural_matcher("body_end"),
10937 raw("</body></html>"),
10938 ]
10939 );
10940 }
10941
10942 #[test]
10943 fn test_style_comment_with_extra_text_is_stripped() {
10944 let mut parser = HtmlParser::new();
10945 let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
10946
10947 let result = parser.parse("test.html", html);
10948 assert!(result.is_ok(), "Parse error: {:?}", result.err());
10949 let fragment_records = parser.into_fragment_records();
10950
10951 assert_stream!(
10952 fragment_records,
10953 "test.html",
10954 [
10955 raw("<html><head>"),
10956 structural_matcher("head_start"),
10957 raw("<style></style>"),
10958 structural_matcher("head_end"),
10959 raw("</head><body>"),
10960 structural_matcher("body_start"),
10961 structural_matcher("body_end"),
10962 raw("</body></html>"),
10963 ]
10964 );
10965 }
10966
10967 #[test]
10968 fn test_style_comment_with_multiple_signals_is_stripped() {
10969 let mut parser = HtmlParser::new();
10970 let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
10971
10972 let result = parser.parse("test.html", html);
10973 assert!(result.is_ok(), "Parse error: {:?}", result.err());
10974 let fragment_records = parser.into_fragment_records();
10975
10976 assert_stream!(
10977 fragment_records,
10978 "test.html",
10979 [
10980 raw("<html><head>"),
10981 structural_matcher("head_start"),
10982 raw("<style></style>"),
10983 structural_matcher("head_end"),
10984 raw("</head><body>"),
10985 structural_matcher("body_start"),
10986 structural_matcher("body_end"),
10987 raw("</body></html>"),
10988 ]
10989 );
10990 }
10991
10992 #[test]
10993 fn test_style_bare_handlebars_stays_raw() {
10994 let mut parser = HtmlParser::new();
10995 let html =
10997 "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
10998
10999 let result = parser.parse("test.html", html);
11000 assert!(result.is_ok(), "Parse error: {:?}", result.err());
11001 let fragment_records = parser.into_fragment_records();
11002
11003 assert_stream!(
11004 fragment_records,
11005 "test.html",
11006 [
11007 raw("<html><head>"),
11008 structural_matcher("head_start"),
11009 raw("<style>body { color: {{textColor}}; }</style>"),
11010 structural_matcher("head_end"),
11011 raw("</head><body>"),
11012 structural_matcher("body_start"),
11013 structural_matcher("body_end"),
11014 raw("</body></html>"),
11015 ]
11016 );
11017 }
11018
11019 #[test]
11020 fn test_style_mixed_css_and_comment_signal_emits_signal() {
11021 let mut parser = HtmlParser::new();
11022 let html = r#"<html><head><style>
11023 .a { color: red; }
11024 /*{{themeCss}}*/
11025 .b { color: blue; }
11026</style></head><body></body></html>"#;
11027
11028 let result = parser.parse("test.html", html);
11029 assert!(result.is_ok(), "Parse error: {:?}", result.err());
11030 let fragment_records = parser.into_fragment_records();
11031
11032 assert_stream!(
11033 fragment_records,
11034 "test.html",
11035 [
11036 raw("<html><head>"),
11037 structural_matcher("head_start"),
11038 raw("<style>\n .a { color: red; }\n "),
11039 raw_text_signal("themeCss", false),
11040 raw("\n .b { color: blue; }\n</style>"),
11041 structural_matcher("head_end"),
11042 raw("</head><body>"),
11043 structural_matcher("body_start"),
11044 structural_matcher("body_end"),
11045 raw("</body></html>"),
11046 ]
11047 );
11048 }
11049
11050 #[test]
11051 fn test_style_legal_comment_preserved_inline_by_default() {
11052 let mut parser = HtmlParser::new();
11053 let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
11054
11055 parser.parse("test.html", html).expect("parse failed");
11056 let fragment_records = parser.into_fragment_records();
11057
11058 assert_stream!(
11059 fragment_records,
11060 "test.html",
11061 [
11062 raw("<html><head>"),
11063 structural_matcher("head_start"),
11064 raw("<style>/*! @license MIT */ .x { color: red; } </style>"),
11065 structural_matcher("head_end"),
11066 raw("</head><body>"),
11067 structural_matcher("body_start"),
11068 structural_matcher("body_end"),
11069 raw("</body></html>"),
11070 ]
11071 );
11072 }
11073
11074 #[test]
11075 fn test_style_legal_comments_none_strips_legal_comment() {
11076 let mut parser = HtmlParser::with_options(ParserOptions {
11077 legal_comments: LegalComments::None,
11078 ..ParserOptions::default()
11079 });
11080 let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
11081
11082 parser.parse("test.html", html).expect("parse failed");
11083 let fragment_records = parser.into_fragment_records();
11084
11085 assert_stream!(
11086 fragment_records,
11087 "test.html",
11088 [
11089 raw("<html><head>"),
11090 structural_matcher("head_start"),
11091 raw("<style> .x { color: red; }</style>"),
11092 structural_matcher("head_end"),
11093 raw("</head><body>"),
11094 structural_matcher("body_start"),
11095 structural_matcher("body_end"),
11096 raw("</body></html>"),
11097 ]
11098 );
11099 }
11100
11101 #[test]
11102 fn test_style_element_plain_css_unchanged() {
11103 let mut parser = HtmlParser::new();
11104 let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
11105
11106 let result = parser.parse("test.html", html);
11107 assert!(result.is_ok(), "Parse error: {:?}", result.err());
11108 let fragment_records = parser.into_fragment_records();
11109
11110 assert_stream!(
11112 fragment_records,
11113 "test.html",
11114 [
11115 raw("<html><head>"),
11116 structural_matcher("head_start"),
11117 raw("<style>body { margin: 0; }</style>"),
11118 structural_matcher("head_end"),
11119 raw("</head><body>"),
11120 structural_matcher("body_start"),
11121 structural_matcher("body_end"),
11122 raw("</body></html>"),
11123 ]
11124 );
11125 }
11126
11127 #[test]
11128 fn test_unclosed_style_element_returns_error() {
11129 let mut parser = HtmlParser::new();
11130 let result = parser.parse("index.html", "<style>.x { color: red; ");
11131 assert!(matches!(result, Err(ParserError::Template(diag)) if
11132 diag.to_string().contains("unclosed <style> tag")
11133 ));
11134 }
11135
11136 #[test]
11137 fn test_text_with_binding_after_void_element() {
11138 let (fragments, _) = parse_and_get_fragments(
11141 "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
11142 );
11143 assert_fragments!(
11144 fragments,
11145 [
11146 raw("Part A: "),
11147 signal("value1"),
11148 raw("<button>Click</button><br>Part B: "),
11149 signal("value2"),
11150 raw("<button>Click2</button>"),
11151 ]
11152 );
11153 }
11154}