feat: add preliminary /blog routes

This change also introduces toml file config
This commit is contained in:
Oliver Booth 2023-08-06 15:57:23 +01:00
parent ba8e186cb5
commit bd999f0ed8
Signed by: oliverbooth
GPG Key ID: 725DB725A0D9EE61
12 changed files with 360 additions and 0 deletions

View File

@ -0,0 +1,42 @@
namespace OliverBooth.Data.Blog;
/// <summary>
/// Represents an author of a blog post.
/// </summary>
public sealed class Author : IEquatable<Author>
{
/// <summary>
/// Gets or sets the email address of the author.
/// </summary>
/// <value>The email address.</value>
public string? EmailAddress { get; set; }
/// <summary>
/// Gets the ID of the author.
/// </summary>
/// <value>The ID.</value>
public int Id { get; private set; }
/// <summary>
/// Gets or sets the name of the author.
/// </summary>
/// <value>The name.</value>
public string Name { get; set; } = string.Empty;
public bool Equals(Author? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Id == other.Id;
}
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is Author other && Equals(other);
}
public override int GetHashCode()
{
return Id;
}
}

View File

@ -0,0 +1,66 @@
namespace OliverBooth.Data.Blog;
/// <summary>
/// Represents a blog post.
/// </summary>
public sealed class BlogPost : IEquatable<BlogPost>
{
/// <summary>
/// Gets the ID of the author.
/// </summary>
/// <value>The author ID.</value>
public int AuthorId { get; private set; }
/// <summary>
/// Gets or sets the body of the blog post.
/// </summary>
/// <value>The body.</value>
public string Body { get; set; } = string.Empty;
/// <summary>
/// Gets the ID of the blog post.
/// </summary>
/// <value>The ID.</value>
public int Id { get; private set; }
/// <summary>
/// Gets or sets the date and time at which the blog post was published.
/// </summary>
/// <value>The publish timestamp.</value>
public DateTimeOffset Published { get; set; }
/// <summary>
/// Gets or sets the URL slug of the blog post.
/// </summary>
/// <value>The URL slug.</value>
public string Slug { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the title of the blog post.
/// </summary>
/// <value>The title.</value>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the legacy WordPress ID of the blog post.
/// </summary>
/// <value>The legacy WordPress ID.</value>
public int? WordPressId { get; set; }
public bool Equals(BlogPost? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Id == other.Id;
}
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is BlogPost other && Equals(other);
}
public override int GetHashCode()
{
return Id;
}
}

View File

@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace OliverBooth.Data.Blog.Configuration;
/// <summary>
/// Represents the configuration for the <see cref="Author" /> entity.
/// </summary>
internal sealed class AuthorConfiguration : IEntityTypeConfiguration<Author>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<Author> builder)
{
builder.ToTable("Author");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedOnAdd();
builder.Property(e => e.Name).HasMaxLength(100).IsRequired();
builder.Property(e => e.EmailAddress).HasMaxLength(255).IsRequired(false);
}
}

View File

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace OliverBooth.Data.Blog.Configuration;
/// <summary>
/// Represents the configuration for the <see cref="BlogPost" /> entity.
/// </summary>
internal sealed class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<BlogPost> builder)
{
builder.ToTable("BlogPost");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedOnAdd();
builder.Property(e => e.WordPressId).IsRequired(false);
builder.Property(e => e.Slug).HasMaxLength(100).IsRequired();
builder.Property(e => e.AuthorId).IsRequired();
builder.Property(e => e.Published).IsRequired();
builder.Property(e => e.Title).HasMaxLength(255).IsRequired();
builder.Property(e => e.Body).IsRequired();
}
}

View File

@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data.Blog;
using OliverBooth.Data.Blog.Configuration;
namespace OliverBooth.Data;
/// <summary>
/// Represents a session with the blog database.
/// </summary>
public sealed class BlogContext : DbContext
{
private readonly IConfiguration _configuration;
public BlogContext(IConfiguration configuration)
{
_configuration = configuration;
}
/// <summary>
/// Gets the set of authors.
/// </summary>
/// <value>The set of authors.</value>
public DbSet<Author> Authors { get; internal set; } = null!;
/// <summary>
/// Gets the set of blog posts.
/// </summary>
/// <value>The set of blog posts.</value>
public DbSet<BlogPost> BlogPosts { get; internal set; } = null!;
/// <inheritdoc />
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
string connectionString = _configuration.GetConnectionString("Blog") ?? string.Empty;
ServerVersion serverVersion = ServerVersion.AutoDetect(connectionString);
optionsBuilder.UseMySql(connectionString, serverVersion);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new AuthorConfiguration());
modelBuilder.ApplyConfiguration(new BlogPostConfiguration());
}
}

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
namespace OliverBooth.Data;
/// <summary>
/// Represents a session with the web database.
/// </summary>
public sealed class WebContext : DbContext
{
private readonly IConfiguration _configuration;
public WebContext(IConfiguration configuration)
{
_configuration = configuration;
}
/// <inheritdoc />
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
string connectionString = _configuration.GetConnectionString("Web") ?? string.Empty;
ServerVersion serverVersion = ServerVersion.AutoDetect(connectionString);
optionsBuilder.UseMySql(connectionString, serverVersion);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
}
}

View File

