compile-typst-site 0.3.1

Binary tool for static site generation using Typst.
Documentation
//! `compile-typst-site` project configuration, pulling from command-line arguments and a config file.

use anyhow::{Context, Result, anyhow};
use derivative::Derivative;
use globset::{Glob, GlobSet, GlobSetBuilder};
use onlyargs_derive::OnlyArgs;
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::exit;

// Don't need a Args rustdoc here because our current crate scrapes from the Cargo.toml description I guess??
#[derive(Clone, Debug, Eq, PartialEq, OnlyArgs)]
struct Args {
    /// Build and then watch for changes.
    watch: bool,
    /// Ignore initial full-site compilation step.
    ignore_initial: bool,
    /// Enable verbose logging.
    verbose: bool,
    /// Enable very verbose logging.
    trace: bool,
}

#[derive(Deserialize)]
struct ConfigFile {
    /// Array of globs to match for passthrough-copying.
    ///
    /// Example in the TOML config file: `passthrough_copy = ["*.css", "*.js", "assets/*"]
    passthrough_copy: Option<Vec<String>>,
    /// Command to run before a full rebuild.
    ///
    /// E.g., `passthrough_copy = ["echo", "rebuilding"]`.
    init: Option<Vec<String>>,
    /// Command to run to post-process HTML files generated by Typst.
    ///
    /// Must take in stdin and return via stdout.
    ///
    /// Example in the TOML config file: `post_processing_typ = ["python", "post_processing_script.py"]`.
    post_processing_typ: Option<Vec<String>>,
    /// Convert paths literally instead of magically tranforming to index.html.
    ///
    /// i.e., ./content.typ goes to ./content.html instead of defaulting to ./content/index.html.
    ///
    /// Example in the TOML config file: `literal_paths = true`
    literal_paths: Option<bool>,
    /// Typst cannot yet glob-find multiple files, which is a problem if one wants to list, e.g., all blog posts on a page.
    /// To work around this, we write all Typst files(?) as a JSON to the project root directory.
    ///
    /// We also let you query for data. (You might want the dates of those blog posts to appear on your listing page).
    /// This is slower than the other options because we have to call `typst query`.
    ///
    /// Must be one of "disabled", "enabled", "include-data"
    ///
    /// Example in the TOML config file: `file_listing = "enabled"`
    file_listing: Option<String>,
}

#[derive(Debug)]
pub enum FileListing {
    Disabled,
    Enabled,
    IncludeData,
}

impl FileListing {
    pub const DISABLED_STR: &str = "disabled";
    pub const ENABLED_STR: &str = "enabled";
    pub const INCLUDE_DATA_STR: &str = "include-data";
    pub const DEFAULT_STR: &str = Self::DISABLED_STR;
}

impl Default for FileListing {
    fn default() -> Self {
        Self::Disabled
    }
}

impl TryFrom<String> for FileListing {
    type Error = anyhow::Error;

    fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
        match value.as_str() {
            Self::DISABLED_STR => Ok(FileListing::Disabled),
            Self::ENABLED_STR => Ok(FileListing::Enabled),
            Self::INCLUDE_DATA_STR => Ok(FileListing::IncludeData),
            _ => Err(anyhow!(
                "TOML parsing error: file_listing must be one of \"{}\", \"{}\", \"{}\", not {}",
                Self::DISABLED_STR,
                Self::ENABLED_STR,
                Self::INCLUDE_DATA_STR,
                value
            )),
        }
    }
}

/// Full config after taking in command line arguments, a configuration file, and other post-computations.
///
/// See [`Args`] and [`ConfigFile`] for documentation of fields.
#[derive(Derivative)]
#[derivative(Debug)]
pub struct Config {
    pub watch: bool,
    pub ignore_initial: bool,
    pub verbose: bool,
    pub trace: bool,
    pub passthrough_copy: Vec<String>,
    #[derivative(Debug = "ignore")]
    pub passthrough_copy_globs: GlobSet,
    // GlobSet has gnarly debug impl; emit a String version instead.
    pub passthrough_copy_globs_string_form: Vec<String>,
    pub init: Vec<String>,
    pub post_processing_typ: Vec<String>,
    pub literal_paths: bool,
    pub file_listing: FileListing,
    pub project_root: PathBuf,
    pub content_root: PathBuf,
    pub output_root: PathBuf,
    pub template_root: PathBuf,
}
const CONFIG_FNAME: &str = "compile-typst-site.toml";

impl Config {
    pub fn new() -> Self {
        Self::new_inner().unwrap_or_else(|err| {
            eprintln!("{:?}", err);
            exit(1)
        })
    }

    fn new_inner() -> Result<Self> {
        let content_root = PathBuf::from("src");
        let output_root = PathBuf::from("_site");
        let template_root = PathBuf::from("templates");

        let Args {
            watch,
            ignore_initial,
            verbose,
            trace,
        } = onlyargs::parse()?;

        let project_root = Self::get_project_root()?;

        let ConfigFile {
            passthrough_copy,
            init,
            post_processing_typ,
            literal_paths,
            file_listing,
        } = Self::get_configfile(&project_root)?;
        let passthrough_copy = passthrough_copy.unwrap_or(vec![]);
        let init = init.unwrap_or(vec![]);
        let post_processing_typ = post_processing_typ.unwrap_or(vec![]);
        let literal_paths = literal_paths.unwrap_or(false);
        let file_listing = file_listing
            .unwrap_or(FileListing::DEFAULT_STR.into())
            .try_into()?;

        let (passthrough_copy_globs, passthrough_copy_globs_string_form) =
            Self::compile_globs(&passthrough_copy, &project_root, &content_root)?;

        Ok(Self {
            watch,
            ignore_initial,
            verbose,
            trace,
            passthrough_copy,
            passthrough_copy_globs,
            passthrough_copy_globs_string_form,
            init,
            post_processing_typ,
            literal_paths,
            file_listing,
            project_root,
            content_root,
            output_root,
            template_root,
        })
    }

    fn get_project_root() -> Result<PathBuf> {
        let mut root = std::env::current_dir()?;

        loop {
            let candidate = root.join(CONFIG_FNAME);

            if candidate.exists() {
                return Ok(root);
            }

            if !root.pop() {
                return Err(anyhow!(
                    "Couldn't find a configuration file (looking for {CONFIG_FNAME}) in the current directory or any parent directories."
                ));
            }
        }
    }

    fn compile_globs(
        globs: &[String],
        project_root: &Path,
        content_root: &Path,
    ) -> Result<(GlobSet, Vec<String>)> {
        let mut builder = GlobSetBuilder::new();
        let mut compiled_globs_string_form = Vec::new();

        for glob in globs {
            let glob = project_root
                .join(content_root)
                .join(glob)
                .to_str()
                .context(anyhow!("{glob} not to str"))?
                .to_string();
            builder.add(Glob::new(&glob)?);
            compiled_globs_string_form.push(glob);
        }
        Ok((builder.build()?, compiled_globs_string_form))
    }

    fn get_configfile(project_root: &Path) -> Result<ConfigFile> {
        let file = project_root.join(CONFIG_FNAME);
        let contents = fs::read_to_string(file)?;
        let config = toml::from_str(&contents)?;
        Ok(config)
    }
}