use super::DiscoveryPlugin;
use crate::catalog::PackageCatalog;
use crate::npm::{
bare_module_package_name, package_component_declarations, package_export_path,
package_export_path_from_metadata, package_has_authored_script, read_optional_file,
read_required_file, resolve_bare_module_specifier, resolve_self_module_specifier,
validate_package_asset_path, ComponentDeclaration, PackageContext,
};
use crate::{
has_sibling_script, ComponentFile, ComponentFileSource, DiscoveredComponent, PreparedPackage,
};
use anyhow::{bail, Context, Result};
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use walkdir::WalkDir;
#[derive(Debug, Default, Clone, Copy)]
pub struct FastDiscoveryPlugin;
impl FastDiscoveryPlugin {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl DiscoveryPlugin for FastDiscoveryPlugin {
fn cache_namespace(&self) -> &'static str {
"fast-prepared-v1"
}
fn discover_local(&self, root: &Path) -> Result<Vec<DiscoveredComponent>> {
discover_local_templates(root, fast_local_tag)
}
fn supports_package(&self, package: PackageContext<'_>) -> Result<bool> {
if package.manifest.get("customElements").is_some() {
return Ok(true);
}
PackageCatalog::new(package)?.has_templates(ordinary_html)
}
fn prepare_package(&self, package: PackageContext<'_>) -> Result<PreparedPackage> {
let declarations = declarations(package)?;
let catalog = PackageCatalog::new(package)?;
let mut components = {
let names: HashSet<_> = declarations
.iter()
.map(|item| item.tag_name.as_str())
.collect();
catalog.prepare(|path| fallback_html(path, &names))?
};
if declarations.is_empty() {
if components.is_empty() && !catalog.is_disabled() {
bail!(
"No components found in package '{}'. Declare FAST components through \
customElements, provide <component-name>.html files under components/, \
or declare webui.components in package.json.",
package.name
);
}
return Ok(PreparedPackage::Files(components));
}
let assets = exported_assets(package, declarations.len())?;
components.reserve(declarations.len());
let source_is_client_owned = package_has_authored_script(package.manifest);
let package_root: Arc<Path> = package.root.into();
let mut seen_templates = HashSet::with_capacity(declarations.len());
for declaration in declarations {
let uses_package_template = assets.template.is_some();
let module = resolve_declaration_module(package, &declaration, !uses_package_template)?;
let module_path = package_path(&module.root, &module.relative_path)
.context("FAST component has an invalid CEM module path")?;
validate_module_asset(&module, &module_path, "FAST component module")?;
let template_path = if let Some(path) = &assets.template {
path.clone()
} else {
inferred_template(package, &declaration, &module)?
};
if !seen_templates.insert(template_path.clone()) {
bail!(
"FAST template {} maps to multiple component declarations",
template_path.display()
);
}
let module_root: Arc<Path> = module.root.as_path().into();
let template_root = if uses_package_template {
&package_root
} else {
&module_root
};
let module_styles = if uses_package_template {
None
} else {
module.exported_styles.as_ref()
};
let css = match (&assets.styles, module_styles) {
(Some(path), _) => Some(ComponentFile::new(Arc::clone(&package_root), path)?),
(None, Some(path)) => Some(ComponentFile::new(Arc::clone(&module_root), path)?),
(None, None) => resolve_package_styles(template_root, &template_path)?
.map(|path| ComponentFile::new(Arc::clone(template_root), &path))
.transpose()?,
};
components.push(ComponentFileSource {
tag_name: declaration.tag_name,
html: ComponentFile::new(Arc::clone(template_root), &template_path)?,
css,
is_client_owned: source_is_client_owned || module.is_client_owned,
});
}
Ok(PreparedPackage::Files(components))
}
}
fn declarations(package: PackageContext<'_>) -> Result<Vec<ComponentDeclaration>> {
if package.manifest.get("customElements").is_some() {
package_component_declarations(package)
} else {
Ok(Vec::new())
}
}
fn ordinary_html(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
!name.ends_with(".template.html") && !name.ends_with(".template-webui.html")
})
}
fn fallback_html(path: &Path, names: &HashSet<&str>) -> bool {
ordinary_html(path)
&& crate::catalog::template_tag(path).is_some_and(|name| !names.contains(name))
}
struct ExportedAssets {
template: Option<PathBuf>,
styles: Option<PathBuf>,
}
fn exported_assets(package: PackageContext<'_>, declarations: usize) -> Result<ExportedAssets> {
let template = package_export_path(package, "./template-webui.html")?;
if template.as_ref().is_some_and(|path| {
!path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".template-webui.html"))
}) {
bail!(
"Package '{}' must export a .template-webui.html file for FAST discovery.",
package.name
);
}
if template.is_some() && declarations != 1 {
bail!(
"Package '{}' exports one './template-webui.html' but declares {declarations} components. \
Use one component declaration or per-module .template-webui.html files.",
package.name
);
}
let styles = if declarations == 1 {
package_export_path(package, "./styles.css")?
} else {
None
};
Ok(ExportedAssets { template, styles })
}
fn inferred_template(
package: PackageContext<'_>,
declaration: &ComponentDeclaration,
module: &ResolvedCemModule,
) -> Result<PathBuf> {
if let Some(template) = &module.exported_template {
return Ok(template.clone());
}
let name = declaration.name.as_deref().unwrap_or(&declaration.tag_name);
let template =
resolve_fast_template(&module.root, &module.relative_path, name).with_context(|| {
format!(
"Failed to locate FAST template for <{}> in package '{}'",
declaration.tag_name, package.name
)
})?;
validate_module_asset(module, &template, "FAST component template")?;
Ok(template)
}
fn validate_module_asset(module: &ResolvedCemModule, path: &Path, kind: &str) -> Result<()> {
validate_package_asset_path(&module.root, path, kind)
.with_context(|| format!("{kind} resolved outside package: {}", path.display()))?;
Ok(())
}
struct ResolvedCemModule {
root: PathBuf,
relative_path: PathBuf,
exported_template: Option<PathBuf>,
exported_styles: Option<PathBuf>,
is_client_owned: bool,
}
fn resolve_declaration_module(
package: PackageContext<'_>,
declaration: &ComponentDeclaration,
resolve_assets: bool,
) -> Result<ResolvedCemModule> {
resolve_declaration_module_inner(package, declaration, resolve_assets).with_context(|| {
format!(
"Failed to resolve FAST component <{}> declared by package '{}'",
declaration.tag_name, package.name
)
})
}
fn resolve_declaration_module_inner(
package: PackageContext<'_>,
declaration: &ComponentDeclaration,
resolve_assets: bool,
) -> Result<ResolvedCemModule> {
let specifier = declaration.module_specifier.as_deref().with_context(|| {
format!(
"FAST component <{}> in package '{}' has no CEM module path",
declaration.tag_name, package.name
)
})?;
let relative_path = Path::new(specifier);
let name = declaration.name.as_deref().unwrap_or(&declaration.tag_name);
let explicitly_relative = specifier.starts_with("./") || specifier.starts_with("../");
let local_module = package_path(package.root, relative_path);
let local_candidates = fast_template_candidates(package.root, relative_path, name);
let has_local_module = local_module.as_ref().is_some_and(|path| path.is_file());
let has_local_template = local_candidates.iter().any(|path| path.is_file());
if explicitly_relative
|| (!specifier.starts_with('@') && (has_local_module || has_local_template))
{
return Ok(ResolvedCemModule {
root: package.root.to_path_buf(),
relative_path: relative_path.to_path_buf(),
exported_template: None,
exported_styles: None,
is_client_owned: false,
});
}
let bare_package = bare_module_package_name(specifier)?;
let manifest = package.manifest;
let self_name = manifest.get("name").and_then(serde_json::Value::as_str);
if let Some(self_name) = self_name {
if bare_package == Some(self_name) {
return Ok(ResolvedCemModule {
root: package.root.to_path_buf(),
relative_path: resolve_self_module_specifier(
self_name,
package.root,
specifier,
manifest,
)?,
exported_template: None,
exported_styles: None,
is_client_owned: false,
});
}
}
let can_resolve_bare = specifier.starts_with('@')
|| match bare_package {
Some(name) => package_has_dependency(manifest, name),
None => false,
};
if can_resolve_bare {
let resolved = resolve_bare_module_specifier(specifier, package.root)?.with_context(|| {
format!(
"Package '{}' declares bare CEM module '{specifier}', but its package could not be resolved",
package.name
)
})?;
let exported_template = if resolve_assets {
package_export_path_from_metadata(
&resolved.name,
&resolved.root,
&resolved.ordered_manifest,
"./template-webui.html",
)?
} else {
None
};
if exported_template.as_ref().is_some_and(|path| {
!path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".template-webui.html"))
}) {
bail!(
"Package '{}' must export a .template-webui.html file for FAST discovery.",
resolved.name
);
}
let exported_styles = if resolve_assets {
package_export_path_from_metadata(
&resolved.name,
&resolved.root,
&resolved.ordered_manifest,
"./styles.css",
)?
} else {
None
};
let is_client_owned = package_has_authored_script(&resolved.manifest)
|| module_path_is_script(&resolved.relative_path);
return Ok(ResolvedCemModule {
root: resolved.root,
relative_path: resolved.relative_path,
exported_template,
exported_styles,
is_client_owned,
});
}
Ok(ResolvedCemModule {
root: package.root.to_path_buf(),
relative_path: relative_path.to_path_buf(),
exported_template: None,
exported_styles: None,
is_client_owned: false,
})
}
fn package_has_dependency(manifest: &serde_json::Value, name: &str) -> bool {
["dependencies", "peerDependencies", "optionalDependencies"]
.into_iter()
.any(|field| {
manifest
.get(field)
.and_then(serde_json::Value::as_object)
.is_some_and(|dependencies| dependencies.contains_key(name))
})
}
fn module_path_is_script(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| matches!(extension, "js" | "mjs" | "cjs" | "ts"))
}
fn discover_local_templates(
root: &Path,
tag_name_for_path: fn(&Path) -> Option<&str>,
) -> Result<Vec<DiscoveredComponent>> {
let source = root.display().to_string();
let mut components = Vec::new();
let mut sibling = PathBuf::new();
for entry in WalkDir::new(root).sort_by_file_name() {
let entry = entry.with_context(|| format!("Failed to scan {}", root.display()))?;
let path = entry.path();
if !path.is_file()
|| !path
.extension()
.is_some_and(|extension| extension == "html")
{
continue;
}
let Some(tag_name) = tag_name_for_path(path) else {
continue;
};
let html_content = read_required_file(path, "component template")?;
sibling.clear();
sibling.push(path);
let css_content =
read_optional_file(resolve_local_styles(path).as_deref(), "component styles")?;
components.push(DiscoveredComponent {
tag_name: tag_name.to_string(),
html_content,
css_content,
is_client_owned: has_sibling_script(&mut sibling, None)?,
source: source.clone(),
});
}
Ok(components)
}
fn fast_local_tag(path: &Path) -> Option<&str> {
let file_name = path.file_name()?.to_str()?;
if file_name.ends_with(".template.html") {
return None;
}
file_name
.strip_suffix(".template-webui.html")
.or_else(|| crate::catalog::template_tag(path))
}
fn resolve_local_styles(template_path: &Path) -> Option<PathBuf> {
let standard = template_path.with_extension("css");
if standard.is_file() {
return Some(standard);
}
resolve_fast_styles(template_path)
}
fn resolve_fast_styles(template_path: &Path) -> Option<PathBuf> {
fast_style_candidates(template_path)
.into_iter()
.find(|candidate| candidate.is_file())
}
fn resolve_package_styles(root: &Path, template: &Path) -> Result<Option<PathBuf>> {
for path in fast_style_candidates(template) {
validate_package_asset_path(root, &path, "FAST component styles")?;
if path.is_file() {
return Ok(Some(path));
}
}
Ok(None)
}
fn fast_style_candidates(template_path: &Path) -> Vec<PathBuf> {
let Some(file_name) = template_path.file_name().and_then(|name| name.to_str()) else {
return Vec::new();
};
let Some(prefix) = file_name.strip_suffix(".template-webui.html") else {
return Vec::new();
};
let Some(parent) = template_path.parent() else {
return Vec::new();
};
let mut candidates = Vec::with_capacity(2);
for suffix in [".styles.css", ".css"] {
candidates.push(parent.join(format!("{prefix}{suffix}")));
}
candidates
}
fn fast_template_candidates(
root: &Path,
module_path: &Path,
declaration_name: &str,
) -> Vec<PathBuf> {
let Some(module) = package_path(root, module_path) else {
return Vec::new();
};
let parent = module.parent().unwrap_or(root);
let module_stem = module.file_stem().and_then(|stem| stem.to_str());
let declaration_stem = to_kebab_case(declaration_name);
let mut candidates = Vec::with_capacity(5);
for stem in module_stem
.into_iter()
.chain(std::iter::once(declaration_stem.as_str()))
{
push_template_candidate(&mut candidates, parent, stem);
}
if !module.is_file() {
let component_root = if parent == root {
root
} else {
parent
.parent()
.filter(|candidate| candidate.starts_with(root))
.unwrap_or(root)
};
push_nested_template_candidate(&mut candidates, component_root, declaration_stem.as_str());
let compact_stem = declaration_stem.replace('-', "");
push_nested_template_candidate(&mut candidates, component_root, &compact_stem);
if let Some((_, suffix)) = declaration_stem.rsplit_once('-') {
push_nested_template_candidate(&mut candidates, component_root, suffix);
}
}
if !candidates.iter().any(|candidate| candidate.is_file()) {
for directory in parent
.ancestors()
.skip(1)
.take_while(|path| path.starts_with(root))
{
for stem in module_stem
.into_iter()
.chain(std::iter::once(declaration_stem.as_str()))
{
push_template_candidate(&mut candidates, directory, stem);
}
}
}
candidates
}
fn package_path(root: &Path, relative: &Path) -> Option<PathBuf> {
if relative.is_absolute()
|| relative.components().any(|component| {
matches!(
component,
Component::Prefix(_) | Component::RootDir | Component::ParentDir
)
})
{
return None;
}
Some(root.join(relative))
}
fn push_template_candidate(candidates: &mut Vec<PathBuf>, parent: &Path, stem: &str) {
let candidate = parent.join(format!("{stem}.template-webui.html"));
if !candidates.contains(&candidate) {
candidates.push(candidate);
}
}
fn push_nested_template_candidate(candidates: &mut Vec<PathBuf>, root: &Path, stem: &str) {
push_template_candidate(candidates, &root.join(stem), stem);
}
fn resolve_fast_template(
root: &Path,
module_path: &Path,
declaration_name: &str,
) -> Result<PathBuf> {
for candidate in fast_template_candidates(root, module_path, declaration_name) {
validate_package_asset_path(root, &candidate, "FAST component template")?;
if candidate.is_file() {
return Ok(candidate);
}
}
bail!("no matching <component>.template-webui.html file found; raw .template.html assets are not used")
}
fn to_kebab_case(name: &str) -> String {
let mut result = String::with_capacity(name.len() + 4);
for (index, character) in name.chars().enumerate() {
if character.is_ascii_uppercase() {
if index != 0 {
result.push('-');
}
result.push(character.to_ascii_lowercase());
} else {
result.push(character);
}
}
result
}
#[cfg(test)]
mod tests;