use std::path::{Path, PathBuf};
use npm_utils::package_json::{spec::Range, Entry, PackageJson};
use npm_utils::{cache, download, extract, registry::Registry};
use crate::importmap::Importmap;
use crate::mount::Mount;
use crate::{Error, Result};
enum Source {
Npm { package: String, range: String },
Git {
owner: String,
repo: String,
reference: String,
},
}
pub enum Extract {
BrowserAssets,
Full,
File { from: String, to: String },
Filter(fn(&str) -> Option<String>),
}
enum Imports {
Auto,
None,
Explicit(Vec<(String, String)>),
}
pub struct PackageSpec {
source: Source,
dir: String,
dest: Option<PathBuf>,
extract: Extract,
imports: Imports,
}
impl PackageSpec {
pub fn npm(package: impl Into<String>, range: impl Into<String>) -> Self {
let package = package.into();
Self {
dir: package.clone(),
source: Source::Npm {
package,
range: range.into(),
},
dest: None,
extract: Extract::BrowserAssets,
imports: Imports::Auto,
}
}
pub fn git(repo: impl Into<String>, reference: impl Into<String>) -> Self {
let full = repo.into();
let (owner, name) = full.split_once('/').unwrap_or(("", full.as_str()));
let (owner, name) = (owner.to_string(), name.to_string());
Self {
dir: name.clone(),
source: Source::Git {
owner,
repo: name,
reference: reference.into(),
},
dest: None,
extract: Extract::BrowserAssets,
imports: Imports::None,
}
}
pub fn dir(mut self, dir: impl Into<String>) -> Self {
self.dir = dir.into();
self
}
pub fn dest(mut self, dest: impl Into<PathBuf>) -> Self {
self.dest = Some(dest.into());
self
}
pub fn extract(mut self, extract: Extract) -> Self {
self.extract = extract;
self
}
pub fn keep(mut self, keep: fn(&str) -> Option<String>) -> Self {
self.extract = Extract::Filter(keep);
self
}
pub fn imports<I, K, V>(mut self, entries: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
self.imports = Imports::Explicit(
entries
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
);
self
}
pub fn no_imports(mut self) -> Self {
self.imports = Imports::None;
self
}
pub fn name(&self) -> &str {
&self.dir
}
}
pub fn specs_from_package_json(path: &Path) -> Result<Vec<PackageSpec>> {
let bytes = std::fs::read(path)?;
let json: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| Error::Vendor(format!("{}: {e}", path.display())))?;
let Some(whitelist) = json
.get("web-modules")
.and_then(|v| v.get("webDependencies"))
else {
return specs_from_package_json_sections(path, &["dependencies"]);
};
let whitelist = whitelist.as_array().ok_or_else(|| {
Error::Vendor(format!(
"{}: web-modules.webDependencies must be an array of dependency names",
path.display()
))
})?;
let deps = json.get("dependencies").and_then(|v| v.as_object());
let mut specs = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for entry in whitelist {
let Some(name) = entry.as_str() else {
return Err(Error::Vendor(format!(
"{}: web-modules.webDependencies entries must be strings",
path.display()
)));
};
if !seen.insert(name.to_string()) {
continue;
}
let value = deps
.and_then(|d| d.get(name))
.and_then(|v| v.as_str())
.ok_or_else(|| {
Error::Vendor(format!(
"{}: web-modules.webDependencies lists `{name}`, not found in dependencies",
path.display()
))
})?;
if let Some(spec) = dep_to_spec(name, value) {
specs.push(spec);
}
}
Ok(specs)
}
pub fn specs_from_package_json_sections(
path: &Path,
sections: &[&str],
) -> Result<Vec<PackageSpec>> {
let bytes = std::fs::read(path)?;
let json: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| Error::Vendor(format!("{}: {e}", path.display())))?;
let mut specs = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for section in sections {
let Some(deps) = json.get(*section).and_then(|v| v.as_object()) else {
continue;
};
for (name, value) in deps {
let Some(value) = value.as_str() else {
continue;
};
let Some(spec) = dep_to_spec(name, value) else {
continue;
};
if seen.insert(name.clone()) {
specs.push(spec);
}
}
}
Ok(specs)
}
fn dep_to_spec(name: &str, value: &str) -> Option<PackageSpec> {
if is_local_protocol(value) {
return None;
}
Some(match parse_github_dep(value) {
Some((repo, reference)) => PackageSpec::git(repo, reference),
None => PackageSpec::npm(name, value),
})
}
fn is_local_protocol(value: &str) -> bool {
["file:", "link:", "workspace:", "portal:"]
.iter()
.any(|p| value.starts_with(p))
}
pub fn read_package_json(path: &Path) -> Result<(Vec<PackageSpec>, Vec<Mount>)> {
let bytes = std::fs::read(path)?;
let json: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| Error::Vendor(format!("{}: {e}", path.display())))?;
let base = path.parent().unwrap_or_else(|| Path::new("."));
let mut specs = Vec::new();
let mut mounts = Vec::new();
let Some(deps) = json.get("dependencies").and_then(|v| v.as_object()) else {
return Ok((specs, mounts));
};
for (name, value) in deps {
let Some(value) = value.as_str() else {
continue;
};
if let Some(rel) = local_path_dep(value) {
mounts.push(
Mount::from_dir(base.join(rel))
.specifier(format!("{name}/"))
.url(format!("/{name}/")),
);
} else if let Some((repo, reference)) = parse_github_dep(value) {
specs.push(PackageSpec::git(repo, reference));
} else if !is_unsupported_protocol(value) {
specs.push(PackageSpec::npm(name.as_str(), value));
}
}
Ok((specs, mounts))
}
fn local_path_dep(value: &str) -> Option<&str> {
if let Some(rest) = value.strip_prefix("file:") {
Some(rest)
} else if let Some(rest) = value.strip_prefix("link:") {
Some(rest)
} else if value.starts_with("./") || value.starts_with("../") {
Some(value)
} else {
None
}
}
fn is_unsupported_protocol(value: &str) -> bool {
["workspace:", "portal:", "npm:"]
.iter()
.any(|p| value.starts_with(p))
}
fn parse_github_dep(value: &str) -> Option<(String, String)> {
let value = value.trim();
let value = value.strip_prefix("git+").unwrap_or(value);
let (locator, reference) = match value.split_once('#') {
Some((l, r)) => (l, r.to_string()),
None => (value, "HEAD".to_string()),
};
let path = if let Some(rest) = locator.strip_prefix("github:") {
rest
} else if let Some(idx) = locator.find("github.com") {
locator[idx + "github.com".len()..].trim_start_matches([':', '/'])
} else {
return None;
};
let path = path.trim_end_matches(".git").trim_matches('/');
let (owner, rest) = path.split_once('/')?;
let repo = rest.split('/').next().unwrap_or(rest);
if owner.is_empty() || repo.is_empty() {
return None;
}
Some((format!("{owner}/{repo}"), reference))
}
pub fn keep_browser_assets(rel: &str) -> Option<String> {
if rel
.split('/')
.any(|seg| matches!(seg, "src" | "node" | "development"))
{
return None;
}
(rel.ends_with(".js")
|| rel.ends_with(".mjs")
|| rel.ends_with(".css")
|| rel.ends_with(".scss"))
.then(|| rel.to_string())
}
pub fn vendor(vendor_dir: &Path, mount: &str, specs: &[PackageSpec]) -> Result<Importmap> {
vendor_inner(vendor_dir, mount, specs).map_err(|e| Error::Vendor(e.to_string()))
}
fn vendor_inner(
vendor_dir: &Path,
mount: &str,
specs: &[PackageSpec],
) -> std::result::Result<Importmap, Box<dyn std::error::Error>> {
let mount = mount.trim_end_matches('/');
let mut map = Importmap::new();
std::fs::create_dir_all(vendor_dir)?;
for spec in specs {
let dest_dir = match &spec.dest {
Some(d) if d.is_absolute() => d.clone(),
Some(d) => vendor_dir.join(d),
None => vendor_dir.join(&spec.dir),
};
let flat = spec.dir.replace('/', "_");
let marker = vendor_dir.join(format!(".{flat}.version"));
let (archive_url, cache_key, is_git) = match &spec.source {
Source::Npm { package, range } => {
let resolved = Registry::npm().resolve(package, &Range::parse(range)?)?;
(resolved.tarball_url, resolved.version.to_string(), false)
}
Source::Git {
owner,
repo,
reference,
} => (
download::github_archive_url(owner, repo, reference),
reference.clone(),
true,
),
};
if !is_up_to_date(&marker, &cache_key, &dest_dir, &spec.extract) {
let lock = vendor_dir.join(format!(".{flat}.lock"));
cache::with_lock(&lock)(|| -> std::result::Result<(), Box<dyn std::error::Error>> {
if is_up_to_date(&marker, &cache_key, &dest_dir, &spec.extract) {
return Ok(());
}
let bytes = download::fetch(&archive_url)?;
extract_archive(&bytes, is_git, &spec.extract, &dest_dir)?;
cache::write_marker(&marker, &cache_key)?;
Ok(())
})?;
}
for (specifier, url) in import_entries(spec, mount, &dest_dir) {
map.insert(specifier, url);
}
}
Ok(map)
}
fn is_up_to_date(marker: &Path, cache_key: &str, dest_dir: &Path, extract: &Extract) -> bool {
if !cache::marker_matches(marker, cache_key) {
return false;
}
match extract {
Extract::File { to, .. } => dest_dir.join(to).exists(),
_ => cache::dir_has_content(dest_dir),
}
}
fn extract_archive(
bytes: &[u8],
is_git: bool,
extract: &Extract,
dest: &Path,
) -> std::result::Result<(), Box<dyn std::error::Error>> {
if !matches!(extract, Extract::File { .. }) {
cache::clear_directory(dest)?;
}
if is_git {
fn strip_first(rel: &str) -> Option<&str> {
rel.split_once('/')
.map(|(_, rest)| rest)
.filter(|r| !r.is_empty())
}
match extract {
Extract::BrowserAssets => {
let keep = move |rel: &str| strip_first(rel).and_then(keep_browser_assets);
extract::zip(bytes, dest, None, extract::Select::Matching(&keep))?;
}
Extract::Full => {
let keep = move |rel: &str| strip_first(rel).map(str::to_string);
extract::zip(bytes, dest, None, extract::Select::Matching(&keep))?;
}
Extract::File { from, to } => {
let keep = move |rel: &str| {
strip_first(rel).and_then(|r| (r == from.as_str()).then(|| to.clone()))
};
extract::zip(bytes, dest, None, extract::Select::Matching(&keep))?;
}
Extract::Filter(f) => {
let f = *f;
let keep = move |rel: &str| strip_first(rel).and_then(f);
extract::zip(bytes, dest, None, extract::Select::Matching(&keep))?;
}
}
return Ok(());
}
let strip = Some("package/");
match extract {
Extract::BrowserAssets => {
extract::tar_gz(
bytes,
dest,
strip,
extract::Select::Files(&[("package.json", "package.json")]),
)?;
let pkg = PackageJson::from_path(&dest.join("package.json")).ok();
let keep = keep_for(pkg);
extract::tar_gz(bytes, dest, strip, extract::Select::Matching(&keep))?;
}
Extract::Full => {
extract::tar_gz(bytes, dest, strip, extract::Select::All)?;
}
Extract::File { from, to } => {
let files = [(from.as_str(), to.as_str())];
extract::tar_gz(bytes, dest, strip, extract::Select::Files(&files))?;
}
Extract::Filter(f) => {
let f = *f;
let keep = move |rel: &str| f(rel);
extract::tar_gz(bytes, dest, strip, extract::Select::Matching(&keep))?;
}
}
Ok(())
}
fn keep_for(pkg: Option<PackageJson>) -> impl Fn(&str) -> Option<String> {
let referenced = pkg
.as_ref()
.map(PackageJson::referenced_paths)
.unwrap_or_default();
let keep_manifest = pkg.is_some();
move |rel: &str| {
if keep_manifest && rel == "package.json" {
return Some(rel.to_string());
}
if referenced.iter().any(|target| path_covered(rel, target)) {
return Some(rel.to_string());
}
keep_browser_assets(rel)
}
}
fn path_covered(rel: &str, target: &str) -> bool {
if target.ends_with('/') {
rel.starts_with(target) && (rel.ends_with(".js") || rel.ends_with(".mjs"))
} else {
rel == target
}
}
fn import_entries(spec: &PackageSpec, mount: &str, dest_dir: &Path) -> Vec<(String, String)> {
match &spec.imports {
Imports::None => Vec::new(),
Imports::Explicit(list) => list
.iter()
.map(|(specifier, path)| {
(
specifier.clone(),
format!("{mount}/{}/{}", spec.dir, path.trim_start_matches('/')),
)
})
.collect(),
Imports::Auto => {
let pkg = PackageJson::from_path(&dest_dir.join("package.json")).ok();
auto_entries(
pkg.as_ref(),
source_name(&spec.source),
&spec.dir,
mount,
dest_dir,
)
}
}
}
fn source_name(source: &Source) -> &str {
match source {
Source::Npm { package, .. } => package,
Source::Git { repo, .. } => repo,
}
}
fn auto_entries(
pkg: Option<&PackageJson>,
package: &str,
dir: &str,
mount: &str,
pkg_dir: &Path,
) -> Vec<(String, String)> {
let Some(pkg) = pkg else {
return Vec::new();
};
let mut out = Vec::new();
let (mut has_bare, mut has_prefix) = (false, false);
for entry in pkg.entries() {
match entry {
Entry::Bare(target) => {
if pkg_dir.join(&target).is_file() {
out.push((package.to_string(), join(mount, dir, &target)));
has_bare = true;
}
}
Entry::Subpath { subpath, target } => {
if target != subpath && pkg_dir.join(&target).is_file() {
out.push((format!("{package}/{subpath}"), join(mount, dir, &target)));
}
}
Entry::Prefix { subpath, dir: tdir } => {
out.push((format!("{package}/{subpath}"), join(mount, dir, &tdir)));
has_prefix = true;
}
}
}
if has_bare && !has_prefix {
out.push((format!("{package}/"), format!("{mount}/{dir}/")));
}
out
}
fn join(mount: &str, dir: &str, path: &str) -> String {
format!("{mount}/{dir}/{}", path.trim_start_matches('/'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keep_filter_picks_browser_assets() {
assert_eq!(keep_browser_assets("index.js").as_deref(), Some("index.js"));
assert_eq!(
keep_browser_assets("dist/foo.mjs").as_deref(),
Some("dist/foo.mjs")
);
assert_eq!(
keep_browser_assets("scss/bootstrap.scss").as_deref(),
Some("scss/bootstrap.scss")
);
assert!(keep_browser_assets("src/index.ts").is_none());
assert!(keep_browser_assets("development/dev.js").is_none());
assert!(keep_browser_assets("README.md").is_none());
}
#[test]
fn keep_for_keeps_exports_targets_even_under_src() {
let pkg = PackageJson::from_json(
r#"{"type":"commonjs","exports":{
"./helpers/decorate":{"import":"./src/helpers/esm/decorate.js"},
"./helpers/extends":{"import":"./src/helpers/esm/extends.js"}
}}"#,
)
.unwrap();
let keep = keep_for(Some(pkg));
assert_eq!(
keep("src/helpers/esm/decorate.js").as_deref(),
Some("src/helpers/esm/decorate.js")
);
assert_eq!(keep("package.json").as_deref(), Some("package.json"));
assert!(keep("src/index.ts").is_none());
}
#[test]
fn auto_entries_lit_like_is_bare_plus_prefix() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("index.js"), "export {}").unwrap();
let pkg = PackageJson::from_json(
r#"{"exports":{".":{"default":"./index.js"},"./decorators.js":{"default":"./decorators.js"}}}"#,
)
.unwrap();
let entries = auto_entries(Some(&pkg), "lit", "lit", "/web_modules", dir.path());
assert!(entries.contains(&("lit".into(), "/web_modules/lit/index.js".into())));
assert!(entries.contains(&("lit/".into(), "/web_modules/lit/".into())));
assert!(!entries.iter().any(|(s, _)| s == "lit/decorators.js"));
}
#[test]
fn auto_entries_maps_remapped_subpaths() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src/helpers/esm")).unwrap();
std::fs::write(dir.path().join("src/helpers/esm/decorate.js"), "export {}").unwrap();
let pkg = PackageJson::from_json(
r#"{"type":"commonjs","exports":{"./helpers/decorate":{"import":"./src/helpers/esm/decorate.js"}}}"#,
)
.unwrap();
let entries = auto_entries(
Some(&pkg),
"@oxc-project/runtime",
"@oxc-project/runtime",
"/web_modules",
dir.path(),
);
assert!(entries.contains(&(
"@oxc-project/runtime/helpers/decorate".into(),
"/web_modules/@oxc-project/runtime/src/helpers/esm/decorate.js".into(),
)));
assert!(!entries.iter().any(|(s, _)| s == "@oxc-project/runtime"));
assert!(!entries.iter().any(|(s, _)| s == "@oxc-project/runtime/"));
}
#[test]
fn auto_entries_none_is_empty() {
let dir = tempfile::tempdir().unwrap();
assert!(auto_entries(None, "d3", "d3", "/web_modules", dir.path()).is_empty());
}
#[test]
fn explicit_imports_are_rooted_at_mount_dir() {
let spec = PackageSpec::npm("jose", "^5").imports([("jose", "index.js"), ("jose/", "")]);
let entries = import_entries(&spec, "/web_modules", Path::new("/nonexistent"));
assert!(entries.contains(&("jose".into(), "/web_modules/jose/index.js".into())));
assert!(entries.contains(&("jose/".into(), "/web_modules/jose/".into())));
}
#[test]
fn no_imports_yields_no_entries() {
let spec = PackageSpec::npm("bootstrap", "^5")
.extract(Extract::Full)
.no_imports();
assert!(import_entries(&spec, "/web_modules", Path::new("/x")).is_empty());
}
#[test]
fn git_spec_defaults() {
let spec = PackageSpec::git("feathericons/feather", "v4.29.2");
assert_eq!(spec.dir, "feather");
assert!(matches!(spec.imports, Imports::None));
match spec.source {
Source::Git {
owner,
repo,
reference,
} => {
assert_eq!(
(owner.as_str(), repo.as_str(), reference.as_str()),
("feathericons", "feather", "v4.29.2")
);
}
_ => panic!("expected git source"),
}
}
#[test]
fn specs_from_package_json_reads_dependencies_only() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("package.json");
std::fs::write(
&p,
r#"{
"dependencies": {
"lit": "^3",
"feather": "github:feathericons/feather#v4.29.2",
"local": "file:../x"
},
"devDependencies": { "typescript": "^5" }
}"#,
)
.unwrap();
let specs = specs_from_package_json(&p).unwrap();
let names: Vec<&str> = specs.iter().map(PackageSpec::name).collect();
assert!(names.contains(&"lit"));
assert!(names.contains(&"feather"));
assert!(!names.contains(&"local"), "file: protocol skipped");
assert!(!names.contains(&"typescript"), "devDependencies not vended");
let lit = specs.iter().find(|s| s.name() == "lit").unwrap();
match &lit.source {
Source::Npm { range, .. } => assert_eq!(range, "^3", "range preserved verbatim"),
_ => panic!("lit should be an npm source"),
}
let feather = specs.iter().find(|s| s.name() == "feather").unwrap();
assert!(matches!(feather.source, Source::Git { .. }));
}
#[test]
fn sections_can_opt_into_devdependencies() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("package.json");
std::fs::write(
&p,
r#"{"dependencies":{"lit":"^3"},"devDependencies":{"typescript":"^5"}}"#,
)
.unwrap();
let specs =
specs_from_package_json_sections(&p, &["dependencies", "devDependencies"]).unwrap();
let names: Vec<&str> = specs.iter().map(PackageSpec::name).collect();
assert!(names.contains(&"lit") && names.contains(&"typescript"));
}
#[test]
fn web_dependencies_whitelist_narrows_to_named_subset() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("package.json");
std::fs::write(
&p,
r#"{
"dependencies": { "lit": "^3", "lit-html": "^3", "pg": "^8" },
"web-modules": { "webDependencies": ["lit", "lit-html"] }
}"#,
)
.unwrap();
let specs = specs_from_package_json(&p).unwrap();
let names: Vec<&str> = specs.iter().map(PackageSpec::name).collect();
assert_eq!(names, vec!["lit", "lit-html"], "whitelist order preserved");
assert!(
!names.contains(&"pg"),
"server-only dep left out of the browser vend"
);
}
#[test]
fn web_dependencies_whitelist_naming_a_missing_dep_errors() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("package.json");
std::fs::write(
&p,
r#"{"dependencies":{"lit":"^3"},"web-modules":{"webDependencies":["lit","nope"]}}"#,
)
.unwrap();
let Err(err) = specs_from_package_json(&p) else {
panic!("expected an error for the missing dep");
};
assert!(
err.to_string().contains("nope"),
"error names the missing dep: {err}"
);
}
#[test]
fn empty_web_dependencies_vends_nothing() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("package.json");
std::fs::write(
&p,
r#"{"dependencies":{"lit":"^3"},"web-modules":{"webDependencies":[]}}"#,
)
.unwrap();
assert!(specs_from_package_json(&p).unwrap().is_empty());
}
#[test]
fn web_dependencies_must_be_an_array() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("package.json");
std::fs::write(
&p,
r#"{"dependencies":{"lit":"^3"},"web-modules":{"webDependencies":{"lit":"^3"}}}"#,
)
.unwrap();
assert!(specs_from_package_json(&p).is_err(), "object form rejected");
}
#[test]
fn parse_github_dep_handles_shorthand_and_urls() {
assert_eq!(
parse_github_dep("github:owner/repo#v1").unwrap(),
("owner/repo".to_string(), "v1".to_string())
);
assert_eq!(
parse_github_dep("git+https://github.com/owner/repo.git#abc123").unwrap(),
("owner/repo".to_string(), "abc123".to_string())
);
assert_eq!(
parse_github_dep("github:owner/repo").unwrap(),
("owner/repo".to_string(), "HEAD".to_string())
);
assert!(parse_github_dep("^3").is_none());
}
#[test]
fn read_package_json_splits_registry_and_path_deps() {
let tmp = tempfile::tempdir().unwrap();
let sib = tmp.path().join("sib");
std::fs::create_dir_all(sib.join("pub")).unwrap();
std::fs::write(
sib.join("package.json"),
r#"{"name":"sibling","web-modules":{"root":"./pub"}}"#,
)
.unwrap();
let p = tmp.path().join("package.json");
std::fs::write(
&p,
r#"{"dependencies":{"lit":"^3","sib":"file:./sib","ws":"workspace:*"}}"#,
)
.unwrap();
let (specs, mounts) = read_package_json(&p).unwrap();
assert_eq!(
specs.iter().map(PackageSpec::name).collect::<Vec<_>>(),
vec!["lit"]
);
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].specifier_prefix(), "sib/");
assert_eq!(mounts[0].url_prefix(), "/sib/");
assert_eq!(mounts[0].dir(), sib.join("pub"));
}
#[test]
#[ignore = "network: resolves and downloads from the npm registry"]
fn vendors_lit_end_to_end_auto() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("web_modules");
let specs = [PackageSpec::npm("lit", "^3")];
let map = vendor(&root, "/web_modules", &specs).unwrap();
assert!(root.join("lit/index.js").exists(), "lit entry vendored");
let json = map.to_json();
assert!(json.contains("\"lit\": \"/web_modules/lit/index.js\""));
assert!(json.contains("\"lit/\": \"/web_modules/lit/\""));
vendor(&root, "/web_modules", &specs).unwrap();
}
}