TIL about serde default

This commit is contained in:
Vincent Prouillet 2018-03-12 20:11:03 +01:00
parent bb5cdc0b6c
commit b3b2527c5d
8 changed files with 121 additions and 122 deletions

View file

@ -24,7 +24,12 @@ mod theme;
use theme::Theme; use theme::Theme;
// We want a default base url for tests
static DEFAULT_BASE_URL: &'static str = "http://a-website.com";
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Config { pub struct Config {
/// Base URL of the site, the only required config argument /// Base URL of the site, the only required config argument
pub base_url: String, pub base_url: String,
@ -33,48 +38,47 @@ pub struct Config {
pub theme: Option<String>, pub theme: Option<String>,
/// Title of the site. Defaults to None /// Title of the site. Defaults to None
pub title: Option<String>, pub title: Option<String>,
/// Whether to highlight all code blocks found in markdown files. Defaults to false
pub highlight_code: Option<bool>,
/// Which themes to use for code highlighting. See Readme for supported themes
pub highlight_theme: Option<String>,
/// Description of the site /// Description of the site
pub description: Option<String>, pub description: Option<String>,
/// The language used in the site. Defaults to "en" /// The language used in the site. Defaults to "en"
pub default_language: Option<String>, pub default_language: String,
/// Languages list and translated strings
pub translations: HashMap<String, Toml>,
/// Whether to highlight all code blocks found in markdown files. Defaults to false
pub highlight_code: bool,
/// Which themes to use for code highlighting. See Readme for supported themes
/// Defaults to "base16-ocean-dark"
pub highlight_theme: String,
/// Whether to generate RSS. Defaults to false /// Whether to generate RSS. Defaults to false
pub generate_rss: Option<bool>, pub generate_rss: bool,
/// The number of articles to include in the RSS feed. Defaults to unlimited /// The number of articles to include in the RSS feed. Defaults to 10_000
pub rss_limit: Option<usize>, pub rss_limit: usize,
/// Whether to generate tags and individual tag pages if some pages have them. Defaults to true /// Whether to generate tags and individual tag pages if some pages have them. Defaults to true
pub generate_tags_pages: Option<bool>, pub generate_tags_pages: bool,
/// Whether to generate categories and individual tag categories if some pages have them. Defaults to true /// Whether to generate categories and individual tag categories if some pages have them. Defaults to true
pub generate_categories_pages: Option<bool>, pub generate_categories_pages: bool,
/// Whether to compile the `sass` directory and output the css files into the static folder /// Whether to compile the `sass` directory and output the css files into the static folder
pub compile_sass: Option<bool>, pub compile_sass: bool,
/// Whether to build the search index for the content
pub build_search_index: bool,
/// A list of file glob patterns to ignore when processing the content folder. Defaults to none. /// A list of file glob patterns to ignore when processing the content folder. Defaults to none.
/// Had to remove the PartialEq derive because GlobSet does not implement it. No impact /// Had to remove the PartialEq derive because GlobSet does not implement it. No impact
/// because it's unused anyway (who wants to sort Configs?). /// because it's unused anyway (who wants to sort Configs?).
pub ignored_content: Option<Vec<String>>, pub ignored_content: Vec<String>,
#[serde(skip_serializing, skip_deserializing)] // not a typo, 2 are needed #[serde(skip_serializing, skip_deserializing)] // not a typo, 2 are needed
pub ignored_content_globber: Option<GlobSet>, pub ignored_content_globset: Option<GlobSet>,
/// Languages list and translated strings
pub translations: Option<HashMap<String, Toml>>,
/// All user params set in [extra] in the config /// All user params set in [extra] in the config
pub extra: Option<HashMap<String, Toml>>, pub extra: HashMap<String, Toml>,
/// Set automatically when instantiating the config. Used for cachebusting /// Set automatically when instantiating the config. Used for cachebusting
pub build_timestamp: Option<i64>, pub build_timestamp: Option<i64>,
} }
macro_rules! set_default {
($key: expr, $default: expr) => {
if $key.is_none() {
$key = Some($default);
}
}
}
impl Config { impl Config {
/// Parses a string containing TOML to our Config struct /// Parses a string containing TOML to our Config struct
@ -85,45 +89,33 @@ impl Config {
Err(e) => bail!(e) Err(e) => bail!(e)
}; };
set_default!(config.default_language, "en".to_string()); if config.base_url.is_empty() || config.base_url == DEFAULT_BASE_URL {
set_default!(config.highlight_code, false); bail!("A base URL is required in config.toml with key `base_url`");
set_default!(config.generate_rss, false); }
set_default!(config.rss_limit, 20);
set_default!(config.generate_tags_pages, false);
set_default!(config.generate_categories_pages, false);
set_default!(config.compile_sass, false);
set_default!(config.ignored_content, Vec::new());
set_default!(config.translations, HashMap::new());
set_default!(config.extra, HashMap::new());
match config.highlight_theme { if !THEME_SET.themes.contains_key(&config.highlight_theme) {
Some(ref t) => { bail!("Highlight theme {} not available", config.highlight_theme)
if !THEME_SET.themes.contains_key(t) { }
bail!("Theme {} not available", t)
}
}
None => config.highlight_theme = Some("base16-ocean-dark".to_string())
};
config.build_timestamp = Some(Utc::now().timestamp()); config.build_timestamp = Some(Utc::now().timestamp());
// Convert the file glob strings into a compiled glob set matcher. We want to do this once,
// at program initialization, rather than for every page, for example. We arrange for the
// globset matcher to always exist (even though it has to be an inside an Option at the
// moment because of the TOML serializer); if the glob set is empty the `is_match` function
// of the globber always returns false.
let mut glob_set_builder = GlobSetBuilder::new();
if let Some(ref v) = config.ignored_content { if !config.ignored_content.is_empty() {
for pat in v { // Convert the file glob strings into a compiled glob set matcher. We want to do this once,
// at program initialization, rather than for every page, for example. We arrange for the
// globset matcher to always exist (even though it has to be an inside an Option at the
// moment because of the TOML serializer); if the glob set is empty the `is_match` function
// of the globber always returns false.
let mut glob_set_builder = GlobSetBuilder::new();
for pat in &config.ignored_content {
let glob = match Glob::new(pat) { let glob = match Glob::new(pat) {
Ok(g) => g, Ok(g) => g,
Err(e) => bail!("Invalid ignored_content glob pattern: {}, error = {}", pat, e) Err(e) => bail!("Invalid ignored_content glob pattern: {}, error = {}", pat, e)
}; };
glob_set_builder.add(glob); glob_set_builder.add(glob);
} }
config.ignored_content_globset = Some(glob_set_builder.build().expect("Bad ignored_content in config file."));
} }
config.ignored_content_globber = Some(glob_set_builder.build().expect("Bad ignored_content in config file."));
Ok(config) Ok(config)
} }
@ -161,19 +153,17 @@ impl Config {
/// Merges the extra data from the theme with the config extra data /// Merges the extra data from the theme with the config extra data
fn add_theme_extra(&mut self, theme: &Theme) -> Result<()> { fn add_theme_extra(&mut self, theme: &Theme) -> Result<()> {
if let Some(ref mut config_extra) = self.extra { // 3 pass merging
// 3 pass merging // 1. save config to preserve user
// 1. save config to preserve user let original = self.extra.clone();
let original = config_extra.clone(); // 2. inject theme extra values
// 2. inject theme extra values for (key, val) in &theme.extra {
for (key, val) in &theme.extra { self.extra.entry(key.to_string()).or_insert_with(|| val.clone());
config_extra.entry(key.to_string()).or_insert_with(|| val.clone()); }
}
// 3. overwrite with original config // 3. overwrite with original config
for (key, val) in &original { for (key, val) in &original {
config_extra.entry(key.to_string()).or_insert_with(|| val.clone()); self.extra.entry(key.to_string()).or_insert_with(|| val.clone());
}
} }
Ok(()) Ok(())
@ -187,27 +177,26 @@ impl Config {
} }
} }
/// Exists only for testing purposes
#[doc(hidden)]
impl Default for Config { impl Default for Config {
fn default() -> Config { fn default() -> Config {
Config { Config {
title: Some("".to_string()), base_url: DEFAULT_BASE_URL.to_string(),
theme: None, title: None,
base_url: "http://a-website.com/".to_string(),
highlight_code: Some(true),
highlight_theme: Some("base16-ocean-dark".to_string()),
description: None, description: None,
default_language: Some("en".to_string()), theme: None,
generate_rss: Some(false), highlight_code: true,
rss_limit: Some(10_000), highlight_theme: "base16-ocean-dark".to_string(),
generate_tags_pages: Some(true), default_language: "en".to_string(),
generate_categories_pages: Some(true), generate_rss: false,
compile_sass: Some(false), rss_limit: 10_000,
ignored_content: Some(Vec::new()), generate_tags_pages: true,
ignored_content_globber: Some(GlobSetBuilder::new().build().unwrap()), generate_categories_pages: true,
translations: None, compile_sass: false,
extra: None, build_search_index: false,
ignored_content: Vec::new(),
ignored_content_globset: None,
translations: HashMap::new(),
extra: HashMap::new(),
build_timestamp: Some(1), build_timestamp: Some(1),
} }
} }
@ -277,7 +266,7 @@ hello = "world"
let config = Config::parse(config); let config = Config::parse(config);
assert!(config.is_ok()); assert!(config.is_ok());
assert_eq!(config.unwrap().extra.unwrap().get("hello").unwrap().as_str().unwrap(), "world"); assert_eq!(config.unwrap().extra.get("hello").unwrap().as_str().unwrap(), "world");
} }
#[test] #[test]
@ -333,7 +322,7 @@ a_value = 10
"#; "#;
let theme = Theme::parse(theme_str).unwrap(); let theme = Theme::parse(theme_str).unwrap();
assert!(config.add_theme_extra(&theme).is_ok()); assert!(config.add_theme_extra(&theme).is_ok());
let extra = config.extra.unwrap(); let extra = config.extra;
assert_eq!(extra["hello"].as_str().unwrap(), "world".to_string()); assert_eq!(extra["hello"].as_str().unwrap(), "world".to_string());
assert_eq!(extra["a_value"].as_integer().unwrap(), 10); assert_eq!(extra["a_value"].as_integer().unwrap(), 10);
} }
@ -355,26 +344,26 @@ title = "A title"
let config = Config::parse(config); let config = Config::parse(config);
assert!(config.is_ok()); assert!(config.is_ok());
let translations = config.unwrap().translations.unwrap(); let translations = config.unwrap().translations;
assert_eq!(translations["fr"]["title"].as_str().unwrap(), "Un titre"); assert_eq!(translations["fr"]["title"].as_str().unwrap(), "Un titre");
assert_eq!(translations["en"]["title"].as_str().unwrap(), "A title"); assert_eq!(translations["en"]["title"].as_str().unwrap(), "A title");
} }
#[test] #[test]
fn missing_ignored_content_results_in_empty_vector_and_empty_globber() { fn missing_ignored_content_results_in_empty_vector_and_empty_globset() {
let config_str = r#" let config_str = r#"
title = "My site" title = "My site"
base_url = "example.com" base_url = "example.com"
"#; "#;
let config = Config::parse(config_str).unwrap(); let config = Config::parse(config_str).unwrap();
let v = config.ignored_content.unwrap(); let v = config.ignored_content;
assert_eq!(v.len(), 0); assert_eq!(v.len(), 0);
assert!(config.ignored_content_globber.unwrap().is_empty()); assert!(config.ignored_content_globset.is_none());
} }
#[test] #[test]
fn empty_ignored_content_results_in_empty_vector_and_empty_globber() { fn empty_ignored_content_results_in_empty_vector_and_empty_globset() {
let config_str = r#" let config_str = r#"
title = "My site" title = "My site"
base_url = "example.com" base_url = "example.com"
@ -382,12 +371,12 @@ ignored_content = []
"#; "#;
let config = Config::parse(config_str).unwrap(); let config = Config::parse(config_str).unwrap();
assert_eq!(config.ignored_content.unwrap().len(), 0); assert_eq!(config.ignored_content.len(), 0);
assert!(config.ignored_content_globber.unwrap().is_empty()); assert!(config.ignored_content_globset.is_none());
} }
#[test] #[test]
fn non_empty_ignored_content_results_in_vector_of_patterns_and_configured_globber() { fn non_empty_ignored_content_results_in_vector_of_patterns_and_configured_globset() {
let config_str = r#" let config_str = r#"
title = "My site" title = "My site"
base_url = "example.com" base_url = "example.com"
@ -395,10 +384,10 @@ ignored_content = ["*.{graphml,iso}", "*.py?"]
"#; "#;
let config = Config::parse(config_str).unwrap(); let config = Config::parse(config_str).unwrap();
let v = config.ignored_content.unwrap(); let v = config.ignored_content;
assert_eq!(v, vec!["*.{graphml,iso}", "*.py?"]); assert_eq!(v, vec!["*.{graphml,iso}", "*.py?"]);
let g = config.ignored_content_globber.unwrap(); let g = config.ignored_content_globset.unwrap();
assert_eq!(g.len(), 2); assert_eq!(g.len(), 2);
assert!(g.is_match("foo.graphml")); assert!(g.is_match("foo.graphml"));
assert!(g.is_match("foo.iso")); assert!(g.is_match("foo.iso"));

View file

@ -130,23 +130,27 @@ impl Page {
let mut page = Page::parse(path, &content, config)?; let mut page = Page::parse(path, &content, config)?;
if page.file.name == "index" { if page.file.name == "index" {
// `find_related_assets` only scans the immediate directory (it is not recursive) so our
// filtering only needs to work against the file_name component, not the full suffix. If
// `find_related_assets` was changed to also return files in subdirectories, we could
// use `PathBuf.strip_prefix` to remove the parent directory and then glob-filter
// against the remaining path. Note that the current behaviour effectively means that
// the `ignored_content` setting in the config file is limited to single-file glob
// patterns (no "**" patterns).
let globber = config.ignored_content_globber.as_ref().unwrap();
let parent_dir = path.parent().unwrap(); let parent_dir = path.parent().unwrap();
page.assets = find_related_assets(parent_dir).into_iter() let assets = find_related_assets(parent_dir);
.filter(|path|
match path.file_name() {
None => true,
Some(file) => !globber.is_match(file)
}
).collect();
if let Some(ref globset) = config.ignored_content_globset {
// `find_related_assets` only scans the immediate directory (it is not recursive) so our
// filtering only needs to work against the file_name component, not the full suffix. If
// `find_related_assets` was changed to also return files in subdirectories, we could
// use `PathBuf.strip_prefix` to remove the parent directory and then glob-filter
// against the remaining path. Note that the current behaviour effectively means that
// the `ignored_content` setting in the config file is limited to single-file glob
// patterns (no "**" patterns).
page.assets = assets.into_iter()
.filter(|path|
match path.file_name() {
None => true,
Some(file) => !globset.is_match(file)
}
).collect();
} else {
page.assets = assets;
}
} else { } else {
page.assets = vec![]; page.assets = vec![];
} }
@ -160,8 +164,8 @@ impl Page {
pub fn render_markdown(&mut self, permalinks: &HashMap<String, String>, tera: &Tera, config: &Config, anchor_insert: InsertAnchor) -> Result<()> { pub fn render_markdown(&mut self, permalinks: &HashMap<String, String>, tera: &Tera, config: &Config, anchor_insert: InsertAnchor) -> Result<()> {
let context = Context::new( let context = Context::new(
tera, tera,
config.highlight_code.unwrap(), config.highlight_code,
config.highlight_theme.clone().unwrap(), config.highlight_theme.clone(),
&self.permalink, &self.permalink,
permalinks, permalinks,
anchor_insert anchor_insert
@ -450,7 +454,7 @@ Hello world
let mut gsb = GlobSetBuilder::new(); let mut gsb = GlobSetBuilder::new();
gsb.add(Glob::new("*.{js,png}").unwrap()); gsb.add(Glob::new("*.{js,png}").unwrap());
let mut config = Config::default(); let mut config = Config::default();
config.ignored_content_globber = Some(gsb.build().unwrap()); config.ignored_content_globset = Some(gsb.build().unwrap());
let res = Page::from_file( let res = Page::from_file(
nested_path.join("index.md").as_path(), nested_path.join("index.md").as_path(),

View file

@ -100,8 +100,8 @@ impl Section {
pub fn render_markdown(&mut self, permalinks: &HashMap<String, String>, tera: &Tera, config: &Config) -> Result<()> { pub fn render_markdown(&mut self, permalinks: &HashMap<String, String>, tera: &Tera, config: &Config) -> Result<()> {
let context = Context::new( let context = Context::new(
tera, tera,
config.highlight_code.unwrap(), config.highlight_code,
config.highlight_theme.clone().unwrap(), config.highlight_theme.clone(),
&self.permalink, &self.permalink,
permalinks, permalinks,
self.meta.insert_anchor_links.unwrap() self.meta.insert_anchor_links.unwrap()

View file

@ -376,8 +376,8 @@ impl Site {
/// Find all the tags and categories if it's asked in the config /// Find all the tags and categories if it's asked in the config
pub fn populate_tags_and_categories(&mut self) { pub fn populate_tags_and_categories(&mut self) {
let generate_tags_pages = self.config.generate_tags_pages.unwrap(); let generate_tags_pages = self.config.generate_tags_pages;
let generate_categories_pages = self.config.generate_categories_pages.unwrap(); let generate_categories_pages = self.config.generate_categories_pages;
if !generate_tags_pages && !generate_categories_pages { if !generate_tags_pages && !generate_categories_pages {
return; return;
} }
@ -505,7 +505,7 @@ impl Site {
self.render_sections()?; self.render_sections()?;
self.render_orphan_pages()?; self.render_orphan_pages()?;
self.render_sitemap()?; self.render_sitemap()?;
if self.config.generate_rss.unwrap() { if self.config.generate_rss {
self.render_rss_feed()?; self.render_rss_feed()?;
} }
self.render_robots()?; self.render_robots()?;
@ -521,7 +521,7 @@ impl Site {
} }
} }
if self.config.compile_sass.unwrap() { if self.config.compile_sass {
self.compile_sass(&self.base_path)?; self.compile_sass(&self.base_path)?;
} }
@ -703,7 +703,7 @@ impl Site {
let (sorted_pages, _) = sort_pages(pages, SortBy::Date); let (sorted_pages, _) = sort_pages(pages, SortBy::Date);
context.add("last_build_date", &sorted_pages[0].meta.date.clone().map(|d| d.to_string())); context.add("last_build_date", &sorted_pages[0].meta.date.clone().map(|d| d.to_string()));
// limit to the last n elements) // limit to the last n elements)
context.add("pages", &sorted_pages.iter().take(self.config.rss_limit.unwrap()).collect::<Vec<_>>()); context.add("pages", &sorted_pages.iter().take(self.config.rss_limit).collect::<Vec<_>>());
context.add("config", &self.config); context.add("config", &self.config);
let rss_feed_url = if self.config.base_url.ends_with('/') { let rss_feed_url = if self.config.base_url.ends_with('/') {

View file

@ -193,7 +193,7 @@ fn can_build_site_with_categories() {
let mut path = env::current_dir().unwrap().parent().unwrap().parent().unwrap().to_path_buf(); let mut path = env::current_dir().unwrap().parent().unwrap().parent().unwrap().to_path_buf();
path.push("test_site"); path.push("test_site");
let mut site = Site::new(&path, "config.toml").unwrap(); let mut site = Site::new(&path, "config.toml").unwrap();
site.config.generate_categories_pages = Some(true); site.config.generate_categories_pages = true;
site.load().unwrap(); site.load().unwrap();
for (i, page) in site.pages.values_mut().enumerate() { for (i, page) in site.pages.values_mut().enumerate() {
@ -247,7 +247,7 @@ fn can_build_site_with_tags() {
let mut path = env::current_dir().unwrap().parent().unwrap().parent().unwrap().to_path_buf(); let mut path = env::current_dir().unwrap().parent().unwrap().parent().unwrap().to_path_buf();
path.push("test_site"); path.push("test_site");
let mut site = Site::new(&path, "config.toml").unwrap(); let mut site = Site::new(&path, "config.toml").unwrap();
site.config.generate_tags_pages = Some(true); site.config.generate_tags_pages = true;
site.load().unwrap(); site.load().unwrap();
for (i, page) in site.pages.values_mut().enumerate() { for (i, page) in site.pages.values_mut().enumerate() {

View file

@ -23,8 +23,8 @@ macro_rules! required_string_arg {
pub fn make_trans(config: Config) -> GlobalFn { pub fn make_trans(config: Config) -> GlobalFn {
let translations_config = config.translations.unwrap(); let translations_config = config.translations;
let default_lang = to_value(config.default_language.unwrap()).unwrap(); let default_lang = to_value(config.default_language).unwrap();
Box::new(move |args| -> Result<Value> { Box::new(move |args| -> Result<Value> {
let key = required_string_arg!(args.get("key"), "`trans` requires a `key` argument."); let key = required_string_arg!(args.get("key"), "`trans` requires a `key` argument.");

View file

@ -19,6 +19,9 @@ compile_sass = %COMPILE_SASS%
# Theme can be customised by setting the `highlight_theme` variable to a theme supported by Gutenberg # Theme can be customised by setting the `highlight_theme` variable to a theme supported by Gutenberg
highlight_code = %HIGHLIGHT% highlight_code = %HIGHLIGHT%
# Whether to build a search index to be used later on by a JavaScript library
build_search_index = %SEARCH%
[extra] [extra]
# Put all your custom variables here # Put all your custom variables here
"#; "#;
@ -37,11 +40,13 @@ pub fn create_new_project(name: &str) -> Result<()> {
let base_url = ask_url("> What is the URL of your site?", "https://example.com")?; let base_url = ask_url("> What is the URL of your site?", "https://example.com")?;
let compile_sass = ask_bool("> Do you want to enable Sass compilation?", true)?; let compile_sass = ask_bool("> Do you want to enable Sass compilation?", true)?;
let highlight = ask_bool("> Do you want to enable syntax highlighting?", false)?; let highlight = ask_bool("> Do you want to enable syntax highlighting?", false)?;
let search = ask_bool("> Do you want to build a search index of the content?", false)?;
let config = CONFIG let config = CONFIG
.trim_left() .trim_left()
.replace("%BASE_URL%", &base_url) .replace("%BASE_URL%", &base_url)
.replace("%COMPILE_SASS%", &format!("{}", compile_sass)) .replace("%COMPILE_SASS%", &format!("{}", compile_sass))
.replace("%SEARCH%", &format!("{}", search))
.replace("%HIGHLIGHT%", &format!("{}", highlight)); .replace("%HIGHLIGHT%", &format!("{}", highlight));
create_file(&path.join("config.toml"), &config)?; create_file(&path.join("config.toml"), &config)?;
@ -53,6 +58,7 @@ pub fn create_new_project(name: &str) -> Result<()> {
if compile_sass { if compile_sass {
create_dir(path.join("sass"))?; create_dir(path.join("sass"))?;
} }
// TODO: if search == true, copy a lunr js file embedded in gutenberg
println!(); println!();
console::success(&format!("Done! Your site was created in {:?}", canonicalize(path).unwrap())); console::success(&format!("Done! Your site was created in {:?}", canonicalize(path).unwrap()));

View file

@ -163,7 +163,7 @@ pub fn serve(interface: &str, port: &str, output_dir: &str, base_url: &str, conf
if watching_static { if watching_static {
watchers.push("static"); watchers.push("static");
} }
if site.config.compile_sass.unwrap() { if site.config.compile_sass {
watchers.push("sass"); watchers.push("sass");
} }