use std::ffi::OsString;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand};
use web_modules::vendor::{vendor, PackageSpec};
type Res<T = ()> = Result<T, Box<dyn std::error::Error>>;
#[derive(Parser)]
#[command(
name = "web-modules",
version,
about = "Buildless web frontend toolchain (no Node)"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Dev {
roots: Vec<PathBuf>,
#[arg(long, default_value = "127.0.0.1:8080")]
addr: SocketAddr,
},
Compile {
roots: Vec<PathBuf>,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
scss: bool,
#[arg(long)]
minify: bool,
},
Vendor {
#[arg(long, default_value = "web_modules")]
out: PathBuf,
#[arg(long, default_value = "/web_modules")]
mount: String,
#[arg(long)]
importmap: Option<PathBuf>,
#[arg(long)]
manifest: Vec<PathBuf>,
packages: Vec<String>,
},
Ci {
#[arg(default_value = ".")]
dir: PathBuf,
},
#[command(disable_help_flag = true)]
Npm {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<OsString>,
},
}
fn resolve_compile_io(
mut roots: Vec<PathBuf>,
out: Option<PathBuf>,
) -> Res<(Vec<PathBuf>, PathBuf)> {
let out = match out {
Some(out) => out,
None => roots
.pop()
.ok_or("compile: give an output directory — a trailing path or `--out <dir>`")?,
};
if roots.is_empty() {
roots.push(PathBuf::from("."));
}
Ok((roots, out))
}
fn parse_spec(p: &str) -> PackageSpec {
match p.rfind('@') {
Some(i) if i > 0 => PackageSpec::npm(&p[..i], &p[i + 1..]),
_ => PackageSpec::npm(p, "*"),
}
}
fn build_vendor_specs(packages: &[String], manifests: &[PathBuf]) -> Res<Vec<PackageSpec>> {
let mut specs: Vec<PackageSpec> = packages
.iter()
.map(String::as_str)
.map(parse_spec)
.collect();
for path in manifests {
specs.extend(web_modules::vendor::specs_from_package_json(path)?);
}
if specs.is_empty() {
return Err("vendor: give package specs (e.g. lit@^3) or --manifest <package.json>".into());
}
let mut seen = std::collections::HashSet::new();
specs.retain(|s| seen.insert(s.name().to_string()));
Ok(specs)
}
#[tokio::main]
async fn main() -> Res {
match Cli::parse().command {
Command::Dev { mut roots, addr } => {
if roots.is_empty() {
roots.push(PathBuf::from("."));
}
web_modules::dev::serve(roots, addr).await?;
}
Command::Compile {
roots,
out,
scss,
minify,
} => {
let (roots, out) = resolve_compile_io(roots, out)?;
let load_paths: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
let (mut modules, mut stylesheets, mut copied) = (0, 0, 0);
for root in roots.iter().rev() {
modules += web_modules::typescript::compile_directory(root, &out)?;
if scss {
stylesheets += web_modules::scss::compile_directory(root, &out, &load_paths)?;
}
copied += web_modules::static_files::copy_static(root, &out)?;
}
if minify {
web_modules::minify::minify_directory(&out)?;
}
println!(
"compiled {modules} module(s){} + copied {copied} static file(s) from {} root(s) → {}{}",
if scss {
format!(", {stylesheets} stylesheet(s)")
} else {
String::new()
},
roots.len(),
out.display(),
if minify { " (minified)" } else { "" },
);
}
Command::Vendor {
out,
mount,
importmap,
manifest,
packages,
} => {
let specs = build_vendor_specs(&packages, &manifest)?;
let map = vendor(&out, &mount, &specs)?;
match importmap {
Some(path) => {
map.write_to(&path)?;
println!("wrote import map → {}", path.display());
}
None => println!("{}", map.to_json()),
}
}
Command::Ci { dir } => {
let installed =
npm_utils::install::from_lockfile(&dir.join("package-lock.json"), &dir)?;
println!(
"installed {} package(s) → {} (npm ci, in Rust — no npm)",
installed.len(),
dir.join("node_modules").display()
);
}
Command::Npm { args } => {
npm_utils::cli::run(std::iter::once(OsString::from("npm-utils")).chain(args))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn names(specs: &[PackageSpec]) -> Vec<&str> {
specs.iter().map(PackageSpec::name).collect()
}
#[test]
fn compile_io_two_paths_last_is_output() {
let (roots, out) = resolve_compile_io(vec!["web".into(), "dist".into()], None).unwrap();
assert_eq!(roots, [PathBuf::from("web")]);
assert_eq!(out, PathBuf::from("dist"));
}
#[test]
fn compile_io_many_roots_last_is_output() {
let (roots, out) =
resolve_compile_io(vec!["a".into(), "b".into(), "dist".into()], None).unwrap();
assert_eq!(roots, [PathBuf::from("a"), PathBuf::from("b")]);
assert_eq!(out, PathBuf::from("dist"));
}
#[test]
fn compile_io_single_path_defaults_source_to_cwd() {
let (roots, out) = resolve_compile_io(vec!["dist".into()], None).unwrap();
assert_eq!(roots, [PathBuf::from(".")]);
assert_eq!(out, PathBuf::from("dist"));
}
#[test]
fn compile_io_out_flag_keeps_all_positionals_as_roots() {
let (roots, out) =
resolve_compile_io(vec!["a".into(), "b".into()], Some("dist".into())).unwrap();
assert_eq!(roots, [PathBuf::from("a"), PathBuf::from("b")]);
assert_eq!(out, PathBuf::from("dist"));
}
#[test]
fn compile_io_out_flag_without_roots_defaults_to_cwd() {
let (roots, out) = resolve_compile_io(vec![], Some("dist".into())).unwrap();
assert_eq!(roots, [PathBuf::from(".")]);
assert_eq!(out, PathBuf::from("dist"));
}
#[test]
fn compile_io_no_output_is_an_error() {
assert!(resolve_compile_io(vec![], None).is_err());
}
#[test]
fn parse_spec_handles_bare_scoped_and_ranged() {
assert_eq!(parse_spec("lit").name(), "lit");
assert_eq!(parse_spec("lit@^3").name(), "lit");
assert_eq!(parse_spec("@lit/context").name(), "@lit/context");
assert_eq!(parse_spec("@lit/context@^1").name(), "@lit/context");
}
#[test]
fn vendor_specs_requires_a_source() {
assert!(build_vendor_specs(&[], &[]).is_err());
}
#[test]
fn vendor_specs_from_positional_specs() {
let specs = build_vendor_specs(&["lit@^3".into(), "@lit/context@^1".into()], &[]).unwrap();
assert_eq!(names(&specs), ["lit", "@lit/context"]);
}
#[test]
fn vendor_specs_reads_a_manifest() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("package.json");
std::fs::write(&manifest, r#"{"dependencies":{"ms":"^2.1.3"}}"#).unwrap();
let specs = build_vendor_specs(&[], std::slice::from_ref(&manifest)).unwrap();
assert_eq!(names(&specs), ["ms"]);
}
#[test]
fn vendor_specs_positional_wins_over_manifest_duplicate() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("package.json");
std::fs::write(&manifest, r#"{"dependencies":{"lit":"^2","ms":"^2"}}"#).unwrap();
let specs =
build_vendor_specs(&["lit@^3".into()], std::slice::from_ref(&manifest)).unwrap();
assert_eq!(names(&specs), ["lit", "ms"]);
}
}