shtola 0.4.1

Minimal static site generator
Documentation
//! With Shtola, you can build your own static site generators easily. All that
//! Shtola itself does is read files and frontmatter, run them through a bunch
//! of user-provided plugins, and write the result back to disk.
//!
//! As a demonstration of Shtola's basic piping feature, see this example:
//! ```
//! use shtola::Shtola;
//!
//! let mut m = Shtola::new();
//! m.source("../fixtures/simple");
//! m.destination("../fixtures/dest");
//! m.clean(true);
//! m.build().unwrap();
//! ```
//!
//! A "plugin" is just a boxed function that takes a `RefMut` to an `IR` (intermediate
//! representation) struct. The plugin may modify the IR freely:
//!
//! ```
//! use shtola::{Plugin, ShFile, RefIR};
//!
//! fn plugin() -> Plugin {
//!   Box::new(|mut ir: RefIR| {
//!     ir.files.insert("myFile".into(), ShFile::empty());
//!   })
//! }
//! ```

use globset::{Glob, GlobSet, GlobSetBuilder};
use log::{debug, info, trace};
use pathdiff::diff_paths;
use serde_json::json;
use std::cell::RefMut;
use std::default::Default;
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

pub use log;
pub use serde_json as json;
pub use std::collections::HashMap;
pub use ware::Ware;

/// Convenience type for a `RefMut<IR>`.
pub type RefIR<'a> = RefMut<'a, IR>;
/// Convenience type to return from plugin functions.
pub type Plugin = Box<dyn Fn(RefIR) -> ()>;

mod frontmatter;
pub mod plugins;

/// The main library struct.
pub struct Shtola {
	ware: Ware<IR>,
	ir: IR,
}

impl Shtola {
	/// Creates a new empty Shtola struct.
	pub fn new() -> Shtola {
		let config: Config = Default::default();
		let ir = IR {
			files: HashMap::new(),
			config,
			metadata: HashMap::new(),
		};
		Shtola {
			ware: Ware::new(),
			ir,
		}
	}

	/// Appends glob-matched paths to the ignore list. If a glob path matches, the
	/// file is excluded from the IR.
	/// ```
	/// use shtola::Shtola;
	///
	/// let mut m = Shtola::new();
	/// m.ignores(&mut vec!["node_modules".into(), "vendor/bundle/".into()])
	/// ```
	pub fn ignores(&mut self, vec: &mut Vec<String>) {
		self.ir.config.ignores.append(vec);
		self.ir.config.ignores.dedup();
	}

	/// Reads paths to ignore from a file. This file needs to have one single path
	/// per line. The most common use for this would be to read your project's `.gitignore`.
	/// ```
	/// use std::path::Path;
	/// use shtola::Shtola;
	///
	/// let mut m = Shtola::new();
	/// m.source_ignores(Path::new(".gitignore"));
	/// ```
	pub fn source_ignores(&mut self, path: &Path) -> Result<(), std::io::Error> {
		let sourcepath = self.ir.config.source.clone().canonicalize()?;
		let file = fs::File::open(path)?;
		let reader = BufReader::new(file);
		let mut ignores: Vec<String> = reader.lines().map(|l| l.unwrap()).collect();
		self.ignores(&mut ignores);
		self.ignores(&mut vec![path
			.canonicalize()?
			.strip_prefix(sourcepath)
			.unwrap_or(path)
			.to_str()
			.unwrap()
			.to_string()]);
		Ok(())
	}

	/// Sets the source directory to read from. Should be relative.
	pub fn source<T: Into<PathBuf>>(&mut self, path: T) {
		self.ir.config.source = fs::canonicalize(path.into()).unwrap();
	}

	/// Sets the destination path to write to. This directory will be created on
	/// calling this function if it doesn't exist.
	pub fn destination<T: Into<PathBuf> + Clone>(&mut self, path: T) {
		fs::create_dir_all(path.clone().into()).expect("Unable to create destination directory!");
		self.ir.config.destination = fs::canonicalize(path.into()).unwrap();
	}

	/// Sets whether the destination directory should be removed before building.
	/// The removal only happens once calling [`Shtola::build`](#method.build).
	/// Default is `false`.
	pub fn clean(&mut self, b: bool) {
		self.ir.config.clean = b;
	}

	/// Sets whether frontmatter should be parsed. Default is `true`.
	pub fn frontmatter(&mut self, b: bool) {
		self.ir.config.frontmatter = b;
	}

	/// Registers a new plugin function in its middleware chain.
	///
	/// ```
	/// use shtola::{Shtola, RefIR};
	///
	/// let mut m = Shtola::new();
	/// let plugin = Box::new(|mut ir: RefIR| ());
	/// m.register(plugin);
	/// ```
	pub fn register(&mut self, func: Box<dyn Fn(RefIR)>) {
		self.ware.wrap(func);
	}

