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