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.

330 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. /// How many words in the raw content
  42. pub word_count: Option<usize>,
  43. /// How long would it take to read the raw content.
  44. /// See `get_reading_analytics` on how it is calculated
  45. pub reading_time: Option<usize>,
  46. }
  47. impl Section {
  48. pub fn new<P: AsRef<Path>>(file_path: P, meta: SectionFrontMatter) -> Section {
  49. let file_path = file_path.as_ref();
  50. Section {
  51. file: FileInfo::new_section(file_path),
  52. meta,
  53. path: "".to_string(),
  54. components: vec![],
  55. permalink: "".to_string(),
  56. raw_content: "".to_string(),
  57. assets: vec![],
  58. content: "".to_string(),
  59. pages: vec![],
  60. ignored_pages: vec![],
  61. subsections: vec![],
  62. toc: vec![],
  63. word_count: None,
  64. reading_time: None,
  65. }
  66. }
  67. pub fn parse(file_path: &Path, content: &str, config: &Config) -> Result<Section> {
  68. let (meta, content) = split_section_content(file_path, content)?;
  69. let mut section = Section::new(file_path, meta);
  70. section.raw_content = content.clone();
  71. let (word_count, reading_time) = get_reading_analytics(&section.raw_content);
  72. section.word_count = Some(word_count);
  73. section.reading_time = Some(reading_time);
  74. section.path = format!("{}/", section.file.components.join("/"));
  75. section.components = section.path.split('/')
  76. .map(|p| p.to_string())
  77. .filter(|p| !p.is_empty())
  78. .collect::<Vec<_>>();
  79. section.permalink = config.make_permalink(&section.path);
  80. Ok(section)
  81. }
  82. /// Read and parse a .md file into a Page struct
  83. pub fn from_file<P: AsRef<Path>>(path: P, config: &Config) -> Result<Section> {
  84. let path = path.as_ref();
  85. let content = read_file(path)?;
  86. let mut section = Section::parse(path, &content, config)?;
  87. let parent_dir = path.parent().unwrap();
  88. let assets = find_related_assets(parent_dir);
  89. if let Some(ref globset) = config.ignored_content_globset {
  90. // `find_related_assets` only scans the immediate directory (it is not recursive) so our
  91. // filtering only needs to work against the file_name component, not the full suffix. If
  92. // `find_related_assets` was changed to also return files in subdirectories, we could
  93. // use `PathBuf.strip_prefix` to remove the parent directory and then glob-filter
  94. // against the remaining path. Note that the current behaviour effectively means that
  95. // the `ignored_content` setting in the config file is limited to single-file glob
  96. // patterns (no "**" patterns).
  97. section.assets = assets.into_iter()
  98. .filter(|path|
  99. match path.file_name() {
  100. None => true,
  101. Some(file) => !globset.is_match(file)
  102. }
  103. ).collect();
  104. } else {
  105. section.assets = assets;
  106. }
  107. Ok(section)
  108. }
  109. pub fn get_template_name(&self) -> String {
  110. match self.meta.template {
  111. Some(ref l) => l.to_string(),
  112. None => {
  113. if self.is_index() {
  114. return "index.html".to_string();
  115. }
  116. "section.html".to_string()
  117. }
  118. }
  119. }
  120. /// We need access to all pages url to render links relative to content
  121. /// so that can't happen at the same time as parsing
  122. pub fn render_markdown(&mut self, permalinks: &HashMap<String, String>, tera: &Tera, config: &Config, base_path: &Path) -> Result<()> {
  123. let mut context = RenderContext::new(
  124. tera,
  125. config,
  126. &self.permalink,
  127. permalinks,
  128. base_path,
  129. self.meta.insert_anchor_links,
  130. );
  131. context.tera_context.insert("section", self);
  132. let res = render_content(&self.raw_content, &context)
  133. .chain_err(|| format!("Failed to render content of {}", self.file.path.display()))?;
  134. self.content = res.body;
  135. self.toc = res.toc;
  136. Ok(())
  137. }
  138. /// Renders the page using the default layout, unless specified in front-matter
  139. pub fn render_html(&self, tera: &Tera, config: &Config) -> Result<String> {
  140. let tpl_name = self.get_template_name();
  141. let mut context = TeraContext::new();
  142. context.insert("config", config);
  143. context.insert("section", self);
  144. context.insert("current_url", &self.permalink);
  145. context.insert("current_path", &self.path);
  146. render_template(&tpl_name, tera, &context, &config.theme)
  147. .chain_err(|| format!("Failed to render section '{}'", self.file.path.display()))
  148. }
  149. /// Is this the index section?
  150. pub fn is_index(&self) -> bool {
  151. self.file.components.is_empty()
  152. }
  153. /// Returns all the paths of the pages belonging to that section
  154. pub fn all_pages_path(&self) -> Vec<PathBuf> {
  155. let mut paths = vec![];
  156. paths.extend(self.pages.iter().map(|p| p.file.path.clone()));
  157. paths.extend(self.ignored_pages.iter().map(|p| p.file.path.clone()));
  158. paths
  159. }
  160. /// Whether the page given belongs to that section
  161. pub fn is_child_page(&self, path: &PathBuf) -> bool {
  162. self.all_pages_path().contains(path)
  163. }
  164. /// Creates a vectors of asset URLs.
  165. fn serialize_assets(&self) -> Vec<String> {
  166. self.assets.iter()
  167. .filter_map(|asset| asset.file_name())
  168. .filter_map(|filename| filename.to_str())
  169. .map(|filename| self.path.clone() + filename)
  170. .collect()
  171. }
  172. pub fn clone_without_pages(&self) -> Section {
  173. let mut subsections = vec![];
  174. for subsection in &self.subsections {
  175. subsections.push(subsection.clone_without_pages());
  176. }
  177. Section {
  178. file: self.file.clone(),
  179. meta: self.meta.clone(),
  180. path: self.path.clone(),
  181. components: self.components.clone(),
  182. permalink: self.permalink.clone(),
  183. raw_content: self.raw_content.clone(),
  184. content: self.content.clone(),
  185. assets: self.assets.clone(),
  186. toc: self.toc.clone(),
  187. subsections,
  188. pages: vec![],
  189. ignored_pages: vec![],
  190. word_count: self.word_count,
  191. reading_time: self.reading_time,
  192. }
  193. }
  194. }
  195. impl ser::Serialize for Section {
  196. fn serialize<S>(&self, serializer: S) -> StdResult<S::Ok, S::Error> where S: ser::Serializer {
  197. let mut state = serializer.serialize_struct("section", 13)?;
  198. state.serialize_field("content", &self.content)?;
  199. state.serialize_field("permalink", &self.permalink)?;
  200. state.serialize_field("title", &self.meta.title)?;
  201. state.serialize_field("description", &self.meta.description)?;
  202. state.serialize_field("extra", &self.meta.extra)?;
  203. state.serialize_field("path", &self.path)?;
  204. state.serialize_field("components", &self.components)?;
  205. state.serialize_field("permalink", &self.permalink)?;
  206. state.serialize_field("pages", &self.pages)?;
  207. state.serialize_field("subsections", &self.subsections)?;
  208. state.serialize_field("word_count", &self.word_count)?;
  209. state.serialize_field("reading_time", &self.reading_time)?;
  210. state.serialize_field("toc", &self.toc)?;
  211. let assets = self.serialize_assets();
  212. state.serialize_field("assets", &assets)?;
  213. state.end()
  214. }
  215. }
  216. /// Used to create a default index section if there is no _index.md in the root content directory
  217. impl Default for Section {
  218. fn default() -> Section {
  219. Section {
  220. file: FileInfo::default(),
  221. meta: SectionFrontMatter::default(),
  222. path: "".to_string(),
  223. components: vec![],
  224. permalink: "".to_string(),
  225. raw_content: "".to_string(),
  226. assets: vec![],
  227. content: "".to_string(),
  228. pages: vec![],
  229. ignored_pages: vec![],
  230. subsections: vec![],
  231. toc: vec![],
  232. reading_time: None,
  233. word_count: None,
  234. }
  235. }
  236. }
  237. #[cfg(test)]
  238. mod tests {
  239. use std::io::Write;
  240. use std::fs::{File, create_dir};
  241. use tempfile::tempdir;
  242. use globset::{Glob, GlobSetBuilder};
  243. use config::Config;
  244. use super::Section;
  245. #[test]
  246. fn section_with_assets_gets_right_info() {
  247. let tmp_dir = tempdir().expect("create temp dir");
  248. let path = tmp_dir.path();
  249. create_dir(&path.join("content")).expect("create content temp dir");
  250. create_dir(&path.join("content").join("posts")).expect("create posts temp dir");
  251. let nested_path = path.join("content").join("posts").join("with-assets");
  252. create_dir(&nested_path).expect("create nested temp dir");
  253. let mut f = File::create(nested_path.join("_index.md")).unwrap();
  254. f.write_all(b"+++\n+++\n").unwrap();
  255. File::create(nested_path.join("example.js")).unwrap();
  256. File::create(nested_path.join("graph.jpg")).unwrap();
  257. File::create(nested_path.join("fail.png")).unwrap();
  258. let res = Section::from_file(
  259. nested_path.join("_index.md").as_path(),
  260. &Config::default(),
  261. );
  262. assert!(res.is_ok());
  263. let section = res.unwrap();
  264. assert_eq!(section.assets.len(), 3);
  265. assert_eq!(section.permalink, "http://a-website.com/posts/with-assets/");
  266. }
  267. #[test]
  268. fn section_with_ignored_assets_filters_out_correct_files() {
  269. let tmp_dir = tempdir().expect("create temp dir");
  270. let path = tmp_dir.path();
  271. create_dir(&path.join("content")).expect("create content temp dir");
  272. create_dir(&path.join("content").join("posts")).expect("create posts temp dir");
  273. let nested_path = path.join("content").join("posts").join("with-assets");
  274. create_dir(&nested_path).expect("create nested temp dir");
  275. let mut f = File::create(nested_path.join("_index.md")).unwrap();
  276. f.write_all(b"+++\nslug=\"hey\"\n+++\n").unwrap();
  277. File::create(nested_path.join("example.js")).unwrap();
  278. File::create(nested_path.join("graph.jpg")).unwrap();
  279. File::create(nested_path.join("fail.png")).unwrap();
  280. let mut gsb = GlobSetBuilder::new();
  281. gsb.add(Glob::new("*.{js,png}").unwrap());
  282. let mut config = Config::default();
  283. config.ignored_content_globset = Some(gsb.build().unwrap());
  284. let res = Section::from_file(
  285. nested_path.join("_index.md").as_path(),
  286. &config,
  287. );
  288. assert!(res.is_ok());
  289. let page = res.unwrap();
  290. assert_eq!(page.assets.len(), 1);
  291. assert_eq!(page.assets[0].file_name().unwrap().to_str(), Some("graph.jpg"));
  292. }
  293. }