use std::path::{Path, PathBuf};
use crate::vendor::{self, PackageSpec};
use crate::{Error, Result};
pub struct BuildOptions<'a> {
pub specs: &'a [PackageSpec],
pub roots: &'a [PathBuf],
pub out: &'a Path,
pub mount: &'a str,
pub html: &'a str,
pub template: Option<&'a Path>,
pub processors: Processors,
pub output: Output,
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Processors {
pub typescript: bool,
pub scss: bool,
pub tera: bool,
pub ts_decorators: crate::Decorators,
pub extra_scss_load_paths: Vec<PathBuf>,
}
impl Default for Processors {
fn default() -> Self {
Self {
typescript: true,
scss: true,
tera: true,
ts_decorators: crate::Decorators::Lit,
extra_scss_load_paths: Vec::new(),
}
}
}
#[derive(Clone, Copy, Debug, Default)]
#[non_exhaustive]
pub struct Output {
pub minify: bool,
pub gzip: bool,
}
impl Output {
pub fn new(minify: bool, gzip: bool) -> Self {
Self { minify, gzip }
}
pub fn optimized() -> Self {
Self::new(true, true)
}
}
fn rerun_if_changed(path: &Path) {
if std::env::var_os("OUT_DIR").is_some() {
println!("cargo:rerun-if-changed={}", path.display());
}
}
pub fn build(opts: &BuildOptions<'_>) -> Result<()> {
std::fs::create_dir_all(opts.out)?;
let mut importmap = if opts.specs.is_empty() {
crate::importmap::Importmap::new()
} else {
vendor::vendor(&opts.out.join("web_modules"), opts.mount, opts.specs)?
};
#[cfg(feature = "scss")]
let load_paths: Vec<&Path> = {
let mut paths: Vec<&Path> = opts.roots.iter().map(PathBuf::as_path).collect();
paths.push(opts.out);
paths.extend(
opts.processors
.extra_scss_load_paths
.iter()
.map(PathBuf::as_path),
);
paths
};
#[cfg(feature = "typescript")]
let transpile = crate::typescript::TranspileOptions {
minify: opts.output.minify,
decorators: opts.processors.ts_decorators,
..Default::default()
};
for root in opts.roots.iter().rev() {
#[cfg(feature = "typescript")]
if opts.processors.typescript {
crate::typescript::compile_directory_with(root, opts.out, &transpile)?;
}
#[cfg(feature = "scss")]
if opts.processors.scss {
crate::scss::compile_directory(root, opts.out, &load_paths)?;
}
crate::static_files::copy_static(root, opts.out)?;
}
importmap.extend(vendor_transform_runtime(opts.out, opts.mount)?);
let unresolved = unresolved_imports(opts.out, &importmap)?;
if !unresolved.is_empty() {
let details = unresolved
.iter()
.map(|(file, spec)| format!(" {file}: import \"{spec}\""))
.collect::<Vec<_>>()
.join("\n");
return Err(Error::Build(format!(
"web-modules: {} unresolved bare import(s) - add them to the vendored \
specs / import map:\n{details}",
unresolved.len()
)));
}
importmap.write_to(&opts.out.join("importmap.json"))?;
#[cfg(feature = "tera")]
if opts.processors.tera {
for root in opts.roots.iter().rev() {
render_tera_tree(root, opts.out, &importmap)?;
}
}
let tera_on = cfg!(feature = "tera") && opts.processors.tera;
let tree_provides_index = opts.roots.iter().any(|root| {
root.join("index.html").exists() || (tera_on && root.join("index.html.tera").exists())
});
if !tree_provides_index {
let html = match opts.template {
Some(template) => {
rerun_if_changed(template);
render_template(template, &importmap)?
}
None => opts.html.replace("{importmap}", &importmap.to_script_tag()),
};
std::fs::write(opts.out.join("index.html"), html)?;
}
#[cfg(feature = "compress")]
if opts.output.gzip {
crate::compress::gzip_dir(opts.out, &["js", "css", "html", "json", "svg"])?;
}
for root in opts.roots {
for entry in walkdir::WalkDir::new(root)
.into_iter()
.filter_map(|e| e.ok())
{
rerun_if_changed(entry.path());
}
}
Ok(())
}
fn module_specifiers(js: &str) -> Vec<String> {
const PATTERNS: &[&str] = &[
"from \"",
"from '",
"import \"",
"import '",
"import(\"",
"import('",
];
let mut specs = Vec::new();
for pat in PATTERNS {
let Some(quote) = pat.chars().last() else {
continue;
};
let mut from = 0;
while let Some(p) = js[from..].find(pat) {
let start = from + p + pat.len();
match js[start..].find(quote) {
Some(end) => {
specs.push(js[start..start + end].to_string());
from = start + end + 1;
}
None => break,
}
}
}
specs
}
fn is_bare(spec: &str) -> bool {
!(spec.starts_with('.')
|| spec.starts_with('/')
|| spec.contains("://")
|| spec.starts_with("data:"))
}
fn unresolved_imports(
dir: &Path,
importmap: &crate::importmap::Importmap,
) -> Result<Vec<(String, String)>> {
let mut out = Vec::new();
for entry in walkdir::WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().and_then(|x| x.to_str()) != Some("js") {
continue;
}
if path.components().any(|c| c.as_os_str() == "web_modules") {
continue;
}
let js = std::fs::read_to_string(path)?;
for spec in module_specifiers(&js) {
if is_bare(&spec) && !importmap.resolves(&spec) {
let rel = path.strip_prefix(dir).unwrap_or(path).display().to_string();
out.push((rel, spec));
}
}
}
Ok(out)
}
const OXC_RUNTIME_RANGE: &str = "^0.137";
pub fn vendor_transform_runtime(out: &Path, mount: &str) -> Result<crate::importmap::Importmap> {
let mut uses_runtime = false;
for entry in walkdir::WalkDir::new(out)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().and_then(|x| x.to_str()) != Some("js") {
continue;
}
if path.components().any(|c| c.as_os_str() == "web_modules") {
continue;
}
let js = std::fs::read_to_string(path)?;
if module_specifiers(&js)
.iter()
.any(|s| is_bare(s) && s.starts_with("@oxc-project/runtime"))
{
uses_runtime = true;
break;
}
}
if !uses_runtime {
return Ok(crate::importmap::Importmap::new());
}
vendor::vendor(
&out.join("web_modules"),
mount,
&[PackageSpec::npm("@oxc-project/runtime", OXC_RUNTIME_RANGE)],
)
}
#[cfg(feature = "tera")]
fn render_tera_tree(
root: &Path,
out: &Path,
importmap: &crate::importmap::Importmap,
) -> Result<usize> {
let mut ctx = crate::templates::Context::new();
ctx.insert("importmap", &importmap.to_script_tag());
let mut count = 0;
for entry in walkdir::WalkDir::new(root)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
let is_tera = path
.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| x.eq_ignore_ascii_case("tera"));
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !is_tera || name.starts_with('_') {
continue;
}
let rel = path
.strip_prefix(root)
.map_err(|e| Error::Template(e.to_string()))?;
let dest = out.join(rel).with_extension("");
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let html = crate::templates::render_file(path, &ctx)?;
std::fs::write(&dest, html)?;
count += 1;
}
Ok(count)
}
#[cfg(feature = "tera")]
fn render_template(template: &Path, importmap: &crate::importmap::Importmap) -> Result<String> {
let mut ctx = crate::templates::Context::new();
ctx.insert("importmap", &importmap.to_script_tag());
crate::templates::render_file(template, &ctx)
}
#[cfg(not(feature = "tera"))]
fn render_template(_template: &Path, _importmap: &crate::importmap::Importmap) -> Result<String> {
Err(Error::Build(
"rendering a `template` requires the `tera` feature".into(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::importmap::Importmap;
#[test]
fn output_optimized_enables_both() {
let o = Output::optimized();
assert!(o.minify && o.gzip, "the production preset turns both on");
}
#[test]
fn scanner_and_bare_classification() {
let js = "import { a } from \"lit\";\n\
import _d from \"@oxc-project/runtime/helpers/decorate\";\n\
import \"./local.js\";\n\
const m = import(\"bootstrap\");";
let specs = module_specifiers(js);
assert!(specs.contains(&"lit".to_string()));
assert!(specs.contains(&"@oxc-project/runtime/helpers/decorate".to_string()));
assert!(specs.contains(&"./local.js".to_string()));
assert!(specs.contains(&"bootstrap".to_string()));
assert!(is_bare("lit") && is_bare("@oxc-project/runtime/helpers/decorate"));
assert!(!is_bare("./local.js") && !is_bare("/x.js") && !is_bare("https://h/y.js"));
}
#[test]
fn vendor_transform_runtime_is_noop_without_helpers() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("app.js"),
"import { LitElement } from \"lit\";",
)
.unwrap();
let map = vendor_transform_runtime(dir.path(), "/web_modules").unwrap();
assert!(!map.resolves("@oxc-project/runtime/helpers/decorate"));
}
#[test]
#[ignore = "network: downloads @oxc-project/runtime from the npm registry"]
fn vendor_transform_runtime_resolves_the_decorator_helper() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("el.js"),
"import _d from \"@oxc-project/runtime/helpers/decorate\";",
)
.unwrap();
let map = vendor_transform_runtime(dir.path(), "/web_modules").unwrap();
assert!(map.resolves("@oxc-project/runtime/helpers/decorate"));
assert!(dir
.path()
.join("web_modules/@oxc-project/runtime/src/helpers/esm/decorate.js")
.is_file());
}
#[test]
fn flags_unresolved_app_import_but_skips_vendored() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("app.js"),
"import { LitElement } from \"lit\";\n\
import _d from \"@oxc-project/runtime/helpers/decorate\";",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("web_modules/lit")).unwrap();
std::fs::write(dir.path().join("web_modules/lit/index.js"), "import \"x\";").unwrap();
let mut map = Importmap::new();
map.insert("lit", "/web_modules/lit/index.js");
let unresolved = unresolved_imports(dir.path(), &map).unwrap();
assert_eq!(
unresolved.len(),
1,
"only the helper import is unresolved; got {unresolved:?}"
);
assert!(unresolved[0].1.starts_with("@oxc-project/runtime"));
}
#[cfg(feature = "tera")]
#[test]
fn template_renders_importmap_variable() {
let dir = tempfile::tempdir().unwrap();
let tpl = dir.path().join("index.html.tera");
std::fs::write(&tpl, "<head>{{ importmap | safe }}</head>").unwrap();
let mut map = Importmap::new();
map.insert("lit", "/web_modules/lit/index.js");
let html = render_template(&tpl, &map).unwrap();
assert!(html.contains("<script type=\"importmap\">"));
assert!(html.contains("/web_modules/lit/index.js"));
}
fn opts<'a>(roots: &'a [PathBuf], out: &'a Path) -> BuildOptions<'a> {
BuildOptions {
specs: &[],
roots,
out,
mount: "/web_modules",
html: "<!doctype html>FALLBACK{importmap}",
template: None,
processors: Processors::default(),
output: Output::default(),
}
}
#[test]
fn build_skips_vendor_when_specs_empty() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
let out = dir.path().join("out");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("page.html"), "<p>hi</p>").unwrap();
build(&opts(std::slice::from_ref(&src), &out)).unwrap();
assert!(
!out.join("web_modules").exists(),
"no specs ⇒ no vendoring, so no web_modules/ dir"
);
assert!(out.join("page.html").exists(), "static file copied through");
assert!(
out.join("importmap.json").exists(),
"empty import map emitted"
);
assert!(
out.join("index.html").exists(),
"fallback index.html written"
);
}
#[cfg(feature = "scss")]
#[test]
fn build_without_typescript_skips_ts_and_compiles_scss() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
let out = dir.path().join("out");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("app.ts"), "export const x: number = 1;").unwrap();
std::fs::write(src.join("styles.scss"), "a { b { color: red } }").unwrap();
let mut o = opts(std::slice::from_ref(&src), &out);
o.processors = Processors {
typescript: false,
..Processors::default()
};
build(&o).unwrap();
assert!(out.join("styles.css").exists(), "SCSS still compiled");
assert!(!out.join("app.js").exists(), "no JS emitted with TS off");
assert!(!out.join("app.ts").exists(), "`.ts` source not copied raw");
}
#[cfg(feature = "tera")]
#[test]
fn build_renders_tera_tree() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
let out = dir.path().join("out");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
src.join("index.html.tera"),
"<head>{{ importmap | safe }}</head>",
)
.unwrap();
std::fs::write(src.join("_partial.html.tera"), "PARTIAL").unwrap();
build(&opts(std::slice::from_ref(&src), &out)).unwrap();
let index = std::fs::read_to_string(out.join("index.html")).unwrap();
assert!(
index.contains("<script type=\"importmap\">"),
"the `.tera` rendered with the importmap var; got:\n{index}"
);
assert!(
!index.contains("FALLBACK"),
"an in-tree index.html.tera wins over the --html fallback"
);
assert!(
!out.join("_partial.html").exists(),
"`_`-prefixed partials are not emitted"
);
}
#[cfg(feature = "tera")]
#[test]
fn build_first_root_wins() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
let out = dir.path().join("out");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(a.join("index.html.tera"), "FROM_A").unwrap();
std::fs::write(b.join("index.html.tera"), "FROM_B").unwrap();
let roots = vec![a, b];
build(&opts(&roots, &out)).unwrap();
let index = std::fs::read_to_string(out.join("index.html")).unwrap();
assert!(
index.contains("FROM_A"),
"first root wins a conflict; got: {index}"
);
}
#[test]
fn unresolved_bare_import_errors_without_vendoring() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
let out = dir.path().join("out");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
src.join("app.ts"),
"import { LitElement } from \"lit\";\nexport class X extends LitElement {}",
)
.unwrap();
let err = build(&opts(std::slice::from_ref(&src), &out)).unwrap_err();
assert!(
matches!(err, Error::Build(_)),
"an unvendored bare import is a build error; got {err:?}"
);
}
#[test]
fn build_fallback_index_refreshes_on_rebuild() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
let out = dir.path().join("out");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("data.json"), "{}").unwrap();
let mut o = opts(std::slice::from_ref(&src), &out);
o.html = "<!doctype html>ONE";
build(&o).unwrap();
assert!(std::fs::read_to_string(out.join("index.html"))
.unwrap()
.contains("ONE"));
o.html = "<!doctype html>TWO";
build(&o).unwrap();
let index = std::fs::read_to_string(out.join("index.html")).unwrap();
assert!(
index.contains("TWO") && !index.contains("ONE"),
"fallback index.html must refresh on rebuild; got:\n{index}"
);
}
#[cfg(feature = "tera")]
#[test]
fn build_tera_wins_over_literal_same_target() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
let out = dir.path().join("out");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("index.html"), "LITERAL").unwrap();
std::fs::write(src.join("index.html.tera"), "TERA").unwrap();
build(&opts(std::slice::from_ref(&src), &out)).unwrap();
let index = std::fs::read_to_string(out.join("index.html")).unwrap();
assert!(
index.contains("TERA") && !index.contains("LITERAL"),
"the .tera overlays the literal same-target; got:\n{index}"
);
}
}