Skip to main content

webui/
component_assets.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Semantic component ESM input generation for application-owned bundling.
5
6mod 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/// A generated component or root ESM input.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ComponentAssetFile {
22    /// Stable root or content-addressed component input filename.
23    pub name: String,
24    /// JavaScript module content.
25    pub content: String,
26}
27
28/// Rendered component asset graph.
29#[derive(Debug)]
30pub struct ComponentAssetGraph {
31    /// Root and component ESM input files.
32    pub files: Vec<ComponentAssetFile>,
33    /// Optional esbuild-compatible metafile JSON.
34    pub metafile: Option<String>,
35    /// Compiler-resolved Link stylesheet hrefs for intent-time root preloading.
36    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/// Render a static component asset graph.
118///
119/// Entry-reachable components remain external prerequisites. Each asset-owned
120/// component is emitted once, and roots statically import their full closure.
121/// The application bundler chooses final chunks, filenames, and delivery.
122///
123/// # Errors
124///
125/// Returns [`WebUIError`] when the entry/root graph is invalid, contains a
126/// route, lacks compiled template metadata, or produces colliding filenames.
127#[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}