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 post_template(matter: &Matter) -> String {
format!(
r#"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>first</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="header">
<h1>ラ<span class="f1">ン</span>ダム<span class="f2">な日</span>本>語</h1>
</div>
<div class="content">
<h1>{}</h1>
<p>Date: {}</p>
<div id="content"></div>
{}
</div>
</body>
</html>
"#,
matter.title(),
matter.date(),
matter.content()
)
}
fn index_template(posts: &str) -> String {
format!(
r#"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Index</title>
</head>
<body>
<h1>Posts</h1>
<ul id="post-list">
{posts}
</ul>
</body>
</html>
"#,
)
}
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()?;
for path in config.posts {
let matter = Matter::new(&path, &config.output)?;
if !matter.valid_type() {
continue;
}
let output = post_template(&matter);
fs::write(matter.output_path.clone(), output)
.map_err(|e| crate::BlogError::io(e, "Unable to write post".to_string()))?;
index
.entry(matter.front_matter.date.clone())
.or_default()
.push(matter);
}
create_index(index, &config.output);
Ok(())
}
}
fn create_index(content: HashMap<String, Vec<Matter>>, out_path: &Path) {
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!(
"<a href=\"{}\">{}</a><br>",
matter.output_path.file_name().unwrap().to_str().unwrap(),
matter.front_matter.title
));
}
}
let index = index_template(&index);
fs::write(out_path.join("index.html"), index).expect("Unable to write index file");
}