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.

623 lines
20KB

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