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;
15
16use anyhow::{Context, Result};
17use std::path::{Path, PathBuf};
18use walkdir::WalkDir;
19
20/// A component discovered from an external source, ready for registration.
21#[derive(Debug, Clone)]
22pub struct DiscoveredComponent {
23    /// The custom element tag name (e.g., "reactive-button")
24    pub tag_name: String,
25    /// The HTML template content
26    pub html_content: String,
27    /// The CSS content, if any
28    pub css_content: Option<String>,
29    /// The original source identifier (for display/diagnostics)
30    pub source: String,
31}
32
33/// Result of discovering components from a single source.
34#[derive(Debug)]
35pub struct DiscoveryResult {
36    /// The original source string
37    pub source: String,
38    /// The discovered components
39    pub components: Vec<DiscoveredComponent>,
40}
41
42/// Classification of a `--components` source string.
43enum ComponentSource {
44    /// npm package (scoped like `@scope` or unscoped like `my-widget`)
45    NpmPackage(String),
46    /// Local filesystem path
47    Path(PathBuf),
48}
49
50/// Classify a source string as either an npm package or a local path.
51///
52/// - Starts with `.`, `/`, `\`, or contains a drive letter (Windows) → path
53/// - Everything else → npm package
54fn classify_source(source: &str) -> ComponentSource {
55    if source.starts_with('.')
56        || source.starts_with('/')
57        || source.starts_with('\\')
58        || (cfg!(windows) && source.len() >= 2 && source.as_bytes()[1] == b':')
59    {
60        ComponentSource::Path(PathBuf::from(source))
61    } else {
62        ComponentSource::NpmPackage(source.to_string())
63    }
64}
65
66/// Discover components from a single source and register them into a component registry.
67///
68/// Returns a [`DiscoveryResult`] with the discovered components.
69pub fn discover_source(source: &str, search_dir: &Path) -> Result<DiscoveryResult> {
70    let mut cache = cache::DiscoveryCache::open()?;
71
72    let components = match classify_source(source) {
73        ComponentSource::NpmPackage(ref name) => npm::resolve(name, search_dir, &mut cache)?,
74        ComponentSource::Path(ref path) => {
75            let resolved = if path.is_relative() {
76                search_dir.join(path)
77            } else {
78                path.clone()
79            };
80            let resolved = resolved
81                .canonicalize()
82                .with_context(|| format!("Component path not found: {}", path.display()))?;
83            discover_from_path(&resolved)?
84        }
85    };
86
87    Ok(DiscoveryResult {
88        source: source.to_string(),
89        components,
90    })
91}
92
93/// Discover components from a local directory path.
94///
95/// Scans recursively for HTML files with hyphenated names (Web Components
96/// convention) and pairs them with matching CSS files.
97fn discover_from_path(dir: &Path) -> Result<Vec<DiscoveredComponent>> {
98    let source = dir.display().to_string();
99    let mut components = Vec::new();
100
101    for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
102        let path = entry.path();
103        if !path.is_file() {
104            continue;
105        }
106        if path.extension().is_some_and(|ext| ext == "html") {
107            if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
108                if filename.contains('-') {
109                    let html_content = std::fs::read_to_string(path).with_context(|| {
110                        format!("Failed to read component HTML: {}", path.display())
111                    })?;
112                    let css_path = path.with_extension("css");
113                    let css_content = if css_path.exists() {
114                        Some(std::fs::read_to_string(&css_path).with_context(|| {
115                            format!("Failed to read component CSS: {}", css_path.display())
116                        })?)
117                    } else {
118                        None
119                    };
120                    components.push(DiscoveredComponent {
121                        tag_name: filename.to_string(),
122                        html_content,
123                        css_content,
124                        source: source.clone(),
125                    });
126                }
127            }
128        }
129    }
130
131    Ok(components)
132}
133
134/// Collect the resolved local paths from source strings for file watching.
135pub fn collect_watch_paths(sources: &[String], search_dir: &Path) -> Vec<PathBuf> {
136    sources
137        .iter()
138        .filter_map(|source| {
139            if let ComponentSource::Path(ref path) = classify_source(source) {
140                let resolved = if path.is_relative() {
141                    search_dir.join(path)
142                } else {
143                    path.clone()
144                };
145                resolved.canonicalize().ok()
146            } else {
147                None
148            }
149        })
150        .collect()
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn test_classify_relative_path() {
159        assert!(matches!(
160            classify_source("./libs/shared"),
161            ComponentSource::Path(_)
162        ));
163        assert!(matches!(
164            classify_source("../components"),
165            ComponentSource::Path(_)
166        ));
167    }
168
169    #[test]
170    fn test_classify_absolute_path() {
171        assert!(matches!(
172            classify_source("/absolute/path"),
173            ComponentSource::Path(_)
174        ));
175    }
176
177    #[test]
178    fn test_classify_npm_package() {
179        assert!(matches!(
180            classify_source("my-widget"),
181            ComponentSource::NpmPackage(_)
182        ));
183    }
184
185    #[test]
186    fn test_classify_scoped_npm_package() {
187        assert!(matches!(
188            classify_source("@reactive-ui"),
189            ComponentSource::NpmPackage(_)
190        ));
191        assert!(matches!(
192            classify_source("@scope/button"),
193            ComponentSource::NpmPackage(_)
194        ));
195    }
196
197    #[cfg(windows)]
198    #[test]
199    fn test_classify_windows_drive_path() {
200        assert!(matches!(
201            classify_source("C:\\components"),
202            ComponentSource::Path(_)
203        ));
204    }
205}