From ced4e89128548571628589bd569e101500060d93 Mon Sep 17 00:00:00 2001 From: Taco Ditiecher Date: Tue, 12 Jul 2016 23:47:48 +0200 Subject: [PATCH 1/2] Added support for specifying frontmatter defaults in _config.yml Functionality is based on: https://jekyllrb.com/docs/configuration/#front-matter-defaults Issue: #305 Note: I removed adding a fixed date of '2012-01-01' to the dictionary if not specified in the _config.yml. It was never used. --- src/Pretzel.Logic/Configuration.cs | 78 ++++++++++++++++-- .../Extensions/DictionaryExtensions.cs | 28 +++++++ src/Pretzel.Logic/Pretzel.Logic.csproj | 1 + .../Context/SiteContextGenerator.cs | 6 +- src/Pretzel.Tests/ConfigurationMock.cs | 14 ++++ src/Pretzel.Tests/ConfigurationTests.cs | 82 +++++++++++++++++++ .../Extensions/DictionaryExtensionTests.cs | 48 +++++++++++ src/Pretzel.Tests/Pretzel.Tests.csproj | 2 + 8 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 src/Pretzel.Logic/Extensions/DictionaryExtensions.cs create mode 100644 src/Pretzel.Tests/ConfigurationTests.cs create mode 100644 src/Pretzel.Tests/Extensions/DictionaryExtensionTests.cs diff --git a/src/Pretzel.Logic/Configuration.cs b/src/Pretzel.Logic/Configuration.cs index 4445bee68..295949755 100644 --- a/src/Pretzel.Logic/Configuration.cs +++ b/src/Pretzel.Logic/Configuration.cs @@ -1,5 +1,6 @@ using Pretzel.Logic.Extensions; using System.Collections.Generic; +using System.IO; using System.IO.Abstractions; namespace Pretzel.Logic @@ -13,15 +14,25 @@ public interface IConfiguration bool TryGetValue(string key, out object value); IDictionary ToDictionary(); + + IDefaultsConfiguration GetDefaults(); + } + + + public interface IDefaultsConfiguration + { + IDictionary ForScope(string path); } + 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 readonly IFileSystem _fileSystem; + private readonly string _configFilePath; public object this[string key] { @@ -48,11 +59,7 @@ private void CheckDefaultConfig() { if (!_config.ContainsKey("permalink")) { - _config.Add("permalink", "date"); - } - if (!_config.ContainsKey("date")) - { - _config.Add("date", "2012-01-01"); + _config.Add("permalink", DefaultPermalink); } } @@ -80,5 +87,62 @@ public IDictionary ToDictionary() { return new Dictionary(_config); } + + public IDefaultsConfiguration GetDefaults() + { + return new DefaultsConfiguration(_config); + } } + + + 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; + } + } + } 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..ed4814a8a 100644 --- a/src/Pretzel.Logic/Pretzel.Logic.csproj +++ b/src/Pretzel.Logic/Pretzel.Logic.csproj @@ -105,6 +105,7 @@ + diff --git a/src/Pretzel.Logic/Templating/Context/SiteContextGenerator.cs b/src/Pretzel.Logic/Templating/Context/SiteContextGenerator.cs index 2237185cf..e5eeda6df 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.GetDefaults().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..78a39278e 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 GetDefaults() + { + 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..2c7e957dd --- /dev/null +++ b/src/Pretzel.Tests/ConfigurationTests.cs @@ -0,0 +1,82 @@ +using System; +using System.IO.Abstractions.TestingHelpers; +using Pretzel.Logic; +using Pretzel.Logic.Extensions; +using Xunit; + +namespace Pretzel.Tests +{ + public class ConfigurationTests + { + private 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.GetDefaults().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.GetDefaults().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.GetDefaults().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 @@ + + From 358e159c8f8226ae27507ad2ca65a43667ddb5fe Mon Sep 17 00:00:00 2001 From: Taco Ditiecher Date: Wed, 13 Jul 2016 08:37:02 +0200 Subject: [PATCH 2/2] Added unit tests for defaults configuration. Moved DefaultsConfiguration to its own file. Converted GetDefaults() method to a property. Fixes #305 --- src/Pretzel.Logic/Configuration.cs | 85 +++---------------- src/Pretzel.Logic/DefaultsConfiguration.cs | 61 +++++++++++++ src/Pretzel.Logic/Pretzel.Logic.csproj | 1 + .../Context/SiteContextGenerator.cs | 2 +- src/Pretzel.Tests/ConfigurationMock.cs | 4 +- src/Pretzel.Tests/ConfigurationTests.cs | 9 +- .../Context/SiteContextGeneratorTests.cs | 62 ++++++++++++++ 7 files changed, 142 insertions(+), 82 deletions(-) create mode 100644 src/Pretzel.Logic/DefaultsConfiguration.cs diff --git a/src/Pretzel.Logic/Configuration.cs b/src/Pretzel.Logic/Configuration.cs index 295949755..749491c65 100644 --- a/src/Pretzel.Logic/Configuration.cs +++ b/src/Pretzel.Logic/Configuration.cs @@ -1,6 +1,5 @@ using Pretzel.Logic.Extensions; using System.Collections.Generic; -using System.IO; using System.IO.Abstractions; namespace Pretzel.Logic @@ -15,13 +14,7 @@ public interface IConfiguration IDictionary ToDictionary(); - IDefaultsConfiguration GetDefaults(); - } - - - public interface IDefaultsConfiguration - { - IDictionary ForScope(string path); + IDefaultsConfiguration Defaults { get; } } @@ -31,21 +24,18 @@ internal sealed class Configuration : IConfiguration public const string DefaultPermalink = "date"; private IDictionary _config; + 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) @@ -55,12 +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", DefaultPermalink); } + + _defaultsConfiguration = new DefaultsConfiguration(_config); } internal void ReadFromFile() @@ -69,7 +61,7 @@ internal void ReadFromFile() if (_fileSystem.File.Exists(_configFilePath)) { _config = _fileSystem.File.ReadAllText(_configFilePath).ParseYaml(); - CheckDefaultConfig(); + EnsureDefaults(); } } @@ -87,62 +79,5 @@ public IDictionary ToDictionary() { return new Dictionary(_config); } - - public IDefaultsConfiguration GetDefaults() - { - return new DefaultsConfiguration(_config); - } } - - - 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; - } - } - } 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/Pretzel.Logic.csproj b/src/Pretzel.Logic/Pretzel.Logic.csproj index ed4814a8a..932409630 100644 --- a/src/Pretzel.Logic/Pretzel.Logic.csproj +++ b/src/Pretzel.Logic/Pretzel.Logic.csproj @@ -89,6 +89,7 @@ + diff --git a/src/Pretzel.Logic/Templating/Context/SiteContextGenerator.cs b/src/Pretzel.Logic/Templating/Context/SiteContextGenerator.cs index e5eeda6df..db1c7c7a1 100644 --- a/src/Pretzel.Logic/Templating/Context/SiteContextGenerator.cs +++ b/src/Pretzel.Logic/Templating/Context/SiteContextGenerator.cs @@ -229,7 +229,7 @@ private Page CreatePage(SiteContext context, IConfiguration config, string file, var content = SafeReadContents(file); var relativePath = MapToOutputPath(context, file); - var scopedDefaults = context.Config.GetDefaults().ForScope(relativePath); + var scopedDefaults = context.Config.Defaults.ForScope(relativePath); var header = scopedDefaults.Merge(content.YamlHeader()); diff --git a/src/Pretzel.Tests/ConfigurationMock.cs b/src/Pretzel.Tests/ConfigurationMock.cs index 78a39278e..81225bc89 100644 --- a/src/Pretzel.Tests/ConfigurationMock.cs +++ b/src/Pretzel.Tests/ConfigurationMock.cs @@ -61,9 +61,9 @@ public IDictionary ToDictionary() return new Dictionary(_config); } - public IDefaultsConfiguration GetDefaults() + public IDefaultsConfiguration Defaults { - return new DefaultsConfigurationMock(); + get { return new DefaultsConfigurationMock(); } } } diff --git a/src/Pretzel.Tests/ConfigurationTests.cs b/src/Pretzel.Tests/ConfigurationTests.cs index 2c7e957dd..2069c16cd 100644 --- a/src/Pretzel.Tests/ConfigurationTests.cs +++ b/src/Pretzel.Tests/ConfigurationTests.cs @@ -8,7 +8,8 @@ namespace Pretzel.Tests { public class ConfigurationTests { - private Configuration _sut; + private readonly Configuration _sut; + private const string SampleConfig = @" pretzel: engine: liquid @@ -58,7 +59,7 @@ public void Permalinks_should_be_added_with_default_value_if_not_specified_in_fi [Fact] public void DefaultsForScope_should_layer_the_most_specific_scope_on_top() { - var defaults = _sut.GetDefaults().ForScope(@"_posts\2016"); + var defaults = _sut.Defaults.ForScope(@"_posts\2016"); Assert.Equal("post-layout-for-2016", defaults["layout"]); } @@ -66,7 +67,7 @@ public void DefaultsForScope_should_layer_the_most_specific_scope_on_top() [Fact] public void DefaultsForScope_should_take_value_from_less_specific_when_not_found_in_most_specific() { - var defaults = _sut.GetDefaults().ForScope(@"_posts\2016"); + var defaults = _sut.Defaults.ForScope(@"_posts\2016"); Assert.Equal("posts-specific-author", defaults["author"]); } @@ -74,7 +75,7 @@ public void DefaultsForScope_should_take_value_from_less_specific_when_not_found [Fact] public void DefaultsForScope_should_fallback_to_value_from_empty_path_when_given_path_not_found() { - var defaults = _sut.GetDefaults().ForScope("_nonexisting"); + var defaults = _sut.Defaults.ForScope("_nonexisting"); Assert.Equal("default-author", defaults["author"]); } 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() {