A static site generator written in Rust
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

entry.rs 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. use chrono::NaiveDate;
  2. use pulldown_cmark::{html, Parser};
  3. use regex::Regex;
  4. use std::fs;
  5. use std::path;
  6. use config::Config;
  7. #[derive(Debug, PartialEq)]
  8. pub enum EntryKind {
  9. Page,
  10. Post,
  11. }
  12. #[derive(Debug)]
  13. pub struct Entry {
  14. pub kind: EntryKind,
  15. pub title: String,
  16. pub slug: String,
  17. pub body: String,
  18. pub date: Option<NaiveDate>,
  19. }
  20. impl Entry {
  21. pub fn render(&self, template: &str, config: &Config) -> String {
  22. let parser = Parser::new(&self.body);
  23. let mut html_buf = String::new();
  24. html::push_html(&mut html_buf, parser);
  25. let formatted_date = match self.date {
  26. Some(date) => date.format("%Y-%m-%d").to_string(),
  27. None => String::from(""),
  28. };
  29. template
  30. .replace(
  31. "{{ page_title }}",
  32. &format!("{} | {}", &self.title, &config.site_name),
  33. )
  34. .replace("{{ title }}", &self.title)
  35. .replace("{{ slug }}", &self.slug)
  36. .replace("{{ body }}", &html_buf.to_string())
  37. .replace("{{ date }}", &formatted_date)
  38. }
  39. }
  40. pub fn read_entry_dir(cwd: &path::Path) -> Vec<fs::DirEntry> {
  41. match fs::read_dir(cwd) {
  42. Ok(entries) => entries.into_iter().map(|entry| entry.unwrap()).collect(),
  43. Err(err) => panic!(err),
  44. }
  45. }
  46. pub fn parse_entry(kind: EntryKind, contents: &str, path: &path::Path) -> Entry {
  47. lazy_static! {
  48. static ref RE_WITH_DATE: Regex =
  49. Regex::new(r"^# (?P<title>.*) \| (?P<date>\d{4}-\d{2}-\d{2})\n\n(?s)(?P<body>.*)")
  50. .unwrap();
  51. static ref RE_WITHOUT_DATE: Regex =
  52. Regex::new(r"^# (?P<title>.*)\n\n(?s)(?P<body>.*)").unwrap();
  53. static ref SLUG_RE: Regex = Regex::new(r"(?P<slug>\S+).md").unwrap();
  54. }
  55. let filename = &path.file_name().unwrap().to_str().unwrap();
  56. let slug = SLUG_RE
  57. .captures(filename)
  58. .expect("Couldn't parse slug from filename")["slug"]
  59. .to_string();
  60. if kind == EntryKind::Post {
  61. let captures = &RE_WITH_DATE
  62. .captures(&contents)
  63. .expect("Couldn't parse post");
  64. let title = captures["title"].to_string();
  65. let body = captures["body"].to_string();
  66. let date = Some(
  67. NaiveDate::parse_from_str(&captures["date"], "%Y-%m-%d").expect("Couldn't parse date"),
  68. );
  69. Entry {
  70. kind,
  71. title,
  72. body,
  73. slug,
  74. date,
  75. }
  76. } else {
  77. let captures = &RE_WITHOUT_DATE
  78. .captures(&contents)
  79. .expect("Couldn't parse page");
  80. let title = captures["title"].to_string();
  81. let body = captures["body"].to_string();
  82. Entry {
  83. kind,
  84. title,
  85. body,
  86. slug,
  87. date: None,
  88. }
  89. }
  90. }
  91. pub fn write_entry(
  92. cwd: &path::Path,
  93. layout: &str,
  94. post_template: &str,
  95. entry: &Entry,
  96. config: &Config,
  97. ) {
  98. let root_path = match entry.kind {
  99. EntryKind::Post => cwd.join("public").join("posts").join(&entry.slug),
  100. EntryKind::Page => cwd.join("public").join(&entry.slug),
  101. };
  102. match fs::create_dir(&root_path) {
  103. Ok(_) => {}
  104. Err(err) => match err.kind() {
  105. std::io::ErrorKind::AlreadyExists => {}
  106. _ => panic!(err),
  107. },
  108. }
  109. let template = layout.replace("{{ contents }}", &post_template);
  110. fs::write(
  111. &root_path.join("index.html"),
  112. entry.render(&template, &config),
  113. )
  114. .expect("Unable to write file");
  115. }
  116. pub fn write_entry_listing(
  117. cwd: &path::Path,
  118. layout: &str,
  119. post_listing_template: &str,
  120. post_item_template: &str,
  121. posts: &Vec<Entry>,
  122. config: &Config,
  123. ) {
  124. fs::write(
  125. cwd.join("public").join("index.html"),
  126. render_post_listing(
  127. layout,
  128. post_listing_template,
  129. post_item_template,
  130. posts,
  131. config,
  132. ),
  133. )
  134. .expect("Unable to write file");
  135. }
  136. pub fn render_post_listing(
  137. layout: &str,
  138. post_listing_template: &str,
  139. post_item_template: &str,
  140. posts: &Vec<Entry>,
  141. config: &Config,
  142. ) -> String {
  143. layout
  144. .replace("{{ page_title }}", &format!("{}", config.site_name))
  145. .replace(
  146. "{{ contents }}",
  147. &post_listing_template.replace(
  148. "{{ post_listing }}",
  149. &posts
  150. .iter()
  151. .map(|post| post.render(&post_item_template, &config))
  152. .collect::<Vec<String>>()
  153. .join("\n"),
  154. ),
  155. )
  156. }
  157. #[cfg(test)]
  158. mod tests {
  159. use chrono::NaiveDate;
  160. use uuid::Uuid;
  161. use std::{env, fs, path};
  162. use super::*;
  163. #[test]
  164. fn test_render_post() {
  165. let config = Config {
  166. site_name: String::from("Test Site"),
  167. url: String::from("testsite.com"),
  168. description: String::from("recent posts from testsite.com"),
  169. };
  170. let post = Entry {
  171. title: String::from("hello world"),
  172. body: String::from("lorem ipsum dolor sit amet"),
  173. slug: String::from("hello-world"),
  174. date: Some(NaiveDate::from_ymd(2019, 1, 1)),
  175. kind: EntryKind::Post,
  176. };
  177. let output = post.render(
  178. "<html><head><title>{{ page_title }}</title></head><body><article><h1>{{ title }}</h1><div>{{ body }}</div></article></body></html>", &config
  179. )
  180. .replace("\n", "");
  181. assert_eq!(
  182. "<html><head><title>hello world | Test Site</title></head><body><article><h1>hello world</h1><div><p>lorem ipsum dolor sit amet</p></div></article></body></html>",
  183. &output,
  184. );
  185. }
  186. #[test]
  187. fn test_render_post_listing() {
  188. let config = Config {
  189. site_name: String::from("Test Site"),
  190. url: String::from("testsite.com"),
  191. description: String::from("recent posts from testsite.com"),
  192. };
  193. let posts = vec![
  194. Entry {
  195. title: String::from("First post"),
  196. body: String::from("lorem ipsum dolor sit amet"),
  197. slug: String::from("first-post"),
  198. date: Some(NaiveDate::from_ymd(2019, 1, 1)),
  199. kind: EntryKind::Post,
  200. },
  201. Entry {
  202. title: String::from("Second post"),
  203. body: String::from("lorem ipsum dolor sit amet"),
  204. slug: String::from("second-post"),
  205. date: Some(NaiveDate::from_ymd(2019, 1, 1)),
  206. kind: EntryKind::Post,
  207. },
  208. Entry {
  209. title: String::from("Third post"),
  210. body: String::from("lorem ipsum dolor sit amet"),
  211. slug: String::from("third-post"),
  212. date: Some(NaiveDate::from_ymd(2019, 1, 1)),
  213. kind: EntryKind::Post,
  214. },
  215. ];
  216. let output = render_post_listing(
  217. "<html><head><title>{{ page_title }}</title></head><body>{{ contents }}</body></html>",
  218. "<ul>{{ post_listing }}</ul>",
  219. "<li><a href=\"/{{ slug }}\">{{ title }}</a></li>",
  220. &posts,
  221. &config,
  222. )
  223. .replace("\n", "");
  224. assert_eq!(
  225. "<html><head><title>Test Site</title></head><body><ul><li><a href=\"/first-post\">First post</a></li><li><a href=\"/second-post\">Second post</a></li><li><a href=\"/third-post\">Third post</a></li></ul></body></html>",
  226. &output,
  227. );
  228. }
  229. #[test]
  230. fn test_write_entry() {
  231. let temp_dir = env::temp_dir();
  232. let project_dir = temp_dir.join(&Uuid::new_v4().to_string());
  233. fs::create_dir(&project_dir).unwrap();
  234. fs::create_dir(project_dir.join("public")).unwrap();
  235. fs::create_dir(project_dir.join("public").join("posts")).unwrap();
  236. let layout =
  237. "<html><head><title>{{ page_title }}</title></head><body>{{ contents }}</body></html>";
  238. let post_template = "<article><h1>{{ title }}</h1><div>{{ body }}</div></article>";
  239. let post = Entry {
  240. title: String::from("Hello world"),
  241. body: String::from("Lorem ipsum dolor sit amet"),
  242. slug: String::from("hello-world"),
  243. date: Some(NaiveDate::from_ymd(2019, 1, 1)),
  244. kind: EntryKind::Post,
  245. };
  246. let config = Config {
  247. site_name: "Test Site".to_string(),
  248. url: String::from("testsite.com"),
  249. description: String::from("recent posts from testsite.com"),
  250. };
  251. write_entry(&project_dir, &layout, &post_template, &post, &config);
  252. let content = fs::read_to_string(
  253. project_dir
  254. .join("public")
  255. .join("posts")
  256. .join("hello-world")
  257. .join("index.html"),
  258. )
  259. .unwrap();
  260. assert_eq!(
  261. "<html><head><title>Hello world | Test Site</title></head><body><article><h1>Hello world</h1><div><p>Lorem ipsum dolor sit amet</p></div></article></body></html>",
  262. content.replace("\n", "")
  263. );
  264. fs::remove_dir_all(temp_dir.join(&project_dir)).unwrap();
  265. }
  266. #[test]
  267. fn test_write_post_listing() {
  268. let temp_dir = env::temp_dir();
  269. let project_dir = temp_dir.join(&Uuid::new_v4().to_string());
  270. fs::create_dir(&project_dir).unwrap();
  271. env::set_current_dir(&project_dir).unwrap();
  272. let cwd = env::current_dir().unwrap();
  273. fs::create_dir(cwd.join("public")).unwrap();
  274. let layout =
  275. "<html><head><title>{{ page_title }}</title></head><body>{{ contents }}</body></html>";
  276. let post_listing_template = "<ul>{{ post_listing }}</ul>";
  277. let post_item_template = "<li><a href=\"/{{ slug }}\">{{ title }}</a></li>";
  278. let posts = vec![
  279. Entry {
  280. title: String::from("First post"),
  281. body: String::from("lorem ipsum dolor sit amet"),
  282. slug: String::from("first-post"),
  283. date: Some(NaiveDate::from_ymd(2019, 1, 1)),
  284. kind: EntryKind::Post,
  285. },
  286. Entry {
  287. title: String::from("Second post"),
  288. body: String::from("lorem ipsum dolor sit amet"),
  289. slug: String::from("second-post"),
  290. date: Some(NaiveDate::from_ymd(2019, 1, 1)),
  291. kind: EntryKind::Post,
  292. },
  293. Entry {
  294. title: String::from("Third post"),
  295. body: String::from("lorem ipsum dolor sit amet"),
  296. slug: String::from("third-post"),
  297. date: Some(NaiveDate::from_ymd(2019, 1, 1)),
  298. kind: EntryKind::Post,
  299. },
  300. ];
  301. let config = Config {
  302. site_name: "Test Site".to_string(),
  303. url: String::from("testsite.com"),
  304. description: String::from("recent posts from testsite.com"),
  305. };
  306. write_entry_listing(
  307. &cwd,
  308. &layout,
  309. &post_listing_template,
  310. &post_item_template,
  311. &posts,
  312. &config,
  313. );
  314. assert_eq!(
  315. "<html><head><title>Test Site</title></head><body><ul><li><a href\
  316. =\"/first-post\">First post</a></li><li><a href=\"/second-post\">\
  317. Second post</a></li><li><a href=\"/third-post\">Third post</a></li\
  318. ></ul></body></html>",
  319. fs::read_to_string(&cwd.join("public").join("index.html"))
  320. .unwrap()
  321. .replace("\n", ""),
  322. );
  323. fs::remove_dir_all(temp_dir.join(&project_dir)).unwrap();
  324. }
  325. #[test]
  326. fn test_parse_post_entry() {
  327. let post = parse_entry(
  328. EntryKind::Post,
  329. "# Test Title | 2000-01-01\n\nThis is the body",
  330. path::PathBuf::from("posts/one.md").as_path(),
  331. );
  332. assert_eq!(post.kind, EntryKind::Post);
  333. assert_eq!(post.slug, "one");
  334. assert_eq!(post.title, "Test Title");
  335. assert_eq!(post.body, "This is the body");
  336. assert_eq!(post.date, Some(NaiveDate::from_ymd(2000, 1, 1)));
  337. }
  338. #[test]
  339. fn test_parse_page_entry() {
  340. let post = parse_entry(
  341. EntryKind::Page,
  342. "# Test Title\n\nThis is the body",
  343. path::PathBuf::from("pages/one.md").as_path(),
  344. );
  345. assert_eq!(post.kind, EntryKind::Page);
  346. assert_eq!(post.slug, "one");
  347. assert_eq!(post.title, "Test Title");
  348. assert_eq!(post.body, "This is the body");
  349. assert_eq!(post.date, None);
  350. }
  351. #[test]
  352. fn test_display_entry_date() {
  353. let post = Entry {
  354. kind: EntryKind::Post,
  355. title: String::from("Test Title"),
  356. slug: String::from("one"),
  357. body: String::from("This is the body"),
  358. date: Some(NaiveDate::from_ymd(2000, 1, 1)),
  359. };
  360. let config = Config {
  361. site_name: String::from("Test Site"),
  362. url: String::from("testsite.com"),
  363. description: String::from("recent posts from testsite.com"),
  364. };
  365. assert_eq!("2000-01-01", post.render("{{ date }}", &config));
  366. }
  367. }