@ -14,9 +14,13 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Alexinea.Extensions.Configuration.Toml" Version="7.0.0"/>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="Markdig" Version="0.32.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="7.0.9"/> <PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="7.0.9"/>
<PackageReference Include="NLog" Version="5.2.3"/> <PackageReference Include="NLog" Version="5.2.3"/>
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.3"/> <PackageReference Include="NLog.Extensions.Logging" Version="5.3.3"/>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="7.0.0"/>
<PackageReference Include="X10D" Version="3.2.2"/> <PackageReference Include="X10D" Version="3.2.2"/>
<PackageReference Include="X10D.Hosting" Version="3.2.2"/> <PackageReference Include="X10D.Hosting" Version="3.2.2"/>
</ItemGroup> </ItemGroup>

View File

@ -0,0 +1,9 @@
@page "/blog/{year:int}/{month:int}/{slug}"
@model OliverBooth.Pages.Blog.Article
@if (Model.Post is { } post)
{
<h1>@post.Title</h1>
<p class="text-muted">@post.Published.ToString("MMMM dd, yyyy") &bull; @Model.Author?.Name</p>
@Html.Raw(Model.SanitizeContent(post.Body))
}

View File

@ -0,0 +1,50 @@
using Humanizer;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data;
using OliverBooth.Data.Blog;
namespace OliverBooth.Pages.Blog;
public class Article : PageModel
{
private readonly IDbContextFactory<BlogContext> _dbContextFactory;
public Article(IDbContextFactory<BlogContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public Author? Author { get; private set; }
public BlogPost? Post { get; private set; }
public string SanitizeContent(string content)
{
content = content.Replace("<more>", string.Empty);
while (content.Contains("\n\n"))
{
content = content.Replace("\n\n", "\n");
}
return Markdig.Markdown.ToHtml(content.Trim());
}
public void OnGet(int year, int month, string slug)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
Post = context.BlogPosts.FirstOrDefault(p => p.Published.Year == year &&
p.Published.Month == month &&
p.Slug == slug);
if (Post is null)
{
Response.StatusCode = 404;
}
else
{
Author = context.Authors.FirstOrDefault(a => a.Id == Post.AuthorId);
}
}
}

View File

@ -0,0 +1,16 @@
@page
@using OliverBooth.Data.Blog
@model OliverBooth.Pages.Blog.Index
@foreach (BlogPost post in Model.BlogPosts)
{
<h2>
<a asp-page="/blog/article" asp-route-year="@post.Published.Year" asp-route-month="@post.Published.Month.ToString().PadLeft(2, '0')" asp-route-slug="@post.Slug">@post.Title</a>
</h2>
<p class="text-muted">@post.Published.ToString("MMMM dd, yyyy") &bull; @Model.GetAuthor(post)?.Name</p>
<p>@Html.Raw(Model.SanitizeContent(Model.TrimContent(post.Body, out bool trimmed)))</p>
if (trimmed)
{
<p><a asp-page="/blog/article" asp-route-year="@post.Published.Year" asp-route-month="@post.Published.Month.ToString().PadLeft(2, '0')" asp-route-slug="@post.Slug">Read more...</a></p>
}
}

View File

@ -0,0 +1,51 @@
using Humanizer;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data;
using OliverBooth.Data.Blog;
namespace OliverBooth.Pages.Blog;
public class Index : PageModel
{
private readonly IDbContextFactory<BlogContext> _dbContextFactory;
public Index(IDbContextFactory<BlogContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public IReadOnlyCollection<BlogPost> BlogPosts { get; private set; } = ArraySegment<BlogPost>.Empty;
public string SanitizeContent(string content)
{
content = content.Replace("<more>", string.Empty);
while (content.Contains("\n\n"))
{
content = content.Replace("\n\n", "\n");
}
return Markdig.Markdown.ToHtml(content.Trim());
}
public string TrimContent(string content, out bool trimmed)
{
ReadOnlySpan<char> span = content.AsSpan();
int moreIndex = span.IndexOf("<more>", StringComparison.Ordinal);
trimmed = moreIndex != -1 || span.Length > 256;
return moreIndex != -1 ? span[..moreIndex].Trim().ToString() : content.Truncate(256);
}
public Author? GetAuthor(BlogPost post)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
return context.Authors.FirstOrDefault(a => a.Id == post.AuthorId);
}
public void OnGet()
{
using BlogContext context = _dbContextFactory.CreateDbContext();
BlogPosts = context.BlogPosts.ToArray();
}
}

View File

@ -1,13 +1,17 @@
using NLog.Extensions.Logging; using NLog.Extensions.Logging;
using OliverBooth.Data;
using OliverBooth.Services; using OliverBooth.Services;
using X10D.Hosting.DependencyInjection; using X10D.Hosting.DependencyInjection;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddTomlFile("data/config.toml", true, true);
builder.Logging.ClearProviders(); builder.Logging.ClearProviders();
builder.Logging.AddNLog(); builder.Logging.AddNLog();
builder.Services.AddHostedSingleton<LoggingService>(); builder.Services.AddHostedSingleton<LoggingService>();
builder.Services.AddDbContextFactory<BlogContext>();
builder.Services.AddDbContextFactory<WebContext>();
builder.Services.AddRazorPages().AddRazorRuntimeCompilation(); builder.Services.AddRazorPages().AddRazorRuntimeCompilation();
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();
builder.Services.AddRouting(options => options.LowercaseUrls = true); builder.Services.AddRouting(options => options.LowercaseUrls = true);