1use super::DiscoveryPlugin;
7use crate::catalog::PackageCatalog;
8use crate::npm::{
9 bare_module_package_name, package_component_declarations, package_export_path,
10 package_export_path_from_metadata, package_has_authored_script, read_optional_file,
11 read_required_file, resolve_bare_module_specifier, resolve_self_module_specifier,
12 validate_package_asset_path, ComponentDeclaration, PackageContext,
13};
14use crate::{
15 has_sibling_script, ComponentFile, ComponentFileSource, DiscoveredComponent, PreparedPackage,
16};
17use anyhow::{bail, Context, Result};
18use std::collections::HashSet;
19use std::path::{Component, Path, PathBuf};
20use std::sync::Arc;
21use walkdir::WalkDir;
22
23#[derive(Debug, Default, Clone, Copy)]
25pub struct FastDiscoveryPlugin;
26
27impl FastDiscoveryPlugin {
28 #[must_use]
30 pub const fn new() -> Self {
31 Self
32 }
33}
34
35impl DiscoveryPlugin for FastDiscoveryPlugin {
36 fn cache_namespace(&self) -> &'static str {
37 "fast-prepared-v1"
38 }
39
40 fn discover_local(&self, root: &Path) -> Result<Vec<DiscoveredComponent>> {
41 discover_local_templates(root, fast_local_tag)
42 }
43
44 fn supports_package(&self, package: PackageContext<'_>) -> Result<bool> {
45 if package.manifest.get("customElements").is_some() {
46 return Ok(true);
47 }
48 PackageCatalog::new(package)?.has_templates(ordinary_html)
49 }
50
51 fn prepare_package(&self, package: PackageContext<'_>) -> Result<PreparedPackage> {
52 let declarations = declarations(package)?;
53 let catalog = PackageCatalog::new(package)?;
54 let mut components = {
55 let names: HashSet<_> = declarations
56 .iter()
57 .map(|item| item.tag_name.as_str())
58 .collect();
59 catalog.prepare(|path| fallback_html(path, &names))?
60 };
61 if declarations.is_empty() {
62 if components.is_empty() && !catalog.is_disabled() {
63 bail!(
64 "No components found in package '{}'. Declare FAST components through \
65 customElements, provide <component-name>.html files under components/, \
66 or declare webui.components in package.json.",
67 package.name
68 );
69 }
70 return Ok(PreparedPackage::Files(components));
71 }
72
73 let assets = exported_assets(package, declarations.len())?;
74 components.reserve(declarations.len());
75 let source_is_client_owned = package_has_authored_script(package.manifest);
76 let package_root: Arc<Path> = package.root.into();
77 let mut seen_templates = HashSet::with_capacity(declarations.len());
78 for declaration in declarations {
79 let uses_package_template = assets.template.is_some();
80 let module = resolve_declaration_module(package, &declaration, !uses_package_template)?;
81 let module_path = package_path(&module.root, &module.relative_path)
82 .context("FAST component has an invalid CEM module path")?;
83 validate_module_asset(&module, &module_path, "FAST component module")?;
84 let template_path = if let Some(path) = &assets.template {
85 path.clone()
86 } else {
87 inferred_template(package, &declaration, &module)?
88 };
89 if !seen_templates.insert(template_path.clone()) {
90 bail!(
91 "FAST template {} maps to multiple component declarations",
92 template_path.display()
93 );
94 }
95 let module_root: Arc<Path> = module.root.as_path().into();
96 let template_root = if uses_package_template {
97 &package_root
98 } else {
99 &module_root
100 };
101 let module_styles = if uses_package_template {
102 None
103 } else {
104 module.exported_styles.as_ref()
105 };
106 let css = match (&assets.styles, module_styles) {
107 (Some(path), _) => Some(ComponentFile::new(Arc::clone(&package_root), path)?),
108 (None, Some(path)) => Some(ComponentFile::new(Arc::clone(&module_root), path)?),
109 (None, None) => resolve_package_styles(template_root, &template_path)?
110 .map(|path| ComponentFile::new(Arc::clone(template_root), &path))
111 .transpose()?,
112 };
113 components.push(ComponentFileSource {
114 tag_name: declaration.tag_name,
115 html: ComponentFile::new(Arc::clone(template_root), &template_path)?,
116 css,
117 is_client_owned: source_is_client_owned || module.is_client_owned,
118 });
119 }
120 Ok(PreparedPackage::Files(components))
121 }
122}
123
124fn declarations(package: PackageContext<'_>) -> Result<Vec<ComponentDeclaration>> {
125 if package.manifest.get("customElements").is_some() {
126 package_component_declarations(package)
127 } else {
128 Ok(Vec::new())
129 }
130}
131
132fn ordinary_html(path: &Path) -> bool {
133 path.file_name()
134 .and_then(|name| name.to_str())
135 .is_some_and(|name| {
136 !name.ends_with(".template.html") && !name.ends_with(".template-webui.html")
137 })
138}
139
140fn fallback_html(path: &Path, names: &HashSet<&str>) -> bool {
141 ordinary_html(path)
142 && crate::catalog::template_tag(path).is_some_and(|name| !names.contains(name))
143}
144
145struct ExportedAssets {
146 template: Option<PathBuf>,
147 styles: Option<PathBuf>,
148}
149
150fn exported_assets(package: PackageContext<'_>, declarations: usize) -> Result<ExportedAssets> {
151 let template = package_export_path(package, "./template-webui.html")?;
152 if template.as_ref().is_some_and(|path| {
153 !path
154 .file_name()
155 .and_then(|name| name.to_str())
156 .is_some_and(|name| name.ends_with(".template-webui.html"))
157 }) {
158 bail!(
159 "Package '{}' must export a .template-webui.html file for FAST discovery.",
160 package.name
161 );
162 }
163 if template.is_some() && declarations != 1 {
164 bail!(
165 "Package '{}' exports one './template-webui.html' but declares {declarations} components. \
166 Use one component declaration or per-module .template-webui.html files.",
167 package.name
168 );
169 }
170 let styles = if declarations == 1 {
171 package_export_path(package, "./styles.css")?
172 } else {
173 None
174 };
175 Ok(ExportedAssets { template, styles })
176}
177
178fn inferred_template(
179 package: PackageContext<'_>,
180 declaration: &ComponentDeclaration,
181 module: &ResolvedCemModule,
182) -> Result<PathBuf> {
183 if let Some(template) = &module.exported_template {
184 return Ok(template.clone());
185 }
186 let name = declaration.name.as_deref().unwrap_or(&declaration.tag_name);
187 let template =
188 resolve_fast_template(&module.root, &module.relative_path, name).with_context(|| {
189 format!(
190 "Failed to locate FAST template for <{}> in package '{}'",
191 declaration.tag_name, package.name
192 )
193 })?;
194 validate_module_asset(module, &template, "FAST component template")?;
195 Ok(template)
196}
197
198fn validate_module_asset(module: &ResolvedCemModule, path: &Path, kind: &str) -> Result<()> {
199 validate_package_asset_path(&module.root, path, kind)
200 .with_context(|| format!("{kind} resolved outside package: {}", path.display()))?;
201 Ok(())
202}
203
204struct ResolvedCemModule {
205 root: PathBuf,
206 relative_path: PathBuf,
207 exported_template: Option<PathBuf>,
208 exported_styles: Option<PathBuf>,
209 is_client_owned: bool,
210}
211
212fn resolve_declaration_module(
213 package: PackageContext<'_>,
214 declaration: &ComponentDeclaration,
215 resolve_assets: bool,
216) -> Result<ResolvedCemModule> {
217 resolve_declaration_module_inner(package, declaration, resolve_assets).with_context(|| {
218 format!(
219 "Failed to resolve FAST component <{}> declared by package '{}'",
220 declaration.tag_name, package.name
221 )
222 })
223}
224
225fn resolve_declaration_module_inner(
226 package: PackageContext<'_>,
227 declaration: &ComponentDeclaration,
228 resolve_assets: bool,
229) -> Result<ResolvedCemModule> {
230 let specifier = declaration.module_specifier.as_deref().with_context(|| {
231 format!(
232 "FAST component <{}> in package '{}' has no CEM module path",
233 declaration.tag_name, package.name
234 )
235 })?;
236 let relative_path = Path::new(specifier);
237 let name = declaration.name.as_deref().unwrap_or(&declaration.tag_name);
238 let explicitly_relative = specifier.starts_with("./") || specifier.starts_with("../");
239 let local_module = package_path(package.root, relative_path);
240 let local_candidates = fast_template_candidates(package.root, relative_path, name);
241 let has_local_module = local_module.as_ref().is_some_and(|path| path.is_file());
242 let has_local_template = local_candidates.iter().any(|path| path.is_file());
243 if explicitly_relative
244 || (!specifier.starts_with('@') && (has_local_module || has_local_template))
245 {
246 return Ok(ResolvedCemModule {
247 root: package.root.to_path_buf(),
248 relative_path: relative_path.to_path_buf(),
249 exported_template: None,
250 exported_styles: None,
251 is_client_owned: false,
252 });
253 }
254 let bare_package = bare_module_package_name(specifier)?;
255 let manifest = package.manifest;
256 let self_name = manifest.get("name").and_then(serde_json::Value::as_str);
257 if let Some(self_name) = self_name {
258 if bare_package == Some(self_name) {
259 return Ok(ResolvedCemModule {
260 root: package.root.to_path_buf(),
261 relative_path: resolve_self_module_specifier(
262 self_name,
263 package.root,
264 specifier,
265 manifest,
266 )?,
267 exported_template: None,
268 exported_styles: None,
269 is_client_owned: false,
270 });
271 }
272 }
273 let can_resolve_bare = specifier.starts_with('@')
274 || match bare_package {
275 Some(name) => package_has_dependency(manifest, name),
276 None => false,
277 };
278 if can_resolve_bare {
279 let resolved = resolve_bare_module_specifier(specifier, package.root)?.with_context(|| {
280 format!(
281 "Package '{}' declares bare CEM module '{specifier}', but its package could not be resolved",
282 package.name
283 )
284 })?;
285 let exported_template = if resolve_assets {
286 package_export_path_from_metadata(
287 &resolved.name,
288 &resolved.root,
289 &resolved.ordered_manifest,
290 "./template-webui.html",
291 )?
292 } else {
293 None
294 };
295 if exported_template.as_ref().is_some_and(|path| {
296 !path
297 .file_name()
298 .and_then(|name| name.to_str())
299 .is_some_and(|name| name.ends_with(".template-webui.html"))
300 }) {
301 bail!(
302 "Package '{}' must export a .template-webui.html file for FAST discovery.",
303 resolved.name
304 );
305 }
306 let exported_styles = if resolve_assets {
307 package_export_path_from_metadata(
308 &resolved.name,
309 &resolved.root,
310 &resolved.ordered_manifest,
311 "./styles.css",
312 )?
313 } else {
314 None
315 };
316 let is_client_owned = package_has_authored_script(&resolved.manifest)
317 || module_path_is_script(&resolved.relative_path);
318 return Ok(ResolvedCemModule {
319 root: resolved.root,
320 relative_path: resolved.relative_path,
321 exported_template,
322 exported_styles,
323 is_client_owned,
324 });
325 }
326 Ok(ResolvedCemModule {
327 root: package.root.to_path_buf(),
328 relative_path: relative_path.to_path_buf(),
329 exported_template: None,
330 exported_styles: None,
331 is_client_owned: false,
332 })
333}
334
335fn package_has_dependency(manifest: &serde_json::Value, name: &str) -> bool {
336 ["dependencies", "peerDependencies", "optionalDependencies"]
337 .into_iter()
338 .any(|field| {
339 manifest
340 .get(field)
341 .and_then(serde_json::Value::as_object)
342 .is_some_and(|dependencies| dependencies.contains_key(name))
343 })
344}
345
346fn module_path_is_script(path: &Path) -> bool {
347 path.extension()
348 .and_then(|extension| extension.to_str())
349 .is_some_and(|extension| matches!(extension, "js" | "mjs" | "cjs" | "ts"))
350}
351
352fn discover_local_templates(
353 root: &Path,
354 tag_name_for_path: fn(&Path) -> Option<&str>,
355) -> Result<Vec<DiscoveredComponent>> {
356 let source = root.display().to_string();
357 let mut components = Vec::new();
358 let mut sibling = PathBuf::new();
359 for entry in WalkDir::new(root).sort_by_file_name() {
360 let entry = entry.with_context(|| format!("Failed to scan {}", root.display()))?;
361 let path = entry.path();
362 if !path.is_file()
363 || !path
364 .extension()
365 .is_some_and(|extension| extension == "html")
366 {
367 continue;
368 }
369 let Some(tag_name) = tag_name_for_path(path) else {
370 continue;
371 };
372 let html_content = read_required_file(path, "component template")?;
373 sibling.clear();
374 sibling.push(path);
375 let css_content =
376 read_optional_file(resolve_local_styles(path).as_deref(), "component styles")?;
377 components.push(DiscoveredComponent {
378 tag_name: tag_name.to_string(),
379 html_content,
380 css_content,
381 is_client_owned: has_sibling_script(&mut sibling, None)?,
382 source: source.clone(),
383 });
384 }
385 Ok(components)
386}
387
388fn fast_local_tag(path: &Path) -> Option<&str> {
389 let file_name = path.file_name()?.to_str()?;
390 if file_name.ends_with(".template.html") {
391 return None;
392 }
393 file_name
394 .strip_suffix(".template-webui.html")
395 .or_else(|| crate::catalog::template_tag(path))
396}
397
398fn resolve_local_styles(template_path: &Path) -> Option<PathBuf> {
399 let standard = template_path.with_extension("css");
400 if standard.is_file() {
401 return Some(standard);
402 }
403 resolve_fast_styles(template_path)
404}
405
406fn resolve_fast_styles(template_path: &Path) -> Option<PathBuf> {
407 fast_style_candidates(template_path)
408 .into_iter()
409 .find(|candidate| candidate.is_file())
410}
411
412fn resolve_package_styles(root: &Path, template: &Path) -> Result<Option<PathBuf>> {
413 for path in fast_style_candidates(template) {
414 validate_package_asset_path(root, &path, "FAST component styles")?;
415 if path.is_file() {
416 return Ok(Some(path));
417 }
418 }
419 Ok(None)
420}
421
422fn fast_style_candidates(template_path: &Path) -> Vec<PathBuf> {
423 let Some(file_name) = template_path.file_name().and_then(|name| name.to_str()) else {
424 return Vec::new();
425 };
426 let Some(prefix) = file_name.strip_suffix(".template-webui.html") else {
427 return Vec::new();
428 };
429 let Some(parent) = template_path.parent() else {
430 return Vec::new();
431 };
432 let mut candidates = Vec::with_capacity(2);
433 for suffix in [".styles.css", ".css"] {
434 candidates.push(parent.join(format!("{prefix}{suffix}")));
435 }
436 candidates
437}
438
439fn fast_template_candidates(
440 root: &Path,
441 module_path: &Path,
442 declaration_name: &str,
443) -> Vec<PathBuf> {
444 let Some(module) = package_path(root, module_path) else {
445 return Vec::new();
446 };
447 let parent = module.parent().unwrap_or(root);
448 let module_stem = module.file_stem().and_then(|stem| stem.to_str());
449 let declaration_stem = to_kebab_case(declaration_name);
450 let mut candidates = Vec::with_capacity(5);
451 for stem in module_stem
452 .into_iter()
453 .chain(std::iter::once(declaration_stem.as_str()))
454 {
455 push_template_candidate(&mut candidates, parent, stem);
456 }
457
458 if !module.is_file() {
461 let component_root = if parent == root {
462 root
463 } else {
464 parent
465 .parent()
466 .filter(|candidate| candidate.starts_with(root))
467 .unwrap_or(root)
468 };
469 push_nested_template_candidate(&mut candidates, component_root, declaration_stem.as_str());
470 let compact_stem = declaration_stem.replace('-', "");
471 push_nested_template_candidate(&mut candidates, component_root, &compact_stem);
472 if let Some((_, suffix)) = declaration_stem.rsplit_once('-') {
473 push_nested_template_candidate(&mut candidates, component_root, suffix);
474 }
475 }
476 if !candidates.iter().any(|candidate| candidate.is_file()) {
477 for directory in parent
478 .ancestors()
479 .skip(1)
480 .take_while(|path| path.starts_with(root))
481 {
482 for stem in module_stem
483 .into_iter()
484 .chain(std::iter::once(declaration_stem.as_str()))
485 {
486 push_template_candidate(&mut candidates, directory, stem);
487 }
488 }
489 }
490 candidates
491}
492
493fn package_path(root: &Path, relative: &Path) -> Option<PathBuf> {
494 if relative.is_absolute()
495 || relative.components().any(|component| {
496 matches!(
497 component,
498 Component::Prefix(_) | Component::RootDir | Component::ParentDir
499 )
500 })
501 {
502 return None;
503 }
504 Some(root.join(relative))
505}
506
507fn push_template_candidate(candidates: &mut Vec<PathBuf>, parent: &Path, stem: &str) {
508 let candidate = parent.join(format!("{stem}.template-webui.html"));
509 if !candidates.contains(&candidate) {
510 candidates.push(candidate);
511 }
512}
513
514fn push_nested_template_candidate(candidates: &mut Vec<PathBuf>, root: &Path, stem: &str) {
515 push_template_candidate(candidates, &root.join(stem), stem);
516}
517
518fn resolve_fast_template(
519 root: &Path,
520 module_path: &Path,
521 declaration_name: &str,
522) -> Result<PathBuf> {
523 for candidate in fast_template_candidates(root, module_path, declaration_name) {
524 validate_package_asset_path(root, &candidate, "FAST component template")?;
525 if candidate.is_file() {
526 return Ok(candidate);
527 }
528 }
529 bail!("no matching <component>.template-webui.html file found; raw .template.html assets are not used")
530}
531
532fn to_kebab_case(name: &str) -> String {
533 let mut result = String::with_capacity(name.len() + 4);
534 for (index, character) in name.chars().enumerate() {
535 if character.is_ascii_uppercase() {
536 if index != 0 {
537 result.push('-');
538 }
539 result.push(character.to_ascii_lowercase());
540 } else {
541 result.push(character);
542 }
543 }
544 result
545}
546
547#[cfg(test)]
548mod tests;