diff --git a/Kontent.Statiq.Tests/LinqExtensionsTests.cs b/Kontent.Statiq.Tests/LinqExtensionsTests.cs index dba8271..2e28d4a 100644 --- a/Kontent.Statiq.Tests/LinqExtensionsTests.cs +++ b/Kontent.Statiq.Tests/LinqExtensionsTests.cs @@ -1,6 +1,5 @@ using Statiq.Common; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using Xunit; diff --git a/Kontent.Statiq.Tests/When_executing_a_Statiq_pipeline.cs b/Kontent.Statiq.Tests/When_executing_a_Statiq_pipeline.cs index 2c19ab2..d80ce74 100644 --- a/Kontent.Statiq.Tests/When_executing_a_Statiq_pipeline.cs +++ b/Kontent.Statiq.Tests/When_executing_a_Statiq_pipeline.cs @@ -100,7 +100,7 @@ public async Task It_should_correctly_set_the_default_content_from_richtext() await engine.ExecuteAsync(); } - public static Engine SetupExecution(Kontent kontentModule, Func, Task> test ) where TContent : class + private static Engine SetupExecution(Kontent kontentModule, Func, Task> test ) where TContent : class { var engine = new Engine(); var pipeline = new Pipeline() diff --git a/Kontent.Statiq.Tests/When_rendering_a_Razor_view.cs b/Kontent.Statiq.Tests/When_rendering_a_Razor_view.cs index e81e400..2f65894 100644 --- a/Kontent.Statiq.Tests/When_rendering_a_Razor_view.cs +++ b/Kontent.Statiq.Tests/When_rendering_a_Razor_view.cs @@ -44,7 +44,7 @@ public async Task It_should_pickup_Layout_and_view_start() new MergeContent(modules: new ReadFiles(patterns: Path.GetFullPath(".\\input\\Article.cshtml"))), // Render the view new RenderRazor() - .WithModel( Config.FromDocument( (document, context) => document.AsKontent() ) ), + .WithModel( KontentConfig.As
() ), new TestModule(async documents => (await documents.First().GetContentStringAsync()).Should().Contain("LAYOUT")) } }; diff --git a/Kontent.Statiq.Tests/When_working_with_related_documents.cs b/Kontent.Statiq.Tests/When_working_with_related_documents.cs new file mode 100644 index 0000000..38de08e --- /dev/null +++ b/Kontent.Statiq.Tests/When_working_with_related_documents.cs @@ -0,0 +1,141 @@ +using FakeItEasy; +using FluentAssertions; +using Kentico.Kontent.Delivery.Abstractions; +using Kontent.Statiq.Tests.Models; +using Kontent.Statiq.Tests.Tools; +using Statiq.Common; +using Statiq.Core; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace Kontent.Statiq.Tests +{ + public class When_working_with_related_documents + { + [Fact] + public async Task It_should_map_child_page_collections() + { + // Arrange + var home = new Home(); + var sub1 = CreateArticle("Sub 1"); + var sub2 = CreateArticle("Sub 2"); + 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(); + } + + [Fact] + public async Task It_should_allow_multiple_child_page_collections() + { + // Arrange + var home = new Home(); + var sub1 = CreateArticle("Sub 1"); + var sub2 = CreateArticle("Sub 2"); + home.Articles = new[] { sub1, sub2 }; + home.Cafes = new[] + { + CreateCafe("Ok Café") + }; + + var deliveryClient = A.Fake().WithFakeContent(home); + + var sut = new Kontent(deliveryClient); + + // Act + var engine = SetupExecution(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é"); + + }); + await engine.ExecuteAsync(); + } + + [Fact] + public async Task It_should_not_throw_on_null_or_empty_collections() + { + // Arrange + var home = new Home + { + Articles = null!, + Cafes = new Cafe[0] + }; + + var deliveryClient = A.Fake().WithFakeContent(home); + + var sut = new Kontent(deliveryClient); + + // Act + var engine = SetupExecution(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); + + var cafes = docs.FirstOrDefault().GetChildren("cafes"); + cafes.Should().HaveCount(0); + }); + await engine.ExecuteAsync(); + } + + private static Article CreateArticle(string content) + { + var body = new TestRichTextContent {content}; + + return new Article { BodyCopy = body, System = new TestContentItemSystemAttributes{ Name = content } }; + } + + 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 + { + 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; + } + } +} diff --git a/Kontent.Statiq/AddKontentDocumentsToMetadata.cs b/Kontent.Statiq/AddKontentDocumentsToMetadata.cs new file mode 100644 index 0000000..bdd4791 --- /dev/null +++ b/Kontent.Statiq/AddKontentDocumentsToMetadata.cs @@ -0,0 +1,43 @@ +using Statiq.Common; +using Statiq.Core; +using System; +using System.Collections.Generic; + +namespace Kontent.Statiq +{ + /// + /// Short-hand module for adding Kontent documents as child documents. + /// Use the with helpers for more control. + /// + /// The content type containing the + public sealed class AddKontentDocumentsToMetadata : AddDocumentsToMetadata + { + /// + /// Add Kontent documents as the default children collection. + /// + /// A function that returns the related documents from a page. + public AddKontentDocumentsToMetadata(Func> func) + : base(Keys.Children, CreateConfig(func)) + { + + } + + /// + /// Add Kontent documents as metadata. + /// + /// The metadata key to set + /// A function that returns the related documents from a page. + public AddKontentDocumentsToMetadata(string key, Func> func ) + : base(key, CreateConfig(func)) + { + + } + + private static Config> CreateConfig(Func> func) + { + if (func == null) throw new ArgumentNullException(nameof(func)); + + return KontentConfig.GetChildren(func); + } + } +} \ No newline at end of file diff --git a/Kontent.Statiq/Kontent.Statiq.csproj b/Kontent.Statiq/Kontent.Statiq.csproj index c385e8e..f8325e4 100644 --- a/Kontent.Statiq/Kontent.Statiq.csproj +++ b/Kontent.Statiq/Kontent.Statiq.csproj @@ -22,6 +22,11 @@ + + + <_Parameter1>$(AssemblyName).Tests + + diff --git a/Kontent.Statiq/Kontent.cs b/Kontent.Statiq/Kontent.cs index 7c0aef0..54314bd 100644 --- a/Kontent.Statiq/Kontent.cs +++ b/Kontent.Statiq/Kontent.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using System.Threading.Tasks; using Module = Statiq.Common.Module; @@ -30,10 +29,7 @@ public sealed class Kontent : Module where TContentModel : class /// Thrown if is null. public Kontent(IDeliveryClient client) { - if (client == null) - throw new ArgumentNullException(nameof(client), $"{nameof(client)} must not be null"); - - _client = client; + _client = client ?? throw new ArgumentNullException(nameof(client), $"{nameof(client)} must not be null"); } /// @@ -41,38 +37,9 @@ protected override async Task> ExecuteContextAsync(IExecu { var items = await _client.GetItemsAsync(QueryParameters); - var documentTasks = items.Items.Select(item => CreateDocument(context, item)).ToArray(); + var documentTasks = items.Items.Select(item => KontentDocumentHelpers.CreateDocument(context, item, GetContent)).ToArray(); return await Task.WhenAll(documentTasks); } - - private async Task CreateDocument(IExecutionContext context, TContentModel item) - { - var props = typeof(TContentModel).GetProperties(BindingFlags.Instance | BindingFlags.FlattenHierarchy | - BindingFlags.GetProperty | BindingFlags.Public); - var metadata = new List> - { - new KeyValuePair(TypedContentExtensions.KontentItemKey, item), - }; - - if (props.FirstOrDefault(prop => typeof(IContentItemSystemAttributes).IsAssignableFrom(prop.PropertyType)) - ?.GetValue(item) is IContentItemSystemAttributes systemProp) - { - metadata.AddRange(new[] - { - new KeyValuePair(KontentKeys.System.Name, systemProp.Name), - new KeyValuePair(KontentKeys.System.CodeName, systemProp.Codename), - new KeyValuePair(KontentKeys.System.Language, systemProp.Language), - new KeyValuePair(KontentKeys.System.Id, systemProp.Id), - new KeyValuePair(KontentKeys.System.Type, systemProp.Type), - new KeyValuePair(KontentKeys.System.LastModified, systemProp.LastModified) - }); - } - - var content = GetContent?.Invoke(item) ?? ""; - - return await context.CreateDocumentAsync(metadata, content, "text/html"); - } - } } diff --git a/Kontent.Statiq/KontentConfig.cs b/Kontent.Statiq/KontentConfig.cs new file mode 100644 index 0000000..dd7b569 --- /dev/null +++ b/Kontent.Statiq/KontentConfig.cs @@ -0,0 +1,65 @@ +using Statiq.Common; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Kontent.Statiq +{ + /// + /// Kontent specific Config expressions + /// + public static class KontentConfig + { + /// + /// Map related content into a collection of Statiq documents. + /// + /// The content type. + /// A function that returns a set of Kontent items. + /// A config object. + public static Config> GetChildren(Func> getChildren) + { + if (getChildren == null) throw new ArgumentNullException(nameof(getChildren)); + + return Config.FromDocument>( async (doc, ctx) => + { + var list = new List(); + var parent = doc.AsKontent(); + + if (parent != null) + { + var children = getChildren(parent)?.ToArray() ?? Array.Empty(); + foreach (var item in children) + { + list.Add(await KontentDocumentHelpers.CreateDocument(ctx, item, null)); + } + } + + return list; + }); + } + + /// + /// Map a value from a Kontent item. + /// + /// The Kontent model type. + /// The return value. + /// A function that retrieves the value from the content. + /// A config object. + public static Config Get(Func getValue) + { + if (getValue == null) throw new ArgumentNullException(nameof(getValue)); + + return Config.FromDocument((doc, ctx) => getValue(doc.AsKontent())); + } + + /// + /// Map a document from a Kontent item. + /// + /// The Kontent model type. + /// A config object. + public static Config As() + { + return Config.FromDocument((doc, ctx) => doc.AsKontent()); + } + } +} \ No newline at end of file diff --git a/Kontent.Statiq/KontentDocumentHelpers.cs b/Kontent.Statiq/KontentDocumentHelpers.cs new file mode 100644 index 0000000..888f0e6 --- /dev/null +++ b/Kontent.Statiq/KontentDocumentHelpers.cs @@ -0,0 +1,74 @@ +using Kentico.Kontent.Delivery.Abstractions; +using Statiq.Common; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Kontent.Statiq +{ + /// + /// Helpers to construct Statiq IDocument instances from Kontent items. + /// + internal static class KontentDocumentHelpers + { + internal static async Task CreateDocument(IExecutionContext context, TContentModel item, Func? getContent) where TContentModel : class + { + var props = typeof(TContentModel).GetProperties(BindingFlags.Instance | BindingFlags.FlattenHierarchy | + BindingFlags.GetProperty | BindingFlags.Public); + + var content = getContent?.Invoke(item) ?? ""; + + return await CreateDocumentInternal(context, item, props, content).ConfigureAwait(false); + + } + + internal static async Task CreateDocument(IExecutionContext context, object item, Func? getContent) + { + var props = item.GetType().GetProperties(BindingFlags.Instance | BindingFlags.FlattenHierarchy | + BindingFlags.GetProperty | BindingFlags.Public); + + var content = getContent?.Invoke(item) ?? ""; + + return await CreateDocumentInternal(context, item, props, content).ConfigureAwait(false); + } + + internal static Task CreateDocumentInternal(IExecutionContext context, object item, PropertyInfo[] props, string content ) + { + var metadata = new List> + { + new KeyValuePair(TypedContentExtensions.KontentItemKey, item), + }; + + MapSystemMetadata(item, props, metadata); + + return context.CreateDocumentAsync(metadata, content, "text/html"); + } + + /// + /// Map Kontent system properties directly into document metadata. + /// + /// The Kontent item. + /// + /// + 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) + { + metadata.AddRange(new[] + { + new KeyValuePair(KontentKeys.System.Name, systemProp.Name), + new KeyValuePair(KontentKeys.System.CodeName, systemProp.Codename), + new KeyValuePair(KontentKeys.System.Language, systemProp.Language), + new KeyValuePair(KontentKeys.System.Id, systemProp.Id), + new KeyValuePair(KontentKeys.System.Type, systemProp.Type), + new KeyValuePair(KontentKeys.System.LastModified, systemProp.LastModified), + }); + } + } + } +} \ No newline at end of file diff --git a/Kontent.Statiq/LinqExtensions.cs b/Kontent.Statiq/LinqExtensions.cs index b1ca3db..0cdace2 100644 --- a/Kontent.Statiq/LinqExtensions.cs +++ b/Kontent.Statiq/LinqExtensions.cs @@ -6,7 +6,7 @@ namespace Kontent.Statiq /// /// Extension methods enriching the LINQ syntax. /// - public static class LinqExtensions + internal static class LinqExtensions { /// /// Returns distinct elements from a sequence by using an equality comparer based on specified properties. @@ -16,7 +16,7 @@ public static class LinqExtensions /// The sequence to remove duplicate elements from. /// Selector allowing to specify one or more properties to base the filtering on. /// that contains distinct elements from the source sequence. - public static IEnumerable DistinctBy(this IEnumerable source, Func keySelector) + internal static IEnumerable DistinctBy(this IEnumerable source, Func keySelector) { HashSet seenKeys = new HashSet(); foreach (TSource element in source) diff --git a/Kontent.Statiq/TypedContentExtensions.cs b/Kontent.Statiq/TypedContentExtensions.cs index 9d11315..9d738c7 100644 --- a/Kontent.Statiq/TypedContentExtensions.cs +++ b/Kontent.Statiq/TypedContentExtensions.cs @@ -22,6 +22,11 @@ public static class TypedContentExtensions /// Thrown when this method is called on a document that doesn't a Kontent content item. public static TModel AsKontent(this IDocument document) { + if (document == null) + { + throw new ArgumentNullException(nameof(document), $"Expected a document of type {typeof(TModel).FullName}"); + } + if (document.TryGetValue(KontentItemKey, out TModel contentItem)) { return contentItem; diff --git a/README.md b/README.md index 4e1026a..7273547 100644 --- a/README.md +++ b/README.md @@ -142,8 +142,8 @@ public class ArticlesPipeline : Pipeline // Load all articles from Kontent new Kontent
(client), // Set the output path for each article - new SetDestination(Config.FromDocument((doc, ctx) - => new NormalizedPath( $"article/{doc.AsKontent
().UrlPattern}.html"))), + new SetDestination(KontentConfig.Get( + (Article item) => new NormalizedPath( $"article/{item.UrlPattern}.html"))), }; ProcessModules = new ModuleList { @@ -152,8 +152,7 @@ public class ArticlesPipeline : Pipeline // Render the Razor view into the content of the document new RenderRazor() // Use the strongly-typed model for the Razor view - .WithModel(Config.FromDocument((document, context) - => document.AsKontent
())) + .WithModel(KontentConfig.As
()) }; OutputModules = new ModuleList { @@ -172,7 +171,7 @@ This is a very basic pipeline and gives you everything you need to get started w ## Filtering content -If you need to filter out the the input document s from Kontent, it is possible to specify the query of your request using `WithQuery(IQueryParameter[])` method. +If you need to filter out the the input document s from Kontent, it is possible to specify the query of your request using `WithQuery(IQueryParameter[])` builder method. Let's say, you just want first three latest articles that have the Title element set and you want to load only a subset of. @@ -180,10 +179,10 @@ Let's say, you just want first three latest articles that have the Title element // ... new Kontent
(client) .WithQuery( - new NotEmptyFilter("$"elements.{Article.TitleCodename}""), - new OrderParameter("elements.post_date", SortOrder.Descending), + new NotEmptyFilter($"elements.{Article.TitleCodename}"), + new OrderParameter($"elements.{Article.PostDateCodename}", SortOrder.Descending), new LimitParameter(3), - new ElementsParameter("title", "summary", "post_date", "url_pattern") + new ElementsParameter(Article.TitleCodename, Article.SummaryCodeName, Article.PostDateCodename, Article.UrlPatternCodeName) ) // ... ``` @@ -209,6 +208,22 @@ 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. +| Example | Description | +|------|------| +| `.AsKontent()` | An extention on IDocument that gets the original Kontent item from the metadata | + +Within Statiq, configuration delegates are a powerful way to specify custom logic that is evaluated during processing of the content. Kontent.Statiq provides a couple of helpers to get access to the strongly typed Kontent model wrapped inside the Statiq Document. + +| Example | Description | +|------|------| +| `KontentConfig.As()` | Fetch the entire Kontent item | +| `KontentConfig.Get((Page page) => page.Title)` | Fetch a single value from a Kontent item | +| `KontentConfig.GetChildren(page => page.RelatedPages)` | Fetch a list of related content items from a property of the Kontent item. + ## Working with images (optional) Kontent can also manage your images. It hase a very comprehensive set of [image manipulations](https://docs.kontent.ai/reference/image-transformation) baked into the Delivery API and you can leverage that with Statiq. @@ -304,4 +319,5 @@ You'll need a .NET Core development setup: Windows, Mac, Linux with VisualStudio ## Blog posts, videos & docs * [Jamstack and .NET - friends or enemies?](https://www.youtube.com/watch?v=Y_yMveuTOTA) Ondrabus on YouTube, 5 nov 2020 * [Statiq Starter Kontent Lumen](https://jamstackthemes.dev/theme/statiq-starter-kontent-lumen/) sources on [Github](https://github.com/petrsvihlik/statiq-starter-kontent-lumen), 20 oct 2020 +* [On .NET Live - Generating static web applications with Statiq](https://www.youtube.com/watch?v=43oQTRZqK9g) on Youtube, 6 aug. 2020 * [Static sites with Kentico Cloud, Statiq and Netlify](https://www.kenticotricks.com/blog/static-sites-with-kentico-cloud) Kristian Bortnik, 31 jan 2018