1use crate::npm::{
7 package_component_declarations, read_optional_file, read_required_file, resolve_webui_assets,
8 PackageContext,
9};
10use crate::{has_sibling_script, DiscoveredComponent};
11use anyhow::{bail, Context, Result};
12use std::collections::HashSet;
13use std::path::{Component, Path, PathBuf};
14use walkdir::WalkDir;
15
16const FAST_CACHE_FILES_PER_COMPONENT: usize = 16;
18
19pub trait DiscoveryPlugin {
21 fn cache_namespace(&self) -> &'static str;
23
24 fn discover_local(&self, root: &Path) -> Result<Vec<DiscoveredComponent>>;
30
31 fn package_cache_files(&self, package: PackageContext<'_>) -> Result<Vec<PathBuf>>;
41
42 fn discover_package(&self, package: PackageContext<'_>) -> Result<Vec<DiscoveredComponent>>;
49}
50
51#[derive(Debug, Default, Clone, Copy)]
53pub struct WebUIDiscoveryPlugin;
54
55impl WebUIDiscoveryPlugin {
56 #[must_use]
58 pub const fn new() -> Self {
59 Self
60 }
61}
62
63impl DiscoveryPlugin for WebUIDiscoveryPlugin {
64 fn cache_namespace(&self) -> &'static str {
65 "webui"
66 }
67
68 fn discover_local(&self, root: &Path) -> Result<Vec<DiscoveredComponent>> {
69 discover_local_templates(root, webui_local_tag)
70 }
71
72 fn package_cache_files(&self, package: PackageContext<'_>) -> Result<Vec<PathBuf>> {
73 let assets = resolve_webui_assets(package)?;
74 let mut files = Vec::with_capacity(3);
75 files.push(assets.manifest_path);
76 files.push(assets.template_path);
77 if let Some(styles) = assets.styles_path {
78 files.push(styles);
79 }
80 Ok(files)
81 }
82
83 fn discover_package(&self, package: PackageContext<'_>) -> Result<Vec<DiscoveredComponent>> {
84 let assets = resolve_webui_assets(package)?;
85 let html_content = read_required_file(&assets.template_path, "component template")?;
86 let css_content = read_optional_file(assets.styles_path.as_deref(), "component styles")?;
87 let tag_names = package_component_declarations(package)?
88 .into_iter()
89 .map(|declaration| declaration.tag_name)
90 .collect::<Vec<_>>();
91 if tag_names.is_empty() {
92 bail!(
93 "No component tag names found in custom elements manifest: {}",
94 assets.manifest_path.display()
95 );
96 }
97
98 Ok(tag_names
99 .into_iter()
100 .map(|tag_name| DiscoveredComponent {
101 tag_name,
102 html_content: html_content.clone(),
103 css_content: css_content.clone(),
104 is_client_owned: package.is_client_owned,
105 source: package.name.to_string(),
106 })
107 .collect())
108 }
109}
110
111#[derive(Debug, Default, Clone, Copy)]
113pub struct FastDiscoveryPlugin;
114
115impl FastDiscoveryPlugin {
116 #[must_use]
118 pub const fn new() -> Self {
119 Self
120 }
121}
122
123impl DiscoveryPlugin for FastDiscoveryPlugin {
124 fn cache_namespace(&self) -> &'static str {
125 "fast"
126 }
127
128 fn discover_local(&self, root: &Path) -> Result<Vec<DiscoveredComponent>> {
129 discover_local_templates(root, fast_local_tag)
130 }
131
132 fn package_cache_files(&self, package: PackageContext<'_>) -> Result<Vec<PathBuf>> {
133 let declarations = package_component_declarations(package)?;
134 let mut files = Vec::with_capacity(1 + declarations.len() * FAST_CACHE_FILES_PER_COMPONENT);
135 files.push(crate::npm::custom_elements_manifest_path(package)?);
136 for declaration in declarations {
137 let module_path = declaration.module_path.as_deref().with_context(|| {
138 format!(
139 "FAST component <{}> in package '{}' has no CEM module path",
140 declaration.tag_name, package.name
141 )
142 })?;
143 files.push(package_path(package.root, module_path).with_context(|| {
144 format!(
145 "FAST component <{}> in package '{}' has an invalid CEM module path",
146 declaration.tag_name, package.name
147 )
148 })?);
149 let declaration_name = declaration.name.as_deref().unwrap_or(&declaration.tag_name);
150 for candidate in fast_template_candidates(package.root, module_path, declaration_name) {
151 files.push(candidate.clone());
152 files.extend(fast_style_candidates(&candidate));
153 }
154 }
155 Ok(files)
156 }
157
158 fn discover_package(&self, package: PackageContext<'_>) -> Result<Vec<DiscoveredComponent>> {
159 let declarations = package_component_declarations(package)?;
160 if declarations.is_empty() {
161 bail!(
162 "No component declarations found in package '{}'",
163 package.name
164 );
165 }
166
167 let mut components = Vec::with_capacity(declarations.len());
168 let mut seen_templates = HashSet::with_capacity(declarations.len());
169 for declaration in declarations {
170 let module_path = declaration.module_path.as_deref().with_context(|| {
171 format!(
172 "FAST component <{}> in package '{}' has no CEM module path",
173 declaration.tag_name, package.name
174 )
175 })?;
176 let declaration_name = declaration.name.as_deref().unwrap_or(&declaration.tag_name);
177 let template_path = resolve_fast_template(package.root, module_path, declaration_name)
178 .with_context(|| {
179 format!(
180 "Failed to locate FAST template for <{}> in package '{}'",
181 declaration.tag_name, package.name
182 )
183 })?;
184 if !seen_templates.insert(template_path.clone()) {
185 bail!(
186 "FAST template {} maps to multiple component declarations",
187 template_path.display()
188 );
189 }
190 let html_content = read_required_file(&template_path, "FAST component template")?;
191 let css_content = read_optional_file(
192 resolve_fast_styles(&template_path).as_deref(),
193 "FAST component styles",
194 )?;
195 components.push(DiscoveredComponent {
196 tag_name: declaration.tag_name,
197 html_content,
198 css_content,
199 is_client_owned: package.is_client_owned,
200 source: package.name.to_string(),
201 });
202 }
203 Ok(components)
204 }
205}
206
207fn discover_local_templates(
208 root: &Path,
209 tag_name_for_path: fn(&Path) -> Option<&str>,
210) -> Result<Vec<DiscoveredComponent>> {
211 let source = root.display().to_string();
212 let mut components = Vec::new();
213 for entry in WalkDir::new(root).sort_by_file_name() {
214 let entry = entry.with_context(|| format!("Failed to scan {}", root.display()))?;
215 let path = entry.path();
216 if !path.is_file()
217 || !path
218 .extension()
219 .is_some_and(|extension| extension == "html")
220 {
221 continue;
222 }
223 let Some(tag_name) = tag_name_for_path(path) else {
224 continue;
225 };
226 let html_content = read_required_file(path, "component template")?;
227 let css_content =
228 read_optional_file(resolve_local_styles(path).as_deref(), "component styles")?;
229 components.push(DiscoveredComponent {
230 tag_name: tag_name.to_string(),
231 html_content,
232 css_content,
233 is_client_owned: has_sibling_script(path)?,
234 source: source.clone(),
235 });
236 }
237 Ok(components)
238}
239
240fn webui_local_tag(path: &Path) -> Option<&str> {
241 path.file_stem()
242 .and_then(|stem| stem.to_str())
243 .filter(|stem| stem.contains('-'))
244}
245
246fn fast_local_tag(path: &Path) -> Option<&str> {
247 let file_name = path.file_name()?.to_str()?;
248 file_name
249 .strip_suffix(".template.html")
250 .or_else(|| webui_local_tag(path))
251}
252
253fn resolve_local_styles(template_path: &Path) -> Option<PathBuf> {
254 let standard = template_path.with_extension("css");
255 if standard.is_file() {
256 return Some(standard);
257 }
258 resolve_fast_styles(template_path)
259}
260
261fn resolve_fast_styles(template_path: &Path) -> Option<PathBuf> {
262 fast_style_candidates(template_path)
263 .into_iter()
264 .find(|candidate| candidate.is_file())
265}
266
267fn fast_style_candidates(template_path: &Path) -> Vec<PathBuf> {
268 let Some(file_name) = template_path.file_name().and_then(|name| name.to_str()) else {
269 return Vec::new();
270 };
271 let Some(prefix) = file_name.strip_suffix(".template.html") else {
272 return Vec::new();
273 };
274 let Some(parent) = template_path.parent() else {
275 return Vec::new();
276 };
277 let mut candidates = Vec::with_capacity(2);
278 for suffix in [".styles.css", ".css"] {
279 candidates.push(parent.join(format!("{prefix}{suffix}")));
280 }
281 candidates
282}
283
284fn fast_template_candidates(
285 root: &Path,
286 module_path: &Path,
287 declaration_name: &str,
288) -> Vec<PathBuf> {
289 let Some(module) = package_path(root, module_path) else {
290 return Vec::new();
291 };
292 let parent = module.parent().unwrap_or(root);
293 let module_stem = module.file_stem().and_then(|stem| stem.to_str());
294 let declaration_stem = to_kebab_case(declaration_name);
295 let mut candidates = Vec::with_capacity(5);
296 for stem in module_stem
297 .into_iter()
298 .chain(std::iter::once(declaration_stem.as_str()))
299 {
300 push_template_candidate(&mut candidates, parent, stem);
301 }
302
303 if !module.is_file() {
306 let component_root = if parent == root {
307 root
308 } else {
309 parent
310 .parent()
311 .filter(|candidate| candidate.starts_with(root))
312 .unwrap_or(root)
313 };
314 push_nested_template_candidate(&mut candidates, component_root, declaration_stem.as_str());
315 let compact_stem = declaration_stem.replace('-', "");
316 push_nested_template_candidate(&mut candidates, component_root, &compact_stem);
317 if let Some((_, suffix)) = declaration_stem.rsplit_once('-') {
318 push_nested_template_candidate(&mut candidates, component_root, suffix);
319 }
320 }
321 candidates
322}
323
324fn package_path(root: &Path, relative: &Path) -> Option<PathBuf> {
325 if relative.is_absolute()
326 || relative.components().any(|component| {
327 matches!(
328 component,
329 Component::Prefix(_) | Component::RootDir | Component::ParentDir
330 )
331 })
332 {
333 return None;
334 }
335 Some(root.join(relative))
336}
337
338fn push_template_candidate(candidates: &mut Vec<PathBuf>, parent: &Path, stem: &str) {
339 let candidate = parent.join(format!("{stem}.template.html"));
340 if !candidates.contains(&candidate) {
341 candidates.push(candidate);
342 }
343}
344
345fn push_nested_template_candidate(candidates: &mut Vec<PathBuf>, root: &Path, stem: &str) {
346 push_template_candidate(candidates, &root.join(stem), stem);
347}
348
349fn resolve_fast_template(
350 root: &Path,
351 module_path: &Path,
352 declaration_name: &str,
353) -> Result<PathBuf> {
354 for candidate in fast_template_candidates(root, module_path, declaration_name) {
355 if candidate.is_file() {
356 return Ok(candidate);
357 }
358 }
359 bail!("no sibling <component>.template.html file found")
360}
361
362fn to_kebab_case(name: &str) -> String {
363 let mut result = String::with_capacity(name.len() + 4);
364 for (index, character) in name.chars().enumerate() {
365 if character.is_ascii_uppercase() {
366 if index != 0 {
367 result.push('-');
368 }
369 result.push(character.to_ascii_lowercase());
370 } else {
371 result.push(character);
372 }
373 }
374 result
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use std::fs;
381
382 #[test]
383 fn fast_local_template_uses_component_filename_prefix() {
384 let root = tempfile::TempDir::new().unwrap();
385 fs::write(
386 root.path().join("todo-item.template.html"),
387 "<f-template><template>item</template></f-template>",
388 )
389 .unwrap();
390
391 let components = FastDiscoveryPlugin::new()
392 .discover_local(root.path())
393 .unwrap();
394
395 assert_eq!(components.len(), 1);
396 assert_eq!(components[0].tag_name, "todo-item");
397 }
398
399 #[test]
400 fn virtual_root_module_candidates_stay_inside_package() {
401 let root = tempfile::TempDir::new().unwrap();
402 let candidates = fast_template_candidates(root.path(), Path::new("index.js"), "MyButton");
403
404 assert!(candidates
405 .iter()
406 .all(|candidate| candidate.starts_with(root.path())));
407 assert!(candidates.contains(
408 &root
409 .path()
410 .join("my-button")
411 .join("my-button.template.html")
412 ));
413 assert!(
414 fast_template_candidates(root.path(), Path::new("../escape/index.js"), "Escape")
415 .is_empty()
416 );
417 }
418}