use chrono::NaiveDate; use pulldown_cmark::{html, Parser}; use regex::Regex; use std::fs; use std::path; use config::Config; #[derive(Debug, PartialEq)] pub enum EntryKind { Page, Post, } #[derive(Debug)] pub struct Entry { pub kind: EntryKind, pub title: String, pub slug: String, pub body: String, pub date: Option, } impl Entry { pub fn render(&self, template: &str, config: &Config) -> String { let parser = Parser::new(&self.body); let mut html_buf = String::new(); html::push_html(&mut html_buf, parser); let formatted_date = match self.date { Some(date) => date.format("%Y-%m-%d").to_string(), None => String::from(""), }; template .replace( "{{ page_title }}", &format!("{} | {}", &self.title, &config.site_name), ) .replace("{{ title }}", &self.title) .replace("{{ slug }}", &self.slug) .replace("{{ body }}", &html_buf.to_string()) .replace("{{ date }}", &formatted_date) } } pub fn read_entry_dir(cwd: &path::Path) -> Vec { match fs::read_dir(cwd) { Ok(entries) => entries.into_iter().map(|entry| entry.unwrap()).collect(), Err(err) => panic!(err), } } pub fn parse_entry(kind: EntryKind, contents: &str, path: &path::Path) -> Entry { lazy_static! { static ref RE_WITH_DATE: Regex = Regex::new(r"^# (?P.*) \| (?P<date>\d{4}-\d{2}-\d{2})\n\n(?s)(?P<body>.*)") .unwrap(); static ref RE_WITHOUT_DATE: Regex = Regex::new(r"^# (?P<title>.*)\n\n(?s)(?P<body>.*)").unwrap(); static ref SLUG_RE: Regex = Regex::new(r"(?P<slug>\S+).md").unwrap(); } let filename = &path.file_name().unwrap().to_str().unwrap(); let slug = SLUG_RE .captures(filename) .expect("Couldn't parse slug from filename")["slug"] .to_string(); if kind == EntryKind::Post { let captures = &RE_WITH_DATE .captures(&contents) .expect("Couldn't parse post"); let title = captures["title"].to_string(); let body = captures["body"].to_string(); let date = Some( NaiveDate::parse_from_str(&captures["date"], "%Y-%m-%d").expect("Couldn't parse date"), ); Entry { kind, title, body, slug, date, } } else { let captures = &RE_WITHOUT_DATE .captures(&contents) .expect("Couldn't parse page"); let title = captures["title"].to_string(); let body = captures["body"].to_string(); Entry { kind, title, body, slug, date: None, } } } pub fn write_entry( cwd: &path::Path, layout: &str, post_template: &str, entry: &Entry, config: &Config, ) { let root_path = match entry.kind { EntryKind::Post => cwd.join("public").join("posts").join(&entry.slug), EntryKind::Page => cwd.join("public").join(&entry.slug), }; match fs::create_dir(&root_path) { Ok(_) => {} Err(err) => match err.kind() { std::io::ErrorKind::AlreadyExists => {} _ => panic!(err), }, } let template = layout.replace("{{ contents }}", &post_template); fs::write( &root_path.join("index.html"), entry.render(&template, &config), ) .expect("Unable to write file"); } pub fn write_entry_listing( cwd: &path::Path, layout: &str, post_listing_template: &str, post_item_template: &str, posts: &Vec<Entry>, config: &Config, ) { fs::write( cwd.join("public").join("index.html"), render_post_listing( layout, post_listing_template, post_item_template, posts, config, ), ) .expect("Unable to write file"); } pub fn render_post_listing( layout: &str, post_listing_template: &str, post_item_template: &str, posts: &Vec<Entry>, config: &Config, ) -> String { layout .replace("{{ page_title }}", &format!("{}", config.site_name)) .replace( "{{ contents }}", &post_listing_template.replace( "{{ post_listing }}", &posts .iter() .map(|post| post.render(&post_item_template, &config)) .collect::<Vec<String>>() .join("\n"), ), ) } #[cfg(test)] mod tests { use chrono::NaiveDate; use uuid::Uuid; use std::{env, fs, path}; use super::*; #[test] fn test_render_post() { let config = Config { site_name: String::from("Test Site"), url: String::from("testsite.com"), description: String::from("recent posts from testsite.com"), }; let post = Entry { title: String::from("hello world"), body: String::from("lorem ipsum dolor sit amet"), slug: String::from("hello-world"), date: Some(NaiveDate::from_ymd(2019, 1, 1)), kind: EntryKind::Post, }; let output = post.render( "<html><head><title>{{ page_title }}

{{ title }}

{{ body }}
", &config ) .replace("\n", ""); assert_eq!( "hello world | Test Site

hello world

lorem ipsum dolor sit amet

