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.

457 lines
14KB

  1. /// A page, can be a blog post or a basic page
  2. use std::cmp::Ordering;
  3. use std::fs::File;
  4. use std::io::prelude::*;
  5. use std::path::Path;
  6. use std::result::Result as StdResult;
  7. use regex::Regex;
  8. use tera::{Tera, Context};
  9. use serde::ser::{SerializeStruct, self};
  10. use slug::slugify;
  11. use errors::{Result, ResultExt};
  12. use config::Config;
  13. use front_matter::{FrontMatter};
  14. use markdown::markdown_to_html;
  15. lazy_static! {
  16. static ref PAGE_RE: Regex = Regex::new(r"^\n?\+\+\+\n((?s).*(?-s))\+\+\+\n((?s).*(?-s))$").unwrap();
  17. static ref SUMMARY_RE: Regex = Regex::new(r"<!-- more -->").unwrap();
  18. }
  19. #[derive(Clone, Debug, PartialEq, Deserialize)]
  20. pub struct Page {
  21. /// .md filepath, excluding the content/ bit
  22. #[serde(skip_serializing)]
  23. pub filepath: String,
  24. /// The name of the .md file
  25. #[serde(skip_serializing)]
  26. pub filename: String,
  27. /// The directories above our .md file are called sections
  28. /// for example a file at content/kb/solutions/blabla.md will have 2 sections:
  29. /// `kb` and `solutions`
  30. #[serde(skip_serializing)]
  31. pub sections: Vec<String>,
  32. /// The actual content of the page, in markdown
  33. #[serde(skip_serializing)]
  34. pub raw_content: String,
  35. /// The HTML rendered of the page
  36. pub content: String,
  37. /// The front matter meta-data
  38. pub meta: FrontMatter,
  39. /// The slug of that page.
  40. /// First tries to find the slug in the meta and defaults to filename otherwise
  41. pub slug: String,
  42. /// The relative URL of the page
  43. pub url: String,
  44. /// The full URL for that page
  45. pub permalink: String,
  46. /// The summary for the article, defaults to empty string
  47. /// When <!-- more --> is found in the text, will take the content up to that part
  48. /// as summary
  49. pub summary: String,
  50. /// The previous page, by date
  51. pub previous: Option<Box<Page>>,
  52. /// The next page, by date
  53. pub next: Option<Box<Page>>,
  54. }
  55. impl Page {
  56. pub fn new(meta: FrontMatter) -> Page {
  57. Page {
  58. filepath: "".to_string(),
  59. filename: "".to_string(),
  60. sections: vec![],
  61. raw_content: "".to_string(),
  62. content: "".to_string(),
  63. slug: "".to_string(),
  64. url: "".to_string(),
  65. permalink: "".to_string(),
  66. summary: "".to_string(),
  67. meta: meta,
  68. previous: None,
  69. next: None,
  70. }
  71. }
  72. // Get word count and estimated reading time
  73. pub fn get_reading_analytics(&self) -> (usize, usize) {
  74. // Only works for latin language but good enough for a start
  75. let word_count: usize = self.raw_content.split_whitespace().count();
  76. // https://help.medium.com/hc/en-us/articles/214991667-Read-time
  77. // 275 seems a bit too high though
  78. (word_count, (word_count / 200))
  79. }
  80. // Parse a page given the content of the .md file
  81. // Files without front matter or with invalid front matter are considered
  82. // erroneous
  83. pub fn parse(filepath: &str, content: &str, config: &Config) -> Result<Page> {
  84. // 1. separate front matter from content
  85. if !PAGE_RE.is_match(content) {
  86. bail!("Couldn't find front matter in `{}`. Did you forget to add `+++`?", filepath);
  87. }
  88. // 2. extract the front matter and the content
  89. let caps = PAGE_RE.captures(content).unwrap();
  90. // caps[0] is the full match
  91. let front_matter = &caps[1];
  92. let content = &caps[2];
  93. // 3. create our page, parse front matter and assign all of that
  94. let meta = FrontMatter::parse(&front_matter)
  95. .chain_err(|| format!("Error when parsing front matter of file `{}`", filepath))?;
  96. let mut page = Page::new(meta);
  97. page.filepath = filepath.to_string();
  98. page.raw_content = content.to_string();
  99. page.content = markdown_to_html(&page.raw_content, config.highlight_code.unwrap());
  100. if page.raw_content.contains("<!-- more -->") {
  101. page.summary = {
  102. let summary = SUMMARY_RE.split(&page.raw_content).collect::<Vec<&str>>()[0];
  103. markdown_to_html(summary, config.highlight_code.unwrap())
  104. }
  105. }
  106. let path = Path::new(filepath);
  107. page.filename = path.file_stem().expect("Couldn't get filename").to_string_lossy().to_string();
  108. page.slug = {
  109. if let Some(ref slug) = page.meta.slug {
  110. slug.trim().to_string()
  111. } else {
  112. slugify(page.filename.clone())
  113. }
  114. };
  115. // 4. Find sections
  116. // Pages with custom urls exists outside of sections
  117. if let Some(ref u) = page.meta.url {
  118. page.url = u.trim().to_string();
  119. } else {
  120. // find out if we have sections
  121. for section in path.parent().unwrap().components() {
  122. page.sections.push(section.as_ref().to_string_lossy().to_string());
  123. }
  124. if !page.sections.is_empty() {
  125. page.url = format!("{}/{}", page.sections.join("/"), page.slug);
  126. } else {
  127. page.url = format!("{}", page.slug);
  128. }
  129. }
  130. page.permalink = if config.base_url.ends_with("/") {
  131. format!("{}{}", config.base_url, page.url)
  132. } else {
  133. format!("{}/{}", config.base_url, page.url)
  134. };
  135. Ok(page)
  136. }
  137. pub fn from_file<P: AsRef<Path>>(path: P, config: &Config) -> Result<Page> {
  138. let path = path.as_ref();
  139. let mut content = String::new();
  140. File::open(path)
  141. .chain_err(|| format!("Failed to open '{:?}'", path.display()))?
  142. .read_to_string(&mut content)?;
  143. // Remove the content string from name
  144. // Maybe get a path as an arg instead and use strip_prefix?
  145. Page::parse(&path.strip_prefix("content").unwrap().to_string_lossy(), &content, config)
  146. }
  147. fn get_layout_name(&self) -> String {
  148. match self.meta.layout {
  149. Some(ref l) => l.to_string(),
  150. None => "page.html".to_string()
  151. }
  152. }
  153. /// Renders the page using the default layout, unless specified in front-matter
  154. pub fn render_html(&self, tera: &Tera, config: &Config) -> Result<String> {
  155. let tpl = self.get_layout_name();
  156. let mut context = Context::new();
  157. context.add("site", config);
  158. context.add("page", self);
  159. tera.render(&tpl, &context)
  160. .chain_err(|| "Error while rendering template")
  161. }
  162. }
  163. impl ser::Serialize for Page {
  164. fn serialize<S>(&self, serializer: S) -> StdResult<S::Ok, S::Error> where S: ser::Serializer {
  165. let mut state = serializer.serialize_struct("page", 13)?;
  166. state.serialize_field("content", &self.content)?;
  167. state.serialize_field("title", &self.meta.title)?;
  168. state.serialize_field("description", &self.meta.description)?;
  169. state.serialize_field("date", &self.meta.date)?;
  170. state.serialize_field("slug", &self.slug)?;
  171. state.serialize_field("url", &format!("/{}", self.url))?;
  172. state.serialize_field("permalink", &self.permalink)?;
  173. state.serialize_field("tags", &self.meta.tags)?;
  174. state.serialize_field("draft", &self.meta.draft)?;
  175. state.serialize_field("category", &self.meta.category)?;
  176. state.serialize_field("extra", &self.meta.extra)?;
  177. let (word_count, reading_time) = self.get_reading_analytics();
  178. state.serialize_field("word_count", &word_count)?;
  179. state.serialize_field("reading_time", &reading_time)?;
  180. state.end()
  181. }
  182. }
  183. impl PartialOrd for Page {
  184. fn partial_cmp(&self, other: &Page) -> Option<Ordering> {
  185. if self.meta.date.is_none() {
  186. println!("No self data");
  187. return Some(Ordering::Less);
  188. }
  189. if other.meta.date.is_none() {
  190. println!("No other date");
  191. return Some(Ordering::Greater);
  192. }
  193. let this_date = self.meta.parse_date().unwrap();
  194. let other_date = other.meta.parse_date().unwrap();
  195. if this_date > other_date {
  196. return Some(Ordering::Less);
  197. }
  198. if this_date < other_date {
  199. return Some(Ordering::Greater);
  200. }
  201. Some(Ordering::Equal)
  202. }
  203. }
  204. #[cfg(test)]
  205. mod tests {
  206. use super::{Page};
  207. use config::Config;
  208. #[test]
  209. fn test_can_parse_a_valid_page() {
  210. let content = r#"
  211. +++
  212. title = "Hello"
  213. description = "hey there"
  214. slug = "hello-world"
  215. +++
  216. Hello world"#;
  217. let res = Page::parse("post.md", content, &Config::default());
  218. assert!(res.is_ok());
  219. let page = res.unwrap();
  220. assert_eq!(page.meta.title, "Hello".to_string());
  221. assert_eq!(page.meta.slug.unwrap(), "hello-world".to_string());
  222. assert_eq!(page.raw_content, "Hello world".to_string());
  223. assert_eq!(page.content, "<p>Hello world</p>\n".to_string());
  224. }
  225. #[test]
  226. fn test_can_find_one_parent_directory() {
  227. let content = r#"
  228. +++
  229. title = "Hello"
  230. description = "hey there"
  231. slug = "hello-world"
  232. +++
  233. Hello world"#;
  234. let res = Page::parse("posts/intro.md", content, &Config::default());
  235. assert!(res.is_ok());
  236. let page = res.unwrap();
  237. assert_eq!(page.sections, vec!["posts".to_string()]);
  238. }
  239. #[test]
  240. fn test_can_find_multiple_parent_directories() {
  241. let content = r#"
  242. +++
  243. title = "Hello"
  244. description = "hey there"
  245. slug = "hello-world"
  246. +++
  247. Hello world"#;
  248. let res = Page::parse("posts/intro/start.md", content, &Config::default());
  249. assert!(res.is_ok());
  250. let page = res.unwrap();
  251. assert_eq!(page.sections, vec!["posts".to_string(), "intro".to_string()]);
  252. }
  253. #[test]
  254. fn test_can_make_url_from_sections_and_slug() {
  255. let content = r#"
  256. +++
  257. title = "Hello"
  258. description = "hey there"
  259. slug = "hello-world"
  260. +++
  261. Hello world"#;
  262. let mut conf = Config::default();
  263. conf.base_url = "http://hello.com/".to_string();
  264. let res = Page::parse("posts/intro/start.md", content, &conf);
  265. assert!(res.is_ok());
  266. let page = res.unwrap();
  267. assert_eq!(page.url, "posts/intro/hello-world");
  268. assert_eq!(page.permalink, "http://hello.com/posts/intro/hello-world");
  269. }
  270. #[test]
  271. fn test_can_make_permalink_with_non_trailing_slash_base_url() {
  272. let content = r#"
  273. +++
  274. title = "Hello"
  275. description = "hey there"
  276. slug = "hello-world"
  277. +++
  278. Hello world"#;
  279. let mut conf = Config::default();
  280. conf.base_url = "http://hello.com".to_string();
  281. let res = Page::parse("posts/intro/start.md", content, &conf);
  282. assert!(res.is_ok());
  283. let page = res.unwrap();
  284. assert_eq!(page.url, "posts/intro/hello-world");
  285. println!("{}", page.permalink);
  286. assert_eq!(page.permalink, format!("{}{}", conf.base_url, "/posts/intro/hello-world"));
  287. }
  288. #[test]
  289. fn test_can_make_url_from_slug_only() {
  290. let content = r#"
  291. +++
  292. title = "Hello"
  293. description = "hey there"
  294. slug = "hello-world"
  295. +++
  296. Hello world"#;
  297. let res = Page::parse("start.md", content, &Config::default());
  298. assert!(res.is_ok());
  299. let page = res.unwrap();
  300. assert_eq!(page.url, "hello-world");
  301. assert_eq!(page.permalink, format!("{}{}", Config::default().base_url, "hello-world"));
  302. }
  303. #[test]
  304. fn test_errors_on_invalid_front_matter_format() {
  305. let content = r#"
  306. title = "Hello"
  307. description = "hey there"
  308. slug = "hello-world"
  309. +++
  310. Hello world"#;
  311. let res = Page::parse("start.md", content, &Config::default());
  312. assert!(res.is_err());
  313. }
  314. #[test]
  315. fn test_can_make_slug_from_non_slug_filename() {
  316. let content = r#"
  317. +++
  318. title = "Hello"
  319. description = "hey there"
  320. +++
  321. Hello world"#;
  322. let res = Page::parse("file with space.md", content, &Config::default());
  323. assert!(res.is_ok());
  324. let page = res.unwrap();
  325. assert_eq!(page.slug, "file-with-space");
  326. assert_eq!(page.permalink, format!("{}{}", Config::default().base_url, "file-with-space"));
  327. }
  328. #[test]
  329. fn test_trim_slug_if_needed() {
  330. let content = r#"
  331. +++
  332. title = "Hello"
  333. description = "hey there"
  334. +++
  335. Hello world"#;
  336. let res = Page::parse(" file with space.md", content, &Config::default());
  337. assert!(res.is_ok());
  338. let page = res.unwrap();
  339. assert_eq!(page.slug, "file-with-space");
  340. assert_eq!(page.permalink, format!("{}{}", Config::default().base_url, "file-with-space"));
  341. }
  342. #[test]
  343. fn test_reading_analytics_short() {
  344. let content = r#"
  345. +++
  346. title = "Hello"
  347. description = "hey there"
  348. +++
  349. Hello world"#;
  350. let res = Page::parse("file with space.md", content, &Config::default());
  351. assert!(res.is_ok());
  352. let page = res.unwrap();
  353. let (word_count, reading_time) = page.get_reading_analytics();
  354. assert_eq!(word_count, 2);
  355. assert_eq!(reading_time, 0);
  356. }
  357. #[test]
  358. fn test_reading_analytics_long() {
  359. let mut content = r#"
  360. +++
  361. title = "Hello"
  362. description = "hey there"
  363. +++
  364. Hello world"#.to_string();
  365. for _ in 0..1000 {
  366. content.push_str(" Hello world");
  367. }
  368. let res = Page::parse("hello.md", &content, &Config::default());
  369. assert!(res.is_ok());
  370. let page = res.unwrap();
  371. let (word_count, reading_time) = page.get_reading_analytics();
  372. assert_eq!(word_count, 2002);
  373. assert_eq!(reading_time, 10);
  374. }
  375. #[test]
  376. fn test_automatic_summary_is_empty_string() {
  377. let content = r#"
  378. +++
  379. title = "Hello"
  380. description = "hey there"
  381. +++
  382. Hello world"#.to_string();
  383. let res = Page::parse("hello.md", &content, &Config::default());
  384. assert!(res.is_ok());
  385. let page = res.unwrap();
  386. assert_eq!(page.summary, "");
  387. }
  388. #[test]
  389. fn test_can_specify_summary() {
  390. let content = r#"
  391. +++
  392. title = "Hello"
  393. description = "hey there"
  394. +++
  395. Hello world
  396. <!-- more -->
  397. "#.to_string();
  398. let res = Page::parse("hello.md", &content, &Config::default());
  399. assert!(res.is_ok());
  400. let page = res.unwrap();
  401. assert_eq!(page.summary, "<p>Hello world</p>\n");
  402. }
  403. }