Skip to main content

webui/
component_assets.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Static component asset graph rendering for CDN-loadable ESM modules.
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::{AssetFileNameTemplate, WebUIError};
18
19/// A rendered static component asset root or shared chunk.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ComponentAssetFile {
22    /// Output filename for the ESM asset.
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 shared chunk ESM 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. Components used
120/// by one requested root stay inline, while components with an identical
121/// multi-root consumer set are emitted once in a shared chunk.
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    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}