use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use axum::{
extract::State,
http::{header, StatusCode, Uri},
response::{IntoResponse, Response},
Router,
};
use include_dir::Dir;
use tower_livereload::LiveReloadLayer;
use super::serving::{
content_type, has_source_extension, has_traversal, is_source_file, relative_under,
resolve_file, Resolved,
};
use crate::build::Processors;
#[cfg(feature = "builder")]
use crate::builder_shared::source_builder_methods;
use crate::mount::Mount;
type Cache = Mutex<HashMap<PathBuf, (SystemTime, Vec<u8>)>>;
pub type DevConfig = Processors;
#[cfg(feature = "builder")]
#[derive(Clone, Debug, Default)]
pub struct Dev {
roots: Vec<PathBuf>,
processors: Processors,
}
#[cfg(feature = "builder")]
source_builder_methods!(Dev);
#[cfg(feature = "builder")]
impl Dev {
pub fn new() -> Self {
Self::default()
}
pub fn router(self) -> Router {
dev_router_with(self.roots, self.processors)
}
pub async fn serve(self, addr: SocketAddr) -> std::io::Result<()> {
serve_with(self.roots, addr, self.processors).await
}
}
#[derive(Clone)]
struct DevState {
mounts: Arc<Vec<Mount>>,
cache: Arc<Cache>,
fallback: Option<&'static Dir<'static>>,
config: Arc<DevConfig>,
}
enum Kind {
Ts,
Scss,
#[cfg(feature = "tera")]
Tera,
}
pub fn dev_router(roots: Vec<PathBuf>) -> Router {
dev_router_with(roots, DevConfig::default())
}
pub fn dev_router_with(roots: Vec<PathBuf>, config: DevConfig) -> Router {
build_router(roots.into_iter().map(Mount::root).collect(), None, config)
}
pub fn dev_router_with_embedded(roots: Vec<PathBuf>, embedded: &'static Dir<'static>) -> Router {
build_router(
roots.into_iter().map(Mount::root).collect(),
Some(embedded),
DevConfig::default(),
)
}
pub fn dev_router_mounted(mounts: Vec<Mount>) -> Router {
build_router(mounts, None, DevConfig::default())
}
pub fn dev_router_mounted_with_embedded(
mounts: Vec<Mount>,
embedded: &'static Dir<'static>,
) -> Router {
build_router(mounts, Some(embedded), DevConfig::default())
}
pub(crate) fn build_router(
mounts: Vec<Mount>,
fallback: Option<&'static Dir<'static>>,
config: DevConfig,
) -> Router {
for warning in preflight_warnings(&mounts, &config) {
eprintln!("{warning}");
}
let livereload = LiveReloadLayer::new();
spawn_watcher(mounts.clone(), livereload.reloader());
let state = DevState {
mounts: Arc::new(mounts),
cache: Arc::new(Mutex::new(HashMap::new())),
fallback,
config: Arc::new(config),
};
Router::new()
.fallback(serve_asset)
.with_state(state)
.layer(livereload)
}
fn preflight_warnings(mounts: &[Mount], config: &DevConfig) -> Vec<String> {
let steps = crate::build::steps::enabled_steps(config, Default::default());
let preflights: Vec<&dyn crate::build::steps::Preflight> = steps
.iter()
.map(|step| step.as_ref() as &dyn crate::build::steps::Preflight)
.collect();
let mut prefixes: Vec<&str> = Vec::new();
let mut groups: HashMap<&str, Vec<PathBuf>> = HashMap::new();
for mount in mounts {
let prefix = mount.url_prefix();
if !groups.contains_key(prefix) {
prefixes.push(prefix);
}
groups
.entry(prefix)
.or_default()
.push(mount.dir().to_path_buf());
}
let mut lines = Vec::new();
for prefix in prefixes {
let roots = &groups[prefix];
let report = crate::build::steps::preflight(
roots,
&preflights,
crate::build::steps::WalkPolicy {
reject: &config.reject,
symlinks: config.symlinks,
},
);
for error in report.walk_errors() {
lines.push(format!("web-modules: preflight: {error}"));
}
for source in report.escaping_sources() {
lines.push(format!(
"web-modules: {} resolves outside its mount ({}) - not served",
roots[source.root].join(&source.rel).display(),
source.target.display(),
));
}
for skipped in report.skipped_symlinks() {
lines.push(format!(
"web-modules: {} is a symlink - served as a redirect, skipped by build",
roots[skipped.root].join(&skipped.rel).display(),
));
}
for rejected in report.rejected_claims() {
lines.push(format!("web-modules: {}", rejected.describe(roots)));
}
if config.skip_duplicates {
continue;
}
for conflict in report.conflicts() {
let target = if prefix.ends_with('/') {
format!("{prefix}{}", conflict.out_rel.display())
} else {
format!("{prefix}/{}", conflict.out_rel.display())
};
let winner = &conflict.claimants[0];
let losers = conflict.claimants[1..]
.iter()
.map(|claim| roots[claim.root].join(&claim.rel).display().to_string())
.collect::<Vec<_>>()
.join(", ");
lines.push(format!(
"web-modules: duplicate target {target}: {} wins over {losers} \
(--skip-duplicates silences this)",
roots[winner.root].join(&winner.rel).display()
));
}
}
lines
}
pub async fn serve(roots: Vec<PathBuf>, addr: SocketAddr) -> std::io::Result<()> {
serve_with(roots, addr, DevConfig::default()).await
}
pub async fn serve_with(
roots: Vec<PathBuf>,
addr: SocketAddr,
config: DevConfig,
) -> std::io::Result<()> {
let app = dev_router_with(roots, config);
let listener = tokio::net::TcpListener::bind(addr).await?;
println!("web-modules dev server on http://{addr}/ (Ctrl-C to stop)");
axum::serve(listener, app).await
}
async fn serve_asset(State(state): State<DevState>, uri: Uri) -> Response {
let raw = uri.path().trim_start_matches('/');
let requested = if raw.is_empty() || raw.ends_with('/') {
format!("{raw}index.html")
} else {
raw.to_string()
};
if has_traversal(&requested) {
return StatusCode::NOT_FOUND.into_response();
}
let task = {
let state = state.clone();
let requested = requested.clone();
tokio::task::spawn_blocking(move || resolve(&state, &requested)).await
};
let Ok(resolved) = task else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
match resolved {
Ok(Some(Served::Bytes { body, content_type })) => {
([(header::CONTENT_TYPE, content_type)], body).into_response()
}
#[cfg(feature = "symlink-move")]
Ok(Some(Served::Redirect {
location,
permanent,
})) => match super::symlink_move::redirect_response(&location, permanent) {
Some(response) => response,
None => (StatusCode::NOT_FOUND, format!("404 Not Found: {requested}")).into_response(),
},
Ok(None) => (StatusCode::NOT_FOUND, format!("404 Not Found: {requested}")).into_response(),
Err(message) => {
eprintln!("web-modules: compile error for /{requested}:\n{message}");
(StatusCode::INTERNAL_SERVER_ERROR, message).into_response()
}
}
}
enum Served {
Bytes {
body: Vec<u8>,
content_type: String,
},
#[cfg(feature = "symlink-move")]
Redirect {
location: String,
permanent: bool,
},
}
fn source_candidate(mount: &Mount, rel: &str, mode: crate::SymlinkMode) -> Option<PathBuf> {
match resolve_file(mount.dir(), rel, mode) {
Some(Resolved::File(path)) => Some(path),
_ => None,
}
}
fn matching<'a>(state: &'a DevState, requested: &str) -> Vec<(&'a Mount, String)> {
let mut hits: Vec<(&Mount, String)> = state
.mounts
.iter()
.filter_map(|m| relative_under(m.url_prefix(), requested).map(|rel| (m, rel)))
.collect();
hits.sort_by_key(|hit| std::cmp::Reverse(hit.0.url_prefix().len()));
hits
}
fn resolve(state: &DevState, requested: &str) -> Result<Option<Served>, String> {
if state.config.reject.rejects(requested) {
crate::reject::warn_rejected(requested);
return Ok(None);
}
let mode = state.config.symlinks;
for (mount, rel) in matching(state, requested) {
#[cfg(feature = "tera")]
if state.config.tera && !rel.is_empty() {
if let Some(src) = source_candidate(mount, &format!("{rel}.tera"), mode) {
let is_partial = src
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with('_'));
if !is_partial {
let body = compile_cached(state, &src, Kind::Tera)?;
return Ok(Some(Served::Bytes {
body,
content_type: content_type(&rel),
}));
}
}
}
if !rel.is_empty() && !is_source_file(&rel) {
match resolve_file(mount.dir(), &rel, mode) {
Some(Resolved::File(path)) => {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if state.config.reject.rejects(name) {
crate::reject::warn_rejected(&rel);
return Ok(None);
}
if !has_source_extension(&path) {
let body = std::fs::read(&path).map_err(|e| e.to_string())?;
return Ok(Some(Served::Bytes {
body,
content_type: content_type(&rel),
}));
}
}
#[cfg(feature = "symlink-move")]
Some(Resolved::Redirect(location)) => {
return Ok(Some(Served::Redirect {
location,
permanent: mode == crate::SymlinkMode::Move,
}));
}
None => {}
}
}
if state.config.typescript {
if let Some(stem) = rel.strip_suffix(".js") {
for ext in ["ts", "tsx", "mts"] {
if let Some(src) = source_candidate(mount, &format!("{stem}.{ext}"), mode) {
let body = compile_cached(state, &src, Kind::Ts)?;
return Ok(Some(Served::Bytes {
body,
content_type: "text/javascript; charset=utf-8".into(),
}));
}
}
}
}
if state.config.scss {
if let Some(stem) = rel.strip_suffix(".css") {
if let Some(src) = source_candidate(mount, &format!("{stem}.scss"), mode) {
let body = compile_cached(state, &src, Kind::Scss)?;
return Ok(Some(Served::Bytes {
body,
content_type: "text/css; charset=utf-8".into(),
}));
}
}
}
}
if let Some(dir) = state.fallback {
if !is_source_file(requested) {
if let Some(file) = dir.get_file(requested) {
return Ok(Some(Served::Bytes {
body: file.contents().to_vec(),
content_type: content_type(requested),
}));
}
}
}
Ok(None)
}
fn compile_cached(state: &DevState, src: &Path, kind: Kind) -> Result<Vec<u8>, String> {
let mtime = std::fs::metadata(src)
.and_then(|m| m.modified())
.map_err(|e| e.to_string())?;
if let Some((cached_mtime, bytes)) = state
.cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(src)
{
if *cached_mtime == mtime {
return Ok(bytes.clone());
}
}
let out = match kind {
Kind::Ts => {
let source = std::fs::read_to_string(src).map_err(|e| e.to_string())?;
let options = crate::typescript::TranspileOptions {
decorators: state.config.ts_decorators,
..Default::default()
};
crate::typescript::compile_str_with(&source, src, &options)
.map_err(|e| e.to_string())?
.into_bytes()
}
Kind::Scss => {
let mut load_paths: Vec<&Path> = state.mounts.iter().map(|m| m.dir()).collect();
load_paths.extend(
state
.config
.extra_scss_load_paths
.iter()
.map(PathBuf::as_path),
);
crate::scss::compile_file(src, &load_paths)
.map_err(|e| e.to_string())?
.into_bytes()
}
#[cfg(feature = "tera")]
Kind::Tera => {
let ctx = crate::templates::importmap_context(&crate::importmap::Importmap::new());
crate::templates::render_file(src, &ctx)
.map_err(|e| e.to_string())?
.into_bytes()
}
};
state
.cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(src.to_path_buf(), (mtime, out.clone()));
Ok(out)
}
fn spawn_watcher(mounts: Vec<Mount>, reloader: tower_livereload::Reloader) {
std::thread::spawn(move || {
use notify::{RecursiveMode, Watcher};
let mut watcher =
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(event) = res {
if event.kind.is_modify() || event.kind.is_create() || event.kind.is_remove() {
reloader.reload();
}
}
}) {
Ok(w) => w,
Err(e) => {
eprintln!("web-modules: file watcher unavailable ({e}); live-reload off");
return;
}
};
for mount in &mounts {
if mount.is_watched() {
if let Err(e) = watcher.watch(mount.dir(), RecursiveMode::Recursive) {
eprintln!("web-modules: cannot watch {}: {e}", mount.dir().display());
}
}
}
loop {
std::thread::park();
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn state(mounts: Vec<Mount>) -> DevState {
state_with(mounts, DevConfig::default())
}
impl Served {
fn bytes(self) -> (Vec<u8>, String) {
match self {
Served::Bytes { body, content_type } => (body, content_type),
#[cfg(feature = "symlink-move")]
Served::Redirect { location, .. } => {
panic!("expected bytes, got a redirect to {location}")
}
}
}
}
fn state_with(mounts: Vec<Mount>, config: DevConfig) -> DevState {
DevState {
mounts: Arc::new(mounts),
cache: Arc::new(Mutex::new(HashMap::new())),
fallback: None,
config: Arc::new(config),
}
}
#[test]
fn dev_rejects_config_and_dotfiles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("index.html"), b"<x>").unwrap();
std::fs::write(root.join("package.json"), b"{}").unwrap();
std::fs::write(root.join(".env"), b"S=1").unwrap();
let state = state(vec![Mount::root(root)]);
assert!(resolve(&state, "index.html").unwrap().is_some());
assert!(
resolve(&state, "package.json").unwrap().is_none(),
"config manifest rejected"
);
assert!(
resolve(&state, ".env").unwrap().is_none(),
"dotfile rejected"
);
}
#[test]
fn dev_rejects_templated_and_compiled_secret_targets() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join(".env.tera"), "SECRET={{ 1 }}").unwrap();
std::fs::write(root.join(".env.ts"), "export const x = 1;").unwrap();
let state = state(vec![Mount::root(root)]);
assert!(
resolve(&state, ".env").unwrap().is_none(),
"templated target rejected"
);
assert!(
resolve(&state, ".env.js").unwrap().is_none(),
"compiled target rejected"
);
}
#[test]
fn resolve_serves_inside_and_blocks_traversal() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("data.json"), b"{}").unwrap();
std::fs::write(tmp.path().join("secret"), b"s").unwrap();
let state = state(vec![Mount::root(root)]);
assert!(resolve(&state, "data.json").unwrap().is_some());
assert!(resolve(&state, "../secret").unwrap().is_none());
}
#[test]
fn dev_serves_literal_js_over_compiling_sibling_ts() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("app.js"), b"literal();").unwrap();
std::fs::write(root.join("app.ts"), "export const compiled = 1;").unwrap();
let state = state(vec![Mount::root(root)]);
let (bytes, _) = resolve(&state, "app.js").unwrap().unwrap().bytes();
assert_eq!(bytes, b"literal();", "the literal file wins");
}
#[test]
fn dev_serves_literal_css_over_scss() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("style.css"), b"a{color:red}").unwrap();
std::fs::write(root.join("style.scss"), "a { color: blue; }").unwrap();
let state = state(vec![Mount::root(root)]);
let (bytes, _) = resolve(&state, "style.css").unwrap().unwrap().bytes();
assert_eq!(bytes, b"a{color:red}", "the literal file wins");
}
#[test]
fn duplicate_warnings_lists_conflicts_and_names_the_winner() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("app.js"), b"literal();").unwrap();
std::fs::write(root.join("app.ts"), "export const compiled = 1;").unwrap();
let mounts = vec![Mount::root(root)];
let warnings = preflight_warnings(&mounts, &DevConfig::default());
assert_eq!(warnings.len(), 1, "got {warnings:?}");
assert!(
warnings[0].contains("/app.js")
&& warnings[0].contains("app.js wins over")
&& warnings[0].contains("app.ts")
&& warnings[0].contains("--skip-duplicates"),
"got: {}",
warnings[0]
);
}
#[cfg(unix)]
#[test]
fn follow_unsafe_serves_an_escaping_link_but_keeps_the_other_guards() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("outside.js"), b"outside").unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::os::unix::fs::symlink(tmp.path().join("outside.js"), root.join("link.js")).unwrap();
std::fs::write(root.join(".env"), b"S=1").unwrap();
let state = state_with(
vec![Mount::root(root)],
DevConfig {
symlinks: crate::SymlinkMode::FollowUnsafe,
..DevConfig::default()
},
);
let (bytes, _) = resolve(&state, "link.js").unwrap().unwrap().bytes();
assert_eq!(bytes, b"outside", "the mode's contract: the link serves");
assert!(
resolve(&state, ".env").unwrap().is_none(),
"reject still applies"
);
}
#[cfg(all(unix, feature = "symlink-move"))]
#[test]
fn redirect_mode_answers_with_the_link_content_and_hides_symlinked_sources() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("real.txt"), b"data").unwrap();
std::os::unix::fs::symlink(Path::new("real.txt"), root.join("link.txt")).unwrap();
std::fs::write(tmp.path().join("app.ts"), "export const x = 1;").unwrap();
std::os::unix::fs::symlink(tmp.path().join("app.ts"), root.join("app.ts")).unwrap();
let state = state_with(
vec![Mount::root(root)],
DevConfig {
symlinks: crate::SymlinkMode::Redirect,
..DevConfig::default()
},
);
match resolve(&state, "link.txt").unwrap().unwrap() {
Served::Redirect {
location,
permanent,
} => {
assert_eq!(location, "real.txt", "the link content is the Location");
assert!(!permanent, "Redirect is the temporary mode");
}
Served::Bytes { .. } => panic!("expected a redirect"),
}
assert!(
resolve(&state, "app.js").unwrap().is_none(),
"a symlinked source is skipped, not redirected"
);
let (bytes, _) = resolve(&state, "real.txt").unwrap().unwrap().bytes();
assert_eq!(bytes, b"data", "plain files keep the full guard chain");
}
#[cfg(all(unix, feature = "symlink-move"))]
#[test]
fn move_mode_marks_the_redirect_permanent() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("real.txt"), b"data").unwrap();
std::os::unix::fs::symlink(Path::new("real.txt"), root.join("link.txt")).unwrap();
let state = state_with(
vec![Mount::root(root)],
DevConfig {
symlinks: crate::SymlinkMode::Move,
..DevConfig::default()
},
);
match resolve(&state, "link.txt").unwrap().unwrap() {
Served::Redirect { permanent, .. } => assert!(permanent),
Served::Bytes { .. } => panic!("expected a redirect"),
}
}
#[cfg(unix)]
#[test]
fn follow_unsafe_suppresses_escape_warnings() {
let tmp = tempfile::tempdir().unwrap();
let private = tmp.path().join("private");
std::fs::create_dir_all(&private).unwrap();
std::fs::write(private.join("credentials.txt"), "secret").unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::os::unix::fs::symlink(&private, root.join("exposed")).unwrap();
let config = DevConfig {
symlinks: crate::SymlinkMode::FollowUnsafe,
..DevConfig::default()
};
let warnings = preflight_warnings(&[Mount::root(root)], &config);
assert!(warnings.is_empty(), "got {warnings:?}");
}
#[cfg(all(unix, feature = "symlink-move"))]
#[test]
fn redirect_mode_warns_about_skipped_symlinks() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("real.txt"), "data").unwrap();
std::os::unix::fs::symlink(root.join("real.txt"), root.join("link.txt")).unwrap();
let config = DevConfig {
symlinks: crate::SymlinkMode::Redirect,
..DevConfig::default()
};
let warnings = preflight_warnings(&[Mount::root(root)], &config);
assert_eq!(warnings.len(), 1, "got {warnings:?}");
assert!(
warnings[0].contains("link.txt")
&& warnings[0].contains("redirect")
&& warnings[0].contains("skipped by build"),
"got: {}",
warnings[0]
);
}
#[cfg(unix)]
#[test]
fn dev_warns_about_a_symlink_escaping_the_mount() {
let tmp = tempfile::tempdir().unwrap();
let private = tmp.path().join("private");
std::fs::create_dir_all(&private).unwrap();
std::fs::write(private.join("credentials.txt"), "secret").unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::os::unix::fs::symlink(&private, root.join("exposed")).unwrap();
let config = DevConfig {
skip_duplicates: true,
..DevConfig::default()
};
let warnings = preflight_warnings(&[Mount::root(root)], &config);
assert_eq!(warnings.len(), 1, "got {warnings:?}");
assert!(
warnings[0].contains("exposed/credentials.txt")
&& warnings[0].contains("outside its mount")
&& warnings[0].contains("not served"),
"got: {}",
warnings[0]
);
}
#[test]
fn skip_duplicates_suppresses_dev_warnings() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("app.js"), b"literal();").unwrap();
std::fs::write(root.join("app.ts"), "export const compiled = 1;").unwrap();
let config = DevConfig {
skip_duplicates: true,
..DevConfig::default()
};
let warnings = preflight_warnings(&[Mount::root(root)], &config);
assert!(warnings.is_empty(), "got {warnings:?}");
}
#[test]
fn dev_reports_reject_list_drops_even_under_skip_duplicates() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("index.html"), b"<x>").unwrap();
std::fs::write(root.join(".env"), b"S=1").unwrap();
let warnings = preflight_warnings(&[Mount::root(root.clone())], &DevConfig::default());
assert_eq!(warnings.len(), 1, "got {warnings:?}");
assert!(
warnings[0].contains(".env") && warnings[0].contains("dropped by the reject list"),
"got: {}",
warnings[0]
);
let config = DevConfig {
skip_duplicates: true,
..DevConfig::default()
};
let warnings = preflight_warnings(&[Mount::root(root)], &config);
assert_eq!(warnings.len(), 1, "got {warnings:?}");
}
#[cfg(feature = "tera")]
#[test]
fn dev_reports_a_template_materializing_a_rejected_target() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join(".env.tera"), "S={{ 1 }}").unwrap();
let warnings = preflight_warnings(&[Mount::root(root)], &DevConfig::default());
assert_eq!(warnings.len(), 1, "got {warnings:?}");
assert!(
warnings[0].contains(".env.tera") && warnings[0].contains("would emit .env"),
"got: {}",
warnings[0]
);
}
#[test]
fn resolve_routes_by_prefix_mount() {
let tmp = tempfile::tempdir().unwrap();
let ui = tmp.path().join("ui");
let api = tmp.path().join("api");
std::fs::create_dir_all(&ui).unwrap();
std::fs::create_dir_all(&api).unwrap();
std::fs::write(ui.join("a.json"), b"\"ui\"").unwrap();
std::fs::write(api.join("a.json"), b"\"api\"").unwrap();
let state = state(vec![Mount::new("ui", ui), Mount::new("api", api)]);
assert_eq!(
&resolve(&state, "ui/a.json").unwrap().unwrap().bytes().0,
b"\"ui\""
);
assert_eq!(
&resolve(&state, "api/a.json").unwrap().unwrap().bytes().0,
b"\"api\""
);
assert!(resolve(&state, "nope/a.json").unwrap().is_none());
}
#[test]
fn overlay_same_prefix_first_dir_wins() {
let tmp = tempfile::tempdir().unwrap();
let first = tmp.path().join("first");
let second = tmp.path().join("second");
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(&second).unwrap();
std::fs::write(first.join("page.html"), b"first").unwrap();
std::fs::write(second.join("page.html"), b"second").unwrap();
let state = state(vec![Mount::root(first), Mount::root(second)]);
assert_eq!(
&resolve(&state, "page.html").unwrap().unwrap().bytes().0,
b"first"
);
}
#[test]
fn sources_are_not_served_raw() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("app.scss"), "a{color:red}").unwrap();
std::fs::write(root.join("app.ts"), "export const x = 1;").unwrap();
let state = state(vec![Mount::root(root)]);
assert!(resolve(&state, "app.scss").unwrap().is_none());
assert!(resolve(&state, "app.ts").unwrap().is_none());
assert!(resolve(&state, "app.css").unwrap().is_some()); assert!(resolve(&state, "app.js").unwrap().is_some()); }
#[cfg(feature = "tera")]
#[test]
fn dev_renders_tera_to_target() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join("index.html.tera"),
"<head>{{ importmap | safe }}</head>",
)
.unwrap();
let state = state(vec![Mount::root(root)]);
let (bytes, ct) = resolve(&state, "index.html").unwrap().unwrap().bytes();
let html = String::from_utf8(bytes).unwrap();
assert!(
html.contains("<script type=\"importmap\">"),
"rendered with the importmap var; got:\n{html}"
);
assert!(ct.starts_with("text/html"), "served as html; got {ct}");
}
#[cfg(feature = "tera")]
#[test]
fn dev_hides_tera_source() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("index.html.tera"), "<p>hi</p>").unwrap();
let state = state(vec![Mount::root(root)]);
assert!(resolve(&state, "index.html").unwrap().is_some());
assert!(resolve(&state, "index.html.tera").unwrap().is_none());
}
#[test]
fn dev_respects_disabled_processor() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("app.scss"), "a{color:red}").unwrap();
let config = DevConfig {
scss: false,
..DevConfig::default()
};
let state = state_with(vec![Mount::root(root)], config);
assert!(resolve(&state, "app.css").unwrap().is_none());
}
#[cfg(feature = "tera")]
#[test]
fn dev_tera_wins_over_literal_same_target() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("index.html"), "LITERAL").unwrap();
std::fs::write(root.join("index.html.tera"), "TERA").unwrap();
let state = state(vec![Mount::root(root)]);
let (bytes, _) = resolve(&state, "index.html").unwrap().unwrap().bytes();
assert_eq!(
String::from_utf8(bytes).unwrap(),
"TERA",
"dev renders the .tera over the literal same-target"
);
}
}