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