use std::ffi::OsString;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use clap::{Args, Parser, Subcommand};
use serde_json::Value;
use web_modules::vendor::{vendor, PackageSpec};
type Res<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
const DEFAULT_HTML: &str = "<!doctype html>{importmap}<script type=module src=./app.js></script>";
#[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)]
addr: Option<SocketAddr>,
#[command(flatten)]
compiler: CompilerConfig,
},
Build {
roots: Vec<PathBuf>,
#[arg(long)]
out: Option<PathBuf>,
#[cfg_attr(feature = "env", arg(long, env = "WEB_MODULES_MOUNT"))]
#[cfg_attr(not(feature = "env"), arg(long))]
mount: Option<String>,
#[cfg_attr(feature = "env", arg(long, env = "WEB_MODULES_HTML"))]
#[cfg_attr(not(feature = "env"), arg(long))]
html: Option<String>,
#[cfg_attr(feature = "env", arg(long, env = "WEB_MODULES_TEMPLATE"))]
#[cfg_attr(not(feature = "env"), arg(long))]
template: Option<PathBuf>,
#[cfg_attr(feature = "env", arg(long, env = "WEB_MODULES_MANIFEST"))]
#[cfg_attr(not(feature = "env"), arg(long))]
manifest: Vec<PathBuf>,
#[cfg_attr(
feature = "env",
arg(
long = "package",
value_name = "SPEC",
env = "WEB_MODULES_PACKAGES",
value_delimiter = ' '
)
)]
#[cfg_attr(not(feature = "env"), arg(long = "package", value_name = "SPEC"))]
packages: Vec<String>,
#[command(flatten)]
compiler: CompilerConfig,
},
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>,
},
}
#[derive(Args, Debug)]
struct CompilerConfig {
#[arg(long)]
no_default_features: bool,
#[command(flatten)]
typescript: web_modules::typescript::TypescriptArgs,
#[command(flatten)]
scss: web_modules::scss::ScssArgs,
#[command(flatten)]
tera: web_modules::templates::TeraArgs,
#[command(flatten)]
minify: web_modules::minify::MinifyArgs,
#[cfg(feature = "compress")]
#[command(flatten)]
gzip: web_modules::compress::GzipArgs,
}
struct ResolvedCompiler {
typescript: bool,
scss: bool,
tera: bool,
minify: bool,
gzip: bool,
ts_decorators: web_modules::typescript::Decorators,
extra_scss_load_paths: Vec<PathBuf>,
}
impl CompilerConfig {
fn resolve_with(&self, cfg: &PkgConfig) -> ResolvedCompiler {
let nd = self.no_default_features;
ResolvedCompiler {
typescript: self.typescript.enabled_with(cfg.typescript, true, nd),
scss: self.scss.enabled_with(cfg.scss, true, nd),
tera: self.tera.enabled_with(cfg.tera, true, nd),
minify: self.minify.enabled_with(cfg.minify, false, nd),
#[cfg(feature = "compress")]
gzip: self.gzip.enabled_with(cfg.gzip, false, nd),
#[cfg(not(feature = "compress"))]
gzip: false,
ts_decorators: self.typescript.config.decorators.into(),
extra_scss_load_paths: {
let mut paths = self.scss.config.load_paths.clone();
paths.extend(cfg.scss_load_paths.iter().cloned());
paths
},
}
}
}
impl ResolvedCompiler {
fn into_processors(self) -> web_modules::build::Processors {
let mut p = web_modules::build::Processors::default();
p.typescript = self.typescript;
p.scss = self.scss;
p.tera = self.tera;
p.ts_decorators = self.ts_decorators;
p.extra_scss_load_paths = self.extra_scss_load_paths;
p
}
}
fn roots_or_cwd(mut roots: Vec<PathBuf>) -> Vec<PathBuf> {
if roots.is_empty() {
roots.push(PathBuf::from("."));
}
roots
}
fn build_vendor_specs(
packages: &[String],
manifests: &[PathBuf],
require_nonempty: bool,
) -> Res<Vec<PackageSpec>> {
let mut specs: Vec<PackageSpec> = packages.iter().map(|p| PackageSpec::parse(p)).collect();
for path in manifests {
specs.extend(web_modules::vendor::specs_from_package_json(path)?);
}
if require_nonempty && 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)
}
#[derive(Debug, Default)]
struct PkgConfig {
roots: Vec<PathBuf>,
out: Option<PathBuf>,
mount: Option<String>,
html: Option<String>,
template: Option<PathBuf>,
packages: Vec<String>,
minify: Option<bool>,
gzip: Option<bool>,
typescript: Option<bool>,
scss: Option<bool>,
tera: Option<bool>,
scss_load_paths: Vec<PathBuf>,
}
fn load_pkg_config() -> Res<(PkgConfig, Option<PathBuf>)> {
load_pkg_config_at(Path::new("."))
}
fn load_pkg_config_at(dir: &Path) -> Res<(PkgConfig, Option<PathBuf>)> {
let path = dir.join("package.json");
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok((PkgConfig::default(), None));
}
Err(e) => return Err(format!("{}: {e}", path.display()).into()),
};
let json: Value =
serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display()))?;
match json.get("web_modules") {
Some(block) => Ok((parse_block(block)?, Some(path))),
None => Ok((PkgConfig::default(), Some(path))),
}
}
fn parse_block(block: &Value) -> Res<PkgConfig> {
let obj = block
.as_object()
.ok_or("package.json: `web_modules` must be an object")?;
let mut cfg = PkgConfig::default();
for (key, val) in obj {
match key.as_str() {
"roots" => cfg.roots = path_array(val, "web_modules.roots")?,
"out" => cfg.out = Some(PathBuf::from(as_string(val, "web_modules.out")?)),
"mount" => cfg.mount = Some(as_string(val, "web_modules.mount")?),
"html" => cfg.html = Some(as_string(val, "web_modules.html")?),
"template" => {
cfg.template = Some(PathBuf::from(as_string(val, "web_modules.template")?));
}
"packages" => cfg.packages = string_array(val, "web_modules.packages")?,
"minify" => cfg.minify = Some(as_bool(val, "web_modules.minify")?),
"gzip" => cfg.gzip = Some(as_bool(val, "web_modules.gzip")?),
"typescript" => {
cfg.typescript = Some(processor_enabled(val, "web_modules.typescript")?)
}
"scss" => {
cfg.scss = Some(processor_enabled(val, "web_modules.scss")?);
if let Some(lp) = val.as_object().and_then(|o| o.get("loadPaths")) {
cfg.scss_load_paths = path_array(lp, "web_modules.scss.loadPaths")?;
}
}
"tera" => cfg.tera = Some(processor_enabled(val, "web_modules.tera")?),
"webDependencies" | "root" => {}
_ => {}
}
}
Ok(cfg)
}
fn processor_enabled(val: &Value, ctx: &str) -> Res<bool> {
match val {
Value::Bool(b) => Ok(*b),
Value::Object(_) => Ok(true),
_ => Err(format!("{ctx} must be a boolean or an object").into()),
}
}
fn as_string(val: &Value, ctx: &str) -> Res<String> {
val.as_str()
.map(str::to_string)
.ok_or_else(|| format!("{ctx} must be a string").into())
}
fn as_bool(val: &Value, ctx: &str) -> Res<bool> {
val.as_bool()
.ok_or_else(|| format!("{ctx} must be a boolean").into())
}
fn string_array(val: &Value, ctx: &str) -> Res<Vec<String>> {
let arr = val
.as_array()
.ok_or_else(|| format!("{ctx} must be an array of strings"))?;
arr.iter()
.map(|e| {
e.as_str()
.map(str::to_string)
.ok_or_else(|| format!("{ctx} entries must be strings").into())
})
.collect()
}
fn path_array(val: &Value, ctx: &str) -> Res<Vec<PathBuf>> {
Ok(string_array(val, ctx)?
.into_iter()
.map(PathBuf::from)
.collect())
}
fn pick<T>(cli: Option<T>, block: Option<T>, default: T) -> T {
cli.or(block).unwrap_or(default)
}
fn pick_vec<T>(cli: Vec<T>, block: Vec<T>) -> Vec<T> {
if cli.is_empty() {
block
} else {
cli
}
}
#[tokio::main]
async fn main() -> Res {
match Cli::parse().command {
Command::Dev {
roots,
addr,
compiler,
} => {
let (cfg, _pkg_path) = load_pkg_config()?;
let config = compiler.resolve_with(&cfg).into_processors();
let roots = roots_or_cwd(pick_vec(roots, cfg.roots));
let addr =
addr.unwrap_or_else(|| "127.0.0.1:8080".parse().expect("valid default addr"));
web_modules::dev::serve_with(roots, addr, config).await?;
}
Command::Build {
roots,
out,
mount,
html,
template,
manifest,
packages,
compiler,
} => {
let (cfg, pkg_path) = load_pkg_config()?;
let resolved = compiler.resolve_with(&cfg);
let (minify, gzip) = (resolved.minify, resolved.gzip);
let output = web_modules::build::Output::new(minify, gzip);
let processors = resolved.into_processors();
let mut manifests = manifest;
if let Some(path) = &pkg_path {
manifests.push(path.clone());
}
let packages = pick_vec(packages, cfg.packages);
let specs = build_vendor_specs(&packages, &manifests, false)?;
let roots = roots_or_cwd(pick_vec(roots, cfg.roots));
let out = pick(out, cfg.out, PathBuf::from("dist"));
let mount = pick(mount, cfg.mount, "/web_modules".to_string());
let html = pick(html, cfg.html, DEFAULT_HTML.to_string());
let template = template.or(cfg.template);
web_modules::build::build(&web_modules::build::BuildOptions {
specs: &specs,
roots: &roots,
out: &out,
mount: &mount,
html: &html,
template: template.as_deref(),
processors,
output,
})?;
println!(
"built {} root(s) → {} ({} package spec(s), mount {mount}{}{})",
roots.len(),
out.display(),
specs.len(),
if minify { ", minified" } else { "" },
if gzip { ", gzipped" } else { "" },
);
}
Command::Vendor {
out,
mount,
importmap,
manifest,
packages,
} => {
let specs = build_vendor_specs(&packages, &manifest, true)?;
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()
}
fn resolve_build(extra: &[&str]) -> ResolvedCompiler {
let argv: Vec<&str> = [&["web-modules", "build", "--out", "out"][..], extra].concat();
match Cli::try_parse_from(argv).unwrap().command {
Command::Build { compiler, .. } => compiler.resolve_with(&PkgConfig::default()),
_ => panic!("expected Build"),
}
}
#[test]
fn vendor_specs_requires_a_source_when_asked() {
assert!(build_vendor_specs(&[], &[], true).is_err());
assert!(build_vendor_specs(&[], &[], false).unwrap().is_empty());
}
#[test]
fn vendor_specs_from_positional_specs() {
let specs =
build_vendor_specs(&["lit@^3".into(), "@lit/context@^1".into()], &[], true).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), true).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), true).unwrap();
assert_eq!(names(&specs), ["lit", "ms"]);
}
#[test]
fn build_out_defaults_to_dist() {
let dist = PathBuf::from("dist");
assert_eq!(pick(None::<PathBuf>, None, dist.clone()), dist);
assert_eq!(
pick(None, Some(PathBuf::from("b")), dist.clone()),
PathBuf::from("b")
);
assert_eq!(
pick(Some(PathBuf::from("a")), Some(PathBuf::from("b")), dist),
PathBuf::from("a")
);
}
#[test]
fn build_defaults_roots_to_cwd() {
let cli = Cli::try_parse_from(["web-modules", "build", "--out", "dist"]).unwrap();
match cli.command {
Command::Build { roots, .. } => {
assert!(roots.is_empty(), "no positional roots given");
assert_eq!(roots_or_cwd(roots), [PathBuf::from(".")]);
}
_ => panic!("expected Build"),
}
}
#[test]
fn build_parses_positional_roots_and_packages() {
let cli = Cli::try_parse_from([
"web-modules",
"build",
"web",
"shared",
"--out",
"dist",
"--mount",
"/repo/web_modules",
"--package",
"lit@^3",
"--package",
"@lit/context@^1",
"--minify",
])
.unwrap();
match cli.command {
Command::Build {
roots,
out,
mount,
packages,
compiler,
..
} => {
assert_eq!(roots, [PathBuf::from("web"), PathBuf::from("shared")]);
assert_eq!(out, Some(PathBuf::from("dist")));
assert_eq!(mount.as_deref(), Some("/repo/web_modules"));
assert_eq!(packages, ["lit@^3", "@lit/context@^1"]);
assert!(
compiler.resolve_with(&PkgConfig::default()).minify,
"--minify opts in"
);
}
_ => panic!("expected Build"),
}
}
#[test]
fn toggles_default_on_set_and_opt_in() {
let d = resolve_build(&[]);
assert!(d.typescript && d.scss && d.tera, "ts/scss/tera default on");
assert!(!d.minify && !d.gzip, "minify/gzip default off");
assert!(resolve_build(&["--minify"]).minify, "--minify opts in");
}
#[test]
fn toggles_no_scss_disables_regardless_of_order() {
assert!(!resolve_build(&["--no-scss"]).scss);
assert!(!resolve_build(&["--no-scss", "--scss"]).scss);
assert!(!resolve_build(&["--scss", "--no-scss"]).scss);
}
#[test]
fn no_default_features_disables_then_reenables() {
let nd = resolve_build(&["--no-default-features"]);
assert!(
!nd.typescript && !nd.scss && !nd.tera,
"--no-default-features turns the default-on set off"
);
let one = resolve_build(&["--no-default-features", "--scss"]);
assert!(one.scss, "--scss re-enables after --no-default-features");
assert!(!one.typescript && !one.tera, "the others stay off");
}
#[test]
fn typescript_decorators_default_lit() {
use web_modules::typescript::Decorators;
assert_eq!(resolve_build(&[]).ts_decorators, Decorators::Lit);
assert_eq!(
resolve_build(&["--typescript-decorators", "standard"]).ts_decorators,
Decorators::Standard
);
}
#[test]
fn build_unset_scalars_are_none() {
let cli = Cli::try_parse_from(["web-modules", "build"]).unwrap();
match cli.command {
Command::Build {
roots,
out,
packages,
..
} => {
assert!(out.is_none(), "no --out and no default ⇒ None");
assert!(roots.is_empty() && packages.is_empty());
}
_ => panic!("expected Build"),
}
}
fn write_pkg(json: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), json).unwrap();
dir
}
#[test]
fn loads_absent_file_as_empty() {
let dir = tempfile::tempdir().unwrap(); let (cfg, path) = load_pkg_config_at(dir.path()).unwrap();
assert!(path.is_none());
assert!(cfg.out.is_none() && cfg.roots.is_empty() && cfg.scss.is_none());
}
#[test]
fn parses_full_block() {
let dir = write_pkg(
r#"{ "web_modules": {
"roots": ["web", "shared"], "out": "dist", "mount": "/m",
"html": "<x>", "template": "shell.html.tera", "packages": ["lit@^3"],
"minify": true, "gzip": false,
"typescript": true,
"scss": { "loadPaths": ["styles"] },
"tera": false
} }"#,
);
let (cfg, path) = load_pkg_config_at(dir.path()).unwrap();
assert!(path.is_some());
assert_eq!(cfg.roots, [PathBuf::from("web"), PathBuf::from("shared")]);
assert_eq!(cfg.out, Some(PathBuf::from("dist")));
assert_eq!(cfg.mount.as_deref(), Some("/m"));
assert_eq!(cfg.html.as_deref(), Some("<x>"));
assert_eq!(cfg.template, Some(PathBuf::from("shell.html.tera")));
assert_eq!(cfg.packages, ["lit@^3"]);
assert_eq!(cfg.minify, Some(true));
assert_eq!(cfg.gzip, Some(false));
assert_eq!(cfg.typescript, Some(true)); assert_eq!(cfg.scss, Some(true));
assert_eq!(cfg.scss_load_paths, [PathBuf::from("styles")]);
assert_eq!(cfg.tera, Some(false)); }
#[test]
fn processor_bool_disables_object_enables() {
let off = write_pkg(r#"{"web_modules":{"scss":false}}"#);
let (cfg, _) = load_pkg_config_at(off.path()).unwrap();
assert_eq!(cfg.scss, Some(false));
assert!(cfg.scss_load_paths.is_empty());
let on = write_pkg(r#"{"web_modules":{"scss":{"loadPaths":["a","b"]}}}"#);
let (cfg, _) = load_pkg_config_at(on.path()).unwrap();
assert_eq!(cfg.scss, Some(true));
assert_eq!(
cfg.scss_load_paths,
[PathBuf::from("a"), PathBuf::from("b")]
);
}
#[test]
fn malformed_block_errors() {
for bad in [
r#"{"web_modules": []}"#, r#"{"web_modules": {"mount": 5}}"#, r#"{"web_modules": {"scss": 3}}"#, r#"{ not json "#, ] {
let dir = write_pkg(bad);
assert!(
load_pkg_config_at(dir.path()).is_err(),
"should reject: {bad}"
);
}
}
#[test]
fn ignores_webdeps_and_root_returns_path() {
let dir = write_pkg(
r#"{ "dependencies": {"lit": "^3"},
"web_modules": { "webDependencies": ["lit"], "root": "./src" } }"#,
);
let (cfg, path) = load_pkg_config_at(dir.path()).unwrap();
assert!(path.is_some());
assert!(cfg.roots.is_empty() && cfg.out.is_none() && cfg.scss.is_none());
}
fn build_compiler(extra: &[&str]) -> CompilerConfig {
let argv: Vec<&str> = [&["web-modules", "build", "--out", "o"][..], extra].concat();
match Cli::try_parse_from(argv).unwrap().command {
Command::Build { compiler, .. } => compiler,
_ => panic!("expected Build"),
}
}
#[test]
fn pick_and_pick_vec_precedence() {
assert_eq!(pick(Some("a"), Some("b"), "c"), "a");
assert_eq!(pick(None, Some("b"), "c"), "b");
assert_eq!(pick(None::<&str>, None, "c"), "c");
assert_eq!(pick_vec(vec!["a"], vec!["b"]), ["a"]);
assert_eq!(pick_vec(Vec::<&str>::new(), vec!["b"]), ["b"]);
}
#[test]
fn toggle_folds_block_under_cli() {
let scss_off = PkgConfig {
scss: Some(false),
..Default::default()
};
let scss_on = PkgConfig {
scss: Some(true),
..Default::default()
};
assert!(!build_compiler(&[]).resolve_with(&scss_off).scss);
assert!(build_compiler(&["--scss"]).resolve_with(&scss_off).scss);
assert!(!build_compiler(&["--no-scss"]).resolve_with(&scss_on).scss);
assert!(
build_compiler(&["--no-default-features"])
.resolve_with(&scss_on)
.scss
);
assert!(
!build_compiler(&["--no-default-features"])
.resolve_with(&PkgConfig::default())
.scss
);
}
#[test]
fn scss_load_paths_concat_cli_then_block() {
let block = PkgConfig {
scss_load_paths: vec![PathBuf::from("b")],
..Default::default()
};
let r = build_compiler(&["--scss-load-path", "a"]).resolve_with(&block);
assert_eq!(
r.extra_scss_load_paths,
[PathBuf::from("a"), PathBuf::from("b")]
);
}
#[cfg(feature = "env")]
#[test]
fn build_env_fills_option() {
std::env::set_var("WEB_MODULES_MOUNT", "/from-env");
let parsed = Cli::try_parse_from(["web-modules", "build"]);
std::env::remove_var("WEB_MODULES_MOUNT");
match parsed.unwrap().command {
Command::Build { mount, .. } => assert_eq!(mount.as_deref(), Some("/from-env")),
_ => panic!("expected Build"),
}
}
}