Skip to main content

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 use fast::v2 as fast_v2;
12pub use fast::v3 as 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    /// Plugin-owned client template payload.
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    /// Whether this component effectively uses Shadow DOM.
92    pub uses_shadow_dom: bool,
93}
94
95impl ComponentTemplateArtifact {
96    /// Create a non-WebUI template payload.
97    #[must_use]
98    pub fn template(tag_name: String, template: String, uses_shadow_dom: bool) -> Self {
99        Self {
100            tag_name,
101            template,
102            template_json: String::new(),
103            template_functions: String::new(),
104            hydration: StateSurface::None,
105            navigation: StateSurface::None,
106            template_roots: Vec::new(),
107            is_scripted: false,
108            uses_shadow_dom,
109        }
110    }
111
112    /// Create a WebUI split metadata/function payload.
113    ///
114    /// The hydration surface starts empty; the producing plugin attaches it with
115    /// [`Self::with_hydration`] once it has derived the surface.
116    #[must_use]
117    pub fn webui(
118        tag_name: String,
119        template_json: String,
120        template_functions: String,
121        uses_shadow_dom: bool,
122    ) -> Self {
123        Self {
124            tag_name,
125            template: String::new(),
126            template_json,
127            template_functions,
128            hydration: StateSurface::None,
129            navigation: StateSurface::None,
130            template_roots: Vec::new(),
131            is_scripted: false,
132            uses_shadow_dom,
133        }
134    }
135
136    /// Attach the initial hydration surface derived by the plugin.
137    #[must_use]
138    pub fn with_hydration(mut self, hydration: StateSurface) -> Self {
139        self.hydration = hydration;
140        self
141    }
142
143    /// Attach the partial-navigation surface derived by the plugin.
144    #[must_use]
145    pub fn with_navigation(mut self, navigation: StateSurface) -> Self {
146        self.navigation = navigation;
147        self
148    }
149
150    /// Attach the compiled template roots (see [`Self::template_roots`]).
151    #[must_use]
152    pub fn with_template_roots(mut self, roots: Vec<String>) -> Self {
153        self.template_roots = roots;
154        self
155    }
156
157    /// Mark whether this component has an authored client module.
158    #[must_use]
159    pub fn scripted(mut self, is_scripted: bool) -> Self {
160        self.is_scripted = is_scripted;
161        self
162    }
163}
164
165/// Build-resolved CSS delivery for a component.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum ComponentStyleDelivery<'a> {
168    /// An external stylesheet served at `href`.
169    Link {
170        /// Resolved stylesheet URL.
171        href: &'a str,
172    },
173    /// The compiled CSS text itself.
174    Inline {
175        /// Compiled CSS source.
176        css: &'a str,
177    },
178    /// A constructed stylesheet the root adopts by `specifier`, already
179    /// recorded on the template's `shadowrootadoptedstylesheets`.
180    Adopted {
181        /// Module specifier for the constructed stylesheet.
182        specifier: &'a str,
183    },
184}
185
186/// A component template after the parser has resolved its build behavior.
187#[derive(Debug, Clone, Copy)]
188pub struct ComponentBuildContext<'a> {
189    /// Registered component definition.
190    pub component: &'a Component,
191    /// Final template view intended for the plugin runtime.
192    pub template: &'a str,
193    /// Whether this component effectively uses Shadow DOM.
194    pub uses_shadow_dom: bool,
195    /// CSS delivery for effective Shadow DOM components.
196    pub style: Option<ComponentStyleDelivery<'a>>,
197}
198
199/// A fragment about to be parsed.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub struct FragmentContext<'a> {
202    /// Stable fragment identifier.
203    pub id: &'a str,
204}
205
206/// An attribute encountered on an opening tag.
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub struct AttributeContext<'a> {
209    /// Authored attribute name.
210    pub name: &'a str,
211}
212
213/// An opening tag after all attributes have been processed.
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub struct ElementStartContext<'a> {
216    /// Authored element tag name.
217    pub tag_name: &'a str,
218    /// Number of dynamic attribute bindings on the element.
219    pub binding_count: u32,
220}
221
222/// Borrowed authored component source presented to a parser plugin before the
223/// component registry stores it.
224#[derive(Debug, Clone, Copy)]
225pub struct ComponentSource<'a> {
226    /// Filename-derived custom-element tag name.
227    pub tag_name: &'a str,
228    /// Authored HTML template for the component.
229    pub html_content: &'a str,
230}
231
232/// Owned replacement views produced by a plugin's component-source transform.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct TransformedComponentSource {
235    /// Resolved registry key. May differ from the filename-derived tag when the
236    /// authored source names the component itself.
237    pub tag_name: String,
238    /// Content the WebUI SSR parser consumes for this component.
239    pub parser_content: String,
240    /// Optional distinct authored source retained for the plugin's client
241    /// artifact. `None` reuses `parser_content` for the artifact.
242    pub artifact_content: Option<String>,
243}
244
245/// Stateless source transform installed into a [`ComponentRegistry`].
246///
247/// It may resolve the registry key and provide separate SSR and client views.
248/// Returning `Ok(None)` preserves the authored source without allocation.
249///
250/// # Errors
251///
252/// Returns a [`crate::Diagnostic`]-carrying error when the authored source is
253/// invalid.
254///
255/// [`ComponentRegistry`]: crate::component_registry::ComponentRegistry
256pub type ComponentSourceTransform =
257    for<'a> fn(ComponentSource<'a>) -> Result<Option<TransformedComponentSource>>;
258
259/// Static component-processing behavior for a parser plugin.
260///
261/// The parser reads this once during construction and keeps it on the direct
262/// path, avoiding repeated virtual calls while compiling component templates.
263#[derive(Debug, Clone, Copy, Default)]
264pub struct ComponentProcessing {
265    /// Optional build-time source transformation.
266    pub source_transform: Option<ComponentSourceTransform>,
267    /// Keep root-template runtime attributes for [`ParserPlugin::process_attribute`].
268    pub process_root_template_attributes: bool,
269    /// Place inline component styles after template content.
270    pub inline_styles_after_content: bool,
271}
272
273/// A parser plugin that can customize template parsing behavior.
274///
275/// Plugins receive callbacks at explicit lifecycle points: parser setup,
276/// fragment start, component build, attribute processing, opening-tag
277/// completion, and build completion. Every callback has a no-op default.
278///
279/// WebUI calls these hooks during parsing; plugins decide what (if anything) to do.
280pub trait ParserPlugin {
281    /// Configure the plugin before parsing or component registration begins.
282    fn configure_parser(&mut self, _options: &ParserOptions) {}
283
284    /// Return static component-processing behavior.
285    fn component_processing(&self) -> ComponentProcessing {
286        ComponentProcessing::default()
287    }
288
289    /// Called before parsing begins for a fragment.
290    ///
291    /// Plugins can use this to reset fragment-local counters while preserving
292    /// global build-level state such as tracked component templates.
293    fn begin_fragment(&mut self, _context: FragmentContext<'_>) {}
294
295    /// Called after a component template is fully built.
296    ///
297    /// # Errors
298    ///
299    /// Returns an error when the plugin cannot compile or retain the component.
300    fn component_built(&mut self, _context: ComponentBuildContext<'_>) -> Result<()> {
301        Ok(())
302    }
303
304    /// Process one authored attribute.
305    fn process_attribute(&mut self, _context: AttributeContext<'_>) -> AttributeAction {
306        AttributeAction::Keep
307    }
308
309    /// Called after all attributes on an opening tag have been processed.
310    /// Returns optional opaque bytes to emit as a `Plugin` protocol fragment.
311    fn finish_opening_tag(&mut self, _context: ElementStartContext<'_>) -> Option<Vec<u8>> {
312        None
313    }
314
315    /// Complete the build and return any captured artifacts.
316    ///
317    /// # Errors
318    ///
319    /// Returns an error if the plugin encountered an invalid template construct
320    /// while producing its artifacts (e.g. an invalid `@event` handler).
321    fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
322        Ok(ParserPluginArtifacts::None)
323    }
324}
325
326#[cfg(test)]
327mod artifact_tests {
328    use super::{ComponentTemplateArtifact, StateSurface};
329
330    #[test]
331    fn template_constructor_starts_with_no_hydration_surface() {
332        let artifact = ComponentTemplateArtifact::template("x-a".into(), "<p></p>".into(), false);
333        assert_eq!(artifact.tag_name, "x-a");
334        assert_eq!(artifact.template, "<p></p>");
335        assert!(artifact.template_json.is_empty());
336        assert!(artifact.template_functions.is_empty());
337        assert_eq!(artifact.hydration, StateSurface::None);
338        assert_eq!(artifact.navigation, StateSurface::None);
339    }
340
341    #[test]
342    fn webui_constructor_starts_with_no_hydration_surface() {
343        let artifact =
344            ComponentTemplateArtifact::webui("x-b".into(), "{\"j\":1}".into(), "[]".into(), false);
345        assert_eq!(artifact.tag_name, "x-b");
346        assert!(artifact.template.is_empty());
347        assert_eq!(artifact.template_json, "{\"j\":1}");
348        assert_eq!(artifact.template_functions, "[]");
349        assert_eq!(artifact.hydration, StateSurface::None);
350        assert_eq!(artifact.navigation, StateSurface::None);
351    }
352
353    #[test]
354    fn with_hydration_attaches_custom_surface() {
355        let artifact =
356            ComponentTemplateArtifact::webui("x-c".into(), "{}".into(), "[]".into(), false)
357                .with_hydration(StateSurface::keys(vec!["count".into(), "name".into()]));
358        assert_eq!(
359            artifact.hydration,
360            StateSurface::Keys(vec!["count".into(), "name".into()])
361        );
362    }
363
364    #[test]
365    fn with_hydration_is_plugin_agnostic_over_template_payloads() {
366        // A non-WebUI plugin payload can carry its own hydration surface too.
367        let artifact =
368            ComponentTemplateArtifact::template("x-d".into(), "<f-t></f-t>".into(), false)
369                .with_hydration(StateSurface::keys(vec!["value".into()]));
370        assert_eq!(artifact.template, "<f-t></f-t>");
371        assert_eq!(artifact.hydration, StateSurface::Keys(vec!["value".into()]));
372    }
373
374    #[test]
375    fn with_navigation_attaches_partial_state_surface() {
376        let artifact =
377            ComponentTemplateArtifact::webui("x-e".into(), "{}".into(), "[]".into(), false)
378                .with_navigation(StateSurface::keys(vec!["items".into(), "title".into()]));
379        assert_eq!(
380            artifact.navigation,
381            StateSurface::Keys(vec!["items".into(), "title".into()])
382        );
383        assert_eq!(artifact.hydration, StateSurface::None);
384    }
385}