1mod graph;
7mod json;
8mod metafile;
9mod payload;
10mod render;
11mod serialize;
12mod traversal;
13
14use std::collections::HashSet;
15use webui_protocol::{ComponentAssetStylePreload, WebUIProtocol};
16
17use crate::WebUIError;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ComponentAssetFile {
22 pub name: String,
24 pub content: String,
26}
27
28#[derive(Debug)]
30pub struct ComponentAssetGraph {
31 pub files: Vec<ComponentAssetFile>,
33 pub metafile: Option<String>,
35 pub style_preloads: Vec<ComponentAssetStylePreload>,
37 entry_fragments: Vec<String>,
38 entry_components: Vec<String>,
39}
40
41impl ComponentAssetGraph {
42 pub(crate) fn retain_entry_protocol(
43 &mut self,
44 protocol: &mut WebUIProtocol,
45 ) -> Result<(), WebUIError> {
46 if self.files.is_empty() {
47 return Ok(());
48 }
49 protocol.component_asset_style_preloads = std::mem::take(&mut self.style_preloads);
50 protocol
51 .fragments
52 .retain(|name, _| self.entry_fragments.binary_search(name).is_ok());
53 protocol
54 .components
55 .retain(|name, _| self.entry_components.binary_search(name).is_ok());
56 let fragments = &protocol.fragments;
57 protocol
58 .style_closures
59 .retain(|name, _| fragments.contains_key(name));
60 let components = &protocol.components;
61 for closure in protocol.style_closures.values_mut() {
62 closure
63 .component_tags
64 .retain(|name| components.contains_key(name));
65 }
66 Self::retain_referenced_style_chunks(protocol)
67 }
68
69 fn retain_referenced_style_chunks(protocol: &mut WebUIProtocol) -> Result<(), WebUIError> {
70 if protocol.style_chunks.is_empty() {
71 return Ok(());
72 }
73
74 let mut retained = vec![false; protocol.style_chunks.len()];
75 for closure in protocol.style_closures.values() {
76 for &index in &closure.style_chunks {
77 if let Some(slot) = retained.get_mut(index as usize) {
78 *slot = true;
79 }
80 }
81 }
82
83 let old_chunks = std::mem::take(&mut protocol.style_chunks);
84 let mut remap = vec![u32::MAX; old_chunks.len()];
85 protocol
86 .style_chunks
87 .reserve(retained.iter().filter(|keep| **keep).count());
88 for (old_index, (chunk, keep)) in old_chunks.into_iter().zip(retained).enumerate() {
89 if !keep {
90 continue;
91 }
92 remap[old_index] = u32::try_from(protocol.style_chunks.len()).map_err(|_| {
93 WebUIError::InvalidBuildOptions(
94 "component asset style chunk count exceeds the protocol u32 index limit"
95 .to_string(),
96 )
97 })?;
98 protocol.style_chunks.push(chunk);
99 }
100
101 for closure in protocol.style_closures.values_mut() {
102 closure.style_chunks.retain(|index| {
103 remap
104 .get(*index as usize)
105 .is_some_and(|mapped| *mapped != u32::MAX)
106 });
107 for index in &mut closure.style_chunks {
108 if let Some(mapped) = remap.get(*index as usize) {
109 *index = *mapped;
110 }
111 }
112 }
113 Ok(())
114 }
115}
116
117#[must_use = "component asset graph files must be written or otherwise consumed"]
128pub fn render_component_assets(
129 protocol: &WebUIProtocol,
130 entry: &str,
131 roots: &[String],
132 emit_metafile: bool,
133) -> Result<ComponentAssetGraph, WebUIError> {
134 if roots.is_empty() {
135 if emit_metafile {
136 return Err(WebUIError::InvalidBuildOptions(
137 "metafile requires at least one component_asset_root".to_string(),
138 ));
139 }
140 return Ok(ComponentAssetGraph {
141 files: Vec::new(),
142 metafile: None,
143 style_preloads: Vec::new(),
144 entry_fragments: Vec::new(),
145 entry_components: Vec::new(),
146 });
147 }
148
149 let plan = graph::plan_component_assets(protocol, entry, roots)?;
150 let rendered = render::render_component_asset_graph(protocol, &plan, emit_metafile)?;
151 let style_preloads = collect_component_asset_style_preloads(protocol, &plan);
152 validate_unique_asset_file_names(&rendered.files)?;
153 let metafile = if emit_metafile {
154 Some(metafile::render_metafile(protocol, &rendered.outputs)?)
155 } else {
156 None
157 };
158
159 Ok(ComponentAssetGraph {
160 files: rendered.files,
161 metafile,
162 style_preloads,
163 entry_fragments: plan.entry_fragments,
164 entry_components: plan.entry_components,
165 })
166}
167
168fn collect_component_asset_style_preloads(
169 protocol: &WebUIProtocol,
170 plan: &graph::AssetGraphPlan<'_>,
171) -> Vec<ComponentAssetStylePreload> {
172 let mut preloads = Vec::with_capacity(plan.roots.len());
173 for root in &plan.roots {
174 let mut style_hrefs = Vec::new();
175 let mut seen = HashSet::with_capacity(root.required_components.len());
176 for component in &root.style_components {
177 if root.external_components.binary_search(component).is_ok() {
178 continue;
179 }
180 let Some(href) = protocol
181 .components
182 .get(plan.component_names[*component])
183 .map(|component| component.css_href.as_str())
184 .filter(|href| !href.is_empty())
185 else {
186 continue;
187 };
188 if seen.insert(href) {
189 style_hrefs.push(href.to_string());
190 }
191 }
192 if style_hrefs.is_empty() {
193 continue;
194 }
195 preloads.push(ComponentAssetStylePreload {
196 root: root.root.clone(),
197 style_hrefs,
198 });
199 }
200 preloads
201}
202
203fn validate_unique_asset_file_names(files: &[ComponentAssetFile]) -> Result<(), WebUIError> {
204 let mut names = HashSet::with_capacity(files.len());
205 for file in files {
206 if !names.insert(file.name.as_str()) {
207 return Err(WebUIError::InvalidBuildOptions(format!(
208 "component asset filename collision for '{}'. Choose distinct component root tags.",
209 file.name
210 )));
211 }
212 }
213 Ok(())
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use webui_protocol::{ComponentData, ComponentStyleClosure, FragmentList, StyleChunk};
220
221 #[test]
222 fn retain_entry_protocol_prunes_style_closure_roots_and_resources() {
223 let mut protocol = WebUIProtocol::default();
224 for name in ["index.html", "kept-card", "removed-card"] {
225 protocol
226 .fragments
227 .insert(name.to_string(), FragmentList::default());
228 }
229 for name in ["kept-card", "removed-card"] {
230 protocol
231 .components
232 .insert(name.to_string(), ComponentData::default());
233 }
234 protocol.style_closures.insert(
235 "index.html".to_string(),
236 ComponentStyleClosure {
237 component_tags: vec!["kept-card".to_string(), "removed-card".to_string()],
238 style_chunks: Vec::new(),
239 },
240 );
241 protocol.style_closures.insert(
242 "removed-card".to_string(),
243 ComponentStyleClosure {
244 component_tags: vec!["removed-card".to_string()],
245 style_chunks: Vec::new(),
246 },
247 );
248 let mut graph = ComponentAssetGraph {
249 files: vec![ComponentAssetFile {
250 name: "root.js".to_string(),
251 content: String::new(),
252 }],
253 metafile: None,
254 style_preloads: Vec::new(),
255 entry_fragments: vec!["index.html".to_string(), "kept-card".to_string()],
256 entry_components: vec!["kept-card".to_string()],
257 };
258
259 graph
260 .retain_entry_protocol(&mut protocol)
261 .expect("retain entry protocol");
262
263 assert_eq!(
264 protocol.style_closures["index.html"].component_tags,
265 ["kept-card"]
266 );
267 assert!(!protocol.style_closures.contains_key("removed-card"));
268 }
269
270 #[test]
271 fn retain_entry_protocol_prunes_and_remaps_style_chunks() {
272 let mut protocol = WebUIProtocol::default();
273 for name in ["index.html", "kept-card", "removed-card"] {
274 protocol
275 .fragments
276 .insert(name.to_string(), FragmentList::default());
277 }
278 for name in ["kept-card", "removed-card"] {
279 protocol
280 .components
281 .insert(name.to_string(), ComponentData::default());
282 }
283 protocol.style_chunks = vec![
284 StyleChunk {
285 name: "removed".to_string(),
286 css: ".removed{}".to_string(),
287 component_tags: vec!["removed-card".to_string()],
288 ..Default::default()
289 },
290 StyleChunk {
291 name: "kept".to_string(),
292 css: ".kept{}".to_string(),
293 component_tags: vec!["kept-card".to_string()],
294 ..Default::default()
295 },
296 ];
297 protocol.style_closures.insert(
298 "index.html".to_string(),
299 ComponentStyleClosure {
300 component_tags: vec!["kept-card".to_string()],
301 style_chunks: vec![1],
302 },
303 );
304 protocol.style_closures.insert(
305 "removed-card".to_string(),
306 ComponentStyleClosure {
307 component_tags: vec!["removed-card".to_string()],
308 style_chunks: vec![0],
309 },
310 );
311 let mut graph = ComponentAssetGraph {
312 files: vec![ComponentAssetFile {
313 name: "removed-card.js".to_string(),
314 content: String::new(),
315 }],
316 metafile: None,
317 style_preloads: Vec::new(),
318 entry_fragments: vec!["index.html".to_string(), "kept-card".to_string()],
319 entry_components: vec!["kept-card".to_string()],
320 };
321
322 graph
323 .retain_entry_protocol(&mut protocol)
324 .expect("retain entry protocol");
325
326 assert_eq!(protocol.style_chunks.len(), 1);
327 assert_eq!(protocol.style_chunks[0].name, "kept");
328 assert_eq!(protocol.style_closures["index.html"].style_chunks, [0]);
329 }
330
331 #[test]
332 fn component_asset_rejects_missing_style_closure_metadata() {
333 let mut protocol = WebUIProtocol::default();
334 protocol
335 .fragments
336 .insert("index.html".to_string(), FragmentList::default());
337 protocol.fragments.insert(
338 "legacy-card".to_string(),
339 FragmentList {
340 fragments: vec![webui_protocol::WebUIFragment::raw("<p>Legacy</p>")],
341 contains_boundary: false,
342 },
343 );
344 protocol.components.insert(
345 "legacy-card".to_string(),
346 ComponentData {
347 template_json: r#"{"h":"<p>Legacy</p>"}"#.to_string(),
348 css: ".legacy{display:block}".to_string(),
349 ..Default::default()
350 },
351 );
352
353 let error =
354 render_component_assets(&protocol, "index.html", &["legacy-card".to_string()], false)
355 .expect_err("current component assets require style closure metadata");
356 assert!(error
357 .to_string()
358 .contains("requires missing style closure metadata"));
359 }
360
361 #[test]
362 fn component_asset_serializes_closures_only_for_owned_components() {
363 let mut protocol = WebUIProtocol::default();
364 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
365 protocol.fragments.insert(
366 "index.html".to_string(),
367 FragmentList {
368 fragments: vec![webui_protocol::WebUIFragment::component("entry-card")],
369 contains_boundary: false,
370 },
371 );
372 protocol
373 .fragments
374 .insert("entry-card".to_string(), FragmentList::default());
375 protocol.fragments.insert(
376 "deferred-card".to_string(),
377 FragmentList {
378 fragments: vec![webui_protocol::WebUIFragment::component("entry-card")],
379 contains_boundary: false,
380 },
381 );
382 for tag in ["entry-card", "deferred-card"] {
383 protocol.components.insert(
384 tag.to_string(),
385 ComponentData {
386 template_json: r#"{"h":"<div></div>"}"#.to_string(),
387 css: format!(".{tag}{{display:block}}"),
388 ..Default::default()
389 },
390 );
391 }
392 protocol.style_closures.insert(
393 "entry-card".to_string(),
394 ComponentStyleClosure {
395 component_tags: vec!["entry-card".to_string()],
396 style_chunks: Vec::new(),
397 },
398 );
399 protocol.style_closures.insert(
400 "deferred-card".to_string(),
401 ComponentStyleClosure {
402 component_tags: vec!["deferred-card".to_string(), "entry-card".to_string()],
403 style_chunks: Vec::new(),
404 },
405 );
406
407 let graph = render_component_assets(
408 &protocol,
409 "index.html",
410 &["deferred-card".to_string()],
411 false,
412 )
413 .expect("render component asset");
414 let asset = graph
415 .files
416 .iter()
417 .find(|file| file.name.starts_with("components/deferred-card."))
418 .expect("deferred component module");
419
420 assert!(asset
421 .content
422 .contains(r#""closures":{"deferred-card":["deferred-card","entry-card"]}"#));
423 assert!(!asset.content.contains(r#""entry-card":["entry-card"]"#));
424
425 for missing in ["unknown-card", "entry-card"] {
426 let mut invalid = protocol.clone();
427 invalid
428 .style_closures
429 .get_mut("deferred-card")
430 .unwrap()
431 .component_tags
432 .push(missing.to_string());
433 if missing == "entry-card" {
434 invalid.components.get_mut(missing).unwrap().css.clear();
435 }
436 let error = render_component_assets(
437 &invalid,
438 "index.html",
439 &["deferred-card".to_string()],
440 false,
441 )
442 .unwrap_err();
443 assert!(error.to_string().contains("unavailable style resource"));
444 }
445
446 let mut outside = protocol.clone();
447 outside.components.insert(
448 "outside-card".to_string(),
449 ComponentData {
450 css: ".outside{}".to_string(),
451 template_json: r#"{"h":"<p>Outside</p>"}"#.to_string(),
452 ..Default::default()
453 },
454 );
455 outside
456 .style_closures
457 .get_mut("deferred-card")
458 .unwrap()
459 .component_tags
460 .push("outside-card".to_string());
461 assert!(render_component_assets(
462 &outside,
463 "index.html",
464 &["deferred-card".to_string()],
465 false,
466 )
467 .is_err());
468 }
469}