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