Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 34 additions & 5 deletions crates/webui-discovery/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,33 @@ enum ComponentSource {
/// - Starts with `.`, `/`, `\`, or contains a drive letter (Windows) → path
/// - Everything else → npm package
fn classify_source(source: &str) -> ComponentSource {
if source.starts_with('.')
|| source.starts_with('/')
|| source.starts_with('\\')
|| (cfg!(windows) && source.len() >= 2 && source.as_bytes()[1] == b':')
{
if is_local_source(source) {
ComponentSource::Path(PathBuf::from(source))
} else {
ComponentSource::NpmPackage(source.to_string())
}
}

/// Returns `true` when a `--components` source string denotes a local
/// filesystem path rather than an npm package name or scope.
///
/// A source is a local path when it starts with `.`, `/`, `\`, or a Windows
/// drive letter (e.g. `C:\...`). Everything else — bare names like
/// `my-widget` and scopes like `@scope` / `@scope/pkg` — is an npm package.
///
/// This is the single source of truth for the classification; callers that
/// pre-resolve sources before handing them to [`discover_source`] (such as
/// `webui-press`, which must resolve local paths against its own working
/// directory while leaving npm names bare) should use it instead of
/// re-implementing the check.
#[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':')
}

/// Discover components from a single source and register them into a component registry.
///
/// Returns a [`DiscoveryResult`] with the discovered components.
Expand Down Expand Up @@ -202,4 +218,17 @@ mod tests {
ComponentSource::Path(_)
));
}

#[test]
fn test_is_local_source() {
// Local paths
assert!(is_local_source("./libs/shared"));
assert!(is_local_source("../components"));
assert!(is_local_source("/absolute/path"));
assert!(is_local_source("\\unc\\path"));
// npm packages / scopes
assert!(!is_local_source("my-widget"));
assert!(!is_local_source("@reactive-ui"));
assert!(!is_local_source("@scope/button"));
}
}
58 changes: 56 additions & 2 deletions crates/webui-discovery/src/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,24 @@ fn find_node_modules(start: &Path) -> Result<PathBuf> {
);
}

/// Find `node_modules/` by walking up from `primary`, falling back to a
/// walk up from `fallback` when the primary search comes up empty.
///
/// The fallback rescues callers whose primary search root lives outside
/// any project tree. For example, `webui-press` builds each docs page in a
/// synthesized scratch directory under the system temp folder, which has no
/// `node_modules` ancestor; the project's `node_modules` is instead reached
/// from the process working directory the command was invoked in. The error
/// from the primary search is preserved when both roots fail.
fn find_node_modules_with_fallback(primary: &Path, fallback: &Path) -> Result<PathBuf> {
find_node_modules(primary).or_else(|primary_err| {
if fallback == primary {
return Err(primary_err);
}
find_node_modules(fallback).map_err(|_| primary_err)
})
}

