diff --git a/README.md b/README.md index 36393b4..a52375d 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,189 @@ # PineBlog PineBlog -A blogging engine based on ASP.NET Core MVC Razor Pages and Entity Framework Core. [![Build Status](https://dev.azure.com/ofpinewood/Of%20Pine%20Wood/_apis/build/status/ofpinewood.pineblog?branchName=master)](https://dev.azure.com/ofpinewood/Of%20Pine%20Wood/_build/latest?definitionId=7&branchName=master) [![NuGet Badge](https://img.shields.io/nuget/v/Opw.PineBlog.svg)](https://www.nuget.org/packages/Opw.PineBlog/) [![License: MIT](https://img.shields.io/github/license/ofpinewood/pineblog.svg)](https://github.com/ofpinewood/pineblog/blob/master/LICENSE) -## Opw.PineBlog metapackage -The Opw.PineBlog metapackage includes the following packages. +PineBlog is a light-weight open source blogging engine written in ASP.NET Core MVC Razor Pages, using Entity Framework Core. It is highly extendable, customizable and easy to integrate in an existing web application. -### Opw.PineBlog.EntityFrameworkCore -The PineBlog data provider that uses Entity Framework Core. +### Features -[![NuGet Badge](https://img.shields.io/nuget/v/Opw.PineBlog.EntityFrameworkCore.svg)](https://www.nuget.org/packages/Opw.PineBlog.EntityFrameworkCore/) +- Markdown post editor +- File management +- Light-weight using Razor Pages +- SEO optimized +- Open Graph protocol +- Clean Architecture (youtube: [Clean Architecture with ASP.NET Core](https://youtu.be/_lwCVE_XgqI)) +- Entity Framework Core, SQL database +- Azure Blob Storage, for file storage +- ..only a blogging engine, nothing else.. -### Opw.PineBlog.RazorPages -The PineBlog UI using ASP.NET Core MVC Razor Pages. +### What is not included +Because PineBlog is very light-weight it is not a complete website, it needs to be integrated in an existing web application of you need to create a basic web application for it. There are a few things PineBlog depends on, but that it does not provide. -[![NuGet Badge](https://img.shields.io/nuget/v/Opw.PineBlog.RazorPages.svg)](https://www.nuget.org/packages/Opw.PineBlog.RazorPages/) +- Authentication and authorization -### Opw.PineBlog.Core -The PineBlog core package. This package is a dependency for `Opw.PineBlog.RazorPages` and `Opw.PineBlog.EntityFrameworkCore`. - -[![NuGet Badge](https://img.shields.io/nuget/v/Opw.PineBlog.Core.svg)](https://www.nuget.org/packages/Opw.PineBlog.Core/) +> **Note:** The admin pages require that authentication/authorization has been setup in your website, the admin area has a `AuthorizeFilter` with the default policy set to all pages in that area folder. ## Where can I get it? -You can install [Opw.PineBlog](https://www.nuget.org/packages/Opw.PineBlog/) from the NuGet package manager console: +You can install the [Opw.PineBlog](https://www.nuget.org/packages/Opw.PineBlog/) metapackage from the console. -``` ps -PM> Install-Package Opw.PineBlog +``` cmd +> dotnet add package Opw.PineBlog ``` + +The Opw.PineBlog metapackage includes the following packages. + +- **Opw.PineBlog.EntityFrameworkCore package** +The PineBlog data provider that uses Entity Framework Core. +[![NuGet Badge](https://img.shields.io/nuget/v/Opw.PineBlog.EntityFrameworkCore.svg)](https://www.nuget.org/packages/Opw.PineBlog.EntityFrameworkCore/) + +- **Opw.PineBlog.RazorPages package** +The PineBlog UI using ASP.NET Core MVC Razor Pages. +[![NuGet Badge](https://img.shields.io/nuget/v/Opw.PineBlog.RazorPages.svg)](https://www.nuget.org/packages/Opw.PineBlog.RazorPages/) + +- **Opw.PineBlog.Core package** +The PineBlog core package. This package is a dependency for `Opw.PineBlog.RazorPages` and `Opw.PineBlog.EntityFrameworkCore`. +[![NuGet Badge](https://img.shields.io/nuget/v/Opw.PineBlog.Core.svg)](https://www.nuget.org/packages/Opw.PineBlog.Core/) + ## Getting started -[TODO] +Add the PineBlog services and the Razor Pages UI in the Startup.cs of your application. + +``` csharp +public void ConfigureServices(IServiceCollection services) +{ + ... + services.AddPineBlog(Configuration); + + services.AddMvc().AddPineBlogRazorPages(); + // or services.AddMvcCore().AddPineBlogRazorPages(); + ... +} +``` + +### Configuration +A few properties need to be configured before you can run your web application with PineBlog. + +**Title** the title of your blog/website. +**CoverUrl** the URL for the cover image of your blog, this can be a relative or absolute URL. +**ConnectionStringName** this is the name to the connection string used in your application. +**CreateAndSeedDatabases** to automatically create and seed the tables for the blog set this property to `true`, if you want to create and seed your database in any other way set this property to `false`. +**AzureStorageConnectionString** your Azure Blog Storage connection string. +**AzureStorageBlobContainerName** the name of the blob container to use for file storage. +**FileBaseUrl** The base URL for the files, this should be the URL for your Azure Blob Storage, e.g. `https://.blob.core.windows.net`. + + +The rest of the properties are optional and will be set with default values if you don't specify them. + +``` json +{ + "ConnectionStrings": { + "DefaultConnection": "Server=inMemory; Database=pineblog-db;" + }, + "PineBlogOptions": { + "Title": "PineBlog", + "Description": "A blogging engine based on ASP.NET Core MVC Razor Pages and Entity Framework Core", + "CoverUrl": "/images/woods.gif", + "CoverCaption": "Battle background for the Misty Woods in the game Shadows of Adam by Tim Wendorf", + "CoverLink": "http://pixeljoint.com/pixelart/94359.htm", + "ItemsPerPage": 5, + "CreateAndSeedDatabases": true, + "ConnectionStringName": "DefaultConnection", + "AzureStorageConnectionString": "UseDevelopmentStorage=true", + "AzureStorageBlobContainerName": "pineblog", + "FileBaseUrl": "http://127.0.0.1:10000/devstoreaccount1" + } +} +``` + +## Blog layout page +For the **Blog** area you need to override the `_Layout.cshtml` for the pages, to do this create a new `_Layout.cshtml` page in the `Areas/Blog/Shared` folder. This will make the blog pages use that layout page instead of the one included in the `Opw.PineBlog.RazorPages` package. +In the new page you can set the layout page of your website. Make sure to add the `head` and `script` sections. + +``` csharp +@{ + Layout = "~/Pages/Shared/_Layout.cshtml"; +} +@section head { + @RenderSection("head", required: false) +} +@section scripts { + @RenderSection("scripts", required: false) +} +@RenderBody() +``` + +### Your layout page +PineBlog is dependent on [Bootstrap 4.3](https://getbootstrap.com/) and [Font Awesome 4.7](https://fontawesome.com/v4.7.0/), so make sure to include them in your layout page and add the necessary files to the `wwwroot` of your project (see the sample project for an example). + +``` html + + + ... + + + + + + + + + + + ... + + + ... + + + + + + + + + + + + +``` ### Open Graph protocol -The blog pages include Open Graph `` tags in the `` of the page. Make sure you add the prefix to the `` tag in your pagers. +The blog pages include Open Graph `` tags in the `` of the page. These require that you add the `og: http://ogp.me/ns#` prefix to the `` tag in your pages. ``` html ``` +### Overriding the UI +You can override any other Razor view you like by following the same steps as described above for the layout page. For an example have a look at the sample project where we override the footer ([_Footer.cshtml](https://github.com/ofpinewood/pineblog/blob/master/samples/Opw.PineBlog.Sample/Areas/Blog/Pages/Shared/_Footer_.cshtml)). + +## Admin layout page +For the **Admin** area layout page do the same as you did for the **Blog** area. + ## Samples -See [Opw.PineBlog.Sample](/docs/Opw.PineBlog.Sample.md). +The [sample project](https://github.com/ofpinewood/pineblog/tree/master/samples/Opw.PineBlog.Sample) contains an example web application with PineBlog. + +**Please see the code** :nerd_face ## Usage PineBlog is used on the following website: diff --git a/docs/Opw.PineBlog.Sample.md b/docs/Opw.PineBlog.Sample.md deleted file mode 100644 index 192727f..0000000 --- a/docs/Opw.PineBlog.Sample.md +++ /dev/null @@ -1,4 +0,0 @@ -# Sample project using PineBlog -This sample project contains an example integration of PineBlog. - -**Please see the code** :nerd_face \ No newline at end of file diff --git a/samples/Opw.PineBlog.Sample/Areas/Blog/Pages/Shared/_Footer.cshtml b/samples/Opw.PineBlog.Sample/Areas/Blog/Pages/Shared/_Footer.cshtml new file mode 100644 index 0000000..d66d3f4 --- /dev/null +++ b/samples/Opw.PineBlog.Sample/Areas/Blog/Pages/Shared/_Footer.cshtml @@ -0,0 +1,24 @@ + + diff --git a/src/Opw.PineBlog.RazorPages/Areas/Blog/Pages/Index.cshtml.cs b/src/Opw.PineBlog.RazorPages/Areas/Blog/Pages/Index.cshtml.cs index be4a26a..e4fd931 100644 --- a/src/Opw.PineBlog.RazorPages/Areas/Blog/Pages/Index.cshtml.cs +++ b/src/Opw.PineBlog.RazorPages/Areas/Blog/Pages/Index.cshtml.cs @@ -50,10 +50,12 @@ public async Task OnGetAsync(CancellationToken cancellationToken, PageCover = new Models.PageCoverModel { + PostListType = PostList.PostListType, + Category = PostList.Category, Title = PostList.Blog.Title, CoverUrl = PostList.Blog.CoverUrl, CoverCaption = PostList.Blog.CoverCaption, - CoverLink = PostList.Blog.CoverLink + CoverLink = PostList.Blog.CoverLink, }; return Page(); diff --git a/src/Opw.PineBlog.RazorPages/Areas/Blog/Pages/Post.cshtml.cs b/src/Opw.PineBlog.RazorPages/Areas/Blog/Pages/Post.cshtml.cs index 87b70bc..d1e6cf2 100644 --- a/src/Opw.PineBlog.RazorPages/Areas/Blog/Pages/Post.cshtml.cs +++ b/src/Opw.PineBlog.RazorPages/Areas/Blog/Pages/Post.cshtml.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using Opw.PineBlog.Models; using Opw.PineBlog.Posts; namespace Opw.PineBlog.RazorPages.Areas.Blog.Pages diff --git a/tests/Opw.PineBlog.Core.Tests/Posts/GetPagedPostListQueryTests.cs b/tests/Opw.PineBlog.Core.Tests/Posts/GetPagedPostListQueryTests.cs index cbd1d89..e45442e 100644 --- a/tests/Opw.PineBlog.Core.Tests/Posts/GetPagedPostListQueryTests.cs +++ b/tests/Opw.PineBlog.Core.Tests/Posts/GetPagedPostListQueryTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Opw.PineBlog.Entities; +using Opw.PineBlog.Models; using System; using System.Linq; using System.Threading.Tasks; @@ -51,6 +52,24 @@ public async Task Handler_Should_ReturnPostListModel_With6Posts_WhenIncludingUnp result.Value.Posts.Should().HaveCount(6); } + [Fact] + public async Task Handler_Should_ReturnPostListModel_WithPostListTypeBlog() + { + var result = await Mediator.Send(new GetPagedPostListQuery()); + + result.IsSuccess.Should().BeTrue(); + result.Value.PostListType.Should().Be(PostListType.Blog); + } + + [Fact] + public async Task Handler_Should_ReturnPostListModel_WhenFilterOnCategory_WithPostListTypeCategory() + { + var result = await Mediator.Send(new GetPagedPostListQuery { Category = "category" }); + + result.IsSuccess.Should().BeTrue(); + result.Value.PostListType.Should().Be(PostListType.Category); + } + [Fact] public async Task Handler_Should_ReturnPostListModel_WithBlogInfo() { diff --git a/tests/Opw.PineBlog.RazorPages.Tests/Areas/Blog/Pages/IndexModelTests.cs b/tests/Opw.PineBlog.RazorPages.Tests/Areas/Blog/Pages/IndexModelTests.cs index eb1ed42..0adf078 100644 --- a/tests/Opw.PineBlog.RazorPages.Tests/Areas/Blog/Pages/IndexModelTests.cs +++ b/tests/Opw.PineBlog.RazorPages.Tests/Areas/Blog/Pages/IndexModelTests.cs @@ -41,17 +41,93 @@ public async Task OnGetAsync_Should_SetPostListModel() pageModel.PostList.Blog.Should().NotBeNull(); pageModel.PostList.Pager.Should().NotBeNull(); pageModel.PostList.Posts.Should().NotBeNull(); - pageModel.Title.Should().NotBeNull(); pageModel.PostList.Blog.Title.Should().Be("Blog title"); pageModel.PostList.Pager.Should().NotBeNull(); pageModel.PostList.Posts.Should().NotBeNull(); pageModel.Title.Should().Be("Blog title"); + } + + [Fact] + public async Task OnGetAsync_Should_SetMetadataModel() + { + var loggerMock = new Mock>(); + var mediaterMock = new Mock(); + mediaterMock.Setup(m => m.Send(It.IsAny>>(), It.IsAny())) + .ReturnsAsync(Result.Success(GetPostListModel())); + + var httpContext = new DefaultHttpContext(); + var pageContext = GetPageContext(httpContext); + + var pageModel = new IndexModel(mediaterMock.Object, loggerMock.Object) + { + PageContext = pageContext.Item1, + TempData = GetTempDataDictionary(httpContext), + Url = new UrlHelper(pageContext.Item2) + }; + + var result = await pageModel.OnGetAsync(default, 0); + + result.Should().BeOfType(); + pageModel.Metadata.Title.Should().Be("Blog title"); pageModel.Metadata.Description.Should().Be("Blog description"); - pageModel.PageCover.Title.Should().Be("Blog title"); } [Fact] - public async Task OnGetAsync_Should_SetPostListModel_WithAbsoluteCoverUrl() + public async Task OnGetAsync_Should_SetPageCoverModel_WhenNoFilters() + { + var loggerMock = new Mock>(); + var mediaterMock = new Mock(); + mediaterMock.Setup(m => m.Send(It.IsAny>>(), It.IsAny())) + .ReturnsAsync(Result.Success(GetPostListModel())); + + var httpContext = new DefaultHttpContext(); + var pageContext = GetPageContext(httpContext); + + var pageModel = new IndexModel(mediaterMock.Object, loggerMock.Object) + { + PageContext = pageContext.Item1, + TempData = GetTempDataDictionary(httpContext), + Url = new UrlHelper(pageContext.Item2) + }; + + var result = await pageModel.OnGetAsync(default, 0); + + result.Should().BeOfType(); + pageModel.PageCover.PostListType.Should().Be(PostListType.Blog); + pageModel.PageCover.Category.Should().BeNull(); + } + + [Fact] + public async Task OnGetAsync_Should_SetPageCoverModel_WhenFilterOnCategory() + { + var loggerMock = new Mock>(); + var postListModel = GetPostListModel(); + postListModel.PostListType = PostListType.Category; + postListModel.Category = "category"; + + var mediaterMock = new Mock(); + mediaterMock.Setup(m => m.Send(It.IsAny>>(), It.IsAny())) + .ReturnsAsync(Result.Success(postListModel)); + + var httpContext = new DefaultHttpContext(); + var pageContext = GetPageContext(httpContext); + + var pageModel = new IndexModel(mediaterMock.Object, loggerMock.Object) + { + PageContext = pageContext.Item1, + TempData = GetTempDataDictionary(httpContext), + Url = new UrlHelper(pageContext.Item2) + }; + + var result = await pageModel.OnGetAsync(default, 0); + + result.Should().BeOfType(); + pageModel.PageCover.PostListType.Should().Be(PostListType.Category); + pageModel.PageCover.Category.Should().Be("category"); + } + + [Fact] + public async Task OnGetAsync_Should_SetAbsoluteCoverUrl() { var loggerMock = new Mock>(); @@ -76,11 +152,12 @@ public async Task OnGetAsync_Should_SetPostListModel_WithAbsoluteCoverUrl() result.Should().BeOfType(); pageModel.PostList.Blog.CoverUrl.Should().Be("http://www.example.com/images.jpg"); + pageModel.PageCover.CoverUrl.Should().Be("http://www.example.com/images.jpg"); pageModel.Metadata.Image.Should().Be("http://www.example.com/images.jpg"); } [Fact] - public async Task OnGetAsync_Should_SetPostListModel_WithRelativeCoverUrl() + public async Task OnGetAsync_Should_SetRelativeCoverUrl() { var loggerMock = new Mock>(); @@ -107,6 +184,7 @@ public async Task OnGetAsync_Should_SetPostListModel_WithRelativeCoverUrl() result.Should().BeOfType(); pageModel.PostList.Blog.CoverUrl.Should().Be("/images.jpg"); + pageModel.PageCover.CoverUrl.Should().Be("/images.jpg"); pageModel.Metadata.Image.Should().Be("http://localhost:5001/images.jpg"); } @@ -114,6 +192,7 @@ private PostListModel GetPostListModel() { return new PostListModel { + PostListType = PostListType.Blog, Blog = new BlogModel(new PineBlogOptions { Title = "Blog title", Description = "Blog description" }), Pager = new Pager(0), Posts = new List()