compile-typst-site 0.3.0

Binary tool for static site generation using Typst.
Documentation
//! Compile Typst to HTML given paths and a [`crate::config::Config`].

use anyhow::{Context, Result, anyhow};
use std::ffi::OsStr;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::mpsc::{self, Sender, TryRecvError};
use walkdir::WalkDir;

use crate::config::Config;

/// Return paths to the files in source we will process.
///
/// This includes data files we ignore, stuff we pass through, typ files, everything.
/// i.e. we walk through the source dir.
/// Ignores inaccessible such files.
pub fn source_files(config: &Config) -> impl Iterator<Item = PathBuf> {
    WalkDir::new(config.project_root.join(&config.content_root))
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|entry| entry.metadata().unwrap().is_file())
        .map(|entry| entry.path().to_path_buf())
}

pub enum CompileOutput {
    Noop,
    Passthrough(PathBuf),
    RecompileAll,
    CompileToPath(PathBuf),
}

impl CompileOutput {
    pub fn from_full_path(full_path: &Path, config: &Config) -> Result<Self> {
        if config.passthrough_copy_globs.is_match(full_path) {
            let rel_path = full_path
                .strip_prefix(&config.project_root)?
                .strip_prefix(&config.content_root)?;
            let dst_path = PathBuf::from(&config.project_root)
                .join(&config.output_root)
                .join(rel_path);
            log::trace!(
                "CompileOutput::from_full_path({:?}, config) computed Passthrough to {:?}",
                full_path,
                dst_path
            );
            return Ok(Self::Passthrough(dst_path));
        }

        if full_path.extension() != Some(&OsStr::new("typ")) {
            log::trace!(
                "CompileOutput::from_full_path({:?}, config) computed Noop",
                full_path
            );
            return Ok(Self::Noop);
        }

        if let Ok(_) = full_path.strip_prefix(config.project_root.join(&config.template_root)) {
            log::trace!(
                "CompileOutput::from_full_path({:?}, config) computed RecompileAll",
                full_path
            );
            return Ok(Self::RecompileAll);
        } else if let Ok(path_to_typ_in_src) =
            full_path.strip_prefix(config.project_root.join(&config.content_root))
        {
            let rel_parent = path_to_typ_in_src.parent().unwrap();
            let parent_dir_in_dst = config
                .project_root
                .join(&config.output_root)
                .join(rel_parent);
            let file_in_dst =
                if full_path.file_name().unwrap() == "index.typ" || config.literal_paths {
                    let mut file_in_dst = parent_dir_in_dst.join(full_path.file_name().unwrap());
                    file_in_dst.set_extension("html");
                    file_in_dst
                } else {
                    parent_dir_in_dst
                        .join(full_path.file_stem().unwrap())
                        .join("index.html")
                };

            log::trace!(
                "CompileOutput::from_full_path({:?}, config) computed CompileToPath to {:?}",
                full_path,
                file_in_dst
            );
            return Ok(Self::CompileToPath(file_in_dst));
        }

        unreachable!(
            "Should not be reachable: all files passed into here should be in src or templates..."
        )
    }
}

pub fn compile_from_scratch(config: &Config) -> Result<()> {
    if config.init.len() > 0 {
        log::info!("running init command");
        let exit_status = Command::new(&config.init[0])
            .args(&config.init[1..])
            .status()
            .context(anyhow!(
                "Couldn't init. We tried running the command {:?}",
                &config.init
            ))?;

        if !exit_status.success() {
            return Err(anyhow!(
                "Running init command failed. Command was {:?}",
                config.init
            ));
        }
        log::trace!("finished init");
    }

    // for file in source_files(&config) {
    //     if let CompileOutput::CompileToPath(path) = CompileOutput::from_full_path(&file, &config)? {
    //     }
    // }

    log::info!("starting compilation");
    compile_batch(source_files(&config), &config)?; // todo in here

    log::info!("compiled project from scratch");

    Ok(())
}

