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.

337 lines
12KB

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