webui_parser/plugin/mod.rs
1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Parser plugin trait and built-in plugin implementations.
5//!
6//! The plugin system is framework-aware but phase-local — parser plugins
7//! classify framework-owned attributes, capture plugin-facing component templates,
8//! and emit per-element hydration metadata for the handler.
9
10pub mod fast;
11pub mod fast_v2;
12pub mod fast_v3;
13pub mod webui;
14
15use crate::component_registry::Component;
16use crate::{ParserOptions, Result};
17
18/// Parser-owned decision about how an attribute should be handled.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum AttributeAction {
21 /// Let the parser process the attribute normally.
22 Keep,
23 /// Skip the attribute without incrementing the element binding count.
24 Skip,
25 /// Skip the attribute and count it as a dynamic binding.
26 SkipAndCountBinding,
27}
28
29/// Build artifacts extracted from a parser plugin after parsing completes.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum ParserPluginArtifacts {
32 /// The plugin did not produce any post-parse artifacts.
33 None,
34 /// Client component templates keyed by tag name.
35 ComponentTemplates(Vec<ComponentTemplateArtifact>),
36}
37
38/// State surface a component consumes through one hydration channel.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum StateSurface {
41 /// The component is known not to consume state through this channel.
42 None,
43 /// The component consumes only these sorted, deduplicated keys.
44 Keys(Vec<String>),
45 /// The complete surface is unknown, so callers must preserve full state.
46 All,
47}
48
49impl StateSurface {
50 /// Create an exact keyed surface, collapsing an empty list to [`Self::None`].
51 #[must_use]
52 pub fn keys(mut keys: Vec<String>) -> Self {
53 keys.sort_unstable();
54 keys.dedup();
55 if keys.is_empty() {
56 Self::None
57 } else {
58 Self::Keys(keys)
59 }
60 }
61}
62
63/// Client template payload generated by a parser plugin for one component.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ComponentTemplateArtifact {
66 /// Component custom-element tag name.
67 pub tag_name: String,
68 /// Non-WebUI plugin template payload, such as FAST `<f-template>` HTML.
69 pub template: String,
70 /// WebUI JSON-safe template metadata.
71 pub template_json: String,
72 /// WebUI component-local JavaScript condition closure array.
73 pub template_functions: String,
74 /// Initial hydration state surface derived by the producing plugin.
75 pub hydration: StateSurface,
76 /// Partial-navigation state surface derived by the producing plugin.
77 pub navigation: StateSurface,
78 /// Compiled template roots — the reactive property names referenced
79 /// directly by the template (e.g. `{{name}}`) independent of any
80 /// authored client script. Always populated (possibly empty), even when
81 /// `hydration`/`navigation` collapse to [`StateSurface::All`], so a
82 /// bundler-neutral manifest consumer can union exact client keys with
83 /// these Rust-derived roots without re-deriving them.
84 pub template_roots: Vec<String>,
85 /// Whether this component has an authored client module (sibling script
86 /// or an externally client-owned package), as opposed to a scriptless
87 /// compiled template. Bundler manifest coverage validation only requires
88 /// an entry for scripted components; scriptless ones are always
89 /// Rust-derived and never require a manifest entry.
90 pub is_scripted: bool,
91}
92
93impl ComponentTemplateArtifact {
94 /// Create a non-WebUI template payload.
95 #[must_use]
96 pub fn template(tag_name: String, template: String) -> Self {
97 Self {
98 tag_name,
99 template,
100 template_json: String::new(),
101 template_functions: String::new(),
102 hydration: StateSurface::None,
103 navigation: StateSurface::None,
104 template_roots: Vec::new(),
105 is_scripted: false,
106 }
107 }
108
109 /// Create a WebUI split metadata/function payload.
110 ///
111 /// The hydration surface starts empty; the producing plugin attaches it with
112 /// [`Self::with_hydration`] once it has derived the surface.
113 #[must_use]
114 pub fn webui(tag_name: String, template_json: String, template_functions: String) -> Self {
115 Self {
116 tag_name,
117 template: String::new(),
118 template_json,
119 template_functions,
120 hydration: StateSurface::None,
121 navigation: StateSurface::None,
122 template_roots: Vec::new(),
123 is_scripted: false,
124 }
125 }
126
127 /// Attach the initial hydration surface derived by the plugin.
128 #[must_use]
129 pub fn with_hydration(mut self, hydration: StateSurface) -> Self {
130 self.hydration = hydration;
131 self
132 }
133
134 /// Attach the partial-navigation surface derived by the plugin.
135 #[must_use]
136 pub fn with_navigation(mut self, navigation: StateSurface) -> Self {
137 self.navigation = navigation;
138 self
139 }
140
141 /// Attach the compiled template roots (see [`Self::template_roots`]).
142 #[must_use]
143 pub fn with_template_roots(mut self, roots: Vec<String>) -> Self {
144 self.template_roots = roots;
145 self
146 }
147
148 /// Mark whether this component has an authored client module.
149 #[must_use]
150 pub fn scripted(mut self, is_scripted: bool) -> Self {
151 self.is_scripted = is_scripted;
152 self
153 }
154}
155
156/// A parser plugin that can customize template parsing behavior.
157///
158/// Plugins receive callbacks at key points during HTML parsing:
159/// - **Fragment lifecycle**: `start_fragment` before a fragment parse begins
160/// - **Component registration**: `register_component_template` when a plugin-facing component template is finalized
161/// - **Attribute classification**: `classify_attribute` for framework-specific attrs
162/// - **Element completion**: `finish_element` after attributes are processed
163///
164/// WebUI calls these hooks during parsing; plugins decide what (if anything) to do.
165pub trait ParserPlugin {
166 /// Called before parsing begins for a fragment.
167 ///
168 /// Plugins can use this to reset fragment-local counters while preserving
169 /// global build-level state such as tracked component templates.
170 fn start_fragment(&mut self, _fragment_id: &str) {}
171
172 /// Called when parser output options change.
173 fn configure(&mut self, _options: &ParserOptions) {}
174
175 /// Called with the plugin-facing component template for the active CSS/DOM
176 /// strategies. Authored root `<template>` attributes are preserved here;
177 /// the SSR/internal parse view may strip runtime-only attributes.
178 ///
179 /// `component` identifies whether browser code owns the tag. Exact
180 /// JavaScript state surfaces come from bundler projection metadata rather
181 /// than parser-side source analysis.
182 fn register_component_template(
183 &mut self,
184 tag_name: &str,
185 component: &Component,
186 processed_template: &str,
187 ) -> Result<()>;
188
189 /// Decide how a framework-owned attribute should be handled.
190 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction;
191
192 /// Called after all attributes on an element have been processed.
193 /// `binding_attribute_count` is the number of dynamic attribute bindings found.
194 /// Returns optional opaque bytes to emit as a `Plugin` protocol fragment.
195 fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>>;
196
197 /// Consume the plugin and return any build artifacts it captured.
198 ///
199 /// # Errors
200 ///
201 /// Returns an error if the plugin encountered an invalid template construct
202 /// while producing its artifacts (e.g. an invalid `@event` handler).
203 fn into_artifacts(self: Box<Self>) -> Result<ParserPluginArtifacts> {
204 Ok(ParserPluginArtifacts::None)
205 }
206}
207
208#[cfg(test)]
209mod artifact_tests {
210 use super::{ComponentTemplateArtifact, StateSurface};
211
212 #[test]
213 fn template_constructor_starts_with_no_hydration_surface() {
214 let artifact = ComponentTemplateArtifact::template("x-a".into(), "<p></p>".into());
215 assert_eq!(artifact.tag_name, "x-a");
216 assert_eq!(artifact.template, "<p></p>");
217 assert!(artifact.template_json.is_empty());
218 assert!(artifact.template_functions.is_empty());
219 assert_eq!(artifact.hydration, StateSurface::None);
220 assert_eq!(artifact.navigation, StateSurface::None);
221 }
222
223 #[test]
224 fn webui_constructor_starts_with_no_hydration_surface() {
225 let artifact =
226 ComponentTemplateArtifact::webui("x-b".into(), "{\"j\":1}".into(), "[]".into());
227 assert_eq!(artifact.tag_name, "x-b");
228 assert!(artifact.template.is_empty());
229 assert_eq!(artifact.template_json, "{\"j\":1}");
230 assert_eq!(artifact.template_functions, "[]");
231 assert_eq!(artifact.hydration, StateSurface::None);
232 assert_eq!(artifact.navigation, StateSurface::None);
233 }
234
235 #[test]
236 fn with_hydration_attaches_surface_fluently() {
237 let artifact = ComponentTemplateArtifact::webui("x-c".into(), "{}".into(), "[]".into())
238 .with_hydration(StateSurface::keys(vec!["count".into(), "name".into()]));
239 assert_eq!(
240 artifact.hydration,
241 StateSurface::Keys(vec!["count".into(), "name".into()])
242 );
243 }
244
245 #[test]
246 fn with_hydration_is_plugin_agnostic_over_template_payloads() {
247 // A non-WebUI plugin payload can carry its own hydration surface too.
248 let artifact = ComponentTemplateArtifact::template("x-d".into(), "<f-t></f-t>".into())
249 .with_hydration(StateSurface::keys(vec!["value".into()]));
250 assert_eq!(artifact.template, "<f-t></f-t>");
251 assert_eq!(artifact.hydration, StateSurface::Keys(vec!["value".into()]));
252 }
253
254 #[test]
255 fn with_navigation_attaches_partial_state_surface() {
256 let artifact = ComponentTemplateArtifact::webui("x-e".into(), "{}".into(), "[]".into())
257 .with_navigation(StateSurface::keys(vec!["items".into(), "title".into()]));
258 assert_eq!(
259 artifact.navigation,
260 StateSurface::Keys(vec!["items".into(), "title".into()])
261 );
262 assert_eq!(artifact.hydration, StateSurface::None);
263 }
264}