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.

294 lines
11KB

  1. use std::collections::HashMap;
  2. use std::path::{Path, PathBuf};
  3. use std::result::Result as StdResult;
  4. use tera::{Tera, Context as TeraContext};
  5. use serde::ser::{SerializeStruct, self};
  6. use config::Config;
  7. use front_matter::{SectionFrontMatter, split_section_content};
  8. use errors::{Result, ResultExt};
  9. use utils::fs::{read_file, find_related_assets};
  10. use utils::templates::render_template;
  11. use utils::site::get_reading_analytics;
  12. use rendering::{RenderContext, Header, render_content};
  13. use page::Page;
  14. use file_info::FileInfo;
  15. #[derive(Clone, Debug, PartialEq)]
  16. pub struct Section {
  17. /// All info about the actual file
  18. pub file: FileInfo,
  19. /// The front matter meta-data
  20. pub meta: SectionFrontMatter,
  21. /// The URL path of the page
  22. pub path: String,
  23. /// The components for the path of that page
  24. pub components: Vec<String>,
  25. /// The full URL for that page
  26. pub permalink: String,
  27. /// The actual content of the page, in markdown
  28. pub raw_content: String,
  29. /// The HTML rendered of the page
  30. pub content: String,
  31. /// All the non-md files we found next to the .md file
  32. pub assets: Vec<PathBuf>,
  33. /// All direct pages of that section
  34. pub pages: Vec<Page>,
  35. /// All pages that cannot be sorted in this section
  36. pub ignored_pages: Vec<Page>,
  37. /// All direct subsections
  38. pub subsections: Vec<Section>,
  39. /// Toc made from the headers of the markdown file
  40. pub toc: Vec<Header>,
  41. }
  42. impl Section {
  43. pub fn new<P: AsRef<Path>>(file_path: P, meta: SectionFrontMatter) -> Section {
  44. let file_path = file_path.as_ref();
  45. Section {
  46. file: FileInfo::new_section(file_path),
  47. meta,
  48. path: "".to_string(),
  49. components: vec![],
  50. permalink: "".to_string(),
  51. raw_content: "".to_string(),
  52. assets: vec![],
  53. content: "".to_string(),
  54. pages: vec![],
  55. ignored_pages: vec![],
  56. subsections: vec![],
  57. toc: vec![],
  58. }
  59. }
  60. pub fn parse(file_path: &Path, content: &str, config: &Config) -> Result<Section> {
  61. let (meta, content) = split_section_content(file_path, content)?;
  62. let mut section = Section::new(file_path, meta);
  63. section.raw_content = content.clone();
  64. section.path = format!("{}/", section.file.components.join("/"));
  65. section.components = section.path.split('/')
  66. .map(|p| p.to_string())
  67. .filter(|p| !p.is_empty())
  68. .collect::<Vec<_>>();
  69. section.permalink = config.make_permalink(&section.path);
  70. Ok(section)
  71. }
  72. /// Read and parse a .md file into a Page struct
  73. pub fn from_file<P: AsRef<Path>>(path: P, config: &Config) -> Result<Section> {
  74. let path = path.as_ref();
  75. let content = read_file(path)?;
  76. let mut section = Section::parse(path, &content, config)?;
  77. let parent_dir = path.parent().unwrap();
  78. let assets = find_related_assets(parent_dir);
  79. if let Some(ref globset) = config.ignored_content_globset {
  80. // `find_related_assets` only scans the immediate directory (it is not recursive) so our
  81. // filtering only needs to work against the file_name component, not the full suffix. If
  82. // `find_related_assets` was changed to also return files in subdirectories, we could
  83. // use `PathBuf.strip_prefix` to remove the parent directory and then glob-filter
  84. // against the remaining path. Note that the current behaviour effectively means that
  85. // the `ignored_content` setting in the config file is limited to single-file glob
  86. // patterns (no "**" patterns).
  87. section.assets = assets.into_iter()
  88. .filter(|path|
  89. match path.file_name() {
  90. None => true,
  91. Some(file) => !globset.is_match(file)
  92. }
  93. ).collect();
  94. } else {
  95. section.assets = assets;
  96. }
  97. Ok(section)
  98. }
  99. pub fn get_template_name(&self) -> String {
  100. match self.meta.template {
  101. Some(ref l) => l.to_string(),
  102. None => {
  103. if self.is_index() {
  104. return "index.html".to_string();
  105. }
  106. "section.html".to_string()
  107. }
  108. }
  109. }
  110. /// We need access to all pages url to render links relative to content
  111. /// so that can't happen at the same time as parsing
  112. pub fn render_markdown(&mut self, permalinks: &HashMap<String, String>, tera: &Tera, config: &Config) -> Result<()> {
  113. let mut context = RenderContext::new(
  114. tera,
  115. config,
  116. &self.permalink,
  117. permalinks,
  118. self.meta.insert_anchor_links,
  119. );
  120. context.tera_context.add("section", self);
  121. let res = render_content(&self.raw_content, &context)
  122. .chain_err(|| format!("Failed to render content of {}", self.file.path.display()))?;
  123. self.content = res.0;
  124. self.toc = res.1;
  125. Ok(())
  126. }
  127. /// Renders the page using the default layout, unless specified in front-matter
  128. pub fn render_html(&self, tera: &Tera, config: &Config) -> Result<String> {
  129. let tpl_name = self.get_template_name();
  130. let mut context = TeraContext::new();
  131. context.add("config", config);
  132. context.add("section", self);
  133. context.add("current_url", &self.permalink);
  134. context.add("current_path", &self.path);
  135. render_template(&tpl_name, tera, &context, &config.theme)
  136. .chain_err(|| format!("Failed to render section '{}'", self.file.path.display()))
  137. }
  138. /// Is this the index section?
  139. pub fn is_index(&self) -> bool {
  140. self.file.components.is_empty()
  141. }
  142. /// Returns all the paths of the pages belonging to that section
  143. pub fn all_pages_path(&self) -> Vec<PathBuf> {
  144. let mut paths = vec![];
  145. paths.extend(self.pages.iter().map(|p| p.file.path.clone()));
  146. paths.extend(self.ignored_pages.iter().map(|p| p.file.path.clone()));
  147. paths
  148. }
  149. /// Whether the page given belongs to that section
  150. pub fn is_child_page(&self, path: &PathBuf) -> bool {
  151. self.all_pages_path().contains(path)
  152. }
  153. /// Creates a vectors of asset URLs.
  154. fn serialize_assets(&self) -> Vec<String> {
  155. self.assets.iter()
  156. .filter_map(|asset| asset.file_name())
  157. .filter_map(|filename| filename.to_str())
  158. .map(|filename| self.path.clone() + filename)
  159. .collect()
  160. }
  161. }
  162. impl ser::Serialize for Section {
  163. fn serialize<S>(&self, serializer: S) -> StdResult<S::Ok, S::Error> where S: ser::Serializer {
  164. let mut state = serializer.serialize_struct("section", 13)?;
  165. state.serialize_field("content", &self.content)?;
  166. state.serialize_field("permalink", &self.permalink)?;
  167. state.serialize_field("title", &self.meta.title)?;
  168. state.serialize_field("description", &self.meta.description)?;
  169. state.serialize_field("extra", &self.meta.extra)?;
  170. state.serialize_field("path", &self.path)?;
  171. state.serialize_field("components", &self.components)?;
  172. state.serialize_field("permalink", &self.permalink)?;
  173. state.serialize_field("pages", &self.pages)?;
  174. state.serialize_field("subsections", &self.subsections)?;
  175. let (word_count, reading_time) = get_reading_analytics(&self.raw_content);
  176. state.serialize_field("word_count", &word_count)?;
  177. state.serialize_field("reading_time", &reading_time)?;
  178. state.serialize_field("toc", &self.toc)?;
  179. let assets = self.serialize_assets();
  180. state.serialize_field("assets", &assets)?;
  181. state.end()
  182. }
  183. }
  184. /// Used to create a default index section if there is no _index.md in the root content directory
  185. impl Default for Section {
  186. fn default() -> Section {
  187. Section {
  188. file: FileInfo::default(),
  189. meta: SectionFrontMatter::default(),
  190. path: "".to_string(),
  191. components: vec![],
  192. permalink: "".to_string(),
  193. raw_content: "".to_string(),
  194. assets: vec![],
  195. content: "".to_string(),
  196. pages: vec![],
  197. ignored_pages: vec![],
  198. subsections: vec![],
  199. toc: vec![],
  200. }
  201. }
  202. }
  203. #[cfg(test)]
  204. mod tests {
  205. use std::io::Write;
  206. use std::fs::{File, create_dir};
  207. use tempfile::tempdir;
  208. use globset::{Glob, GlobSetBuilder};
  209. use config::Config;
  210. use super::Section;
  211. #[test]
  212. fn section_with_assets_gets_right_info() {
  213. let tmp_dir = tempdir().expect("create temp dir");
  214. let path = tmp_dir.path();
  215. create_dir(&path.join("content")).expect("create content temp dir");
  216. create_dir(&path.join("content").join("posts")).expect("create posts temp dir");
  217. let nested_path = path.join("content").join("posts").join("with-assets");
  218. create_dir(&nested_path).expect("create nested temp dir");
  219. let mut f = File::create(nested_path.join("_index.md")).unwrap();
  220. f.write_all(b"+++\n+++\n").unwrap();
  221. File::create(nested_path.join("example.js")).unwrap();
  222. File::create(nested_path.join("graph.jpg")).unwrap();
  223. File::create(nested_path.join("fail.png")).unwrap();
  224. let res = Section::from_file(
  225. nested_path.join("_index.md").as_path(),
  226. &Config::default(),
  227. );
  228. assert!(res.is_ok());
  229. let section = res.unwrap();
  230. assert_eq!(section.assets.len(), 3);
  231. assert_eq!(section.permalink, "http://a-website.com/posts/with-assets/");
  232. }
  233. #[test]
  234. fn section_with_ignored_assets_filters_out_correct_files() {
  235. let tmp_dir = tempdir().expect("create temp dir");
  236. let path = tmp_dir.path();
  237. create_dir(&path.join("content")).expect("create content temp dir");
  238. create_dir(&path.join("content").join("posts")).expect("create posts temp dir");
  239. let nested_path = path.join("content").join("posts").join("with-assets");
  240. create_dir(&nested_path).expect("create nested temp dir");
  241. let mut f = File::create(nested_path.join("_index.md")).unwrap();
  242. f.write_all(b"+++\nslug=\"hey\"\n+++\n").unwrap();
  243. File::create(nested_path.join("example.js")).unwrap();
  244. File::create(nested_path.join("graph.jpg")).unwrap();
  245. File::create(nested_path.join("fail.png")).unwrap();
  246. let mut gsb = GlobSetBuilder::new();
  247. gsb.add(Glob::new("*.{js,png}").unwrap());
  248. let mut config = Config::default();
  249. config.ignored_content_globset = Some(gsb.build().unwrap());
  250. let res = Section::from_file(
  251. nested_path.join("_index.md").as_path(),
  252. &config,
  253. );
  254. assert!(res.is_ok());
  255. let page = res.unwrap();
  256. assert_eq!(page.assets.len(), 1);
  257. assert_eq!(page.assets[0].file_name().unwrap().to_str(), Some("graph.jpg"));
  258. }
  259. }