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, 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/// Returns the serialized `WebUIProtocol` as protobuf bytes.
18#[wasm_bindgen]
19pub fn build_protocol(
20    files: JsValue,
21    entry: &str,
22    projection_manifests: Option<JsValue>,
23) -> Result<Vec<u8>, JsValue> {
24    let files_map: HashMap<String, String> =
25        serde_wasm_bindgen::from_value(files).map_err(|e| JsValue::from_str(&e.to_string()))?;
26    let manifests: Vec<ProjectionManifest> = projection_manifests
27        .map(serde_wasm_bindgen::from_value)
28        .transpose()
29        .map_err(|error| JsValue::from_str(&format!("invalid projection manifests: {error}")))?
30        .unwrap_or_default();
31
32    build_protocol_inner(&files_map, entry, &manifests)
33        .map_err(|e| JsValue::from_str(&e.to_string()))
34}
35
36pub(crate) fn build_protocol_inner(
37    files: &HashMap<String, String>,
38    entry: &str,
39    projection_manifests: &[ProjectionManifest],
40) -> Result<Vec<u8>, WasmError> {
41    let protocol = parse_to_protocol(files, entry, projection_manifests)?;
42    protocol.to_protobuf().map_err(WasmError::Protocol)
43}
44
45/// Register all component `.html` files and optional companion `.css` files
46/// from the virtual file map, skipping the entry.
47fn register_components(
48    parser: &mut HtmlParser,
49    files: &HashMap<String, String>,
50    entry: &str,
51) -> Result<(), WasmError> {
52    for (filename, content) in files {
53        if filename != entry && filename.ends_with(".html") {
54            let tag_name = filename.trim_end_matches(".html");
55            if tag_name.contains('-') {
56                let css_key = format!("{tag_name}.css");
57                let css = files.get(&css_key).map(String::as_str);
58                // A sibling module marks an authored client component. Rust
59                // never analyzes its source; optional projection manifests
60                // provide exact client state surfaces.
61                parser.component_registry_mut().register_component(
62                    webui_parser::ComponentRegistration {
63                        tag_name,
64                        html_content: content,
65                        css_content: css,
66                        is_client_owned: has_component_script(files, tag_name),
67                    },
68                )?;
69            }
70        }
71    }
72    Ok(())
73}
74
75/// Return whether the virtual file map contains an authored component module.
76fn has_component_script(files: &HashMap<String, String>, tag_name: &str) -> bool {
77    files.contains_key(&format!("{tag_name}.ts")) || files.contains_key(&format!("{tag_name}.js"))
78}
79
80/// Parse virtual files into a `WebUIProtocol` using the real `webui-parser`
81/// with the WebUI plugin.
82pub(crate) fn parse_to_protocol(
83    files: &HashMap<String, String>,
84    entry: &str,
85    projection_manifests: &[ProjectionManifest],
86) -> Result<WebUIProtocol, WasmError> {
87    let entry_html = files
88        .get(entry)
89        .ok_or_else(|| WasmError::MissingEntry(entry.to_string()))?;
90
91    let mut parser =
92        HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), CssStrategy::Style);
93    register_components(&mut parser, files, entry)?;
94    parser.parse(entry, entry_html)?;
95    let templates = match parser.take_plugin_artifacts()? {
96        ParserPluginArtifacts::None => Vec::new(),
97        ParserPluginArtifacts::ComponentTemplates(templates) => templates,
98    };
99
100    let mut protocol = WebUIProtocol::new(parser.into_fragment_records());
101    let projection = merge_projection_manifests(projection_manifests)?;
102    protocol.initial_state_strategy = if projection.is_some() {
103        InitialStateStrategy::Components as i32
104    } else {
105        InitialStateStrategy::Full as i32
106    };
107    if let Some(entries) = &projection {
108        let mut missing = Vec::new();
109        for artifact in &templates {
110            if artifact.is_scripted
111                && protocol.fragments.contains_key(&artifact.tag_name)
112                && !entries.contains_key(&artifact.tag_name)
113            {
114                missing.push(artifact.tag_name.as_str());
115            }
116        }
117        if !missing.is_empty() {
118            missing.sort_unstable();
119            return Err(WasmError::Projection(format!(
120                "PROJ-B001: scripted components have no projection entry: {}",
121                missing.join(", ")
122            )));
123        }
124    }
125    for artifact in templates {
126        let manifest_entry = if artifact.is_scripted {
127            projection
128                .as_ref()
129                .and_then(|entries| entries.get(&artifact.tag_name))
130        } else {
131            None
132        };
133        let component = protocol.components.entry(artifact.tag_name).or_default();
134        component.template = artifact.template;
135        component.template_json = artifact.template_json;
136        component.template_functions = artifact.template_functions;
137        let hydration = manifest_entry.map_or(artifact.hydration, |entry| {
138            StateSurface::Keys(entry.hydration_keys.clone())
139        });
140        let navigation = manifest_entry.map_or(artifact.navigation, |entry| {
141            StateSurface::Keys(union_keys(&entry.navigation_keys, &artifact.template_roots))
142        });
143        let (hydration_mode, hydration_keys) = encode_state_surface(hydration);
144        component.hydration_mode = hydration_mode;
145        component.hydration_keys = hydration_keys;
146        let (navigation_mode, navigation_keys) = encode_state_surface(navigation);
147        component.navigation_mode = navigation_mode;
148        component.navigation_keys = navigation_keys;
149    }
150
151    fn merge_projection_manifests(
152        manifests: &[ProjectionManifest],
153    ) -> Result<Option<std::collections::BTreeMap<String, ProjectionComponent>>, WasmError> {
154        if manifests.is_empty() {
155            return Ok(None);
156        }
157        let mut components = std::collections::BTreeMap::new();
158        for manifest in manifests {
159            let serialized_size = serde_json::to_vec(manifest)
160                .map_err(|error| WasmError::Projection(format!("PROJ-M009: {error}")))?
161                .len();
162            if serialized_size > 16 * 1024 * 1024 {
163                return Err(WasmError::Projection(
164                    "PROJ-S001: projection manifest exceeds the 16 MiB limit".to_string(),
165                ));
166            }
167            manifest
168                .validate()
169                .map_err(|error| WasmError::Projection(format!("{}: {error}", error.code())))?;
170            for (tag, entry) in &manifest.components {
171                if components.insert(tag.clone(), entry.clone()).is_some() {
172                    return Err(WasmError::Projection(format!(
173                        "PROJ-M006: component <{tag}> is declared by more than one projection manifest"
174                    )));
175                }
176            }
177        }
178        Ok(Some(components))
179    }
180
181    fn union_keys(left: &[String], right: &[String]) -> Vec<String> {
182        let mut keys = Vec::with_capacity(left.len() + right.len());
183        keys.extend_from_slice(left);
184        keys.extend_from_slice(right);
185        keys.sort_unstable();
186        keys.dedup();
187        keys
188    }
189    Ok(protocol)
190}
191
192fn encode_state_surface(surface: StateSurface) -> (i32, Vec<String>) {
193    match surface {
194        StateSurface::None => (StateProjectionMode::None as i32, Vec::new()),
195        StateSurface::Keys(keys) => (StateProjectionMode::Keys as i32, keys),
196        StateSurface::All => (StateProjectionMode::All as i32, Vec::new()),
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn build_protocol_reports_missing_entry() {
206        let files = HashMap::new();
207        let err = build_protocol_inner(&files, "index.html", &[]).unwrap_err();
208        assert_eq!(err.to_string(), "Entry file 'index.html' not found");
209    }
210
211    #[test]
212    fn parse_to_protocol_without_manifest_preserves_full_state() {
213        let files = HashMap::from([
214            (
215                "index.html".to_string(),
216                "<html><body><my-card></my-card></body></html>".to_string(),
217            ),
218            (
219                "my-card.html".to_string(),
220                "<template shadowrootmode=\"open\"><p>{{name}}</p></template>".to_string(),
221            ),
222            (
223                "my-card.ts".to_string(),
224                "class MyCard { @observable name = ''; }".to_string(),
225            ),
226        ]);
227
228        let protocol = parse_to_protocol(&files, "index.html", &[]).unwrap();
229        let component = protocol.components.get("my-card").unwrap();
230
231        assert_eq!(
232            protocol.initial_state_strategy,
233            InitialStateStrategy::Full as i32
234        );
235        assert_eq!(component.hydration_mode, StateProjectionMode::All as i32);
236        assert!(component.hydration_keys.is_empty());
237        assert!(!component.template_json.is_empty());
238    }
239
240    #[test]
241    fn parse_to_protocol_applies_manifest_surfaces() {
242        use std::collections::BTreeMap;
243        use webui_protocol::projection_manifest::{
244            ProjectionAdapter, ProjectionComponent, ProjectionProducer, PRODUCER_NAME, SCHEMA_ID,
245        };
246
247        let files = HashMap::from([
248            (
249                "index.html".to_string(),
250                "<html><body><my-card></my-card></body></html>".to_string(),
251            ),
252            (
253                "my-card.html".to_string(),
254                "<template shadowrootmode=\"open\"><p>{{name}}</p></template>".to_string(),
255            ),
256            ("my-card.ts".to_string(), "export {};".to_string()),
257        ]);
258        let mut manifest = ProjectionManifest {
259            schema: SCHEMA_ID.to_string(),
260            producer: ProjectionProducer {
261                name: PRODUCER_NAME.to_string(),
262                version: "0.0.18".to_string(),
263            },
264            adapter: ProjectionAdapter {
265                name: "test".to_string(),
266                bundler: "test@1.0.0".to_string(),
267            },
268            root: ".".to_string(),
269            analysis_hash: format!("sha256:{}", "1".repeat(64)),
270            build_id: String::new(),
271            inputs: BTreeMap::from([(
272                "my-card.ts".to_string(),
273                format!("sha256:{}", "2".repeat(64)),
274            )]),
275            outputs: BTreeMap::from([(
276                "bundle.js".to_string(),
277                format!("sha256:{}", "3".repeat(64)),
278            )]),
279            components: BTreeMap::from([(
280                "my-card".to_string(),
281                ProjectionComponent {
282                    module: "my-card.ts".to_string(),
283                    outputs: vec!["bundle.js".to_string()],
284                    hydration_keys: vec!["name".to_string()],
285                    navigation_keys: vec!["label".to_string(), "name".to_string()],
286                },
287            )]),
288        };
289        manifest.build_id = manifest.compute_build_id();
290
291        let mut missing = manifest.clone();
292        missing.components.clear();
293        missing.build_id = missing.compute_build_id();
294        let error = parse_to_protocol(&files, "index.html", &[missing]).unwrap_err();
295        assert!(error.to_string().contains("PROJ-B001"));
296
297        let protocol = parse_to_protocol(&files, "index.html", &[manifest]).unwrap();
298        let component = protocol.components.get("my-card").unwrap();
299        assert_eq!(
300            protocol.initial_state_strategy,
301            InitialStateStrategy::Components as i32
302        );
303        assert_eq!(component.hydration_keys, ["name"]);
304        assert_eq!(component.navigation_keys, ["label", "name"]);
305    }
306
307    #[test]
308    fn parse_to_protocol_keeps_scriptless_navigation_metadata() {
309        let files = HashMap::from([
310            (
311                "index.html".to_string(),
312                "<html><body><my-card></my-card></body></html>".to_string(),
313            ),
314            (
315                "my-card.html".to_string(),
316                "<template shadowrootmode=\"open\"><p>{{name}}</p></template>".to_string(),
317            ),
318        ]);
319
320        let protocol = parse_to_protocol(&files, "index.html", &[]).unwrap();
321        let component = protocol.components.get("my-card").unwrap();
322        assert!(component.hydration_keys.is_empty());
323        assert_eq!(component.navigation_keys, ["name"]);
324        assert!(component.template_json.contains(r#""th":1"#));
325    }
326}