1use 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#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ComponentAssetFile {
20 pub name: String,
22 pub content: String,
24}
25
26struct ComponentAssetPlan {
27 root: String,
28 components: Vec<String>,
29}
30
31#[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}