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.

620 lines
19KB

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