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.

444 lines
16KB

  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::{PageFrontMatter, SortBy, split_page_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 front matter meta-data
  35. pub meta: PageFrontMatter,
  36. /// The .md path
  37. pub file_path: PathBuf,
  38. /// The .md path, starting from the content directory, with / slashes
  39. pub relative_path: String,
  40. /// The parent directory of the file. Is actually the grand parent directory
  41. /// if it's an asset folder
  42. pub parent_path: PathBuf,
  43. /// The name of the .md file
  44. pub file_name: String,
  45. /// The directories above our .md file
  46. /// for example a file at content/kb/solutions/blabla.md will have 2 components:
  47. /// `kb` and `solutions`
  48. pub components: Vec<String>,
  49. /// The actual content of the page, in markdown
  50. pub raw_content: String,
  51. /// All the non-md files we found next to the .md file
  52. pub assets: Vec<PathBuf>,
  53. /// The HTML rendered of the page
  54. pub content: String,
  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: PageFrontMatter) -> Page {
  73. Page {
  74. meta: meta,
  75. file_path: PathBuf::new(),
  76. relative_path: String::new(),
  77. parent_path: PathBuf::new(),
  78. file_name: "".to_string(),
  79. components: vec![],
  80. raw_content: "".to_string(),
  81. assets: vec![],
  82. content: "".to_string(),
  83. slug: "".to_string(),
  84. path: "".to_string(),
  85. permalink: "".to_string(),
  86. summary: None,
  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_page_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. let mut path_set = false;
  126. if let Some(ref u) = page.meta.url {
  127. page.path = u.trim().to_string();
  128. path_set = true;
  129. }
  130. if !page.components.is_empty() {
  131. // If we have a folder with an asset, don't consider it as a component
  132. if page.file_name == "index" {
  133. page.components.pop();
  134. // also set parent_path to grandparent instead
  135. page.parent_path = page.parent_path.parent().unwrap().to_path_buf();
  136. }
  137. if !path_set {
  138. // Don't add a trailing slash to sections
  139. page.path = format!("{}/{}", page.components.join("/"), page.slug);
  140. }
  141. } else if !path_set {
  142. page.path = page.slug.clone();
  143. }
  144. page.permalink = config.make_permalink(&page.path);
  145. Ok(page)
  146. }
  147. /// Read and parse a .md file into a Page struct
  148. pub fn from_file<P: AsRef<Path>>(path: P, config: &Config) -> Result<Page> {
  149. let path = path.as_ref();
  150. let content = read_file(path)?;
  151. let mut page = Page::parse(path, &content, config)?;
  152. page.assets = find_related_assets(path.parent().unwrap());
  153. if !page.assets.is_empty() && page.file_name != "index" {
  154. bail!("Page `{}` has assets ({:?}) but is not named index.md", path.display(), page.assets);
  155. }
  156. Ok(page)
  157. }
  158. /// We need access to all pages url to render links relative to content
  159. /// so that can't happen at the same time as parsing
  160. pub fn render_markdown(&mut self, permalinks: &HashMap<String, String>, tera: &Tera, config: &Config) -> Result<()> {
  161. self.content = markdown_to_html(&self.raw_content, permalinks, tera, config)?;
  162. if self.raw_content.contains("<!-- more -->") {
  163. self.summary = Some({
  164. let summary = self.raw_content.splitn(2, "<!-- more -->").collect::<Vec<&str>>()[0];
  165. markdown_to_html(summary, permalinks, tera, config)?
  166. })
  167. }
  168. Ok(())
  169. }
  170. /// Renders the page using the default layout, unless specified in front-matter
  171. pub fn render_html(&self, tera: &Tera, config: &Config) -> Result<String> {
  172. let tpl_name = match self.meta.template {
  173. Some(ref l) => l.to_string(),
  174. None => "page.html".to_string()
  175. };
  176. let mut context = Context::new();
  177. context.add("config", config);
  178. context.add("page", self);
  179. context.add("current_url", &self.permalink);
  180. context.add("current_path", &self.path);
  181. tera.render(&tpl_name, &context)
  182. .chain_err(|| format!("Failed to render page '{}'", self.file_path.display()))
  183. }
  184. }
  185. impl Default for Page {
  186. fn default() -> Page {
  187. Page {
  188. meta: PageFrontMatter::default(),
  189. file_path: PathBuf::new(),
  190. relative_path: String::new(),
  191. parent_path: PathBuf::new(),
  192. file_name: "".to_string(),
  193. components: vec![],
  194. raw_content: "".to_string(),
  195. assets: vec![],
  196. content: "".to_string(),
  197. slug: "".to_string(),
  198. path: "".to_string(),
  199. permalink: "".to_string(),
  200. summary: None,
  201. previous: None,
  202. next: None,
  203. }
  204. }
  205. }
  206. impl ser::Serialize for Page {
  207. fn serialize<S>(&self, serializer: S) -> StdResult<S::Ok, S::Error> where S: ser::Serializer {
  208. let mut state = serializer.serialize_struct("page", 16)?;
  209. state.serialize_field("content", &self.content)?;
  210. state.serialize_field("title", &self.meta.title)?;
  211. state.serialize_field("description", &self.meta.description)?;
  212. state.serialize_field("date", &self.meta.date)?;
  213. state.serialize_field("slug", &self.slug)?;
  214. state.serialize_field("path", &format!("/{}", self.path))?;
  215. state.serialize_field("permalink", &self.permalink)?;
  216. state.serialize_field("summary", &self.summary)?;
  217. state.serialize_field("tags", &self.meta.tags)?;
  218. state.serialize_field("draft", &self.meta.draft)?;
  219. state.serialize_field("category", &self.meta.category)?;
  220. state.serialize_field("extra", &self.meta.extra)?;
  221. let (word_count, reading_time) = self.get_reading_analytics();
  222. state.serialize_field("word_count", &word_count)?;
  223. state.serialize_field("reading_time", &reading_time)?;
  224. state.serialize_field("previous", &self.previous)?;
  225. state.serialize_field("next", &self.next)?;
  226. state.end()
  227. }
  228. }
  229. /// Sort pages using the method for the given section
  230. ///
  231. /// Any pages that doesn't have a date when the sorting method is date or order
  232. /// when the sorting method is order will be ignored.
  233. pub fn sort_pages(pages: Vec<Page>, sort_by: SortBy) -> (Vec<Page>, Vec<Page>) {
  234. match sort_by {
  235. SortBy::Date => {
  236. let mut can_be_sorted = vec![];
  237. let mut cannot_be_sorted = vec![];
  238. for page in pages {
  239. if page.meta.date.is_some() {
  240. can_be_sorted.push(page);
  241. } else {
  242. cannot_be_sorted.push(page);
  243. }
  244. }
  245. can_be_sorted.sort_by(|a, b| b.meta.date().unwrap().cmp(&a.meta.date().unwrap()));
  246. (can_be_sorted, cannot_be_sorted)
  247. },
  248. SortBy::Order => {
  249. let mut can_be_sorted = vec![];
  250. let mut cannot_be_sorted = vec![];
  251. for page in pages {
  252. if page.meta.order.is_some() {
  253. can_be_sorted.push(page);
  254. } else {
  255. cannot_be_sorted.push(page);
  256. }
  257. }
  258. can_be_sorted.sort_by(|a, b| b.meta.order().cmp(&a.meta.order()));
  259. (can_be_sorted, cannot_be_sorted)
  260. },
  261. SortBy::None => (pages, vec![])
  262. }
  263. }
  264. /// Horribly inefficient way to set previous and next on each pages
  265. /// So many clones
  266. pub fn populate_previous_and_next_pages(input: &[Page]) -> Vec<Page> {
  267. let pages = input.to_vec();
  268. let mut res = Vec::new();
  269. // the input is already sorted
  270. // We might put prev/next randomly if a page is missing date/order, probably fine
  271. for (i, page) in input.iter().enumerate() {
  272. let mut new_page = page.clone();
  273. if i > 0 {
  274. let next = &pages[i - 1];
  275. new_page.next = Some(Box::new(next.clone()));
  276. }
  277. if i < input.len() - 1 {
  278. let previous = &pages[i + 1];
  279. new_page.previous = Some(Box::new(previous.clone()));
  280. }
  281. res.push(new_page);
  282. }
  283. res
  284. }
  285. #[cfg(test)]
  286. mod tests {
  287. use tempdir::TempDir;
  288. use std::fs::File;
  289. use front_matter::{PageFrontMatter, SortBy};
  290. use super::{Page, find_related_assets, sort_pages, populate_previous_and_next_pages};
  291. fn create_page_with_date(date: &str) -> Page {
  292. let mut front_matter = PageFrontMatter::default();
  293. front_matter.date = Some(date.to_string());
  294. Page::new(front_matter)
  295. }
  296. fn create_page_with_order(order: usize) -> Page {
  297. let mut front_matter = PageFrontMatter::default();
  298. front_matter.order = Some(order);
  299. Page::new(front_matter)
  300. }
  301. #[test]
  302. fn test_find_related_assets() {
  303. let tmp_dir = TempDir::new("example").expect("create temp dir");
  304. File::create(tmp_dir.path().join("index.md")).unwrap();
  305. File::create(tmp_dir.path().join("example.js")).unwrap();
  306. File::create(tmp_dir.path().join("graph.jpg")).unwrap();
  307. File::create(tmp_dir.path().join("fail.png")).unwrap();
  308. let assets = find_related_assets(tmp_dir.path());
  309. assert_eq!(assets.len(), 3);
  310. assert_eq!(assets.iter().filter(|p| p.extension().unwrap() != "md").count(), 3);
  311. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "example.js").count(), 1);
  312. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "graph.jpg").count(), 1);
  313. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "fail.png").count(), 1);
  314. }
  315. #[test]
  316. fn test_can_sort_dates() {
  317. let input = vec![
  318. create_page_with_date("2018-01-01"),
  319. create_page_with_date("2017-01-01"),
  320. create_page_with_date("2019-01-01"),
  321. ];
  322. let (pages, _) = sort_pages(input, SortBy::Date);
  323. // Should be sorted by date
  324. assert_eq!(pages[0].clone().meta.date.unwrap(), "2019-01-01");
  325. assert_eq!(pages[1].clone().meta.date.unwrap(), "2018-01-01");
  326. assert_eq!(pages[2].clone().meta.date.unwrap(), "2017-01-01");
  327. }
  328. #[test]
  329. fn test_can_sort_order() {
  330. let input = vec![
  331. create_page_with_order(2),
  332. create_page_with_order(3),
  333. create_page_with_order(1),
  334. ];
  335. let (pages, _) = sort_pages(input, SortBy::Order);
  336. // Should be sorted by date
  337. assert_eq!(pages[0].clone().meta.order.unwrap(), 3);
  338. assert_eq!(pages[1].clone().meta.order.unwrap(), 2);
  339. assert_eq!(pages[2].clone().meta.order.unwrap(), 1);
  340. }
  341. #[test]
  342. fn test_can_sort_none() {
  343. let input = vec![
  344. create_page_with_order(2),
  345. create_page_with_order(3),
  346. create_page_with_order(1),
  347. ];
  348. let (pages, _) = sort_pages(input, SortBy::None);
  349. // Should be sorted by date
  350. assert_eq!(pages[0].clone().meta.order.unwrap(), 2);
  351. assert_eq!(pages[1].clone().meta.order.unwrap(), 3);
  352. assert_eq!(pages[2].clone().meta.order.unwrap(), 1);
  353. }
  354. #[test]
  355. fn test_ignore_page_with_missing_field() {
  356. let input = vec![
  357. create_page_with_order(2),
  358. create_page_with_order(3),
  359. create_page_with_date("2019-01-01"),
  360. ];
  361. let (pages, unsorted) = sort_pages(input, SortBy::Order);
  362. assert_eq!(pages.len(), 2);
  363. assert_eq!(unsorted.len(), 1);
  364. }
  365. #[test]
  366. fn test_populate_previous_and_next_pages() {
  367. let input = vec![
  368. create_page_with_order(3),
  369. create_page_with_order(2),
  370. create_page_with_order(1),
  371. ];
  372. let pages = populate_previous_and_next_pages(input.as_slice());
  373. assert!(pages[0].clone().next.is_none());
  374. assert!(pages[0].clone().previous.is_some());
  375. assert_eq!(pages[0].clone().previous.unwrap().meta.order.unwrap(), 2);
  376. assert!(pages[1].clone().next.is_some());
  377. assert!(pages[1].clone().previous.is_some());
  378. assert_eq!(pages[1].clone().next.unwrap().meta.order.unwrap(), 3);
  379. assert_eq!(pages[1].clone().previous.unwrap().meta.order.unwrap(), 1);
  380. assert!(pages[2].clone().next.is_some());
  381. assert!(pages[2].clone().previous.is_none());
  382. assert_eq!(pages[2].clone().next.unwrap().meta.order.unwrap(), 2);
  383. }
  384. }