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.

295 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, base_path: &Path) -> Result<()> {
  113. let mut context = RenderContext::new(
  114. tera,
  115. config,
  116. &self.permalink,
  117. permalinks,
  118. base_path,
  119. self.meta.insert_anchor_links,
  120. );
  121. context.tera_context.insert("section", self);
  122. let res = render_content(&self.raw_content, &context)
  123. .chain_err(|| format!("Failed to render content of {}", self.file.path.display()))?;
  124. self.content = res.body;
  125. self.toc = res.toc;
  126. Ok(())
  127. }
  128. /// Renders the page using the default layout, unless specified in front-matter
  129. pub fn render_html(&self, tera: &Tera, config: &Config) -> Result<String> {
  130. let tpl_name = self.get_template_name();
  131. let mut context = TeraContext::new();
  132. context.insert("config", config);
  133. context.insert("section", self);
  134. context.insert("current_url", &self.permalink);
  135. context.insert("current_path", &self.path);
  136. render_template(&tpl_name, tera, &context, &config.theme)
  137. .chain_err(|| format!("Failed to render section '{}'", self.file.path.display()))
  138. }
  139. /// Is this the index section?
  140. pub fn is_index(&self) -> bool {
  141. self.file.components.is_empty()
  142. }
  143. /// Returns all the paths of the pages belonging to that section
  144. pub fn all_pages_path(&self) -> Vec<PathBuf> {
  145. let mut paths = vec![];
  146. paths.extend(self.pages.iter().map(|p| p.file.path.clone()));
  147. paths.extend(self.ignored_pages.iter().map(|p| p.file.path.clone()));
  148. paths
  149. }
  150. /// Whether the page given belongs to that section
  151. pub fn is_child_page(&self, path: &PathBuf) -> bool {
  152. self.all_pages_path().contains(path)
  153. }
  154. /// Creates a vectors of asset URLs.
  155. fn serialize_assets(&self) -> Vec<String> {
  156. self.assets.iter()
  157. .filter_map(|asset| asset.file_name())
  158. .filter_map(|filename| filename.to_str())
  159. .map(|filename| self.path.clone() + filename)
  160. .collect()
  161. }
  162. }
  163. impl ser::Serialize for Section {
  164. fn serialize<S>(&self, serializer: S) -> StdResult<S::Ok, S::Error> where S: ser::Serializer {
  165. let mut state = serializer.serialize_struct("section", 13)?;
  166. state.serialize_field("content", &self.content)?;
  167. state.serialize_field("permalink", &self.permalink)?;
  168. state.serialize_field("title", &self.meta.title)?;
  169. state.serialize_field("description", &self.meta.description)?;
  170. state.serialize_field("extra", &self.meta.extra)?;
  171. state.serialize_field("path", &self.path)?;
  172. state.serialize_field("components", &self.components)?;
  173. state.serialize_field("permalink", &self.permalink)?;
  174. state.serialize_field("pages", &self.pages)?;
  175. state.serialize_field("subsections", &self.subsections)?;
  176. let (word_count, reading_time) = get_reading_analytics(&self.raw_content);
  177. state.serialize_field("word_count", &word_count)?;
  178. state.serialize_field("reading_time", &reading_time)?;
  179. state.serialize_field("toc", &self.toc)?;
  180. let assets = self.serialize_assets();
  181. state.serialize_field("assets", &assets)?;
  182. state.end()
  183. }
  184. }
  185. /// Used to create a default index section if there is no _index.md in the root content directory
  186. impl Default for Section {
  187. fn default() -> Section {
  188. Section {
  189. file: FileInfo::default(),
  190. meta: SectionFrontMatter::default(),
  191. path: "".to_string(),
  192. components: vec![],
  193. permalink: "".to_string(),
  194. raw_content: "".to_string(),
  195. assets: vec![],
  196. content: "".to_string(),
  197. pages: vec![],
  198. ignored_pages: vec![],
  199. subsections: vec![],
  200. toc: vec![],
  201. }
  202. }
  203. }
  204. #[cfg(test)]
  205. mod tests {
  206. use std::io::Write;
  207. use std::fs::{File, create_dir};
  208. use tempfile::tempdir;
  209. use globset::{Glob, GlobSetBuilder};
  210. use config::Config;
  211. use super::Section;
  212. #[test]
  213. fn section_with_assets_gets_right_info() {
  214. let tmp_dir = tempdir().expect("create temp dir");
  215. let path = tmp_dir.path();
  216. create_dir(&path.join("content")).expect("create content temp dir");
  217. create_dir(&path.join("content").join("posts")).expect("create posts temp dir");
  218. let nested_path = path.join("content").join("posts").join("with-assets");
  219. create_dir(&nested_path).expect("create nested temp dir");
  220. let mut f = File::create(nested_path.join("_index.md")).unwrap();
  221. f.write_all(b"+++\n+++\n").unwrap();
  222. File::create(nested_path.join("example.js")).unwrap();
  223. File::create(nested_path.join("graph.jpg")).unwrap();
  224. File::create(nested_path.join("fail.png")).unwrap();
  225. let res = Section::from_file(
  226. nested_path.join("_index.md").as_path(),
  227. &Config::default(),
  228. );
  229. assert!(res.is_ok());
  230. let section = res.unwrap();
  231. assert_eq!(section.assets.len(), 3);
  232. assert_eq!(section.permalink, "http://a-website.com/posts/with-assets/");
  233. }
  234. #[test]
  235. fn section_with_ignored_assets_filters_out_correct_files() {
  236. let tmp_dir = tempdir().expect("create temp dir");
  237. let path = tmp_dir.path();
  238. create_dir(&path.join("content")).expect("create content temp dir");
  239. create_dir(&path.join("content").join("posts")).expect("create posts temp dir");
  240. let nested_path = path.join("content").join("posts").join("with-assets");
  241. create_dir(&nested_path).expect("create nested temp dir");
  242. let mut f = File::create(nested_path.join("_index.md")).unwrap();
  243. f.write_all(b"+++\nslug=\"hey\"\n+++\n").unwrap();
  244. File::create(nested_path.join("example.js")).unwrap();
  245. File::create(nested_path.join("graph.jpg")).unwrap();
  246. File::create(nested_path.join("fail.png")).unwrap();
  247. let mut gsb = GlobSetBuilder::new();
  248. gsb.add(Glob::new("*.{js,png}").unwrap());
  249. let mut config = Config::default();
  250. config.ignored_content_globset = Some(gsb.build().unwrap());
  251. let res = Section::from_file(
  252. nested_path.join("_index.md").as_path(),
  253. &config,
  254. );
  255. assert!(res.is_ok());
  256. let page = res.unwrap();
  257. assert_eq!(page.assets.len(), 1);
  258. assert_eq!(page.assets[0].file_name().unwrap().to_str(), Some("graph.jpg"));
  259. }
  260. }