	/// Performs the build process. This does a couple of things:
	/// - If [`Shtola::clean`](#method.clean) is set, removes and recreates the
	///   destination directory
	/// - Reads from the source file and ignores files as it's been configured
	/// - Parses front matter for the remaining files
	/// - Runs the middleware chain, executing all plugins
	/// - Writes the result back to the destination directory
	pub fn build(&mut self) -> Result<IR, std::io::Error> {
		trace!("Starting IR config: {:?}", self.ir.config);
		if self.ir.config.clean {
			info!("Cleaning before build...");
			debug!("Removing {:?}", &self.ir.config.destination);
			fs::remove_dir_all(&self.ir.config.destination)?;
			debug!("Recreating {:?}", &self.ir.config.destination);
			fs::create_dir_all(&self.ir.config.destination)
				.expect("Unable to recreate destination directory!");
		}

		let path_clone = self.ir.config.destination.clone();
		let pathstr = path_clone.to_str().expect("No destination provided!");
		self.ignores(&mut vec![pathstr.to_string()]);
		let mut builder = GlobSetBuilder::new();
		for item in &self.ir.config.ignores {
			builder.add(Glob::new(item).unwrap());
		}
		let set = builder.build().unwrap();
		info!("Reading files...");
		let files = read_dir(&self.ir.config.source, self.ir.config.frontmatter, set)?;
		trace!("{} file(s) read", &files.len());

		self.ir.files = files;
		info!("Running plugins...");
		let result_ir = self.ware.run(self.ir.clone());
		info!("Writing to disk...");
		write_dir(result_ir.clone(), &self.ir.config.destination)?;
		info!("OK, done");
		Ok(result_ir)
	}
}

/// The intermediate representation that's passed to plugins. Includes global
/// metadata, the files with frontmatter and the global config.
#[derive(Debug, Clone)]
pub struct IR {
	/// The filestate, contained in a `HashMap`.
	pub files: HashMap<PathBuf, ShFile>,
	/// The configuration.
	pub config: Config,
	/// Global metadata managed as a `HashMap` that keep JSON values as values.
	pub metadata: HashMap<String, json::Value>,
}

/// Configuration struct.
#[derive(Debug, Clone)]
pub struct Config {
	/// Files that are to be ignored.
	pub ignores: Vec<String>,
	/// Source to read from.
	pub source: PathBuf,
	/// Destination to write to.
	pub destination: PathBuf,
	/// Whether to clean the destination directory.
	pub clean: bool,
	/// Whether to parse frontmatter.
	pub frontmatter: bool,
}

impl Default for Config {
	fn default() -> Self {
		Config {
			ignores: vec![".git/**/*".into()],
			source: PathBuf::from("."),
			destination: PathBuf::from("./dest"),
			clean: false,
			frontmatter: true,
		}
	}
}

/// Shtola's file representation, with frontmatter included.
#[derive(Debug, Clone)]
pub struct ShFile {
	/// The frontmatter.
	pub frontmatter: json::Value,
	/// The file contents (without frontmatter). UTF-8-encoded.
	pub content: Vec<u8>,
	/// Raw content for anything that can't be read as UTF-8.
	pub raw_content: Option<Vec<u8>>,
}

impl Default for ShFile {
	fn default() -> ShFile {
		ShFile {
			content: Vec::new(),
			frontmatter: json::Value::Null,
			raw_content: None,
		}
	}
}

impl ShFile {
	/// Creates an empty ShFile. Useful for deleting files using
	/// [`HashMap::difference`](struct.HashMap.html#method.difference):
	///
	/// ```
	/// use shtola::{Plugin, RefIR, ShFile, HashMap};
	/// use std::path::PathBuf;
	///
	/// fn plugin() -> Plugin {
	///   Box::new(|mut ir: RefIR| {
	///     ir.files.insert("empty-file.md".into(), ShFile::empty());
	///   })
	/// }
	/// ```
	pub fn empty() -> ShFile {
		ShFile {
			frontmatter: json!(null),
			content: Vec::new(),
			raw_content: None,
		}
	}
}

fn read_dir(
	source: &PathBuf,
	frontmatter: bool,
	set: GlobSet,
) -> Result<HashMap<PathBuf, ShFile>, std::io::Error> {
	let mut result = HashMap::new();
	let iters = WalkDir::new(source)
		.into_iter()
		.filter_entry(|e| {
			let path = diff_paths(e.path(), source).unwrap();
			!set.is_match(path)
		})
		.filter(|e| !e.as_ref().ok().unwrap().file_type().is_dir());
	for entry in iters {
		let entry = entry?;
		let path = entry.path();
		let file: ShFile;
		let mut content = Vec::new();
		debug!("Reading file at {:?}", &path);
		fs::File::open(path)?.read_to_end(&mut content)?;
		if let Ok(content_string) = std::str::from_utf8(&content) {
			if frontmatter {
				let (matter, content) = frontmatter::lexer(content_string);
				if matter.len() > 0 {
					debug!("Lexing frontmatter for {:?}", &path);
				}
				let json = frontmatter::to_json(&matter);
				file = ShFile {
					frontmatter: json,
					content: content.into(),
					raw_content: None,
				};
			} else {
				file = ShFile {
					frontmatter: json!(null),
					content,
					raw_content: None,
				};
			}
		} else {
			// Not valid UTF-8, store as binary
			file = ShFile {
				frontmatter: json!(null),
				content: Vec::new(),
				raw_content: Some(content),
			}
		}

		let rel_path = diff_paths(path, source).unwrap();
		result.insert(rel_path, file);
	}
	Ok(result)
}

fn write_dir(ir: IR, dest: &PathBuf) -> Result<(), std::io::Error> {
	for (path, file) in ir.files {
		let dest_path = dest.join(&path);
		debug!("Writing {:?} to {:?}", &path, &dest_path);
		fs::create_dir_all(dest_path.parent().unwrap())
			.expect("Unable to create destination subdirectory!");
		if let Some(raw) = file.raw_content {
			fs::File::create(dest_path)?.write_all(&raw)?;
		} else {
			fs::File::create(dest_path)?.write_all(&file.content)?;
		}
	}
	Ok(())
}