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.

284 lines
10.0KB

  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
  65. pub previous: Option<Box<Page>>,
  66. /// The next page, by date
  67. pub next: Option<Box<Page>>,
  68. }
  69. impl Page {
  70. pub fn new(meta: FrontMatter) -> Page {
  71. Page {
  72. file_path: PathBuf::new(),
  73. parent_path: PathBuf::new(),
  74. file_name: "".to_string(),
  75. components: vec![],
  76. raw_content: "".to_string(),
  77. assets: vec![],
  78. content: "".to_string(),
  79. slug: "".to_string(),
  80. url: "".to_string(),
  81. permalink: "".to_string(),
  82. summary: "".to_string(),
  83. meta: meta,
  84. previous: None,
  85. next: None,
  86. }
  87. }
  88. /// Get word count and estimated reading time
  89. pub fn get_reading_analytics(&self) -> (usize, usize) {
  90. // Only works for latin language but good enough for a start
  91. let word_count: usize = self.raw_content.split_whitespace().count();
  92. // https://help.medium.com/hc/en-us/articles/214991667-Read-time
  93. // 275 seems a bit too high though
  94. (word_count, (word_count / 200))
  95. }
  96. /// Parse a page given the content of the .md file
  97. /// Files without front matter or with invalid front matter are considered
  98. /// erroneous
  99. pub fn parse(file_path: &Path, content: &str, config: &Config) -> Result<Page> {
  100. // 1. separate front matter from content
  101. let (meta, content) = split_content(file_path, content)?;
  102. let mut page = Page::new(meta);
  103. page.file_path = file_path.to_path_buf();
  104. page.parent_path = page.file_path.parent().unwrap().to_path_buf();
  105. page.raw_content = content;
  106. // We try to be smart about highlighting code as it can be time-consuming
  107. // If the global config disables it, then we do nothing. However,
  108. // if we see a code block in the content, we assume that this page needs
  109. // to be highlighted. It could potentially have false positive if the content
  110. // has ``` in it but that seems kind of unlikely
  111. let should_highlight = if config.highlight_code.unwrap() {
  112. page.raw_content.contains("```")
  113. } else {
  114. false
  115. };
  116. let highlight_theme = config.highlight_theme.clone().unwrap();
  117. page.content = markdown_to_html(&page.raw_content, should_highlight, &highlight_theme);
  118. if page.raw_content.contains("<!-- more -->") {
  119. page.summary = {
  120. let summary = page.raw_content.splitn(2, "<!-- more -->").collect::<Vec<&str>>()[0];
  121. markdown_to_html(summary, should_highlight, &highlight_theme)
  122. }
  123. }
  124. let path = Path::new(file_path);
  125. page.file_name = path.file_stem().unwrap().to_string_lossy().to_string();
  126. page.slug = {
  127. if let Some(ref slug) = page.meta.slug {
  128. slug.trim().to_string()
  129. } else {
  130. slugify(page.file_name.clone())
  131. }
  132. };
  133. // 4. Find sections
  134. // Pages with custom urls exists outside of sections
  135. if let Some(ref u) = page.meta.url {
  136. page.url = u.trim().to_string();
  137. } else {
  138. page.components = find_content_components(&page.file_path);
  139. if !page.components.is_empty() {
  140. // If we have a folder with an asset, don't consider it as a component
  141. if page.file_name == "index" {
  142. page.components.pop();
  143. // also set parent_path to grandparent instead
  144. page.parent_path = page.parent_path.parent().unwrap().to_path_buf();
  145. }
  146. // Don't add a trailing slash to sections
  147. page.url = format!("{}/{}", page.components.join("/"), page.slug);
  148. } else {
  149. page.url = page.slug.clone();
  150. }
  151. }
  152. page.permalink = config.make_permalink(&page.url);
  153. Ok(page)
  154. }
  155. /// Read and parse a .md file into a Page struct
  156. pub fn from_file<P: AsRef<Path>>(path: P, config: &Config) -> Result<Page> {
  157. let path = path.as_ref();
  158. let content = read_file(path)?;
  159. let mut page = Page::parse(path, &content, config)?;
  160. page.assets = find_related_assets(path.parent().unwrap());
  161. if !page.assets.is_empty() && page.file_name != "index" {
  162. bail!("Page `{}` has assets but is not named index.md", path.display());
  163. }
  164. Ok(page)
  165. }
  166. /// Renders the page using the default layout, unless specified in front-matter
  167. pub fn render_html(&self, tera: &Tera, config: &Config) -> Result<String> {
  168. let tpl_name = match self.meta.template {
  169. Some(ref l) => l.to_string(),
  170. None => "page.html".to_string()
  171. };
  172. // TODO: create a helper to create context to ensure all contexts
  173. // have the same names
  174. let mut context = Context::new();
  175. context.add("config", config);
  176. context.add("page", self);
  177. tera.render(&tpl_name, &context)
  178. .chain_err(|| format!("Failed to render page '{}'", self.file_name))
  179. }
  180. }
  181. impl ser::Serialize for Page {
  182. fn serialize<S>(&self, serializer: S) -> StdResult<S::Ok, S::Error> where S: ser::Serializer {
  183. let mut state = serializer.serialize_struct("page", 13)?;
  184. state.serialize_field("content", &self.content)?;
  185. state.serialize_field("title", &self.meta.title)?;
  186. state.serialize_field("description", &self.meta.description)?;
  187. state.serialize_field("date", &self.meta.date)?;
  188. state.serialize_field("slug", &self.slug)?;
  189. state.serialize_field("url", &format!("/{}", self.url))?;
  190. state.serialize_field("permalink", &self.permalink)?;
  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.end()
  199. }
  200. }
  201. impl PartialOrd for Page {
  202. fn partial_cmp(&self, other: &Page) -> Option<Ordering> {
  203. if self.meta.date.is_none() {
  204. return Some(Ordering::Less);
  205. }
  206. if other.meta.date.is_none() {
  207. return Some(Ordering::Greater);
  208. }
  209. let this_date = self.meta.parse_date().unwrap();
  210. let other_date = other.meta.parse_date().unwrap();
  211. if this_date > other_date {
  212. return Some(Ordering::Less);
  213. }
  214. if this_date < other_date {
  215. return Some(Ordering::Greater);
  216. }
  217. Some(Ordering::Equal)
  218. }
  219. }
  220. #[cfg(test)]
  221. mod tests {
  222. use tempdir::TempDir;
  223. use std::fs::File;
  224. use super::{find_related_assets};
  225. #[test]
  226. fn test_find_related_assets() {
  227. let tmp_dir = TempDir::new("example").expect("create temp dir");
  228. File::create(tmp_dir.path().join("index.md")).unwrap();
  229. File::create(tmp_dir.path().join("example.js")).unwrap();
  230. File::create(tmp_dir.path().join("graph.jpg")).unwrap();
  231. File::create(tmp_dir.path().join("fail.png")).unwrap();
  232. let assets = find_related_assets(tmp_dir.path());
  233. assert_eq!(assets.len(), 3);
  234. assert_eq!(assets.iter().filter(|p| p.extension().unwrap() != "md").count(), 3);
  235. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "example.js").count(), 1);
  236. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "graph.jpg").count(), 1);
  237. assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "fail.png").count(), 1);
  238. }
  239. }