mod cache;
mod catalog;
mod npm;
mod plugin;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
pub use npm::PackageContext;
pub use plugin::{DiscoveryPlugin, FastDiscoveryPlugin, WebUIDiscoveryPlugin};
#[derive(Debug, Clone)]
pub struct DiscoveredComponent {
pub tag_name: String,
pub html_content: String,
pub css_content: Option<String>,
pub is_client_owned: bool,
pub source: String,
}
#[derive(Debug)]
pub struct DiscoveryResult {
pub source: String,
pub components: Vec<DiscoveredComponent>,
}
enum ComponentSource {
NpmPackage(String),
Path(PathBuf),
}
fn classify_source(source: &str) -> ComponentSource {
if is_local_source(source) {
ComponentSource::Path(PathBuf::from(source))
} else {
ComponentSource::NpmPackage(source.to_string())
}
}
#[must_use]
pub fn is_local_source(source: &str) -> bool {
source.starts_with('.')
|| source.starts_with('/')
|| source.starts_with('\\')
|| (cfg!(windows) && source.len() >= 2 && source.as_bytes()[1] == b':')
}
pub fn discover_source(source: &str, search_dir: &Path) -> Result<DiscoveryResult> {
discover_source_with_plugin(source, search_dir, &WebUIDiscoveryPlugin::new())
}
pub fn discover_source_with_plugin(
source: &str,
search_dir: &Path,
plugin: &dyn DiscoveryPlugin,
) -> Result<DiscoveryResult> {
let mut cache = cache::DiscoveryCache::open()?;
let components = match classify_source(source) {
ComponentSource::NpmPackage(ref name) => {
npm::resolve(name, search_dir, plugin, &mut cache)?
}
ComponentSource::Path(ref path) => {
let resolved = if path.is_relative() {
search_dir.join(path)
} else {
path.clone()
};
let resolved = resolved
.canonicalize()
.with_context(|| format!("Component path not found: {}", path.display()))?;
plugin.discover_local(&resolved)?
}
};
Ok(DiscoveryResult {
source: source.to_string(),
components,
})
}
pub(crate) fn has_sibling_script(html_path: &Path) -> Result<bool> {
for ext in ["ts", "js"] {
let candidate = html_path.with_extension(ext);
if candidate.try_exists().with_context(|| {
format!(
"Failed to inspect component script: {}",
candidate.display()
)
})? {
return Ok(true);
}
}
Ok(false)
}
pub fn collect_watch_paths(sources: &[String], search_dir: &Path) -> Vec<PathBuf> {
sources
.iter()
.filter_map(|source| {
if let ComponentSource::Path(ref path) = classify_source(source) {
let resolved = if path.is_relative() {
search_dir.join(path)
} else {
path.clone()
};
resolved.canonicalize().ok()
} else {
None
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_classify_relative_path() {
assert!(matches!(
classify_source("./libs/shared"),
ComponentSource::Path(_)
));
assert!(matches!(
classify_source("../components"),
ComponentSource::Path(_)
));
}
#[test]
fn test_classify_absolute_path() {
assert!(matches!(
classify_source("/absolute/path"),
ComponentSource::Path(_)
));
}
#[test]
fn test_classify_npm_package() {
assert!(matches!(
classify_source("my-widget"),
ComponentSource::NpmPackage(_)
));
}
#[test]
fn test_classify_scoped_npm_package() {
assert!(matches!(
classify_source("@reactive-ui"),
ComponentSource::NpmPackage(_)
));
assert!(matches!(
classify_source("@scope/button"),
ComponentSource::NpmPackage(_)
));
}
#[cfg(windows)]
#[test]
fn test_classify_windows_drive_path() {
assert!(matches!(
classify_source("C:\\components"),
ComponentSource::Path(_)
));
}
#[test]
fn test_is_local_source() {
assert!(is_local_source("./libs/shared"));
assert!(is_local_source("../components"));
assert!(is_local_source("/absolute/path"));
assert!(is_local_source("\\unc\\path"));
assert!(!is_local_source("my-widget"));
assert!(!is_local_source("@reactive-ui"));
assert!(!is_local_source("@scope/button"));
}
#[test]
fn test_discover_from_path_preserves_script_ownership() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("plain-card.html"), "<p>{{title}}</p>").unwrap();
std::fs::write(
tmp.path().join("interactive-card.html"),
"<button>go</button>",
)
.unwrap();
std::fs::write(tmp.path().join("interactive-card.ts"), "export {};").unwrap();
let components = WebUIDiscoveryPlugin::new()
.discover_local(tmp.path())
.unwrap();
let plain = components
.iter()
.find(|component| component.tag_name == "plain-card")
.unwrap();
let interactive = components
.iter()
.find(|component| component.tag_name == "interactive-card")
.unwrap();
assert!(!plain.is_client_owned);
assert!(interactive.is_client_owned);
}
}