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.

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