compile-typst-site 2.0.2

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 glob::Pattern;
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 {
    /// Use the specified path as the project root.
    path: Option<String>,
    /// Build and then watch for changes.
    watch: bool,
    /// Build and then watch for changes while serving website locally.
    serve: 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.
    ///
    /// Strings should not contain $ unless the symbol begins:
    /// - $PROJECT_ROOT, which is replaced with the path to the project root.
    ///
    /// E.g., `passthrough_copy = ["python", "$PROJECT_ROOT/prebuild.py"]`.
    init: Option<Vec<String>>,
    /// Command to run to post-process HTML files generated by Typst.
    ///
    /// Must take in stdin and return via stdout.
    ///
    /// Strings should not contain $ unless the symbol begins:
    /// - $PROJECT_ROOT, which is replaced with the path to the project root.
    ///
    /// Example in the TOML config file: `post_processing_typ = ["python", "$PROJECT_ROOT/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 serve: bool,
    pub ignore_initial: bool,
    pub verbose: bool,
    pub trace: bool,
    pub passthrough_copy: Vec<String>,
    #[derivative(Debug = "ignore")]
    pub passthrough_copy_globs: Vec<Pattern>,
    // Pattern 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_relpath: PathBuf,
    pub output_relpath: PathBuf,
    pub template_relpath: PathBuf,
}
pub 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)
        })
    }

    pub fn content_root(&self) -> PathBuf {
        self.project_root.join(&self.content_relpath)
    }

    pub fn output_root(&self) -> PathBuf {
        self.project_root.join(&self.output_relpath)
    }

    pub fn template_root(&self) -> PathBuf {
        self.project_root.join(&self.template_relpath)
    }

    fn new_inner() -> Result<Self> {
        let content_relpath = PathBuf::from("src");
        let output_relpath = PathBuf::from("_site");
        let template_relpath = PathBuf::from("templates");

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

        let project_root = if let Some(path) = path {
            path.parse()?
        } else {
            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_relpath)?;

        Ok(Self {
            watch,
            serve,
            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_relpath,
            output_relpath,
            template_relpath,
        })
    }

    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(
        string_globs: &[String],
        project_root: &Path,
        content_root: &Path,
    ) -> Result<(Vec<Pattern>, Vec<String>)> {
        let mut compiled_globs = Vec::new();
        let mut compiled_globs_string_form = Vec::new();

        for glob in string_globs {
            let string_glob = project_root
                .join(content_root)
                .join(glob)
                .to_str()
                .context(anyhow!("{glob} not utf8"))?
                .to_owned();
            let compiled_glob = string_glob.parse::<Pattern>()?;

            compiled_globs.push(compiled_glob);
            compiled_globs_string_form.push(string_glob);
        }
        Ok((compiled_globs, compiled_globs_string_form))
    }

    fn get_configfile(project_root: &Path) -> Result<ConfigFile> {
        const PROJ_ROOT_REPLACEE: &str = "$PROJECT_ROOT";

        let file = project_root.join(CONFIG_FNAME);
        let contents = fs::read_to_string(file)?;
        let mut config: ConfigFile = toml::from_str(&contents)?;

        config.init = config.init.map(|init_args| {
            init_args
                .into_iter()
                .map(|arg| arg.replace(PROJ_ROOT_REPLACEE, &project_root.to_string_lossy()))
                .collect()
        });
        config.post_processing_typ = config.post_processing_typ.map(|init_args| {
            init_args
                .into_iter()
                .map(|arg| arg.replace(PROJ_ROOT_REPLACEE, &project_root.to_string_lossy()))
                .collect()
        });

        Ok(config)
    }
}