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.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  1. use std::sync::mpsc::channel;
  2. use std::time::Duration;
  3. use std::{env, fs, path};
  4. use fs_extra::dir;
  5. use notify::{DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher};
  6. use toml::Value;
  7. use config::Config;
  8. use entry::{parse_entry, read_entry_dir, write_entry, write_entry_listing, Entry};
  9. pub fn build(include_drafts: bool) {
  10. let cwd = env::current_dir().expect("Couldn't read current directory");
  11. let config = match fs::read_to_string(cwd.join("casaubon.toml")) {
  12. Ok(contents) => match contents.parse::<Value>() {
  13. Ok(config) => Config {
  14. site_name: String::from(config["site_name"].as_str().unwrap()),
  15. },
  16. Err(_) => panic!("Invalid casaubon.toml"),
  17. },
  18. Err(_) => {
  19. panic!("Can't find casaubon.toml");
  20. }
  21. };
  22. match fs::read_dir(cwd.join("public")) {
  23. Ok(_) => {
  24. fs::remove_dir_all(cwd.join("public")).unwrap();
  25. }
  26. Err(_) => {}
  27. }
  28. fs::create_dir(cwd.join("public")).expect("Couldn't create public directory");
  29. fs::create_dir(cwd.join("public").join("posts")).expect("Couldn't create posts directory");
  30. let layout_template = fs::read_to_string(&cwd.join("templates").join("layout.html"))
  31. .expect("Couldn't find layout template");
  32. let post_template = fs::read_to_string(cwd.join("templates").join("post.html"))
  33. .expect("Couldn't find post template");
  34. let post_listing_template = fs::read_to_string(cwd.join("templates").join("post_listing.html"))
  35. .expect("Couldn't find post listing item template");
  36. let post_item_template =
  37. fs::read_to_string(cwd.join("templates").join("post_listing_item.html"))
  38. .expect("Couldn't find post listing item template");
  39. let page_template = fs::read_to_string(cwd.join("templates").join("page.html"))
  40. .expect("Couldn't find page template");
  41. let post_paths = match include_drafts {
  42. true => {
  43. let mut posts = read_entry_dir(&cwd.join("posts"));
  44. posts.append(&mut read_entry_dir(&cwd.join("drafts")));
  45. posts
  46. }
  47. false => read_entry_dir(&cwd.join("posts")),
  48. };
  49. let page_paths = read_entry_dir(&cwd.join("pages"));
  50. let mut posts: Vec<Entry> = post_paths
  51. .into_iter()
  52. .map(|entry| {
  53. let path = entry.path();
  54. let contents = fs::read_to_string(&path).expect("Couldn't read post file");
  55. parse_entry(&contents, path)
  56. })
  57. .collect::<Vec<Entry>>();
  58. posts.sort_by(|a, b| b.date.cmp(&a.date));
  59. for post in &posts {
  60. write_entry(&cwd, &layout_template, &post_template, &post, &config);
  61. }
  62. for entry in page_paths.into_iter() {
  63. let path = entry.path();
  64. let contents = fs::read_to_string(&path).expect("Couldn't read page file");
  65. let page = parse_entry(&contents, path);
  66. write_entry(&cwd, &layout_template, &page_template, &page, &config);
  67. }
  68. write_entry_listing(
  69. &cwd,
  70. &layout_template,
  71. &post_listing_template,
  72. &post_item_template,
  73. &posts,
  74. &config,
  75. );
  76. fs_extra::copy_items(
  77. &vec![cwd.join("css"), cwd.join("js")],
  78. cwd.join("public"),
  79. &dir::CopyOptions::new(),
  80. )
  81. .expect("Couldn't copy css/js directories");
  82. }
  83. pub fn new(name: &str) {
  84. let cwd = env::current_dir().expect("Couldn't read current directory");
  85. let project_path = cwd.join(name);
  86. fs::create_dir(&project_path).expect(&format!("Couldn't create directory '{}'", &name));
  87. fs::write(
  88. project_path.join("casaubon.toml"),
  89. format!("site_name = \"{}\"", &name),
  90. )
  91. .expect("Could not create casaubon.toml");
  92. for dir in &[
  93. "drafts",
  94. "posts",
  95. "pages",
  96. "public",
  97. "templates",
  98. "css",
  99. "js",
  100. ] {
  101. fs::create_dir(&project_path.join(&dir))
  102. .expect(&format!("Couldn't create {} directory", &dir));
  103. }
  104. fs::write(project_path.join("css").join("style.css"), "")
  105. .expect("Couldn't create css/style.css");
  106. fs::write(project_path.join("js").join("index.js"), "").expect("Couldn't create js/index.js");
  107. let default_layout_template = format!(
  108. "<html>
  109. <head>
  110. <title>{}</title>
  111. </head>
  112. <body>
  113. <h1>{}</h1>
  114. {{{{ contents }}}}
  115. </body>
  116. </html>\n",
  117. name, name
  118. );
  119. let default_post_listing_template = format!(
  120. "<div>
  121. <h3>Posts</h3>
  122. <ul>{{{{ post_listing }}}}</ul>
  123. </div>\n"
  124. );
  125. let default_post_template = format!(
  126. "<article>
  127. <h1>{{{{ title }}}}</h1>
  128. <div>{{{{ body }}}}</div>
  129. </article>\n"
  130. );
  131. let default_page_template = format!(
  132. "<article>
  133. <h1>{{{{ title }}}}</h1>
  134. <div>{{{{ body }}}}</div>
  135. </article>\n"
  136. );
  137. let default_post_listing_item_template = format!(
  138. "<li>
  139. {{ date }} <a href=\"/posts/{{{{ slug }}}}/\">{{{{ title }}}}</a>
  140. </li>\n"
  141. );
  142. for (filename, contents) in &[
  143. ("layout", &default_layout_template),
  144. ("post_listing", &default_post_listing_template),
  145. ("post", &default_post_template),
  146. ("page", &default_page_template),
  147. ("post_listing_item", &default_post_listing_item_template),
  148. ] {
  149. fs::write(
  150. &project_path
  151. .join("templates")
  152. .join(format!("{}.html", filename)),
  153. &contents,
  154. )
  155. .expect(&format!("Couldn't write templates/{}.html", filename));
  156. }
  157. }
  158. fn should_rebuild(cwd: &path::PathBuf, path: &path::PathBuf) -> bool {
  159. let path_string = path.to_str().unwrap().to_string();
  160. let change_is_from_public = path_string.contains(cwd.join("public").to_str().unwrap());
  161. let change_is_from_git = path_string.contains(cwd.join(".git").to_str().unwrap());
  162. !change_is_from_public && !change_is_from_git
  163. }
  164. pub fn watch(include_drafts: bool) -> notify::Result<()> {
  165. let cwd = env::current_dir().expect("Couldn't read current directory");
  166. let (tx, rx) = channel();
  167. let mut watcher: RecommendedWatcher = try!(Watcher::new(tx, Duration::from_secs(2)));
  168. try!(watcher.watch(&cwd, RecursiveMode::Recursive));
  169. println!("Watching {}", cwd.to_str().unwrap());
  170. let handle_event = |path: &path::PathBuf| {
  171. if should_rebuild(&cwd, &path) {
  172. println!("Rebuilding");
  173. build(include_drafts);
  174. }
  175. };
  176. loop {
  177. match rx.recv() {
  178. Ok(e) => match e {
  179. DebouncedEvent::Create(path) => {
  180. handle_event(&path);
  181. }
  182. DebouncedEvent::Write(path) => {
  183. handle_event(&path);
  184. }
  185. _ => {}
  186. },
  187. Err(e) => println!("watch error: {:?}", e),
  188. }
  189. }
  190. }
  191. #[cfg(test)]
  192. mod tests {
  193. #[allow(unused_imports)]
  194. use super::*;
  195. #[allow(unused_imports)]
  196. use uuid::Uuid;
  197. #[test]
  198. fn test_new() {
  199. let temp_dir = env::temp_dir();
  200. env::set_current_dir(&temp_dir).unwrap();
  201. let uuid = Uuid::new_v4().to_string();
  202. let project_dir = temp_dir.join(&uuid);
  203. new(&uuid);
  204. for dir in &["public", "pages", "posts", "templates"] {
  205. fs::read_dir(&project_dir.join(dir)).unwrap();
  206. }
  207. assert_eq!(
  208. format!(
  209. "<html><head><title>{}</title></head><body><h1>{}</h1>{{{{ contents }}}}</body></html>",
  210. uuid, uuid
  211. ),
  212. fs::read_to_string(&project_dir.join("templates").join("layout.html"))
  213. .unwrap()
  214. .replace("\n", "")
  215. .replace(" ", "")
  216. );
  217. assert_eq!(
  218. format!("<div><h3>Posts</h3><ul>{{{{ post_listing }}}}</ul></div>"),
  219. fs::read_to_string(&project_dir.join("templates").join("post_listing.html"))
  220. .unwrap()
  221. .replace("\n", "")
  222. .replace(" ", "")
  223. );
  224. assert_eq!(
  225. format!("<li>{{ date }} <a href=\"/posts/{{{{ slug }}}}/\">{{{{ title }}}}</a></li>"),
  226. fs::read_to_string(&project_dir.join("templates").join("post_listing_item.html"))
  227. .unwrap()
  228. .replace("\n", "")
  229. .replace(" ", "")
  230. );
  231. assert_eq!(
  232. format!("<article><h1>{{{{ title }}}}</h1><div>{{{{ body }}}}</div></article>"),
  233. fs::read_to_string(&project_dir.join("templates").join("post.html"))
  234. .unwrap()
  235. .replace("\n", "")
  236. .replace(" ", "")
  237. );
  238. assert_eq!(
  239. format!("<article><h1>{{{{ title }}}}</h1><div>{{{{ body }}}}</div></article>"),
  240. fs::read_to_string(&project_dir.join("templates").join("page.html"))
  241. .unwrap()
  242. .replace("\n", "")
  243. .replace(" ", "")
  244. );
  245. assert_eq!(
  246. "",
  247. fs::read_to_string(&project_dir.join("css").join("style.css")).unwrap()
  248. );
  249. assert_eq!(
  250. "",
  251. fs::read_to_string(&project_dir.join("js").join("index.js")).unwrap()
  252. );
  253. assert_eq!(
  254. format!("site_name = \"{}\"", &uuid),
  255. fs::read_to_string(&project_dir.join("casaubon.toml")).unwrap()
  256. );
  257. fs::remove_dir_all(project_dir).unwrap();
  258. }
  259. #[test]
  260. fn test_build() {
  261. let temp_dir = env::temp_dir();
  262. let uuid = Uuid::new_v4().to_string();
  263. let project_dir = temp_dir.join(&uuid);
  264. fs::create_dir(&project_dir).unwrap();
  265. env::set_current_dir(&project_dir).unwrap();
  266. fs::create_dir(project_dir.join("posts")).unwrap();
  267. fs::create_dir(project_dir.join("pages")).unwrap();
  268. fs::create_dir(project_dir.join("public")).unwrap();
  269. fs::create_dir(project_dir.join("public").join("posts")).unwrap();
  270. fs::create_dir(project_dir.join("templates")).unwrap();
  271. fs::create_dir(project_dir.join("css")).unwrap();
  272. fs::create_dir(project_dir.join("js")).unwrap();
  273. fs::write(
  274. project_dir.join("css").join("style.css"),
  275. "body { background: blue; }",
  276. )
  277. .unwrap();
  278. fs::write(
  279. project_dir.join("js").join("index.js"),
  280. "window.onload = function () { alert() }",
  281. )
  282. .unwrap();
  283. fs::write(
  284. project_dir.join("templates").join("layout.html"),
  285. "<html><head><title>{{ page_title }}</title></head><body>{{ contents }}</body></html>",
  286. )
  287. .unwrap();
  288. fs::write(
  289. project_dir.join("templates").join("post.html"),
  290. "<article><h1>{{ title }}</h1><div>{{ body }}</div></article>",
  291. )
  292. .unwrap();
  293. fs::write(
  294. project_dir.join("templates").join("page.html"),
  295. "<article class=\"page\"><h1>{{ title }}</h1><div>{{ body }}</div></article>",
  296. )
  297. .unwrap();
  298. fs::write(
  299. project_dir.join("templates").join("post_listing.html"),
  300. "<ul>{{ post_listing }}</ul>",
  301. )
  302. .unwrap();
  303. fs::write(
  304. project_dir.join("templates").join("post_listing_item.html"),
  305. "<li><a href=\"/{{ slug }}\">{{ title }}</a></li>",
  306. )
  307. .unwrap();
  308. fs::write(
  309. project_dir.join("posts").join("first-post.md"),
  310. "# First post | 2019-01-01\n\nThis is the first post\n\nIt has multiple paragraphs",
  311. )
  312. .unwrap();
  313. fs::write(
  314. project_dir.join("pages").join("first-page.md"),
  315. "# First page\n\nThis is the first page\n\nIt has multiple paragraphs",
  316. )
  317. .unwrap();
  318. fs::write(
  319. project_dir.join("casaubon.toml"),
  320. "site_name = \"Test Site\"",
  321. )
  322. .unwrap();
  323. build(false);
  324. assert_eq!(
  325. "<html><head><title>Test Site</title></head><body><ul><li><a href=\"/fir\
  326. st-post\">First post</a></li></ul></body></html>",
  327. fs::read_to_string(project_dir.join("public").join("index.html")).unwrap(),
  328. );
  329. assert_eq!(
  330. "<html><head><title>First post | Test Site</title></head><body><article><h1>First pos\
  331. t</h1><div><p>This is the first post</p><p>It has multiple paragra\
  332. phs</p></div></article></body></html>",
  333. fs::read_to_string(
  334. project_dir
  335. .join("public")
  336. .join("posts")
  337. .join("first-post")
  338. .join("index.html")
  339. )
  340. .unwrap()
  341. .replace("\n", ""),
  342. );
  343. assert_eq!(
  344. "<html><head><title>First page | Test Site</title></head><body><article class=\"page\"><h1>First pag\
  345. e</h1><div><p>This is the first page</p><p>It has multiple paragra\
  346. phs</p></div></article></body></html>",
  347. fs::read_to_string(
  348. project_dir
  349. .join("public")
  350. .join("first-page")
  351. .join("index.html")
  352. )
  353. .unwrap()
  354. .replace("\n", ""),
  355. );
  356. assert_eq!(
  357. "body { background: blue; }",
  358. fs::read_to_string(project_dir.join("public").join("css").join("style.css")).unwrap()
  359. );
  360. assert_eq!(
  361. "window.onload = function () { alert() }",
  362. fs::read_to_string(project_dir.join("public").join("js").join("index.js")).unwrap()
  363. );
  364. fs::remove_dir_all(project_dir).unwrap();
  365. }
  366. #[test]
  367. fn test_build_drafts() {
  368. let temp_dir = env::temp_dir();
  369. let uuid = Uuid::new_v4().to_string();
  370. let project_dir = temp_dir.join(&uuid);
  371. fs::create_dir(&project_dir).unwrap();
  372. env::set_current_dir(&project_dir).unwrap();
  373. fs::create_dir(project_dir.join("drafts")).unwrap();
  374. fs::create_dir(project_dir.join("posts")).unwrap();
  375. fs::create_dir(project_dir.join("pages")).unwrap();
  376. fs::create_dir(project_dir.join("public")).unwrap();
  377. fs::create_dir(project_dir.join("public").join("posts")).unwrap();
  378. fs::create_dir(project_dir.join("templates")).unwrap();
  379. fs::create_dir(project_dir.join("css")).unwrap();
  380. fs::create_dir(project_dir.join("js")).unwrap();
  381. fs::write(
  382. project_dir.join("css").join("style.css"),
  383. "body { background: blue; }",
  384. )
  385. .unwrap();
  386. fs::write(
  387. project_dir.join("js").join("index.js"),
  388. "window.onload = function () { alert() }",
  389. )
  390. .unwrap();
  391. fs::write(
  392. project_dir.join("templates").join("layout.html"),
  393. "<html><head><title>{{ page_title }}</title></head><body>{{ contents }}</body></html>",
  394. )
  395. .unwrap();
  396. fs::write(
  397. project_dir.join("templates").join("post.html"),
  398. "<article><h1>{{ title }}</h1><div>{{ body }}</div></article>",
  399. )
  400. .unwrap();
  401. fs::write(
  402. project_dir.join("templates").join("page.html"),
  403. "<article class=\"page\"><h1>{{ title }}</h1><div>{{ body }}</div></article>",
  404. )
  405. .unwrap();
  406. fs::write(
  407. project_dir.join("templates").join("post_listing.html"),
  408. "<ul>{{ post_listing }}</ul>",
  409. )
  410. .unwrap();
  411. fs::write(
  412. project_dir.join("templates").join("post_listing_item.html"),
  413. "<li><a href=\"/{{ slug }}\">{{ title }}</a></li>",
  414. )
  415. .unwrap();
  416. fs::write(
  417. project_dir.join("posts").join("first-post.md"),
  418. "# First post | 2019-01-01\n\nThis is the first post",
  419. )
  420. .unwrap();
  421. fs::write(
  422. project_dir.join("pages").join("first-page.md"),
  423. "# First page\n\nThis is the first page",
  424. )
  425. .unwrap();
  426. fs::write(
  427. project_dir.join("drafts").join("first-draft.md"),
  428. "# First draft | 2019-01-01\n\nThis is the first draft",
  429. )
  430. .unwrap();
  431. fs::write(
  432. project_dir.join("casaubon.toml"),
  433. "site_name = \"Test Site\"",
  434. )
  435. .unwrap();
  436. build(true);
  437. assert_eq!(
  438. "<html><head><title>Test Site</title></head><body><ul><li><a href=\"/first-post\">First post</a></li><li><a href=\"/first-draft\">First draft</a></li></ul></body></html>",
  439. fs::read_to_string(project_dir.join("public").join("index.html")).unwrap().replace("\n", ""),
  440. );
  441. assert_eq!(
  442. "<html><head><title>First post | Test Site</title></head><body><article><h1>First post</h1><div><p>This is the first post</p></div></article></body></html>",
  443. fs::read_to_string(
  444. project_dir
  445. .join("public")
  446. .join("posts")
  447. .join("first-post")
  448. .join("index.html")
  449. ).unwrap()
  450. .replace("\n", ""),
  451. );
  452. assert_eq!(
  453. "<html><head><title>First page | Test Site</title></head><body><article class=\"page\"><h1>First page</h1><div><p>This is the first page</p></div></article></body></html>",
  454. fs::read_to_string(
  455. project_dir
  456. .join("public")
  457. .join("first-page")
  458. .join("index.html")
  459. ).unwrap()
  460. .replace("\n", ""),
  461. );
  462. assert_eq!(
  463. "<html><head><title>First draft | Test Site</title></head><body><article><h1>First draft</h1><div><p>This is the first draft</p></div></article></body></html>",
  464. fs::read_to_string(
  465. project_dir
  466. .join("public")
  467. .join("posts")
  468. .join("first-draft")
  469. .join("index.html")
  470. ).unwrap()
  471. .replace("\n", ""),
  472. );
  473. fs::remove_dir_all(project_dir).unwrap();
  474. }
  475. #[test]
  476. fn test_should_rebuild() {
  477. let cwd = env::current_dir().unwrap();
  478. assert_eq!(
  479. false,
  480. should_rebuild(&cwd, &cwd.join("public").join("index.html"))
  481. );
  482. assert_eq!(
  483. false,
  484. should_rebuild(&cwd, &cwd.join(".git").join("index.html"))
  485. );
  486. assert_eq!(
  487. true,
  488. should_rebuild(&cwd, &cwd.join("posts").join("test.md"))
  489. );
  490. assert_eq!(
  491. true,
  492. should_rebuild(&cwd, &cwd.join("drafts").join("test.md"))
  493. );
  494. assert_eq!(
  495. true,
  496. should_rebuild(&cwd, &cwd.join("css").join("style.css"))
  497. );
  498. assert_eq!(true, should_rebuild(&cwd, &cwd.join("js").join("index.js")));
  499. }
  500. }