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::{AssetFileNameTemplate, 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 file_name_template: &str,
133 emit_metafile: bool,
134) -> Result<ComponentAssetGraph, WebUIError> {
135 if roots.is_empty() {
136 if emit_metafile {
137 return Err(WebUIError::InvalidBuildOptions(
138 "metafile requires at least one component_asset_root".to_string(),
139 ));
140 }
141 return Ok(ComponentAssetGraph {
142 files: Vec::new(),
143 metafile: None,
144 style_preloads: Vec::new(),
145 entry_fragments: Vec::new(),
146 entry_components: Vec::new(),
147 });
148 }
149
150 let file_name_template =
151 AssetFileNameTemplate::try_new(file_name_template.to_string(), "asset_file_name_template")
152 .map_err(|error| WebUIError::InvalidBuildOptions(error.to_string()))?;
153 let plan = graph::plan_component_assets(protocol, entry, roots)?;
154 let rendered =
155 render::render_component_asset_graph(protocol, &plan, &file_name_template, emit_metafile)?;
156 let style_preloads = collect_component_asset_style_preloads(protocol, &plan);
157 validate_unique_asset_file_names(&rendered.files)?;
158 let metafile = if emit_metafile {
159 Some(metafile::render_metafile(protocol, &rendered.outputs)?)
160 } else {
161 None
162 };
163
164 Ok(ComponentAssetGraph {
165 files: rendered.files,
166 metafile,
167 style_preloads,
168 entry_fragments: plan.entry_fragments,
169 entry_components: plan.entry_components,
170 })
171}
172
173fn collect_component_asset_style_preloads(
174 protocol: &WebUIProtocol,
175 plan: &graph::AssetGraphPlan<'_>,
176) -> Vec<ComponentAssetStylePreload> {
177 let mut preloads = Vec::with_capacity(plan.roots.len());
178 for root in &plan.roots {
179 let mut style_hrefs = Vec::new();
180 let mut seen = HashSet::with_capacity(root.required_components.len());
181 for component in &root.style_components {
182 if root.external_components.binary_search(component).is_ok() {
183 continue;
184 }
185 let Some(href) = protocol
186 .components
187 .get(plan.component_names[*component])
188 .map(|component| component.css_href.as_str())
189 .filter(|href| !href.is_empty())
190 else {
191 continue;
192 };
193 if seen.insert(href) {
194 style_hrefs.push(href.to_string());
195 }
196 }
197 if style_hrefs.is_empty() {
198 continue;
199 }
200 preloads.push(ComponentAssetStylePreload {
201 root: root.root.clone(),
202 style_hrefs,
203 });
204 }
205 preloads
206}
207
208fn validate_unique_asset_file_names(files: &[ComponentAssetFile]) -> Result<(), WebUIError> {
209 let mut names = HashSet::with_capacity(files.len());
210 for file in files {
211 if !names.insert(file.name.as_str()) {
212 return Err(WebUIError::InvalidBuildOptions(format!(
213 "component asset filename collision for '{}'. Adjust --asset-file-name-template to include [name] or another unique component-specific segment.",
214 file.name
215 )));
216 }
217 }
218 Ok(())
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use webui_protocol::{ComponentData, ComponentStyleClosure, FragmentList, StyleChunk};
225
226 #[test]
227 fn retain_entry_protocol_prunes_style_closure_roots_and_resources() {
228 let mut protocol = WebUIProtocol::default();
229 for name in ["index.html", "kept-card", "removed-card"] {
230 protocol
231 .fragments
232 .insert(name.to_string(), FragmentList::default());
233 }
234 for name in ["kept-card", "removed-card"] {
235 protocol
236 .components
237 .insert(name.to_string(), ComponentData::default());
238 }
239 protocol.style_closures.insert(
240 "index.html".to_string(),
241 ComponentStyleClosure {
242 component_tags: vec!["kept-card".to_string(), "removed-card".to_string()],
243 style_chunks: Vec::new(),
244 },
245 );
246 protocol.style_closures.insert(
247 "removed-card".to_string(),
248 ComponentStyleClosure {
249 component_tags: vec!["removed-card".to_string()],
250 style_chunks: Vec::new(),
251 },
252 );
253 let mut graph = ComponentAssetGraph {
254 files: vec![ComponentAssetFile {
255 name: "root.js".to_string(),
256 content: String::new(),
257 }],
258 metafile: None,
259 style_preloads: Vec::new(),
260 entry_fragments: vec!["index.html".to_string(), "kept-card".to_string()],
261 entry_components: vec!["kept-card".to_string()],
262 };
263
264 graph
265 .retain_entry_protocol(&mut protocol)
266 .expect("retain entry protocol");
267
268 assert_eq!(
269 protocol.style_closures["index.html"].component_tags,
270 ["kept-card"]
271 );
272 assert!(!protocol.style_closures.contains_key("removed-card"));
273 }
274
275 #[test]
276 fn retain_entry_protocol_prunes_and_remaps_style_chunks() {
277 let mut protocol = WebUIProtocol::default();
278 for name in ["index.html", "kept-card", "removed-card"] {
279 protocol
280 .fragments
281 .insert(name.to_string(), FragmentList::default());
282 }
283 for name in ["kept-card", "removed-card"] {
284 protocol
285 .components
286 .insert(name.to_string(), ComponentData::default());
287 }
288 protocol.style_chunks = vec![
289 StyleChunk {
290 name: "removed".to_string(),
291 css: ".removed{}".to_string(),
292 component_tags: vec!["removed-card".to_string()],
293 ..Default::default()
294 },
295 StyleChunk {
296 name: "kept".to_string(),
297 css: ".kept{}".to_string(),
298 component_tags: vec!["kept-card".to_string()],
299 ..Default::default()
300 },
301 ];
302 protocol.style_closures.insert(
303 "index.html".to_string(),
304 ComponentStyleClosure {
305 component_tags: vec!["kept-card".to_string()],
306 style_chunks: vec![1],
307 },
308 );
309 protocol.style_closures.insert(
310 "removed-card".to_string(),
311 ComponentStyleClosure {
312 component_tags: vec!["removed-card".to_string()],
313 style_chunks: vec![0],
314 },
315 );
316 let mut graph = ComponentAssetGraph {
317 files: vec![ComponentAssetFile {
318 name: "removed-card.js".to_string(),
319 content: String::new(),
320 }],
321 metafile: None,
322 style_preloads: Vec::new(),
323 entry_fragments: vec!["index.html".to_string(), "kept-card".to_string()],
324 entry_components: vec!["kept-card".to_string()],
325 };
326
327 graph
328 .retain_entry_protocol(&mut protocol)
329 .expect("retain entry protocol");
330
331 assert_eq!(protocol.style_chunks.len(), 1);
332 assert_eq!(protocol.style_chunks[0].name, "kept");
333 assert_eq!(protocol.style_closures["index.html"].style_chunks, [0]);
334 }
335
336 #[test]
337 fn component_asset_rejects_missing_style_closure_metadata() {
338 let mut protocol = WebUIProtocol::default();
339 protocol
340 .fragments
341 .insert("index.html".to_string(), FragmentList::default());
342 protocol.fragments.insert(
343 "legacy-card".to_string(),
344 FragmentList {
345 fragments: vec![webui_protocol::WebUIFragment::raw("<p>Legacy</p>")],
346 contains_boundary: false,
347 },
348 );
349 protocol.components.insert(
350 "legacy-card".to_string(),
351 ComponentData {
352 template_json: r#"{"h":"<p>Legacy</p>"}"#.to_string(),
353 css: ".legacy{display:block}".to_string(),
354 ..Default::default()
355 },
356 );
357
358 let error = render_component_assets(
359 &protocol,
360 "index.html",
361 &["legacy-card".to_string()],
362 "[name].[ext]",
363 false,
364 )
365 .expect_err("current component assets require style closure metadata");
366 assert!(error
367 .to_string()
368 .contains("requires missing style closure metadata"));
369 }
370
371 #[test]
372 fn component_asset_serializes_closures_only_for_owned_components() {
373 let mut protocol = WebUIProtocol::default();
374 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
375 protocol.fragments.insert(
376 "index.html".to_string(),
377 FragmentList {
378 fragments: vec![webui_protocol::WebUIFragment::component("entry-card")],
379 contains_boundary: false,
380 },
381 );
382 protocol
383 .fragments
384 .insert("entry-card".to_string(), FragmentList::default());
385 protocol.fragments.insert(
386 "deferred-card".to_string(),
387 FragmentList {
388 fragments: vec![webui_protocol::WebUIFragment::component("entry-card")],
389 contains_boundary: false,
390 },
391 );
392 for tag in ["entry-card", "deferred-card"] {
393 protocol.components.insert(
394 tag.to_string(),
395 ComponentData {
396 template_json: r#"{"h":"<div></div>"}"#.to_string(),
397 css: format!(".{tag}{{display:block}}"),
398 ..Default::default()
399 },
400 );
401 }
402 protocol.style_closures.insert(
403 "entry-card".to_string(),
404 ComponentStyleClosure {
405 component_tags: vec!["entry-card".to_string()],
406 style_chunks: Vec::new(),
407 },
408 );
409 protocol.style_closures.insert(
410 "deferred-card".to_string(),
411 ComponentStyleClosure {
412 component_tags: vec!["deferred-card".to_string(), "entry-card".to_string()],
413 style_chunks: Vec::new(),
414 },
415 );
416
417 let graph = render_component_assets(
418 &protocol,
419 "index.html",
420 &["deferred-card".to_string()],
421 "[name].[ext]",
422 false,
423 )
424 .expect("render component asset");
425 let asset = graph.files.first().expect("deferred root asset");
426
427 assert!(asset
428 .content
429 .contains(r#""closures":{"deferred-card":["deferred-card","entry-card"]}"#));
430 assert!(!asset.content.contains(r#""entry-card":["entry-card"]"#));
431 }
432}