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.

425 lines
15KB

  1. /// A page, can be a blog post or a basic page
  2. use std::collections::HashMap;
  3. use std::fs::{read_dir};
  4. use std::path::{Path, PathBuf};
  5. use std::result::Result as StdResult;
  6. use tera::{Tera, Context};
  7. use serde::ser::{SerializeStruct, self};
  8. use slug::slugify;
  9. use errors::{Result, ResultExt};
  10. use config::Config;
  11. use front_matter::{FrontMatter, SortBy, split_content};
  12. use markdown::markdown_to_html;
  13. use utils::{read_file, find_content_components};
  14. /// Looks into the current folder for the path and see if there's anything that is not a .md
  15. /// file. Those will be copied next to the rendered .html file
  16. fn find_related_assets(path: &Path) -> Vec<PathBuf> {
  17. let mut assets = vec![];
  18. for entry in read_dir(path).unwrap().filter_map(|e| e.ok()) {
  19. let entry_path = entry.path();
  20. if entry_path.is_file() {
  21. match entry_path.extension() {
  22. Some(e) => match e.to_str() {
  23. Some("md") => continue,
  24. _ => assets.push(entry_path.to_path_buf()),
  25. },
  26. None => continue,
  27. }
  28. }
  29. }
  30. assets
  31. }
  32. #[derive(Clone, Debug, PartialEq)]
  33. pub struct Page {
  34. /// The .md path
  35. pub file_path: PathBuf,
  36. /// The .md path, starting from the content directory, with / slashes
  37. pub relative_path: String,
  38. /// The parent directory of the file. Is actually the grand parent directory
  39. /// if it's an asset folder
  40. pub parent_path: PathBuf,
  41. /// The name of the .md file
  42. pub file_name: String,
  43. /// The directories above our .md file
  44. /// for example a file at content/kb/solutions/blabla.md will have 2 components:
  45. /// `kb` and `solutions`
  46. pub components: Vec<String>,
  47. /// The actual content of the page, in markdown
  48. pub raw_content: String,
  49. /// All the non-md files we found next to the .md file
  50. pub assets: Vec<PathBuf>,
  51. /// The HTML rendered of the page
  52. pub content: String,
  53. /// The front matter meta-data
  54. pub meta: FrontMatter,
  55. /// The slug of that page.
  56. /// First tries to find the slug in the meta and defaults to filename otherwise
  57. pub slug: String,
  58. /// The URL path of the page
  59. pub path: String,
  60. /// The full URL for that page
  61. pub permalink: String,
  62. /// The summary for the article, defaults to None
  63. /// When <!-- more --> is found in the text, will take the content up to that part
  64. /// as summary
  65. pub summary: Option<String>,
  66. /// The previous page, by whatever sorting is used for the index/section
  67. pub previous: Option<Box<Page>>,
  68. /// The next page, by whatever sorting is used for the index/section
  69. pub next: Option<Box<Page>>,
  70. }
  71. impl Page {
  72. pub fn new(meta: FrontMatter) -> Page {
  73. Page {
  74. file_path: PathBuf::new(),
  75. relative_path: String::new(),
  76. parent_path: PathBuf::new(),
  77. file_name: "".to_string(),
  78. components: vec![],
  79. raw_content: "".to_string(),
  80. assets: vec![],
  81. content: "".to_string(),
  82. slug: "".to_string(),
  83. path: "".to_string(),
  84. permalink: "".to_string(),
  85. summary: None,
  86. meta: meta,
  87. previous: None,
  88. next: None,
  89. }
  90. }
  91. pub fn has_date(&self) -> bool {
  92. self.meta.date.is_some()
  93. }
  94. /// Get word count and estimated reading time
  95. pub fn get_reading_analytics(&self) -> (usize, usize) {
  96. // Only works for latin language but good enough for a start
  97. let word_count: usize = self.raw_content.split_whitespace().count();
  98. // https://help.medium.com/hc/en-us/articles/214991667-Read-time
  99. // 275 seems a bit too high though
  100. (word_count, (word_count / 200))
  101. }
  102. /// Parse a page given the content of the .md file
  103. /// Files without front matter or with invalid front matter are considered
  104. /// erroneous
  105. pub fn parse(file_path: &Path, content: &str, config: &Config) -> Result<Page> {
  106. // 1. separate front matter from content
  107. let (meta, content) = split_content(file_path, content)?;
  108. let mut page = Page::new(meta);
  109. page.file_path = file_path.to_path_buf();
  110. page.parent_path = page.file_path.parent().unwrap().to_path_buf();
  111. page.raw_content = content;
  112. let path = Path::new(file_path);
  113. page.file_name = path.file_stem().unwrap().to_string_lossy().to_string();
  114. page.slug = {
  115. if let Some(ref slug) = page.meta.slug {
  116. slug.trim().to_string()
  117. } else {
  118. slugify(page.file_name.clone())
  119. }
  120. };
  121. page.components = find_content_components(&page.file_path);
  122. page.relative_path = format!("{}/{}.md", page.components.join("/"), page.file_name);
  123. // 4. Find sections
  124. // Pages with custom urls exists outside of sections
  125. if let Some(ref u) = page.meta.url {
  126. page.path = u.trim().to_string();
  127. } else if !page.components.is_empty() {
  128. // If we have a folder with an asset, don't consider it as a component
  129. if page.file_name == "index" {
  130. page.components.pop();
  131. // also set parent_path to grandparent instead
  132. page.parent_path = page.parent_path.parent().unwrap().to_path_buf();
  133. }
  134. // Don't add a trailing slash to sections
  135. page.path = format!("{}/{}", page.components.join("/"), page.slug);
  136. } else {
  137. page.path = page.slug.clone();
  138. }
  139. page.permalink = config.make_permalink(&page.path);
  140. Ok(page)
  141. }
  142. /// Read and parse a .md file into a Page struct
  143. pub fn from_file<P: AsRef<Path>>(path: P, config: &Config) -> Result<Page> {
  144. let path = path.as_ref();
  145. let content = read_file(path)?;
  146. let mut page = Page::parse(path, &content, config)?;
  147. page.assets = find_related_assets(path.parent().unwrap());
  148. if !page.assets.is_empty() && page.file_name != "index" {
  149. bail!("Page `{}` has assets ({:?}) but is not named index.md", path.display(), page.assets);
  150. }
  151. Ok(page)
  152. }
  153. /// We need access to all pages url to render links relative to content
  154. /// so that can't happen at the same time as parsing
  155. pub fn render_markdown(&mut self, permalinks: &HashMap<String, String>, tera: &Tera, config: &Config) -> Result<()> {
  156. self.content = markdown_to_html(&self.raw_content, permalinks, tera, config)?;
  157. if self.raw_content.contains("<!-- more -->") {
  158. self.summary = Some({
  159. let summary = self.raw_content.splitn(2, "<!-- more -->").collect::<Vec<&str>>()[0];
  160. markdown_to_html(summary, permalinks, tera, config)?
  161. })
  162. }
  163. Ok(())
  164. }
  165. /// Renders the page using the default layout, unless specified in front-matter
  166. pub fn render_html(&self, tera: &Tera, config: &Config) -> Result<String> {
  167. let tpl_name = match self.meta.template {
  168. Some(ref l) => l.to_string(),
  169. None => "page.html".to_string()
  170. };
  171. let mut context = Context::new();
  172. context.add("config", config);
  173. context.add("page", self);
  174. context.add("current_url", &self.permalink);
  175. context.add("current_path", &self.path);
  176. tera.render(&tpl_name, &context)
  177. .chain_err(|| format!("Failed to render page '{}'", self.file_path.display()))
  178. }
  179. }
  180. impl ser::Serialize for Page {
  181. fn serialize<S>(&self, serializer: S) -> StdResult<S::Ok, S::Error> where S: ser::Serializer {
  182. let mut state = serializer.serialize_struct("page", 16)?;
  183. state.serialize_field("content", &self.content)?;
  184. state.serialize_field("title", &self.meta.title)?;
  185. state.serialize_field("description", &self.meta.description)?;
  186. state.serialize_field("date", &self.meta.date)?;
  187. state.serialize_field("slug", &self.slug)?;
  188. state.serialize_field("path", &format!("/{}", self.path))?;
  189. state.serialize_field("permalink", &self.permalink)?;
  190. state.serialize_field("summary", &self.summary)?;
  191. state.serialize_field("tags", &self.meta.tags)?;
  192. state.serialize_field("draft", &self.meta.draft)?;
  193. state.serialize_field("category", &self.meta.category)?;
  194. state.serialize_field("extra", &self.meta.extra)?;
  195. let (word_count, reading_time) = self.get_reading_analytics();
  196. state.serialize_field("word_count", &word_count)?;
  197. state.serialize_field("reading_time", &reading_time)?;
  198. state.serialize_field("previous", &self.previous)?;
  199. state.serialize_field("next", &self.next)?;
  200. state.end()
  201. }
  202. }
  203. /// Sort pages using the method for the given section
  204. ///
  205. /// Any pages that doesn't have a date when the sorting method is date or order
  206. /// when the sorting method is order will be ignored.
  207. pub fn sort_pages(pages: Vec<Page>, sort_by: SortBy) -> (Vec<Page>, Vec<Page>) {
  208. match sort_by {
  209. SortBy::Date => {
  210. let mut can_be_sorted = vec![];
  211. let mut cannot_be_sorted = vec![];
  212. for page in pages {
  213. if page.meta.date.is_some() {
  214. can_be_sorted.push(page);
  215. } else {
  216. cannot_be_sorted.push(page);
  217. }
  218. }
  219. can_be_sorted.sort_by(|a, b| b.meta.date().unwrap().cmp(&a.meta.date().unwrap()));
  220. (can_be_sorted, cannot_be_sorted)
  221. },
  222. SortBy::Order => {
  223. let mut can_be_sorted = vec![];
  224. let mut cannot_be_sorted = vec![];
  225. for page in pages {
  226. if page.meta.order.is_some() {
  227. can_be_sorted.push(page);
  228. } else {
  229. cannot_be_sorted.push(page);
  230. }
  231. }
  232. can_be_sorted.sort_by(|a, b| b.meta.order().cmp(&a.meta.order()));
  233. (can_be_sorted, cannot_be_sorted)
  234. },
  235. SortBy::None => {
  236. let mut p = vec![];
  237. for page in pages {
  238. p.push(page);
  239. }
  240. (p, vec![])
  241. },
  242. }
  243. }
  244. /// Horribly inefficient way to set previous and next on each pages
  245. /// So many clones
  246. pub fn populate_previous_and_next_pages(input: &[Page]) -> Vec<Page> {
  247. let pages = input.to_vec();
  248. let mut res = Vec::new();
  249. // the input is already sorted
  250. // We might put prev/next randomly if a page is missing date/order, probably fine
  251. for (i, page) in input.iter().enumerate() {
  252. let mut new_page = page.clone();
  253. if i > 0 {
  254. let next = &pages[i - 1];
  255. new_page.next = Some(Box::new(next.clone()));
  256. }
  257. if i < input.len() - 1 {
  258. let previous = &pages[i + 1];
  259. new_page.previous = Some(Box::new(previous.clone()));
  260. }
  261. res.push(new_page);
  262. }
  263. res
  264. }
  265. #[cfg(test)]
  266. mod tests {
  267. use tempdir::TempDir;
  268. use std::fs::File;
  269. use front_matter::{FrontMatter, SortBy};
  270. use super::{Page, find_related_assets, sort_pages, populate_previous_and_next_pages};
  271. fn create_page_with_date(date: &str) -> Page {
  272. let mut front_matter = FrontMatter::default();
  273. front_matter.date = Some(date.to_string());
  274. Page::new(front_matter)
  275. }
  276. fn create_page_with_order(order: usize) -> Page {
  277. let mut front_matter = FrontMatter::default();
  278. front_matter.order = Some(order);
  279. Page::new(front_matter)
  280. }
  281. #[test]
  282. fn test_find_related_assets() {
  283. let tmp_dir = TempDir::new("example").expect("create temp dir");
  284. File::create(tmp_dir.path().join("index.md")).unwrap();
  285. File::create(tmp_dir.path().join("example.js")).unwrap();
  286. File::create(tmp_dir.path().join("graph.jpg")).unwrap();
  287. File::create(tmp_dir.path().join("fail.png")).unwrap();
  288. let assets = find_related_assets(tmp_dir.path());
  289. assert_eq!(assets.len(), 3);
  290. assert_eq!(assets.iter().filter(|p| p.extension().unwrap() != "md").count(), 3);
  291. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "example.js").count(), 1);
  292. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "graph.jpg").count(), 1);
  293. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "fail.png").count(), 1);
  294. }
  295. #[test]
  296. fn test_can_sort_dates() {
  297. let input = vec![
  298. create_page_with_date("2018-01-01"),
  299. create_page_with_date("2017-01-01"),
  300. create_page_with_date("2019-01-01"),
  301. ];
  302. let (pages, _) = sort_pages(input, SortBy::Date);
  303. // Should be sorted by date
  304. assert_eq!(pages[0].clone().meta.date.unwrap(), "2019-01-01");
  305. assert_eq!(pages[1].clone().meta.date.unwrap(), "2018-01-01");
  306. assert_eq!(pages[2].clone().meta.date.unwrap(), "2017-01-01");
  307. }
  308. #[test]
  309. fn test_can_sort_order() {
  310. let input = vec![
  311. create_page_with_order(2),
  312. create_page_with_order(3),
  313. create_page_with_order(1),
  314. ];
  315. let (pages, _) = sort_pages(input, SortBy::Order);
  316. // Should be sorted by date
  317. assert_eq!(pages[0].clone().meta.order.unwrap(), 3);
  318. assert_eq!(pages[1].clone().meta.order.unwrap(), 2);
  319. assert_eq!(pages[2].clone().meta.order.unwrap(), 1);
  320. }
  321. #[test]
  322. fn test_can_sort_none() {
  323. let input = vec![
  324. create_page_with_order(2),
  325. create_page_with_order(3),
  326. create_page_with_order(1),
  327. ];
  328. let (pages, _) = sort_pages(input, SortBy::None);
  329. // Should be sorted by date
  330. assert_eq!(pages[0].clone().meta.order.unwrap(), 2);
  331. assert_eq!(pages[1].clone().meta.order.unwrap(), 3);
  332. assert_eq!(pages[2].clone().meta.order.unwrap(), 1);
  333. }
  334. #[test]
  335. fn test_ignore_page_with_missing_field() {
  336. let input = vec![
  337. create_page_with_order(2),
  338. create_page_with_order(3),
  339. create_page_with_date("2019-01-01"),
  340. ];
  341. let (pages, unsorted) = sort_pages(input, SortBy::Order);
  342. assert_eq!(pages.len(), 2);
  343. assert_eq!(unsorted.len(), 1);
  344. }
  345. #[test]
  346. fn test_populate_previous_and_next_pages() {
  347. let input = vec![
  348. create_page_with_order(3),
  349. create_page_with_order(2),
  350. create_page_with_order(1),
  351. ];
  352. let pages = populate_previous_and_next_pages(input.as_slice());
  353. assert!(pages[0].clone().next.is_none());
  354. assert!(pages[0].clone().previous.is_some());
  355. assert_eq!(pages[0].clone().previous.unwrap().meta.order.unwrap(), 2);
  356. assert!(pages[1].clone().next.is_some());
  357. assert!(pages[1].clone().previous.is_some());
  358. assert_eq!(pages[1].clone().next.unwrap().meta.order.unwrap(), 3);
  359. assert_eq!(pages[1].clone().previous.unwrap().meta.order.unwrap(), 1);
  360. assert!(pages[2].clone().next.is_some());
  361. assert!(pages[2].clone().previous.is_none());
  362. assert_eq!(pages[2].clone().next.unwrap().meta.order.unwrap(), 2);
  363. }
  364. }