pub fn compile_single(path: &Path, config: &Config, failure_sender: Sender<()>) -> Result<()> {
    log::trace!("here1 compiling {}", path.to_str().unwrap());

    match CompileOutput::from_full_path(path, config)? {
        CompileOutput::Noop => (),
        CompileOutput::RecompileAll => {
            compile_from_scratch(config)?
            // need to be careful of infinite recursion, compile_everything calls us (compile)
            // should be fine because this code path should only trigger when compiling
            // on the template root.
            //
            // ... what if someone puts their template code in their src folder?
        }
        CompileOutput::Passthrough(dst_path) => {
            fs::create_dir_all(&dst_path.parent().unwrap())?;

            fs::copy(path, &dst_path)
                .context(format!("Failed to write output to {:?}", &dst_path))?;

            log::trace!(
                "passthroughcopied {} to {}",
                path.to_str().unwrap(),
                dst_path.to_str().unwrap()
            );
        }
        CompileOutput::CompileToPath(dst_path) => {
            let mut child = {
                let args = [
                    OsStr::new("--color"),
                    OsStr::new("always"),
                    OsStr::new("c"),
                    OsStr::new(&path),
                    OsStr::new("-"),
                    OsStr::new("--features"),
                    OsStr::new("html"),
                    OsStr::new("--format"),
                    OsStr::new("html"),
                    OsStr::new("--root"),
                    OsStr::new(&config.project_root),
                ];

                Command::new("typst")
                    .args(args)
                    .stdout(Stdio::piped())
                    .stderr(Stdio::piped())
                    .spawn()
                    .context(anyhow!("Failed to run Typst compiler. Maybe you don't have it installed? We ran `typst` with args: {:?}", args))?
            };

            let mut stderr_reader = child.stderr.take().unwrap();
            let mut stderr = String::new();
            stderr_reader.read_to_string(&mut stderr)?;
            stderr = stderr.replace("\n", "\n      ");
            log::trace!("captured stderr from typst call:\n      {}", &stderr);

            let child = if config.post_processing_typ.len() > 0 {
                Command::new(&config.post_processing_typ[0])
                    .args(&config.post_processing_typ[1..])
                    .stdin(child.stdout.unwrap())
                    .stdout(Stdio::piped())
                    .spawn()
                    .context(anyhow!(
                        "Failed to post process. We tried to run the command {:?}",
                        &config.post_processing_typ
                    ))?
            } else {
                child
            };

            let output = child
                .wait_with_output()
                .context("Waiting for output of typst and post-processing failed.")?;

            if !output.status.success() {
                failure_sender.send(())?;
                return Err(anyhow!(
                    "Compiling {} failed. Captured stderr from typst was \n      {}",
                    path.to_str().unwrap(),
                    stderr,
                ));
            }

            fs::create_dir_all(&dst_path.parent().unwrap())?;
            fs::write(&dst_path, output.stdout)
                .context(format!("Failed to write output to {:?}", &dst_path))?;

            log::trace!(
                "typfile compiled {} to {}",
                path.to_str().unwrap(),
                dst_path.to_str().unwrap()
            );
        }
    };

    Ok(())
}

pub fn compile_batch(paths: impl Iterator<Item = PathBuf>, config: &Config) -> Result<()> {
    let (failure_tx, failure_rx) = mpsc::channel();
    std::thread::scope(|s| {
        let mut paths_and_handles = vec![];
        for path in paths {
            let failure_tx = failure_tx.clone();
            paths_and_handles.push((
                path.clone(),
                s.spawn(move || {
                    compile_single(&path, &config, failure_tx).unwrap_or_else(|err| {
                        log::info!("{:?}", err);
                    });
                }),
            ));
        }

        for (path, handle) in paths_and_handles {
            handle.join().unwrap();
            log::debug!("compiled {}", path.to_str().unwrap());
        }
    });

    match failure_rx.try_recv() {
        Ok(_) => {
            return Err(anyhow!(
                "Received at least one failure code from calling compilation pipeline (Typst and post-processsing). See the logs."
            ));
        }
        Err(TryRecvError::Empty) => (),
        Err(TryRecvError::Disconnected) => {
            return Err(anyhow!("Failure receiver disconnected somehow..."));
        }
    }

    Ok(())
}