1use 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#[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
64fn 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 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
94fn 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#[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}