use anyhow::Result;
use std::path::{Path, PathBuf};
use wwwhat_core::{Config, server};
pub struct PreRenderConfig {
pub project_path: PathBuf,
pub output_path: PathBuf,
pub minify: bool,
}
pub struct PreRenderResult {
pub pages_rendered: usize,
pub pages_skipped: Vec<String>,
pub total_bytes: usize,
}
pub async fn prerender(config: PreRenderConfig) -> Result<PreRenderResult> {
let project_path = std::fs::canonicalize(&config.project_path)?;
let config_path = project_path.join("wwwhat.toml");
let app_config = if config_path.exists() {
Config::load(&config_path)?
} else {
Config::default()
};
let state = server::AppState::new(app_config, project_path.clone())?;
let routes = server::discover_routes(&project_path);
if routes.is_empty() {
anyhow::bail!(
"No pages found in {}/{}/",
project_path.display(),
server::content_dir_name(&project_path)
);
}
std::fs::create_dir_all(&config.output_path)?;
let mut result = PreRenderResult {
pages_rendered: 0,
pages_skipped: Vec::new(),
total_bytes: 0,
};
for (url_path, is_dynamic) in &routes {
if *is_dynamic {
result
.pages_skipped
.push(format!("{} (dynamic route)", url_path));
continue;
}
match server::render_page_to_html(&state, url_path).await {
Ok(Some(html)) => {
let final_html = if config.minify {
minify_html(&html)
} else {
html
};
let output_file = url_to_file_path(&config.output_path, url_path);
if let Some(parent) = output_file.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&output_file, &final_html)?;
result.total_bytes += final_html.len();
result.pages_rendered += 1;
tracing::info!(
" {} -> {} ({} bytes)",
url_path,
output_file.display(),
final_html.len()
);
}
Ok(None) => {
result
.pages_skipped
.push(format!("{} (auth required or excluded)", url_path));
}
Err(e) => {
tracing::warn!(" Failed to render {}: {}", url_path, e);
result
.pages_skipped
.push(format!("{} (error: {})", url_path, e));
}
}
}
copy_static_assets(&project_path, &config.output_path, config.minify)?;
Ok(result)
}
fn url_to_file_path(output_dir: &Path, url_path: &str) -> PathBuf {
let clean = url_path.trim_start_matches('/');
if clean.is_empty() {
output_dir.join("index.html")
} else {
output_dir.join(clean).join("index.html")
}
}
fn minify_html(html: &str) -> String {
let cfg = minify_html::Cfg {
minify_js: true,
minify_css: true,
..minify_html::Cfg::default()
};
let minified = minify_html::minify(html.as_bytes(), &cfg);
String::from_utf8(minified).unwrap_or_else(|_| html.to_string())
}
fn copy_static_assets(project_path: &Path, output_path: &Path, minify: bool) -> Result<()> {
let static_dest = output_path.join("static");
std::fs::create_dir_all(&static_dest)?;
std::fs::write(
static_dest.join("what.css"),
include_str!("../assets/client/what.css"),
)?;
std::fs::write(
static_dest.join("what.js"),
include_str!("../assets/client/what.js"),
)?;
let static_src = project_path.join("static");
if static_src.exists() {
copy_dir_all(&static_src, &static_dest)?;
}
if minify {
minify_css_files(&static_dest)?;
minify_js_files(&static_dest)?;
}
tracing::info!(" Copied static assets");
let uploads_src = project_path.join("uploads");
if uploads_src.exists() {
let uploads_dest = output_path.join("uploads");
copy_dir_all(&uploads_src, &uploads_dest)?;
tracing::info!(" Copied uploads");
}
Ok(())
}
fn minify_css_files(dir: &Path) -> Result<()> {
for entry in walkdir::WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.path().extension().map_or(false, |e| e == "css") {
if let Ok(css) = std::fs::read_to_string(entry.path()) {
let cfg = minify_html::Cfg {
minify_css: true,
..minify_html::Cfg::default()
};
let wrapped = format!("<style>{}</style>", css);
let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
if let Ok(result) = String::from_utf8(minified) {
let css_only = result
.strip_prefix("<style>")
.and_then(|s| s.strip_suffix("</style>"))
.unwrap_or(&result);
std::fs::write(entry.path(), css_only)?;
}
}
}
}
Ok(())
}
fn minify_js_files(dir: &Path) -> Result<()> {
for entry in walkdir::WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.path().extension().map_or(false, |e| e == "js") {
if let Ok(js) = std::fs::read_to_string(entry.path()) {
let cfg = minify_html::Cfg {
minify_js: true,
..minify_html::Cfg::default()
};
let wrapped = format!("<script>{}</script>", js);
let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
if let Ok(result) = String::from_utf8(minified) {
let js_only = result
.strip_prefix("<script>")
.and_then(|s| s.strip_suffix("</script>"))
.unwrap_or(&result);
std::fs::write(entry.path(), js_only)?;
}
}
}
}
Ok(())
}
fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_dir() {
copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
} else {
std::fs::copy(entry.path(), dst.join(entry.file_name()))?;
}
}
Ok(())
}
fn epoch_days_to_date(mut days: i64) -> (i64, u32, u32) {
days += 719468;
let era = if days >= 0 { days } else { days - 146096 } / 146097;
let doe = (days - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
pub fn generate_sitemap(project_path: &Path, base_url: &str) -> String {
let routes = server::discover_routes(project_path);
let base_url = base_url.trim_end_matches('/');
let mut xml = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
xml.push('\n');
xml.push_str(r#"<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">"#);
xml.push('\n');
let pages_dir = server::content_dir(project_path);
for (url_path, is_dynamic) in &routes {
if *is_dynamic {
continue;
}
if url_path.starts_with("/partials") {
continue;
}
let file_path = if *url_path == "/" {
pages_dir.join("index.html")
} else {
let clean = url_path.trim_start_matches('/');
let direct = pages_dir.join(format!("{}.html", clean));
if direct.exists() {
direct
} else {
pages_dir.join(clean).join("index.html")
}
};
let lastmod = file_path
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.map(|t| {
let duration = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = duration.as_secs() as i64;
let days = secs / 86400;
let (year, month, day) = epoch_days_to_date(days);
format!("{:04}-{:02}-{:02}", year, month, day)
});
xml.push_str(" <url>\n");
xml.push_str(&format!(" <loc>{}{}</loc>\n", base_url, url_path));
if let Some(date) = lastmod {
xml.push_str(&format!(" <lastmod>{}</lastmod>\n", date));
}
xml.push_str(" </url>\n");
}
xml.push_str("</urlset>\n");
xml
}
pub fn hash_static_assets(output_dir: &Path) -> Result<Vec<(String, String)>> {
use std::collections::HashMap;
use std::io::Read;
let static_dir = output_dir.join("static");
if !static_dir.exists() {
return Ok(Vec::new());
}
let mut renames: HashMap<String, String> = HashMap::new();
let mut renamed_files = Vec::new();
let entries: Vec<_> = std::fs::read_dir(&static_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file())
.collect();
for entry in &entries {
let path = entry.path();
let filename = path.file_name().unwrap().to_string_lossy().to_string();
let mut file = std::fs::File::open(&path)?;
let mut content = Vec::new();
file.read_to_end(&mut content)?;
let hash = fnv1a_hash(&content);
let hash_hex = format!("{:08x}", hash);
let hashed_name = if let Some(dot_pos) = filename.rfind('.') {
format!(
"{}.{}{}",
&filename[..dot_pos],
hash_hex,
&filename[dot_pos..]
)
} else {
format!("{}.{}", filename, hash_hex)
};
let new_path = static_dir.join(&hashed_name);
std::fs::rename(&path, &new_path)?;
renames.insert(filename.clone(), hashed_name.clone());
renamed_files.push((filename, hashed_name));
}
if renames.is_empty() {
return Ok(renamed_files);
}
rewrite_html_references(output_dir, &renames)?;
Ok(renamed_files)
}
fn rewrite_html_references(
dir: &Path,
renames: &std::collections::HashMap<String, String>,
) -> Result<()> {
for entry in std::fs::read_dir(dir)?.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
rewrite_html_references(&path, renames)?;
} else if path.extension().map_or(false, |ext| ext == "html") {
let mut content = std::fs::read_to_string(&path)?;
let mut changed = false;
for (original, hashed) in renames {
let old_ref = format!("/static/{}", original);
let new_ref = format!("/static/{}", hashed);
if content.contains(&old_ref) {
content = content.replace(&old_ref, &new_ref);
changed = true;
}
}
if changed {
std::fs::write(&path, content)?;
}
}
}
Ok(())
}
fn fnv1a_hash(data: &[u8]) -> u32 {
let mut hash: u32 = 0x811c9dc5;
for &byte in data {
hash ^= byte as u32;
hash = hash.wrapping_mul(0x01000193);
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_to_file_path() {
let out = PathBuf::from("/tmp/dist");
assert_eq!(
url_to_file_path(&out, "/"),
PathBuf::from("/tmp/dist/index.html")
);
assert_eq!(
url_to_file_path(&out, "/about"),
PathBuf::from("/tmp/dist/about/index.html")
);
assert_eq!(
url_to_file_path(&out, "/demo/ui/buttons"),
PathBuf::from("/tmp/dist/demo/ui/buttons/index.html")
);
}
#[test]
fn test_minify_html_basic() {
let html = "<html> <body> <h1>Hello</h1> </body> </html>";
let minified = minify_html(html);
assert!(minified.len() <= html.len());
assert!(minified.contains("Hello"));
}
#[test]
fn test_sitemap_basic_content() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path();
let cd = server::content_dir_name(root);
std::fs::create_dir_all(root.join(cd)).unwrap();
std::fs::write(root.join(cd).join("index.html"), "<h1>Home</h1>").unwrap();
std::fs::write(root.join(cd).join("about.html"), "<h1>About</h1>").unwrap();
let sitemap = generate_sitemap(root, "https://example.com");
assert!(sitemap.contains(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
assert!(sitemap.contains("<urlset"));
assert!(sitemap.contains("<loc>https://example.com/</loc>"));
assert!(sitemap.contains("<loc>https://example.com/about</loc>"));
assert!(sitemap.contains("<lastmod>"));
}
#[test]
fn test_sitemap_excludes_dynamic_and_partials() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path();
let cd = server::content_dir_name(root);
std::fs::create_dir_all(root.join(cd).join("blog")).unwrap();
std::fs::create_dir_all(root.join(cd).join("partials")).unwrap();
std::fs::write(root.join(cd).join("index.html"), "<h1>Home</h1>").unwrap();
std::fs::write(root.join(cd).join("blog/[id].html"), "<h1>Post</h1>").unwrap();
std::fs::write(
root.join(cd).join("partials/header.html"),
"<header>Nav</header>",
)
.unwrap();
let sitemap = generate_sitemap(root, "https://example.com");
assert!(sitemap.contains("<loc>https://example.com/</loc>"));
assert!(
!sitemap.contains("[id]"),
"Dynamic routes should be excluded"
);
assert!(
!sitemap.contains("/partials"),
"Partials should be excluded"
);
}
#[test]
fn test_epoch_days_to_date_known() {
let (y, m, d) = epoch_days_to_date(19723);
assert_eq!((y, m, d), (2024, 1, 1));
}
#[test]
fn test_fnv1a_hash_deterministic() {
let hash1 = fnv1a_hash(b"hello world");
let hash2 = fnv1a_hash(b"hello world");
assert_eq!(hash1, hash2);
}
#[test]
fn test_fnv1a_hash_different_inputs() {
let hash1 = fnv1a_hash(b"what.js");
let hash2 = fnv1a_hash(b"what.css");
assert_ne!(hash1, hash2);
}
#[test]
fn test_hash_static_assets_basic() {
let temp = tempfile::TempDir::new().unwrap();
let output = temp.path();
let static_dir = output.join("static");
std::fs::create_dir_all(&static_dir).unwrap();
std::fs::write(static_dir.join("app.js"), "console.log('hello')").unwrap();
std::fs::write(static_dir.join("style.css"), "body { color: red }").unwrap();
std::fs::write(
output.join("index.html"),
r#"<link href="/static/style.css"><script src="/static/app.js"></script>"#,
)
.unwrap();
let result = hash_static_assets(output).unwrap();
assert_eq!(result.len(), 2);
assert!(!static_dir.join("app.js").exists());
assert!(!static_dir.join("style.css").exists());
for (_, hashed_name) in &result {
assert!(
static_dir.join(hashed_name).exists(),
"Expected {} to exist",
hashed_name
);
}
let html = std::fs::read_to_string(output.join("index.html")).unwrap();
assert!(
!html.contains("/static/app.js"),
"Should not contain original reference"
);
assert!(
!html.contains("/static/style.css"),
"Should not contain original reference"
);
for (_, hashed_name) in &result {
assert!(
html.contains(hashed_name),
"HTML should reference {}",
hashed_name
);
}
}
#[test]
fn test_minify_js_files() {
let temp = tempfile::TempDir::new().unwrap();
let dir = temp.path();
let js = "function hello(name) {\n console.log('Hello, ' + name);\n}\n";
std::fs::write(dir.join("app.js"), js).unwrap();
minify_js_files(dir).unwrap();
let result = std::fs::read_to_string(dir.join("app.js")).unwrap();
assert!(result.len() < js.len(), "Minified JS should be smaller");
assert!(result.contains("hello"), "Should preserve function name");
}
#[test]
fn test_hash_static_assets_no_static_dir() {
let temp = tempfile::TempDir::new().unwrap();
let result = hash_static_assets(temp.path()).unwrap();
assert!(result.is_empty());
}
}