Skip to main content

webui/
component_assets.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Static component asset rendering for CDN-loadable ESM modules.
5
6use rayon::prelude::*;
7use std::collections::HashSet;
8use webui_handler::css_module;
9use webui_protocol::{web_ui_fragment::Fragment, WebUIFragmentRoute, WebUIProtocol};
10
11use crate::{AssetFileNameTemplate, WebUIError};
12
13const ASSET_TYPE: &str = "webui-component-asset";
14const ASSET_VERSION: u64 = 1;
15const COMPONENT_ASSET_EXT: &str = "webui.js";
16
17/// A rendered static component asset file.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ComponentAssetFile {
20    /// Output filename for the ESM asset.
21    pub name: String,
22    /// JavaScript module content.
23    pub content: String,
24}
25
26struct ComponentAssetPlan {
27    root: String,
28    components: Vec<String>,
29}
30
31/// Render static CDN-loadable component asset modules for root components.
32///
33/// Each requested root produces one ESM module. The module contains the root's
34/// conservative component dependency closure, template/style metadata, and any
35/// WebUI condition closures needed by those templates.
36///
37/// # Errors
38///
39/// Returns [`WebUIError`] when the root allowlist is invalid, a requested root
40/// has no compiled template metadata, asset filename generation fails, or two
41/// component assets resolve to the same filename.
42#[must_use = "component asset files must be written or otherwise consumed"]
43pub fn render_component_assets(
44    protocol: &WebUIProtocol,
45    roots: &[String],
46    file_name_template: &str,
47) -> Result<Vec<ComponentAssetFile>, WebUIError> {
48    let plans = plan_component_assets(protocol, roots)?;
49    if plans.is_empty() {
50        return Ok(Vec::new());
51    }
52
53    let file_name_template =
54        AssetFileNameTemplate::try_new(file_name_template.to_string(), "asset_file_name_template")
55            .map_err(|error| WebUIError::InvalidBuildOptions(error.to_string()))?;
56
57    let rendered: Vec<Result<ComponentAssetFile, WebUIError>> = plans
58        .par_iter()
59        .map(|plan| render_asset_file(protocol, plan, &file_name_template))
60        .collect();
61
62    let mut files = Vec::with_capacity(plans.len());
63    for file in rendered {
64        files.push(file?);
65    }
66    validate_unique_asset_file_names(&files)?;
67    Ok(files)
68}
69
70fn plan_component_assets(
71    protocol: &WebUIProtocol,
72    roots: &[String],
73) -> Result<Vec<ComponentAssetPlan>, WebUIError> {
74    let roots = validate_roots(protocol, roots)?;
75    let mut plans = Vec::with_capacity(roots.len());
76    for root in roots {
77        plans.push(ComponentAssetPlan {
78            components: collect_component_asset_closure(protocol, &root),
79            root,
80        });
81    }
82    Ok(plans)
83}
84
85fn render_asset_file(
86    protocol: &WebUIProtocol,
87    plan: &ComponentAssetPlan,
88    file_name_template: &AssetFileNameTemplate,
89) -> Result<ComponentAssetFile, WebUIError> {
90    let content = build_asset_module(protocol, plan)?;
91    let name = file_name_template.resolve(&plan.root, COMPONENT_ASSET_EXT, content.as_bytes());
92    Ok(ComponentAssetFile { name, content })
93}
94
95fn validate_unique_asset_file_names(files: &[ComponentAssetFile]) -> Result<(), WebUIError> {
96    let mut names = HashSet::with_capacity(files.len());
97    for file in files {
98        if !names.insert(file.name.as_str()) {
99            return Err(WebUIError::InvalidBuildOptions(format!(
100                "component asset filename collision for '{}'. Adjust --asset-file-name-template to include [name] or another unique component-specific segment.",
101                file.name
102            )));
103        }
104    }
105    Ok(())
106}
107
108fn build_asset_module(
109    protocol: &WebUIProtocol,
110    plan: &ComponentAssetPlan,
111) -> Result<String, WebUIError> {
112    let estimated = estimate_asset_module_size(protocol, plan);
113    let mut js = String::with_capacity(estimated);
114    js.push_str("const asset={\"type\":\"");
115    js.push_str(ASSET_TYPE);
116    js.push_str("\",\"version\":");
117    push_u64(&mut js, ASSET_VERSION);
118    js.push_str(",\"components\":[");
119    push_string_array(&mut js, &plan.components)?;
120    js.push_str("],\"templateStyles\":[");
121    push_template_styles(protocol, &plan.components, &mut js)?;
122    js.push_str("],\"templates\":{");
123    push_templates(protocol, &plan.components, &mut js)?;
124    js.push('}');
125    if has_template_functions(protocol, &plan.components) {
126        js.push_str(",\"templateFunctions\":{");
127        push_template_functions(protocol, &plan.root, &plan.components, &mut js)?;
128        js.push('}');
129    }
130    js.push_str("};\nexport default asset;\n");
131    Ok(js)
132}
133
134fn estimate_asset_module_size(protocol: &WebUIProtocol, plan: &ComponentAssetPlan) -> usize {
135    let mut size = 128 + plan.root.len();
136    for tag in &plan.components {
137        size += tag.len() + 8;
138        if let Some(component) = protocol.components.get(tag) {
139            size += component.template_json.len();
140            size += component.template.len();
141            size += component.template_functions.len();
142            size += component.css.len();
143        }
144    }
145    size
146}
147
148fn push_string_array(out: &mut String, values: &[String]) -> Result<(), WebUIError> {
149    for (index, value) in values.iter().enumerate() {
150        if index > 0 {
151            out.push(',');
152        }
153        push_json_string(out, value, "component tag")?;
154    }
155    Ok(())
156}
157
158fn push_template_styles(
159    protocol: &WebUIProtocol,
160    components: &[String],
161    out: &mut String,
162) -> Result<(), WebUIError> {
163    let mut written = 0usize;
164    for tag in components {
165        let Some(component) = protocol.components.get(tag) else {
166            continue;
167        };
168        if component.css.is_empty() {
169            continue;
170        }
171        if written > 0 {
172            out.push(',');
173        }
174        let tag_html = css_module::build_importmap_tag(tag, &component.css, None);
175        push_json_string(out, &tag_html, "component asset templateStyles entry")?;
176        written += 1;
177    }
178    Ok(())
179}
180
181fn push_templates(
182    protocol: &WebUIProtocol,
183    components: &[String],
184    out: &mut String,
185) -> Result<(), WebUIError> {
186    let mut written = 0usize;
187    for tag in components {
188        let Some(component) = protocol.components.get(tag) else {
189            continue;
190        };
191        if !has_template_payload(component) {
192            continue;
193        }
194        if written > 0 {
195            out.push(',');
196        }
197        push_json_string(out, tag, "component tag")?;
198        out.push(':');
199        if !component.template_json.is_empty() {
200            out.push_str(&component.template_json);
201        } else {
202            push_json_string(out, &component.template, "component template")?;
203        }
204        written += 1;
205    }
206    Ok(())
207}
208
209fn has_template_functions(protocol: &WebUIProtocol, components: &[String]) -> bool {
210    components.iter().any(|tag| {
211        protocol
212            .components
213            .get(tag)
214            .is_some_and(|component| !component.template_functions.is_empty())
215    })
216}
217
218fn push_template_functions(
219    protocol: &WebUIProtocol,
220    root: &str,
221    components: &[String],
222    out: &mut String,
223) -> Result<(), WebUIError> {
224    let mut written = 0usize;
225    for tag in components {
226        let Some(component) = protocol.components.get(tag) else {
227            continue;
228        };
229        if component.template_functions.is_empty() {
230            continue;
231        }
232        if written > 0 {
233            out.push(',');
234        }
235        push_json_string(out, tag, "component tag")?;
236        out.push(':');
237        out.push_str(&component.template_functions);
238        written += 1;
239    }
240    if written == 0 {
241        return Err(WebUIError::InvalidBuildOptions(format!(
242            "component asset for <{root}> had no template functions to emit"
243        )));
244    }
245    Ok(())
246}
247
248fn push_json_string(out: &mut String, value: &str, context: &str) -> Result<(), WebUIError> {
249    let encoded = serde_json::to_string(value).map_err(|error| {
250        WebUIError::Serialization(format!("Failed to encode {context}: {error}"))
251    })?;
252    out.push_str(&encoded);
253    Ok(())
254}
255
256fn push_u64(out: &mut String, value: u64) {
257    let mut digits = [0u8; 20];
258    let mut n = value;
259    let mut i = digits.len();
260    if n == 0 {
261        out.push('0');
262        return;
263    }
264    while n > 0 {
265        i -= 1;
266        digits[i] = match n % 10 {
267            0 => b'0',
268            1 => b'1',
269            2 => b'2',
270            3 => b'3',
271            4 => b'4',
272            5 => b'5',
273            6 => b'6',
274            7 => b'7',
275            8 => b'8',
276            _ => b'9',
277        };
278        n /= 10;
279    }
280    for digit in &digits[i..] {
281        out.push(char::from(*digit));
282    }
283}
284
285fn validate_roots(protocol: &WebUIProtocol, roots: &[String]) -> Result<Vec<String>, WebUIError> {
286    let mut seen = HashSet::with_capacity(roots.len());
287    let mut normalized = Vec::with_capacity(roots.len());
288    for raw in roots {
289        let tag = raw.trim();
290        if tag.is_empty() {
291            return Err(WebUIError::InvalidBuildOptions(
292                "--emit-component-assets contains an empty component tag".to_string(),
293            ));
294        }
295        if !is_component_tag_name(tag) {
296            return Err(WebUIError::InvalidBuildOptions(format!(
297                "--emit-component-assets component '{tag}' must be a lowercase kebab-case custom element tag"
298            )));
299        }
300        if !seen.insert(tag.to_string()) {
301            return Err(WebUIError::InvalidBuildOptions(format!(
302                "--emit-component-assets contains duplicate component <{tag}>"
303            )));
304        }
305        if !protocol.fragments.contains_key(tag) {
306            return Err(WebUIError::InvalidBuildOptions(format!(
307                "--emit-component-assets requested unknown component <{tag}>. Add a discovered {tag}.html component or remove it from the allowlist."
308            )));
309        }
310        if !protocol
311            .components
312            .get(tag)
313            .is_some_and(has_template_payload)
314        {
315            return Err(WebUIError::InvalidBuildOptions(format!(
316                "--emit-component-assets requested <{tag}>, but it has no compiled template metadata. Build with a plugin that emits component templates and ensure the component has a template."
317            )));
318        }
319        normalized.push(tag.to_string());
320    }
321    Ok(normalized)
322}
323
324fn is_component_tag_name(tag: &str) -> bool {
325    let bytes = tag.as_bytes();
326    !bytes.is_empty()
327        && bytes.contains(&b'-')
328        && bytes[0].is_ascii_lowercase()
329        && bytes[bytes.len() - 1].is_ascii_alphanumeric()
330        && bytes
331            .iter()
332            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
333}
334
335fn has_template_payload(component: &webui_protocol::ComponentData) -> bool {
336    !component.template_json.is_empty() || !component.template.is_empty()
337}
338
339fn collect_component_asset_closure(protocol: &WebUIProtocol, root: &str) -> Vec<String> {
340    let mut visited_fragments = HashSet::new();
341    let mut components = HashSet::new();
342    let mut stack = vec![root.to_string()];
343
344    while let Some(fragment_id) = stack.pop() {
345        if fragment_id.is_empty() || !visited_fragments.insert(fragment_id.clone()) {
346            continue;
347        }
348
349        if protocol
350            .components
351            .get(&fragment_id)
352            .is_some_and(has_template_payload)
353        {
354            components.insert(fragment_id.clone());
355        }
356
357        let Some(fragment_list) = protocol.fragments.get(&fragment_id) else {
358            continue;
359        };
360
361        for fragment in &fragment_list.fragments {
362            match fragment.fragment.as_ref() {
363                Some(Fragment::Component(component)) => {
364                    stack.push(component.fragment_id.clone());
365                }
366                Some(Fragment::ForLoop(for_loop)) => {
367                    stack.push(for_loop.fragment_id.clone());
368                }
369                Some(Fragment::IfCond(if_cond)) => {
370                    stack.push(if_cond.fragment_id.clone());
371                }
372                Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
373                    stack.push(attr.template.clone());
374                }
375                Some(Fragment::Route(route)) => {
376                    push_route_component_ids(route, &mut stack);
377                }
378                _ => {}
379            }
380        }
381    }
382
383    let mut ordered: Vec<String> = components.into_iter().collect();
384    ordered.sort_unstable();
385    ordered
386}
387
388fn push_route_component_ids(route: &WebUIFragmentRoute, stack: &mut Vec<String>) {
389    let mut routes = vec![route];
390    while let Some(current) = routes.pop() {
391        if !current.fragment_id.is_empty() {
392            stack.push(current.fragment_id.clone());
393        }
394        if !current.pending_component.is_empty() {
395            stack.push(current.pending_component.clone());
396        }
397        if !current.error_component.is_empty() {
398            stack.push(current.error_component.clone());
399        }
400        routes.extend(current.children.iter());
401    }
402}
403
404#[cfg(test)]
405#[allow(clippy::disallowed_methods)]
406mod tests {
407    use super::*;
408    use webui_protocol::{FragmentList, WebUIFragment};
409
410    fn protocol_with_component(tag: &str) -> WebUIProtocol {
411        let mut fragments = std::collections::HashMap::new();
412        fragments.insert(
413            tag.to_string(),
414            FragmentList {
415                fragments: vec![WebUIFragment::raw("<p></p>")],
416            },
417        );
418        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
419        protocol
420            .components
421            .entry(tag.to_string())
422            .or_default()
423            .template_json = r#"{"h":"<p></p>"}"#.to_string();
424        protocol
425    }
426
427    #[test]
428    fn validates_lowercase_kebab_component_tags() {
429        assert!(is_component_tag_name("mail-thread"));
430        assert!(is_component_tag_name("mail-thread2"));
431        assert!(!is_component_tag_name("mail"));
432        assert!(!is_component_tag_name("Mail-thread"));
433        assert!(!is_component_tag_name("mail_thread"));
434        assert!(!is_component_tag_name("mail-thread-"));
435    }
436
437    #[test]
438    fn validate_roots_rejects_duplicate_tags() {
439        let protocol = protocol_with_component("mail-thread");
440        let err = validate_roots(
441            &protocol,
442            &["mail-thread".to_string(), "mail-thread".to_string()],
443        )
444        .unwrap_err()
445        .to_string();
446        assert!(err.contains("duplicate"));
447    }
448
449    #[test]
450    fn render_component_assets_emits_esm_module() {
451        let protocol = protocol_with_component("mail-thread");
452        let files =
453            render_component_assets(&protocol, &["mail-thread".to_string()], "[name].[ext]")
454                .unwrap();
455
456        assert_eq!(files.len(), 1);
457        assert_eq!(files[0].name, "mail-thread.webui.js");
458        assert!(files[0]
459            .content
460            .contains(r#""type":"webui-component-asset""#));
461        assert!(files[0].content.contains("export default asset;"));
462    }
463
464    #[test]
465    fn closure_follows_components_and_all_route_branches() {
466        let mut fragments = std::collections::HashMap::new();
467        fragments.insert(
468            "app-shell".to_string(),
469            FragmentList {
470                fragments: vec![
471                    WebUIFragment::component("mail-list"),
472                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
473                        path: "compose".to_string(),
474                        fragment_id: "compose-page".to_string(),
475                        exact: true,
476                        children: vec![webui_protocol::WebUiFragmentRoute {
477                            path: "preview".to_string(),
478                            fragment_id: "compose-preview".to_string(),
479                            exact: true,
480                            ..Default::default()
481                        }],
482                        ..Default::default()
483                    }),
484                ],
485            },
486        );
487        for tag in ["mail-list", "compose-page", "compose-preview"] {
488            fragments.insert(
489                tag.to_string(),
490                FragmentList {
491                    fragments: vec![WebUIFragment::raw("<p></p>")],
492                },
493            );
494        }
495        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
496        for tag in ["app-shell", "mail-list", "compose-page", "compose-preview"] {
497            protocol
498                .components
499                .entry(tag.to_string())
500                .or_default()
501                .template_json = r#"{"h":"<p></p>"}"#.to_string();
502        }
503
504        let closure = collect_component_asset_closure(&protocol, "app-shell");
505        assert_eq!(
506            closure,
507            vec![
508                "app-shell".to_string(),
509                "compose-page".to_string(),
510                "compose-preview".to_string(),
511                "mail-list".to_string(),
512            ]
513        );
514    }
515}