use std::path::{Path, PathBuf};
use super::steps;
use crate::vendor::{self, PackageSpec};
use crate::{Error, Result};
pub const DEFAULT_HTML: &str =
"<!doctype html>{importmap}<script type=module src=./app.js></script>";
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>,
pub reject: crate::reject::Reject,
pub symlinks: crate::SymlinkMode,
pub skip_duplicates: bool,
}
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(),
reject: crate::reject::Reject::all(),
symlinks: crate::SymlinkMode::default(),
skip_duplicates: false,
}
}
}
#[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)
}
}
const GZIP_EXTS: [&str; 5] = ["js", "css", "html", "json", "svg"];
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<()> {
let out = std::path::absolute(opts.out)?;
ensure_replaceable(&out)?;
let stage = sibling(&out, "stage")?;
let old = sibling(&out, "old")?;
remove_dir_all_if_present(&stage)?;
remove_dir_all_if_present(&old)?;
std::fs::create_dir_all(&stage)?;
if let Err(e) = build_into(&stage, &out, opts) {
let _ = std::fs::remove_dir_all(&stage);
return Err(e);
}
if out.exists() {
std::fs::rename(&out, &old)?;
}
std::fs::rename(&stage, &out)?;
remove_dir_all_if_present(&old)?;
Ok(())
}
const OUT_MARKER: &str = ".web-modules-out";
const OXC_RUNTIME_PACKAGE: &str = "@oxc-project/runtime";
fn ensure_replaceable(out: &Path) -> Result<()> {
match std::fs::metadata(out) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
Ok(meta) if meta.is_dir() => {}
Ok(_) => {
return Err(Error::Build(format!(
"web-modules: --out {} exists and is not a directory",
out.display()
)))
}
}
if out.join(OUT_MARKER).exists() || std::fs::read_dir(out)?.next().is_none() {
return Ok(());
}
Err(Error::Build(format!(
"web-modules: {} contains files web-modules did not produce - refusing to \
replace it; pass an absent or empty --out, or delete the directory once \
(each build marks its output with {OUT_MARKER})",
out.display()
)))
}
fn sibling(dir: &Path, suffix: &str) -> Result<PathBuf> {
let name = dir.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
Error::Build(format!(
"web-modules: --out {} has no usable directory name",
dir.display()
))
})?;
Ok(dir.with_file_name(format!(".{name}.web-modules-{suffix}")))
}
fn remove_dir_all_if_present(dir: &Path) -> Result<()> {
match std::fs::remove_dir_all(dir) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
fn seed_vendor_cache(previous: &Path, stage: &Path) -> Result<()> {
if !previous.is_dir() {
return Ok(());
}
for entry in walkdir::WalkDir::new(previous) {
let Ok(entry) = entry else { continue };
let path = entry.path();
let Ok(rel) = path.strip_prefix(previous) else {
continue;
};
let dest = stage.join(rel);
if entry.file_type().is_dir() {
std::fs::create_dir_all(&dest)?;
} else if entry.file_type().is_file() {
let dotted = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with('.'));
if dotted || std::fs::hard_link(path, &dest).is_err() {
std::fs::copy(path, &dest)?;
}
}
}
Ok(())
}
fn build_into(stage: &Path, previous: &Path, opts: &BuildOptions<'_>) -> Result<()> {
std::fs::write(
stage.join(OUT_MARKER),
concat!(env!("CARGO_PKG_VERSION"), "\n"),
)?;
#[cfg(feature = "typescript")]
let transpile = crate::typescript::TranspileOptions {
minify: opts.output.minify,
decorators: opts.processors.ts_decorators,
..Default::default()
};
#[cfg(feature = "scss")]
let scss_load_paths: Vec<PathBuf> = {
let mut paths: Vec<PathBuf> = opts.roots.to_vec();
paths.push(stage.to_path_buf());
paths.extend(opts.processors.extra_scss_load_paths.iter().cloned());
paths
};
let steps = steps::enabled_steps(
&opts.processors,
steps::StepConfig {
#[cfg(feature = "typescript")]
transpile,
#[cfg(feature = "scss")]
scss_load_paths,
},
);
let preflights: Vec<&dyn steps::Preflight> = steps
.iter()
.map(|step| step.as_ref() as &dyn steps::Preflight)
.collect();
let report = steps::preflight(
opts.roots,
&preflights,
steps::WalkPolicy {
reject: &opts.processors.reject,
symlinks: opts.processors.symlinks,
},
);
for error in report.walk_errors() {
crate::static_files::build_warning(&format!("web-modules: preflight: {error}"));
}
for skipped in report.skipped_symlinks() {
crate::static_files::build_warning(&format!(
"web-modules: {}: symlink skipped - a static build cannot express a redirect",
opts.roots[skipped.root].join(&skipped.rel).display()
));
}
for rejected in report.rejected_claims() {
eprintln!("web-modules: {}", rejected.describe(opts.roots));
}
let escaping_sources = report.escaping_sources();
if !escaping_sources.is_empty() {
let lines = escaping_sources
.iter()
.map(|source| {
format!(
" {} -> {}",
opts.roots[source.root].join(&source.rel).display(),
source.target.display(),
)
})
.collect::<Vec<_>>()
.join("\n");
return Err(Error::Build(format!(
"web-modules: {} source path(s) resolve outside the source root - remove \
the symlink or move the files into the tree:\n{lines}",
escaping_sources.len()
)));
}
let escaping = report.escaping();
if !escaping.is_empty() {
let lines = escaping
.iter()
.map(|claim| {
format!(
" {} (from {}, {})",
claim.out_rel.display(),
opts.roots[claim.root].join(&claim.rel).display(),
steps[claim.step].name(),
)
})
.collect::<Vec<_>>()
.join("\n");
return Err(Error::Build(format!(
"web-modules: {} output path(s) would escape the output directory:\n{lines}",
escaping.len()
)));
}
let conflicts = report.conflicts();
if !conflicts.is_empty() && !opts.processors.skip_duplicates {
let mut lines = Vec::new();
for conflict in &conflicts {
lines.push(format!(" {}:", conflict.out_rel.display()));
for (i, claim) in conflict.claimants.iter().enumerate() {
let wins = if i == 0 {
" - wins with --skip-duplicates"
} else {
""
};
lines.push(format!(
" {} ({}){wins}",
opts.roots[claim.root].join(&claim.rel).display(),
steps[claim.step].name(),
));
}
}
let noun = if conflicts.len() == 1 {
"path is"
} else {
"paths are"
};
return Err(Error::Build(format!(
"web-modules: {} output {noun} claimed by more than one source - remove \
the duplicates or pass --skip-duplicates:\n{}",
conflicts.len(),
lines.join("\n")
)));
}
let winners = report.winners();
let violations: Vec<String> = winners
.iter()
.filter_map(|winner| {
let out_rel = winner.out_rel.as_path();
let reserved_for = if out_rel == Path::new("importmap.json") {
Some("the generated import map".to_string())
} else if out_rel == Path::new(OUT_MARKER) {
Some("the output marker".to_string())
} else if out_rel.starts_with("web_modules") {
Some("the vendored modules directory (web_modules/)".to_string())
} else if cfg!(feature = "compress")
&& opts.output.gzip
&& out_rel.extension().and_then(|e| e.to_str()) == Some("gz")
{
let inner = out_rel.with_extension("");
let eligible = inner
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| GZIP_EXTS.contains(&e));
let written = report.claims_target(&inner)
|| inner == Path::new("importmap.json")
|| inner == Path::new("index.html");
(eligible && written).then(|| format!("the gzip sidecar of {}", inner.display()))
} else {
None
};
reserved_for.map(|what| {
format!(
" {} ({}) claims {} - reserved for {what}",
opts.roots[winner.root].join(&winner.rel).display(),
steps[winner.step].name(),
out_rel.display(),
)
})
})
.collect();
if !violations.is_empty() {
return Err(Error::Build(format!(
"web-modules: {} output path(s) are reserved for generated files:\n{}",
violations.len(),
violations.join("\n")
)));
}
seed_vendor_cache(&previous.join("web_modules"), &stage.join("web_modules"))?;
let mut importmap = if opts.specs.is_empty() {
crate::importmap::Importmap::new()
} else {
vendor::vendor(&stage.join("web_modules"), opts.mount, opts.specs)?
};
let mut graph = crate::module_graph::ModuleGraph::new();
let mut tera_winners = Vec::new();
for winner in winners {
if steps[winner.step].rank() == steps::Rank::Tera {
tera_winners.push(winner);
continue;
}
emit_winner(&steps, winner, opts, stage, &importmap, &mut graph)?;
}
for asset in report.npm_assets() {
let link = opts.roots[asset.root].join(&asset.rel);
let from_dir = link
.parent()
.map(std::fs::canonicalize)
.transpose()?
.unwrap_or_else(|| opts.roots[asset.root].clone());
let reference = crate::npm_link::parse(&asset.target).ok_or_else(|| {
Error::Build(format!(
"{}: malformed npm:// target {:?}",
link.display(),
asset.target
))
})?;
for (sub, source) in crate::npm_link::resolve(&from_dir, &reference)? {
let out_rel = if sub.as_os_str().is_empty() {
asset.rel.clone()
} else {
asset.rel.join(&sub)
};
if report.claims_target(&out_rel)
|| out_rel == Path::new("importmap.json")
|| out_rel.starts_with("web_modules")
{
return Err(Error::Build(format!(
"web-modules: {} (npm://{}) resolves onto {}, which the build already produces",
link.display(),
reference.package,
out_rel.display()
)));
}
let dest = stage.join(&out_rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&source, &dest)?;
}
}
let runtime_vendored = graph.uses_runtime_helpers();
if runtime_vendored {
importmap.extend(vendor_transform_runtime(stage, opts.mount)?);
}
let extra_vendored: &[&str] = if runtime_vendored {
&[OXC_RUNTIME_PACKAGE]
} else {
&[]
};
vendor::prune(&stage.join("web_modules"), opts.specs, extra_vendored)?;
importmap.write_to(&stage.join("importmap.json"))?;
for winner in tera_winners {
emit_winner(&steps, winner, opts, stage, &importmap, &mut graph)?;
}
if !report.claims_target("index.html") {
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(stage.join("index.html"), html)?;
}
let unresolved = graph.unresolved(&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()
)));
}
#[cfg(feature = "compress")]
if opts.output.gzip {
crate::compress::gzip_dir(stage, &GZIP_EXTS)?;
}
for path in report.walked_paths() {
rerun_if_changed(path);
}
Ok(())
}
fn emit_winner(
steps: &[Box<dyn steps::Step>],
winner: &steps::ClaimRecord,
opts: &BuildOptions<'_>,
out: &Path,
importmap: &crate::importmap::Importmap,
graph: &mut crate::module_graph::ModuleGraph,
) -> Result<()> {
let src = opts.roots[winner.root].join(&winner.rel);
let dest = out.join(&winner.out_rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let emitted =
steps[winner.step].emit(&steps::EmitCx { importmap }, &src, &winner.rel, &dest)?;
if let Some(imports) = emitted.imports {
graph.insert(winner.out_rel.clone(), imports);
}
Ok(())
}
const OXC_RUNTIME_RANGE: &str = "^0.137";
pub fn vendor_transform_runtime(out: &Path, mount: &str) -> Result<crate::importmap::Importmap> {
vendor::vendor(
&out.join("web_modules"),
mount,
&[PackageSpec::npm(OXC_RUNTIME_PACKAGE, OXC_RUNTIME_RANGE)],
)
}
#[cfg(feature = "tera")]
fn render_template(template: &Path, importmap: &crate::importmap::Importmap) -> Result<String> {
let ctx = crate::templates::importmap_context(importmap);
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]
#[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());
}
#[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(),
}
}
fn opts_skip<'a>(roots: &'a [PathBuf], out: &'a Path) -> BuildOptions<'a> {
let mut o = opts(roots, out);
o.processors.skip_duplicates = true;
o
}
#[cfg(feature = "compress")]
#[test]
fn build_replaces_stale_outputs_across_rebuilds() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("web");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("app.ts"), "export const x: number = 1;").unwrap();
let out = dir.path().join("out");
let mut o = opts(std::slice::from_ref(&src), &out);
o.output = Output::new(false, true);
build(&o).unwrap();
assert!(out.join("app.js").exists());
assert!(out.join("app.js.gz").exists());
assert!(out.join(OUT_MARKER).exists(), "the output is marked");
std::fs::remove_file(src.join("app.ts")).unwrap();
std::fs::write(src.join("b.txt"), "fresh").unwrap();
build(&o).unwrap();
assert!(!out.join("app.js").exists(), "the stale module is gone");
assert!(!out.join("app.js.gz").exists(), "its sidecar too");
assert!(out.join("b.txt").exists(), "the fresh file shipped");
}
#[test]
fn build_refuses_a_foreign_output_directory() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("web");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("page.html"), "<p>hi</p>").unwrap();
let out = dir.path().join("out");
std::fs::create_dir_all(&out).unwrap();
std::fs::write(out.join("precious.txt"), "mine").unwrap();
let err = build(&opts(std::slice::from_ref(&src), &out))
.unwrap_err()
.to_string();
assert!(
err.contains("refusing to replace") && err.contains(OUT_MARKER),
"got {err}"
);
assert_eq!(
std::fs::read_to_string(out.join("precious.txt")).unwrap(),
"mine",
"the foreign directory is untouched"
);
}
#[test]
fn build_accepts_empty_and_previously_built_outputs() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("web");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("page.html"), "<p>hi</p>").unwrap();
let out = dir.path().join("out");
std::fs::create_dir_all(&out).unwrap();
let o = opts(std::slice::from_ref(&src), &out);
build(&o).unwrap();
build(&o).unwrap();
assert!(out.join("page.html").exists());
}
#[test]
fn build_failure_leaves_the_previous_output_intact() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("web");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("page.html"), "<p>v1</p>").unwrap();
let out = dir.path().join("out");
let o = opts(std::slice::from_ref(&src), &out);
build(&o).unwrap();
std::fs::write(src.join("page.html.tera"), "<p>v2</p>").unwrap();
build(&o).unwrap_err();
assert_eq!(
std::fs::read_to_string(out.join("page.html")).unwrap(),
"<p>v1</p>",
"the previous output survives a failed rebuild"
);
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| name.contains("web-modules-"))
.collect();
assert!(leftovers.is_empty(), "no stage/old residue: {leftovers:?}");
}
#[test]
fn build_preserves_the_vendor_cache_and_prunes_dropped_packages() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("web");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
src.join("el.ts"),
"function dec(_v: unknown, _c: unknown) {}\nclass El { @dec accessor x = 1; }\nexport { El };",
)
.unwrap();
let out = dir.path().join("out");
let o = opts(std::slice::from_ref(&src), &out);
build(&o).unwrap();
let helper_dir = out.join("web_modules/@oxc-project/runtime");
let marker = out.join("web_modules/.@oxc-project_runtime.version");
assert!(helper_dir.is_dir(), "runtime helpers vendored");
assert!(marker.is_file(), "cache marker written");
let marker_content = std::fs::read_to_string(&marker).unwrap();
std::fs::create_dir_all(out.join("web_modules/dropped")).unwrap();
std::fs::write(out.join("web_modules/dropped/x.js"), "export {};").unwrap();
std::fs::write(out.join("web_modules/.dropped.version"), "1.0.0").unwrap();
build(&o).unwrap();
assert!(helper_dir.is_dir(), "helpers survive the rebuild");
assert_eq!(
std::fs::read_to_string(&marker).unwrap(),
marker_content,
"the cache marker still matches - no re-vendor"
);
assert!(
!out.join("web_modules/dropped").exists(),
"the dropped package is pruned"
);
assert!(
!out.join("web_modules/.dropped.version").exists(),
"its marker too"
);
}
#[test]
fn build_reserves_the_generated_import_map() {
for name in ["importmap.json.tera", "importmap.json"] {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("web");
std::fs::create_dir_all(&src).unwrap();
let content = if name.ends_with(".tera") {
"{{ 1 }}"
} else {
"{}"
};
std::fs::write(src.join(name), content).unwrap();
let out = dir.path().join("out");
let err = build(&opts_skip(std::slice::from_ref(&src), &out))
.unwrap_err()
.to_string();
assert!(
err.contains("reserved") && err.contains("importmap.json"),
"{name}: got {err}"
);
let untouched = std::fs::read_dir(&out)
.map(|mut entries| entries.next().is_none())
.unwrap_or(true);
assert!(untouched, "{name}: nothing may be written");
}
}
#[test]
fn build_reserves_the_vendor_directory() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("web");
std::fs::create_dir_all(src.join("web_modules")).unwrap();
std::fs::write(src.join("web_modules/shim.js"), "export {};").unwrap();
let out = dir.path().join("out");
let err = build(&opts(std::slice::from_ref(&src), &out))
.unwrap_err()
.to_string();
assert!(
err.contains("reserved") && err.contains("web_modules"),
"got {err}"
);
}
#[cfg(feature = "compress")]
#[test]
fn build_reserves_gzip_sidecars() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("web");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("app.js"), "export {};").unwrap();
std::fs::write(src.join("app.js.gz"), b"\x1f\x8bstale").unwrap();
let out = dir.path().join("out");
let mut gzipped = opts(std::slice::from_ref(&src), &out);
gzipped.output = Output::new(false, true);
let err = build(&gzipped).unwrap_err().to_string();
assert!(
err.contains("reserved") && err.contains("gzip sidecar") && err.contains("app.js"),
"got {err}"
);
let plain_out = dir.path().join("out-plain");
build(&opts(std::slice::from_ref(&src), &plain_out)).unwrap();
assert_eq!(
std::fs::read(plain_out.join("app.js.gz")).unwrap(),
b"\x1f\x8bstale",
"without --gzip the shipped .gz is just a static file"
);
}
#[test]
fn build_rejects_secret_targets_from_every_step() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("web");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join(".env.tera"), "SECRET={{ 1 }}").unwrap();
std::fs::write(src.join(".env.ts"), "export const x = 1;").unwrap();
std::fs::write(src.join(".env.scss"), "b { color: red; }").unwrap();
std::fs::write(src.join("private.key.tera"), "{{ 2 }}").unwrap();
std::fs::write(src.join("page.html"), "<p>hi</p>").unwrap();
let out = dir.path().join("out");
build(&opts(std::slice::from_ref(&src), &out)).unwrap();
for target in [".env", ".env.js", ".env.css", "private.key"] {
assert!(!out.join(target).exists(), "{target} must not be emitted");
}
assert!(out.join("page.html").exists(), "the page still ships");
}
#[test]
fn reject_none_opts_rejected_targets_back_in() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("web");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("private.key.tera"), "key: {{ 40 + 2 }}").unwrap();
let out = dir.path().join("out");
let mut o = opts(std::slice::from_ref(&src), &out);
o.processors.reject = crate::reject::Reject::none();
build(&o).unwrap();
assert_eq!(
std::fs::read_to_string(out.join("private.key")).unwrap(),
"key: 42",
"an explicit `none` reject list opts the target back in"
);
}
#[cfg(unix)]
#[test]
fn build_refuses_a_symlink_escaping_the_source_root() {
let dir = tempfile::tempdir().unwrap();
let private = dir.path().join("private");
std::fs::create_dir_all(&private).unwrap();
std::fs::write(private.join("credentials.txt"), "secret").unwrap();
let src = dir.path().join("web");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("page.html"), "<p>hi</p>").unwrap();
std::os::unix::fs::symlink(&private, src.join("exposed")).unwrap();
let out = dir.path().join("out");
let err = build(&opts(std::slice::from_ref(&src), &out))
.unwrap_err()
.to_string();
assert!(
err.contains("resolve outside the source root")
&& err.contains("exposed/credentials.txt")
&& err.contains("private"),
"got: {err}"
);
let untouched = std::fs::read_dir(&out)
.map(|mut entries| entries.next().is_none())
.unwrap_or(true);
assert!(untouched, "nothing may be written");
}
#[cfg(unix)]
#[test]
fn build_ships_symlinks_resolving_inside_the_root() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("web");
std::fs::create_dir_all(src.join("real")).unwrap();
std::fs::write(src.join("real/data.txt"), "data").unwrap();
std::os::unix::fs::symlink(src.join("real/data.txt"), src.join("alias.txt")).unwrap();
let out = dir.path().join("out");
build(&opts(std::slice::from_ref(&src), &out)).unwrap();
assert_eq!(
std::fs::read_to_string(out.join("alias.txt")).unwrap(),
"data",
"an in-root link publishes its content"
);
}
#[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_under_skip_duplicates() {
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];
let err = build(&opts(&roots, &out)).unwrap_err();
assert!(
err.to_string().contains("--skip-duplicates"),
"strict by default; got: {err}"
);
build(&opts_skip(&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}"
);
}
#[cfg(feature = "tera")]
#[test]
fn build_conflict_error_lists_every_conflict() {
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();
std::fs::write(src.join("app.ts"), "export const x = 1;").unwrap();
std::fs::write(src.join("app.js"), "export const ok = true;").unwrap();
let err = build(&opts(std::slice::from_ref(&src), &out)).unwrap_err();
let message = err.to_string();
for expected in [
"index.html",
"app.js",
"Tera template",
"static copy",
"TypeScript transform",
"--skip-duplicates",
] {
assert!(
message.contains(expected),
"missing {expected:?} in: {message}"
);
}
assert!(
!out.exists(),
"the conflict check runs before anything is written - no output appears"
);
}
#[test]
fn build_cross_root_same_relative_path_errors() {
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("logo.svg"), "<svg>A</svg>").unwrap();
std::fs::write(b.join("logo.svg"), "<svg>B</svg>").unwrap();
let roots = vec![a, b];
let err = build(&opts(&roots, &out)).unwrap_err();
let message = err.to_string();
assert!(
message.contains("logo.svg") && message.contains("--skip-duplicates"),
"got: {message}"
);
}
#[cfg(all(feature = "tera", feature = "typescript"))]
#[test]
fn build_analyzes_tera_rendered_js() {
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.js.tera"), "import \"missing-package\";").unwrap();
let err = build(&opts(std::slice::from_ref(&src), &out)).unwrap_err();
let message = err.to_string();
assert!(
message.contains("missing-package"),
"the rendered module's import is validated; got: {message}"
);
}
#[cfg(all(feature = "tera", feature = "typescript"))]
#[test]
fn build_tera_rendered_mjs_must_parse() {
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("worker.mjs.tera"), "var await = 1;").unwrap();
let err = build(&opts(std::slice::from_ref(&src), &out)).unwrap_err();
let message = err.to_string();
assert!(
message.contains("worker.mjs.tera")
&& message.contains("does not parse as an ES module"),
"got: {message}"
);
}
#[cfg(feature = "tera")]
#[test]
fn build_tera_does_not_override_earlier_roots_under_skip() {
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"), "LITERAL_A").unwrap();
std::fs::write(b.join("index.html.tera"), "TERA_B").unwrap();
let roots = vec![a, b];
build(&opts_skip(&roots, &out)).unwrap();
let index = std::fs::read_to_string(out.join("index.html")).unwrap();
assert!(
index.contains("LITERAL_A"),
"the earlier root's literal wins over a later root's template; got: {index}"
);
}
#[cfg(all(feature = "tera", feature = "typescript"))]
#[test]
fn build_graph_follows_root_precedence() {
let dir = tempfile::tempdir().unwrap();
let primary = dir.path().join("primary");
let fallback = dir.path().join("fallback");
let out = dir.path().join("out");
std::fs::create_dir_all(&primary).unwrap();
std::fs::create_dir_all(&fallback).unwrap();
std::fs::write(primary.join("app.js"), "export const ok = true;").unwrap();
std::fs::write(fallback.join("app.js"), "import \"missing-package\";").unwrap();
let roots = vec![primary.clone(), fallback.clone()];
build(&opts_skip(&roots, &out)).unwrap();
let shipped = std::fs::read_to_string(out.join("app.js")).unwrap();
assert!(
shipped.contains("ok"),
"the first root's file ships; got:\n{shipped}"
);
std::fs::write(primary.join("app.js"), "import \"missing-package\";").unwrap();
std::fs::write(fallback.join("app.js"), "export const ok = true;").unwrap();
let out2 = dir.path().join("out2");
let err = build(&opts_skip(&roots, &out2)).unwrap_err();
assert!(matches!(err, Error::Build(_)), "got {err:?}");
}
#[cfg(feature = "typescript")]
#[test]
fn build_graph_follows_literal_over_transform_precedence() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
src.join("app.ts"),
"import { x } from \"missing-package\";\nexport const y = x;",
)
.unwrap();
std::fs::write(src.join("app.js"), "export const ok = true;").unwrap();
let out = dir.path().join("out");
build(&opts_skip(std::slice::from_ref(&src), &out)).unwrap();
let shipped = std::fs::read_to_string(out.join("app.js")).unwrap();
assert!(
shipped.contains("ok"),
"the literal file outranks the transformed sibling; got:\n{shipped}"
);
std::fs::write(src.join("app.ts"), "export const y = 1;").unwrap();
std::fs::write(src.join("app.js"), "import \"missing-package\";").unwrap();
let out2 = dir.path().join("out2");
let err = build(&opts_skip(std::slice::from_ref(&src), &out2)).unwrap_err();
assert!(matches!(err, Error::Build(_)), "got {err:?}");
}
#[test]
fn build_non_utf8_winner_replaces_shadowed_record() {
let dir = tempfile::tempdir().unwrap();
let primary = dir.path().join("primary");
let fallback = dir.path().join("fallback");
let out = dir.path().join("out");
std::fs::create_dir_all(&primary).unwrap();
std::fs::create_dir_all(&fallback).unwrap();
std::fs::write(primary.join("app.js"), [0xFF, 0xFE, 0x80, b';']).unwrap();
std::fs::write(fallback.join("app.js"), "import \"missing-package\";").unwrap();
let roots = vec![primary, fallback];
build(&opts_skip(&roots, &out)).unwrap();
assert_eq!(
std::fs::read(out.join("app.js")).unwrap(),
[0xFF, 0xFE, 0x80, b';'],
"the winning bytes ship unchanged"
);
}
#[cfg(feature = "typescript")]
#[test]
fn build_non_utf8_literal_replaces_transformed_record() {
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 { x } from \"missing-package\";\nexport const y = x;",
)
.unwrap();
std::fs::write(src.join("app.js"), [0xFF, 0xFE, 0x80, b';']).unwrap();
build(&opts_skip(std::slice::from_ref(&src), &out)).unwrap();
}
#[cfg(feature = "typescript")]
#[test]
fn build_checks_classic_script_dynamic_imports() {
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("legacy.js"),
"var await = 1;\nimport(\"missing-package\");",
)
.unwrap();
let err = build(&opts(std::slice::from_ref(&src), &out)).unwrap_err();
assert!(matches!(err, Error::Build(_)), "got {err:?}");
}
#[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_under_skip() {
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();
let err = build(&opts(std::slice::from_ref(&src), &out)).unwrap_err();
assert!(
err.to_string().contains("index.html"),
"the double assignment is named; got: {err}"
);
build(&opts_skip(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 outranks the literal same-target; got:\n{index}"
);
}
#[test]
fn build_validates_only_the_generated_map() {
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"),
r#"<script type="importmap">{"imports": {"lit": "./web_modules/lit/index.js"}}</script>"#,
)
.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();
let message = err.to_string();
assert!(
message.contains("unresolved bare import"),
"the generated map is the only validation target; got: {message}"
);
}
#[test]
fn build_never_parses_page_html() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
let out = dir.path().join("out");
std::fs::create_dir_all(&src).unwrap();
let page = r#"<!doctype html><script type="importmap">{not json}</script>"#;
std::fs::write(src.join("index.html"), page).unwrap();
build(&opts(std::slice::from_ref(&src), &out)).unwrap();
assert_eq!(
std::fs::read_to_string(out.join("index.html")).unwrap(),
page,
"the literal page ships unchanged"
);
}
}