use std::{
cell::RefCell,
env,
fs::{self},
io::{self},
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
time::{Instant, SystemTime, UNIX_EPOCH},
};
use crate::assets::css::bundle_css;
use crate::assets::run_tailwind;
use crate::{
BuildOptions, BuildOutput,
assets::{
self, HashAssetType, HashConfig, PrefetchPlugin, RouteAssets, Script, Style, StyleOptions,
calculate_hash, image_cache::ImageCache, make_final_url, prefetch,
},
build::{images::process_image, options::PrefetchStrategy},
content::ContentSources,
is_dev,
logging::print_title,
route::{CachedRoute, DynamicRouteContext, FullRoute, InternalRoute, PageContext, PageParams},
routing::extract_params_from_raw_route,
sitemap::{SitemapEntry, generate_sitemap},
};
use colored::{ColoredString, Colorize};
use log::{debug, info, trace, warn};
use pathdiff::diff_paths;
use rolldown::{Bundler, BundlerOptions, ExperimentalOptions, InputItem};
use rolldown_plugin_replace::ReplacePlugin;
use rustc_hash::{FxHashMap, FxHashSet};
use crate::assets::Asset;
use crate::logging::{FormatElapsedTimeOptions, format_elapsed_time};
use rayon::prelude::*;
pub mod cache;
pub mod images;
pub mod metadata;
pub mod options;
pub fn execute_build(
routes: &[&dyn FullRoute],
content_sources: &mut ContentSources,
options: &BuildOptions,
async_runtime: &tokio::runtime::Runtime,
) -> Result<BuildOutput, Box<dyn std::error::Error>> {
async_runtime.block_on(async { build(routes, content_sources, options).await })
}
fn try_cache_hit(
route: &dyn FullRoute,
page_key: &cache::PageKey,
incremental_state: &cache::IncrementalState,
new_cache: &mut Option<cache::BuildCache>,
route_assets_options: &assets::RouteAssetsOptions,
build_scripts: &mut FxHashSet<Script>,
build_styles: &mut FxHashSet<Style>,
) -> bool {
let Some(cache) = new_cache else {
return false;
};
if route.always_revalidate() || incremental_state.is_page_dirty(page_key) {
return false;
}
let Some(cached_entry) = incremental_state
.previous_cache
.as_ref()
.and_then(|c| c.pages.get(page_key))
else {
return false;
};
restore_assets_from_cache(
cached_entry,
route_assets_options,
build_scripts,
build_styles,
);
cache.pages.insert(page_key.clone(), cached_entry.clone());
true
}
fn record_page_cache_entry(
new_cache: &mut Option<cache::BuildCache>,
page_key: cache::PageKey,
access_log: crate::content::tracked::ContentAccessLog,
route_assets: &RouteAssets,
output_file: PathBuf,
) {
let Some(cache) = new_cache.as_mut() else {
return;
};
cache.pages.insert(
page_key,
cache::PageCacheEntry {
content_entries_read: access_log.entries_read,
content_sources_iterated: access_log.sources_iterated,
scripts: route_assets
.scripts
.iter()
.map(|s| cache::CachedScript {
path: s.path.clone(),
hash: s.hash.clone(),
included: s.included,
})
.collect(),
styles: route_assets
.styles
.iter()
.map(|s| cache::CachedStyle {
path: s.path.clone(),
hash: s.hash.clone(),
included: s.included,
tailwind: s.tailwind,
})
.collect(),
images: route_assets
.images
.iter()
.map(|img| cache::CachedImage {
path: img.path.clone(),
hash: img.hash.clone(),
filename: img.filename.clone(),
})
.collect(),
output_file,
},
);
}
fn restore_assets_from_cache(
cached_entry: &cache::PageCacheEntry,
route_assets_options: &assets::RouteAssetsOptions,
build_scripts: &mut FxHashSet<Script>,
build_styles: &mut FxHashSet<Style>,
) {
for s in &cached_entry.scripts {
build_scripts.insert(Script::new(
s.path.clone(),
s.included,
s.hash.clone(),
route_assets_options,
));
}
for s in &cached_entry.styles {
build_styles.insert(Style::new(
s.path.clone(),
s.included,
&StyleOptions {
tailwind: s.tailwind,
},
s.hash.clone(),
route_assets_options,
));
}
}
pub async fn build(
routes: &[&dyn FullRoute],
content_sources: &mut ContentSources,
options: &BuildOptions,
) -> Result<BuildOutput, Box<dyn std::error::Error>> {
let build_start = Instant::now();
let mut build_metadata = BuildOutput::new(build_start);
trace!(target: "build", "Setting up required directories...");
let should_clean = options.clean_output_dir && !options.incremental;
let clean_up_handle = if should_clean {
let old_dist_tmp_dir = {
let duration = SystemTime::now().duration_since(UNIX_EPOCH)?;
let num = (duration.as_secs() + duration.subsec_nanos() as u64) % 100000;
let new_dir_for_old_dist = env::temp_dir().join(format!("maudit_old_dist_{}", num));
let _ = fs::rename(&options.output_dir, &new_dir_for_old_dist);
new_dir_for_old_dist
};
Some(tokio::spawn(async {
let _ = fs::remove_dir_all(old_dist_tmp_dir);
}))
} else {
None
};
let cache_load_start = Instant::now();
let image_cache_dir = options.cache_dir.join("images");
let image_cache = ImageCache::load(&image_cache_dir, &options.cache_dir);
let previous_build_cache = if options.incremental {
let cache = cache::BuildCache::load(&options.cache_dir);
if let Some(cache) = cache {
if !options.output_dir.exists() {
info!(target: "cache", "Output directory missing, forcing full rebuild");
None
} else {
info!(target: "cache", "Build cache loaded in {}", format_elapsed_time(cache_load_start.elapsed(), &FormatElapsedTimeOptions::default()));
Some(cache)
}
} else {
None
}
} else {
None
};
let route_assets_options = {
let mut opts = options.route_assets_options();
opts.intermediate_url_format = assets::IntermediateUrlFormat::Placeholder;
opts
};
info!(target: "build", "Output directory: {}", options.output_dir.display());
let content_sources_start = Instant::now();
print_title("initializing content sources");
content_sources.sources_mut().iter_mut().for_each(|source| {
let source_start = Instant::now();
source.init();
info!(target: "content", "{} initialized in {}", source.get_name(), format_elapsed_time(source_start.elapsed(), &FormatElapsedTimeOptions::default()));
});
info!(target: "content", "{}", format!("Content sources initialized in {}", format_elapsed_time(
content_sources_start.elapsed(),
&FormatElapsedTimeOptions::default(),
)).bold());
let incremental_state;
let mut new_cache: Option<cache::BuildCache>;
if options.incremental {
let incremental_start = Instant::now();
let current_binary_hash = cache::BuildCache::compute_binary_hash();
let current_content_states: FxHashMap<String, cache::ContentSourceState> = content_sources
.sources()
.iter()
.map(|s| {
let entries = s.entry_file_info();
let raw_content = s.entry_raw_content();
(
s.get_name().to_string(),
cache::compute_content_source_state(&entries, &raw_content),
)
})
.collect();
let current_options_hash = options.options_hash();
incremental_state = cache::load_incremental_state(
previous_build_cache,
¤t_content_states,
¤t_binary_hash,
¤t_options_hash,
);
new_cache = Some(cache::BuildCache {
version: cache::BUILD_CACHE_VERSION,
binary_hash: current_binary_hash,
content_sources: current_content_states,
options_hash: current_options_hash,
..Default::default()
});
info!(target: "cache", "Incremental state computed in {}", format_elapsed_time(incremental_start.elapsed(), &FormatElapsedTimeOptions::default()));
} else {
incremental_state = cache::IncrementalState::full_build();
new_cache = None;
};
print_title("generating pages");
if !incremental_state.is_full_build() {
let dirty_count = incremental_state.dirty_pages.len();
if dirty_count == 0 {
if let Some(prev) = &incremental_state.previous_cache {
info!(target: "cache", "all {} pages are clean", prev.pages.len());
}
} else if let Some(prev) = &incremental_state.previous_cache {
info!(target: "cache", "{}/{} pages need re-rendering", dirty_count, prev.pages.len());
}
}
let pages_start = Instant::now();
let route_format_options = FormatElapsedTimeOptions {
additional_fn: Some(&|msg: ColoredString| {
let formatted_msg = format!("(+{})", msg);
if msg.fgcolor.is_none() {
formatted_msg.dimmed()
} else {
formatted_msg.into()
}
}),
..Default::default()
};
let section_format_options = FormatElapsedTimeOptions {
sec_red_threshold: 5,
sec_yellow_threshold: 1,
millis_red_threshold: None,
millis_yellow_threshold: None,
..Default::default()
};
#[allow(clippy::mutable_key_type)]
let mut build_pages_images: FxHashSet<assets::Image> = FxHashSet::default();
let mut build_pages_scripts: FxHashSet<assets::Script> = FxHashSet::default();
let mut build_pages_styles: FxHashSet<assets::Style> = FxHashSet::default();
let mut sitemap_entries: Vec<SitemapEntry> = Vec::new();
let mut rendered_count: usize = 0;
let mut cached_count: usize = 0;
let mut created_dirs: FxHashSet<PathBuf> = FxHashSet::default();
let mut pages_with_assets: Vec<PathBuf> = Vec::new();
let assets_prefix_bytes = assets::PENDING_URL_PREFIX.as_bytes();
let asset_hash_cache: assets::AssetHashCache = {
let mut map = FxHashMap::default();
if let Some(ref prev) = incremental_state.previous_cache {
for (path, entries) in &prev.persisted_asset_hashes {
if let Some((mtime, size)) = cache::file_fingerprint(path) {
for entry in entries {
if entry.mtime_ns == mtime && entry.size == size {
let key =
assets::AssetHashKey::from_raw(path.clone(), entry.options_hash);
map.insert(key, entry.asset_hash.clone());
}
}
}
}
}
if !map.is_empty() {
debug!(target: "cache", "Seeded asset hash cache with {} entries from previous build", map.len());
}
Rc::new(RefCell::new(map))
};
let normalized_base_url = options
.base_url
.as_ref()
.map(|url| url.trim_end_matches('/'));
let mut default_scripts = vec![];
let prefetch_path = match options.prefetch.strategy {
PrefetchStrategy::None => None,
PrefetchStrategy::Hover => Some(PathBuf::from(prefetch::PREFETCH_HOVER_PATH)),
PrefetchStrategy::Tap => Some(PathBuf::from(prefetch::PREFETCH_TAP_PATH)),
PrefetchStrategy::Viewport => Some(PathBuf::from(prefetch::PREFETCH_VIEWPORT_PATH)),
};
if let Some(prefetch_path) = prefetch_path {
let prefetch_script = Script::new(
prefetch_path.clone(),
true,
calculate_hash(
&prefetch_path,
Some(&HashConfig {
asset_type: HashAssetType::Script,
hashing_strategy: &options.assets.hashing_strategy,
}),
)?,
&route_assets_options,
);
default_scripts.push(prefetch_script);
}
let mut shared_route_assets = if new_cache.is_none() {
Some(RouteAssets::with_default_assets(
&route_assets_options,
Some(image_cache.clone()),
Some(asset_hash_cache.clone()),
default_scripts.clone(),
vec![],
))
} else {
None
};
for route in routes {
let cached_route = CachedRoute::new(*route);
let base_path = route.route_raw();
let variants = cached_route.variants();
trace!(target: "build", "Processing route: base='{}', variants={}", base_path.as_deref().unwrap_or(""), variants.len());
let has_base_route = base_path.is_some();
if !has_base_route && !variants.is_empty() {
info!(target: "pages", "(variants only)");
}
if let Some(ref base_path) = base_path {
let base_params = extract_params_from_raw_route(base_path);
if base_params.is_empty() {
let params = PageParams::default();
let (url, file_path) = cached_route.url_and_file_path(¶ms, &options.output_dir);
let page_key = if new_cache.is_some() {
Some(cache::PageKey::new_static(base_path, None))
} else {
None
};
let cache_hit = page_key.as_ref().is_some_and(|pk| {
try_cache_hit(
*route,
pk,
&incremental_state,
&mut new_cache,
&route_assets_options,
&mut build_pages_scripts,
&mut build_pages_styles,
)
});
if cache_hit {
info!(target: "pages", "{} -> {} (cached)", url, file_path.to_string_lossy().dimmed());
build_metadata.add_page(
base_path.clone(),
file_path.to_string_lossy().to_string(),
None,
true,
);
add_sitemap_entry(
&mut sitemap_entries,
normalized_base_url,
&url,
base_path,
&route.sitemap_metadata(),
&options.sitemap,
);
cached_count += 1;
} else {
let page_start = Instant::now();
let mut route_assets = RouteAssets::with_default_assets(
&route_assets_options,
Some(image_cache.clone()),
Some(asset_hash_cache.clone()),
default_scripts.clone(),
vec![],
);
let mut page_ctx = PageContext::from_static_route(
content_sources,
&mut route_assets,
&url,
&options.base_url,
None,
);
let result = route.build(&mut page_ctx)?;
let access_log = page_ctx.take_access_log();
write_route_file(
&result,
&file_path,
&mut created_dirs,
&mut pages_with_assets,
assets_prefix_bytes,
)?;
info!(target: "pages", "{} -> {} {}", url, file_path.to_string_lossy().dimmed(), format_elapsed_time(page_start.elapsed(), &route_format_options));
if let Some(page_key) = page_key {
record_page_cache_entry(
&mut new_cache,
page_key,
access_log,
&route_assets,
file_path.clone(),
);
}
build_pages_images.extend(route_assets.images);
build_pages_scripts.extend(route_assets.scripts);
build_pages_styles.extend(route_assets.styles);
build_metadata.add_page(
base_path.clone(),
file_path.to_string_lossy().to_string(),
None,
false,
);
add_sitemap_entry(
&mut sitemap_entries,
normalized_base_url,
&url,
base_path,
&route.sitemap_metadata(),
&options.sitemap,
);
rendered_count += 1;
}
} else {
let mut pages_route_assets = RouteAssets::with_default_assets(
&route_assets_options,
Some(image_cache.clone()),
Some(asset_hash_cache.clone()),
default_scripts.clone(),
vec![],
);
let mut dynamic_ctx =
DynamicRouteContext::new(content_sources, &mut pages_route_assets, None);
let pages = route.get_pages(&mut dynamic_ctx);
let get_pages_access_log = dynamic_ctx.take_access_log();
if pages.is_empty() {
warn!(target: "build", "{} is a dynamic route, but its implementation of Route::pages returned an empty Vec. No pages will be generated for this route.", base_path.bold());
continue;
}
info!(target: "pages", "{}", base_path);
if new_cache.is_some() {
for page in pages {
let page_key = cache::PageKey::new(base_path, &page.0.0, None);
let (url, file_path) =
cached_route.url_and_file_path(&page.0, &options.output_dir);
if try_cache_hit(
*route,
&page_key,
&incremental_state,
&mut new_cache,
&route_assets_options,
&mut build_pages_scripts,
&mut build_pages_styles,
) {
info!(target: "pages", "├─ {} (cached)", file_path.to_string_lossy().dimmed());
build_metadata.add_page(
base_path.clone(),
file_path.to_string_lossy().to_string(),
Some(page.0.0.clone()),
true,
);
add_sitemap_entry(
&mut sitemap_entries,
normalized_base_url,
&url,
base_path,
&route.sitemap_metadata(),
&options.sitemap,
);
cached_count += 1;
continue;
}
let page_start = Instant::now();
let mut route_assets = RouteAssets::with_default_assets(
&route_assets_options,
Some(image_cache.clone()),
Some(asset_hash_cache.clone()),
default_scripts.clone(),
vec![],
);
let mut page_ctx = PageContext::from_dynamic_route(
&page,
content_sources,
&mut route_assets,
&url,
&options.base_url,
None,
);
let content = route.build(&mut page_ctx)?;
let mut access_log = page_ctx.take_access_log();
access_log.merge_entries_read(&get_pages_access_log);
if let Some((src, id)) = &page.3 {
access_log.entries_read.push((src.clone(), id.clone()));
} else if access_log.entries_read.is_empty()
&& access_log.sources_iterated.is_empty()
{
access_log
.sources_iterated
.extend(get_pages_access_log.sources_iterated.iter().cloned());
}
write_route_file(
&content,
&file_path,
&mut created_dirs,
&mut pages_with_assets,
assets_prefix_bytes,
)?;
info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(page_start.elapsed(), &route_format_options));
record_page_cache_entry(
&mut new_cache,
page_key,
access_log,
&route_assets,
file_path.clone(),
);
build_pages_images.extend(route_assets.images);
build_pages_scripts.extend(route_assets.scripts);
build_pages_styles.extend(route_assets.styles);
build_metadata.add_page(
base_path.clone(),
file_path.to_string_lossy().to_string(),
Some(page.0.0.clone()),
false,
);
add_sitemap_entry(
&mut sitemap_entries,
normalized_base_url,
&url,
base_path,
&route.sitemap_metadata(),
&options.sitemap,
);
rendered_count += 1;
}
} else {
let route_assets = shared_route_assets.as_mut().unwrap();
for page in pages {
let page_start = Instant::now();
let (url, file_path) =
cached_route.url_and_file_path(&page.0, &options.output_dir);
let content = route.build(&mut PageContext::from_dynamic_route(
&page,
content_sources,
route_assets,
&url,
&options.base_url,
None,
))?;
write_route_file(
&content,
&file_path,
&mut created_dirs,
&mut pages_with_assets,
assets_prefix_bytes,
)?;
info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(page_start.elapsed(), &route_format_options));
build_metadata.add_page(
base_path.clone(),
file_path.to_string_lossy().to_string(),
Some(page.0.0.clone()),
false,
);
add_sitemap_entry(
&mut sitemap_entries,
normalized_base_url,
&url,
base_path,
&route.sitemap_metadata(),
&options.sitemap,
);
rendered_count += 1;
}
}
}
}
for (variant_id, variant_path) in variants {
let variant_params = extract_params_from_raw_route(&variant_path);
if variant_params.is_empty() {
let params = PageParams::default();
let (url, file_path) = cached_route.variant_url_and_file_path(
¶ms,
&options.output_dir,
&variant_id,
)?;
let page_key = if new_cache.is_some() {
Some(cache::PageKey::new_static(&variant_path, Some(&variant_id)))
} else {
None
};
let cache_hit = page_key.as_ref().is_some_and(|pk| {
try_cache_hit(
*route,
pk,
&incremental_state,
&mut new_cache,
&route_assets_options,
&mut build_pages_scripts,
&mut build_pages_styles,
)
});
if cache_hit {
info!(target: "pages", "├─ {} (cached)", file_path.to_string_lossy().dimmed());
build_metadata.add_page(
variant_path.clone(),
file_path.to_string_lossy().to_string(),
None,
true,
);
add_sitemap_entry(
&mut sitemap_entries,
normalized_base_url,
&url,
&variant_path,
&route.sitemap_metadata(),
&options.sitemap,
);
cached_count += 1;
continue;
}
let variant_start = Instant::now();
let mut route_assets = RouteAssets::with_default_assets(
&route_assets_options,
Some(image_cache.clone()),
Some(asset_hash_cache.clone()),
default_scripts.clone(),
vec![],
);
let mut page_ctx = PageContext::from_static_route(
content_sources,
&mut route_assets,
&url,
&options.base_url,
Some(variant_id.clone()),
);
let result = route.build(&mut page_ctx)?;
let access_log = page_ctx.take_access_log();
write_route_file(
&result,
&file_path,
&mut created_dirs,
&mut pages_with_assets,
assets_prefix_bytes,
)?;
info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_start.elapsed(), &route_format_options));
if let Some(page_key) = page_key {
record_page_cache_entry(
&mut new_cache,
page_key,
access_log,
&route_assets,
file_path.clone(),
);
}
build_pages_images.extend(route_assets.images);
build_pages_scripts.extend(route_assets.scripts);
build_pages_styles.extend(route_assets.styles);
build_metadata.add_page(
variant_path.clone(),
file_path.to_string_lossy().to_string(),
None,
false,
);
add_sitemap_entry(
&mut sitemap_entries,
normalized_base_url,
&url,
&variant_path,
&route.sitemap_metadata(),
&options.sitemap,
);
rendered_count += 1;
} else {
let mut pages_route_assets = RouteAssets::with_default_assets(
&route_assets_options,
Some(image_cache.clone()),
Some(asset_hash_cache.clone()),
default_scripts.clone(),
vec![],
);
let mut dynamic_ctx = DynamicRouteContext::new(
content_sources,
&mut pages_route_assets,
Some(&variant_id),
);
let pages = route.get_pages(&mut dynamic_ctx);
let get_pages_access_log = dynamic_ctx.take_access_log();
if pages.is_empty() {
warn!(target: "build", "Variant {} has dynamic parameters but Route::pages returned an empty Vec.", variant_id.bold());
continue;
}
info!(target: "pages", "├─ {}", variant_path);
if new_cache.is_some() {
for page in pages {
let page_key =
cache::PageKey::new(&variant_path, &page.0.0, Some(&variant_id));
let (url, file_path) = cached_route.variant_url_and_file_path(
&page.0,
&options.output_dir,
&variant_id,
)?;
if try_cache_hit(
*route,
&page_key,
&incremental_state,
&mut new_cache,
&route_assets_options,
&mut build_pages_scripts,
&mut build_pages_styles,
) {
info!(target: "pages", "│ ├─ {} (cached)", file_path.to_string_lossy().dimmed());
build_metadata.add_page(
variant_path.clone(),
file_path.to_string_lossy().to_string(),
Some(page.0.0.clone()),
true,
);
add_sitemap_entry(
&mut sitemap_entries,
normalized_base_url,
&url,
&variant_path,
&route.sitemap_metadata(),
&options.sitemap,
);
cached_count += 1;
continue;
}
let variant_page_start = Instant::now();
let mut route_assets = RouteAssets::with_default_assets(
&route_assets_options,
Some(image_cache.clone()),
Some(asset_hash_cache.clone()),
default_scripts.clone(),
vec![],
);
let mut page_ctx = PageContext::from_dynamic_route(
&page,
content_sources,
&mut route_assets,
&url,
&options.base_url,
Some(variant_id.clone()),
);
let content = route.build(&mut page_ctx)?;
let mut access_log = page_ctx.take_access_log();
access_log.merge_entries_read(&get_pages_access_log);
if let Some((src, id)) = &page.3 {
access_log.entries_read.push((src.clone(), id.clone()));
} else if access_log.entries_read.is_empty()
&& access_log.sources_iterated.is_empty()
{
access_log
.sources_iterated
.extend(get_pages_access_log.sources_iterated.iter().cloned());
}
write_route_file(
&content,
&file_path,
&mut created_dirs,
&mut pages_with_assets,
assets_prefix_bytes,
)?;
info!(target: "pages", "│ ├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_page_start.elapsed(), &route_format_options));
record_page_cache_entry(
&mut new_cache,
page_key,
access_log,
&route_assets,
file_path.clone(),
);
build_pages_images.extend(route_assets.images);
build_pages_scripts.extend(route_assets.scripts);
build_pages_styles.extend(route_assets.styles);
build_metadata.add_page(
variant_path.clone(),
file_path.to_string_lossy().to_string(),
Some(page.0.0.clone()),
false,
);
add_sitemap_entry(
&mut sitemap_entries,
normalized_base_url,
&url,
&variant_path,
&route.sitemap_metadata(),
&options.sitemap,
);
rendered_count += 1;
}
} else {
let route_assets = shared_route_assets.as_mut().unwrap();
for page in pages {
let variant_page_start = Instant::now();
let (url, file_path) = cached_route.variant_url_and_file_path(
&page.0,
&options.output_dir,
&variant_id,
)?;
let content = route.build(&mut PageContext::from_dynamic_route(
&page,
content_sources,
route_assets,
&url,
&options.base_url,
Some(variant_id.clone()),
))?;
write_route_file(
&content,
&file_path,
&mut created_dirs,
&mut pages_with_assets,
assets_prefix_bytes,
)?;
info!(target: "pages", "│ ├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_page_start.elapsed(), &route_format_options));
build_metadata.add_page(
variant_path.clone(),
file_path.to_string_lossy().to_string(),
Some(page.0.0.clone()),
false,
);
add_sitemap_entry(
&mut sitemap_entries,
normalized_base_url,
&url,
&variant_path,
&route.sitemap_metadata(),
&options.sitemap,
);
rendered_count += 1;
}
}
}
}
}
if let Some(route_assets) = shared_route_assets {
build_pages_images.extend(route_assets.images);
build_pages_scripts.extend(route_assets.scripts);
build_pages_styles.extend(route_assets.styles);
}
let page_count = rendered_count + cached_count;
if cached_count > 0 {
info!(target: "pages", "{}", format!("generated {} pages ({} rendered, {} cached) in {}", page_count, rendered_count, cached_count, format_elapsed_time(pages_start.elapsed(), §ion_format_options)).bold());
} else {
info!(target: "pages", "{}", format!("generated {} pages in {}", page_count, format_elapsed_time(pages_start.elapsed(), §ion_format_options)).bold());
}
if let Some(ref mut cache) = new_cache {
let asset_hash_start = Instant::now();
let previous_fingerprints = incremental_state
.previous_cache
.as_ref()
.map(|c| &c.asset_file_hashes);
let mut compute_or_reuse = |path: &PathBuf| {
cache
.asset_file_hashes
.entry(path.clone())
.or_insert_with(|| {
if let Some(prev) = previous_fingerprints
&& let Some(fp) = prev.get(path)
&& let Some((mtime, size)) = cache::file_fingerprint(path)
&& mtime == fp.mtime_ns
&& size == fp.size
{
return fp.clone();
}
cache::AssetFileFingerprint::from_path(path).unwrap_or(
cache::AssetFileFingerprint {
hash: String::new(),
mtime_ns: 0,
size: 0,
},
)
});
};
let asset_paths: Vec<PathBuf> = cache
.pages
.values()
.flat_map(|page_entry| {
page_entry
.images
.iter()
.map(|a| a.path.clone())
.chain(page_entry.scripts.iter().map(|a| a.path.clone()))
.chain(page_entry.styles.iter().map(|a| a.path.clone()))
})
.collect();
for path in asset_paths {
compute_or_reuse(&path);
}
info!(target: "cache", "Asset fingerprints computed in {}", format_elapsed_time(asset_hash_start.elapsed(), &FormatElapsedTimeOptions::default()));
let hash_cache = asset_hash_cache.borrow();
for (key, asset_hash) in hash_cache.iter() {
if let Some((mtime, size)) = cache::file_fingerprint(key.path()) {
cache
.persisted_asset_hashes
.entry(key.path().to_path_buf())
.or_default()
.push(cache::PersistedAssetHash {
options_hash: key.options_hash(),
asset_hash: asset_hash.clone(),
mtime_ns: mtime,
size,
});
}
}
}
if (!build_pages_images.is_empty())
|| !build_pages_styles.is_empty()
|| !build_pages_scripts.is_empty()
{
fs::create_dir_all(&route_assets_options.output_assets_dir)?;
}
let should_bundle = if !incremental_state.is_full_build() {
let current_bundled_scripts: FxHashSet<cache::SerializedAssetRef> = build_pages_scripts
.iter()
.map(|s| cache::SerializedAssetRef {
path: s.path.clone(),
hash: s.hash.clone(),
})
.collect();
let current_bundled_styles: FxHashSet<cache::SerializedAssetRef> = build_pages_styles
.iter()
.map(|s| cache::SerializedAssetRef {
path: s.path.clone(),
hash: s.hash.clone(),
})
.collect();
let has_tailwind = build_pages_styles.iter().any(|s| s.tailwind);
let content_changed = rendered_count > 0;
let needs_bundle = if has_tailwind && content_changed {
true
} else if let Some(prev) = &incremental_state.previous_cache {
cache::needs_rebundle(
&prev.bundled_scripts,
&prev.bundled_styles,
¤t_bundled_scripts,
¤t_bundled_styles,
&prev.css_url_dependencies,
&prev.script_asset_dependencies,
)
} else {
true
};
if let Some(ref mut cache) = new_cache {
cache.bundled_scripts = current_bundled_scripts.into_iter().collect();
cache.bundled_styles = current_bundled_styles.into_iter().collect();
}
needs_bundle
} else {
if let Some(ref mut cache) = new_cache {
cache.bundled_scripts = build_pages_scripts
.iter()
.map(|s| cache::SerializedAssetRef {
path: s.path.clone(),
hash: s.hash.clone(),
})
.collect();
cache.bundled_styles = build_pages_styles
.iter()
.map(|s| cache::SerializedAssetRef {
path: s.path.clone(),
hash: s.hash.clone(),
})
.collect();
}
true
};
let mut script_substitutions: FxHashMap<String, String> = FxHashMap::default();
let mut style_substitutions: FxHashMap<String, String> = FxHashMap::default();
if should_bundle && (!build_pages_styles.is_empty() || !build_pages_scripts.is_empty()) {
let assets_start = Instant::now();
print_title("generating assets");
let mut current_output_files: FxHashSet<String> = FxHashSet::default();
if !build_pages_styles.is_empty() {
let should_minify = !is_dev();
for style in &build_pages_styles {
debug!(
target: "bundling",
"Processing CSS: {:?}",
style.path()
);
let tailwind_output = if style.tailwind {
Some(run_tailwind(
&options.assets.tailwind_binary_path,
style.path(),
)?)
} else {
None
};
let css_output = bundle_css(
style.path(),
tailwind_output.as_deref(),
should_minify,
&route_assets_options.output_assets_dir,
)?;
let output_hash = hash_asset_bytes(css_output.code.as_bytes());
let stem = bare_script_chunk_name(style.path());
let filename = format!("{}-{}.css", stem, output_hash);
let final_path = route_assets_options.output_assets_dir.join(&filename);
if let Some(parent) = final_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&final_path, &css_output.code)?;
build_metadata.add_asset(final_path.to_string_lossy().to_string());
current_output_files.insert(filename.clone());
let final_url =
make_final_url(&route_assets_options.assets_dir, Path::new(&filename));
style_substitutions.insert(style.url.clone(), final_url);
for asset_filename in &css_output.copied_asset_filenames {
current_output_files.insert(asset_filename.clone());
}
if let Some(ref mut cache) = new_cache {
for dep_path in &css_output.source_dependencies {
if let Some(fp) = cache::AssetFileFingerprint::from_path(dep_path) {
cache.css_url_dependencies.insert(dep_path.clone(), fp);
}
}
}
}
}
let bundler_inputs = build_pages_scripts
.iter()
.map(|script| InputItem {
import: script.path().to_string_lossy().to_string(),
name: Some(bare_script_chunk_name(script.path())),
})
.collect::<Vec<InputItem>>();
debug!(
target: "bundling",
"Bundler inputs: {:?}",
bundler_inputs
.iter()
.map(|input| input.import.clone())
.collect::<Vec<String>>()
);
if !bundler_inputs.is_empty() {
let scripts_by_path: FxHashMap<String, &Script> = build_pages_scripts
.iter()
.map(|s| (s.path().to_string_lossy().into_owned(), s))
.collect();
let mut bundler = Bundler::with_plugins(
BundlerOptions {
input: Some(bundler_inputs),
cwd: env::current_dir().ok(),
minify: Some(rolldown::RawMinifyOptions::Bool(!is_dev())),
dir: Some(
route_assets_options
.output_assets_dir
.to_string_lossy()
.to_string(),
),
entry_filenames: Some("[name]-[hash].js".to_string().into()),
chunk_filenames: Some("[name]-[hash].js".to_string().into()),
experimental: Some(ExperimentalOptions {
resolve_new_url_to_asset: Some(true),
..Default::default()
}),
..Default::default()
},
vec![
Arc::new(PrefetchPlugin {}),
Arc::new(ReplacePlugin::new(FxHashMap::default())?),
],
)?;
let result = bundler.write().await?;
for output in &result.assets {
let filename = output.filename().to_string();
build_metadata.add_asset(
route_assets_options
.output_assets_dir
.join(&filename)
.to_string_lossy()
.to_string(),
);
current_output_files.insert(filename.clone());
match output {
rolldown_common::Output::Chunk(chunk) if chunk.is_entry => {
if let Some(facade) = chunk.facade_module_id.as_ref()
&& let Some(script) = scripts_by_path.get(facade.as_str())
{
let final_url = make_final_url(
&route_assets_options.assets_dir,
Path::new(&filename),
);
script_substitutions.insert(script.url.clone(), final_url);
}
}
rolldown_common::Output::Asset(asset) => {
if let Some(ref mut cache) = new_cache {
for original in &asset.original_file_names {
let path = PathBuf::from(original);
if let Some(fp) = cache::AssetFileFingerprint::from_path(&path) {
cache.script_asset_dependencies.insert(path, fp);
}
}
}
}
_ => {}
}
}
}
if !incremental_state.is_full_build()
&& let Some(prev_cache) = &incremental_state.previous_cache
{
for stale_file in &prev_cache.bundled_output_files {
if !current_output_files.contains(stale_file) {
let stale_path = route_assets_options.output_assets_dir.join(stale_file);
if fs::remove_file(&stale_path).is_ok() {
info!(target: "cache", "Removed stale bundle: {}", stale_path.display());
}
}
}
}
if let Some(ref mut cache) = new_cache {
cache.bundled_output_files = current_output_files;
cache.script_substitutions = script_substitutions.clone();
cache.style_substitutions = style_substitutions.clone();
}
info!(target: "build", "{}", format!("Assets generated in {}", format_elapsed_time(assets_start.elapsed(), §ion_format_options)).bold());
} else if !should_bundle && (!build_pages_styles.is_empty() || !build_pages_scripts.is_empty())
{
if let Some(ref mut cache) = new_cache
&& let Some(prev_cache) = &incremental_state.previous_cache
{
cache.bundled_output_files = prev_cache.bundled_output_files.clone();
cache.css_url_dependencies = prev_cache.css_url_dependencies.clone();
cache.script_asset_dependencies = prev_cache.script_asset_dependencies.clone();
cache.script_substitutions = prev_cache.script_substitutions.clone();
cache.style_substitutions = prev_cache.style_substitutions.clone();
script_substitutions = prev_cache.script_substitutions.clone();
style_substitutions = prev_cache.style_substitutions.clone();
}
info!(target: "build", "Assets unchanged, skipping bundling");
}
if !pages_with_assets.is_empty()
|| !script_substitutions.is_empty()
|| !style_substitutions.is_empty()
{
let mut new_map = script_substitutions.clone();
new_map.extend(
style_substitutions
.iter()
.map(|(k, v)| (k.clone(), v.clone())),
);
for (rendered, resolved) in &new_map {
build_metadata.record_asset_substitution(
rendered.clone(),
resolved.clone(),
url_to_disk_path(rendered, &options.output_dir),
url_to_disk_path(resolved, &options.output_dir),
);
}
let previous_map = incremental_state.previous_cache.as_ref().map(|c| {
let mut m = c.script_substitutions.clone();
m.extend(
c.style_substitutions
.iter()
.map(|(k, v)| (k.clone(), v.clone())),
);
m
});
substitute_asset_urls(
&pages_with_assets,
&new_map,
previous_map.as_ref(),
new_cache.as_ref(),
)?;
}
if !build_pages_images.is_empty() {
print_title("processing images");
let start_time = Instant::now();
build_pages_images.par_iter().for_each(|image| {
let start_process = Instant::now();
let dest_path: &Path = image.build_path();
let image_cwd_relative = diff_paths(image.path(), env::current_dir().unwrap())
.unwrap_or_else(|| image.path().to_path_buf());
if let Some(image_options) = &image.options {
let final_filename = image.filename();
let cached_path = image_cache.get_transformed_image(final_filename);
if let Some(cached_path) = cached_path {
if fs::copy(&cached_path, dest_path).is_ok() {
info!(target: "assets", "{} -> {} (from cache) {}", image_cwd_relative.to_string_lossy(), dest_path.to_string_lossy().dimmed(), format_elapsed_time(start_process.elapsed(), &route_format_options).dimmed());
return;
}
}
let cache_path = image_cache.generate_cache_path(final_filename);
process_image(image, &cache_path, image_options);
if fs::copy(&cache_path, dest_path).is_ok() {
image_cache.cache_transformed_image(final_filename, cache_path);
} else {
debug!("Failed to copy from cache {} to dest {}", cache_path.display(), dest_path.display());
}
} else if !dest_path.exists() {
fs::copy(image.path(), dest_path).unwrap_or_else(|e| {
panic!(
"Failed to copy image from {} to {}: {}",
image.path().to_string_lossy(),
dest_path.to_string_lossy(),
e
)
});
}
info!(target: "assets", "{} -> {} {}", image_cwd_relative.to_string_lossy(), dest_path.to_string_lossy().dimmed(), format_elapsed_time(start_process.elapsed(), &route_format_options).dimmed());
});
info!(target: "assets", "{}", format!("Images processed in {}", format_elapsed_time(start_time.elapsed(), §ion_format_options)).bold());
}
if options.static_dir.exists() {
let assets_start = Instant::now();
print_title("copying assets");
copy_recursively(
&options.static_dir,
&options.output_dir,
&mut build_metadata,
)?;
info!(target: "build", "{}", format!("Assets copied in {}", format_elapsed_time(assets_start.elapsed(), &FormatElapsedTimeOptions::default())).bold());
}
{
let current_static_files: FxHashSet<String> = build_metadata
.static_files
.iter()
.map(|s| s.file_path.clone())
.collect();
if !incremental_state.is_full_build()
&& let Some(prev_cache) = &incremental_state.previous_cache
{
let stale =
cache::find_stale_static_files(&prev_cache.static_files, ¤t_static_files);
for path in &stale {
if fs::remove_file(path).is_ok() {
info!(target: "cache", "Removed stale static file: {path}");
}
}
}
if let Some(ref mut cache) = new_cache {
cache.static_files = current_static_files;
}
}
if !incremental_state.is_full_build()
&& let Some(ref cache) = new_cache
{
let current_page_keys: FxHashSet<cache::PageKey> = cache.pages.keys().cloned().collect();
if let Some(prev_cache) = &incremental_state.previous_cache {
let stale = cache::find_stale_pages(&prev_cache.pages, ¤t_page_keys);
build_metadata.removed_pages = stale.len();
for stale_key in &stale {
if let Some(entry) = prev_cache.pages.get(stale_key)
&& fs::remove_file(&entry.output_file).is_ok()
{
info!(target: "cache", "Removed stale output: {}", entry.output_file.display());
}
}
}
}
if options.sitemap.enabled {
if let Some(base_url) = normalized_base_url {
if !build_metadata.has_changes() && !incremental_state.is_full_build() {
info!(target: "build", "Sitemap unchanged, skipping regeneration");
} else {
let sitemap_start = Instant::now();
print_title("generating sitemap");
generate_sitemap(
sitemap_entries,
base_url,
&options.output_dir,
&options.sitemap,
)?;
info!(target: "build", "{}", format!("Sitemap generated in {}", format_elapsed_time(sitemap_start.elapsed(), &FormatElapsedTimeOptions::default())).bold());
}
} else {
warn!(target: "build", "Sitemap generation is enabled but no base_url is set in BuildOptions. Either disable sitemap generation or set a base_url to enable it.");
}
}
info!(target: "SKIP_FORMAT", "{}", "");
info!(target: "build", "{}", format!("Build completed in {}", format_elapsed_time(build_start.elapsed(), §ion_format_options)).bold());
{
let cache_save_start = Instant::now();
if !image_cache.is_empty() {
let mut live_src_paths = FxHashSet::default();
let mut live_transformed = FxHashSet::default();
if let Some(ref cache) = new_cache {
for img in cache.pages.values().flat_map(|p| &p.images) {
live_src_paths.insert(img.path.clone());
live_transformed.insert(img.filename.clone());
}
} else {
for img in &build_pages_images {
live_src_paths.insert(img.path().to_path_buf());
live_transformed.insert(img.filename().to_path_buf());
}
};
let evicted = image_cache.gc(&live_src_paths, &live_transformed);
if evicted > 0 {
info!(target: "cache", "Image cache GC: evicted {} stale entries", evicted);
}
if let Err(e) = image_cache.save(&options.cache_dir) {
warn!(target: "cache", "Failed to save image cache: {}", e);
}
}
if let Some(cache) = new_cache {
if let Err(e) = cache.save(&options.cache_dir) {
warn!(target: "cache", "Failed to save build cache: {}", e);
} else {
info!(target: "cache", "Build cache saved in {}", format_elapsed_time(cache_save_start.elapsed(), &FormatElapsedTimeOptions::default()));
}
}
}
if let Some(clean_up_handle) = clean_up_handle {
clean_up_handle.await?;
}
Ok(build_metadata)
}
fn add_sitemap_entry(
sitemap_entries: &mut Vec<SitemapEntry>,
base_url: Option<&str>,
url: &str,
route_path: &str,
sitemap_metadata: &crate::sitemap::RouteSitemapMetadata,
sitemap_options: &crate::sitemap::SitemapOptions,
) {
let Some(base_url) = base_url else {
return;
};
if sitemap_metadata.exclude.unwrap_or(false) || route_path.contains("404") {
return;
}
let full_url = if url == "/" {
base_url.to_string()
} else {
format!("{}{}", base_url, url)
};
sitemap_entries.push(SitemapEntry {
loc: full_url,
lastmod: None,
changefreq: sitemap_metadata
.changefreq
.or(sitemap_options.default_changefreq),
priority: sitemap_metadata
.priority
.or(sitemap_options.default_priority),
});
}
fn copy_recursively(
source: impl AsRef<Path>,
destination: impl AsRef<Path>,
build_metadata: &mut BuildOutput,
) -> io::Result<()> {
fs::create_dir_all(&destination)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let filetype = entry.file_type()?;
if filetype.is_dir() {
copy_recursively(
entry.path(),
destination.as_ref().join(entry.file_name()),
build_metadata,
)?;
} else {
fs::copy(entry.path(), destination.as_ref().join(entry.file_name()))?;
build_metadata.add_static_file(
destination
.as_ref()
.join(entry.file_name())
.to_string_lossy()
.to_string(),
entry.path().to_string_lossy().to_string(),
);
}
}
Ok(())
}
fn hash_asset_bytes(bytes: &[u8]) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = rapidhash::fast::RapidHasher::default();
bytes.hash(&mut hasher);
let hex = format!("{:016x}", hasher.finish());
hex[..5].to_string()
}
fn url_to_disk_path(url: &str, output_dir: &Path) -> PathBuf {
output_dir.join(url.trim_start_matches('/'))
}
fn bare_script_chunk_name(path: &Path) -> String {
use crate::assets::sanitize_filename::default_sanitize_file_name;
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("asset");
default_sanitize_file_name(stem)
}
fn write_route_file(
content: &[u8],
file_path: &PathBuf,
created_dirs: &mut FxHashSet<PathBuf>,
pages_with_assets: &mut Vec<PathBuf>,
assets_prefix: &[u8],
) -> Result<(), io::Error> {
if let Some(parent_dir) = file_path.parent()
&& created_dirs.insert(parent_dir.to_path_buf())
{
fs::create_dir_all(parent_dir)?;
}
fs::write(file_path, content)?;
if memchr::memmem::find(content, assets_prefix).is_some() {
pages_with_assets.push(file_path.clone());
}
Ok(())
}
fn substitute_asset_urls(
fresh_pages: &[PathBuf],
new_map: &FxHashMap<String, String>,
previous_map: Option<&FxHashMap<String, String>>,
new_cache: Option<&cache::BuildCache>,
) -> Result<(), io::Error> {
let fresh_patterns: Vec<(String, String)> = new_map
.iter()
.map(|(p, u)| (p.clone(), u.clone()))
.collect();
let remap_patterns: Vec<(String, String)> = match previous_map {
Some(prev) => prev
.iter()
.filter_map(|(placeholder, old_url)| {
let new_url = new_map.get(placeholder)?;
(old_url != new_url).then(|| (old_url.clone(), new_url.clone()))
})
.collect(),
None => Vec::new(),
};
let cached_pages: Vec<PathBuf> = if remap_patterns.is_empty() {
Vec::new()
} else if let Some(cache) = new_cache {
let fresh_set: FxHashSet<&PathBuf> = fresh_pages.iter().collect();
cache
.pages
.values()
.filter(|entry| !entry.scripts.is_empty() || !entry.styles.is_empty())
.map(|entry| entry.output_file.clone())
.filter(|path| !fresh_set.contains(path))
.collect()
} else {
Vec::new()
};
let fresh_replacer = build_replacer(&fresh_patterns);
let remap_replacer = build_replacer(&remap_patterns);
fresh_pages
.par_iter()
.try_for_each(|path| do_replace(path, fresh_replacer.as_ref()))?;
cached_pages
.par_iter()
.try_for_each(|path| do_replace(path, remap_replacer.as_ref()))?;
Ok(())
}
type Replacer = (aho_corasick::AhoCorasick, Vec<String>);
fn build_replacer(patterns: &[(String, String)]) -> Option<Replacer> {
if patterns.is_empty() {
return None;
}
let ac = aho_corasick::AhoCorasick::new(patterns.iter().map(|(n, _)| n.as_bytes())).ok()?;
let replacements = patterns.iter().map(|(_, r)| r.clone()).collect();
Some((ac, replacements))
}
fn do_replace(path: &PathBuf, replacer: Option<&Replacer>) -> Result<(), io::Error> {
let Some((ac, replacements)) = replacer else {
return Ok(());
};
let bytes = match fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
let mut out = Vec::with_capacity(bytes.len());
let mut last_end = 0;
for m in ac.find_iter(&bytes) {
out.extend_from_slice(&bytes[last_end..m.start()]);
out.extend_from_slice(replacements[m.pattern().as_usize()].as_bytes());
last_end = m.end();
}
if last_end == 0 {
return Ok(());
}
out.extend_from_slice(&bytes[last_end..]);
fs::write(path, &out)
}