A static site generator written in Rust
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

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. }