├── .gitignore ├── Contentful.Wyam.Tests ├── Contentful.Wyam.Tests.csproj └── ContentfulTests.cs ├── Contentful.Wyam ├── Contentful.Wyam.csproj ├── ContentfulKeys.cs ├── IDocumentExtensions.cs └── Contentful.cs ├── Contentful.Wyam.sln └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | [Bb]in/ 2 | [Oo]bj/ 3 | *.suo 4 | *.user 5 | *.userprefs 6 | .vs/ 7 | .vscode/ 8 | packages/ 9 | .TestResults/ 10 | *.nuget.props 11 | *.nuget.targets 12 | *.orig 13 | -------------------------------------------------------------------------------- /Contentful.Wyam.Tests/Contentful.Wyam.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.2 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Contentful.Wyam/Contentful.Wyam.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 0.7.0 6 | https://github.com/contentful/contentful.wyam 7 | https://contentful.github.io/contentful.net-docs/images/contentful_wyam_logo.png 8 | Wyam;Static;StaticContent;Contentful 9 | Contentful GmbH 10 | Contentful 11 | Contentful GmbH 12 | git 13 | https://github.com/contentful/contentful.wyam 14 | A Contentful module for the static site generator Wyam. 15 | 16 | 17 | 18 | 19 | 20 | all 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Contentful.Wyam.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.28306.52 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Contentful.Wyam", "Contentful.Wyam\Contentful.Wyam.csproj", "{26ADD4E4-5FAC-437D-BEDF-58B283876BE0}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contentful.Wyam.Tests", "Contentful.Wyam.Tests\Contentful.Wyam.Tests.csproj", "{CE76C19C-A7E1-4F5B-9231-A7DD819978EF}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Release|Any CPU = Release|Any CPU 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {26ADD4E4-5FAC-437D-BEDF-58B283876BE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {26ADD4E4-5FAC-437D-BEDF-58B283876BE0}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {26ADD4E4-5FAC-437D-BEDF-58B283876BE0}.Release|Any CPU.ActiveCfg = Release|Any CPU 19 | {26ADD4E4-5FAC-437D-BEDF-58B283876BE0}.Release|Any CPU.Build.0 = Release|Any CPU 20 | {CE76C19C-A7E1-4F5B-9231-A7DD819978EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {CE76C19C-A7E1-4F5B-9231-A7DD819978EF}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {CE76C19C-A7E1-4F5B-9231-A7DD819978EF}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {CE76C19C-A7E1-4F5B-9231-A7DD819978EF}.Release|Any CPU.Build.0 = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ExtensibilityGlobals) = postSolution 29 | SolutionGuid = {86808D5B-8D24-4C6C-8823-DB645C667474} 30 | EndGlobalSection 31 | EndGlobal 32 | -------------------------------------------------------------------------------- /Contentful.Wyam/ContentfulKeys.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Contentful.Wyam 6 | { 7 | /// 8 | /// Class containing meta-data keys for the Contentful documents. 9 | /// 10 | public static class ContentfulKeys 11 | { 12 | /// 13 | /// The id of the Entry. Note that this is not guaranteed to unique as there will be one document created per requested locale of the Entry. 14 | /// A unique combination would be and . The string version of this key is "ContentfulId". 15 | /// 16 | /// 17 | public const string EntryId = "ContentfulId"; 18 | 19 | /// 20 | /// The locale of the Entry. The string version of this key is "ContentfulLocale". 21 | /// 22 | /// 23 | public const string EntryLocale = "ContentfulLocale"; 24 | 25 | /// 26 | /// The included assets of the Entry. Refer to the Contentful .NET SDK documentation for more details. 27 | /// The string version of this key is "ContentfulIncludedAssets". 28 | /// 29 | /// IEnumerable<Asset> 30 | public const string IncludedAssets = "ContentfulIncludedAssets"; 31 | 32 | /// 33 | /// The included referenced entries of the Entry. Refer to the Contentful .NET SDK documentation for more details. 34 | /// The string version of this key is "ContentfulIncludedEntries". 35 | /// 36 | /// IEnumerable<Entry<dynamic>> 37 | public const string IncludedEntries = "ContentfulIncludedEntries"; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Contentful.Wyam.Tests/ContentfulTests.cs: -------------------------------------------------------------------------------- 1 | using Contentful.Core; 2 | using Contentful.Core.Models; 3 | using Contentful.Core.Models.Management; 4 | using Contentful.Core.Search; 5 | using Moq; 6 | using Newtonsoft.Json.Linq; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Threading; 10 | using Wyam.Common.Documents; 11 | using Wyam.Common.Execution; 12 | using Xunit; 13 | 14 | namespace Contentful.Wyam.Tests 15 | { 16 | public class ContentfulTests 17 | { 18 | [Fact] 19 | public void CallingContentfulShouldReturnCorrectNumberOfDocuments() 20 | { 21 | //Arrange 22 | var mockClient = new Mock(); 23 | mockClient.Setup(c => c.GetSpace(default(CancellationToken))) 24 | .ReturnsAsync( 25 | new Space() 26 | { 27 | SystemProperties = new SystemProperties 28 | { 29 | Id = "467" 30 | }, 31 | Locales = new List() 32 | { 33 | new Locale() 34 | { 35 | Code = "en-US", 36 | Default = true 37 | } 38 | } 39 | }); 40 | 41 | var collection = new ContentfulCollection() 42 | { 43 | Items = new List() 44 | { 45 | JObject.FromObject(new { sys = new { id = "123" } }), 46 | JObject.FromObject(new { sys = new { id = "3456" } }), 47 | JObject.FromObject(new { sys = new { id = "62365" } }), 48 | JObject.FromObject(new { sys = new { id = "tw635" } }), 49 | JObject.FromObject(new { sys = new { id = "uer46" } }), 50 | JObject.FromObject(new { sys = new { id = "jy456" } }), 51 | }, 52 | 53 | IncludedAssets = new List(), 54 | IncludedEntries = new List>() 55 | }; 56 | mockClient.Setup(c => c.GetEntries(It.IsAny>(), default(CancellationToken))) 57 | .ReturnsAsync(collection); 58 | 59 | var mockContext = new Mock(); 60 | 61 | 62 | var contentful = new Contentful(mockClient.Object).WithContentField("body"); 63 | 64 | //Act 65 | var res = contentful.Execute(new List(), mockContext.Object); 66 | 67 | //Assert 68 | Assert.Equal(6, res.Count()); 69 | } 70 | 71 | [Fact] 72 | public void CallingContentfulRecursivelyShouldReturnCorrectNumberOfDocuments() 73 | { 74 | //Arrange 75 | var mockClient = new Mock(); 76 | mockClient.Setup(c => c.GetSpace(default(CancellationToken))) 77 | .ReturnsAsync( 78 | new Space() 79 | { 80 | SystemProperties = new SystemProperties 81 | { 82 | Id = "467" 83 | }, 84 | Locales = new List() 85 | { 86 | new Locale() 87 | { 88 | Code = "en-US", 89 | Default = true 90 | } 91 | } 92 | }); 93 | 94 | var collection = new ContentfulCollection() 95 | { 96 | Items = new List() 97 | { 98 | JObject.FromObject(new { sys = new { id = "123" } }), 99 | JObject.FromObject(new { sys = new { id = "3456" } }), 100 | JObject.FromObject(new { sys = new { id = "62365" } }), 101 | JObject.FromObject(new { sys = new { id = "tw635" } }), 102 | JObject.FromObject(new { sys = new { id = "uer46" } }), 103 | JObject.FromObject(new { sys = new { id = "jy456" } }), 104 | }, 105 | 106 | IncludedAssets = new List(), 107 | IncludedEntries = new List>(), 108 | Total = 24 109 | }; 110 | var callCount = 0; 111 | 112 | mockClient.Setup(c => c.GetEntries(It.IsAny>(), default(CancellationToken))) 113 | .ReturnsAsync(() => { 114 | 115 | if (callCount == 4) 116 | { 117 | return new ContentfulCollection() 118 | { 119 | Items = new List(), 120 | IncludedAssets = new List(), 121 | IncludedEntries = new List>(), 122 | Total = 24 123 | }; 124 | } 125 | callCount++; 126 | return collection; 127 | }); 128 | 129 | var mockContext = new Mock(); 130 | 131 | var contentful = new Contentful(mockClient.Object).WithContentField("body").WithRecursiveCalling().WithLimit(6); 132 | 133 | //Act 134 | var res = contentful.Execute(new List(), mockContext.Object); 135 | 136 | //Assert 137 | Assert.Equal(24, res.Count()); 138 | mockClient.Verify(c => c.GetEntries(It.IsAny>(), default(CancellationToken)), Times.Exactly(5)); 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # contentful.wyam 2 | Contentful.wyam is a module to the [Wyam static site generator](https://wyam.io) allowing you to fetch content from the Contentful API. It is powered 3 | by the [Contentful .NET SDK](https://github.com/contentful/contentful.net). 4 | 5 | ## Installation 6 | 7 | Add the following nuget package to your `config.wyam` file. 8 | 9 | ``` 10 | #n -p Contentful.Wyam 11 | ``` 12 | 13 | Note that the `-p` switch is necessary since this package is still in pre-release. 14 | 15 | ## Usage 16 | 17 | Adding the package above gives you access to the `Contentful` module that can be used to fetch content. In your 18 | `config.wyam` you can add it to your pipeline in this fashion. 19 | 20 | ```csharp 21 | Pipelines.Add("Your pipeline", Contentful("", "")); 22 | ``` 23 | 24 | There are a number of fluent methods available to further customize what content to fetch. 25 | 26 | Use `WithContentfield` to specify which field in your content should be used as content for your Wyam documents. 27 | 28 | ```csharp 29 | Contentful("", "").WithContentField("body"); 30 | ```` 31 | 32 | Use `WithContentType` to specify that only content of a specific content type should be pulled from Contentful. 33 | 34 | ```csharp 35 | Contentful("", "").WithContentType(""); 36 | ```` 37 | 38 | Use `WithLocale` to specify that only content of a specific locale should be pulled from Contentful. By default only 39 | content of the default locale will be fetched. 40 | 41 | ```csharp 42 | Contentful("", "").WithLocale("en-GB"); 43 | ```` 44 | 45 | If you want to fetch all locales, use `*` as your locale. 46 | 47 | ```csharp 48 | Contentful("", "").WithLocale("*"); 49 | ```` 50 | 51 | Note that this will fetch multiple copies of the same Entry. One for each locale. 52 | 53 | Use `WithIncludes` to specify the number of levels of referenced content to fetch. Default is to fetch 1 level of referenced content. See [the Contentful documentation](https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/links) 54 | for more information on referenced content. 55 | 56 | ```csharp 57 | Contentful("", "").WithIncludes(3); 58 | ```` 59 | 60 | Use `WithLimit` and `WithSkip` to specify the maximum number of items to fetch and to skip an arbitrary number of items. Highly useful if you 61 | wish to paginate your content. 62 | 63 | ```csharp 64 | Contentful("", "").WithSkip(10).WithLimit(10); 65 | ```` 66 | 67 | Use `WithRecursiveCalling` if you have multple pages of content that you need to fetch. Note that this might result in several 68 | calls being made to the Contentful API. Ratelimits may apply. 69 | 70 | ```csharp 71 | Contentful("", "").WithRecursiveCalling(); 72 | ``` 73 | 74 | Once the `Contentful` module has run it will output all of your entries in Contentful as Wyam documents. Available as metadata will be all of the fields of the entry. 75 | Here's an example in a razor file. 76 | 77 | ```csharp 78 |
79 | Model.Get("productName") 80 |
81 | ``` 82 | 83 | This would fetch the `productName` field from the current document. 84 | 85 | There's also four specific metadata properties that are always available: `ContentfulId`, `ContentfulLocale`, `ContentfulIncludedAssets` and `ContentfulIncludedEntries`. 86 | 87 | `ContentfulId` and `ContentfulLocale` are simply the id and the locale of the entry of the current document in Contentful. 88 | 89 | The `ContentfulIncludedAssets` and `ContentfulIncludedEntries` are two collections of referenced entries and assets. These correspond to the `includes` section of the json 90 | response returned from Contentful and can be used to input images, links to PDFs or other entries in your documents. 91 | 92 | To use them directly as metadata means alot of casting back and can result in quite bloated output. 93 | 94 | ```csharp 95 | c.SystemProperties.Id == (Model.Get("featuredImage") as JToken)["sys"]["id"].ToString()).FilesLocalized["en-US"].Url)" /> 96 | ``` 97 | 98 | The contentful.wyam packages has a number of helper extension methods that are better suited for the task. 99 | 100 | ```csharp 101 | @Model.ImageTagForAsset(Model.Get("featuredImage")) 102 | //This will output an image tag as in the bloated example above. 103 | ``` 104 | 105 | The image tag extension method also allows you to leverage the entire powerful Contentful image API to manipulate your images. It has the following optional parameters: 106 | 107 | * `alt` — the alt text of the image, will default to the title of the Asset in Contentful if available. 108 | * `width` — the width of the image. 109 | * `height` — the height of the image. 110 | * `jpgQuality` — the quality of the image if it is a JPG. 111 | * `resizeBehaviour` — how the image should be resized if needed. 112 | * `format` — the format of the image, supported are JPG, PNG or WEBP. 113 | * `cornerRadius` — the radius of the corners. 114 | * `focus` — the focus area of the image. 115 | * `backgroundColor` — the background color of the image. 116 | 117 | For more information on how to use the Contentful image API, refer to [the official documentation](https://www.contentful.com/developers/docs/references/images-api/). 118 | 119 | There are also extensions methods available to get an `Asset` or `Entry` directly. 120 | 121 | ```csharp 122 | @Model.GetIncludedAsset(Model.Get("topImage")) //Accepts a JToken, extracts the id and returns an Asset from the ContentfulIncludedAssets. 123 | @Model.GetIncludedAssetById("") //Returns an asset from ContentfulIncludedAssets by the specified id. 124 | 125 | @Model.GetIncludedEntry(Model.Get("referencedEntry")) //Accepts a JToken, extracts the id and returns an Entry from the ContentfulIncludedEntries. 126 | @Model.GetIncludedEntryById("") //Returns an entry from ContentfulIncludedEntries by the specified id. 127 | ``` 128 | 129 | ## Example pipeline 130 | 131 | A common scenario for a content pipeline is to fetch a number of entries from contentful, parse markdown and then output them as .html files. 132 | 133 | Here's an example of such a pipeline with a complete `config.wyam` configuration file. 134 | 135 | ```csharp 136 | // Preprocessor directives 137 | #n -p Wyam.Razor 138 | #n -p Wyam.Markdown 139 | #n -p Contentful.Wyam 140 | 141 | // Body code 142 | Pipelines.Add("Contentful-pipeline", 143 | Contentful("","") 144 | .WithContentField("body").WithContentType(""), //Get all entries from contentful of the specified type, set the body field as content for each resulting document. 145 | Markdown(), //Parse the markdown from the documents and turn it into html. 146 | Meta("Body", @doc.Content), //Put the parsed HTML result into a metadata property to access later in the .cshtml files 147 | Merge(ReadFiles("templates/post.cshtml")), //Merge the documents with the post.cshtml template, now ready to be parsed by the Razor engine 148 | Razor(), //Parse the resulting documents using the Razor engine, turning them into plain .html output again. 149 | WriteFiles($"{@doc["slug"]}.html") //Write the files as .html files to disk 150 | ); 151 | ``` -------------------------------------------------------------------------------- /Contentful.Wyam/IDocumentExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Wyam.Common.Documents; 5 | using Wyam.Common.Meta; 6 | using Contentful.Core.Images; 7 | using Contentful.Core.Models; 8 | using Newtonsoft.Json.Linq; 9 | using System.Linq; 10 | 11 | namespace Contentful.Wyam 12 | { 13 | /// 14 | /// Helper extension methods for IDocument. 15 | /// 16 | public static class IDocumentExtensions 17 | { 18 | /// 19 | /// Gets an asset from the ContentfulIncludedAssets collection. 20 | /// 21 | /// The IDocument. 22 | /// The Json token representing the asset. 23 | /// The found asset or null. 24 | public static Asset GetIncludedAsset(this IDocument doc, JToken token) 25 | { 26 | if (token["sys"] == null || token["sys"]["id"] == null) 27 | { 28 | return null; 29 | } 30 | 31 | return GetIncludedAssetById(doc, token["sys"]["id"].ToString()); 32 | } 33 | 34 | /// 35 | /// Gets an asset from the ContentfulIncludedAssets collection. 36 | /// 37 | /// The IDocument. 38 | /// The id of the asset. 39 | /// The found asset or null. 40 | public static Asset GetIncludedAssetById(this IDocument doc, string id) 41 | { 42 | var assets = doc.List(ContentfulKeys.IncludedAssets); 43 | 44 | return assets.FirstOrDefault(c => c.SystemProperties.Id == id); 45 | } 46 | 47 | /// 48 | /// Gets an entry from the ContentfulIncludedEntries collection. 49 | /// 50 | /// The IDocument. 51 | /// The Json token representing the entry. 52 | /// The found entry or null. 53 | public static Entry GetIncludedEntry(this IDocument doc, JToken token) 54 | { 55 | if (token["sys"] == null || token["sys"]["id"] == null) 56 | { 57 | return null; 58 | } 59 | 60 | return GetIncludedEntryById(doc, token["sys"]["id"].ToString()); 61 | } 62 | 63 | /// 64 | /// Gets an entry from the ContentfulIncludedEntries collection. 65 | /// 66 | /// The IDocument. 67 | /// The id of the entry. 68 | /// The found entry or null. 69 | public static Entry GetIncludedEntryById(this IDocument doc, string id) 70 | { 71 | var entries = doc.List>(ContentfulKeys.IncludedEntries); 72 | 73 | return entries.FirstOrDefault(c => c.SystemProperties.Id == id); 74 | } 75 | 76 | /// 77 | /// Creates an image tag for an asset. 78 | /// 79 | /// The IDocument. 80 | /// The Json token representing the asset. 81 | /// The alt text of the image. Will default to the title of the asset if null. 82 | /// The width of the image. 83 | /// The height of the image. 84 | /// The quality of the image. 85 | /// How the image should resize to conform to the width and height. 86 | /// The format of the image, jpg,png or webp. 87 | /// The corner radius of the image. 88 | /// The focus area of the image when resizing. 89 | /// The background color of any padding that is added to the image. 90 | /// The image tag as a string. 91 | public static string ImageTagForAsset(this IDocument doc, JToken token, string alt = null, 92 | int? width = null, int? height = null, int? jpgQuality = null, ImageResizeBehaviour resizeBehaviour = ImageResizeBehaviour.Default, 93 | ImageFormat format = ImageFormat.Default, int? cornerRadius = 0, ImageFocusArea focus = ImageFocusArea.Default, string backgroundColor = null) 94 | { 95 | if (token["sys"] == null || token["sys"]["id"] == null) 96 | { 97 | return null; 98 | } 99 | 100 | return ImageTagForAsset(doc, token["sys"]["id"].ToString(), alt, width, height, jpgQuality, resizeBehaviour, format, cornerRadius, focus, backgroundColor); 101 | } 102 | 103 | /// 104 | /// Creates an image tag for an asset. 105 | /// 106 | /// The IDocument. 107 | /// The id of the asset. 108 | /// The alt text of the image. Will default to the title of the asset if null. 109 | /// The width of the image. 110 | /// The height of the image. 111 | /// The quality of the image. 112 | /// How the image should resize to conform to the width and height. 113 | /// The format of the image, jpg,png or webp. 114 | /// The corner radius of the image. 115 | /// The focus area of the image when resizing. 116 | /// The background color of any padding that is added to the image. 117 | /// The image tag as a string. 118 | public static string ImageTagForAsset(this IDocument doc, string assetId, string alt = null, 119 | int? width = null, int? height = null, int? jpgQuality = null, ImageResizeBehaviour resizeBehaviour = ImageResizeBehaviour.Default, 120 | ImageFormat format = ImageFormat.Default, int? cornerRadius = 0, ImageFocusArea focus = ImageFocusArea.Default, string backgroundColor = null) 121 | { 122 | var asset = doc.List(ContentfulKeys.IncludedAssets)?.FirstOrDefault(c => c.SystemProperties.Id == assetId); 123 | 124 | if (asset == null) 125 | { 126 | return string.Empty; 127 | } 128 | 129 | var locale = doc.Get(ContentfulKeys.EntryLocale); 130 | 131 | var imageUrlBuilder = ImageUrlBuilder.New(); 132 | 133 | if (width.HasValue) 134 | { 135 | imageUrlBuilder.SetWidth(width.Value); 136 | } 137 | 138 | if (height.HasValue) 139 | { 140 | imageUrlBuilder.SetHeight(height.Value); 141 | } 142 | 143 | if (jpgQuality.HasValue) 144 | { 145 | imageUrlBuilder.SetJpgQuality(jpgQuality.Value); 146 | } 147 | 148 | if (cornerRadius.HasValue) 149 | { 150 | imageUrlBuilder.SetCornerRadius(cornerRadius.Value); 151 | } 152 | 153 | imageUrlBuilder.SetResizingBehaviour(resizeBehaviour).SetFormat(format).SetFocusArea(focus).SetBackgroundColor(backgroundColor); 154 | 155 | if (alt == null && !string.IsNullOrEmpty(asset.TitleLocalized[locale])) 156 | { 157 | alt = asset.TitleLocalized[locale]; 158 | } 159 | 160 | return $@""; 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Contentful.Wyam/Contentful.cs: -------------------------------------------------------------------------------- 1 | using Contentful.Core; 2 | using Contentful.Core.Errors; 3 | using Contentful.Core.Models; 4 | using Contentful.Core.Models.Management; 5 | using Contentful.Core.Search; 6 | using Newtonsoft.Json.Linq; 7 | using System; 8 | using System.Collections.Generic; 9 | using System.Diagnostics; 10 | using System.Linq; 11 | using System.Net.Http; 12 | using System.Text; 13 | using System.Threading.Tasks; 14 | using Wyam.Common.Documents; 15 | using Wyam.Common.Execution; 16 | using Wyam.Common.Modules; 17 | 18 | namespace Contentful.Wyam 19 | { 20 | /// 21 | /// Fetch content from Contentful and create new documents using the fields as metadata. 22 | /// 23 | /// 24 | /// For each Entry in Contentful one output document per requested locale will be created. For each output document every field of the Entry will be available 25 | /// as metadata. In addition a number of other metadata properties will be set for each document. 26 | /// 27 | /// 28 | /// 29 | /// 30 | /// 31 | /// Content 32 | public class Contentful : IModule 33 | { 34 | private readonly IContentfulClient _client; 35 | private string _contentField = ""; 36 | private string _contentTypeId = ""; 37 | private string _locale = null; 38 | private int _includes = 1; 39 | private int _limit = 100; 40 | private int _skip = 0; 41 | private bool _recursive = false; 42 | 43 | /// 44 | /// Calls the Contentful API using the specified deliverykey and spaceId. 45 | /// 46 | /// The Contentful Content Delivery API key. 47 | /// The id of the space in Contentful from which to fetch content. 48 | public Contentful(string deliveryKey, string spaceId) : this(deliveryKey, spaceId, false) 49 | { 50 | 51 | } 52 | 53 | /// 54 | /// Calls the Contentful API using the specified deliverykey and spaceId. 55 | /// 56 | /// The Contentful Content Delivery API key. 57 | /// The id of the space in Contentful from which to fetch content. 58 | /// Whether or not to use the Contentful Preview API. Note that if the preview API is used a preview API key must also be specified. 59 | public Contentful(string deliveryKey, string spaceId, bool usePreview) 60 | { 61 | var httpClient = new HttpClient(); 62 | _client = new ContentfulClient(httpClient, deliveryKey, "", spaceId, usePreview); 63 | } 64 | 65 | /// 66 | /// Calls the Contentful API using the specified IContentfulClient. 67 | /// 68 | /// The IContentfulClient to use when calling the Contentul API. 69 | public Contentful(IContentfulClient client) 70 | { 71 | _client = client ?? throw new ArgumentNullException(nameof(client), "IContentful client cannot be null."); 72 | } 73 | 74 | /// 75 | /// Specifies which field of the Contentful entries that should be used as content for the documents created. 76 | /// 77 | /// The id of the field in Contentful. 78 | public Contentful WithContentField(string field) 79 | { 80 | _contentField = field; 81 | return this; 82 | } 83 | 84 | /// 85 | /// Specifies that only entries of a specific content type should be fetched. 86 | /// 87 | /// The id of the content type in Contentful. 88 | public Contentful WithContentType(string contentTypeId) 89 | { 90 | _contentTypeId = contentTypeId; 91 | return this; 92 | } 93 | 94 | /// 95 | /// Specifies that only entries of a specific locale should be fetched. 96 | /// 97 | /// 98 | /// Note that by default only the default locale is fetched. If a specific locale is specified only that locale will be fetched. 99 | /// To fetch all locales use "*". 100 | /// 101 | /// The locale code of the locale in Contentful. E.g. "en-US". 102 | public Contentful WithLocale(string locale) 103 | { 104 | _locale = locale; 105 | return this; 106 | } 107 | 108 | /// 109 | /// Specifies the levels of included assets and entries that should be resolved when calling Contentful. 110 | /// 111 | /// 112 | /// Note that the maximum number of levels resolved are 10 and the default is 1. 113 | /// 114 | /// The number of levels of references that should be resolved. 115 | public Contentful WithIncludes(int includes) 116 | { 117 | _includes = includes; 118 | return this; 119 | } 120 | 121 | /// 122 | /// Specifies the maximum number of entries to fetch from Contentful. 123 | /// 124 | /// The maximum number of entries. 125 | public Contentful WithLimit(int limit) 126 | { 127 | _limit = limit; 128 | return this; 129 | } 130 | 131 | /// 132 | /// Specifies the number of entries to skip when fetching from Contentful. 133 | /// 134 | /// The number of entries to skip. 135 | public Contentful WithSkip(int skip) 136 | { 137 | _skip = skip; 138 | return this; 139 | } 140 | 141 | /// 142 | /// Specifies that the Contentful API should be called recursively until all entries have been fetched. 143 | /// 144 | /// 145 | /// Note that this might result in several calls being made to the Contentful API. Ratelimits may apply. 146 | /// 147 | public Contentful WithRecursiveCalling() 148 | { 149 | _recursive = true; 150 | return this; 151 | } 152 | 153 | /// 154 | public IEnumerable Execute(IReadOnlyList inputs, IExecutionContext context) 155 | { 156 | Space space = null; 157 | 158 | try 159 | { 160 | space = _client.GetSpace().Result; 161 | } 162 | catch (AggregateException ae) 163 | { 164 | ae.Handle((ex) => { 165 | 166 | if (ex is ContentfulException) 167 | { 168 | Trace.TraceError($"Error when fetching space from Contentful: {ex.Message}"); 169 | Trace.TraceError($"Details: {(ex as ContentfulException).ErrorDetails.Errors}"); 170 | Trace.TraceError($"ContentfulRequestId:{(ex as ContentfulException).RequestId}"); 171 | } 172 | 173 | return false; 174 | }); 175 | } 176 | 177 | var queryBuilder = CreateQueryBuilder(); 178 | ContentfulCollection entries = null; 179 | 180 | try 181 | { 182 | entries = _client.GetEntries(queryBuilder).Result; 183 | } 184 | catch (AggregateException ae) 185 | { 186 | ae.Handle((ex) => { 187 | 188 | if (ex is ContentfulException) 189 | { 190 | Trace.TraceError($"Error when fetching entries from Contentful: {ex.Message}"); 191 | Trace.TraceError($"Details: {(ex as ContentfulException).ErrorDetails.Errors}"); 192 | Trace.TraceError($"ContentfulRequestId:{(ex as ContentfulException).RequestId}"); 193 | } 194 | 195 | return false; 196 | }); 197 | } 198 | 199 | if (_recursive && entries.Total > entries.Items.Count()) 200 | { 201 | entries = FetchEntriesRecursively(entries); 202 | } 203 | 204 | var includedAssets = entries.IncludedAssets; 205 | var includedEntries = entries.IncludedEntries; 206 | 207 | var locales = space.Locales.Where(l => l.Default); 208 | 209 | if (_locale == "*") 210 | { 211 | locales = space.Locales; 212 | } 213 | else if (!string.IsNullOrEmpty(_locale)) 214 | { 215 | locales = space.Locales.Where(l => l.Code == _locale); 216 | } 217 | 218 | if (!locales.Any()) 219 | { 220 | //Warn or throw here? 221 | throw new ArgumentException($"Locale {_locale} not found for space. Note that locale codes are case-sensitive."); 222 | } 223 | 224 | foreach (var entry in entries) 225 | { 226 | foreach (var locale in locales) 227 | { 228 | var localeCode = locale.Code; 229 | 230 | var items = (entry as IEnumerable>).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); 231 | 232 | var content = "No content"; 233 | 234 | content = items.ContainsKey(_contentField) ? GetNextValidJTokenValue(items[_contentField], locale).Value() : "No content"; 235 | 236 | if (string.IsNullOrEmpty(_contentField)) 237 | { 238 | content = ""; 239 | } 240 | 241 | var metaData = items 242 | .Where(c => !c.Key.StartsWith("$") && c.Key != "sys") 243 | .Select(c => new KeyValuePair(c.Key, GetNextValidJTokenValue(c.Value, locale))).ToList(); 244 | metaData.Add(new KeyValuePair(ContentfulKeys.EntryId, $"{entry["sys"]["id"]}")); 245 | metaData.Add(new KeyValuePair(ContentfulKeys.EntryLocale, localeCode)); 246 | metaData.Add(new KeyValuePair(ContentfulKeys.IncludedAssets, includedAssets)); 247 | metaData.Add(new KeyValuePair(ContentfulKeys.IncludedEntries, includedEntries)); 248 | var doc = context.GetDocument(context.GetContentStream(content), metaData); 249 | 250 | yield return doc; 251 | } 252 | } 253 | 254 | JToken GetNextValidJTokenValue(JToken token, Locale locale) 255 | { 256 | var localizedField = token.Children().FirstOrDefault(c => c.Name == locale.Code); 257 | 258 | if (localizedField == null && locale.FallbackCode != null) 259 | { 260 | return GetNextValidJTokenValue(token, locales.FirstOrDefault(c => c.Code == locale.FallbackCode)); 261 | } 262 | 263 | return localizedField?.Value; 264 | } 265 | } 266 | 267 | private QueryBuilder CreateQueryBuilder() 268 | { 269 | var queryBuilder = QueryBuilder.New.LocaleIs("*").Include(_includes) 270 | .OrderBy(SortOrderBuilder.New("sys.createdAt").Build()) 271 | .Limit(_limit).Skip(_skip); 272 | 273 | if (!string.IsNullOrEmpty(_contentTypeId)) 274 | { 275 | queryBuilder.ContentTypeIs(_contentTypeId); 276 | } 277 | 278 | return queryBuilder; 279 | } 280 | 281 | private ContentfulCollection FetchEntriesRecursively(ContentfulCollection entries) 282 | { 283 | var entryList = new List(); 284 | var includedAssets = new List(); 285 | var includedEntries = new List>(); 286 | var collection = new ContentfulCollection() 287 | { 288 | Items = entries.Items 289 | }; 290 | entryList.AddRange(entries.Items); 291 | includedAssets.AddRange(entries.IncludedAssets); 292 | includedEntries.AddRange(entries.IncludedEntries); 293 | 294 | _skip += _limit; 295 | 296 | while (collection.Count() == _limit) 297 | { 298 | collection = _client.GetEntries(CreateQueryBuilder()).Result; 299 | 300 | entryList.AddRange(collection.Items); 301 | includedAssets.AddRange(collection.IncludedAssets.Where(c => !includedAssets.Any(a => a.SystemProperties.Id == c.SystemProperties.Id))); 302 | includedEntries.AddRange(collection.IncludedEntries.Where(c => !includedAssets.Any(a => a.SystemProperties.Id == c.SystemProperties.Id))); 303 | 304 | _skip += _limit; 305 | } 306 | 307 | collection.Items = entryList; 308 | collection.IncludedAssets = includedAssets; 309 | collection.IncludedEntries = includedEntries; 310 | 311 | return collection; 312 | } 313 | } 314 | } 315 | --------------------------------------------------------------------------------