", &output, ); } #[test] fn test_render_post_listing() { let config = Config { site_name: String::from("Test Site"), url: String::from("testsite.com"), description: String::from("recent posts from testsite.com"), }; let posts = vec![ Entry { title: String::from("First post"), body: String::from("lorem ipsum dolor sit amet"), slug: String::from("first-post"), date: Some(NaiveDate::from_ymd(2019, 1, 1)), kind: EntryKind::Post, }, Entry { title: String::from("Second post"), body: String::from("lorem ipsum dolor sit amet"), slug: String::from("second-post"), date: Some(NaiveDate::from_ymd(2019, 1, 1)), kind: EntryKind::Post, }, Entry { title: String::from("Third post"), body: String::from("lorem ipsum dolor sit amet"), slug: String::from("third-post"), date: Some(NaiveDate::from_ymd(2019, 1, 1)), kind: EntryKind::Post, }, ]; let output = render_post_listing( "{{ page_title }}{{ contents }}", "", "
  • {{ title }}
  • ", &posts, &config, ) .replace("\n", ""); assert_eq!( "Test Site", &output, ); } #[test] fn test_write_entry() { let temp_dir = env::temp_dir(); let project_dir = temp_dir.join(&Uuid::new_v4().to_string()); fs::create_dir(&project_dir).unwrap(); fs::create_dir(project_dir.join("public")).unwrap(); fs::create_dir(project_dir.join("public").join("posts")).unwrap(); let layout = "{{ page_title }}{{ contents }}"; let post_template = "

    {{ title }}

    {{ body }}
    "; let post = Entry { title: String::from("Hello world"), body: String::from("Lorem ipsum dolor sit amet"), slug: String::from("hello-world"), date: Some(NaiveDate::from_ymd(2019, 1, 1)), kind: EntryKind::Post, }; let config = Config { site_name: "Test Site".to_string(), url: String::from("testsite.com"), description: String::from("recent posts from testsite.com"), }; write_entry(&project_dir, &layout, &post_template, &post, &config); let content = fs::read_to_string( project_dir .join("public") .join("posts") .join("hello-world") .join("index.html"), ) .unwrap(); assert_eq!( "Hello world | Test Site

    Hello world

    Lorem ipsum dolor sit amet

    ", content.replace("\n", "") ); fs::remove_dir_all(temp_dir.join(&project_dir)).unwrap(); } #[test] fn test_write_post_listing() { let temp_dir = env::temp_dir(); let project_dir = temp_dir.join(&Uuid::new_v4().to_string()); fs::create_dir(&project_dir).unwrap(); env::set_current_dir(&project_dir).unwrap(); let cwd = env::current_dir().unwrap(); fs::create_dir(cwd.join("public")).unwrap(); let layout = "{{ page_title }}{{ contents }}"; let post_listing_template = ""; let post_item_template = "
  • {{ title }}
  • "; let posts = vec![ Entry { title: String::from("First post"), body: String::from("lorem ipsum dolor sit amet"), slug: String::from("first-post"), date: Some(NaiveDate::from_ymd(2019, 1, 1)), kind: EntryKind::Post, }, Entry { title: String::from("Second post"), body: String::from("lorem ipsum dolor sit amet"), slug: String::from("second-post"), date: Some(NaiveDate::from_ymd(2019, 1, 1)), kind: EntryKind::Post, }, Entry { title: String::from("Third post"), body: String::from("lorem ipsum dolor sit amet"), slug: String::from("third-post"), date: Some(NaiveDate::from_ymd(2019, 1, 1)), kind: EntryKind::Post, }, ]; let config = Config { site_name: "Test Site".to_string(), url: String::from("testsite.com"), description: String::from("recent posts from testsite.com"), }; write_entry_listing( &cwd, &layout, &post_listing_template, &post_item_template, &posts, &config, ); assert_eq!( "Test Site", fs::read_to_string(&cwd.join("public").join("index.html")) .unwrap() .replace("\n", ""), ); fs::remove_dir_all(temp_dir.join(&project_dir)).unwrap(); } #[test] fn test_parse_post_entry() { let post = parse_entry( EntryKind::Post, "# Test Title | 2000-01-01\n\nThis is the body", path::PathBuf::from("posts/one.md").as_path(), ); assert_eq!(post.kind, EntryKind::Post); assert_eq!(post.slug, "one"); assert_eq!(post.title, "Test Title"); assert_eq!(post.body, "This is the body"); assert_eq!(post.date, Some(NaiveDate::from_ymd(2000, 1, 1))); } #[test] fn test_parse_page_entry() { let post = parse_entry( EntryKind::Page, "# Test Title\n\nThis is the body", path::PathBuf::from("pages/one.md").as_path(), ); assert_eq!(post.kind, EntryKind::Page); assert_eq!(post.slug, "one"); assert_eq!(post.title, "Test Title"); assert_eq!(post.body, "This is the body"); assert_eq!(post.date, None); } #[test] fn test_display_entry_date() { let post = Entry { kind: EntryKind::Post, title: String::from("Test Title"), slug: String::from("one"), body: String::from("This is the body"), date: Some(NaiveDate::from_ymd(2000, 1, 1)), }; let config = Config { site_name: String::from("Test Site"), url: String::from("testsite.com"), description: String::from("recent posts from testsite.com"), }; assert_eq!("2000-01-01", post.render("{{ date }}", &config)); } }