use anyhow::{Context, Result};
use camino::Utf8Path;
use std::fs;
pub fn write_html(output_root: &Utf8Path, relpath: &Utf8Path, contents: &str) -> Result<()> {
let dest = output_root.join(relpath);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create parent dir: {parent}"))?;
}
fs::write(&dest, contents).with_context(|| format!("Failed to write: {dest}"))?;
Ok(())
}
pub fn write_bytes(output_root: &Utf8Path, relpath: &Utf8Path, bytes: &[u8]) -> Result<()> {
let dest = output_root.join(relpath);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create parent dir: {parent}"))?;
}
fs::write(&dest, bytes).with_context(|| format!("Failed to write: {dest}"))?;
Ok(())
}
const EXCLUDED_STATIC_PREFIXES: &[&str] = &["css/input.css", "css/components.css", "js/src/"];
fn is_shipped_asset(path: &str) -> bool {
!EXCLUDED_STATIC_PREFIXES
.iter()
.any(|prefix| path.starts_with(prefix))
}
pub fn copy_static_assets(output_root: &Utf8Path) -> Result<usize> {
let mut count = 0;
for path in crate::web::StaticFiles::iter() {
if !is_shipped_asset(path.as_ref()) {
continue;
}
let rel = Utf8Path::new("static").join(path.as_ref());
let file = crate::web::StaticFiles::get(path.as_ref())
.with_context(|| format!("Embedded file vanished: {path}"))?;
write_bytes(output_root, &rel, &file.data)?;
count += 1;
}
Ok(count)
}
pub fn copy_image(
output_root: &Utf8Path,
source_root: &Utf8Path,
abs_image: &Utf8Path,
) -> Result<()> {
let rel = abs_image
.strip_prefix(source_root)
.with_context(|| format!("Image {abs_image} not under source {source_root}"))?;
let dest = output_root.join("api/static").join(rel);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create parent dir: {parent}"))?;
}
fs::copy(abs_image, &dest).with_context(|| format!("Failed to copy {abs_image} -> {dest}"))?;
Ok(())
}
pub fn copy_recipe_source(
output_root: &Utf8Path,
source_root: &Utf8Path,
recipe_relpath: &str,
) -> Result<()> {
let src = source_root.join(recipe_relpath);
let dest = output_root.join("recipe").join(recipe_relpath);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create parent dir: {parent}"))?;
}
fs::copy(&src, &dest).with_context(|| format!("Failed to copy {src} -> {dest}"))?;
Ok(())
}
const COMPRESSIBLE_EXTENSIONS: &[&str] =
&["html", "css", "js", "json", "xml", "svg", "txt", "cook"];
pub fn compress_output(output_root: &Utf8Path) -> Result<usize> {
use flate2::{write::GzEncoder, Compression};
use std::io::Write;
let mut count = 0;
for entry in walkdir::WalkDir::new(output_root) {
let entry = entry.with_context(|| format!("Failed to walk: {output_root}"))?;
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let compressible = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| {
COMPRESSIBLE_EXTENSIONS
.iter()
.any(|c| ext.eq_ignore_ascii_case(c))
});
if !compressible {
continue;
}
let contents =
fs::read(path).with_context(|| format!("Failed to read: {}", path.display()))?;
let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
encoder
.write_all(&contents)
.with_context(|| format!("Failed to compress: {}", path.display()))?;
let compressed = encoder
.finish()
.with_context(|| format!("Failed to compress: {}", path.display()))?;
if compressed.len() < contents.len() {
let dest = format!("{}.gz", path.display());
fs::write(&dest, compressed).with_context(|| format!("Failed to write: {dest}"))?;
count += 1;
}
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn write_html_creates_nested_dirs() {
let tmp = TempDir::new().unwrap();
let root = camino::Utf8Path::from_path(tmp.path()).unwrap();
let rel = Utf8Path::new("a/b/c.html");
write_html(root, rel, "<html></html>").unwrap();
let contents = std::fs::read_to_string(root.join(rel)).unwrap();
assert_eq!(contents, "<html></html>");
}
#[test]
fn copy_static_assets_writes_known_file() {
let tmp = TempDir::new().unwrap();
let root = camino::Utf8Path::from_path(tmp.path()).unwrap();
let count = copy_static_assets(root).unwrap();
assert!(count > 0, "should copy at least one static asset");
assert!(root.join("static/css/output.css").is_file());
assert!(root.join("static/js/editor.bundle.js").is_file());
}
#[test]
fn copy_static_assets_skips_build_inputs() {
let tmp = TempDir::new().unwrap();
let root = camino::Utf8Path::from_path(tmp.path()).unwrap();
copy_static_assets(root).unwrap();
assert!(!root.join("static/css/input.css").exists());
assert!(!root.join("static/css/components.css").exists());
assert!(!root.join("static/js/src").exists());
}
#[test]
fn is_shipped_asset_excludes_sources_only() {
assert!(is_shipped_asset("css/output.css"));
assert!(is_shipped_asset("css/cooking-mode.css"));
assert!(is_shipped_asset("js/editor.bundle.js"));
assert!(is_shipped_asset("favicon.ico"));
assert!(!is_shipped_asset("css/input.css"));
assert!(!is_shipped_asset("css/components.css"));
assert!(!is_shipped_asset("js/src/editor.js"));
assert!(!is_shipped_asset("js/src/cooklang-mode.js"));
}
}