Skip to main content

webui_wasm/
parser.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Parser-only WASM exports.
5
6use crate::error::WasmError;
7use std::collections::HashMap;
8use wasm_bindgen::prelude::*;
9use webui_parser::plugin::webui::WebUIParserPlugin;
10use webui_parser::plugin::{ParserPluginArtifacts, StateSurface};
11use webui_parser::{CssStrategy, DomStrategy, HtmlParser};
12use webui_protocol::projection_manifest::{ProjectionComponent, ProjectionManifest};
13use webui_protocol::{InitialStateStrategy, StateProjectionMode, WebUIProtocol};
14
15/// Build protocol protobuf bytes from virtual files without rendering.
16///
17/// `dom` accepts `"shadow"` (default) or `"light"` for unwrapped components;
18/// authored open Shadow roots remain Shadow in either mode.
19///
20/// Returns the serialized `WebUIProtocol` as protobuf bytes.
21#[wasm_bindgen]
22pub fn build_protocol(
23    files: JsValue,
24    entry: &str,
25    projection_manifests: Option<JsValue>,
26    dom: Option<String>,
27) -> Result<Vec<u8>, JsValue> {
28    let files_map: HashMap<String, String> =
29        serde_wasm_bindgen::from_value(files).map_err(|e| JsValue::from_str(&e.to_string()))?;
30    let manifests: Vec<ProjectionManifest> = projection_manifests
31        .map(serde_wasm_bindgen::from_value)
32        .transpose()
33        .map_err(|error| JsValue::from_str(&format!("invalid projection manifests: {error}")))?
34        .unwrap_or_default();
35    let dom = dom
36        .map(|value| value.parse::<DomStrategy>())
37        .transpose()
38        .map_err(|error| JsValue::from_str(&error))?
39        .unwrap_or_default();
40
41    build_protocol_inner_with_dom(&files_map, entry, &manifests, dom)
42        .map_err(|e| JsValue::from_str(&e.to_string()))
43}
44
45#[cfg(test)]
46pub(crate) fn build_protocol_inner(
47    files: &HashMap<String, String>,
48    entry: &str,
49    projection_manifests: &[ProjectionManifest],
50) -> Result<Vec<u8>, WasmError> {
51    build_protocol_inner_with_dom(files, entry, projection_manifests, DomStrategy::Shadow)
52}
53
54fn build_protocol_inner_with_dom(
55    files: &HashMap<String, String>,
56    entry: &str,
57    projection_manifests: &[ProjectionManifest],
58    dom: DomStrategy,
59) -> Result<Vec<u8>, WasmError> {
60    let protocol = parse_to_protocol_with_dom(files, entry, projection_manifests, dom)?;
61    protocol.to_protobuf().map_err(WasmError::Protocol)
62}
63
64/// Register all component `.html` files and optional companion `.css` files
65/// from the virtual file map, skipping the entry.
66fn register_components(
67    parser: &mut HtmlParser,
68    files: &HashMap<String, String>,
69    entry: &str,
70) -> Result<(), WasmError> {
71    for (filename, content) in files {
72        if filename != entry && filename.ends_with(".html") {
73            let tag_name = filename.trim_end_matches(".html");
74            if tag_name.contains('-') {
75                let css_key = format!("{tag_name}.css");
76                let css = files.get(&css_key).map(String::as_str);
77                // A sibling module marks an authored client component. Rust
78                // never analyzes its source; optional projection manifests
79                // provide exact client state surfaces.
80                parser.component_registry_mut().register_component(
81                    webui_parser::ComponentRegistration {
82                        tag_name,
83                        html_content: content,
84                        css_content: css,
85                        is_client_owned: has_component_script(files, tag_name),
86                    },
87                )?;
88            }
89        }
90    }
91    Ok(())
92}
93
94/// Return whether the virtual file map contains an authored component module.
95fn has_component_script(files: &HashMap<String, String>, tag_name: &str) -> bool {
96    files.contains_key(&format!("{tag_name}.ts")) || files.contains_key(&format!("{tag_name}.js"))
97}
98
99type ComponentDeliverySnapshot = (
100    Vec<(String, String)>,
101    Vec<(String, bool)>,
102    Vec<(String, u8)>,
103);
104
105fn component_delivery_snapshot(parser: &HtmlParser) -> ComponentDeliverySnapshot {
106    let css = parser
107        .component_registry()
108        .get_all()
109        .filter(|component| parser.has_fragment(&component.tag_name))
110        .filter_map(|component| {
111            component
112                .css_content
113                .as_ref()
114                .map(|css| (component.tag_name.clone(), css.clone()))
115        })
116        .collect();
117    let shadow_dom = parser
118        .component_shadow_dom_usage()
119        .map(|(tag_name, uses_shadow_dom)| (tag_name.to_string(), uses_shadow_dom))
120        .collect();
121    let work_policies = parser
122        .component_work_policies()
123        .map(|(tag_name, policy)| (tag_name.to_string(), policy))
124        .collect();
125    (css, shadow_dom, work_policies)
126}
127
128fn apply_component_delivery(
129    protocol: &mut WebUIProtocol,
130    snapshot: ComponentDeliverySnapshot,
131    entry: &str,
132) {
133    protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
134    for (tag_name, uses_shadow_dom) in snapshot.1 {
135        if !protocol.fragments.contains_key(&tag_name) {
136            continue;
137        }
138        protocol
139            .components
140            .entry(tag_name)
141            .or_default()
142            .uses_shadow_dom = uses_shadow_dom;
143    }
144    for (tag_name, css) in snapshot.0 {
145        if protocol.fragments.contains_key(&tag_name) {
146            protocol.components.entry(tag_name).or_default().css = css;
147        }
148    }
149    for (tag_name, work_policy) in snapshot.2 {
150        if protocol.fragments.contains_key(&tag_name) {
151            protocol.components.entry(tag_name).or_default().work_policy = i32::from(work_policy);
152        }
153    }
154    protocol.populate_style_closures(&[entry]);
155}
156
157/// Parse virtual files into a `WebUIProtocol` using the real `webui-parser`
158/// with the WebUI plugin.
159#[cfg(test)]
160pub(crate) fn parse_to_protocol(
161    files: &HashMap<String, String>,
162    entry: &str,
163    projection_manifests: &[ProjectionManifest],
164) -> Result<WebUIProtocol, WasmError> {
165    parse_to_protocol_with_dom(files, entry, projection_manifests, DomStrategy::Shadow)
166}
167
168pub(crate) fn parse_to_protocol_with_dom(
169    files: &HashMap<String, String>,
170    entry: &str,
171    projection_manifests: &[ProjectionManifest],
172    dom: DomStrategy,
173) -> Result<WebUIProtocol, WasmError> {
174    let entry_html = files
175        .get(entry)
176        .ok_or_else(|| WasmError::MissingEntry(entry.to_string()))?;
177
178    let mut parser = HtmlParser::with_plugin_options(
179        Box::new(WebUIParserPlugin::new()),
180        (CssStrategy::Style, dom),
181    );
182    register_components(&mut parser, files, entry)?;
183    parser.parse(entry, entry_html)?;
184    let component_delivery = component_delivery_snapshot(&parser);
185    let templates = match parser.take_plugin_artifacts()? {
186        ParserPluginArtifacts::None => Vec::new(),
187        ParserPluginArtifacts::ComponentTemplates(templates) => templates,
188    };
189    let component_render_css = parser.component_registry().render_policy_css(
190        parser
191            .component_registry()
192            .get_all()
193            .filter(|component| parser.has_fragment(&component.tag_name))
194            .map(|component| component.tag_name.as_str()),
195    );
196
197    let mut protocol = WebUIProtocol::new(parser.into_fragment_records());
198    protocol.component_render_css = component_render_css;
199    let projection = merge_projection_manifests(projection_manifests)?;
200    protocol.initial_state_strategy = if projection.is_some() {
201        InitialStateStrategy::Components as i32
202    } else {
203        InitialStateStrategy::Full as i32
204    };
205    if let Some(entries) = &projection {
206        let mut missing = Vec::new();
207        for artifact in &templates {
208            if artifact.is_scripted
209                && protocol.fragments.contains_key(&artifact.tag_name)
210                && !entries.contains_key(&artifact.tag_name)
211            {
212                missing.push(artifact.tag_name.as_str());
213            }
214        }
215        if !missing.is_empty() {
216            missing.sort_unstable();
217            return Err(WasmError::Projection(format!(
218                "PROJ-B001: scripted components have no projection entry: {}",
219                missing.join(", ")
220            )));
221        }
222    }
223    for artifact in templates {
224        let manifest_entry = if artifact.is_scripted {
225            projection
226                .as_ref()
227                .and_then(|entries| entries.get(&artifact.tag_name))
228        } else {
229            None
230        };
231        let component = protocol.components.entry(artifact.tag_name).or_default();
232        component.template = artifact.template;
233        component.template_json = artifact.template_json;
234        component.template_functions = artifact.template_functions;
235        let hydration = manifest_entry.map_or(artifact.hydration, |entry| {
236            StateSurface::Keys(entry.hydration_keys.clone())
237        });
238        let navigation = manifest_entry.map_or(artifact.navigation, |entry| {
239            StateSurface::Keys(union_keys(&entry.navigation_keys, &artifact.template_roots))
240        });
241        let (hydration_mode, hydration_keys) = encode_state_surface(hydration);
242        component.hydration_mode = hydration_mode;
243        component.hydration_keys = hydration_keys;
244        let (navigation_mode, navigation_keys) = encode_state_surface(navigation);
245        component.navigation_mode = Some(navigation_mode);
246        component.navigation_keys = navigation_keys;
247    }
248    apply_component_delivery(&mut protocol, component_delivery, entry);
249
250    fn merge_projection_manifests(
251        manifests: &[ProjectionManifest],
252    ) -> Result<Option<std::collections::BTreeMap<String, ProjectionComponent>>, WasmError> {
253        if manifests.is_empty() {
254            return Ok(None);
255        }
256        let mut components = std::collections::BTreeMap::new();
257        for manifest in manifests {
258            let serialized_size = serde_json::to_vec(manifest)
259                .map_err(|error| WasmError::Projection(format!("PROJ-M009: {error}")))?
260                .len();
261            if serialized_size > 16 * 1024 * 1024 {
262                return Err(WasmError::Projection(
263                    "PROJ-S001: projection manifest exceeds the 16 MiB limit".to_string(),
264                ));
265            }
266            manifest
267                .validate()
268                .map_err(|error| WasmError::Projection(format!("{}: {error}", error.code())))?;
269            for (tag, entry) in &manifest.components {
270                if components.insert(tag.clone(), entry.clone()).is_some() {
271                    return Err(WasmError::Projection(format!(
272                        "PROJ-M006: component <{tag}> is declared by more than one projection manifest"
273                    )));
274                }
275            }
276        }
277        Ok(Some(components))
278    }
279
280    fn union_keys(left: &[String], right: &[String]) -> Vec<String> {
281        let mut keys = Vec::with_capacity(left.len() + right.len());
282        keys.extend_from_slice(left);
283        keys.extend_from_slice(right);
284        keys.sort_unstable();
285        keys.dedup();
286        keys
287    }
288    Ok(protocol)
289}
290
291fn encode_state_surface(surface: StateSurface) -> (i32, Vec<String>) {
292    match surface {
293        StateSurface::None => (StateProjectionMode::None as i32, Vec::new()),
294        StateSurface::Keys(keys) => (StateProjectionMode::Keys as i32, keys),
295        StateSurface::All => (StateProjectionMode::All as i32, Vec::new()),
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn build_protocol_reports_missing_entry() {
305        let files = HashMap::new();
306        let err = build_protocol_inner(&files, "index.html", &[]).unwrap_err();
307        assert_eq!(err.to_string(), "Entry file 'index.html' not found");
308    }
309
310    #[test]
311    fn parse_to_protocol_without_manifest_preserves_full_state() {
312        let files = HashMap::from([
313            (
314                "index.html".to_string(),
315                "<html><body><my-card></my-card></body></html>".to_string(),
316            ),
317            (
318                "my-card.html".to_string(),
319                "<template shadowrootmode=\"open\"><p>{{name}}</p></template>".to_string(),
320            ),
321            (
322                "my-card.ts".to_string(),
323                "class MyCard { @observable name = ''; }".to_string(),
324            ),
325        ]);
326
327        let protocol = parse_to_protocol(&files, "index.html", &[]).unwrap();
328        let component = protocol.components.get("my-card").unwrap();
329
330        assert_eq!(
331            protocol.initial_state_strategy,
332            InitialStateStrategy::Full as i32
333        );
334        assert_eq!(component.hydration_mode, StateProjectionMode::All as i32);
335        assert!(component.hydration_keys.is_empty());
336        assert!(!component.template_json.is_empty());
337        assert!(component.uses_shadow_dom);
338        assert!(protocol.style_closures.contains_key("my-card"));
339    }
340
341    #[test]
342    fn parse_to_protocol_resolves_unwrapped_dom_strategy() {
343        let files = HashMap::from([
344            (
345                "index.html".to_string(),
346                "<html><body><my-card></my-card></body></html>".to_string(),
347            ),
348            (
349                "my-card.html".to_string(),
350                "<p>Light content</p>".to_string(),
351            ),
352            (
353                "my-card.css".to_string(),
354                ".card { display: block; }".to_string(),
355            ),
356        ]);
357
358        let shadow = parse_to_protocol(&files, "index.html", &[]).unwrap();
359        assert!(shadow.components["my-card"].uses_shadow_dom);
360        assert_eq!(
361            shadow.components["my-card"].css,
362            ".card { display: block; }"
363        );
364        assert_eq!(
365            shadow.style_closure("my-card").expect("Shadow closure"),
366            ["my-card"]
367        );
368
369        let protocol =
370            parse_to_protocol_with_dom(&files, "index.html", &[], DomStrategy::Light).unwrap();
371        assert_eq!(protocol.css_strategy(), webui_protocol::CssStrategy::Style);
372        assert!(!protocol.components["my-card"].uses_shadow_dom);
373        assert_eq!(
374            protocol.components["my-card"].css,
375            ".card { display: block; }"
376        );
377        assert_eq!(
378            protocol.style_closure("index.html").expect("entry closure"),
379            ["my-card"]
380        );
381    }
382
383    #[test]
384    fn parse_to_protocol_applies_manifest_surfaces() {
385        use std::collections::BTreeMap;
386        use webui_protocol::projection_manifest::{
387            ProjectionAdapter, ProjectionComponent, ProjectionProducer, PRODUCER_NAME, SCHEMA_ID,
388        };
389
390        let files = HashMap::from([
391            (
392                "index.html".to_string(),
393                "<html><body><my-card></my-card></body></html>".to_string(),
394            ),
395            (
396                "my-card.html".to_string(),
397                "<template shadowrootmode=\"open\"><p>{{name}}</p></template>".to_string(),
398            ),
399            ("my-card.ts".to_string(), "export {};".to_string()),
400        ]);
401        let mut manifest = ProjectionManifest {
402            schema: SCHEMA_ID.to_string(),
403            producer: ProjectionProducer {
404                name: PRODUCER_NAME.to_string(),
405                version: "0.0.18".to_string(),
406            },
407            adapter: ProjectionAdapter {
408                name: "test".to_string(),
409                bundler: "test@1.0.0".to_string(),
410            },
411            root: ".".to_string(),
412            analysis_hash: format!("sha256:{}", "1".repeat(64)),
413            build_id: String::new(),
414            inputs: BTreeMap::from([(
415                "my-card.ts".to_string(),
416                format!("sha256:{}", "2".repeat(64)),
417            )]),
418            outputs: BTreeMap::from([(
419                "bundle.js".to_string(),
420                format!("sha256:{}", "3".repeat(64)),
421            )]),
422            components: BTreeMap::from([(
423                "my-card".to_string(),
424                ProjectionComponent {
425                    module: "my-card.ts".to_string(),
426                    outputs: vec!["bundle.js".to_string()],
427                    hydration_keys: vec!["name".to_string()],
428                    navigation_keys: vec!["label".to_string(), "name".to_string()],
429                },
430            )]),
431            entry_closures: BTreeMap::new(),
432        };
433        manifest.build_id = manifest.compute_build_id();
434
435        let mut missing = manifest.clone();
436        missing.components.clear();
437        missing.build_id = missing.compute_build_id();
438        let error = parse_to_protocol(&files, "index.html", &[missing]).unwrap_err();
439        assert!(error.to_string().contains("PROJ-B001"));
440
441        let protocol = parse_to_protocol(&files, "index.html", &[manifest]).unwrap();
442        let component = protocol.components.get("my-card").unwrap();
443        assert_eq!(
444            protocol.initial_state_strategy,
445            InitialStateStrategy::Components as i32
446        );
447        assert_eq!(component.hydration_keys, ["name"]);
448        assert_eq!(component.navigation_keys, ["label", "name"]);
449    }
450
451    #[test]
452    fn parse_to_protocol_keeps_scriptless_navigation_metadata() {
453        let files = HashMap::from([
454            (
455                "index.html".to_string(),
456                "<html><body><my-card></my-card></body></html>".to_string(),
457            ),
458            (
459                "my-card.html".to_string(),
460                "<template shadowrootmode=\"open\"><p>{{name}}</p></template>".to_string(),
461            ),
462        ]);
463
464        let protocol = parse_to_protocol(&files, "index.html", &[]).unwrap();
465        let component = protocol.components.get("my-card").unwrap();
466        assert!(component.hydration_keys.is_empty());
467        assert_eq!(component.navigation_keys, ["name"]);
468        assert!(component.template_json.contains(r#""th":1"#));
469    }
470
471    #[test]
472    fn parse_to_protocol_preserves_lazy_render_policy() {
473        let files = HashMap::from([
474            (
475                "index.html".to_string(),
476                "<html><head></head><body><my-card></my-card></body></html>".to_string(),
477            ),
478            (
479                "my-card.html".to_string(),
480                concat!(
481                    r#"<template w-render="lazy" "#,
482                    r#"w-reserve-block-size="18rem"><p>{{name}}</p></template>"#,
483                )
484                .to_string(),
485            ),
486            (
487                "unused-card.html".to_string(),
488                concat!(
489                    r#"<template w-render="lazy" "#,
490                    r#"w-reserve-block-size="99px"><p>unused</p></template>"#,
491                )
492                .to_string(),
493            ),
494            ("my-card.ts".to_string(), "class MyCard {}".to_string()),
495        ]);
496
497        let protocol = parse_to_protocol(&files, "index.html", &[]).unwrap();
498        let component = protocol.components.get("my-card").unwrap();
499        assert!(component.template_json.contains(r#""wp":2"#));
500        assert_eq!(
501            protocol.component_render_css,
502            concat!(
503                r#"my-card:not([w-render="eager"]){content-visibility:auto;"#,
504                "contain-intrinsic-block-size:auto 18rem;}",
505            )
506        );
507    }
508
509    #[test]
510    fn parse_to_protocol_preserves_combined_render_interaction_policy() {
511        let files = HashMap::from([
512            (
513                "index.html".to_string(),
514                "<html><head></head><body><my-panel></my-panel></body></html>".to_string(),
515            ),
516            (
517                "my-panel.html".to_string(),
518                concat!(
519                    r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
520                    r#"w-hydrate="interaction"><button>Open</button></template>"#,
521                )
522                .to_string(),
523            ),
524            ("my-panel.ts".to_string(), "class MyPanel {}".to_string()),
525        ]);
526
527        let protocol = parse_to_protocol(&files, "index.html", &[]).unwrap();
528        let component = protocol.components.get("my-panel").unwrap();
529        assert!(component.template_json.contains(r#""wp":4"#));
530        assert_eq!(
531            component.work_policy,
532            webui_protocol::ComponentWorkPolicy::LazyRenderInteraction as i32
533        );
534        assert!(protocol
535            .component_render_css
536            .contains("content-visibility:auto"));
537    }
538}