cookcli 0.38.1

A command-line interface for managing and working with Cooklang recipes
Documentation
use anyhow::{Context, Result};
use camino::Utf8Path;
use std::fs;

/// Write `contents` to `output_root/relpath`, creating parent directories.
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(())
}

/// Copy `bytes` to `output_root/relpath`, creating parent directories.
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(())
}

/// Embedded `static/` paths that are build inputs rather than shipped assets.
/// Pages only link the compiled `output.css` and `editor.bundle.js`, so the
/// Tailwind sources and the unbundled editor modules stay out of the site.
const EXCLUDED_STATIC_PREFIXES: &[&str] = &["css/input.css", "css/components.css", "js/src/"];

/// Whether an embedded `static/` path (relative to `static/`) belongs in the
/// generated site.
fn is_shipped_asset(path: &str) -> bool {
    !EXCLUDED_STATIC_PREFIXES
        .iter()
        .any(|prefix| path.starts_with(prefix))
}

/// Copy every shipped file in the rust-embed `StaticFiles` to
/// `output_root/static/<path>`, skipping stylesheet and script sources.
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)
}

/// Copy a single source file into `output_root/api/static/<relpath>`.
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(())
}

/// Copy a `.cook` source file into `output_root/recipe/<relpath>` so visitors
/// can download the canonical recipe source alongside the rendered page.
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(())
}

/// File extensions worth precompressing. Images, fonts and other binary
/// formats are already compressed and are skipped.
const COMPRESSIBLE_EXTENSIONS: &[&str] =
    &["html", "css", "js", "json", "xml", "svg", "txt", "cook"];

/// Write a gzip-compressed sibling (`<file>.gz`) for every compressible file
/// under `output_root`, for static hosts that serve precompressed assets
/// (e.g. GitLab Pages). A `.gz` is only written when it is actually smaller
/// than the original. Returns the number of files compressed.
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()))?;

        // Only keep the .gz when it actually saves space; hosts fall back to
        // the uncompressed file when no sibling .gz exists.
        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"));
    }
}