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.

520 lines
16KB

  1. use std::collections::HashMap;
  2. use std::path::{Path, PathBuf};
  3. use chrono::Utc;
  4. use globset::{Glob, GlobSet, GlobSetBuilder};
  5. use syntect::parsing::{SyntaxSet, SyntaxSetBuilder};
  6. use toml;
  7. use toml::Value as Toml;
  8. use errors::Result;
  9. use highlighting::THEME_SET;
  10. use theme::Theme;
  11. use utils::fs::read_file_with_error;
  12. // We want a default base url for tests
  13. static DEFAULT_BASE_URL: &'static str = "http://a-website.com";
  14. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
  15. pub enum Mode {
  16. Build,
  17. Serve,
  18. Check,
  19. }
  20. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
  21. #[serde(default)]
  22. pub struct Language {
  23. /// The language code
  24. pub code: String,
  25. /// Whether to generate a RSS feed for that language, defaults to `false`
  26. pub rss: bool,
  27. }
  28. impl Default for Language {
  29. fn default() -> Language {
  30. Language { code: String::new(), rss: false }
  31. }
  32. }
  33. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
  34. #[serde(default)]
  35. pub struct Taxonomy {
  36. /// The name used in the URL, usually the plural
  37. pub name: String,
  38. /// If this is set, the list of individual taxonomy term page will be paginated
  39. /// by this much
  40. pub paginate_by: Option<usize>,
  41. pub paginate_path: Option<String>,
  42. /// Whether to generate a RSS feed only for each taxonomy term, defaults to false
  43. pub rss: bool,
  44. /// The language for that taxonomy, only used in multilingual sites.
  45. /// Defaults to the config `default_language` if not set
  46. pub lang: String,
  47. }
  48. impl Taxonomy {
  49. pub fn is_paginated(&self) -> bool {
  50. if let Some(paginate_by) = self.paginate_by {
  51. paginate_by > 0
  52. } else {
  53. false
  54. }
  55. }
  56. pub fn paginate_path(&self) -> &str {
  57. if let Some(ref path) = self.paginate_path {
  58. path
  59. } else {
  60. "page"
  61. }
  62. }
  63. }
  64. impl Default for Taxonomy {
  65. fn default() -> Taxonomy {
  66. Taxonomy {
  67. name: String::new(),
  68. paginate_by: None,
  69. paginate_path: None,
  70. rss: false,
  71. lang: String::new(),
  72. }
  73. }
  74. }
  75. #[derive(Clone, Debug, Serialize, Deserialize)]
  76. #[serde(default)]
  77. pub struct Config {
  78. /// Base URL of the site, the only required config argument
  79. pub base_url: String,
  80. /// Theme to use
  81. pub theme: Option<String>,
  82. /// Title of the site. Defaults to None
  83. pub title: Option<String>,
  84. /// Description of the site
  85. pub description: Option<String>,
  86. /// The language used in the site. Defaults to "en"
  87. pub default_language: String,
  88. /// The list of supported languages outside of the default one
  89. pub languages: Vec<Language>,
  90. /// Languages list and translated strings
  91. pub translations: HashMap<String, Toml>,
  92. /// Whether to highlight all code blocks found in markdown files. Defaults to false
  93. pub highlight_code: bool,
  94. /// Which themes to use for code highlighting. See Readme for supported themes
  95. /// Defaults to "base16-ocean-dark"
  96. pub highlight_theme: String,
  97. /// Whether to generate RSS. Defaults to false
  98. pub generate_rss: bool,
  99. /// The number of articles to include in the RSS feed. Defaults to including all items.
  100. pub rss_limit: Option<usize>,
  101. /// If set, files from static/ will be hardlinked instead of copied to the output dir.
  102. pub hard_link_static: bool,
  103. pub taxonomies: Vec<Taxonomy>,
  104. /// Whether to compile the `sass` directory and output the css files into the static folder
  105. pub compile_sass: bool,
  106. /// Whether to build the search index for the content
  107. pub build_search_index: bool,
  108. /// A list of file glob patterns to ignore when processing the content folder. Defaults to none.
  109. /// Had to remove the PartialEq derive because GlobSet does not implement it. No impact
  110. /// because it's unused anyway (who wants to sort Configs?).
  111. pub ignored_content: Vec<String>,
  112. #[serde(skip_serializing, skip_deserializing)] // not a typo, 2 are needed
  113. pub ignored_content_globset: Option<GlobSet>,
  114. /// The mode Zola is currently being ran on. Some logging/feature can differ depending on the
  115. /// command being used.
  116. #[serde(skip_serializing)]
  117. pub mode: Mode,
  118. /// A list of directories to search for additional `.sublime-syntax` files in.
  119. pub extra_syntaxes: Vec<String>,
  120. /// The compiled extra syntaxes into a syntax set
  121. #[serde(skip_serializing, skip_deserializing)] // not a typo, 2 are need
  122. pub extra_syntax_set: Option<SyntaxSet>,
  123. /// All user params set in [extra] in the config
  124. pub extra: HashMap<String, Toml>,
  125. /// Set automatically when instantiating the config. Used for cachebusting
  126. pub build_timestamp: Option<i64>,
  127. }
  128. impl Config {
  129. /// Parses a string containing TOML to our Config struct
  130. /// Any extra parameter will end up in the extra field
  131. pub fn parse(content: &str) -> Result<Config> {
  132. let mut config: Config = match toml::from_str(content) {
  133. Ok(c) => c,
  134. Err(e) => bail!(e),
  135. };
  136. if config.base_url.is_empty() || config.base_url == DEFAULT_BASE_URL {
  137. bail!("A base URL is required in config.toml with key `base_url`");
  138. }
  139. if !THEME_SET.themes.contains_key(&config.highlight_theme) {
  140. bail!("Highlight theme {} not available", config.highlight_theme)
  141. }
  142. config.build_timestamp = Some(Utc::now().timestamp());
  143. if !config.ignored_content.is_empty() {
  144. // Convert the file glob strings into a compiled glob set matcher. We want to do this once,
  145. // at program initialization, rather than for every page, for example. We arrange for the
  146. // globset matcher to always exist (even though it has to be an inside an Option at the
  147. // moment because of the TOML serializer); if the glob set is empty the `is_match` function
  148. // of the globber always returns false.
  149. let mut glob_set_builder = GlobSetBuilder::new();
  150. for pat in &config.ignored_content {
  151. let glob = match Glob::new(pat) {
  152. Ok(g) => g,
  153. Err(e) => bail!("Invalid ignored_content glob pattern: {}, error = {}", pat, e),
  154. };
  155. glob_set_builder.add(glob);
  156. }
  157. config.ignored_content_globset =
  158. Some(glob_set_builder.build().expect("Bad ignored_content in config file."));
  159. }
  160. for taxonomy in config.taxonomies.iter_mut() {
  161. if taxonomy.lang.is_empty() {
  162. taxonomy.lang = config.default_language.clone();
  163. }
  164. }
  165. Ok(config)
  166. }
  167. /// Parses a config file from the given path
  168. pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Config> {
  169. let path = path.as_ref();
  170. let file_name = path.file_name().unwrap();
  171. let content = read_file_with_error(
  172. path,
  173. &format!("No `{:?}` file found. Are you in the right directory?", file_name),
  174. )?;
  175. Config::parse(&content)
  176. }
  177. /// Attempt to load any extra syntax found in the extra syntaxes of the config
  178. pub fn load_extra_syntaxes(&mut self, base_path: &Path) -> Result<()> {
  179. if self.extra_syntaxes.is_empty() {
  180. return Ok(());
  181. }
  182. let mut ss = SyntaxSetBuilder::new();
  183. for dir in &self.extra_syntaxes {
  184. ss.add_from_folder(base_path.join(dir), true)?;
  185. }
  186. self.extra_syntax_set = Some(ss.build());
  187. Ok(())
  188. }
  189. /// Makes a url, taking into account that the base url might have a trailing slash
  190. pub fn make_permalink(&self, path: &str) -> String {
  191. let trailing_bit = if path.ends_with('/') || path.ends_with("rss.xml") || path.is_empty() {
  192. ""
  193. } else {
  194. "/"
  195. };
  196. // Index section with a base url that has a trailing slash
  197. if self.base_url.ends_with('/') && path == "/" {
  198. self.base_url.clone()
  199. } else if path == "/" {
  200. // index section with a base url that doesn't have a trailing slash
  201. format!("{}/", self.base_url)
  202. } else if self.base_url.ends_with('/') && path.starts_with('/') {
  203. format!("{}{}{}", self.base_url, &path[1..], trailing_bit)
  204. } else if self.base_url.ends_with('/') || path.starts_with('/') {
  205. format!("{}{}{}", self.base_url, path, trailing_bit)
  206. } else {
  207. format!("{}/{}{}", self.base_url, path, trailing_bit)
  208. }
  209. }
  210. /// Merges the extra data from the theme with the config extra data
  211. fn add_theme_extra(&mut self, theme: &Theme) -> Result<()> {
  212. // 3 pass merging
  213. // 1. save config to preserve user
  214. let original = self.extra.clone();
  215. // 2. inject theme extra values
  216. for (key, val) in &theme.extra {
  217. self.extra.entry(key.to_string()).or_insert_with(|| val.clone());
  218. }
  219. // 3. overwrite with original config
  220. for (key, val) in &original {
  221. self.extra.entry(key.to_string()).or_insert_with(|| val.clone());
  222. }
  223. Ok(())
  224. }
  225. /// Parse the theme.toml file and merges the extra data from the theme
  226. /// with the config extra data
  227. pub fn merge_with_theme(&mut self, path: &PathBuf) -> Result<()> {
  228. let theme = Theme::from_file(path)?;
  229. self.add_theme_extra(&theme)
  230. }
  231. /// Is this site using i18n?
  232. pub fn is_multilingual(&self) -> bool {
  233. !self.languages.is_empty()
  234. }
  235. /// Returns the codes of all additional languages
  236. pub fn languages_codes(&self) -> Vec<&str> {
  237. self.languages.iter().map(|l| l.code.as_ref()).collect()
  238. }
  239. pub fn is_in_build_mode(&self) -> bool {
  240. self.mode == Mode::Build
  241. }
  242. pub fn is_in_serve_mode(&self) -> bool {
  243. self.mode == Mode::Serve
  244. }
  245. pub fn is_in_check_mode(&self) -> bool {
  246. self.mode == Mode::Check
  247. }
  248. pub fn enable_serve_mode(&mut self) {
  249. self.mode = Mode::Serve;
  250. }
  251. pub fn enable_check_mode(&mut self) {
  252. self.mode = Mode::Check;
  253. // Disable syntax highlighting since the results won't be used
  254. // and this operation can be expensive.
  255. self.highlight_code = false;
  256. }
  257. }
  258. impl Default for Config {
  259. fn default() -> Config {
  260. Config {
  261. base_url: DEFAULT_BASE_URL.to_string(),
  262. title: None,
  263. description: None,
  264. theme: None,
  265. highlight_code: false,
  266. highlight_theme: "base16-ocean-dark".to_string(),
  267. default_language: "en".to_string(),
  268. languages: Vec::new(),
  269. generate_rss: false,
  270. rss_limit: None,
  271. hard_link_static: false,
  272. taxonomies: Vec::new(),
  273. compile_sass: false,
  274. mode: Mode::Build,
  275. build_search_index: false,
  276. ignored_content: Vec::new(),
  277. ignored_content_globset: None,
  278. translations: HashMap::new(),
  279. extra_syntaxes: Vec::new(),
  280. extra_syntax_set: None,
  281. extra: HashMap::new(),
  282. build_timestamp: Some(1),
  283. }
  284. }
  285. }
  286. #[cfg(test)]
  287. mod tests {
  288. use super::{Config, Theme};
  289. #[test]
  290. fn can_import_valid_config() {
  291. let config = r#"
  292. title = "My site"
  293. base_url = "https://replace-this-with-your-url.com"
  294. "#;
  295. let config = Config::parse(config).unwrap();
  296. assert_eq!(config.title.unwrap(), "My site".to_string());
  297. }
  298. #[test]
  299. fn errors_when_invalid_type() {
  300. let config = r#"
  301. title = 1
  302. base_url = "https://replace-this-with-your-url.com"
  303. "#;
  304. let config = Config::parse(config);
  305. assert!(config.is_err());
  306. }
  307. #[test]
  308. fn errors_when_missing_required_field() {
  309. // base_url is required
  310. let config = r#"
  311. title = ""
  312. "#;
  313. let config = Config::parse(config);
  314. assert!(config.is_err());
  315. }
  316. #[test]
  317. fn can_add_extra_values() {
  318. let config = r#"
  319. title = "My site"
  320. base_url = "https://replace-this-with-your-url.com"
  321. [extra]
  322. hello = "world"
  323. "#;
  324. let config = Config::parse(config);
  325. assert!(config.is_ok());
  326. assert_eq!(config.unwrap().extra.get("hello").unwrap().as_str().unwrap(), "world");
  327. }
  328. #[test]
  329. fn can_make_url_index_page_with_non_trailing_slash_url() {
  330. let mut config = Config::default();
  331. config.base_url = "http://vincent.is".to_string();
  332. assert_eq!(config.make_permalink(""), "http://vincent.is/");
  333. }
  334. #[test]
  335. fn can_make_url_index_page_with_railing_slash_url() {
  336. let mut config = Config::default();
  337. config.base_url = "http://vincent.is/".to_string();
  338. assert_eq!(config.make_permalink(""), "http://vincent.is/");
  339. }
  340. #[test]
  341. fn can_make_url_with_non_trailing_slash_base_url() {
  342. let mut config = Config::default();
  343. config.base_url = "http://vincent.is".to_string();
  344. assert_eq!(config.make_permalink("hello"), "http://vincent.is/hello/");
  345. }
  346. #[test]
  347. fn can_make_url_with_trailing_slash_path() {
  348. let mut config = Config::default();
  349. config.base_url = "http://vincent.is/".to_string();
  350. assert_eq!(config.make_permalink("/hello"), "http://vincent.is/hello/");
  351. }
  352. #[test]
  353. fn can_make_url_with_localhost() {
  354. let mut config = Config::default();
  355. config.base_url = "http://127.0.0.1:1111".to_string();
  356. assert_eq!(config.make_permalink("/tags/rust"), "http://127.0.0.1:1111/tags/rust/");
  357. }
  358. // https://github.com/Keats/gutenberg/issues/486
  359. #[test]
  360. fn doesnt_add_trailing_slash_to_rss() {
  361. let mut config = Config::default();
  362. config.base_url = "http://vincent.is/".to_string();
  363. assert_eq!(config.make_permalink("rss.xml"), "http://vincent.is/rss.xml");
  364. }
  365. #[test]
  366. fn can_merge_with_theme_data_and_preserve_config_value() {
  367. let config_str = r#"
  368. title = "My site"
  369. base_url = "https://replace-this-with-your-url.com"
  370. [extra]
  371. hello = "world"
  372. "#;
  373. let mut config = Config::parse(config_str).unwrap();
  374. let theme_str = r#"
  375. [extra]
  376. hello = "foo"
  377. a_value = 10
  378. "#;
  379. let theme = Theme::parse(theme_str).unwrap();
  380. assert!(config.add_theme_extra(&theme).is_ok());
  381. let extra = config.extra;
  382. assert_eq!(extra["hello"].as_str().unwrap(), "world".to_string());
  383. assert_eq!(extra["a_value"].as_integer().unwrap(), 10);
  384. }
  385. #[test]
  386. fn can_use_language_configuration() {
  387. let config = r#"
  388. base_url = "https://remplace-par-ton-url.fr"
  389. default_language = "fr"
  390. [translations]
  391. [translations.fr]
  392. title = "Un titre"
  393. [translations.en]
  394. title = "A title"
  395. "#;
  396. let config = Config::parse(config);
  397. assert!(config.is_ok());
  398. let translations = config.unwrap().translations;
  399. assert_eq!(translations["fr"]["title"].as_str().unwrap(), "Un titre");
  400. assert_eq!(translations["en"]["title"].as_str().unwrap(), "A title");
  401. }
  402. #[test]
  403. fn missing_ignored_content_results_in_empty_vector_and_empty_globset() {
  404. let config_str = r#"
  405. title = "My site"
  406. base_url = "example.com"
  407. "#;
  408. let config = Config::parse(config_str).unwrap();
  409. let v = config.ignored_content;
  410. assert_eq!(v.len(), 0);
  411. assert!(config.ignored_content_globset.is_none());
  412. }
  413. #[test]
  414. fn empty_ignored_content_results_in_empty_vector_and_empty_globset() {
  415. let config_str = r#"
  416. title = "My site"
  417. base_url = "example.com"
  418. ignored_content = []
  419. "#;
  420. let config = Config::parse(config_str).unwrap();
  421. assert_eq!(config.ignored_content.len(), 0);
  422. assert!(config.ignored_content_globset.is_none());
  423. }
  424. #[test]
  425. fn non_empty_ignored_content_results_in_vector_of_patterns_and_configured_globset() {
  426. let config_str = r#"
  427. title = "My site"
  428. base_url = "example.com"
  429. ignored_content = ["*.{graphml,iso}", "*.py?"]
  430. "#;
  431. let config = Config::parse(config_str).unwrap();
  432. let v = config.ignored_content;
  433. assert_eq!(v, vec!["*.{graphml,iso}", "*.py?"]);
  434. let g = config.ignored_content_globset.unwrap();
  435. assert_eq!(g.len(), 2);
  436. assert!(g.is_match("foo.graphml"));
  437. assert!(g.is_match("foo.iso"));
  438. assert!(!g.is_match("foo.png"));
  439. assert!(g.is_match("foo.py2"));
  440. assert!(g.is_match("foo.py3"));
  441. assert!(!g.is_match("foo.py"));
  442. }
  443. }