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.

322 lines
11KB

  1. /// A page, can be a blog post or a basic page
  2. use std::cmp::Ordering;
  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, 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 parent directory of the file. Is actually the grand parent directory
  37. /// if it's an asset folder
  38. pub parent_path: PathBuf,
  39. /// The name of the .md file
  40. pub file_name: String,
  41. /// The directories above our .md file
  42. /// for example a file at content/kb/solutions/blabla.md will have 2 components:
  43. /// `kb` and `solutions`
  44. pub components: Vec<String>,
  45. /// The actual content of the page, in markdown
  46. pub raw_content: String,
  47. /// All the non-md files we found next to the .md file
  48. pub assets: Vec<PathBuf>,
  49. /// The HTML rendered of the page
  50. pub content: String,
  51. /// The front matter meta-data
  52. pub meta: FrontMatter,
  53. /// The slug of that page.
  54. /// First tries to find the slug in the meta and defaults to filename otherwise
  55. pub slug: String,
  56. /// The relative URL of the page
  57. pub url: String,
  58. /// The full URL for that page
  59. pub permalink: String,
  60. /// The summary for the article, defaults to empty string
  61. /// When <!-- more --> is found in the text, will take the content up to that part
  62. /// as summary
  63. pub summary: String,
  64. /// The previous page, by date globally
  65. pub previous: Option<Box<Page>>,
  66. /// The previous page, by date only for the section the page is in
  67. pub previous_in_section: Option<Box<Page>>,
  68. /// The next page, by date
  69. pub next: Option<Box<Page>>,
  70. /// The next page, by date only for the section the page is in
  71. pub next_in_section: Option<Box<Page>>,
  72. }
  73. impl Page {
  74. pub fn new(meta: FrontMatter) -> Page {
  75. Page {
  76. file_path: PathBuf::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. url: "".to_string(),
  85. permalink: "".to_string(),
  86. summary: "".to_string(),
  87. meta: meta,
  88. previous: None,
  89. previous_in_section: None,
  90. next: None,
  91. next_in_section: None,
  92. }
  93. }
  94. pub fn has_date(&self) -> bool {
  95. self.meta.date.is_some()
  96. }
  97. /// Get word count and estimated reading time
  98. pub fn get_reading_analytics(&self) -> (usize, usize) {
  99. // Only works for latin language but good enough for a start
  100. let word_count: usize = self.raw_content.split_whitespace().count();
  101. // https://help.medium.com/hc/en-us/articles/214991667-Read-time
  102. // 275 seems a bit too high though
  103. (word_count, (word_count / 200))
  104. }
  105. /// Parse a page given the content of the .md file
  106. /// Files without front matter or with invalid front matter are considered
  107. /// erroneous
  108. pub fn parse(file_path: &Path, content: &str, config: &Config) -> Result<Page> {
  109. // 1. separate front matter from content
  110. let (meta, content) = split_content(file_path, content)?;
  111. let mut page = Page::new(meta);
  112. page.file_path = file_path.to_path_buf();
  113. page.parent_path = page.file_path.parent().unwrap().to_path_buf();
  114. page.raw_content = content;
  115. let highlight_theme = config.highlight_theme.clone().unwrap();
  116. page.content = markdown_to_html(&page.raw_content, config.highlight_code.unwrap(), &highlight_theme);
  117. if page.raw_content.contains("<!-- more -->") {
  118. page.summary = {
  119. let summary = page.raw_content.splitn(2, "<!-- more -->").collect::<Vec<&str>>()[0];
  120. markdown_to_html(summary, config.highlight_code.unwrap(), &highlight_theme)
  121. }
  122. }
  123. let path = Path::new(file_path);
  124. page.file_name = path.file_stem().unwrap().to_string_lossy().to_string();
  125. page.slug = {
  126. if let Some(ref slug) = page.meta.slug {
  127. slug.trim().to_string()
  128. } else {
  129. slugify(page.file_name.clone())
  130. }
  131. };
  132. // 4. Find sections
  133. // Pages with custom urls exists outside of sections
  134. if let Some(ref u) = page.meta.url {
  135. page.url = u.trim().to_string();
  136. } else {
  137. page.components = find_content_components(&page.file_path);
  138. if !page.components.is_empty() {
  139. // If we have a folder with an asset, don't consider it as a component
  140. if page.file_name == "index" {
  141. page.components.pop();
  142. // also set parent_path to grandparent instead
  143. page.parent_path = page.parent_path.parent().unwrap().to_path_buf();
  144. }
  145. // Don't add a trailing slash to sections
  146. page.url = format!("{}/{}", page.components.join("/"), page.slug);
  147. } else {
  148. page.url = page.slug.clone();
  149. }
  150. }
  151. page.permalink = config.make_permalink(&page.url);
  152. Ok(page)
  153. }
  154. /// Read and parse a .md file into a Page struct
  155. pub fn from_file<P: AsRef<Path>>(path: P, config: &Config) -> Result<Page> {
  156. let path = path.as_ref();
  157. let content = read_file(path)?;
  158. let mut page = Page::parse(path, &content, config)?;
  159. page.assets = find_related_assets(path.parent().unwrap());
  160. if !page.assets.is_empty() && page.file_name != "index" {
  161. bail!("Page `{}` has assets but is not named index.md", path.display());
  162. }
  163. Ok(page)
  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. // TODO: create a helper to create context to ensure all contexts
  172. // have the same names
  173. let mut context = Context::new();
  174. context.add("config", config);
  175. context.add("page", self);
  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", 13)?;
  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("url", &format!("/{}", self.url))?;
  189. state.serialize_field("permalink", &self.permalink)?;
  190. state.serialize_field("tags", &self.meta.tags)?;
  191. state.serialize_field("draft", &self.meta.draft)?;
  192. state.serialize_field("category", &self.meta.category)?;
  193. state.serialize_field("extra", &self.meta.extra)?;
  194. let (word_count, reading_time) = self.get_reading_analytics();
  195. state.serialize_field("word_count", &word_count)?;
  196. state.serialize_field("reading_time", &reading_time)?;
  197. state.end()
  198. }
  199. }
  200. impl PartialOrd for Page {
  201. fn partial_cmp(&self, other: &Page) -> Option<Ordering> {
  202. if self.meta.date.is_none() {
  203. return Some(Ordering::Less);
  204. }
  205. if other.meta.date.is_none() {
  206. return Some(Ordering::Greater);
  207. }
  208. let this_date = self.meta.parse_date().unwrap();
  209. let other_date = other.meta.parse_date().unwrap();
  210. if this_date > other_date {
  211. return Some(Ordering::Less);
  212. }
  213. if this_date < other_date {
  214. return Some(Ordering::Greater);
  215. }
  216. Some(Ordering::Equal)
  217. }
  218. }
  219. /// Horribly inefficient way to set previous and next on each pages
  220. /// So many clones
  221. pub fn populate_previous_and_next_pages(input: &[Page], in_section: bool) -> Vec<Page> {
  222. let pages = input.to_vec();
  223. let mut res = Vec::new();
  224. // the input is sorted from most recent to least recent already
  225. for (i, page) in input.iter().enumerate() {
  226. let mut new_page = page.clone();
  227. if new_page.has_date() {
  228. if i > 0 {
  229. let next = &pages[i - 1];
  230. if next.has_date() {
  231. if in_section {
  232. new_page.next_in_section = Some(Box::new(next.clone()));
  233. } else {
  234. new_page.next = Some(Box::new(next.clone()));
  235. }
  236. }
  237. }
  238. if i < input.len() - 1 {
  239. let previous = &pages[i + 1];
  240. if previous.has_date() {
  241. if in_section {
  242. new_page.previous_in_section = Some(Box::new(previous.clone()));
  243. } else {
  244. new_page.previous = Some(Box::new(previous.clone()));
  245. }
  246. }
  247. }
  248. }
  249. res.push(new_page);
  250. }
  251. res
  252. }
  253. #[cfg(test)]
  254. mod tests {
  255. use tempdir::TempDir;
  256. use std::fs::File;
  257. use super::{find_related_assets};
  258. #[test]
  259. fn test_find_related_assets() {
  260. let tmp_dir = TempDir::new("example").expect("create temp dir");
  261. File::create(tmp_dir.path().join("index.md")).unwrap();
  262. File::create(tmp_dir.path().join("example.js")).unwrap();
  263. File::create(tmp_dir.path().join("graph.jpg")).unwrap();
  264. File::create(tmp_dir.path().join("fail.png")).unwrap();
  265. let assets = find_related_assets(tmp_dir.path());
  266. assert_eq!(assets.len(), 3);
  267. assert_eq!(assets.iter().filter(|p| p.extension().unwrap() != "md").count(), 3);
  268. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "example.js").count(), 1);
  269. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "graph.jpg").count(), 1);
  270. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "fail.png").count(), 1);
  271. }
  272. }