use crate::{BlogResult, Config, Run};
use derive_builder::Builder;
use regex::Regex;
use std::path::Path;
use std::{collections::HashMap, fs, path::PathBuf, time::UNIX_EPOCH};
use tracing::warn;
fn find_file(dir: &PathBuf, file_name: &str) -> Option<String> {
let entries = fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& path.file_stem().unwrap().to_str().unwrap().to_lowercase()
== file_name.to_lowercase()
{
return Some(path.to_str().unwrap().to_string());
}
}
None
}
fn match_imports(html: &str, config: &Config) -> String {
let template_path = config.template_dir();
let re_with_quotes = Regex::new(r"\[\[(.*?)\]\]").unwrap();
let result_with_quotes = re_with_quotes.replace_all(html, |caps: ®ex::Captures| {
let path_parts: Vec<&str> = caps[1].split('.').map(str::trim).collect();
if !path_parts.contains(&"") {
let file_name = path_parts.last().unwrap();
let dir_path = template_path.join(path_parts[..path_parts.len() - 1].join("/"));
let full_path = find_file(&dir_path, file_name);
if let Some(full_path) = full_path {
let extension = PathBuf::from(&full_path)
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default()
.to_string();
let out_path = config.output.join(file_name).with_extension(&extension);
fs::copy(&full_path, out_path).unwrap();
return PathBuf::from(file_name)
.with_extension(&extension)
.to_str()
.unwrap()
.to_string();
}
}
caps[0].to_string()
});
result_with_quotes.to_string()
}
fn match_inner(html: &str, config: &Config) -> String {
let template_path = config.template_dir();
let re_without_quotes = Regex::new(r"\{\{ (.*?) \}\}").unwrap();
let mut html = html.to_string();
loop {
let result_without_quotes =
re_without_quotes.replace_all(&html, |caps: ®ex::Captures| {
let path_parts: Vec<&str> = caps[1].split('.').map(str::trim).collect();
let file_name = path_parts.last().unwrap();
let dir_path: String = path_parts[..path_parts.len() - 1].join("/");
let dir_path = template_path.join(dir_path);
let full_path = find_file(&dir_path, file_name);
if let Some(full_path) = full_path {
fs::read_to_string(full_path).unwrap()
} else {
caps[0].to_string()
}
});
let match_imports = match_imports(&result_without_quotes, config);
if html == match_imports {
break;
}
html = match_imports;
}
html.to_string()
}
fn index_template(posts: &str, config: &Config) -> String {
let layouts_path = config.template_dir().join("layouts");
let index_base_path = layouts_path.join("base").join("posts.html");
let html = fs::read_to_string(index_base_path).expect("Unable to read index template");
let html = html
.replace("{{ .Posts }}", posts)
.replace("{{ .Title }}", &config.title);
match_inner(&html, config)
}
fn post_template(matter: &Matter, config: &Config) -> String {
let layouts_path = config.template_dir().join("layouts");
let post_base_path = layouts_path.join("base").join("post.html");
let html = fs::read_to_string(post_base_path).expect("Unable to read post template");
let html = match_inner(&html, config);
html.replace("{{ matter.Title }}", &matter.title())
.replace("{{ matter.Date }}", &matter.date())
.replace("{{ matter.Content }}", &matter.content())
.replace(
"{{ matter.TimeToRead }}",
&time_to_read(&matter.content()).to_string(),
)
}
fn time_to_read(content: &str) -> usize {
let words = content.split_whitespace().count();
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_precision_loss)]
#[allow(clippy::cast_sign_loss)]
let time = (words as f64 / 200.0).ceil() as usize;
if time == 0 {
1
} else {
time
}
}
pub struct Compile;
fn get_file_name_without_extension_and_extension(path: &Path) -> Option<(String, String)> {
let file_name_without_extension = path.file_stem()?.to_str()?.to_string();
let extension = path.extension()?.to_str()?.to_string();
Some((file_name_without_extension, extension))
}
fn get_file_creation_date(path: PathBuf) -> Option<String> {
let metadata = fs::metadata(path).ok()?;
let creation_date = metadata.created().ok()?;
let chron = chrono::NaiveDateTime::from_timestamp_opt(
creation_date
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
.try_into()
.unwrap(),
0,
)
.unwrap()
.format("%Y-%m-%d")
.to_string();
Some(chron)
}
#[derive(Debug, Clone, Builder)]
struct FrontMatter {
#[builder(setter(custom))]
pub date: String,
#[builder(setter(custom))]
pub title: String,
}
impl FrontMatterBuilder {
fn date(&mut self, date: Option<String>, path: Option<PathBuf>) -> &mut Self {
if self.date.is_some() {
return self;
}
let date = date.unwrap_or_else(|| {
get_file_creation_date(path.unwrap()).unwrap_or_else(|| {
warn!("Unable to get file creation date");
"Unknown".to_string()
})
});
self.date = Some(date);
self
}
fn title(&mut self, title: Option<String>, path: Option<PathBuf>) -> &mut Self {
if self.title.is_some() {
return self;
}
let title = title.unwrap_or_else(|| {
get_file_name_without_extension_and_extension(&path.unwrap())
.unwrap_or_else(|| ("Unknown".to_string(), "Unknown".to_string()))
.0
});
self.title = Some(title);
self
}
}
#[derive(Debug, Clone)]
struct Matter {
#[allow(clippy::struct_field_names)]
front_matter: FrontMatter,
content: String,
extension: String,
output_path: PathBuf,
}
impl Matter {
fn content(&self) -> String {
markdown::to_html(&self.content)
}
fn title(&self) -> String {
self.front_matter.title.clone()
}
fn date(&self) -> String {
self.front_matter.date.clone()
}
fn split_file(path: &Path) -> BlogResult<(String, String)> {
let post = fs::read_to_string(path).map_err(|e| {
crate::BlogError::io(
e,
path.to_path_buf().to_str().unwrap_or_default().to_string(),
)
})?;
let re = Regex::new(r"(?s)\+\+\+(.*?)\+\+\+(.*)").unwrap();
let caps = re.captures(&post).unwrap();
let front_matter = caps.get(1).map_or("", |m| m.as_str());
let content = caps.get(2).map_or("", |m| m.as_str());
Ok((front_matter.to_string(), content.to_string()))
}
fn new(path: &Path, out_path: &Path) -> BlogResult<Self> {
let (front_text, content) = Matter::split_file(path)?;
let front_matter = FrontMatter::parse(&front_text, path)?;
let (name, extension) =
get_file_name_without_extension_and_extension(path).ok_or_else(|| {
crate::BlogError::InvalidFileName(path.to_str().unwrap_or_default().to_string())
})?;
let output_path = out_path.join(format!("{name}.html"));
Ok(Self {
front_matter,
content,
extension,
output_path,
})
}
fn valid_type(&self) -> bool {
self.extension == "md"
}
}
impl FrontMatter {
fn parse(input: &str, path: &Path) -> BlogResult<Self> {
let mut s = FrontMatterBuilder::default();
let tokens = input.split('\n');
let mut date = None;
let mut title = None;
for token in tokens {
if token.is_empty() {
continue;
}
let (key, value) = token.split_once(':').ok_or_else(|| {
crate::BlogError::InvalidFrontMatter(format!(
"Invalid seperator front matter token in {} expected a ':'",
path.to_str().unwrap(),
))
})?;
match key {
"date" => {
date = Some(value.trim().to_string());
}
"title" => {
title = Some(value.trim().to_string());
}
_ => {}
}
}
s.date(date, Some((path).to_path_buf()))
.title(title, Some((path).to_path_buf()))
.build()
.map_err(|e| crate::BlogError::InvalidFrontMatter(e.to_string()))
}
}
impl Run for Compile {
fn run(&self) -> Result<(), crate::BlogError> {
let mut index: HashMap<String, Vec<Matter>> = HashMap::new();
let config = Config::load_toml()?;
for path in Config::load_posts()? {
let matter = Matter::new(&path, &config.output)?;
if !matter.valid_type() {
continue;
}
let output = post_template(&matter, &config);
fs::write(matter.output_path.clone(), output)
.map_err(|e| crate::BlogError::io(e, format!("{:?}", matter.output_path)))?;
index
.entry(matter.front_matter.date.clone())
.or_default()
.push(matter);
}
create_index(index, &config);
Ok(())
}
}
fn create_index(content: HashMap<String, Vec<Matter>>, config: &Config) {
let mut index = String::new();
let mut content: Vec<_> = content.into_iter().collect();
content.sort_by(|a, b| b.0.cmp(&a.0));
for (date, posts) in content {
index.push_str(&format!("<h1>{date}</h1>"));
for matter in posts {
index.push_str(&format!(
"<li><a href=\"{}\">{}</a></li>",
matter.output_path.file_name().unwrap().to_str().unwrap(),
matter.front_matter.title
));
}
}
let index = index_template(&index, config);
fs::write(config.output.join("posts.html"), index).expect("Unable to write index file");
fs::write(config.output.join("index.html"), create_main_page(&config))
.expect("Unable to write main page");
}
fn create_main_page(config: &Config) -> String {
let html = fs::read_to_string(
config
.template_dir()
.join("layouts")
.join("base")
.join("index.html"),
)
.expect("Unable to read main page template");
match_inner(&html, config)
}