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.

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