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, HtmlParser};
12use webui_protocol::projection_manifest::{ProjectionComponent, ProjectionManifest};
13use webui_protocol::{InitialStateStrategy, StateProjectionMode, WebUIProtocol};
14
15#[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
45fn 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 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
75fn 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
80pub(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 = Some(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 entry_closures: BTreeMap::new(),
289 };
290 manifest.build_id = manifest.compute_build_id();
291
292 let mut missing = manifest.clone();
293 missing.components.clear();
294 missing.build_id = missing.compute_build_id();
295 let error = parse_to_protocol(&files, "index.html", &[missing]).unwrap_err();
296 assert!(error.to_string().contains("PROJ-B001"));
297
298 let protocol = parse_to_protocol(&files, "index.html", &[manifest]).unwrap();
299 let component = protocol.components.get("my-card").unwrap();
300 assert_eq!(
301 protocol.initial_state_strategy,
302 InitialStateStrategy::Components as i32
303 );
304 assert_eq!(component.hydration_keys, ["name"]);
305 assert_eq!(component.navigation_keys, ["label", "name"]);
306 }
307
308 #[test]
309 fn parse_to_protocol_keeps_scriptless_navigation_metadata() {
310 let files = HashMap::from([
311 (
312 "index.html".to_string(),
313 "<html><body><my-card></my-card></body></html>".to_string(),
314 ),
315 (
316 "my-card.html".to_string(),
317 "<template shadowrootmode=\"open\"><p>{{name}}</p></template>".to_string(),
318 ),
319 ]);
320
321 let protocol = parse_to_protocol(&files, "index.html", &[]).unwrap();
322 let component = protocol.components.get("my-card").unwrap();
323 assert!(component.hydration_keys.is_empty());
324 assert_eq!(component.navigation_keys, ["name"]);
325 assert!(component.template_json.contains(r#""th":1"#));
326 }
327}