Skip to main content

webui_discovery/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! External component discovery for the WebUI framework.
5//!
6//! Discovers components from npm packages and local filesystem paths.
7//! Returns [`DiscoveredComponent`] structs that callers can register
8//! into their component registry.
9//!
10//! This crate has no dependency on `webui-parser` — it is pure discovery
11//! logic reusable by CLI, FFI, and other host integrations.
12
13mod cache;
14mod npm;
15mod plugin;
16
17use anyhow::{Context, Result};
18use std::path::{Path, PathBuf};
19
20pub use npm::PackageContext;
21pub use plugin::{DiscoveryPlugin, FastDiscoveryPlugin, WebUIDiscoveryPlugin};
22
23/// A component discovered from an external source, ready for registration.
24#[derive(Debug, Clone)]
25pub struct DiscoveredComponent {
26    /// The custom element tag name (e.g., "reactive-button")
27    pub tag_name: String,
28    /// The HTML template content
29    pub html_content: String,
30    /// The CSS content, if any
31    pub css_content: Option<String>,
32    /// Whether authored browser code owns this custom element tag.
33    pub is_client_owned: bool,
34    /// The original source identifier (for display/diagnostics)
35    pub source: String,
36}
37
38/// Result of discovering components from a single source.
39#[derive(Debug)]
40pub struct DiscoveryResult {
41    /// The original source string
42    pub source: String,
43    /// The discovered components
44    pub components: Vec<DiscoveredComponent>,
45}
46
47/// Classification of a `--components` source string.
48enum ComponentSource {
49    /// npm package (scoped like `@scope` or unscoped like `my-widget`)
50    NpmPackage(String),
51    /// Local filesystem path
52    Path(PathBuf),
53}
54
55/// Classify a source string as either an npm package or a local path.
56///
57/// - Starts with `.`, `/`, `\`, or contains a drive letter (Windows) → path
58/// - Everything else → npm package
59fn classify_source(source: &str) -> ComponentSource {
60    if is_local_source(source) {
61        ComponentSource::Path(PathBuf::from(source))
62    } else {
63        ComponentSource::NpmPackage(source.to_string())
64    }
65}
66
67/// Returns `true` when a `--components` source string denotes a local
68/// filesystem path rather than an npm package name or scope.
69///
70/// A source is a local path when it starts with `.`, `/`, `\`, or a Windows
71/// drive letter (e.g. `C:\...`). Everything else — bare names like
72/// `my-widget` and scopes like `@scope` / `@scope/pkg` — is an npm package.
73///
74/// This is the single source of truth for the classification; callers that
75/// pre-resolve sources before handing them to [`discover_source`] (such as
76/// `webui-press`, which must resolve local paths against its own working
77/// directory while leaving npm names bare) should use it instead of
78/// re-implementing the check.
79#[must_use]
80pub fn is_local_source(source: &str) -> bool {
81    source.starts_with('.')
82        || source.starts_with('/')
83        || source.starts_with('\\')
84        || (cfg!(windows) && source.len() >= 2 && source.as_bytes()[1] == b':')
85}
86
87/// Discover components from a single source and register them into a component registry.
88///
89/// Returns a [`DiscoveryResult`] with the discovered components.
90pub fn discover_source(source: &str, search_dir: &Path) -> Result<DiscoveryResult> {
91    discover_source_with_plugin(source, search_dir, &WebUIDiscoveryPlugin::new())
92}
93
94/// Discover components from a source using the selected package layout.
95///
96/// # Errors
97///
98/// Returns an error when the source cannot be resolved or the plugin rejects
99/// its component layout.
100pub fn discover_source_with_plugin(
101    source: &str,
102    search_dir: &Path,
103    plugin: &dyn DiscoveryPlugin,
104) -> Result<DiscoveryResult> {
105    let mut cache = cache::DiscoveryCache::open()?;
106
107    let components = match classify_source(source) {
108        ComponentSource::NpmPackage(ref name) => {
109            npm::resolve(name, search_dir, plugin, &mut cache)?
110        }
111        ComponentSource::Path(ref path) => {
112            let resolved = if path.is_relative() {
113                search_dir.join(path)
114            } else {
115                path.clone()
116            };
117            let resolved = resolved
118                .canonicalize()
119                .with_context(|| format!("Component path not found: {}", path.display()))?;
120            plugin.discover_local(&resolved)?
121        }
122    };
123
124    Ok(DiscoveryResult {
125        source: source.to_string(),
126        components,
127    })
128}
129
130/// Return whether a component has an authored sibling module.
131///
132/// Use `try_exists()` rather than `exists()`: `exists()` converts metadata
133/// errors into `false`, which could silently classify an inaccessible authored
134/// component as scriptless and bypass projection-manifest coverage.
135pub(crate) fn has_sibling_script(html_path: &Path) -> Result<bool> {
136    for ext in ["ts", "js"] {
137        let candidate = html_path.with_extension(ext);
138        if candidate.try_exists().with_context(|| {
139            format!(
140                "Failed to inspect component script: {}",
141                candidate.display()
142            )
143        })? {
144            return Ok(true);
145        }
146    }
147    Ok(false)
148}
149
150/// Collect the resolved local paths from source strings for file watching.
151pub fn collect_watch_paths(sources: &[String], search_dir: &Path) -> Vec<PathBuf> {
152    sources
153        .iter()
154        .filter_map(|source| {
155            if let ComponentSource::Path(ref path) = classify_source(source) {
156                let resolved = if path.is_relative() {
157                    search_dir.join(path)
158                } else {
159                    path.clone()
160                };
161                resolved.canonicalize().ok()
162            } else {
163                None
164            }
165        })
166        .collect()
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_classify_relative_path() {
175        assert!(matches!(
176            classify_source("./libs/shared"),
177            ComponentSource::Path(_)
178        ));
179        assert!(matches!(
180            classify_source("../components"),
181            ComponentSource::Path(_)
182        ));
183    }
184
185    #[test]
186    fn test_classify_absolute_path() {
187        assert!(matches!(
188            classify_source("/absolute/path"),
189            ComponentSource::Path(_)
190        ));
191    }
192
193    #[test]
194    fn test_classify_npm_package() {
195        assert!(matches!(
196            classify_source("my-widget"),
197            ComponentSource::NpmPackage(_)
198        ));
199    }
200
201    #[test]
202    fn test_classify_scoped_npm_package() {
203        assert!(matches!(
204            classify_source("@reactive-ui"),
205            ComponentSource::NpmPackage(_)
206        ));
207        assert!(matches!(
208            classify_source("@scope/button"),
209            ComponentSource::NpmPackage(_)
210        ));
211    }
212
213    #[cfg(windows)]
214    #[test]
215    fn test_classify_windows_drive_path() {
216        assert!(matches!(
217            classify_source("C:\\components"),
218            ComponentSource::Path(_)
219        ));
220    }
221
222    #[test]
223    fn test_is_local_source() {
224        // Local paths
225        assert!(is_local_source("./libs/shared"));
226        assert!(is_local_source("../components"));
227        assert!(is_local_source("/absolute/path"));
228        assert!(is_local_source("\\unc\\path"));
229        // npm packages / scopes
230        assert!(!is_local_source("my-widget"));
231        assert!(!is_local_source("@reactive-ui"));
232        assert!(!is_local_source("@scope/button"));
233    }
234
235    #[test]
236    fn test_discover_from_path_preserves_script_ownership() {
237        let tmp = tempfile::TempDir::new().unwrap();
238        std::fs::write(tmp.path().join("plain-card.html"), "<p>{{title}}</p>").unwrap();
239        std::fs::write(
240            tmp.path().join("interactive-card.html"),
241            "<button>go</button>",
242        )
243        .unwrap();
244        std::fs::write(tmp.path().join("interactive-card.ts"), "export {};").unwrap();
245
246        let components = WebUIDiscoveryPlugin::new()
247            .discover_local(tmp.path())
248            .unwrap();
249        let plain = components
250            .iter()
251            .find(|component| component.tag_name == "plain-card")
252            .unwrap();
253        let interactive = components
254            .iter()
255            .find(|component| component.tag_name == "interactive-card")
256            .unwrap();
257
258        assert!(!plain.is_client_owned);
259        assert!(interactive.is_client_owned);
260    }
261}