roxy_cli 0.1.2

A command-line static site generator
pub mod config;
pub mod context;
mod file_path;
pub mod functions;
mod iter_ext;
mod result_ext;

use config::{Config, ConfigDeserializer, Merge};
use context::Context;
use file_path::FilePath;
use functions::register_functions;

use roxy_markdown_parser::{MarkdownParser, Options as MPOptions};
use roxy_markdown_tera_rewriter::{MarkdownTeraPreformatter, MarkdownTeraRewriter};
use roxy_syntect::SyntectParser;
use roxy_tera_parser::{TeraParser, TeraParserOptions};

use clap::Parser as Clap;
use std::{
    ffi, fs,
    path::{Path, PathBuf},
};

use syntect::{highlighting::ThemeSet, parsing::SyntaxSet};

use glob::glob;
use roxy_core::result::Result;
use roxy_core::roxy::{Parser, Roxy};

use crate::iter_ext::{Head, MapFoldExt};

const DEFAULT_THEME: &'static str = "base16-ocean.dark";
const CONTENT_EXT: [&'static str; 4] = ["md", "tera", "html", "htm"];

#[derive(Clap)]
#[command(name = "Roxy")]
#[command(author = "KitsuneCafe")]
#[command(version = "2.0")]
#[command(about = "A very small static site generator", long_about = None)]
pub struct Options {
    pub input: String,
    pub output: String,
}

fn get_files<P: AsRef<Path> + std::fmt::Debug>(path: &P) -> Result<Vec<PathBuf>> {
    let path = path
        .as_ref()
        .to_str()
        .ok_or_else(|| anyhow::Error::msg(format!("{path:?} is not a valid path.")))?;

    let files: Vec<PathBuf> = glob(path)
        .map_err(anyhow::Error::from)?
        .filter_map(|x| x.ok())
        .filter(|f| Path::is_file(f))
        .collect();

    Ok(files)
}

fn try_find_file(path: &Path) -> Option<PathBuf> {
    let file_name = path.file_name()?;

    let paths = path
        .with_file_name("")
        .components()
        .map_fold(PathBuf::new(), |acc, path| acc.join(path))
        .collect::<Vec<PathBuf>>();

    paths
        .iter()
        .rev()
        .skip(1)
        .filter_map(|p| p.with_file_name(file_name).canonicalize().ok())
        .take(1)
        .head()
        .map(|x| x.0)
}

fn load_config(path: &Path) -> Config {
    try_find_file(path)
        .and_then(|p| fs::read_to_string(p).ok())
        .map_or_else(
            || ConfigDeserializer::default(),
            |f| {
                toml::from_str::<ConfigDeserializer>(f.as_str())
                    .unwrap()
                    .merge(ConfigDeserializer::default())
            },
        )
        .into()
}

fn copy_static<T: AsRef<Path>>(files: &Vec<&PathBuf>, file_path: &FilePath<T>) -> Result<()> {
    for file in files {
        let output = file_path.to_output(file).unwrap();
        fs::create_dir_all(output.parent().unwrap())?;
        fs::copy(file, output)?;
    }

    Ok(())
}

fn main() -> Result<()> {
    let opts = Options::parse();

    let mut file_path = FilePath::new(&opts.input, &opts.output);

    let config_path = file_path.input.with_file_name("config.toml");
    let config = load_config(&config_path);

    file_path.slug_word_limit = config.roxy.slug_word_limit;
    let file_path = file_path;

    let files = get_files(&file_path.input)?;
    let (meta, files): (Vec<&PathBuf>, Vec<&PathBuf>) =
        files.iter().partition(|f| f.extension().unwrap() == "toml");

    let (content, files): (Vec<&PathBuf>, Vec<&PathBuf>) = files.iter().partition(|f| {
        let ext = f.extension().and_then(ffi::OsStr::to_str).unwrap();
        CONTENT_EXT.contains(&ext)
    });

    let mut context = Context::from_files(meta, &file_path)?;
    context.build_paths(&content, &file_path)?;

    let md_options = MPOptions::all()
        & !MPOptions::ENABLE_SMART_PUNCTUATION
        & !MPOptions::ENABLE_YAML_STYLE_METADATA_BLOCKS
        & !MPOptions::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS;

    let theme = config.syntect.theme;

    let syntax_set = SyntaxSet::load_defaults_newlines();

    let theme_set = if let Some(dir) = config.syntect.theme_dir {
        let path = file_path.input.join(dir);
        try_find_file(path.as_path())
            .and_then(|p| ThemeSet::load_from_folder(p).ok())
            .unwrap_or_else(|| ThemeSet::load_defaults())
    } else {
        ThemeSet::load_defaults()
    };

    for file in content {
        let file_name = file.with_extension("html");
        let output_path = file_path.to_output(&file_name).unwrap();

        let mut parser = Parser::new();
        let mut preformatter = MarkdownTeraPreformatter::new();
        parser.push(&mut preformatter);

        let mut syntect = SyntectParser::new(&syntax_set, &theme_set, theme.as_str());
        parser.push(&mut syntect);

        let mut md = MarkdownParser::with_options(md_options);
        parser.push(&mut md);

        let mut rewriter = MarkdownTeraRewriter::new();
        parser.push(&mut rewriter);

        if let Ok(path) = &file_path.strip_root(&file) {
            if let Some(current_context) = context.get(path) {
                context.set(&"this", &current_context.clone());
            }
        }

        let mut tera = tera::Tera::default();
        register_functions(&mut tera);
        let mut html = TeraParser::new(&mut tera, TeraParserOptions::default());
        let ctx: tera::Context = context.clone().try_into().unwrap();
        html.add_context(&ctx);
        parser.push(&mut html);

        Roxy::process_file(&file, &output_path, &mut parser).unwrap();
    }

    copy_static(&files, &file_path)?;

    Ok(())
}