use crate::query::Query;
use crate::related::{LINKS, Related};
use crate::taxonomy;
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_yaml_ng::{Mapping, Value};
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Config {
pub content_dir: PathBuf,
pub output_dir: PathBuf,
pub templates_dir: PathBuf,
pub static_dir: PathBuf,
pub data_dir: PathBuf,
pub archives_dir: PathBuf,
pub theme: Option<PathBuf>,
pub hashtags: bool,
#[serde(skip)]
pub site_url: Option<String>,
#[serde(skip)]
pub base_path: String,
#[serde(skip)]
pub defaults: Vec<(String, Mapping)>,
#[serde(skip)]
pub collections: Vec<(String, Query)>,
#[serde(skip)]
pub taxonomies: Vec<String>,
#[serde(skip)]
pub related: Related,
}
impl Default for Config {
fn default() -> Self {
Self {
content_dir: PathBuf::from("content"),
output_dir: PathBuf::from("public"),
templates_dir: PathBuf::from("templates"),
static_dir: PathBuf::from("static"),
data_dir: PathBuf::from("data"),
archives_dir: PathBuf::from("archives"),
theme: None,
hashtags: false,
site_url: None,
base_path: String::new(),
defaults: Vec::new(),
collections: Vec::new(),
taxonomies: Vec::new(),
related: Related::default(),
}
}
}
impl Config {
pub fn load_with_theme(path: &Path) -> Result<(Self, Mapping)> {
let (mut config, mut site) = Self::load(path)?;
if let Some(theme_dir) = config.theme.clone() {
let theme_config_path = theme_dir.join("config.yaml");
let (theme_config, theme_site) = Self::load(&theme_config_path)
.with_context(|| format!("loading theme at {}", theme_dir.display()))?;
config.apply_theme(&mut site, theme_config, theme_site);
}
validate_defaults(&config.defaults, &config.collections)
.with_context(|| format!("validating `defaults` in {}", path.display()))?;
if config.related.weights.is_empty() {
config.related.weights = default_related_weights(&config.taxonomies);
}
config.site_url = site
.get(Value::String("url".into()))
.and_then(|v| v.as_str())
.and_then(normalize_site_url);
config.base_path = site
.get(Value::String("base_path".into()))
.and_then(|v| v.as_str())
.map(normalize_base_path)
.unwrap_or_default();
Ok((config, site))
}
fn load(path: &Path) -> Result<(Self, Mapping)> {
if !path.exists() {
return Ok((Self::default(), Mapping::new()));
}
let source =
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let mut config: Self = serde_yaml_ng::from_str(&source)
.with_context(|| format!("parsing {} into Config", path.display()))?;
let raw: Value = serde_yaml_ng::from_str(&source)
.with_context(|| format!("parsing {} as YAML", path.display()))?;
let (site, defaults_map, collections_map, taxonomies_map, related_map) = match raw {
Value::Mapping(mut m) => {
let site = match m.remove(Value::String("site".into())) {
Some(Value::Mapping(s)) => s,
_ => Mapping::new(),
};
let defaults = match m.remove(Value::String("defaults".into())) {
Some(Value::Mapping(d)) => Some(d),
_ => None,
};
let collections = match m.remove(Value::String("collections".into())) {
Some(Value::Mapping(c)) => Some(c),
_ => None,
};
let taxonomies = match m.remove(Value::String("taxonomies".into())) {
Some(Value::Sequence(t)) => Some(t),
_ => None,
};
let related = match m.remove(Value::String("related".into())) {
Some(Value::Mapping(r)) => Some(r),
_ => None,
};
(site, defaults, collections, taxonomies, related)
}
_ => (Mapping::new(), None, None, None, None),
};
if let Some(map) = collections_map {
config.collections = parse_collections(&map)
.with_context(|| format!("parsing `collections` in {}", path.display()))?;
}
if let Some(map) = defaults_map {
config.defaults = parse_defaults(&map)
.with_context(|| format!("parsing `defaults` in {}", path.display()))?;
}
config.taxonomies = taxonomy::parse(taxonomies_map.as_ref())
.with_context(|| format!("parsing `taxonomies` in {}", path.display()))?;
if let Some(map) = related_map {
config.related = parse_related(&map)
.with_context(|| format!("parsing `related` in {}", path.display()))?;
}
Ok((config, site))
}
fn apply_theme(&mut self, site: &mut Mapping, theme: Config, theme_site: Mapping) {
self.collections = merge_named(theme.collections, std::mem::take(&mut self.collections));
self.defaults = merge_named(theme.defaults, std::mem::take(&mut self.defaults));
let mut taxonomies = theme.taxonomies;
for t in std::mem::take(&mut self.taxonomies) {
if !taxonomies.contains(&t) {
taxonomies.push(t);
}
}
self.taxonomies = taxonomies;
self.hashtags = self.hashtags || theme.hashtags;
if self.related.weights.is_empty() {
self.related.weights = theme.related.weights;
}
let mut merged = theme_site;
deep_merge(&mut merged, std::mem::take(site));
*site = merged;
}
fn theme_subdir(&self, name: &str) -> Option<PathBuf> {
self.theme.as_ref().map(|theme| theme.join(name))
}
fn overlay_roots(&self, theme_sub: &str, site_dir: &Path) -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Some(theme_root) = self.theme_subdir(theme_sub) {
roots.push(theme_root);
}
roots.push(site_dir.to_path_buf());
roots
}
pub fn static_roots(&self) -> Vec<PathBuf> {
self.overlay_roots("static", &self.static_dir)
}
pub fn template_roots(&self) -> Vec<PathBuf> {
self.overlay_roots("templates", &self.templates_dir)
}
pub fn archive_roots(&self) -> Vec<PathBuf> {
self.overlay_roots("archives", &self.archives_dir)
}
}
fn merge_named<T>(mut base: Vec<(String, T)>, overrides: Vec<(String, T)>) -> Vec<(String, T)> {
for (name, value) in overrides {
if let Some(slot) = base.iter_mut().find(|(n, _)| *n == name) {
slot.1 = value;
} else {
base.push((name, value));
}
}
base
}
pub(crate) fn overlay_files(
roots: &[PathBuf],
keep: impl Fn(&Path) -> bool,
) -> Result<Vec<(PathBuf, PathBuf)>> {
let mut files: Vec<(PathBuf, PathBuf)> = Vec::new();
for root in roots {
if !root.exists() {
continue;
}
for entry in WalkDir::new(root) {
let entry = entry.with_context(|| format!("walking {}", root.display()))?;
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if !keep(path) {
continue;
}
let rel = path
.strip_prefix(root)
.with_context(|| format!("stripping prefix from {}", path.display()))?
.to_path_buf();
if let Some(slot) = files.iter_mut().find(|(r, _)| *r == rel) {
slot.1 = path.to_path_buf();
} else {
files.push((rel, path.to_path_buf()));
}
}
}
Ok(files)
}
fn deep_merge(base: &mut Mapping, over: Mapping) {
for (k, v) in over {
match v {
Value::Mapping(om) if matches!(base.get(&k), Some(Value::Mapping(_))) => {
if let Some(Value::Mapping(bm)) = base.get_mut(&k) {
deep_merge(bm, om);
}
}
other => {
base.insert(k, other);
}
}
}
}
fn parse_collections(map: &Mapping) -> Result<Vec<(String, Query)>> {
let mut collections = Vec::with_capacity(map.len());
for (key, value) in map {
let name = key
.as_str()
.ok_or_else(|| anyhow::anyhow!("collection names must be strings"))?;
let query_map = value.as_mapping().ok_or_else(|| {
anyhow::anyhow!("collection `{}` must be a mapping of query options", name)
})?;
let query = Query::from_yaml_mapping(query_map)
.with_context(|| format!("in collection `{}`", name))?;
collections.push((name.to_string(), query));
}
Ok(collections)
}
fn parse_related(map: &Mapping) -> Result<Related> {
for key in map.keys() {
match key.as_str() {
Some("weights") => {}
Some("limit") => {
return Err(anyhow::anyhow!(
"related: `limit` is no longer a config key — pass it to the \
filter instead, e.g. `page.id_path | related(limit=5)`"
));
}
other => {
return Err(anyhow::anyhow!(
"related: unknown key `{}` (allowed: weights)",
other.unwrap_or("<non-string>")
));
}
}
}
let mut weights = Vec::new();
if let Some(value) = map.get(Value::String("weights".into())) {
let weights_map = value
.as_mapping()
.ok_or_else(|| anyhow::anyhow!("related: `weights` must be a mapping"))?;
for (key, value) in weights_map {
let name = key
.as_str()
.ok_or_else(|| anyhow::anyhow!("related: weight names must be strings"))?;
let weight = value.as_f64().ok_or_else(|| {
anyhow::anyhow!("related: weight for `{}` must be a number", name)
})?;
weights.push((name.to_string(), weight));
}
}
Ok(Related {
weights,
..Related::default()
})
}
fn default_related_weights(taxonomies: &[String]) -> Vec<(String, f64)> {
let mut weights: Vec<(String, f64)> = taxonomies.iter().map(|t| (t.clone(), 1.0)).collect();
weights.push((LINKS.to_string(), 1.0));
weights
}
fn parse_defaults(map: &Mapping) -> Result<Vec<(String, Mapping)>> {
let mut defaults = Vec::with_capacity(map.len());
for (key, value) in map {
let name = key
.as_str()
.ok_or_else(|| anyhow::anyhow!("defaults keys must be collection names (strings)"))?;
let frontmatter = value.as_mapping().ok_or_else(|| {
anyhow::anyhow!(
"defaults for `{}` must be a mapping of frontmatter values",
name
)
})?;
defaults.push((name.to_string(), frontmatter.clone()));
}
Ok(defaults)
}
fn validate_defaults(
defaults: &[(String, Mapping)],
collections: &[(String, Query)],
) -> Result<()> {
for (name, _) in defaults {
if !collections.iter().any(|(n, _)| n == name) {
return Err(anyhow::anyhow!(
"defaults: `{}` does not name a collection (declare it under `collections:`)",
name
));
}
}
Ok(())
}
fn normalize_site_url(s: &str) -> Option<String> {
let trimmed = s.trim_end_matches('/');
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn normalize_base_path(s: &str) -> String {
let trimmed = s.trim_matches('/');
if trimmed.is_empty() {
String::new()
} else {
format!("/{}", trimmed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::{cleanup, tempdir};
use std::io::Write;
fn write_config(dir: &Path, body: &str) -> PathBuf {
let path = dir.join("config.yaml");
let mut f = fs::File::create(&path).unwrap();
f.write_all(body.as_bytes()).unwrap();
path
}
#[test]
fn missing_file_yields_defaults() {
let path = Path::new("/definitely/does/not/exist/config.yaml");
let (config, site) = Config::load_with_theme(path).unwrap();
assert_eq!(config.content_dir, PathBuf::from("content"));
assert_eq!(config.output_dir, PathBuf::from("public"));
assert!(site.is_empty());
}
#[test]
fn partial_overrides_keep_defaults_for_missing_keys() {
let dir = tempdir("config");
let path = write_config(&dir, "output_dir: build\n");
let (config, site) = Config::load_with_theme(&path).unwrap();
assert_eq!(config.output_dir, PathBuf::from("build"));
assert_eq!(config.content_dir, PathBuf::from("content"));
assert!(site.is_empty());
cleanup(&dir);
}
#[test]
fn site_submap_is_extracted() {
let dir = tempdir("config");
let path = write_config(&dir, "site:\n title: My Site\n description: A blog\n");
let (_, site) = Config::load_with_theme(&path).unwrap();
assert_eq!(
site.get(Value::String("title".into()))
.and_then(|v| v.as_str()),
Some("My Site"),
);
cleanup(&dir);
}
#[test]
fn site_absent_yields_empty_map() {
let dir = tempdir("config");
let path = write_config(&dir, "content_dir: foo\n");
let (_, site) = Config::load_with_theme(&path).unwrap();
assert!(site.is_empty());
cleanup(&dir);
}
#[test]
fn normalize_site_url_strips_trailing_slash() {
assert_eq!(
normalize_site_url("https://example.com/"),
Some("https://example.com".to_string())
);
assert_eq!(
normalize_site_url("https://example.com"),
Some("https://example.com".to_string())
);
assert_eq!(normalize_site_url(""), None);
assert_eq!(normalize_site_url("/"), None);
}
#[test]
fn normalize_base_path_re_anchors() {
assert_eq!(normalize_base_path(""), "");
assert_eq!(normalize_base_path("/"), "");
assert_eq!(normalize_base_path("blog"), "/blog");
assert_eq!(normalize_base_path("/blog"), "/blog");
assert_eq!(normalize_base_path("/blog/"), "/blog");
assert_eq!(normalize_base_path("blog/sub"), "/blog/sub");
}
#[test]
fn site_url_and_base_path_loaded_from_site_submap() {
let dir = tempdir("config");
let path = write_config(
&dir,
"site:\n url: https://example.com/\n base_path: blog\n",
);
let (config, site) = Config::load_with_theme(&path).unwrap();
assert_eq!(config.site_url.as_deref(), Some("https://example.com"));
assert_eq!(config.base_path, "/blog");
assert_eq!(
site.get(Value::String("url".into()))
.and_then(|v| v.as_str()),
Some("https://example.com/")
);
cleanup(&dir);
}
#[test]
fn site_url_and_base_path_default_when_absent() {
let dir = tempdir("config");
let path = write_config(&dir, "site:\n title: My Site\n");
let (config, _) = Config::load_with_theme(&path).unwrap();
assert!(config.site_url.is_none());
assert_eq!(config.base_path, "");
cleanup(&dir);
}
#[test]
fn defaults_block_is_parsed() {
let dir = tempdir("config");
let path = write_config(
&dir,
"collections:\n posts:\n path: \"posts/*.md\"\ndefaults:\n posts:\n permalink: \":yyyy/:mm/:dd/:slug\"\n",
);
let (config, _) = Config::load_with_theme(&path).unwrap();
assert_eq!(config.defaults.len(), 1);
let (name, fm) = &config.defaults[0];
assert_eq!(name, "posts");
assert_eq!(
fm.get(Value::String("permalink".into()))
.and_then(|v| v.as_str()),
Some(":yyyy/:mm/:dd/:slug")
);
cleanup(&dir);
}
#[test]
fn defaults_referencing_unknown_collection_errors() {
let dir = tempdir("config");
let path = write_config(&dir, "defaults:\n posts:\n template: post.html\n");
assert!(Config::load_with_theme(&path).is_err());
cleanup(&dir);
}
#[test]
fn collections_block_is_parsed() {
let dir = tempdir("config");
let path = write_config(
&dir,
"collections:\n posts:\n path: \"posts/*.md\"\n recent:\n order_by: updated\n",
);
let (config, _) = Config::load_with_theme(&path).unwrap();
let names: Vec<&str> = config.collections.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, vec!["posts", "recent"]);
let posts = &config.collections[0].1;
assert!(posts.path.is_some());
cleanup(&dir);
}
#[test]
fn collection_limit_key_errors() {
let dir = tempdir("config");
let path = write_config(
&dir,
"collections:\n posts:\n path: \"posts/*.md\"\n limit: 5\n",
);
let err = format!("{:#}", Config::load_with_theme(&path).unwrap_err());
assert!(err.contains("limit"), "error should mention limit: {err}");
cleanup(&dir);
}
#[test]
fn taxonomies_absent_yields_empty() {
let dir = tempdir("config");
let path = write_config(&dir, "content_dir: foo\n");
let (config, _) = Config::load_with_theme(&path).unwrap();
assert!(config.taxonomies.is_empty());
cleanup(&dir);
}
#[test]
fn taxonomies_block_parsed_in_declaration_order() {
let dir = tempdir("config");
let path = write_config(&dir, "taxonomies:\n - tags\n - categories\n - series\n");
let (config, _) = Config::load_with_theme(&path).unwrap();
assert_eq!(config.taxonomies, vec!["tags", "categories", "series"]);
cleanup(&dir);
}
#[test]
fn missing_file_yields_empty_taxonomies() {
let path = Path::new("/definitely/does/not/exist/config.yaml");
let (config, _) = Config::load_with_theme(path).unwrap();
assert!(config.taxonomies.is_empty());
}
#[test]
fn related_block_is_parsed() {
let dir = tempdir("config");
let path = write_config(
&dir,
"taxonomies:\n - tags\nrelated:\n weights:\n tags: 3.0\n links: 2.0\n",
);
let (config, _) = Config::load_with_theme(&path).unwrap();
assert_eq!(
config.related.weights,
vec![("tags".to_string(), 3.0), ("links".to_string(), 2.0)]
);
cleanup(&dir);
}
#[test]
fn related_absent_defaults_to_declared_taxonomies_plus_links() {
let dir = tempdir("config");
let path = write_config(&dir, "taxonomies:\n - tags\n - categories\n");
let (config, _) = Config::load_with_theme(&path).unwrap();
assert_eq!(
config.related.weights,
vec![
("tags".to_string(), 1.0),
("categories".to_string(), 1.0),
("links".to_string(), 1.0),
]
);
cleanup(&dir);
}
#[test]
fn related_with_no_taxonomies_defaults_to_links_only() {
let dir = tempdir("config");
let path = write_config(&dir, "content_dir: foo\n");
let (config, _) = Config::load_with_theme(&path).unwrap();
assert_eq!(config.related.weights, vec![("links".to_string(), 1.0)]);
cleanup(&dir);
}
#[test]
fn related_limit_in_config_errors() {
let dir = tempdir("config");
let path = write_config(&dir, "related:\n weights:\n tags: 1.0\n limit: 5\n");
let err = format!("{:#}", Config::load_with_theme(&path).unwrap_err());
assert!(err.contains("limit"), "error should mention limit: {err}");
cleanup(&dir);
}
#[test]
fn related_unknown_key_errors() {
let dir = tempdir("config");
let path = write_config(&dir, "related:\n wieghts:\n tags: 1.0\n");
assert!(Config::load_with_theme(&path).is_err());
cleanup(&dir);
}
#[test]
fn collections_absent_yields_empty() {
let dir = tempdir("config");
let path = write_config(&dir, "content_dir: foo\n");
let (config, _) = Config::load_with_theme(&path).unwrap();
assert!(config.collections.is_empty());
cleanup(&dir);
}
#[test]
fn collections_unknown_query_key_errors() {
let dir = tempdir("config");
let path = write_config(&dir, "collections:\n bad:\n paht: x\n");
assert!(Config::load_with_theme(&path).is_err());
cleanup(&dir);
}
#[test]
fn defaults_absent_yields_empty() {
let dir = tempdir("config");
let path = write_config(&dir, "content_dir: foo\n");
let (config, _) = Config::load_with_theme(&path).unwrap();
assert!(config.defaults.is_empty());
cleanup(&dir);
}
#[test]
fn no_config_file_yields_empty_url_fields() {
let path = Path::new("/definitely/does/not/exist/config.yaml");
let (config, _) = Config::load_with_theme(path).unwrap();
assert!(config.site_url.is_none());
assert_eq!(config.base_path, "");
}
fn write_config_in(dir: &Path, body: &str) -> PathBuf {
fs::create_dir_all(dir).unwrap();
write_config(dir, body)
}
#[test]
fn theme_overlays_templates_archives_and_static_roots() {
let dir = tempdir("config");
let theme = dir.join("themes/demo");
write_config_in(&theme, "site:\n title: Demo\n");
let path = write_config(&dir, &format!("theme: {}\n", theme.display()));
let (config, _) = Config::load_with_theme(&path).unwrap();
assert_eq!(config.templates_dir, PathBuf::from("templates"));
assert_eq!(config.archives_dir, PathBuf::from("archives"));
assert_eq!(
config.template_roots(),
vec![theme.join("templates"), PathBuf::from("templates")]
);
assert_eq!(
config.archive_roots(),
vec![theme.join("archives"), PathBuf::from("archives")]
);
assert_eq!(
config.static_roots(),
vec![theme.join("static"), PathBuf::from("static")]
);
assert_eq!(config.content_dir, PathBuf::from("content"));
assert_eq!(config.output_dir, PathBuf::from("public"));
assert_eq!(config.data_dir, PathBuf::from("data"));
cleanup(&dir);
}
#[test]
fn theme_ignores_its_own_dir_names() {
let dir = tempdir("config");
let theme = dir.join("theme");
write_config_in(
&theme,
"templates_dir: layouts\narchives_dir: views\nstatic_dir: assets\n",
);
let path = write_config(&dir, &format!("theme: {}\n", theme.display()));
let (config, _) = Config::load_with_theme(&path).unwrap();
assert_eq!(config.template_roots()[0], theme.join("templates"));
assert_eq!(config.archive_roots()[0], theme.join("archives"));
assert_eq!(config.static_roots()[0], theme.join("static"));
cleanup(&dir);
}
#[test]
fn theme_without_config_resolves_conventional_dirs() {
let dir = tempdir("config");
let theme = dir.join("theme");
fs::create_dir_all(&theme).unwrap(); let path = write_config(&dir, &format!("theme: {}\n", theme.display()));
let (config, _) = Config::load_with_theme(&path).unwrap();
assert_eq!(config.template_roots()[0], theme.join("templates"));
assert_eq!(config.archive_roots()[0], theme.join("archives"));
assert_eq!(config.static_roots()[0], theme.join("static"));
cleanup(&dir);
}
#[test]
fn template_and_archive_roots_are_just_site_without_theme() {
let config = Config::default();
assert_eq!(config.template_roots(), vec![PathBuf::from("templates")]);
assert_eq!(config.archive_roots(), vec![PathBuf::from("archives")]);
}
#[test]
fn static_roots_orders_theme_before_site() {
let dir = tempdir("config");
let theme = dir.join("theme");
write_config_in(&theme, "");
let path = write_config(&dir, &format!("theme: {}\n", theme.display()));
let (config, _) = Config::load_with_theme(&path).unwrap();
assert_eq!(
config.static_roots(),
vec![theme.join("static"), PathBuf::from("static")]
);
cleanup(&dir);
}
#[test]
fn static_roots_is_just_site_without_theme() {
let config = Config::default();
assert_eq!(config.static_roots(), vec![PathBuf::from("static")]);
}
#[test]
fn theme_collections_merge_with_site_winning_by_name() {
use crate::query::OrderKey;
let dir = tempdir("config");
let theme = dir.join("theme");
write_config_in(
&theme,
"collections:\n posts:\n order_by: title\n news:\n order_by: title\n",
);
let path = write_config(
&dir,
&format!(
"theme: {}\ncollections:\n posts:\n order_by: updated\n",
theme.display()
),
);
let (config, _) = Config::load_with_theme(&path).unwrap();
let names: Vec<&str> = config.collections.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, vec!["posts", "news"]);
assert_eq!(config.collections[0].1.order_by, OrderKey::Updated); assert_eq!(config.collections[1].1.order_by, OrderKey::Title); cleanup(&dir);
}
#[test]
fn theme_taxonomies_merge_and_dedup() {
let dir = tempdir("config");
let theme = dir.join("theme");
write_config_in(&theme, "taxonomies:\n - tags\n - categories\n");
let path = write_config(
&dir,
&format!(
"theme: {}\ntaxonomies:\n - tags\n - series\n",
theme.display()
),
);
let (config, _) = Config::load_with_theme(&path).unwrap();
assert_eq!(config.taxonomies, vec!["tags", "categories", "series"]);
cleanup(&dir);
}
#[test]
fn site_defaults_may_reference_theme_collection() {
let dir = tempdir("config");
let theme = dir.join("theme");
write_config_in(&theme, "collections:\n posts:\n path: \"posts/*.md\"\n");
let path = write_config(
&dir,
&format!(
"theme: {}\ndefaults:\n posts:\n template: post.html\n",
theme.display()
),
);
let (config, _) = Config::load_with_theme(&path).unwrap();
assert_eq!(config.defaults.len(), 1);
assert_eq!(config.defaults[0].0, "posts");
cleanup(&dir);
}
#[test]
fn theme_site_map_deep_merges_with_site_winning() {
let dir = tempdir("config");
let theme = dir.join("theme");
write_config_in(
&theme,
"site:\n title: Theme Title\n author: Theme Author\n url: https://theme.example\n",
);
let path = write_config(
&dir,
&format!(
"theme: {}\nsite:\n title: Site Title\n url: https://site.example/\n",
theme.display()
),
);
let (config, site) = Config::load_with_theme(&path).unwrap();
assert_eq!(
site.get(Value::String("title".into()))
.and_then(|v| v.as_str()),
Some("Site Title")
);
assert_eq!(
site.get(Value::String("author".into()))
.and_then(|v| v.as_str()),
Some("Theme Author")
);
assert_eq!(config.site_url.as_deref(), Some("https://site.example"));
cleanup(&dir);
}
#[test]
fn theme_hashtags_enable_propagates() {
let dir = tempdir("config");
let theme = dir.join("theme");
write_config_in(&theme, "hashtags: true\n");
let path = write_config(&dir, &format!("theme: {}\n", theme.display()));
let (config, _) = Config::load_with_theme(&path).unwrap();
assert!(config.hashtags);
cleanup(&dir);
}
}