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.

333 lines
12KB

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