From 8c0b9fcffe3b52887cf94a5dfb7f703c586b0216 Mon Sep 17 00:00:00 2001 From: Marnix van Valen Date: Sun, 20 Dec 2020 20:48:48 +0100 Subject: [PATCH] Taxonomy (#27) Support for taxonomy --- .../Kontent.Statiq.Tests.csproj | 6 +- Kontent.Statiq.Tests/Models/AboutUs.cs | 2 + Kontent.Statiq.Tests/Models/Accessory.cs | 2 + Kontent.Statiq.Tests/Models/Article.cs | 2 + Kontent.Statiq.Tests/Models/Brewer.cs | 2 + Kontent.Statiq.Tests/Models/Cafe.cs | 2 + Kontent.Statiq.Tests/Models/Coffee.cs | 2 + .../Models/CustomTypeProvider.cs | 4 +- Kontent.Statiq.Tests/Models/FactAboutUs.cs | 2 + Kontent.Statiq.Tests/Models/Grinder.cs | 2 + Kontent.Statiq.Tests/Models/HeroUnit.cs | 2 + Kontent.Statiq.Tests/Models/Home.cs | 2 + Kontent.Statiq.Tests/Models/HostedVideo.cs | 2 + Kontent.Statiq.Tests/Models/Office.cs | 2 + Kontent.Statiq.Tests/Models/Tweet.cs | 2 + .../Tools/KontentSetupHelpers.cs | 10 + .../Tools/TestContentItemSystemAttributes.cs | 13 +- Kontent.Statiq.Tests/Tools/XUnitLogger.cs | 69 ++++ .../When_executing_a_Statiq_pipeline.cs | 2 +- .../When_rendering_a_Razor_view.cs | 8 - .../When_working_with_related_documents.cs | 79 ++-- .../When_working_with_taxonomy.cs | 342 ++++++++++++++++++ Kontent.Statiq.sln | 1 + Kontent.Statiq/IDocumentToLookupExtensions.cs | 48 +++ Kontent.Statiq/Kontent.Statiq.csproj | 8 +- Kontent.Statiq/Kontent.cs | 7 +- Kontent.Statiq/KontentDocumentHelpers.cs | 6 +- Kontent.Statiq/KontentKeys.cs | 31 +- Kontent.Statiq/KontentModuleConfiguration.cs | 3 + Kontent.Statiq/KontentTaxonomy.cs | 69 ++++ Kontent.Statiq/KontentTaxonomyHelpers.cs | 87 +++++ .../KontentTaxonomyModuleConfiguration.cs | 22 ++ Kontent.Statiq/TaxonomyTerm.cs | 23 ++ Kontent.Statiq/TaxonomyTermComparer.cs | 27 ++ Kontent.Statiq/TypedContentExtensions.cs | 84 ++++- README.md | 142 +++++++- 36 files changed, 1034 insertions(+), 83 deletions(-) create mode 100644 Kontent.Statiq.Tests/Tools/XUnitLogger.cs create mode 100644 Kontent.Statiq.Tests/When_working_with_taxonomy.cs create mode 100644 Kontent.Statiq/IDocumentToLookupExtensions.cs create mode 100644 Kontent.Statiq/KontentTaxonomy.cs create mode 100644 Kontent.Statiq/KontentTaxonomyHelpers.cs create mode 100644 Kontent.Statiq/KontentTaxonomyModuleConfiguration.cs create mode 100644 Kontent.Statiq/TaxonomyTerm.cs create mode 100644 Kontent.Statiq/TaxonomyTermComparer.cs diff --git a/Kontent.Statiq.Tests/Kontent.Statiq.Tests.csproj b/Kontent.Statiq.Tests/Kontent.Statiq.Tests.csproj index 7809c01..d8fd39c 100644 --- a/Kontent.Statiq.Tests/Kontent.Statiq.Tests.csproj +++ b/Kontent.Statiq.Tests/Kontent.Statiq.Tests.csproj @@ -12,9 +12,9 @@ - - - + + + all diff --git a/Kontent.Statiq.Tests/Models/AboutUs.cs b/Kontent.Statiq.Tests/Models/AboutUs.cs index 7cdea5c..34c7263 100644 --- a/Kontent.Statiq.Tests/Models/AboutUs.cs +++ b/Kontent.Statiq.Tests/Models/AboutUs.cs @@ -27,6 +27,7 @@ public partial class AboutUs public const string SitemapCodename = "sitemap"; public const string UrlPatternCodename = "url_pattern"; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public IEnumerable Facts { get; set; } public string MetadataMetaDescription { get; set; } public string MetadataMetaTitle { get; set; } @@ -41,5 +42,6 @@ public partial class AboutUs public IEnumerable Sitemap { get; set; } public IContentItemSystemAttributes System { get; set; } public string UrlPattern { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Models/Accessory.cs b/Kontent.Statiq.Tests/Models/Accessory.cs index 9d386f7..3e11739 100644 --- a/Kontent.Statiq.Tests/Models/Accessory.cs +++ b/Kontent.Statiq.Tests/Models/Accessory.cs @@ -33,6 +33,7 @@ public partial class Accessory public const string SitemapCodename = "sitemap"; public const string UrlPatternCodename = "url_pattern"; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public IEnumerable Image { get; set; } public IRichTextContent LongDescription { get; set; } public string Manufacturer { get; set; } @@ -53,5 +54,6 @@ public partial class Accessory public IEnumerable Sitemap { get; set; } public IContentItemSystemAttributes System { get; set; } public string UrlPattern { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Models/Article.cs b/Kontent.Statiq.Tests/Models/Article.cs index 34ef4da..c6152dd 100644 --- a/Kontent.Statiq.Tests/Models/Article.cs +++ b/Kontent.Statiq.Tests/Models/Article.cs @@ -35,6 +35,7 @@ public partial class Article public const string TitleCodename = "title"; public const string UrlPatternCodename = "url_pattern"; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public IRichTextContent BodyCopy { get; set; } public string MetadataMetaDescription { get; set; } public string MetadataMetaTitle { get; set; } @@ -57,5 +58,6 @@ public partial class Article public IEnumerable TeaserImage { get; set; } public string Title { get; set; } public string UrlPattern { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Models/Brewer.cs b/Kontent.Statiq.Tests/Models/Brewer.cs index 14d30c8..24a37a5 100644 --- a/Kontent.Statiq.Tests/Models/Brewer.cs +++ b/Kontent.Statiq.Tests/Models/Brewer.cs @@ -33,6 +33,7 @@ public partial class Brewer public const string SitemapCodename = "sitemap"; public const string UrlPatternCodename = "url_pattern"; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public IEnumerable Image { get; set; } public IRichTextContent LongDescription { get; set; } public IEnumerable Manufacturer { get; set; } @@ -53,5 +54,6 @@ public partial class Brewer public IEnumerable Sitemap { get; set; } public IContentItemSystemAttributes System { get; set; } public string UrlPattern { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Models/Cafe.cs b/Kontent.Statiq.Tests/Models/Cafe.cs index b94bd7a..1945f68 100644 --- a/Kontent.Statiq.Tests/Models/Cafe.cs +++ b/Kontent.Statiq.Tests/Models/Cafe.cs @@ -23,6 +23,7 @@ public partial class Cafe public const string StreetCodename = "street"; public const string ZipCodeCodename = "zip_code"; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public string City { get; set; } public string Country { get; set; } public string Email { get; set; } @@ -33,5 +34,6 @@ public partial class Cafe public string Street { get; set; } public IContentItemSystemAttributes System { get; set; } public string ZipCode { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Models/Coffee.cs b/Kontent.Statiq.Tests/Models/Coffee.cs index 885917c..2e85be9 100644 --- a/Kontent.Statiq.Tests/Models/Coffee.cs +++ b/Kontent.Statiq.Tests/Models/Coffee.cs @@ -37,6 +37,7 @@ public partial class Coffee public const string UrlPatternCodename = "url_pattern"; public const string VarietyCodename = "variety"; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public string Altitude { get; set; } public string Country { get; set; } public string Farm { get; set; } @@ -61,5 +62,6 @@ public partial class Coffee public IContentItemSystemAttributes System { get; set; } public string UrlPattern { get; set; } public string Variety { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Models/CustomTypeProvider.cs b/Kontent.Statiq.Tests/Models/CustomTypeProvider.cs index 4de81a3..9e9748f 100644 --- a/Kontent.Statiq.Tests/Models/CustomTypeProvider.cs +++ b/Kontent.Statiq.Tests/Models/CustomTypeProvider.cs @@ -24,14 +24,14 @@ public class CustomTypeProvider : ITypeProvider {typeof(Tweet), "tweet"} }; - public Type GetType(string contentType) + public Type? GetType(string contentType) { return _codenames.Keys.FirstOrDefault(type => GetCodename(type).Equals(contentType)); } public string GetCodename(Type contentType) { - return _codenames.TryGetValue(contentType, out var codename) ? codename : null; + return _codenames.TryGetValue(contentType, out var codename) ? codename : ""; } } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Models/FactAboutUs.cs b/Kontent.Statiq.Tests/Models/FactAboutUs.cs index 4d80e76..f450817 100644 --- a/Kontent.Statiq.Tests/Models/FactAboutUs.cs +++ b/Kontent.Statiq.Tests/Models/FactAboutUs.cs @@ -18,10 +18,12 @@ public partial class FactAboutUs public const string SitemapCodename = "sitemap"; public const string TitleCodename = "title"; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public IRichTextContent Description { get; set; } public IEnumerable Image { get; set; } public IEnumerable Sitemap { get; set; } public IContentItemSystemAttributes System { get; set; } public string Title { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Models/Grinder.cs b/Kontent.Statiq.Tests/Models/Grinder.cs index 45c2cfa..fe52271 100644 --- a/Kontent.Statiq.Tests/Models/Grinder.cs +++ b/Kontent.Statiq.Tests/Models/Grinder.cs @@ -33,6 +33,7 @@ public partial class Grinder public const string SitemapCodename = "sitemap"; public const string UrlPatternCodename = "url_pattern"; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public IEnumerable Image { get; set; } public IRichTextContent LongDescription { get; set; } public string Manufacturer { get; set; } @@ -53,5 +54,6 @@ public partial class Grinder public IEnumerable Sitemap { get; set; } public IContentItemSystemAttributes System { get; set; } public string UrlPattern { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Models/HeroUnit.cs b/Kontent.Statiq.Tests/Models/HeroUnit.cs index dc1e175..6d768da 100644 --- a/Kontent.Statiq.Tests/Models/HeroUnit.cs +++ b/Kontent.Statiq.Tests/Models/HeroUnit.cs @@ -18,10 +18,12 @@ public partial class HeroUnit public const string SitemapCodename = "sitemap"; public const string TitleCodename = "title"; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public IEnumerable Image { get; set; } public IRichTextContent MarketingMessage { get; set; } public IEnumerable Sitemap { get; set; } public IContentItemSystemAttributes System { get; set; } public string Title { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Models/Home.cs b/Kontent.Statiq.Tests/Models/Home.cs index 45db07f..f29a1ce 100644 --- a/Kontent.Statiq.Tests/Models/Home.cs +++ b/Kontent.Statiq.Tests/Models/Home.cs @@ -31,6 +31,7 @@ public partial class Home public const string SitemapCodename = "sitemap"; public const string UrlPatternCodename = "url_pattern"; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public IEnumerable Articles { get; set; } public IEnumerable Cafes { get; set; } public IRichTextContent Contact { get; set; } @@ -49,5 +50,6 @@ public partial class Home public IEnumerable Sitemap { get; set; } public IContentItemSystemAttributes System { get; set; } public string UrlPattern { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Models/HostedVideo.cs b/Kontent.Statiq.Tests/Models/HostedVideo.cs index 383e4b0..225f6ad 100644 --- a/Kontent.Statiq.Tests/Models/HostedVideo.cs +++ b/Kontent.Statiq.Tests/Models/HostedVideo.cs @@ -16,8 +16,10 @@ public partial class HostedVideo public const string VideoHostCodename = "video_host"; public const string VideoIdCodename = "video_id"; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public IContentItemSystemAttributes System { get; set; } public IEnumerable VideoHost { get; set; } public string VideoId { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Models/Office.cs b/Kontent.Statiq.Tests/Models/Office.cs index 7b7c833..4f3bc7a 100644 --- a/Kontent.Statiq.Tests/Models/Office.cs +++ b/Kontent.Statiq.Tests/Models/Office.cs @@ -23,6 +23,7 @@ public partial class Office public const string StreetCodename = "street"; public const string ZipCodeCodename = "zip_code"; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public string City { get; set; } public string Country { get; set; } public string Email { get; set; } @@ -33,5 +34,6 @@ public partial class Office public string Street { get; set; } public IContentItemSystemAttributes System { get; set; } public string ZipCode { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Models/Tweet.cs b/Kontent.Statiq.Tests/Models/Tweet.cs index 8a3d7ce..e1f6d0a 100644 --- a/Kontent.Statiq.Tests/Models/Tweet.cs +++ b/Kontent.Statiq.Tests/Models/Tweet.cs @@ -17,9 +17,11 @@ public partial class Tweet public const string ThemeCodename = "theme"; public const string TweetLinkCodename = "tweet_link"; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public IEnumerable DisplayOptions { get; set; } public IContentItemSystemAttributes System { get; set; } public IEnumerable Theme { get; set; } public string TweetLink { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Tools/KontentSetupHelpers.cs b/Kontent.Statiq.Tests/Tools/KontentSetupHelpers.cs index c0c3bd4..4344dd8 100644 --- a/Kontent.Statiq.Tests/Tools/KontentSetupHelpers.cs +++ b/Kontent.Statiq.Tests/Tools/KontentSetupHelpers.cs @@ -15,5 +15,15 @@ public static IDeliveryClient WithFakeContent(this IDeliveryClient cli return client; } + + public static IDeliveryClient WithFakeTaxonomy(this IDeliveryClient client, params ITaxonomyGroup[] taxonomies) + { + var response = A.Fake(); + A.CallTo(() => response.Taxonomies).Returns(taxonomies); + A.CallTo(() => client.GetTaxonomiesAsync(A>._)) + .Returns(response); + + return client; + } } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Tools/TestContentItemSystemAttributes.cs b/Kontent.Statiq.Tests/Tools/TestContentItemSystemAttributes.cs index 57f9c1b..24e3dad 100644 --- a/Kontent.Statiq.Tests/Tools/TestContentItemSystemAttributes.cs +++ b/Kontent.Statiq.Tests/Tools/TestContentItemSystemAttributes.cs @@ -9,12 +9,13 @@ namespace Kontent.Statiq.Tests.Tools /// internal sealed class TestContentItemSystemAttributes : IContentItemSystemAttributes { - public string Id { get; internal set; } - public string Name { get; internal set; } - public string Codename { get; internal set; } - public string Type { get; internal set; } - public IList SitemapLocation { get; internal set; } + public string Id { get; internal set; } = ""; + public string Name { get; internal set; } = ""; + public string Codename { get; internal set; } = ""; + public string Type { get; internal set; } = ""; + public string Collection { get; } = ""; + public IList SitemapLocation { get; internal set; } = Array.Empty(); public DateTime LastModified { get; internal set; } - public string Language { get; internal set; } + public string Language { get; internal set; } = ""; } } \ No newline at end of file diff --git a/Kontent.Statiq.Tests/Tools/XUnitLogger.cs b/Kontent.Statiq.Tests/Tools/XUnitLogger.cs new file mode 100644 index 0000000..e5bc990 --- /dev/null +++ b/Kontent.Statiq.Tests/Tools/XUnitLogger.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Logging; +using System; +using Xunit.Abstractions; + +namespace Kontent.Statiq.Tests.Tools +{ + public class XUnitLoggerFactory : ILoggerFactory + { + private readonly ITestOutputHelper _output; + + public XUnitLoggerFactory(ITestOutputHelper output) + { + _output = output; + } + public void Dispose() + { + throw new NotImplementedException(); + } + + public ILogger CreateLogger(string categoryName) + { + return new XunitLogger(_output, categoryName); + } + + public void AddProvider(ILoggerProvider provider) + { + + } + } + public class XunitLogger : ILogger + { + private readonly ITestOutputHelper _testOutputHelper; + private readonly string _categoryName; + + public XunitLogger(ITestOutputHelper testOutputHelper, string categoryName) + { + _testOutputHelper = testOutputHelper; + _categoryName = categoryName; + } + + public IDisposable BeginScope(TState state) + { + return NoopDisposable.Instance; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + _testOutputHelper.WriteLine($"{_categoryName} [{eventId}] {formatter(state, exception)}"); + + if (exception != null) + _testOutputHelper.WriteLine(exception.ToString()); + } + + private class NoopDisposable : IDisposable + { + public static NoopDisposable Instance = new NoopDisposable(); + + public void Dispose() + { + + } + } + } +} diff --git a/Kontent.Statiq.Tests/When_executing_a_Statiq_pipeline.cs b/Kontent.Statiq.Tests/When_executing_a_Statiq_pipeline.cs index d80ce74..ec4ff2b 100644 --- a/Kontent.Statiq.Tests/When_executing_a_Statiq_pipeline.cs +++ b/Kontent.Statiq.Tests/When_executing_a_Statiq_pipeline.cs @@ -89,7 +89,7 @@ public async Task It_should_correctly_set_the_default_content_from_richtext() var deliveryClient = A.Fake().WithFakeContent(content); var sut = new Kontent
(deliveryClient) - .WithContent(item => item.BodyCopy.ToString() ); + .WithContent(item => item.BodyCopy.ToString() ?? "" ); // Act var engine = SetupExecution(sut, diff --git a/Kontent.Statiq.Tests/When_rendering_a_Razor_view.cs b/Kontent.Statiq.Tests/When_rendering_a_Razor_view.cs index 2f65894..d096a7b 100644 --- a/Kontent.Statiq.Tests/When_rendering_a_Razor_view.cs +++ b/Kontent.Statiq.Tests/When_rendering_a_Razor_view.cs @@ -10,19 +10,11 @@ using System.Linq; using System.Threading.Tasks; using Xunit; -using Xunit.Abstractions; namespace Kontent.Statiq.Tests { public class When_rendering_a_Razor_view { - private readonly ITestOutputHelper _testOutput; - - public When_rendering_a_Razor_view(ITestOutputHelper testOutput) - { - _testOutput = testOutput; - } - [Fact] public async Task It_should_pickup_Layout_and_view_start() { diff --git a/Kontent.Statiq.Tests/When_working_with_related_documents.cs b/Kontent.Statiq.Tests/When_working_with_related_documents.cs index 38de08e..ad61877 100644 --- a/Kontent.Statiq.Tests/When_working_with_related_documents.cs +++ b/Kontent.Statiq.Tests/When_working_with_related_documents.cs @@ -5,7 +5,6 @@ using Kontent.Statiq.Tests.Tools; using Statiq.Common; using Statiq.Core; -using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -22,21 +21,21 @@ public async Task It_should_map_child_page_collections() var home = new Home(); var sub1 = CreateArticle("Sub 1"); var sub2 = CreateArticle("Sub 2"); - home.Articles = new[] { sub1, sub2 }; + home.Articles = new[] {sub1, sub2}; var deliveryClient = A.Fake().WithFakeContent(home); var sut = new Kontent(deliveryClient); // Act - var engine = SetupExecution(sut, - new[]{ - new AddDocumentsToMetadata(Keys.Children, KontentConfig.GetChildren( page => page.Articles ) ), - }, - // Assert - async docs => docs.FirstOrDefault().GetChildren().Should().HaveCount(2) - ); - await engine.ExecuteAsync(); + var docs = await Execute(sut, + new[] + { + new AddDocumentsToMetadata(Keys.Children, KontentConfig.GetChildren(page => page.Articles)), + }); + + // Assert + docs.FirstOrDefault().GetChildren().Should().HaveCount(2); } [Fact] @@ -46,7 +45,7 @@ public async Task It_should_allow_multiple_child_page_collections() var home = new Home(); var sub1 = CreateArticle("Sub 1"); var sub2 = CreateArticle("Sub 2"); - home.Articles = new[] { sub1, sub2 }; + home.Articles = new[] {sub1, sub2}; home.Cafes = new[] { CreateCafe("Ok Café") @@ -57,26 +56,22 @@ public async Task It_should_allow_multiple_child_page_collections() var sut = new Kontent(deliveryClient); // Act - var engine = SetupExecution(sut, - new[]{ + var docs = await Execute(sut, + new[] + { new AddKontentDocumentsToMetadata(page => page.Articles), new AddKontentDocumentsToMetadata("cafes", page => page.Cafes), - }, - - // Assert - async docs => - { - var articles = docs.FirstOrDefault().GetChildren(); - articles.Should().HaveCount(2); - articles.Should().Contain(a => string.Equals(a[KontentKeys.System.Name], "Sub 1")); - articles.Should().Contain(a => string.Equals(a[KontentKeys.System.Name], "Sub 2")); + }); - var cafes = docs.FirstOrDefault().GetChildren("cafes"); - cafes.Should().HaveCount(1); - cafes.First()[KontentKeys.System.Name].Should().Be("Ok Café"); + // Assert + var articles = docs.FirstOrDefault().GetChildren(); + articles.Should().HaveCount(2); + articles.Should().Contain(a => string.Equals(a[KontentKeys.System.Name], "Sub 1")); + articles.Should().Contain(a => string.Equals(a[KontentKeys.System.Name], "Sub 2")); - }); - await engine.ExecuteAsync(); + var cafes = docs.FirstOrDefault().GetChildren("cafes"); + cafes.Should().HaveCount(1); + cafes.First()[KontentKeys.System.Name].Should().Be("Ok Café"); } [Fact] @@ -85,7 +80,7 @@ public async Task It_should_not_throw_on_null_or_empty_collections() // Arrange var home = new Home { - Articles = null!, + Articles = null!, Cafes = new Cafe[0] }; @@ -94,22 +89,19 @@ public async Task It_should_not_throw_on_null_or_empty_collections() var sut = new Kontent(deliveryClient); // Act - var engine = SetupExecution(sut, - new[]{ + var docs = await Execute(sut, + new[] + { new AddKontentDocumentsToMetadata(page => page.Articles), new AddKontentDocumentsToMetadata("cafes", page => page.Cafes), - }, + }); - // Assert - async docs => - { - var articles = docs.FirstOrDefault().GetChildren(); - articles.Should().HaveCount(0); + // Assert + var articles = docs.FirstOrDefault().GetChildren(); + articles.Should().HaveCount(0); - var cafes = docs.FirstOrDefault().GetChildren("cafes"); - cafes.Should().HaveCount(0); - }); - await engine.ExecuteAsync(); + var cafes = docs.FirstOrDefault().GetChildren("cafes"); + cafes.Should().HaveCount(0); } private static Article CreateArticle(string content) @@ -124,18 +116,19 @@ private static Cafe CreateCafe(string name) return new Cafe { System = new TestContentItemSystemAttributes{ Name = name }}; } - private static Engine SetupExecution(Kontent kontentModule, IModule[] processModules, Func, Task> test) where TContent : class + private static async Task> Execute(Kontent kontentModule, IModule[] processModules) where TContent : class { var engine = new Engine(); var pipeline = new Pipeline() { InputModules = { kontentModule }, - OutputModules = { new TestModule(test) } }; pipeline.ProcessModules.AddRange(processModules); engine.Pipelines.Add("test", pipeline); - return engine; + var result = await engine.ExecuteAsync(); + + return result.FromPipeline("Test"); } } } diff --git a/Kontent.Statiq.Tests/When_working_with_taxonomy.cs b/Kontent.Statiq.Tests/When_working_with_taxonomy.cs new file mode 100644 index 0000000..ea3bf4e --- /dev/null +++ b/Kontent.Statiq.Tests/When_working_with_taxonomy.cs @@ -0,0 +1,342 @@ +using FakeItEasy; +using FluentAssertions; +using Kentico.Kontent.Delivery.Abstractions; +using Kentico.Kontent.Delivery.Urls.QueryParameters.Filters; +using Kontent.Statiq.Tests.Models; +using Kontent.Statiq.Tests.Tools; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Statiq.Common; +using Statiq.Core; +using Statiq.Testing; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace Kontent.Statiq.Tests +{ + public class When_working_with_taxonomy + { + private readonly ITestOutputHelper _output; + + public When_working_with_taxonomy(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task It_should_load_taxonomy() + { + // Arrange + var group = SetupTaxonomyGroup("Test", "Test1", "Test2"); + group.Terms.First().Terms.AddRange( CreateTaxonomyTerms("Test1a", "Test1b", "Test1c") ); + var deliveryClient = A.Fake().WithFakeTaxonomy( group ); + + // Act + var pipeline = new Pipeline + { + InputModules = + { + new KontentTaxonomy(deliveryClient) + .WithNesting(), + } + }; + + var results = await Execute(pipeline); + + // Assert + results.Should().HaveCount(1); + results.First().GetChildren().Should().HaveCount(2); + results.First().GetChildren().First().GetChildren().Should().HaveCount(3); + } + + [Fact] + public async Task It_should_load_taxonomy_and_retrieve_it() + { + // Arrange + var group = SetupTaxonomyGroup("Test", "Test1", "Test2"); + var deliveryClient = A.Fake().WithFakeTaxonomy(group); + + // Act + var pipeline = new Pipeline + { + InputModules = + { + new KontentTaxonomy(deliveryClient) + .WithNesting() + } + }; + + var results = await Execute(pipeline); + + // Assert + results.First().AsKontentTaxonomy().Should().BeEquivalentTo(group); + } + + [Fact] + public async Task It_should_load_taxonomy_and_retrieve_its_terms() + { + // Arrange + var group = SetupTaxonomyGroup("Test", "Test1", "Test2"); + var deliveryClient = A.Fake().WithFakeTaxonomy(group); + + // Act + var pipeline = new Pipeline + { + InputModules = + { + new KontentTaxonomy(deliveryClient) + .WithNesting() + } + }; + + var results = await Execute(pipeline); + + // Assert + results.First().AsKontentTaxonomyTerms().Should().HaveCount(2); + + } + + [Fact] + public async Task It_should_flatten_taxonomy() + { + // Arrange + var group = SetupTaxonomyGroup("Test", "Test1", "Test2"); + var deliveryClient = A.Fake().WithFakeTaxonomy(group); + + // Act + var pipeline = new Pipeline + { + InputModules = + { + new KontentTaxonomy(deliveryClient) + .WithNesting(), + new FlattenTree(), + new SetDestination(Config.FromDocument((doc,ctx)=> + new NormalizedPath( doc.Get(KontentKeys.System.Name)))), + } + }; + + var results = await Execute(pipeline); + + // Assert + results.Should().HaveCount(3); + results.Get(new NormalizedPath("Test1")).Get(Keys.TreePath).Should().BeEquivalentTo("test", "test1"); + results.Get(new NormalizedPath("Test2")).Get(Keys.TreePath).Should().BeEquivalentTo("test", "test2"); + } + + [Fact] + public async Task It_should_work_with_lookup() + { + // Arrange + var recipe = new TestTaxonomyTerm {Codename = "recipe-tag", Name = "Recipe"}; + var featured = new TestTaxonomyTerm { Codename = "featured-tag", Name = "Featured" }; + var product = new TestTaxonomyTerm {Codename = "product-tag", Name = "Product"}; + + var articles = new[] + { + new Article + { + System = new TestContentItemSystemAttributes(){ Name = "make-cookies" }, + Title = "How to make cookies", + Sitemap = new ITaxonomyTerm[] {recipe, featured} + }, + new Article + { + System = new TestContentItemSystemAttributes(){ Name = "make-a-cake" }, + Title = "How to make a cake", + Sitemap = new ITaxonomyTerm[] {recipe} + }, + new Article + { + System = new TestContentItemSystemAttributes(){ Name = "buy-cookies"}, + Title = "Chocolate chip cookies", + Sitemap = new ITaxonomyTerm[] { product, featured } + } + }; + + var deliveryClient = A.Fake() + .WithFakeContent(articles); + + // Act + var pipeline = new Pipeline + { + InputModules = + { + new Kontent
(deliveryClient), + // Set taxonomy terms as metadata + new SetMetadata("Tags", KontentConfig.Get((Article art) => art.Sitemap)), + } + }; + + var results = await Execute(pipeline); + var documentsByTag = results.ToLookupManyByTaxonomy("Tags"); + + // Assert + documentsByTag[recipe].Should().HaveCount(2); + documentsByTag[new TestTaxonomyTerm{ Codename = product.Codename }].Should().HaveCount(1); + documentsByTag[featured].Should().Contain( x => x.AsKontent
().Title == articles[0].Title ); + } + + [Fact] + public async Task It_should_enable_grouping_documents_into_trees() + { + // Arrange + var group = SetupTaxonomyGroup("Menu", "Product", "Recipe", "Featured"); + group.Terms.First().Terms.AddRange(CreateTaxonomyTerms("Sugar-free","Vegan","Low-carb")); + + var recipe = new TestTaxonomyTerm { Codename = "recipe", Name = "Recipe" }; + var featured = new TestTaxonomyTerm { Codename = "featured", Name = "Featured" }; + var product = new TestTaxonomyTerm { Codename = "product", Name = "Product" }; + var lowCarb = new TestTaxonomyTerm {Codename = "low-carb", Name = "Low-carb"}; + + var articles = new[] + { + new Article + { + System = new TestContentItemSystemAttributes(){ Name = "make-cookies" }, + Title = "How to make cookies", + Sitemap = new ITaxonomyTerm[] {recipe, featured} + }, + new Article + { + System = new TestContentItemSystemAttributes(){ Name = "make-a-cake" }, + Title = "How to make a cake", + Sitemap = new ITaxonomyTerm[] {recipe} + }, + new Article + { + System = new TestContentItemSystemAttributes(){ Name = "buy-cookies"}, + Title = "Chocolate chip cookies", + Sitemap = new ITaxonomyTerm[] { product, featured } + }, + new Article + { + System = new TestContentItemSystemAttributes(){ Name = "low-carb-cookies"}, + Title = "Low-carb cookies", + Sitemap = new ITaxonomyTerm[] { product, lowCarb } + } + }; + + var deliveryClient = A.Fake() + .WithFakeTaxonomy(group) + .WithFakeContent(articles); + + // Act + var articlePipeline = new Pipeline + { + InputModules = + { + new Kontent
(deliveryClient), + // Set taxonomy terms as metadata + new SetMetadata("Tags", KontentConfig.Get((Article art) => art.Sitemap)), + new GroupDocuments( "Tags" ).WithComparer(new TaxonomyTermComparer()) + } + }; + + var menuPipeline = new Pipeline + { + Dependencies = { "Articles" }, + InputModules = + { + new KontentTaxonomy(deliveryClient) + .WithQuery(new EqualsFilter("system.codename", "menu")), + }, + ProcessModules = { + new SetMetadata( Keys.Children, Config.FromDocument((doc, ctx) => + { + var taxonomyTerm = doc.AsKontentTaxonomyTerm()?.Codename; + + return ctx.Outputs.FromPipeline("Articles") + .FirstOrDefault(x => x.AsKontentTaxonomyTerm(Keys.GroupKey)?.Codename == taxonomyTerm) + ?.GetChildren().ToArray(); + })) + } + }; + + var engine = new Engine( + new ApplicationState(Array.Empty(), "", ""), + new TestServiceProvider(cfg => cfg.AddSingleton(new XUnitLoggerFactory(_output)))); + + engine.Pipelines.Add("Articles", articlePipeline); + engine.Pipelines.Add("Menu", menuPipeline); + var results = await engine.ExecuteAsync(); + + // Assert + var sitemap = results.FromPipeline("Menu"); + sitemap.Select(node => node.Get(Keys.Title)) + .Should().BeEquivalentTo("Product", "Featured", "Vegan", "Low-carb", "Recipe", "Sugar-free"); + + var productSection = sitemap.FirstOrDefault( p => p.Get(Keys.Title) == "Product" ); + productSection.GetChildren().Should().HaveCount(2); + var lowCarbSection = sitemap.FirstOrDefault( p => p.Get(Keys.Title) == "Low-carb" ); + lowCarbSection.GetChildren().Should().HaveCount(1); + lowCarbSection.Get(Keys.TreePath).Should().Equal( "product", "low-carb"); + var featuredSection = sitemap.FirstOrDefault(p => p.Get(Keys.Title) == "Featured"); + featuredSection.GetChildren().Should().HaveCount(2); + + _output.WriteLine( string.Join("\n", sitemap.Select( doc => string.Join( "/", doc.Get(Keys.TreePath)) + $" ({doc.GetChildren().Count})"))); + } + + + private ITaxonomyGroup SetupTaxonomyGroup(string name, params string[] terms) + { + var group = new TestTaxonomyGroup(new TestTaxonomyGroupSystemAttributes + { + Name = name, + Codename = name.ToLower(), + Id = Guid.NewGuid().ToString("N"), + LastModified = DateTime.Now + }); + group.Terms.AddRange(CreateTaxonomyTerms(terms)); + return group; + } + + private IList CreateTaxonomyTerms(params string[] terms) + { + return terms.Select(t => new TestTaxonomyTermDetails {Codename = t.ToLower(), Name = t}) + .Cast().ToList(); + } + + private static Task Execute(Pipeline pipeline) + { + var engine = new Engine(); + engine.Pipelines.Add("test", pipeline); + return engine.ExecuteAsync(); + } + } + + internal class TestTaxonomyGroupSystemAttributes : ITaxonomyGroupSystemAttributes + { + public string Codename { get; set; } = ""; + public string Id { get; set; } = ""; + public DateTime LastModified { get; set; } + public string Name { get; set; } = ""; + } + + internal class TestTaxonomyGroup : ITaxonomyGroup + { + public TestTaxonomyGroup(ITaxonomyGroupSystemAttributes system) + { + System = system; + } + public ITaxonomyGroupSystemAttributes System { get; } + public IList Terms { get; } = new List(); + } + + internal class TestTaxonomyTermDetails : ITaxonomyTermDetails + { + public string Codename { get; set; } = ""; + public string Name { get; set; } = ""; + public IList Terms { get; } = new List(); + } + + internal class TestTaxonomyTerm : ITaxonomyTerm + { + public string Codename { get; set; } = ""; + public string Name { get; set; } = ""; + } +} diff --git a/Kontent.Statiq.sln b/Kontent.Statiq.sln index 8a8c3fc..63959e7 100644 --- a/Kontent.Statiq.sln +++ b/Kontent.Statiq.sln @@ -70,6 +70,7 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {B34C1AB3-6D7F-4AFA-8706-FBFDE1F942DC} = {E6921A26-7A5F-48E1-A4BD-9D251B0ABE65} {2F7DACF5-7D47-4683-B7C9-A08726F3A8A8} = {EAFC4D0E-920E-4D34-98BE-D18C25E8A4F1} {1167DDD2-C6FC-4E2D-BF02-62EB42A212B0} = {EAFC4D0E-920E-4D34-98BE-D18C25E8A4F1} EndGlobalSection diff --git a/Kontent.Statiq/IDocumentToLookupExtensions.cs b/Kontent.Statiq/IDocumentToLookupExtensions.cs new file mode 100644 index 0000000..f250e8a --- /dev/null +++ b/Kontent.Statiq/IDocumentToLookupExtensions.cs @@ -0,0 +1,48 @@ +using Kentico.Kontent.Delivery.Abstractions; +using Statiq.Common; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Kontent.Statiq +{ + /// + /// Extensions for creating lookups from document sequences. + /// + public static class IDocumentToLookupExtensions + { + /// + /// Creates a lookup from a sequence of documents according to a specified metadata key + /// that contains a sequence of Kontent taxonomy terms. + /// + /// The documents. + /// The key metadata key. + /// A lookup. + public static ILookup ToLookupManyByTaxonomy( + this IEnumerable documents, + string keyMetadataKey) + { + documents.ThrowIfNull(nameof(documents)); + keyMetadataKey.ThrowIfNull(nameof(keyMetadataKey)); + + return documents.ToLookupMany(keyMetadataKey, new TaxonomyTermComparer()); + } + + /// + /// Creates a lookup from a sequence of documents according to a specified metadata key + /// that contains a sequence of Kontent taxonomy terms. + /// + /// The documents. + /// A function to access the taxonomy terms of the content. + /// A lookup. + public static ILookup ToLookupManyByTaxonomy( + this IEnumerable documents, + Func> getTaxonomy) + { + documents.ThrowIfNull(nameof(documents)); + getTaxonomy.ThrowIfNull(nameof(getTaxonomy)); + + return documents.ToLookupMany( doc => getTaxonomy(doc.AsKontent()), new TaxonomyTermComparer()); + } + } +} \ No newline at end of file diff --git a/Kontent.Statiq/Kontent.Statiq.csproj b/Kontent.Statiq/Kontent.Statiq.csproj index f8325e4..402c759 100644 --- a/Kontent.Statiq/Kontent.Statiq.csproj +++ b/Kontent.Statiq/Kontent.Statiq.csproj @@ -28,14 +28,14 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Kontent.Statiq/Kontent.cs b/Kontent.Statiq/Kontent.cs index 54314bd..f1b1911 100644 --- a/Kontent.Statiq/Kontent.cs +++ b/Kontent.Statiq/Kontent.cs @@ -10,13 +10,12 @@ namespace Kontent.Statiq { /// /// Retrieves content items from Kontent. + /// Use .WithContent() to specify what property to load content from and .WithQuery() to specify query parameters. /// + /// The content model. public sealed class Kontent : Module where TContentModel : class { - /// - /// The code name of the field uses to fill the main Content field on the Statiq document. This is mostly useful for untyped content. - /// - public Func? GetContent { get; set; } + internal Func? GetContent { get; set; } private readonly IDeliveryClient _client; diff --git a/Kontent.Statiq/KontentDocumentHelpers.cs b/Kontent.Statiq/KontentDocumentHelpers.cs index 888f0e6..c18b27d 100644 --- a/Kontent.Statiq/KontentDocumentHelpers.cs +++ b/Kontent.Statiq/KontentDocumentHelpers.cs @@ -34,11 +34,11 @@ internal static async Task CreateDocument(IExecutionContext context, return await CreateDocumentInternal(context, item, props, content).ConfigureAwait(false); } - internal static Task CreateDocumentInternal(IExecutionContext context, object item, PropertyInfo[] props, string content ) + private static Task CreateDocumentInternal(IExecutionContext context, object item, PropertyInfo[] props, string content ) { var metadata = new List> { - new KeyValuePair(TypedContentExtensions.KontentItemKey, item), + new KeyValuePair(KontentKeys.Item, item), }; MapSystemMetadata(item, props, metadata); @@ -54,8 +54,6 @@ internal static Task CreateDocumentInternal(IExecutionContext context /// private static void MapSystemMetadata(object item, PropertyInfo[] props, List> metadata) { - // TODO : add a check here to validate that this in fact a Kontent item? - if (props.FirstOrDefault(prop => typeof(IContentItemSystemAttributes).IsAssignableFrom(prop.PropertyType)) ?.GetValue(item) is IContentItemSystemAttributes systemProp) { diff --git a/Kontent.Statiq/KontentKeys.cs b/Kontent.Statiq/KontentKeys.cs index 4648152..2629933 100644 --- a/Kontent.Statiq/KontentKeys.cs +++ b/Kontent.Statiq/KontentKeys.cs @@ -5,6 +5,11 @@ /// public static class KontentKeys { + /// + /// The key used to store the Kontent item in Statiq document metadata. + /// + public const string Item = "KONTENT"; + /// /// Keys for well-known Statiq document metadata for Kontent system metadata. /// @@ -36,13 +41,35 @@ public static class System public const string LastModified = "system.lastmodified"; } + /// + /// Keys for well-known Statiq document metadata for image processing. + /// public static class Images { - /// + /// /// The key used by the and modules to handle - /// images that need to be downloaded. + /// images that need to be downloaded. /// public const string Downloads = "KONTENT-ASSET-DOWNLOADS"; } + + /// + /// Keys for well-known Statiq document metadata for strong-typed taxonomy data. + /// + public static class Taxonomy + { + /// + /// The key used to store the Kontent taxonomy group in Statiq document metadata. + /// + public const string Group = "KONTENT-TAXONOMY-GROUP"; + /// + /// The key used to store the Kontent taxonomy terms in Statiq document metadata. + /// + public const string Terms = "KONTENT-TAXONOMY-TERMS"; + /// + /// The key used to store the Kontent taxonomy term in Statiq document metadata. + /// + public const string Term = "KONTENT-TAXONOMY-TERM"; + } } } \ No newline at end of file diff --git a/Kontent.Statiq/KontentModuleConfiguration.cs b/Kontent.Statiq/KontentModuleConfiguration.cs index 982f028..04ccb50 100644 --- a/Kontent.Statiq/KontentModuleConfiguration.cs +++ b/Kontent.Statiq/KontentModuleConfiguration.cs @@ -30,6 +30,7 @@ public static Kontent WithContent(this KontentSort order /// The content model type. /// The module. + [Obsolete("Please use WithQuery to add query parameters instead.")] public static Kontent OrderBy(this Kontent module, string field, SortOrder sortOrder) where TContentModel : class { if (!field.StartsWith("elements") && !field.StartsWith("system")) @@ -48,6 +49,7 @@ public static Kontent OrderBy(this KontentThe number of items to skip. /// The content model type. /// The module. + [Obsolete("Please use WithQuery to add query parameters instead.")] public static Kontent Skip(this Kontent module, int skip) where TContentModel : class { module.QueryParameters.Add(new SkipParameter(skip)); @@ -61,6 +63,7 @@ public static Kontent Skip(this KontentThe maximum number of items to return. /// The content model type. /// The module. + [Obsolete("Please use WithQuery to add query parameters instead.")] public static Kontent Limit(this Kontent module, int limit) where TContentModel : class { module.QueryParameters.Add(new LimitParameter(limit)); diff --git a/Kontent.Statiq/KontentTaxonomy.cs b/Kontent.Statiq/KontentTaxonomy.cs new file mode 100644 index 0000000..935793f --- /dev/null +++ b/Kontent.Statiq/KontentTaxonomy.cs @@ -0,0 +1,69 @@ +using Kentico.Kontent.Delivery.Abstractions; +using Statiq.Common; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Kontent.Statiq +{ + /// + /// Module that retrieves Kontent taxonomy groups as a Statiq document structure. + /// The output is a page structure for each group that matches the query. Child documents are child terms. + /// + public sealed class KontentTaxonomy : Module + { + private readonly IDeliveryClient _client; + private bool _nesting; + private bool _collapseRoot = true; + + internal List QueryParameters { get; } = new List(); + + /// + /// Create a new instance of the KontentTaxonomy module for Statiq using an existing Kontent client. + /// + /// The Kontent client to use. + /// Thrown if is null. + public KontentTaxonomy(IDeliveryClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client), $"{nameof(client)} must not be null"); + } + + /// + /// Indicates that the module should only output root nodes (instead of all + /// nodes which is the default behavior). + /// + /// true to enable nesting and only output the root nodes. + /// + /// Indicates that the root of the tree should be collapsed and the module + /// should output first-level documents as if they were root documents. This setting + /// has no effect if not nesting. + /// + /// The current module instance. + public KontentTaxonomy WithNesting(bool nesting = true, bool collapseRoot = false) + { + _nesting = nesting; + _collapseRoot = collapseRoot; + return this; + } + + /// + protected override async Task> ExecuteContextAsync(IExecutionContext context) + { + var items = await _client.GetTaxonomiesAsync(QueryParameters); + + var documentTasks = items.Taxonomies.Select(item => KontentTaxonomyHelpers.CreateDocument(context, item, _collapseRoot)).ToArray(); + + var results = await Task.WhenAll(documentTasks); + var documents = results.SelectMany(d => d).ToArray(); + + if (!_nesting) + { + // flatten the hierarchy + documents = documents.Flatten(Keys.TreePlaceholder, Keys.Children).ToArray(); + } + + return documents; + } + } +} \ No newline at end of file diff --git a/Kontent.Statiq/KontentTaxonomyHelpers.cs b/Kontent.Statiq/KontentTaxonomyHelpers.cs new file mode 100644 index 0000000..13cf615 --- /dev/null +++ b/Kontent.Statiq/KontentTaxonomyHelpers.cs @@ -0,0 +1,87 @@ +using Kentico.Kontent.Delivery.Abstractions; +using Statiq.Common; +using System.Collections.Generic; +using System.Linq; +using System; +using System.Threading.Tasks; + +namespace Kontent.Statiq +{ + internal static class KontentTaxonomyHelpers + { + internal static async Task> CreateDocument(IExecutionContext context, ITaxonomyGroup item, bool collapseRoot) + { + string[] treePath = collapseRoot ? Array.Empty() : new[] {item.System.Codename}; + IEnumerable? terms = Array.Empty(); + + if (item.Terms != null && item.Terms.Count > 0) + { + terms = await item.Terms.ParallelSelectAsync(async t => await CreateDocument(context, t, treePath)); + } + + if (collapseRoot) + { + return terms; + } + + var metadata = BuildRootNode(item, treePath, terms); + var root = await context.CreateDocumentAsync(metadata, "", "text/html"); + return root.Yield(); + } + + private static IList> BuildRootNode(ITaxonomyGroup item, string[] treePath, IEnumerable terms) + { + var metadata = new List> + { + new KeyValuePair(Keys.Title, item.System.Name), + new KeyValuePair(Keys.GroupKey, item.System.Codename), + new KeyValuePair(Keys.TreePath, treePath), + new KeyValuePair(KontentKeys.Taxonomy.Group, item), + }; + + if (item.Terms != null && item.Terms.Count > 0) + { + metadata.AddRange( + new KeyValuePair(Keys.Children, terms), + new KeyValuePair(KontentKeys.Taxonomy.Terms, item.Terms?.ToArray() ?? Array.Empty()) + ); + } + + if( item.System != null ) + { + metadata.AddRange(new[] + { + new KeyValuePair(KontentKeys.System.Name, item.System.Name), + new KeyValuePair(KontentKeys.System.CodeName, item.System.Codename), + new KeyValuePair(KontentKeys.System.Id, item.System.Id), + new KeyValuePair(KontentKeys.System.LastModified, item.System.LastModified), + }); + } + + return metadata; + } + + private static async Task CreateDocument(IExecutionContext context, ITaxonomyTermDetails item, string[] parentPath) + { + var treePath = parentPath.Concat(item.Codename).ToArray(); + var metadata = new List> + { + new KeyValuePair(Keys.Title, item.Name), + new KeyValuePair(Keys.GroupKey, item.Codename), + new KeyValuePair(Keys.TreePath, treePath), + new KeyValuePair(KontentKeys.System.Name, item.Name), + new KeyValuePair(KontentKeys.System.CodeName, item.Codename), + new KeyValuePair(KontentKeys.Taxonomy.Term, item) + }; + + if (item.Terms != null && item.Terms.Count > 0) + { + var terms = await item.Terms.ParallelSelectAsync(async t => await CreateDocument(context, t, treePath)); + metadata.Add(new KeyValuePair(Keys.Children, terms)); + metadata.Add(new KeyValuePair(KontentKeys.Taxonomy.Terms, item.Terms.ToArray())); + } + + return await context.CreateDocumentAsync(metadata, "", "text/html"); + } + } +} \ No newline at end of file diff --git a/Kontent.Statiq/KontentTaxonomyModuleConfiguration.cs b/Kontent.Statiq/KontentTaxonomyModuleConfiguration.cs new file mode 100644 index 0000000..316d5a5 --- /dev/null +++ b/Kontent.Statiq/KontentTaxonomyModuleConfiguration.cs @@ -0,0 +1,22 @@ +using Kentico.Kontent.Delivery.Abstractions; + +namespace Kontent.Statiq +{ + /// + /// Extension methods to configure the Kontent.Statiq Taxonomy module. + /// + public static class KontentTaxonomyModuleConfiguration + { + /// + /// Add query parameters. + /// + /// The module. + /// + /// The module. + public static KontentTaxonomy WithQuery(this KontentTaxonomy module, params IQueryParameter[] queryParameters) + { + module.QueryParameters.AddRange(queryParameters); + return module; + } + } +} \ No newline at end of file diff --git a/Kontent.Statiq/TaxonomyTerm.cs b/Kontent.Statiq/TaxonomyTerm.cs new file mode 100644 index 0000000..d95e45b --- /dev/null +++ b/Kontent.Statiq/TaxonomyTerm.cs @@ -0,0 +1,23 @@ +using Kentico.Kontent.Delivery.Abstractions; + +namespace Kontent.Statiq +{ + // This is used to map ITaxonomyTermDetails to ITaxonomyTerm and make it easier to + // compare and group content by taxonomy. + internal class TaxonomyTerm : ITaxonomyTerm + { + private TaxonomyTerm(string codename, string name) + { + Codename = codename; + Name = name; + } + + public string Codename { get; } + public string Name { get; } + + public static ITaxonomyTerm CreateFrom(ITaxonomyTermDetails details) + { + return new TaxonomyTerm(details.Codename, details.Name); + } + } +} \ No newline at end of file diff --git a/Kontent.Statiq/TaxonomyTermComparer.cs b/Kontent.Statiq/TaxonomyTermComparer.cs new file mode 100644 index 0000000..65eef6e --- /dev/null +++ b/Kontent.Statiq/TaxonomyTermComparer.cs @@ -0,0 +1,27 @@ +using Kentico.Kontent.Delivery.Abstractions; +using System.Collections.Generic; + +namespace Kontent.Statiq +{ + /// + /// Comparer for Kontent taxonomy. + /// Use this with to group content based on taxonomy terms. + /// + public class TaxonomyTermComparer : IEqualityComparer + { + /// + public bool Equals(ITaxonomyTerm? x, ITaxonomyTerm? y) + { + if (ReferenceEquals(x, y)) return true; + if (ReferenceEquals(x, null)) return false; + if (ReferenceEquals(y, null)) return false; + return x.Codename == y.Codename; + } + + /// + public int GetHashCode(ITaxonomyTerm obj) + { + return (obj.Codename != null ? obj.Codename.GetHashCode() : 0); + } + } +} diff --git a/Kontent.Statiq/TypedContentExtensions.cs b/Kontent.Statiq/TypedContentExtensions.cs index 9d738c7..67a271a 100644 --- a/Kontent.Statiq/TypedContentExtensions.cs +++ b/Kontent.Statiq/TypedContentExtensions.cs @@ -1,5 +1,7 @@ -using Statiq.Common; +using Kentico.Kontent.Delivery.Abstractions; +using Statiq.Common; using System; +using System.Linq; namespace Kontent.Statiq { @@ -11,8 +13,9 @@ public static class TypedContentExtensions /// /// The key used in Statiq documents to store the Kontent item. /// - public const string KontentItemKey = "KONTENT"; - + [Obsolete("Please use KontentKeys.Item instead")] + public const string KontentItemKey = KontentKeys.Item; + /// /// Return the strong typed model for a Statiq document. /// @@ -27,12 +30,85 @@ public static TModel AsKontent(this IDocument document) throw new ArgumentNullException(nameof(document), $"Expected a document of type {typeof(TModel).FullName}"); } - if (document.TryGetValue(KontentItemKey, out TModel contentItem)) + if (document.TryGetValue(KontentKeys.Item, out TModel contentItem)) { return contentItem; } throw new InvalidOperationException($"This is not a Kontent document: {document.Source}"); } + + /// + /// Return the strong typed model for a Statiq document. + /// + /// The Document. + /// The strong typed content model. + /// Thrown when this method is called on a document that doesn't a Kontent content item. + public static ITaxonomyGroup AsKontentTaxonomy(this IDocument document) + { + if (document == null) + { + throw new ArgumentNullException(nameof(document), "Expected a document but got "); + } + + if (document.TryGetValue(KontentKeys.Taxonomy.Group, out ITaxonomyGroup contentItem)) + { + return contentItem; + } + + throw new InvalidOperationException($"This document does not contain a Kontent taxonomy group: {document.Source}"); + } + + /// + /// Return the strong typed model for a Statiq document. + /// + /// The Document. + /// The metadata key to fetch the term from. + /// The taxonomy term or null. + public static ITaxonomyTerm? AsKontentTaxonomyTerm(this IDocument document, string key = KontentKeys.Taxonomy.Term) + { + if (document == null) + { + throw new ArgumentNullException(nameof(document), "Expected a document but got "); + } + + if (document.TryGetValue(key, out ITaxonomyTerm term)) + { + return term; + } + + if (document.TryGetValue(KontentKeys.Taxonomy.Term, out ITaxonomyTermDetails termDetails)) + { + return TaxonomyTerm.CreateFrom(termDetails); + } + + return null; + } + + /// + /// Return the strong typed model for a Statiq document. + /// + /// The Document. + /// The metadata key to get the terms from. + /// The taxonomy terms or an empty array. + public static ITaxonomyTerm[] AsKontentTaxonomyTerms(this IDocument document, string key = KontentKeys.Taxonomy.Terms) + { + if (document == null) + { + throw new ArgumentNullException(nameof(document), "Expected a document but got "); + } + + if (document.TryGetValue(key, out object? terms)) + { + return terms switch + { + ITaxonomyTerm[] items => items, + ITaxonomyTermDetails[] details => details.Select(TaxonomyTerm.CreateFrom).ToArray(), + _ => Array.Empty() + }; + } + + return Array.Empty(); + } } } diff --git a/README.md b/README.md index 75f08a3..e72544e 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,6 @@ Both these content models can be used with Statiq, it's up to your preferences. Where ever Statiq hands you an `IDocument`, use the extension method `.AsKontent()` to get the typed model from the document. - ## Accessing content Kontent.Statiq wraps the strong typed content model in a Statiq Document. In order to access the strong typed model within the document, a couple of helpers are available. @@ -292,6 +291,141 @@ public class DownloadImages : Pipeline The `KontentImageProcessor` will add any replaced image url to a collection in the Document metadata for processing by the `KontentDownloadImages` module at a later stage. +## Taxonomy + +The `KontentTaxonomy` module allows you to get the taxonomies defined in Kontent. You can download a specific taxonomy group or a single group using filters. + +```csharp +public class Taxonomies : Pipeline +{ + public Taxonomies(IDeliveryClient client) + { + PostProcessModules = new ModuleList( + // Load the taxonomy named 'menu' + new KontentTaxonomy(client) + .WithQuery(new EqualsFilter("system.codename", "menu")) + ); + } +} +``` +This will return all the terms for the taxonomy group _menu_ as a list. For convenience the root node is skipped because you cannot select the root node as a tag in Kontent. + +### Returning a tree or the root node + +Using the `.WithNesting(nesting:true, collapseRoot:false)` method on the module, you can make the module return a hierarchical structure of documents and control whether the root node is returned as well. + +```csharp +public class Taxonomies : Pipeline +{ + public Taxonomies(IDeliveryClient client) + { + PostProcessModules = new ModuleList( + // Load the taxonomy named 'menu' + new KontentTaxonomy(client) + .WithQuery(new EqualsFilter("system.codename", "menu")) + ); + } +} +``` + +To flatten this into a list, use the [`FlattenTree`](https://github.com/statiqdev/Statiq.Framework/blob/main/src/core/Statiq.Core/Modules/Metadata/FlattenTree.cs) module. + +### Grouping documents by taxonomy + +If you add a taxonomy field to one of your content types, this will result in a property of type `IEnumerable` in the generated model class. You can match these terms up to a taxonomy group to get a structured set of pages. This enables you to [manage the structure of the site using taxonomy](https://docs.kontent.ai/tutorials/develop-apps/optimize-your-app/sitemaps-for-seo#a-tag-items-with-a-taxonomy-to-create-a-sitemap) or generate an overview of documents per tag. Note that you don't need to load the entire taxonomy if you don't need that structure. + +```csharp +new Pipeline +{ + InputModules = + { + new Kontent
(deliveryClient), + // Set taxonomy terms as metadata + new SetMetadata("Tags", KontentConfig.Get((Article art) => art.Tags)), + // Group by taxonomy + new GroupDocuments( "Tags" ) + .WithComparer(new TaxonomyTermComparer()) + } +} +``` +The output for this pipeline is a flat list of documents, one for each taxonomy term with the documents for that term as its children. +The taxonomy term on each document is avalailable through `doc.AsTaxonomyTerm(Keys.GroupKey)`. + +### Structuring documents by taxonomy + +If you do need the structure of the taxonomy, for example to build a sitemap, you can join content to the taxonomy data returned by the `KontentTaxonomy` module. + +```csharp +var articlePipeline = new Pipeline +{ + InputModules = + { + // Load all articles + new Kontent
(deliveryClient), + // Set taxonomy terms as metadata + new SetMetadata("Tags", KontentConfig.Get((Article art) => art.Sitemap)), + // Group the articles by tag + new GroupDocuments( "Tags" ).WithComparer(new TaxonomyTermComparer()) + } +}; + +var sitemapPipeline = new Pipeline +{ + Dependencies = { "Articles" }, + InputModules = + { + // Load the taxonomy as a flat list + new KontentTaxonomy(deliveryClient) + .WithQuery(new EqualsFilter("system.codename", "menu")), + }, + ProcessModules = { + new SetMetadata( Keys.Children, Config.FromDocument((doc, ctx) => + { + var taxonomyTerm = doc.AsKontentTaxonomyTerm().Codename; + + // Find the articles for this term + return ctx.Outputs.FromPipeline("Articles") + .FirstOrDefault( x => x.AsKontentTaxonomyTerm(Keys.GroupKey)?.Codename==taxonomyTerm) + ?.GetChildren() + .ToArray(); + })) + } +}; +``` + +The output of the sitemap pipeline is a flat list of nodes representing the taxonomy terms. Each nodes children are the documents that are tagged with that term. + +Note that the path to each node in the tree is available through `doc.Get(Keys.TreePath)`. + +### Taxonomy helpers + +Extensions on `IDocument` that can be used with documents loaded through the `KontentTaxonomy` module: + +| Example | Description | +|------|------| +| `.AsKontentTaxonomy()` | Fetch the entire `ITaxonomyGroup` from a root taxonomy document. | +| `.AsKontentTaxonomyTerm()` | Fetch a single term from an IDocument | +| `.AsKomtentTaxonomyTerms()` | Fetch a list of child terms from an IDocument, if any. + +### Taxonomy metadata + +All the keys used by the Modules provided are listed in the [KontentKeys](https://github.com/alanta/Kontent.Statiq/blob/main/Kontent.Statiq/KontentKeys.cs) class. +For most cases there is a helper or extension to access the metadata but if you need direct access, you can use these keys to do so. + +| Key | Value | +|------|------| +| `Keys.Title` | The name of the term or group | +| `Keys.GroupKey` | The code name of the term or group | +| `Keys.TreePath` | A `string[]` containing the path of the node in the taxonomy by code name | +| `Keys.Children` | The list of child terms as Statiq `IDocument` | +| `KontentKeys.Taxonomy.Terms` | The child terms as `ITaxonomyTermDetails[]` | +| `KontentKeys.Taxonomy.Group` | The taxonomy group as `ITaxonomyGroup` | +| `KontentKeys.Taxonomy.Term` | The taxonomy term as `ITaxonomyTermDetails` | +| `KontentKeys.System.Name` | The name of the term or group | +| `KontentKeys.System.CodeName` | The code name of the term or group | +| `KontentKeys.System.Id` | The id of the group (not available for terms) | +| `KontentKeys.System.LastModified` | The `DateTime` the group was last modified (not available for terms) | + ## Troubleshooting > There are weird object tags like this in my content: @@ -300,12 +434,16 @@ The `KontentImageProcessor` will add any replaced image url to a collection in t ``` -Make sure you read the section on structured content and follow the configuration steps. +This means that the Kontent Delivery SDK was unable to resolve the type of content included in your rich text field. Make sure you read the section on structured content and follow the configuration steps. > Links to other pages don't work Implement and register a link resolver. See the [Kontent docs](https://github.com/Kentico/kontent-delivery-sdk-net/wiki/Resolving-Links-to-Content-Items) for more information. +> Null reference while rendering Razor + +Make sure all your classes are in a namespace. + ## How do I build this repo? You'll need a .NET Core development setup: Windows, Mac, Linux with VisualStudio, VS Code or Rider.