/// Check if a package name is a bare scope (e.g., `@reactive-ui` without a sub-package).
fn is_bare_scope(name: &str) -> bool {
name.starts_with('@') && !name.contains('/')
Expand Down Expand Up @@ -79,10 +97,16 @@ fn read_to_string_limited(path: &Path, max_size: u64) -> Result<String> {
/// Resolve an npm package or scope to discovered components.
pub fn resolve(
name: &str,
cwd: &Path,
search_dir: &Path,
cache: &mut DiscoveryCache,
) -> Result<Vec<DiscoveredComponent>> {
let node_modules = find_node_modules(cwd)?;
// Walk up from the build's app directory first, then fall back to the
// process working directory. The fallback covers callers whose app
// directory lives outside the project (e.g. a system-temp scratch dir),
// where the project's `node_modules` is only reachable from the cwd the
// command was invoked in.
let fallback = std::env::current_dir().unwrap_or_else(|_| search_dir.to_path_buf());
let node_modules = find_node_modules_with_fallback(search_dir, &fallback)?;

if is_bare_scope(name) {
resolve_scoped(name, &node_modules, cache)
Expand Down Expand Up @@ -378,6 +402,36 @@ mod tests {
assert!(result.is_err());
}

#[test]
fn test_find_node_modules_fallback_used_when_primary_empty() {
let primary = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let nm = project.path().join("node_modules");
fs::create_dir_all(&nm).unwrap();

let found = find_node_modules_with_fallback(primary.path(), project.path()).unwrap();
assert_eq!(found, nm);
}

#[test]
fn test_find_node_modules_fallback_prefers_primary() {
let primary = TempDir::new().unwrap();
let nm_primary = primary.path().join("node_modules");
fs::create_dir_all(&nm_primary).unwrap();
let project = TempDir::new().unwrap();
fs::create_dir_all(project.path().join("node_modules")).unwrap();

let found = find_node_modules_with_fallback(primary.path(), project.path()).unwrap();
assert_eq!(found, nm_primary);
}

#[test]
fn test_find_node_modules_fallback_errors_when_neither_has_it() {
let primary = TempDir::new().unwrap();
let fallback = TempDir::new().unwrap();
assert!(find_node_modules_with_fallback(primary.path(), fallback.path()).is_err());
}

#[test]
fn test_is_bare_scope() {
assert!(is_bare_scope("@reactive-ui"));
Expand Down
1 change: 1 addition & 0 deletions crates/webui-press/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ path = "src/main.rs"

[dependencies]
microsoft-webui = { path = "../webui", version = "0.0.17", features = ["cli"] }
microsoft-webui-discovery = { path = "../webui-discovery", version = "0.0.17" }
microsoft-webui-handler = { path = "../webui-handler", version = "0.0.17" }
microsoft-webui-tokens = { path = "../webui-tokens", version = "0.0.17" }

Expand Down
51 changes: 46 additions & 5 deletions crates/webui-press/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ fn inject_theme_tokens(
Ok(())
}

/// Resolve a configured component source for the per-page builds.
///
/// Local paths are made absolute against `cwd` (the project root) because
/// `webui-discovery` resolves relative paths against the synthesized per-page
/// app directory, not the project. npm package names and scopes (e.g.
/// `@mai-ui`) are left bare so discovery resolves them from `node_modules`.
fn resolve_config_component_source(source: &str, cwd: &Path) -> String {
if webui_discovery::is_local_source(source) {
cwd.join(source).to_string_lossy().to_string()
} else {
source.to_string()
}
}

// ── Output helpers ──────────────────────────────────────────────
//
// Mirrors the styling vocabulary in `crates/webui-cli/src/utils/output.rs`
Expand Down Expand Up @@ -266,18 +280,20 @@ pub fn build_docs_with_cache(

// Step 2: Resolve component sources for the per-page builds
let mut component_sources: Vec<String> = Vec::new();
let cwd = std::env::current_dir().unwrap_or_default();
// Built-in component library (e.g. crates/webui-press/components/)
let builtin_components = template_dir.parent().map(|p| p.join("components"));
if let Some(ref bc) = builtin_components {
if bc.exists() {
component_sources.push(bc.to_string_lossy().to_string());
}
}
// User component dirs from config
if let Some(ref user_dirs) = config.components {
for d in user_dirs {
let abs = std::env::current_dir().unwrap_or_default().join(d);
component_sources.push(abs.to_string_lossy().to_string());
// User component sources from config. Local paths are resolved against
// the current project root; npm package names/scopes must stay bare so
// webui-discovery resolves them from node_modules.
if let Some(ref user_sources) = config.components {
for source in user_sources {
component_sources.push(resolve_config_component_source(source, &cwd));
}
}
// Template-local components (e.g. docs-search, docs-theme-toggle living
Expand Down Expand Up @@ -1232,6 +1248,31 @@ mod tests {
assert_eq!(npx_command(), "npx");
}

#[test]
fn config_component_source_preserves_npm_packages() {
let cwd = Path::new("project");

assert_eq!(resolve_config_component_source("@mai-ui", cwd), "@mai-ui");
assert_eq!(
resolve_config_component_source("@mai-ui/button", cwd),
"@mai-ui/button"
);
assert_eq!(
resolve_config_component_source("plain-widget", cwd),
"plain-widget"
);
}

#[test]
fn config_component_source_resolves_local_paths() {
let cwd = Path::new("project");

assert_eq!(
std::path::PathBuf::from(resolve_config_component_source("./components", cwd)),
cwd.join("./components")
);
}

// --- truncate_utf8 ---------------------------------------------------

#[test]
Expand Down
Loading