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.

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