diff --git a/src/Pretzel.Logic/Configuration.cs b/src/Pretzel.Logic/Configuration.cs index 4445bee68..749491c65 100644 --- a/src/Pretzel.Logic/Configuration.cs +++ b/src/Pretzel.Logic/Configuration.cs @@ -13,28 +13,29 @@ public interface IConfiguration bool TryGetValue(string key, out object value); IDictionary ToDictionary(); + + IDefaultsConfiguration Defaults { get; } } + internal sealed class Configuration : IConfiguration { private const string ConfigFileName = "_config.yml"; + public const string DefaultPermalink = "date"; private IDictionary _config; - private IFileSystem _fileSystem; - private string _configFilePath; + private IDefaultsConfiguration _defaultsConfiguration; + private readonly IFileSystem _fileSystem; + private readonly string _configFilePath; - public object this[string key] - { - get - { - return _config[key]; - } - } + public object this[string key] => _config[key]; + + public IDefaultsConfiguration Defaults => _defaultsConfiguration; internal Configuration() { _config = new Dictionary(); - CheckDefaultConfig(); + EnsureDefaults(); } internal Configuration(IFileSystem fileSystem, string sitePath) @@ -44,16 +45,14 @@ internal Configuration(IFileSystem fileSystem, string sitePath) _configFilePath = _fileSystem.Path.Combine(sitePath, ConfigFileName); } - private void CheckDefaultConfig() + private void EnsureDefaults() { if (!_config.ContainsKey("permalink")) { - _config.Add("permalink", "date"); - } - if (!_config.ContainsKey("date")) - { - _config.Add("date", "2012-01-01"); + _config.Add("permalink", DefaultPermalink); } + + _defaultsConfiguration = new DefaultsConfiguration(_config); } internal void ReadFromFile() @@ -62,7 +61,7 @@ internal void ReadFromFile() if (_fileSystem.File.Exists(_configFilePath)) { _config = _fileSystem.File.ReadAllText(_configFilePath).ParseYaml(); - CheckDefaultConfig(); + EnsureDefaults(); } } diff --git a/src/Pretzel.Logic/DefaultsConfiguration.cs b/src/Pretzel.Logic/DefaultsConfiguration.cs new file mode 100644 index 000000000..b55772a31 --- /dev/null +++ b/src/Pretzel.Logic/DefaultsConfiguration.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.IO; +using Pretzel.Logic.Extensions; + +namespace Pretzel.Logic +{ + public interface IDefaultsConfiguration + { + IDictionary ForScope(string path); + } + + internal sealed class DefaultsConfiguration : IDefaultsConfiguration + { + private readonly IDictionary> _scopedValues; + + public DefaultsConfiguration(IDictionary configuration) + { + _scopedValues = new Dictionary>(); + FillScopedValues(configuration); + } + + private void FillScopedValues(IDictionary configuration) + { + if (!configuration.ContainsKey("defaults")) return; + + var defaults = configuration["defaults"] as List; + if (defaults == null) return; + + foreach (var item in defaults.ConvertAll(x => x as IDictionary)) + { + if (item != null && item.ContainsKey("scope") && item.ContainsKey("values")) + { + var scopeDictionary = item["scope"] as IDictionary; + if (scopeDictionary != null && scopeDictionary.ContainsKey("path")) + { + var path = (string)scopeDictionary["path"]; + var values = item["values"] as IDictionary; + _scopedValues.Add(path, values ?? new Dictionary()); + } + } + } + } + + public IDictionary ForScope(string path) + { + IDictionary result = new Dictionary(); + + if (path == null) return result; + + if (path.Length > 0) + { + result = result.Merge(ForScope(Path.GetDirectoryName(path))); + } + if (_scopedValues.ContainsKey(path)) + { + result = result.Merge(_scopedValues[path]); + } + return result; + } + } +} \ No newline at end of file diff --git a/src/Pretzel.Logic/Extensions/DictionaryExtensions.cs b/src/Pretzel.Logic/Extensions/DictionaryExtensions.cs new file mode 100644 index 000000000..21f47e273 --- /dev/null +++ b/src/Pretzel.Logic/Extensions/DictionaryExtensions.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace Pretzel.Logic.Extensions +{ + /// + /// Dictionary extension methods. + /// + public static class DictionaryExtensions + { + /// + /// Merges two dictionaries on top of each other and returns a new dictionary. + /// Values from the second override the original values when the key is already present. + /// Values from the second will be added when the key is not present in the first. + /// + public static IDictionary Merge(this IDictionary first, IDictionary second) + { + var result = new Dictionary(first); + if (second != null) + { + foreach (var key in second.Keys) + { + result[key] = second[key]; + } + } + return result; + } + } +} diff --git a/src/Pretzel.Logic/Pretzel.Logic.csproj b/src/Pretzel.Logic/Pretzel.Logic.csproj index 7a4ff334a..932409630 100644 --- a/src/Pretzel.Logic/Pretzel.Logic.csproj +++ b/src/Pretzel.Logic/Pretzel.Logic.csproj @@ -89,6 +89,7 @@ + @@ -105,6 +106,7 @@ + diff --git a/src/Pretzel.Logic/Templating/Context/SiteContextGenerator.cs b/src/Pretzel.Logic/Templating/Context/SiteContextGenerator.cs index 2237185cf..db1c7c7a1 100644 --- a/src/Pretzel.Logic/Templating/Context/SiteContextGenerator.cs +++ b/src/Pretzel.Logic/Templating/Context/SiteContextGenerator.cs @@ -227,7 +227,11 @@ private Page CreatePage(SiteContext context, IConfiguration config, string file, if (pageCache.ContainsKey(file)) return pageCache[file]; var content = SafeReadContents(file); - var header = content.YamlHeader(); + + var relativePath = MapToOutputPath(context, file); + var scopedDefaults = context.Config.Defaults.ForScope(relativePath); + + var header = scopedDefaults.Merge(content.YamlHeader()); if (header.ContainsKey("published") && header["published"].ToString().ToLower() == "false") { diff --git a/src/Pretzel.Tests/ConfigurationMock.cs b/src/Pretzel.Tests/ConfigurationMock.cs index c0052198c..81225bc89 100644 --- a/src/Pretzel.Tests/ConfigurationMock.cs +++ b/src/Pretzel.Tests/ConfigurationMock.cs @@ -60,5 +60,19 @@ public IDictionary ToDictionary() { return new Dictionary(_config); } + + public IDefaultsConfiguration Defaults + { + get { return new DefaultsConfigurationMock(); } + } } + + internal class DefaultsConfigurationMock : IDefaultsConfiguration + { + public IDictionary ForScope(string path) + { + return new Dictionary(); + } + } + } diff --git a/src/Pretzel.Tests/ConfigurationTests.cs b/src/Pretzel.Tests/ConfigurationTests.cs new file mode 100644 index 000000000..2069c16cd --- /dev/null +++ b/src/Pretzel.Tests/ConfigurationTests.cs @@ -0,0 +1,83 @@ +using System; +using System.IO.Abstractions.TestingHelpers; +using Pretzel.Logic; +using Pretzel.Logic.Extensions; +using Xunit; + +namespace Pretzel.Tests +{ + public class ConfigurationTests + { + private readonly Configuration _sut; + + private const string SampleConfig = @" +pretzel: + engine: liquid + +title: 'Site Title' + +defaults: + - + scope: + path: '' + values: + author: 'default-author' + - + scope: + path: '_posts' + values: + layout: 'post' + author: 'posts-specific-author' + - + scope: + path: '_posts\2016' + values: + layout: 'post-layout-for-2016' +"; + + public ConfigurationTests() + { + var fileSystem = new MockFileSystem(); + fileSystem.AddFile(@"C:\WebSite\_config.yml", new MockFileData(SampleConfig)); + + _sut = new Configuration(fileSystem, @"C:\WebSite"); + _sut.ReadFromFile(); + } + + [Fact] + public void Indexer_should_correctly_return_the_value() + { + Assert.Equal("Site Title", _sut["title"]); + } + + [Fact] + public void Permalinks_should_be_added_with_default_value_if_not_specified_in_file() + { + Assert.Equal(Configuration.DefaultPermalink, _sut["permalink"]); + } + + [Fact] + public void DefaultsForScope_should_layer_the_most_specific_scope_on_top() + { + var defaults = _sut.Defaults.ForScope(@"_posts\2016"); + + Assert.Equal("post-layout-for-2016", defaults["layout"]); + } + + [Fact] + public void DefaultsForScope_should_take_value_from_less_specific_when_not_found_in_most_specific() + { + var defaults = _sut.Defaults.ForScope(@"_posts\2016"); + + Assert.Equal("posts-specific-author", defaults["author"]); + } + + [Fact] + public void DefaultsForScope_should_fallback_to_value_from_empty_path_when_given_path_not_found() + { + var defaults = _sut.Defaults.ForScope("_nonexisting"); + + Assert.Equal("default-author", defaults["author"]); + } + } +} diff --git a/src/Pretzel.Tests/Extensions/DictionaryExtensionTests.cs b/src/Pretzel.Tests/Extensions/DictionaryExtensionTests.cs new file mode 100644 index 000000000..f0a665a00 --- /dev/null +++ b/src/Pretzel.Tests/Extensions/DictionaryExtensionTests.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Pretzel.Logic.Extensions; +using Xunit; + +namespace Pretzel.Tests.Extensions +{ + public class DictionaryExtensionTests + { + [Fact] + public void Merge_two_dictionaries_returns_new_merged_dictionary() + { + var first = new Dictionary + { + { "A", "a-first" }, + { "B", "b-first" }, + }; + var second = new Dictionary + { + { "A", "a-second" }, + { "C", "c-second" }, + }; + + var merged = first.Merge(second); + + Assert.Equal(3, merged.Count); + Assert.Equal("a-second", merged["A"]); + Assert.Equal("b-first", merged["B"]); + Assert.Equal("c-second", merged["C"]); + } + + [Fact] + public void Merge_with_null_returns_copy_of_first() + { + var first = new Dictionary + { + { "A", "a-first" }, + { "B", "b-first" }, + }; + + var merged = first.Merge(null); + + Assert.NotSame(merged, first); + Assert.Equal(2, merged.Count); + Assert.Equal("a-first", merged["A"]); + Assert.Equal("b-first", merged["B"]); + } + } +} diff --git a/src/Pretzel.Tests/Pretzel.Tests.csproj b/src/Pretzel.Tests/Pretzel.Tests.csproj index 9788e61e3..00f6a72a4 100644 --- a/src/Pretzel.Tests/Pretzel.Tests.csproj +++ b/src/Pretzel.Tests/Pretzel.Tests.csproj @@ -91,9 +91,11 @@ + + diff --git a/src/Pretzel.Tests/Templating/Context/SiteContextGeneratorTests.cs b/src/Pretzel.Tests/Templating/Context/SiteContextGeneratorTests.cs index 4522d814e..f8bbc0631 100644 --- a/src/Pretzel.Tests/Templating/Context/SiteContextGeneratorTests.cs +++ b/src/Pretzel.Tests/Templating/Context/SiteContextGeneratorTests.cs @@ -315,6 +315,68 @@ public void site_context_pages_have_date_in_bag(string fileName, bool useDefault Assert.Equal(expectedDate, actualDate); } + [Fact] + public void defaults_in_config_should_be_combined_with_page_frontmatter_in_page_bag() + { + // Arrange + fileSystem.AddFile(@"C:\TestSite\_config.yml", new MockFileData(@" +defaults: + - + scope: + path: '' + values: + author: 'default-author' +")); + + fileSystem.AddFile(@"C:\TestSite\about.md", new MockFileData(@"--- +title: 'about' +--- +# About page +")); + + var config = new Configuration(fileSystem, @"C:\TestSite"); + config.ReadFromFile(); + var sut = new SiteContextGenerator(fileSystem, new LinkHelper(), config); + + // Act + var siteContext = sut.BuildContext(@"C:\TestSite", @"C:\TestSite\_site", false); + + // Assert + Assert.Equal("about", siteContext.Pages[0].Bag["title"]); // from page frontmatter + Assert.Equal("default-author", siteContext.Pages[0].Bag["author"]); // from config defaults + } + + + [Fact] + public void page_frontmatter_should_have_priority_over_defaults_in_config() + { + // Arrange + fileSystem.AddFile(@"C:\TestSite\_config.yml", new MockFileData(@" +defaults: + - + scope: + path: '' + values: + author: 'default-author' +")); + + fileSystem.AddFile(@"C:\TestSite\about.md", new MockFileData(@"--- +author: 'page-specific-author' +--- +# About page +")); + + var config = new Configuration(fileSystem, @"C:\TestSite"); + config.ReadFromFile(); + var sut = new SiteContextGenerator(fileSystem, new LinkHelper(), config); + + // Act + var siteContext = sut.BuildContext(@"C:\TestSite", @"C:\TestSite\_site", false); + + // Assert + Assert.Equal("page-specific-author", siteContext.Pages[0].Bag["author"]); + } + [Fact] public void CanBeIncluded_Scenarios_AreWorking() {