#![doc(
html_logo_url = "https://www.rust-lang.org/logos/rust-logo-128x128-blk-v2.png",
html_favicon_url = "https://www.rust-lang.org/favicon.ico",
html_root_url = "https://docs.rs/morpho"
)]
#![deny(unused_extern_crates)]
#![allow(clippy::needless_return)]
#![allow(clippy::expect_fun_call)]
#![allow(clippy::or_fun_call)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::fs::read_to_string;
use std::rc::Rc;
use std::sync::mpsc::{ self, channel };
use std::thread;
use std::time::{Duration, Instant};
use std::cmp::Ordering;
use chrono::Local;
use glob::Pattern;
use log::{debug, error, info};
use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher};
use tempfile::{Builder as TempBuilder, TempDir};
use tera::{Context, Tera};
use walkdir::{DirEntry, WalkDir};
use civet::{Config as CivetConfig, Server};
pub use crate::error::{Error, Result};
pub use crate::post::Post;
pub use crate::post::PostHeaders;
pub use crate::settings::Settings;
pub use crate::tag::Tag;
pub use crate::theme::Theme;
pub use crate::static_handler::Static;
use crate::utils::write_file;
mod error;
mod post;
mod settings;
mod tag;
mod theme;
mod utils;
mod static_handler;
pub struct Mdsite {
root: PathBuf,
settings: Settings,
theme: Theme,
posts: Vec<Rc<Post>>,
tags_map: BTreeMap<String, Tag>,
server_root_dir: Option<TempDir>,
}
impl Mdsite {
pub fn new<P: AsRef<Path>>(root: P) -> Result<Mdsite> {
let root = root.as_ref();
let settings: Settings = Default::default();
let theme_root_dir = get_dir(root, &settings.theme_root_dir)?;
let theme = Theme::new(theme_root_dir, &settings.theme)?;
Ok(Mdsite {
root: root.to_owned(),
settings,
theme,
posts: Vec::new(),
tags_map: BTreeMap::new(),
server_root_dir: None,
})
}
pub fn load_customize_settings(&mut self) -> Result<()> {
let cfg = read_to_string("config.toml")?;
self.settings = toml::de::from_str(&cfg)?;
if self.settings.site_url.ends_with('/') {
self.settings.site_url =
self.settings.site_url.trim_end_matches('/').to_string();
}
let theme_root_dir = self.theme_root_dir()?;
self.theme = Theme::new(&theme_root_dir, &self.settings.theme)?;
Ok(())
}
pub fn load_posts(&mut self) -> Result<()> {
let mut posts: Vec<Rc<Post>> = Vec::new();
let mut tags_map: BTreeMap<String, Tag> = BTreeMap::new();
let walker = WalkDir::new(&self.post_root_dir()?).into_iter();
for entry in walker.filter_entry(|e| !is_hidden(e)) {
let entry = entry.expect("get walker entry error");
if !is_markdown_file(&entry) {
continue;
}
let post_path = entry.path().strip_prefix(&self.root)?.to_owned();
let post = Post::new(&self.root, &post_path)?;
let post = Rc::new(post);
posts.push(Rc::clone(&post));
if post.headers.hidden {
continue;
}
for tag_name in &post.headers.tags {
let tag = tags_map
.entry(tag_name.to_string())
.or_insert(Tag::new(tag_name, &format!("/tags/{}.html", tag_name)));
tag.add(post.clone());
}
}
posts.sort_by(|p1, p2| match (p1.headers.created, p2.headers.created) {
(Some(_), Some(_)) => p2
.headers
.created
.clone()
.unwrap()
.cmp(&p1.headers.created.clone().unwrap()),
(None, None) => p1.title.cmp(&p2.title),
(None, Some(_)) => Ordering::Less,
(Some(_), None) => Ordering::Greater,
});
for tag in tags_map.values_mut() {
tag.posts.sort_by(|p1, p2| match (p1.headers.created, p2.headers.created) {
(Some(_), Some(_)) => p2
.headers
.created
.clone()
.unwrap()
.cmp(&p1.headers.created.clone().unwrap()),
(None, None) => p1.title.cmp(&p2.title),
(None, Some(_)) => Ordering::Less,
(Some(_), None) => Ordering::Greater,
});
}
self.posts = posts;
self.tags_map = tags_map;
Ok(())
}
pub fn init(&mut self) -> Result<()> {
if self.root.exists() {
return Err(Error::RootDirExisted(self.root.clone()));
}
let mut tera = Tera::default();
tera.add_raw_template(
"hello.md.tpl",
include_str!("demo_templates/hello.md.tpl"),
)?;
tera.add_raw_template("math.md.tpl", include_str!("demo_templates/math.md.tpl"))?;
let now = Local::now();
let mut context = Context::new();
context.insert("now", &now.format("%Y-%m-%dT%H:%M:%S%:z").to_string());
let hello_content = tera.render("hello.md.tpl", &context)?;
let math_content = tera.render("math.md.tpl", &context)?;
write_file(&self.post_root_dir()?.join("hello.md"), hello_content.as_bytes())?;
write_file(&self.post_root_dir()?.join("math.md"), math_content.as_bytes())?;
self.export_config()?;
self.theme.init_dir(&self.theme.name)?;
std::fs::create_dir_all(self.root.join("media"))?;
Ok(())
}
pub fn build(&mut self) -> Result<()> {
self.load_posts()?;
self.export_media()?;
self.export_static()?;
self.export_posts()?;
self.export_index()?;
for tag in self.tags_map.values() {
self.export_tag(tag)?;
}
Ok(())
}
pub fn serve(&mut self, port: u16) -> Result<()> {
let addr_str = format!("127.0.0.1:{}", port);
let server_root_dir =
TempBuilder::new().prefix("morpho.").rand_bytes(10).tempdir()?;
info!("server root dir: {}", &server_root_dir.path().display());
self.server_root_dir = Some(server_root_dir);
self.settings.site_url = format!("http://{}", &addr_str);
self.build()?;
info!("server site at {}", &self.settings.site_url);
let server_root_dir = self.server_root_dir.as_ref().unwrap().path().to_owned();
thread::spawn(move || {
let handler = Static::new(&server_root_dir);
let mut cfg = CivetConfig::new();
cfg.port(port).threads(30);
match Server::start(cfg, handler) {
Ok(_) => {
let (_tx, rx) = channel::<()>();
rx.recv().unwrap();
},
Err(e) => error!("failed to start server: {}", e),
};
});
self.open_browser();
self.watch()?;
Ok(())
}
fn watch(&mut self) -> Result<()> {
let (tx, rx) = mpsc::channel();
let ignore_patterns = self.ignore_patterns()?;
info!("watching dir: {}", self.root.display());
let mut watcher = watcher(tx, Duration::new(2, 0))?;
watcher.watch(&self.root, RecursiveMode::Recursive)?;
let interval = Duration::new(self.settings.rebuild_interval.into(), 0);
let mut last_run: Option<Instant> = None;
loop {
match rx.recv() {
Err(why) => error!("watch error: {:?}", why),
Ok(event) => match event {
DebouncedEvent::Create(ref fpath)
| DebouncedEvent::Write(ref fpath)
| DebouncedEvent::Remove(ref fpath)
| DebouncedEvent::Rename(ref fpath, _) => {
if ignore_patterns.iter().any(|ref pat| pat.matches_path(fpath)) {
continue;
}
let now = Instant::now();
if let Some(last_time) = last_run {
if now.duration_since(last_time) < interval {
continue;
}
}
last_run = Some(now);
info!("Modified file: {}", fpath.display());
if let Err(ref e) = self.rebuild() {
crate::utils::log_error_chain(e);
continue;
}
}
_ => {}
},
}
}
#[allow(unreachable_code)]
Ok(())
}
fn open_browser(&self) {
let url = self.settings.site_url.clone();
thread::spawn(move || {
open::that(url).unwrap();
});
}
pub fn rebuild(&mut self) -> Result<()> {
info!("Rebuild site again...");
let site_url = self.settings.site_url.clone();
self.load_customize_settings()?;
self.settings.site_url = site_url;
self.build()?;
info!("Rebuild done!");
Ok(())
}
pub fn build_root_dir(&self) -> Result<PathBuf> {
if let Some(ref server_root_dir) = self.server_root_dir {
Ok(server_root_dir.path().to_owned())
} else {
get_dir(&self.root, &self.settings.build_dir)
}
}
pub fn theme_root_dir(&self) -> Result<PathBuf> {
get_dir(&self.root, &self.settings.theme_root_dir)
}
pub fn media_root_dir(&self) -> Result<PathBuf> {
get_dir(&self.root, &self.settings.media_dir)
}
pub fn post_root_dir(&self) -> Result<PathBuf> {
Ok(self.root.join("posts"))
}
pub fn ignore_patterns(&self) -> Result<Vec<Pattern>> {
let mut patterns = vec![Pattern::new("**/.*")?];
let build_dir =
self.build_root_dir()?.to_str().expect("get build dir error").to_string();
patterns
.push(Pattern::new(&format!("{}/**/*", build_dir.trim_end_matches('/')))?);
Ok(patterns)
}
pub fn create_post(&self, path: &Path, tags: &[String]) -> Result<()> {
let post_title = path.file_stem();
if !path.is_relative()
|| path.extension().is_some()
|| path.to_str().unwrap_or("").is_empty()
|| post_title.is_none()
|| self.ignore_patterns()?.iter().any(|ref pat| pat.matches_path(path))
{
return Err(Error::PostPathInvaild(path.into()));
}
if path.is_dir() {
return Err(Error::PostPathExisted(path.into()));
}
let post_path = self.post_root_dir()?.join(path).with_extension("md");
if post_path.exists() {
return Err(Error::PostPathExisted(path.into()));
}
let now = Local::now();
let content = format!(
"created = \"{}\"\n\
tags = [\"{}\"]\n\
\n\
this is a new post!\n",
now.format("%Y-%m-%dT%H:%M:%S%:z"),
tags.iter().map(|x| format!("\"{}\"", x)).collect::<Vec<_>>().join(", ")
);
write_file(&post_path, content.as_bytes())?;
Ok(())
}
pub fn export_config(&self) -> Result<()> {
let content = toml::to_string(&self.settings)?;
write_file(&self.root.join("config.toml"), content.as_bytes())?;
Ok(())
}
fn media_dest<P: AsRef<Path>>(&self, media: P) -> Result<PathBuf> {
let build_dir = self.build_root_dir()?;
let rel_path = media.as_ref().strip_prefix(&self.media_root_dir()?)?.to_owned();
Ok(build_dir.join("media").join(rel_path))
}
pub fn export_media(&self) -> Result<()> {
debug!("exporting media ...");
let media_root_dir = self.media_root_dir()?;
if !media_root_dir.exists() {
return Ok(());
}
let walker = WalkDir::new(&media_root_dir).into_iter();
for entry in walker.filter_entry(|e| !is_hidden(e)) {
let entry = entry.expect("get walker entry error");
let src_path = entry.path();
if src_path.is_dir() {
std::fs::create_dir_all(self.media_dest(src_path)?)?;
continue;
}
std::fs::copy(src_path, self.media_dest(src_path)?)?;
}
Ok(())
}
pub fn export_static(&self) -> Result<()> {
let build_dir = self.build_root_dir()?;
self.theme.export_static(&build_dir)?;
Ok(())
}
pub fn export_posts(&self) -> Result<()> {
let build_dir = self.build_root_dir()?;
for post in &self.posts {
let dest = build_dir.join(post.dest());
let html = self.render_post(post)?;
write_file(&dest, html.as_bytes())?;
}
Ok(())
}
pub fn export_index(&self) -> Result<()> {
let build_dir = self.build_root_dir()?;
let posts: Vec<_> = self.posts.iter().filter(|p| !p.headers.hidden).collect();
let total = posts.len();
let pages =
(total + self.settings.posts_per_page - 1) / self.settings.posts_per_page;
let mut i = 1;
while i <= pages {
let start = (i - 1) * self.settings.posts_per_page;
let end = total.min(start + self.settings.posts_per_page);
let prev_name = format_page_name("index", i - 1, pages);
let current_name = format_page_name("index", i, pages);
let next_name = format_page_name("index", i + 1, pages);
let dest = build_dir.join(current_name);
let html = self.render_index(&posts[start..end], &prev_name, &next_name)?;
write_file(&dest, html.as_bytes())?;
i += 1;
}
Ok(())
}
pub fn export_tag(&self, tag: &Tag) -> Result<()> {
let build_dir = self.build_root_dir()?;
let total = tag.posts.len();
let pages =
(total + self.settings.posts_per_page - 1) / self.settings.posts_per_page;
let mut i = 1;
while i <= pages {
let start = (i - 1) * self.settings.posts_per_page;
let end = total.min(start + self.settings.posts_per_page);
let prev_name = format_page_name(&tag.name, i - 1, pages);
let current_name = format_page_name(&tag.name, i, pages);
let next_name = format_page_name(&tag.name, i + 1, pages);
let dest = build_dir.join("tags").join(current_name);
debug!("rendering tag: {} ...", dest.display());
let html = self.render_tag(
&tag.name,
&tag.posts[start..end],
&prev_name,
&next_name,
)?;
write_file(&dest, html.as_bytes())?;
i += 1;
}
Ok(())
}
fn get_base_context(&self) -> Result<Context> {
let mut context = Context::new();
context.insert("config", &self.settings);
context.insert("all_tags", &self.tags_map.values().collect::<Vec<_>>());
Ok(context)
}
pub fn render_post(&self, post: &Post) -> Result<String> {
debug!("rendering post({}) ...", post.path.display());
let post_tags = self
.tags_map
.iter()
.filter(|&(name, _)| post.headers.tags.contains(name))
.map(|(_, tag)| tag)
.collect::<Vec<_>>();
let mut context = self.get_base_context()?;
context.insert("post", &post);
context.insert("post_tags", &post_tags);
Ok(self.theme.renderer.render("post.tpl", &context)?)
}
pub fn render_index(
&self,
posts: &[&Rc<Post>],
prev_name: &str,
next_name: &str,
) -> Result<String> {
debug!("rendering index ...");
let mut context = self.get_base_context()?;
context.insert("prev_name", prev_name);
context.insert("next_name", next_name);
context.insert("posts", posts);
Ok(self.theme.renderer.render("index.tpl", &context)?)
}
pub fn render_tag(
&self,
title: &str,
posts: &[Rc<Post>],
prev_name: &str,
next_name: &str,
) -> Result<String> {
let mut context = self.get_base_context()?;
context.insert("title", title);
context.insert("prev_name", prev_name);
context.insert("next_name", next_name);
context.insert("posts", posts);
Ok(self.theme.renderer.render("tag.tpl", &context)?)
}
pub fn list_site_theme(&self) -> Result<()> {
let theme_root = self.theme_root_dir()?;
if !theme_root.exists() || !theme_root.is_dir() {
error!("no theme");
}
for entry in std::fs::read_dir(theme_root)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
println!(
"* {}",
path.file_name()
.expect("theme name error")
.to_str()
.expect("theme name error")
);
}
}
Ok(())
}
pub fn create_site_theme(&self, name: &str) -> Result<()> {
self.theme.init_dir(name)?;
Ok(())
}
pub fn delete_site_theme(&self, name: &str) -> Result<()> {
if self.settings.theme == name {
return Err(Error::ThemeInUse(name.into()));
}
let theme_path = self.theme_root_dir()?.join(name);
if !theme_path.exists() || !theme_path.is_dir() {
return Err(Error::ThemeNotFound(name.into()));
}
std::fs::remove_dir_all(theme_path)?;
Ok(())
}
pub fn set_site_theme(&mut self, name: &str) -> Result<()> {
let theme_path = self.theme_root_dir()?.join(name);
if !theme_path.exists() || !theme_path.is_dir() {
return Err(Error::ThemeNotFound(name.into()));
}
self.settings.theme = name.to_string();
self.export_config()?;
Ok(())
}
}
fn is_hidden(entry: &DirEntry) -> bool {
entry.file_name().to_str().map(|s| s.starts_with('.')).unwrap_or(false)
}
fn is_markdown_file(entry: &DirEntry) -> bool {
if !entry.path().is_file() {
return false;
}
let fname = entry.file_name().to_str();
match fname {
None => {
return false;
}
Some(s) => {
if s.starts_with(|c| (c == '.') | (c == '~')) {
return false;
}
return s.ends_with(".md");
}
}
}
fn get_dir<P: AsRef<Path>>(root: P, value: &str) -> Result<PathBuf> {
let expanded_path = shellexpand::full(value)?.into_owned();
let dir = PathBuf::from(expanded_path.to_string());
if dir.is_relative() {
return Ok(root.as_ref().join(&dir));
} else {
return Ok(dir);
}
}
fn format_page_name(prefix: &str, page: usize, total: usize) -> String {
if page == 0 || page > total {
return String::default();
}
let mut s = String::with_capacity(prefix.len() + 10);
s.push_str(prefix);
if page > 1 {
s.push_str(&format!("-{}", page));
}
s.push_str(".html");
s
}