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.

317 lines
12KB

  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. pub fn clone_without_pages(&self) -> Section {
  163. let mut subsections = vec![];
  164. for subsection in &self.subsections {
  165. subsections.push(subsection.clone_without_pages());
  166. }
  167. Section {
  168. file: self.file.clone(),
  169. meta: self.meta.clone(),
  170. path: self.path.clone(),
  171. components: self.components.clone(),
  172. permalink: self.permalink.clone(),
  173. raw_content: self.raw_content.clone(),
  174. content: self.content.clone(),
  175. assets: self.assets.clone(),
  176. toc: self.toc.clone(),
  177. subsections,
  178. pages: vec![],
  179. ignored_pages: vec![],
  180. }
  181. }
  182. }
  183. impl ser::Serialize for Section {
  184. fn serialize<S>(&self, serializer: S) -> StdResult<S::Ok, S::Error> where S: ser::Serializer {
  185. let mut state = serializer.serialize_struct("section", 13)?;
  186. state.serialize_field("content", &self.content)?;
  187. state.serialize_field("permalink", &self.permalink)?;
  188. state.serialize_field("title", &self.meta.title)?;
  189. state.serialize_field("description", &self.meta.description)?;
  190. state.serialize_field("extra", &self.meta.extra)?;
  191. state.serialize_field("path", &self.path)?;
  192. state.serialize_field("components", &self.components)?;
  193. state.serialize_field("permalink", &self.permalink)?;
  194. state.serialize_field("pages", &self.pages)?;
  195. state.serialize_field("subsections", &self.subsections)?;
  196. let (word_count, reading_time) = get_reading_analytics(&self.raw_content);
  197. state.serialize_field("word_count", &word_count)?;
  198. state.serialize_field("reading_time", &reading_time)?;
  199. state.serialize_field("toc", &self.toc)?;
  200. let assets = self.serialize_assets();
  201. state.serialize_field("assets", &assets)?;
  202. state.end()
  203. }
  204. }
  205. /// Used to create a default index section if there is no _index.md in the root content directory
  206. impl Default for Section {
  207. fn default() -> Section {
  208. Section {
  209. file: FileInfo::default(),
  210. meta: SectionFrontMatter::default(),
  211. path: "".to_string(),
  212. components: vec![],
  213. permalink: "".to_string(),
  214. raw_content: "".to_string(),
  215. assets: vec![],
  216. content: "".to_string(),
  217. pages: vec![],
  218. ignored_pages: vec![],
  219. subsections: vec![],
  220. toc: vec![],
  221. }
  222. }
  223. }
  224. #[cfg(test)]
  225. mod tests {
  226. use std::io::Write;
  227. use std::fs::{File, create_dir};
  228. use tempfile::tempdir;
  229. use globset::{Glob, GlobSetBuilder};
  230. use config::Config;
  231. use super::Section;
  232. #[test]
  233. fn section_with_assets_gets_right_info() {
  234. let tmp_dir = tempdir().expect("create temp dir");
  235. let path = tmp_dir.path();
  236. create_dir(&path.join("content")).expect("create content temp dir");
  237. create_dir(&path.join("content").join("posts")).expect("create posts temp dir");
  238. let nested_path = path.join("content").join("posts").join("with-assets");
  239. create_dir(&nested_path).expect("create nested temp dir");
  240. let mut f = File::create(nested_path.join("_index.md")).unwrap();
  241. f.write_all(b"+++\n+++\n").unwrap();
  242. File::create(nested_path.join("example.js")).unwrap();
  243. File::create(nested_path.join("graph.jpg")).unwrap();
  244. File::create(nested_path.join("fail.png")).unwrap();
  245. let res = Section::from_file(
  246. nested_path.join("_index.md").as_path(),
  247. &Config::default(),
  248. );
  249. assert!(res.is_ok());
  250. let section = res.unwrap();
  251. assert_eq!(section.assets.len(), 3);
  252. assert_eq!(section.permalink, "http://a-website.com/posts/with-assets/");
  253. }
  254. #[test]
  255. fn section_with_ignored_assets_filters_out_correct_files() {
  256. let tmp_dir = tempdir().expect("create temp dir");
  257. let path = tmp_dir.path();
  258. create_dir(&path.join("content")).expect("create content temp dir");
  259. create_dir(&path.join("content").join("posts")).expect("create posts temp dir");
  260. let nested_path = path.join("content").join("posts").join("with-assets");
  261. create_dir(&nested_path).expect("create nested temp dir");
  262. let mut f = File::create(nested_path.join("_index.md")).unwrap();
  263. f.write_all(b"+++\nslug=\"hey\"\n+++\n").unwrap();
  264. File::create(nested_path.join("example.js")).unwrap();
  265. File::create(nested_path.join("graph.jpg")).unwrap();
  266. File::create(nested_path.join("fail.png")).unwrap();
  267. let mut gsb = GlobSetBuilder::new();
  268. gsb.add(Glob::new("*.{js,png}").unwrap());
  269. let mut config = Config::default();
  270. config.ignored_content_globset = Some(gsb.build().unwrap());
  271. let res = Section::from_file(
  272. nested_path.join("_index.md").as_path(),
  273. &config,
  274. );
  275. assert!(res.is_ok());
  276. let page = res.unwrap();
  277. assert_eq!(page.assets.len(), 1);
  278. assert_eq!(page.assets[0].file_name().unwrap().to_str(), Some("graph.jpg"));
  279. }
  280. }