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.

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