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.

332 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 relative URL of the page
  60. pub url: String,
  61. /// The full URL for that page
  62. pub permalink: String,
  63. /// The summary for the article, defaults to empty string
  64. /// When <!-- more --> is found in the text, will take the content up to that part
  65. /// as summary
  66. pub summary: 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. url: "".to_string(),
  89. permalink: "".to_string(),
  90. summary: "".to_string(),
  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.url = u.trim().to_string();
  134. } else {
  135. if !page.components.is_empty() {
  136. // If we have a folder with an asset, don't consider it as a component
  137. if page.file_name == "index" {
  138. page.components.pop();
  139. // also set parent_path to grandparent instead
  140. page.parent_path = page.parent_path.parent().unwrap().to_path_buf();
  141. }
  142. // Don't add a trailing slash to sections
  143. page.url = format!("{}/{}", page.components.join("/"), page.slug);
  144. } else {
  145. page.url = page.slug.clone();
  146. }
  147. }
  148. page.permalink = config.make_permalink(&page.url);
  149. Ok(page)
  150. }
  151. /// Read and parse a .md file into a Page struct
  152. pub fn from_file<P: AsRef<Path>>(path: P, config: &Config) -> Result<Page> {
  153. let path = path.as_ref();
  154. let content = read_file(path)?;
  155. let mut page = Page::parse(path, &content, config)?;
  156. page.assets = find_related_assets(path.parent().unwrap());
  157. if !page.assets.is_empty() && page.file_name != "index" {
  158. bail!("Page `{}` has assets but is not named index.md", path.display());
  159. }
  160. Ok(page)
  161. }
  162. /// We need access to all pages url to render links relative to content
  163. /// so that can't happen at the same time as parsing
  164. pub fn render_markdown(&mut self, permalinks: &HashMap<String, String>, tera: &Tera, config: &Config) -> Result<()> {
  165. self.content = markdown_to_html(&self.raw_content, permalinks, tera, config)?;
  166. if self.raw_content.contains("<!-- more -->") {
  167. self.summary = {
  168. let summary = self.raw_content.splitn(2, "<!-- more -->").collect::<Vec<&str>>()[0];
  169. markdown_to_html(summary, permalinks, tera, config)?
  170. }
  171. }
  172. Ok(())
  173. }
  174. /// Renders the page using the default layout, unless specified in front-matter
  175. pub fn render_html(&self, tera: &Tera, config: &Config) -> Result<String> {
  176. let tpl_name = match self.meta.template {
  177. Some(ref l) => l.to_string(),
  178. None => "page.html".to_string()
  179. };
  180. // TODO: create a helper to create context to ensure all contexts
  181. // have the same names
  182. let mut context = Context::new();
  183. context.add("config", config);
  184. context.add("page", self);
  185. tera.render(&tpl_name, &context)
  186. .chain_err(|| format!("Failed to render page '{}'", self.file_path.display()))
  187. }
  188. }
  189. impl ser::Serialize for Page {
  190. fn serialize<S>(&self, serializer: S) -> StdResult<S::Ok, S::Error> where S: ser::Serializer {
  191. let mut state = serializer.serialize_struct("page", 13)?;
  192. state.serialize_field("content", &self.content)?;
  193. state.serialize_field("title", &self.meta.title)?;
  194. state.serialize_field("description", &self.meta.description)?;
  195. state.serialize_field("date", &self.meta.date)?;
  196. state.serialize_field("slug", &self.slug)?;
  197. state.serialize_field("url", &format!("/{}", self.url))?;
  198. state.serialize_field("permalink", &self.permalink)?;
  199. state.serialize_field("tags", &self.meta.tags)?;
  200. state.serialize_field("draft", &self.meta.draft)?;
  201. state.serialize_field("category", &self.meta.category)?;
  202. state.serialize_field("extra", &self.meta.extra)?;
  203. let (word_count, reading_time) = self.get_reading_analytics();
  204. state.serialize_field("word_count", &word_count)?;
  205. state.serialize_field("reading_time", &reading_time)?;
  206. state.end()
  207. }
  208. }
  209. impl PartialOrd for Page {
  210. fn partial_cmp(&self, other: &Page) -> Option<Ordering> {
  211. if self.meta.date.is_none() {
  212. return Some(Ordering::Less);
  213. }
  214. if other.meta.date.is_none() {
  215. return Some(Ordering::Greater);
  216. }
  217. let this_date = self.meta.parse_date().unwrap();
  218. let other_date = other.meta.parse_date().unwrap();
  219. if this_date > other_date {
  220. return Some(Ordering::Less);
  221. }
  222. if this_date < other_date {
  223. return Some(Ordering::Greater);
  224. }
  225. Some(Ordering::Equal)
  226. }
  227. }
  228. /// Horribly inefficient way to set previous and next on each pages
  229. /// So many clones
  230. pub fn populate_previous_and_next_pages(input: &[Page], in_section: bool) -> Vec<Page> {
  231. let pages = input.to_vec();
  232. let mut res = Vec::new();
  233. // the input is sorted from most recent to least recent already
  234. for (i, page) in input.iter().enumerate() {
  235. let mut new_page = page.clone();
  236. if new_page.has_date() {
  237. if i > 0 {
  238. let next = &pages[i - 1];
  239. if next.has_date() {
  240. if in_section {
  241. new_page.next_in_section = Some(Box::new(next.clone()));
  242. } else {
  243. new_page.next = Some(Box::new(next.clone()));
  244. }
  245. }
  246. }
  247. if i < input.len() - 1 {
  248. let previous = &pages[i + 1];
  249. if previous.has_date() {
  250. if in_section {
  251. new_page.previous_in_section = Some(Box::new(previous.clone()));
  252. } else {
  253. new_page.previous = Some(Box::new(previous.clone()));
  254. }
  255. }
  256. }
  257. }
  258. res.push(new_page);
  259. }
  260. res
  261. }
  262. #[cfg(test)]
  263. mod tests {
  264. use tempdir::TempDir;
  265. use std::fs::File;
  266. use super::{find_related_assets};
  267. #[test]
  268. fn test_find_related_assets() {
  269. let tmp_dir = TempDir::new("example").expect("create temp dir");
  270. File::create(tmp_dir.path().join("index.md")).unwrap();
  271. File::create(tmp_dir.path().join("example.js")).unwrap();
  272. File::create(tmp_dir.path().join("graph.jpg")).unwrap();
  273. File::create(tmp_dir.path().join("fail.png")).unwrap();
  274. let assets = find_related_assets(tmp_dir.path());
  275. assert_eq!(assets.len(), 3);
  276. assert_eq!(assets.iter().filter(|p| p.extension().unwrap() != "md").count(), 3);
  277. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "example.js").count(), 1);
  278. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "graph.jpg").count(), 1);
  279. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "fail.png").count(), 1);
  280. }
  281. }