use std::path::{Path, PathBuf};
use crate::css_bundler;
use crate::minify;
use crate::reload::ChangeType;
use crate::watcher::{Broadcaster, ChangeEvent};
pub(crate) struct SourcePipeline {
source_folders: Vec<PathBuf>,
bundle_roots: Vec<PathBuf>,
output_dir: PathBuf,
css_bundle_output: Option<PathBuf>,
prune_output: bool,
broadcaster: Broadcaster,
}
impl SourcePipeline {
pub(crate) fn new(
source_folders: Vec<PathBuf>,
bundle_roots: Vec<PathBuf>,
output_dir: PathBuf,
css_bundle_output: Option<PathBuf>,
prune_output: bool,
broadcaster: Broadcaster,
) -> Self {
SourcePipeline {
source_folders,
bundle_roots,
output_dir,
css_bundle_output,
prune_output,
broadcaster,
}
}
pub(crate) async fn full_build(&self) -> Result<(), SourceError> {
let css_written = self.build_css().await?;
self.build_all_js().await?;
if self.prune_output {
self.prune_stale_output(css_written.as_deref()).await?;
}
Ok(())
}
pub(crate) async fn process_change(
&self,
path: &Path,
change_type: &ChangeType,
) -> Result<(), SourceError> {
if self.css_bundle_output.is_some()
&& change_type == &ChangeType::Css
&& self.is_css_input(path)
{
if let Some(output) = self.build_css().await? {
self.broadcast_change(&output);
}
return Ok(());
}
if change_type == &ChangeType::Script {
if let Some(output) = self.build_js_file(path).await? {
self.broadcast_change(&output);
}
return Ok(());
}
self.broadcaster.broadcast(ChangeEvent {
path: path.to_path_buf(),
change_type: change_type.clone(),
});
Ok(())
}
async fn build_css(&self) -> Result<Option<PathBuf>, SourceError> {
let Some(output) = &self.css_bundle_output else {
return Ok(None);
};
if !self.has_css_sources() {
return Ok(None);
}
let mut allowed_roots = self.source_folders.clone();
allowed_roots.extend(self.bundle_roots.iter().cloned());
css_bundler::bundle_css_sources(&allowed_roots, &self.source_folders, output)
.await
.map_err(SourceError::Css)?;
Ok(Some(output.clone()))
}
async fn build_all_js(&self) -> Result<(), SourceError> {
for folder in &self.source_folders {
let files = list_files(folder).await.map_err(SourceError::Io)?;
for file in files {
if is_script(&file) {
let output = self.mirror_output(folder, &file)?;
self.write_minified_js(&file, &output).await?;
}
}
}
Ok(())
}
async fn build_js_file(&self, source: &Path) -> Result<Option<PathBuf>, SourceError> {
let Some(folder) = self.containing_source_folder(source) else {
return Ok(None);
};
if !is_script(source) {
return Ok(None);
}
let output = self.mirror_output(folder, source)?;
self.write_minified_js(source, &output).await?;
Ok(Some(output))
}
fn mirror_output(&self, folder: &Path, source: &Path) -> Result<PathBuf, SourceError> {
let relative = source
.strip_prefix(folder)
.map_err(|_| SourceError::NotUnderSource(source.to_path_buf()))?;
Ok(self.output_dir.join(relative))
}
async fn write_minified_js(&self, source: &Path, output: &Path) -> Result<(), SourceError> {
let bytes = tokio::fs::read(source).await.map_err(SourceError::Io)?;
let output_bytes = match minify::minify(&bytes, ChangeType::Script) {
Ok(minified) => minified,
Err(e) => {
eprintln!(
"source pipeline: minify failed for {}, serving raw bytes: {e}",
source.display()
);
bytes.into()
}
};
if let Some(parent) = output.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(SourceError::Io)?;
}
tokio::fs::write(output, &output_bytes)
.await
.map_err(SourceError::Io)?;
Ok(())
}
fn broadcast_change(&self, output: &Path) {
self.broadcaster.broadcast(ChangeEvent {
path: output.to_path_buf(),
change_type: ChangeType::from_path(output),
});
}
fn is_css_input(&self, path: &Path) -> bool {
self.source_folders
.iter()
.chain(self.bundle_roots.iter())
.any(|root| path.starts_with(root))
}
fn containing_source_folder(&self, path: &Path) -> Option<&PathBuf> {
self.source_folders
.iter()
.find(|folder| path.starts_with(folder))
}
fn has_css_sources(&self) -> bool {
for folder in &self.source_folders {
if walk_dir(folder).any(|path| is_css(&path)) {
return true;
}
}
false
}
async fn prune_stale_output(&self, css_written: Option<&Path>) -> Result<(), SourceError> {
let Some(bundle) = &self.css_bundle_output else {
return Ok(());
};
let wrote_bundle = css_written.is_some_and(|written| written == bundle);
if wrote_bundle {
return Ok(());
}
if tokio::fs::metadata(bundle).await.is_err() {
return Ok(());
}
tokio::fs::remove_file(bundle)
.await
.map_err(SourceError::Io)?;
eprintln!("pruned stale css bundle output: {}", bundle.display());
Ok(())
}
}
#[derive(Debug)]
pub(crate) enum SourceError {
Css(css_bundler::CssBundlerError),
Io(std::io::Error),
NotUnderSource(PathBuf),
}
impl std::fmt::Display for SourceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SourceError::Css(e) => write!(f, "css bundle failed: {e}"),
SourceError::Io(e) => write!(f, "io error: {e}"),
SourceError::NotUnderSource(p) => {
write!(f, "path not under any source folder: {}", p.display())
}
}
}
}
impl std::error::Error for SourceError {}
fn is_css(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()) == Some("css")
}
fn is_script(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("js" | "mjs")
)
}
async fn list_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut files = Vec::new();
let mut dirs = vec![dir.to_path_buf()];
while let Some(current) = dirs.pop() {
let mut entries = tokio::fs::read_dir(¤t).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if entry.file_type().await?.is_dir() {
dirs.push(path);
} else {
files.push(path);
}
}
}
Ok(files)
}
fn walk_dir(dir: &Path) -> impl Iterator<Item = PathBuf> {
let mut dirs = vec![dir.to_path_buf()];
std::iter::from_fn(move || {
while let Some(current) = dirs.pop() {
let Ok(entries) = std::fs::read_dir(¤t) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if entry.file_type().is_ok_and(|t| t.is_dir()) {
dirs.push(path);
} else {
return Some(path);
}
}
}
None
})
}