As an exercise, I wrote an opinionated simple static site generator in Rust. I like to use Gulp and Bower for front-end development, so this tool is responsible only for converting the markdown posts and pages into HTML and providing some global variables. Gulp handles everything else (including the development server).
#![feature(custom_derive, plugin)]
#![plugin(serde_macros)]
extern crate serde;
extern crate serde_json;
extern crate serde_yaml;
extern crate handlebars;
extern crate pulldown_cmark;
extern crate glob;
extern crate walkdir;
extern crate notify;
#[macro_use] extern crate maplit;
#[macro_use] extern crate log;
pub mod err {
macro_rules! from {
($t: ty) => {
impl ::std::convert::From<$t> for Error {
fn from(e: $t) -> Self {
Error(e.into())
}
}
}
}
#[derive(Debug)]
pub struct Error(pub Box<::std::error::Error + Send + Sync>);
impl ::std::error::Error for Error {
fn description(&self) -> &str {
self.0.description()
}
}
impl ::std::fmt::Display for Error {
fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
::std::error::Error::description(self).fmt(f)
}
}
from!(&'static str);
from!(String);
from!(::std::io::Error);
from!(::serde_yaml::Error);
from!(::handlebars::TemplateError);
from!(::handlebars::TemplateFileError);
from!(::handlebars::RenderError);
pub type Result<T> = ::std::result::Result<T, Error>;
}
use ::std::rc::Rc;
use ::std::collections::BTreeMap;
fn md_to_html(md: &str) -> String {
let mut s = String::new();
let p = ::pulldown_cmark::Parser::new(md);
::pulldown_cmark::html::push_html(&mut s, p);
s
}
fn parse_excerpt<S>(value: &Option<String>, ser: &mut S) -> Result<(), S::Error>
where S: ::serde::Serializer {
use ::serde::Serialize;
Ok(try!(value.as_ref().map(|x| md_to_html(&x) ).serialize(ser)))
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct Metadata {
name: Option<String>,
path: String,
layout: String,
title: Option<String>,
date: Option<String>,
#[serde(serialize_with="parse_excerpt")]
excerpt: Option<String>,
#[serde(default)]
tags: Vec<String>,
other: Option<::serde_json::Value>,
#[serde(skip_deserializing)]
common: Option<Rc<Common>>,
}
impl Metadata {
fn name(&self) -> &str { self.name.as_ref().unwrap() }
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct Common {
title: String,
author: String,
#[serde(default)]
posts: Vec<Metadata>,
#[serde(default)]
pages: Vec<Metadata>,
other: Option<::serde_json::Value>,
#[serde(default)]
tags: BTreeMap<String, Vec<Metadata>>
}
pub fn markdown_helper(
c: &::handlebars::Context,
h: &::handlebars::Helper,
r : &::handlebars::Handlebars,
rc: &mut ::handlebars::RenderContext) -> Result<(), ::handlebars::RenderError> {
use ::handlebars::Renderable;
if let Some(cont) = h.template().map(|x| x.to_string() ) {
let s = md_to_html(&cont);
if let Ok(t) = ::handlebars::Template::compile(&s) {
return t.render(c, r, rc);
}
}
Err(::handlebars::RenderError::new("Wrong arguments for \"markdown\""))
}
pub fn process(src: &str, dest: &str) -> err::Result<()> {
use ::std::path::Path;
use ::std::fs::File;
use ::std::io::{Read, Write};
use ::walkdir::WalkDir;
fn extract(path: &Path) -> err::Result<(Metadata, String)> {
let mut buff = String::new();
let mut fo = try!(File::open(path));
let _ = try!(fo.read_to_string(&mut buff));
let mut spl = buff.split("---");
let _ = try!(spl.next().ok_or("Wrong file metadata"));
let md = try!(spl.next().ok_or("Wrong file metadata"));
let cont = try!(spl.next().ok_or("Wrong file metadata"));
let mut md: Metadata = try!(::serde_yaml::from_str(&md));
if md.name.is_none() {
let name = try!(path.file_stem()
.and_then(|x| x.to_str())
.ok_or("Can't determine file name"));
md.name = Some(name.into());
}
Ok((md, cont.to_owned()))
}
let src_path = Path::new(src);
let dest_path = Path::new(dest);
let mut common: Common = {
let f = try!(File::open(src_path.join("_config.yml")));
try!(::serde_yaml::from_reader(f))
};
let mut handlebars = {
let mut handlebars = ::handlebars::Handlebars::new();
handlebars.register_helper("markdown", Box::new(markdown_helper));
handlebars
};
for fp in WalkDir::new(src_path.join("_posts"))
.min_depth(1)
.into_iter()
.flat_map(|x| x) {
let (md, cont) = try!(extract(&fp.path()));
for tag in &md.tags {
common.tags.entry(tag.clone())
.or_insert_with(Vec::new)
.push(md.clone());
}
let _ = try!(handlebars.register_template_string(&md.name(), cont));
common.posts.push(md);
}
let _ = common.posts.sort_by(|a, b| b.date.cmp(&a.date));
for fp in WalkDir::new(src_path.join("_pages"))
.min_depth(1)
.into_iter()
.flat_map(|x| x) {
let (md, cont) = try!(extract(&fp.path()));
let _ = try!(handlebars.register_template_string(&md.name(), cont));
common.pages.push(md);
}
for fp in WalkDir::new(src_path.join("_layouts"))
.min_depth(1)
.into_iter()
.flat_map(|x| x) {
let ref name = try!(fp.path().file_stem()
.and_then(|x| x.to_str())
.ok_or("Can't determine layout name"));
let _ = try!(handlebars.register_template_file(name, &fp.path()));
}
let common = Rc::new(common);
for md in common.posts.iter().chain(common.pages.iter()) {
let mut fo = {
let path = if md.path.ends_with("/") {
format!(".{}index.html", &md.path)
} else {
md.path.clone()
};
let path = dest_path.join(&path);
if let Some(parent) = path.parent() {
let _ = try!(::std::fs::create_dir_all(parent));
}
try!(File::create(path))
};
let ctx = {
let md = Metadata { common: Some(common.clone()), ..md.clone() };
::handlebars::Context::wraps(&md)
};
let _ = try!(handlebars.renderw(&md.name(), &ctx, &mut fo));
}
Ok(())
}
pub fn watch(src: &str, dest: &str) -> err::Result<()> {
use notify::{RecommendedWatcher, Error, Watcher};
use std::sync::mpsc::channel;
let (tx, rx) = channel();
let mut watcher: RecommendedWatcher = try!(Watcher::new(tx)
.map_err(|_| "Notify error"));
let _ = try!(watcher.watch(src)
.map_err(|_| "Notify error"));
loop {
if let Ok(val) = rx.recv() {
info!("file changed {:?}", val);
if let Err(e) = process(src, dest) {
info!("Possible processing error");
}
}
}
}