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.

561 lines
18KB

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