Compare commits

...

68 Commits

Author SHA1 Message Date
5d10f251a8
fix: move sender name to Reply-To 2024-10-20 04:07:36 +01:00
ee8a2cb569
feat: add configurable SMTP settings 2024-10-20 04:02:54 +01:00
fdf8f9c32d
[ci skip] docs: add detailed README 2024-10-18 23:11:15 +01:00
fa087a513d
style: write as first person 2024-09-17 20:46:49 +01:00
771ccc52ad
refactor: haha I'm a student now
dear god help me
2024-09-17 20:45:36 +01:00
59e42ff7cd
perf: use http utility to decode url 2024-07-15 19:44:02 +01:00
d32d46e221
fix: fix tag links 2024-07-15 19:38:56 +01:00
0bebcb69fe
fix: fix display of single-page tabs 2024-07-15 19:38:34 +01:00
d3ac89d071
refactor: use mastodon info from configuration 2024-07-05 17:56:42 +01:00
901a8347b9
refactor: move to new mastodon instance 2024-07-05 00:46:40 +01:00
8dbfeb8d38
chore: drop version in docker-compose 2024-06-16 19:29:37 +01:00
eb67c25e09
refactor: C != C++ 2024-06-16 19:16:48 +01:00
15e28bd223
feat: add keystroke class 2024-05-12 13:31:36 +01:00
acb6b32938
style: begin to organise app.scss monolith
colors now defined separately
2024-05-12 13:31:08 +01:00
cf4d92c035
fix: oops, page 2 was completely missing.
The model contains a 1-based page number, whereas GetBlogPosts wants 0-based index, causing an entire page to be missing.
2024-05-06 17:40:03 +01:00
58797b82ca
fix: don't include redirected posts in count 2024-05-06 17:39:28 +01:00
9991ecf173
Merge branch 'feature/cleanup' 2024-05-06 15:02:19 +01:00
7d21bc0b85
style: use custom post tag styling 2024-05-06 14:59:37 +01:00
c9b64cc778
style: remove border from mastodon card, reduce border-radius 2024-05-06 14:54:22 +01:00
23d3950695
chore: add launchSettings.json to make development not suck
This workflow allows the possibility to reload a changed page without reloading the entire app. I hate launchSettings.json, but whatever.
2024-05-05 21:10:23 +01:00
83beffe685
perf: remove redundant StatusCode set 2024-05-05 20:55:32 +01:00
f08a3d3607
feat: add real http status pages 2024-05-05 20:55:13 +01:00
746b4d8728
fix: don't use 0-based page index in blog index 2024-05-05 18:16:17 +01:00
435a69b27a
perf: add pagination to blog post list
removes the need for API controller accessed via JS
2024-05-05 18:13:06 +01:00
435cae95db
refactor: move Mastodon status to partial 2024-05-05 13:48:01 +01:00
2ec2c0befc
chore: move Markdig reference to Markdig extension project 2024-05-05 02:27:21 +01:00
99ff3124c3
refactor: move SmartFormat extensions to separate project 2024-05-05 02:27:04 +01:00
dec9307f1d
refactor: amend 6ec4103a3a
Remove unused Markdown classes from within core web app project
2024-05-05 02:21:40 +01:00
6ec4103a3a
refactor: separate Markdig extensions from project
Also introduces .Common project to house common references and types
2024-05-05 02:18:20 +01:00
e0037fbff2
refactor: use bootstrap's collapse functionality
they do it better than me I guess
2024-05-04 13:34:04 +01:00
ad12d6b836
style: revert 5bfe5a044d 2024-05-04 13:20:09 +01:00
5bfe5a044d
style: swap "important" and "question" callout colours 2024-05-04 13:14:23 +01:00
7ede8b13fa
feat: add support for collapsible callouts 2024-05-04 13:11:49 +01:00
29ed46eb9e
feat: add "important" callout 2024-05-04 01:10:37 +01:00
b0f0658148
style: use callouts instead of alerts site-wide 2024-05-04 00:59:02 +01:00
35a82a9663
feat: allow markdown in callout titles 2024-05-04 00:40:59 +01:00
01031057e0
feat: add support for Obsidian-style callouts 2024-05-03 23:31:47 +01:00
16618cc135
Merge branch 'feature/legacy-comments' 2024-05-01 16:47:51 +01:00
a7426b008b
feat: show legacy disqus comments beneath gisqus
No Disqus connection here. I just saved them to DB
2024-05-01 16:47:31 +01:00
217aaf2f79
fix(style): use correct styling for link <code> elements 2024-05-01 12:55:34 +01:00
b1f31f7850
fix(style): don't apply padding to full codeblock 2024-05-01 02:57:42 +01:00
818173b806
style: don't use bootstraps weird pink <code> style 2024-04-30 23:04:55 +01:00
cd304aa09b
style: hide line numbers, fix codeblock bg color 2024-04-30 22:58:35 +01:00
98c923b07b
style: hide empty section for articles with no other parts 2024-04-27 17:00:20 +01:00
91249029dc
style: remove comment count from article meta 2024-04-27 16:45:59 +01:00
55b9f79e46
style: remove comment counter from post cards 2024-04-27 16:27:08 +01:00
96e63a3088
feat: replace Disqus with gisqus
GitHub discussion integration doesn't serve ads. I'm so fucking sorry
2024-04-27 16:21:11 +01:00
1919b1d5c8
refactor: remove april fool's nonsense 2024-04-27 16:03:06 +01:00
a1dd6ef6ff
feat: add <meta> tag creation for tutorial articles 2024-04-27 15:59:39 +01:00
985acf7bc3
feat: pave the way for adding comments to tutorials 2024-04-27 15:59:05 +01:00
879ff6a295
feat: add support for excerpts on blog posts / tutorial articles 2024-04-27 15:41:19 +01:00
cd6bbec1a5
feat: add <meta> tag creation abstraction 2024-04-27 15:36:13 +01:00
b119861eee
fix: prevent anchor link from being a link 2024-04-27 00:31:11 +01:00
720b636439
Merge branch 'feature/code_snippets' 2024-04-27 00:26:04 +01:00
14cac1e38d
feat: add support for multi-lingual code snippets 2024-04-27 00:25:32 +01:00
ba09fa22df
chore: update actions/setup-dotnet@v4
Some checks failed
.NET / Build & Test (push) Failing after 14m51s
2024-04-26 18:45:35 +01:00
d527fce02c
ci: update actions/checkout@v4 2024-04-26 18:45:10 +01:00
81e1d25a7c
ci: remove github nuget source 2024-04-26 18:44:49 +01:00
e548758608
fix: don't show redirected posts
Some checks failed
.NET / Build & Test (push) Failing after 1m4s
2024-04-26 17:30:42 +01:00
0b1066c273
Merge branch 'feature/tutorials'
Some checks failed
.NET / Build & Test (push) Failing after 1m13s
2024-04-26 17:24:08 +01:00
05638e5deb
fix: hide non-published entries
Some checks failed
.NET / Build & Test (push) Failing after 44s
2024-04-26 17:23:53 +01:00
238f519e0c
feat: add hack to remove line numbers in codeblocks 2024-04-26 17:22:35 +01:00
817019ad16
fix: use EnumToStringConverter for Visibility fields 2024-02-23 17:57:08 +00:00
bd1e9dac1f
refactor: add MaxLength to folder fields 2024-02-23 17:51:22 +00:00
9074cf5210
feat: add visibility to tutorial folders and articles 2024-02-23 17:50:40 +00:00
577f3b0148
refactor: move BlogPostVisibility to Visibility
shared enum for blog posts and tutorials, and anything else that may need it
2024-02-23 16:50:43 +00:00
8629f8f963
Merge branch 'main' into feature/tutorials 2024-02-23 15:47:21 +00:00
9e0410f100
feat: add tutorials page 2024-02-20 20:36:23 +00:00
156 changed files with 3943 additions and 869 deletions

View File

@ -10,16 +10,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - name: Checkout
uses: actions/checkout@v4
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v3 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 8.0.x dotnet-version: 8.0.x
- name: Add NuGet source
run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json"
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore run: dotnet restore
- name: Build - name: Build
run: dotnet build --no-restore --configuration Release run: dotnet build --no-restore --configuration Release
- name: Test - name: Test
run: dotnet test --no-build --verbosity normal run: dotnet test --no-build --verbosity normal

View File

@ -12,16 +12,13 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v3 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 8.0.x dotnet-version: 8.0.x
- name: Add GitHub NuGet source
run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json"
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore run: dotnet restore

View File

@ -12,16 +12,13 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v3 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 8.0.x dotnet-version: 8.0.x
- name: Add GitHub NuGet source
run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json"
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore run: dotnet restore

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Blog; namespace OliverBooth.Common.Data.Blog;
/// <summary> /// <summary>
/// Represents the author of a blog post. /// Represents the author of a blog post.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Blog; namespace OliverBooth.Common.Data.Blog;
/// <summary> /// <summary>
/// Represents a blog post. /// Represents a blog post.
@ -25,6 +25,12 @@ public interface IBlogPost
/// </value> /// </value>
bool EnableComments { get; } bool EnableComments { get; }
/// <summary>
/// Gets the excerpt of this post, if it has one.
/// </summary>
/// <value>The excerpt, or <see langword="null" /> if this post has no excerpt.</value>
string? Excerpt { get; }
/// <summary> /// <summary>
/// Gets the ID of the post. /// Gets the ID of the post.
/// </summary> /// </summary>
@ -85,7 +91,7 @@ public interface IBlogPost
/// Gets the visibility of the post. /// Gets the visibility of the post.
/// </summary> /// </summary>
/// <value>The visibility of the post.</value> /// <value>The visibility of the post.</value>
BlogPostVisibility Visibility { get; } Visibility Visibility { get; }
/// <summary> /// <summary>
/// Gets the WordPress ID of the post. /// Gets the WordPress ID of the post.

View File

@ -0,0 +1,54 @@
namespace OliverBooth.Common.Data.Blog;
/// <summary>
/// Represents a comment that was posted on a legacy comment framework.
/// </summary>
public interface ILegacyComment
{
/// <summary>
/// Gets the PNG-encoded avatar of the author.
/// </summary>
/// <value>The author's avatar.</value>
string? Avatar { get; }
/// <summary>
/// Gets the name of the comment's author.
/// </summary>
/// <value>The author's name.</value>
string Author { get; }
/// <summary>
/// Gets the body of the comment.
/// </summary>
/// <value>The comment body.</value>
string Body { get; }
/// <summary>
/// Gets the date and time at which this comment was posted.
/// </summary>
/// <value>The creation timestamp.</value>
DateTimeOffset CreatedAt { get; }
/// <summary>
/// Gets the ID of this comment.
/// </summary>
Guid Id { get; }
/// <summary>
/// Gets the ID of the comment this comment is replying to.
/// </summary>
/// <value>The parent comment ID, or <see langword="null" /> if this comment is not a reply.</value>
Guid? ParentComment { get; }
/// <summary>
/// Gets the ID of the post to which this comment was posted.
/// </summary>
/// <value>The post ID.</value>
Guid PostId { get; }
/// <summary>
/// Gets the avatar URL of the comment's author.
/// </summary>
/// <returns>The avatar URL.</returns>
string GetAvatarUrl();
}

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Blog; namespace OliverBooth.Common.Data.Blog;
/// <summary> /// <summary>
/// Represents a user which can log in to the blog. /// Represents a user which can log in to the blog.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Mastodon; namespace OliverBooth.Common.Data.Mastodon;
public enum AttachmentType public enum AttachmentType
{ {

View File

@ -0,0 +1,31 @@
namespace OliverBooth.Common.Data.Mastodon;
/// <summary>
/// Represents a status on Mastodon.
/// </summary>
public interface IMastodonStatus
{
/// <summary>
/// Gets the content of the status.
/// </summary>
/// <value>The content.</value>
string Content { get; }
/// <summary>
/// Gets the date and time at which this status was posted.
/// </summary>
/// <value>The post timestamp.</value>
DateTimeOffset CreatedAt { get; }
/// <summary>
/// Gets the media attachments for this status.
/// </summary>
/// <value>The media attachments.</value>
IReadOnlyList<MediaAttachment> MediaAttachments { get; }
/// <summary>
/// Gets the original URI of the status.
/// </summary>
/// <value>The original URI.</value>
Uri OriginalUri { get; }
}

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Mastodon; namespace OliverBooth.Common.Data.Mastodon;
public sealed class MediaAttachment public sealed class MediaAttachment
{ {

View File

@ -1,10 +1,15 @@
namespace OliverBooth.Data.Blog; namespace OliverBooth.Common.Data;
/// <summary> /// <summary>
/// An enumeration of the possible visibilities of a blog post. /// An enumeration of the possible visibilities of a blog post.
/// </summary> /// </summary>
public enum BlogPostVisibility public enum Visibility
{ {
/// <summary>
/// Used for filtering results. Represents all visibilities.
/// </summary>
None = -1,
/// <summary> /// <summary>
/// The post is private and only visible to the author, or those with the password. /// The post is private and only visible to the author, or those with the password.
/// </summary> /// </summary>

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web; namespace OliverBooth.Common.Data.Web;
/// <summary> /// <summary>
/// Represents the state of a book. /// Represents the state of a book.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web; namespace OliverBooth.Common.Data.Web;
/// <summary> /// <summary>
/// Represents an entry in the blacklist. /// Represents an entry in the blacklist.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web; namespace OliverBooth.Common.Data.Web;
/// <summary> /// <summary>
/// Represents a book. /// Represents a book.

View File

@ -0,0 +1,25 @@
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents a code snippet.
/// </summary>
public interface ICodeSnippet
{
/// <summary>
/// Gets the content for this snippet.
/// </summary>
/// <value>The content for this snippet</value>
string Content { get; }
/// <summary>
/// Gets the ID for this snippet.
/// </summary>
/// <value>The ID for this snippet</value>
int Id { get; }
/// <summary>
/// Gets the language for this snippet.
/// </summary>
/// <value>The language for this snippet</value>
string Language { get; }
}

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web; namespace OliverBooth.Common.Data.Web;
/// <summary> /// <summary>
/// Represents a programming language. /// Represents a programming language.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web; namespace OliverBooth.Common.Data.Web;
/// <summary> /// <summary>
/// Represents a project. /// Represents a project.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web; namespace OliverBooth.Common.Data.Web;
/// <summary> /// <summary>
/// Represents a template. /// Represents a template.

View File

@ -0,0 +1,99 @@
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents a tutorial article.
/// </summary>
public interface ITutorialArticle
{
/// <summary>
/// Gets the body of this article.
/// </summary>
/// <value>The body.</value>
string Body { get; }
/// <summary>
/// Gets a value indicating whether comments are enabled for the article.
/// </summary>
/// <value>
/// <see langword="true" /> if comments are enabled for the article; otherwise, <see langword="false" />.
/// </value>
bool EnableComments { get; }
/// <summary>
/// Gets the excerpt of this article, if it has one.
/// </summary>
/// <value>The excerpt, or <see langword="null" /> if this article has no excerpt.</value>
string? Excerpt { get; }
/// <summary>
/// Gets the ID of the folder this article is contained within.
/// </summary>
/// <value>The ID of the folder.</value>
int Folder { get; }
/// <summary>
/// Gets a value indicating whether this article is part of a multi-part series.
/// </summary>
/// <value><see langword="true" /> if this article has additional parts; otherwise, <see langword="false" />.</value>
bool HasOtherParts { get; }
/// <summary>
/// Gets the ID of this article.
/// </summary>
/// <value>The ID.</value>
int Id { get; }
/// <summary>
/// Gets the ID of the next article to this one.
/// </summary>
/// <value>The next part ID.</value>
int? NextPart { get; }
/// <summary>
/// Gets the URL of the article's preview image.
/// </summary>
/// <value>The preview image URL.</value>
Uri? PreviewImageUrl { get; }
/// <summary>
/// Gets the ID of the previous article to this one.
/// </summary>
/// <value>The previous part ID.</value>
int? PreviousPart { get; }
/// <summary>
/// Gets the date and time at which this article was published.
/// </summary>
/// <value>The publish timestamp.</value>
DateTimeOffset Published { get; }
/// <summary>
/// Gets the ID of the post that was redirected to this article.
/// </summary>
/// <value>The source redirect post ID.</value>
Guid? RedirectFrom { get; }
/// <summary>
/// Gets the slug of this article.
/// </summary>
/// <value>The slug.</value>
string Slug { get; }
/// <summary>
/// Gets the title of this article.
/// </summary>
/// <value>The title.</value>
string Title { get; }
/// <summary>
/// Gets the date and time at which this article was updated.
/// </summary>
/// <value>The update timestamp, or <see langword="null" /> if this article has not been updated.</value>
DateTimeOffset? Updated { get; }
/// <summary>
/// Gets the visibility of this article.
/// </summary>
/// <value>The visibility of the article.</value>
Visibility Visibility { get; }
}

View File

@ -0,0 +1,43 @@
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents a folder for tutorial articles.
/// </summary>
public interface ITutorialFolder
{
/// <summary>
/// Gets the ID of this folder.
/// </summary>
/// <value>The ID of the folder.</value>
int Id { get; }
/// <summary>
/// Gets the ID of this folder's parent.
/// </summary>
/// <value>The ID of the parent, or <see langword="null" /> if this folder is at the root.</value>
int? Parent { get; }
/// <summary>
/// Gets the URL of the folder's preview image.
/// </summary>
/// <value>The preview image URL.</value>
Uri? PreviewImageUrl { get; }
/// <summary>
/// Gets the slug of this folder.
/// </summary>
/// <value>The slug.</value>
string Slug { get; }
/// <summary>
/// Gets the title of this folder.
/// </summary>
/// <value>The title.</value>
string Title { get; }
/// <summary>
/// Gets the visibility of this article.
/// </summary>
/// <value>The visibility of the article.</value>
Visibility Visibility { get; }
}

View File

@ -1,6 +1,6 @@
using System.ComponentModel; using System.ComponentModel;
namespace OliverBooth.Data.Web; namespace OliverBooth.Common.Data.Web;
/// <summary> /// <summary>
/// Represents the status of a project. /// Represents the status of a project.

View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.11.59"/>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="ZString" Version="2.5.1"/>
</ItemGroup>
</Project>

View File

@ -1,7 +1,8 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data.Blog; using OliverBooth.Common.Data;
using OliverBooth.Common.Data.Blog;
namespace OliverBooth.Services; namespace OliverBooth.Common.Services;
/// <summary> /// <summary>
/// Represents a service for managing blog posts. /// Represents a service for managing blog posts.
@ -22,8 +23,10 @@ public interface IBlogPostService
/// <summary> /// <summary>
/// Returns the total number of blog posts. /// Returns the total number of blog posts.
/// </summary> /// </summary>
/// <param name="visibility">The post visibility filter.</param>
/// <param name="tags">The tags of the posts to return.</param>
/// <returns>The total number of blog posts.</returns> /// <returns>The total number of blog posts.</returns>
int GetBlogPostCount(); int GetBlogPostCount(Visibility visibility = Visibility.None, string[]? tags = null);
/// <summary> /// <summary>
/// Returns a collection of blog posts from the specified page, optionally limiting the number of posts /// Returns a collection of blog posts from the specified page, optionally limiting the number of posts
@ -31,8 +34,30 @@ public interface IBlogPostService
/// </summary> /// </summary>
/// <param name="page">The zero-based index of the page to return.</param> /// <param name="page">The zero-based index of the page to return.</param>
/// <param name="pageSize">The maximum number of posts to return per page.</param> /// <param name="pageSize">The maximum number of posts to return per page.</param>
/// <param name="tags">The tags of the posts to return.</param>
/// <returns>A collection of blog posts.</returns> /// <returns>A collection of blog posts.</returns>
IReadOnlyList<IBlogPost> GetBlogPosts(int page, int pageSize = 10); IReadOnlyList<IBlogPost> GetBlogPosts(int page, int pageSize = 10, string[]? tags = null);
/// <summary>
/// Returns the number of legacy comments for the specified post.
/// </summary>
/// <param name="post">The post whose legacy comments to count.</param>
/// <returns>The total number of legacy comments.</returns>
int GetLegacyCommentCount(IBlogPost post);
/// <summary>
/// Returns the collection of legacy comments for the specified post.
/// </summary>
/// <param name="post">The post whose legacy comments to retrieve.</param>
/// <returns>A read-only view of the legacy comments.</returns>
IReadOnlyList<ILegacyComment> GetLegacyComments(IBlogPost post);
/// <summary>
/// Returns the collection of replies to the specified legacy comment.
/// </summary>
/// <param name="comment">The comment whose replies to retrieve.</param>
/// <returns>A read-only view of the replies.</returns>
IReadOnlyList<ILegacyComment> GetLegacyReplies(ILegacyComment comment);
/// <summary> /// <summary>
/// Returns the next blog post from the specified blog post. /// Returns the next blog post from the specified blog post.
@ -41,6 +66,16 @@ public interface IBlogPostService
/// <returns>The next blog post from the specified blog post.</returns> /// <returns>The next blog post from the specified blog post.</returns>
IBlogPost? GetNextPost(IBlogPost blogPost); IBlogPost? GetNextPost(IBlogPost blogPost);
/// <summary>
/// Returns the number of pages needed to render all blog posts, using the specified <paramref name="pageSize" /> as an
/// indicator of how many posts are allowed per page.
/// </summary>
/// <param name="pageSize">The page size. Defaults to 10.</param>
/// <param name="visibility">The post visibility filter.</param>
/// <param name="tags">The tags of the posts to return.</param>
/// <returns>The page count.</returns>
int GetPageCount(int pageSize = 10, Visibility visibility = Visibility.None, string[]? tags = null);
/// <summary> /// <summary>
/// Returns the previous blog post from the specified blog post. /// Returns the previous blog post from the specified blog post.
/// </summary> /// </summary>

View File

@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data.Blog; using OliverBooth.Common.Data.Blog;
namespace OliverBooth.Services; namespace OliverBooth.Common.Services;
/// <summary> /// <summary>
/// Represents a service for managing users. /// Represents a service for managing users.

View File

@ -0,0 +1,32 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service which can fetch multi-language code snippets.
/// </summary>
public interface ICodeSnippetService
{
/// <summary>
/// Returns all the languages which apply to the specified snippet.
/// </summary>
/// <param name="id">The ID of the snippet whose languages should be returned.</param>
/// <returns>
/// A read-only view of the languages that apply to the snippet. This list may be empty if the snippet ID is invalid.
/// </returns>
IReadOnlyList<string> GetLanguagesForSnippet(int id);
/// <summary>
/// Attempts to find a code snippet by the specified ID, in the specified language.
/// </summary>
/// <param name="id">The ID of the snippet to search for.</param>
/// <param name="language">The language to search for.</param>
/// <param name="snippet">
/// When this method returns, contains the code snippet matching the specified criteria, if such a snippet was found;
/// otherwise, <see langword="null" />.
/// </param>
/// <returns><see langword="true" /> if the snippet was found; otherwise, <see langword="false" />.</returns>
/// <exception cref="ArgumentNullException"><paramref name="language" /> is <see langword="null" />.</exception>
bool TryGetCodeSnippetForLanguage(int id, string language, [NotNullWhen(true)] out ICodeSnippet? snippet);
}

View File

@ -1,6 +1,6 @@
using OliverBooth.Data.Web; using OliverBooth.Common.Data.Web;
namespace OliverBooth.Services; namespace OliverBooth.Common.Services;
/// <summary> /// <summary>
/// Represents a service for managing contact information. /// Represents a service for managing contact information.

View File

@ -1,6 +1,6 @@
using OliverBooth.Data.Mastodon; using OliverBooth.Common.Data.Mastodon;
namespace OliverBooth.Services; namespace OliverBooth.Common.Services;
public interface IMastodonService public interface IMastodonService
{ {
@ -8,5 +8,5 @@ public interface IMastodonService
/// Gets the latest status posted to Mastodon. /// Gets the latest status posted to Mastodon.
/// </summary> /// </summary>
/// <returns>The latest status.</returns> /// <returns>The latest status.</returns>
MastodonStatus GetLatestStatus(); IMastodonStatus GetLatestStatus();
} }

View File

@ -0,0 +1,14 @@
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service which can perform programming language lookup.
/// </summary>
public interface IProgrammingLanguageService
{
/// <summary>
/// Returns the human-readable name of a language.
/// </summary>
/// <param name="alias">The alias of the language.</param>
/// <returns>The human-readable name, or <paramref name="alias" /> if the name could not be found.</returns>
string GetLanguageName(string alias);
}

View File

@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data.Web; using OliverBooth.Common.Data.Web;
namespace OliverBooth.Services; namespace OliverBooth.Common.Services;
/// <summary> /// <summary>
/// Represents a service for interacting with projects. /// Represents a service for interacting with projects.

View File

@ -1,6 +1,6 @@
using OliverBooth.Data.Web; using OliverBooth.Common.Data.Web;
namespace OliverBooth.Services; namespace OliverBooth.Common.Services;
/// <summary> /// <summary>
/// Represents a service which fetches books from the reading list. /// Represents a service which fetches books from the reading list.

View File

@ -0,0 +1,124 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Common.Data;
using OliverBooth.Common.Data.Blog;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service which can retrieve tutorial articles.
/// </summary>
public interface ITutorialService
{
/// <summary>
/// Gets the articles within a tutorial folder.
/// </summary>
/// <param name="folder">The folder whose articles to retrieve.</param>
/// <param name="visibility">The visibility to filter by. -1 does not filter.</param>
/// <returns>A read-only view of the articles in the folder.</returns>
IReadOnlyCollection<ITutorialArticle> GetArticles(ITutorialFolder folder, Visibility visibility = Visibility.None);
/// <summary>
/// Gets the tutorial folders within a specified folder.
/// </summary>
/// <param name="parent">The parent folder.</param>
/// <param name="visibility">The visibility to filter by. -1 does not filter.</param>
/// <returns>A read-only view of the subfolders in the folder.</returns>
IReadOnlyCollection<ITutorialFolder> GetFolders(ITutorialFolder? parent = null, Visibility visibility = Visibility.None);
/// <summary>
/// Gets a folder by its ID.
/// </summary>
/// <param name="id">The ID of the folder to get</param>
/// <param name="folder">
/// When this method returns, contains the folder whose ID is equal to the ID specified, or
/// <see langword="null" /> if no such folder was found.
/// </param>
/// <returns><see langword="true" /></returns>
ITutorialFolder? GetFolder(int id);
/// <summary>
/// Gets a folder by its slug.
/// </summary>
/// <param name="slug">The slug of the folder.</param>
/// <param name="parent">The parent folder.</param>
/// <returns>The folder.</returns>
ITutorialFolder? GetFolder(string? slug, ITutorialFolder? parent = null);
/// <summary>
/// Gets the full slug of the specified folder.
/// </summary>
/// <param name="folder">The folder whose slug to return.</param>
/// <returns>The full slug of the folder.</returns>
/// <exception cref="ArgumentNullException"><paramref name="folder" /> is <see langword="null" />.</exception>
string GetFullSlug(ITutorialFolder folder);
/// <summary>
/// Gets the full slug of the specified article.
/// </summary>
/// <param name="article">The article whose slug to return.</param>
/// <returns>The full slug of the article.</returns>
/// <exception cref="ArgumentNullException"><paramref name="article" /> is <see langword="null" />.</exception>
string GetFullSlug(ITutorialArticle article);
/// <summary>
/// Returns the number of legacy comments for the specified article.
/// </summary>
/// <param name="article">The article whose legacy comments to count.</param>
/// <returns>The total number of legacy comments.</returns>
int GetLegacyCommentCount(ITutorialArticle article);
/// <summary>
/// Returns the collection of legacy comments for the specified article.
/// </summary>
/// <param name="article">The article whose legacy comments to retrieve.</param>
/// <returns>A read-only view of the legacy comments.</returns>
IReadOnlyList<ILegacyComment> GetLegacyComments(ITutorialArticle article);
/// <summary>
/// Returns the collection of replies to the specified legacy comment.
/// </summary>
/// <param name="comment">The comment whose replies to retrieve.</param>
/// <returns>A read-only view of the replies.</returns>
IReadOnlyList<ILegacyComment> GetLegacyReplies(ILegacyComment comment);
/// <summary>
/// Renders the body of the specified article.
/// </summary>
/// <param name="article">The article to render.</param>
/// <returns>The rendered HTML of the article.</returns>
string RenderArticle(ITutorialArticle article);
/// <summary>
/// Renders the excerpt of the specified article.
/// </summary>
/// <param name="article">The article whose excerpt to render.</param>
/// <param name="wasTrimmed">
/// When this method returns, contains <see langword="true" /> if the excerpt was trimmed; otherwise,
/// <see langword="false" />.
/// </param>
/// <returns>The rendered HTML of the article's excerpt.</returns>
string RenderExcerpt(ITutorialArticle article, out bool wasTrimmed);
/// <summary>
/// Attempts to find an article by its ID.
/// </summary>
/// <param name="id">The ID of the article.</param>
/// <param name="article">
/// When this method returns, contains the article whose ID matches the specified <paramref name="id" />, or
/// <see langword="null" /> if no such article was found.
/// </param>
/// <returns><see langword="true" /> if a matching article was found; otherwise, <see langword="false" />.</returns>
bool TryGetArticle(int id, [NotNullWhen(true)] out ITutorialArticle? article);
/// <summary>
/// Attempts to find an article by its slug.
/// </summary>
/// <param name="slug">The slug of the article.</param>
/// <param name="article">
/// When this method returns, contains the article whose slug matches the specified <paramref name="slug" />, or
/// <see langword="null" /> if no such article was found.
/// </param>
/// <returns><see langword="true" /> if a matching article was found; otherwise, <see langword="false" />.</returns>
bool TryGetArticle(string slug, [NotNullWhen(true)] out ITutorialArticle? article);
}

View File

@ -0,0 +1,43 @@
using Markdig.Helpers;
using Markdig.Syntax;
namespace OliverBooth.Extensions.Markdig.Markdown.Callout;
/// <summary>
/// Represents a callout block.
/// </summary>
internal sealed class CalloutBlock : QuoteBlock
{
/// <summary>
/// Initializes a new instance of the <see cref="CalloutBlock" /> class.
/// </summary>
/// <param name="type">The type of the callout.</param>
public CalloutBlock(StringSlice type) : base(null)
{
Type = type;
}
/// <summary>
/// Gets or sets a value indicating whether this callout is foldable.
/// </summary>
/// <value><see langword="true" /> if this callout is foldable; otherwise, <see langword="false" />.</value>
public bool Foldable { get; set; }
/// <summary>
/// Gets or sets the title of the callout.
/// </summary>
/// <value>The title of the callout.</value>
public StringSlice Title { get; set; }
/// <summary>
/// Gets or sets the trailing whitespace trivia.
/// </summary>
/// <value>The trailing whitespace trivia.</value>
public StringSlice TrailingWhitespaceTrivia { get; set; }
/// <summary>
/// Gets or sets the type of the callout.
/// </summary>
/// <value>The type of the callout.</value>
public StringSlice Type { get; set; }
}

View File

@ -0,0 +1,32 @@
using Markdig;
using Markdig.Parsers.Inlines;
using Markdig.Renderers;
using Markdig.Renderers.Html;
namespace OliverBooth.Extensions.Markdig.Markdown.Callout;
/// <summary>
/// Extension for adding Obsidian-style callouts to a Markdown pipeline.
/// </summary>
internal sealed class CalloutExtension : IMarkdownExtension
{
/// <inheritdoc />
public void Setup(MarkdownPipelineBuilder pipeline)
{
var parser = pipeline.InlineParsers.Find<CalloutInlineParser>();
if (parser is null)
{
pipeline.InlineParsers.InsertBefore<LinkInlineParser>(new CalloutInlineParser());
}
}
/// <inheritdoc />
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
{
var blockRenderer = renderer.ObjectRenderers.FindExact<CalloutRenderer>();
if (blockRenderer is null)
{
renderer.ObjectRenderers.InsertBefore<QuoteBlockRenderer>(new CalloutRenderer(pipeline));
}
}
}

View File

@ -0,0 +1,176 @@
using System.Reflection;
using Cysharp.Text;
using Markdig.Helpers;
using Markdig.Parsers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
namespace OliverBooth.Extensions.Markdig.Markdown.Callout;
/// <summary>
/// An inline parser for Obsidian-style callouts (<c>[!NOTE]</c> etc.)
/// </summary>
internal sealed class CalloutInlineParser : InlineParser
{
// ugly hack to access internal method
private static readonly MethodInfo ReplaceParentContainerMethod =
typeof(InlineProcessor).GetMethod("ReplaceParentContainer", BindingFlags.Instance | BindingFlags.NonPublic)!;
/// <summary>
/// Initializes a new instance of the <see cref="CalloutInlineParser" /> class.
/// </summary>
public CalloutInlineParser()
{
OpeningCharacters = ['['];
}
/// <inheritdoc />
public override bool Match(InlineProcessor processor, ref StringSlice slice)
{
// We expect the alert to be the first child of a quote block. Example:
// > [!NOTE]
// > This is a note
if (processor.Block is not ParagraphBlock { Parent: QuoteBlock quoteBlock } paragraphBlock ||
paragraphBlock.Inline?.FirstChild != null)
{
return false;
}
StringSlice cache = slice;
char current = slice.NextChar();
if (current != '!')
{
slice = cache;
return false;
}
current = slice.NextChar(); // skip !
int start = slice.Start;
int end = start;
while (current.IsAlphaUpper())
{
end = slice.Start;
current = slice.NextChar();
}
if (current != ']' || start == end)
{
slice = cache;
return false;
}
var type = new StringSlice(slice.Text, start, end);
current = slice.NextChar(); // skip ]
start = slice.Start;
bool fold = false;
if (current == '-')
{
fold = true;
current = slice.NextChar(); // skip -
start = slice.Start;
}
ReadTitle(current, ref slice, out StringSlice title, out end);
var callout = new CalloutBlock(type)
{
Foldable = fold,
Span = quoteBlock.Span,
TrailingWhitespaceTrivia = new StringSlice(slice.Text, start, end),
Line = quoteBlock.Line,
Column = quoteBlock.Column,
Title = title
};
AddAttributes(callout, type);
ReplaceQuoteBlock(processor, quoteBlock, callout);
return true;
}
private static void ReadTitle(char startChar, ref StringSlice slice, out StringSlice title, out int end)
{
using Utf16ValueStringBuilder builder = ZString.CreateStringBuilder();
char current = startChar;
while (true)
{
if (current is not ('\0' or '\r' or '\n'))
{
builder.Append(current);
current = slice.NextChar();
continue;
}
end = slice.Start;
if (HandleCharacter(ref slice, ref end, ref current))
{
continue;
}
break;
}
title = new StringSlice(builder.ToString(), 0, builder.Length);
}
private static bool HandleCharacter(ref StringSlice slice, ref int end, ref char current)
{
switch (current)
{
case '\r':
current = slice.NextChar(); // skip \r
if (current is not ('\0' or '\n'))
{
return true;
}
end = slice.Start;
if (current == '\n')
{
slice.NextChar(); // skip \n
}
break;
case '\n':
slice.NextChar(); // skip \n
break;
}
return false;
}
private static void AddAttributes(IMarkdownObject callout, StringSlice type)
{
HtmlAttributes attributes = callout.GetAttributes();
attributes.AddClass("callout");
attributes.AddProperty("data-callout", type.AsSpan().ToString().ToLowerInvariant());
}
private static void ReplaceQuoteBlock(InlineProcessor processor, QuoteBlock quoteBlock, CalloutBlock callout)
{
ContainerBlock? parentQuoteBlock = quoteBlock.Parent;
if (parentQuoteBlock is null)
{
return;
}
int indexOfQuoteBlock = parentQuoteBlock.IndexOf(quoteBlock);
parentQuoteBlock[indexOfQuoteBlock] = callout;
while (quoteBlock.Count > 0)
{
var block = quoteBlock[0];
quoteBlock.RemoveAt(0);
callout.Add(block);
}
ReplaceParentContainerMethod.Invoke(processor, [quoteBlock, callout]);
// ReplaceParentContainer(processor, quoteBlock, callout);
}
}

View File

@ -0,0 +1,119 @@
using HtmlAgilityPack;
using Humanizer;
using Markdig;
using Markdig.Renderers;
using Markdig.Renderers.Html;
namespace OliverBooth.Extensions.Markdig.Markdown.Callout;
/// <summary>
/// Represents an HTML renderer which renders a <see cref="CalloutBlock" />.
/// </summary>
internal sealed class CalloutRenderer : HtmlObjectRenderer<CalloutBlock>
{
private readonly MarkdownPipeline _pipeline;
private static readonly Dictionary<string, string> CalloutTypes = new()
{
["NOTE"] = "pencil",
["ABSTRACT"] = "clipboard-list",
["INFO"] = "info",
["TODO"] = "circle-check",
["TIP"] = "flame",
["IMPORTANT"] = "flame",
["SUCCESS"] = "check",
["QUESTION"] = "circle-help",
["WARNING"] = "triangle-alert",
["FAILURE"] = "x",
["DANGER"] = "zap",
["BUG"] = "bug",
["EXAMPLE"] = "list",
["CITE"] = "quote",
["UPDATE"] = "calendar-check",
};
public CalloutRenderer(MarkdownPipeline pipeline)
{
_pipeline = pipeline;
}
/// <inheritdoc />
protected override void Write(HtmlRenderer renderer, CalloutBlock block)
{
renderer.EnsureLine();
if (renderer.EnableHtmlForBlock)
{
RenderAsHtml(renderer, block, _pipeline);
}
else
{
RenderAsText(renderer, block);
}
renderer.EnsureLine();
}
private static void RenderAsHtml(HtmlRenderer renderer, CalloutBlock block, MarkdownPipeline pipeline)
{
string title = block.Title.Text;
ReadOnlySpan<char> type = block.Type.AsSpan();
Span<char> upperType = stackalloc char[type.Length];
type.ToUpperInvariant(upperType);
if (!CalloutTypes.TryGetValue(upperType.ToString(), out string? lucideClass))
{
lucideClass = "pencil";
}
var typeString = type.ToString().ToLowerInvariant();
renderer.Write($"<div class=\"callout\" data-callout=\"{typeString}\"");
if (block.Foldable)
{
renderer.Write(" data-callout-fold=\"true\"");
}
renderer.Write('>');
renderer.Write("<div class=\"callout-title\"><i data-lucide=\"");
renderer.Write(lucideClass);
renderer.Write("\"></i> ");
string calloutTitle = title.Length == 0 ? typeString.Humanize(LetterCasing.Sentence) : title;
WriteTitle(renderer, pipeline, calloutTitle);
if (block.Foldable)
{
renderer.Write("<span class=\"callout-fold\"><i data-lucide=\"chevron-down\"></i></span>");
}
renderer.WriteLine("</div>");
renderer.Write("<div class=\"callout-content\">");
renderer.WriteChildren(block);
renderer.WriteLine("</div>");
renderer.WriteLine("</div>");
renderer.EnsureLine();
}
private static void WriteTitle(TextRendererBase renderer, MarkdownPipeline pipeline, string calloutTitle)
{
string html = global::Markdig.Markdown.ToHtml(calloutTitle, pipeline);
var document = new HtmlDocument();
document.LoadHtml(html);
if (document.DocumentNode.FirstChild is { Name: "p" } child)
{
// ugly hack to remove <p> tag generated by Markdig
document.DocumentNode.InnerHtml = child.InnerHtml;
}
document.Save(renderer.Writer);
}
private static void RenderAsText(HtmlRenderer renderer, CalloutBlock block)
{
string title = block.Title.Text;
ReadOnlySpan<char> type = block.Type.AsSpan();
renderer.WriteLine(title.Length == 0 ? type.ToString().ToUpperInvariant() : title.ToUpperInvariant());
renderer.WriteChildren(block);
renderer.EnsureLine();
}
}

View File

@ -1,8 +1,8 @@
using Markdig; using Markdig;
using Markdig.Renderers; using Markdig.Renderers;
using OliverBooth.Services; using OliverBooth.Extensions.Markdig.Services;
namespace OliverBooth.Markdown.Template; namespace OliverBooth.Extensions.Markdig.Markdown.Template;
/// <summary> /// <summary>
/// Represents a Markdown extension that adds support for MediaWiki-style templates. /// Represents a Markdown extension that adds support for MediaWiki-style templates.

View File

@ -1,6 +1,6 @@
using Markdig.Syntax.Inlines; using Markdig.Syntax.Inlines;
namespace OliverBooth.Markdown.Template; namespace OliverBooth.Extensions.Markdig.Markdown.Template;
/// <summary> /// <summary>
/// Represents a Markdown inline element that represents a MediaWiki-style template. /// Represents a Markdown inline element that represents a MediaWiki-style template.

View File

@ -2,7 +2,7 @@ using Cysharp.Text;
using Markdig.Helpers; using Markdig.Helpers;
using Markdig.Parsers; using Markdig.Parsers;
namespace OliverBooth.Markdown.Template; namespace OliverBooth.Extensions.Markdig.Markdown.Template;
/// <summary> /// <summary>
/// Represents a Markdown inline parser that handles MediaWiki-style templates. /// Represents a Markdown inline parser that handles MediaWiki-style templates.

View File

@ -1,8 +1,8 @@
using Markdig.Renderers; using Markdig.Renderers;
using Markdig.Renderers.Html; using Markdig.Renderers.Html;
using OliverBooth.Services; using OliverBooth.Extensions.Markdig.Services;
namespace OliverBooth.Markdown.Template; namespace OliverBooth.Extensions.Markdig.Markdown.Template;
/// <summary> /// <summary>
/// Represents a Markdown object renderer that handles <see cref="TemplateInline" /> elements. /// Represents a Markdown object renderer that handles <see cref="TemplateInline" /> elements.

View File

@ -1,7 +1,7 @@
using Markdig; using Markdig;
using Markdig.Renderers; using Markdig.Renderers;
namespace OliverBooth.Markdown.Timestamp; namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary> /// <summary>
/// Represents a Markdig extension that supports Discord-style timestamps. /// Represents a Markdig extension that supports Discord-style timestamps.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Markdown.Timestamp; namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary> /// <summary>
/// An enumeration of timestamp formats. /// An enumeration of timestamp formats.

View File

@ -1,6 +1,6 @@
using Markdig.Syntax.Inlines; using Markdig.Syntax.Inlines;
namespace OliverBooth.Markdown.Timestamp; namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary> /// <summary>
/// Represents a Markdown inline element that contains a timestamp. /// Represents a Markdown inline element that contains a timestamp.

View File

@ -1,7 +1,7 @@
using Markdig.Helpers; using Markdig.Helpers;
using Markdig.Parsers; using Markdig.Parsers;
namespace OliverBooth.Markdown.Timestamp; namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary> /// <summary>
/// Represents a Markdown inline parser that matches Discord-style timestamps. /// Represents a Markdown inline parser that matches Discord-style timestamps.

View File

@ -3,7 +3,7 @@ using Humanizer;
using Markdig.Renderers; using Markdig.Renderers;
using Markdig.Renderers.Html; using Markdig.Renderers.Html;
namespace OliverBooth.Markdown.Timestamp; namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary> /// <summary>
/// Represents a Markdown object renderer that renders <see cref="TimestampInline" /> elements. /// Represents a Markdown object renderer that renders <see cref="TimestampInline" /> elements.

View File

@ -0,0 +1,56 @@
using Markdig;
using OliverBooth.Extensions.Markdig.Markdown.Callout;
using OliverBooth.Extensions.Markdig.Markdown.Template;
using OliverBooth.Extensions.Markdig.Services;
namespace OliverBooth.Extensions.Markdig;
/// <summary>
/// Extension methods for <see cref="MarkdownPipelineBuilder" />.
/// </summary>
public static class MarkdownPipelineExtensions
{
/// <summary>
/// Enables the use of Obsidian-style callouts in this pipeline.
/// </summary>
/// <param name="builder">The Markdig markdown pipeline builder.</param>
/// <returns>The modified Markdig markdown pipeline builder.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder" /> is <see langword="null" />.</exception>
public static MarkdownPipelineBuilder UseCallouts(this MarkdownPipelineBuilder builder)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
builder.Extensions.AddIfNotAlready<CalloutExtension>();
return builder;
}
/// <summary>
/// Enables the use of Wiki-style templates in this pipeline.
/// </summary>
/// <param name="builder">The Markdig markdown pipeline builder.</param>
/// <param name="templateService">The template service responsible for fetching and rendering templates.</param>
/// <returns>The modified Markdig markdown pipeline builder.</returns>
/// <exception cref="ArgumentNullException">
/// <para><paramref name="builder" /> is <see langword="null" />.</para>
/// -or-
/// <para><paramref name="templateService" /> is <see langword="null" />.</para>
/// </exception>
public static MarkdownPipelineBuilder UseTemplates(this MarkdownPipelineBuilder builder, ITemplateService templateService)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (templateService is null)
{
throw new ArgumentNullException(nameof(templateService));
}
builder.Use(new TemplateExtension(templateService));
return builder;
}
}

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="0.36.2"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OliverBooth.Common\OliverBooth.Common.csproj"/>
</ItemGroup>
</Project>

View File

@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data.Web; using OliverBooth.Common.Data.Web;
using OliverBooth.Markdown.Template; using OliverBooth.Extensions.Markdig.Markdown.Template;
namespace OliverBooth.Services; namespace OliverBooth.Extensions.Markdig.Services;
/// <summary> /// <summary>
/// Represents a service that renders MediaWiki-style templates. /// Represents a service that renders MediaWiki-style templates.

View File

@ -1,7 +1,7 @@
using System.Globalization; using System.Globalization;
using SmartFormat.Core.Extensions; using SmartFormat.Core.Extensions;
namespace OliverBooth.Formatting; namespace OliverBooth.Extensions.SmartFormat;
/// <summary> /// <summary>
/// Represents a SmartFormat formatter that formats a date. /// Represents a SmartFormat formatter that formats a date.

View File

@ -1,7 +1,8 @@
using Markdig; using Markdig;
using Microsoft.Extensions.DependencyInjection;
using SmartFormat.Core.Extensions; using SmartFormat.Core.Extensions;
namespace OliverBooth.Formatting; namespace OliverBooth.Extensions.SmartFormat;
/// <summary> /// <summary>
/// Represents a SmartFormat formatter that formats markdown. /// Represents a SmartFormat formatter that formats markdown.

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="0.36.2"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1"/>
<PackageReference Include="SmartFormat.NET" Version="3.3.2"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OliverBooth.Common\OliverBooth.Common.csproj"/>
</ItemGroup>
</Project>

View File

@ -13,6 +13,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
global.json = global.json global.json = global.json
EndProjectSection EndProjectSection
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OliverBooth.Extensions.Markdig", "OliverBooth.Extensions.Markdig\OliverBooth.Extensions.Markdig.csproj", "{3B012CD2-3201-41A0-BEF9-8E0B6247BB7E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OliverBooth.Common", "OliverBooth.Common\OliverBooth.Common.csproj", "{AD231E0F-FAED-4661-963F-EB22F858E148}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OliverBooth.Extensions.SmartFormat", "OliverBooth.Extensions.SmartFormat\OliverBooth.Extensions.SmartFormat.csproj", "{9D56FA9B-B95B-460D-8745-41AABAA8BF61}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -23,6 +29,18 @@ Global
{A58A6FA3-480C-400B-822A-3786741BF39C}.Debug|Any CPU.Build.0 = Debug|Any CPU {A58A6FA3-480C-400B-822A-3786741BF39C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.ActiveCfg = Release|Any CPU {A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.Build.0 = Release|Any CPU {A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.Build.0 = Release|Any CPU
{3B012CD2-3201-41A0-BEF9-8E0B6247BB7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3B012CD2-3201-41A0-BEF9-8E0B6247BB7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3B012CD2-3201-41A0-BEF9-8E0B6247BB7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3B012CD2-3201-41A0-BEF9-8E0B6247BB7E}.Release|Any CPU.Build.0 = Release|Any CPU
{AD231E0F-FAED-4661-963F-EB22F858E148}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AD231E0F-FAED-4661-963F-EB22F858E148}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD231E0F-FAED-4661-963F-EB22F858E148}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD231E0F-FAED-4661-963F-EB22F858E148}.Release|Any CPU.Build.0 = Release|Any CPU
{9D56FA9B-B95B-460D-8745-41AABAA8BF61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9D56FA9B-B95B-460D-8745-41AABAA8BF61}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9D56FA9B-B95B-460D-8745-41AABAA8BF61}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9D56FA9B-B95B-460D-8745-41AABAA8BF61}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
EndGlobalSection EndGlobalSection

View File

@ -1,102 +0,0 @@
using Humanizer;
using Microsoft.AspNetCore.Mvc;
using OliverBooth.Data.Blog;
using OliverBooth.Services;
namespace OliverBooth.Controllers.Blog;
/// <summary>
/// Represents a controller for the blog API.
/// </summary>
[ApiController]
[Route("api/blog")]
[Produces("application/json")]
public sealed class BlogApiController : ControllerBase
{
private readonly IBlogPostService _blogPostService;
private readonly IBlogUserService _userService;
/// <summary>
/// Initializes a new instance of the <see cref="BlogApiController" /> class.
/// </summary>
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
/// <param name="userService">The <see cref="IBlogUserService" />.</param>
public BlogApiController(IBlogPostService blogPostService, IBlogUserService userService)
{
_blogPostService = blogPostService;
_userService = userService;
}
[Route("count")]
public IActionResult Count()
{
return Ok(new { count = _blogPostService.GetBlogPostCount() });
}
[HttpGet("posts/{page:int?}")]
public IActionResult GetAllBlogPosts(int page = 0)
{
const int itemsPerPage = 10;
IReadOnlyList<IBlogPost> allPosts = _blogPostService.GetBlogPosts(page, itemsPerPage);
return Ok(allPosts.Select(post => CreatePostObject(post)));
}
[HttpGet("posts/tagged/{tag}/{page:int?}")]
public IActionResult GetTaggedBlogPosts(string tag, int page = 0)
{
const int itemsPerPage = 10;
tag = tag.Replace('-', ' ').ToLowerInvariant();
IReadOnlyList<IBlogPost> allPosts = _blogPostService.GetBlogPosts(page, itemsPerPage);
allPosts = allPosts.Where(post => post.Tags.Contains(tag)).ToList();
return Ok(allPosts.Select(post => CreatePostObject(post)));
}
[HttpGet("author/{id:guid}")]
public IActionResult GetAuthor(Guid id)
{
if (!_userService.TryGetUser(id, out IUser? author)) return NotFound();
return Ok(new
{
id = author.Id,
name = author.DisplayName,
avatarUrl = author.AvatarUrl,
});
}
[HttpGet("post/{id:guid?}")]
public IActionResult GetPost(Guid id)
{
if (!_blogPostService.TryGetPost(id, out IBlogPost? post)) return NotFound();
return Ok(CreatePostObject(post, true));
}
private object CreatePostObject(IBlogPost post, bool includeContent = false)
{
return new
{
id = post.Id,
commentsEnabled = post.EnableComments,
identifier = post.GetDisqusIdentifier(),
author = post.Author.Id,
title = post.Title,
published = post.Published.ToUnixTimeSeconds(),
updated = post.Updated?.ToUnixTimeSeconds(),
formattedPublishDate = post.Published.ToString("dddd, d MMMM yyyy HH:mm"),
formattedUpdateDate = post.Updated?.ToString("dddd, d MMMM yyyy HH:mm"),
humanizedTimestamp = post.Updated?.Humanize() ?? post.Published.Humanize(),
excerpt = _blogPostService.RenderExcerpt(post, out bool trimmed),
content = includeContent ? _blogPostService.RenderPost(post) : null,
trimmed,
tags = post.Tags.Select(t => t.Replace(' ', '-')),
url = new
{
year = post.Published.ToString("yyyy"),
month = post.Published.ToString("MM"),
day = post.Published.ToString("dd"),
slug = post.Slug
}
};
}
}

View File

@ -1,8 +1,8 @@
using System.Xml.Serialization; using System.Xml.Serialization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using OliverBooth.Data.Blog; using OliverBooth.Common.Data.Blog;
using OliverBooth.Common.Services;
using OliverBooth.Data.Blog.Rss; using OliverBooth.Data.Blog.Rss;
using OliverBooth.Services;
namespace OliverBooth.Controllers.Blog; namespace OliverBooth.Controllers.Blog;

View File

@ -11,6 +11,7 @@ public class ContactController : Controller
private readonly ILogger<ContactController> _logger; private readonly ILogger<ContactController> _logger;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IConfigurationSection _destination; private readonly IConfigurationSection _destination;
private readonly IConfigurationSection _sender;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="ContactController" /> class. /// Initializes a new instance of the <see cref="ContactController" /> class.
@ -21,7 +22,10 @@ public class ContactController : Controller
{ {
_logger = logger; _logger = logger;
_configuration = configuration; _configuration = configuration;
_destination = configuration.GetSection("Mail").GetSection("Destination");
IConfigurationSection mailConfiguration = configuration.GetSection("Mail");
_destination = mailConfiguration.GetSection("Destination");
_sender = mailConfiguration.GetSection("Sender");
} }
[HttpGet("{_?}")] [HttpGet("{_?}")]
@ -57,8 +61,9 @@ public class ContactController : Controller
{ {
await sender.WriteEmail await sender.WriteEmail
.To("Oliver Booth", _destination.Get<string>()) .To("Oliver Booth", _destination.Get<string>())
.From(name, email) .From("Contact via Website", _sender.Get<string>())
.Subject($"[Contact via Website] {subject}") .ReplyTo(name, email)
.Subject(subject)
.BodyText(message) .BodyText(message)
.SendAsync(); .SendAsync();
} }
@ -79,8 +84,9 @@ public class ContactController : Controller
string? mailServer = mailSection.GetSection("Server").Value; string? mailServer = mailSection.GetSection("Server").Value;
string? mailUsername = mailSection.GetSection("Username").Value; string? mailUsername = mailSection.GetSection("Username").Value;
string? mailPassword = mailSection.GetSection("Password").Value; string? mailPassword = mailSection.GetSection("Password").Value;
ushort port = mailSection.GetSection("Port").Get<ushort>();
var sender = SmtpSender.Create(mailServer); var sender = SmtpSender.Create(mailServer, port);
sender.SetCredential(mailUsername, mailPassword); sender.SetCredential(mailUsername, mailPassword);
return sender; return sender;
} }

View File

@ -1,7 +1,7 @@
using System.Text; using System.Text;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using OliverBooth.Data.Web; using OliverBooth.Common.Data.Web;
using OliverBooth.Services; using OliverBooth.Common.Services;
namespace OliverBooth.Controllers; namespace OliverBooth.Controllers;

View File

@ -25,6 +25,12 @@ internal sealed class BlogContext : DbContext
/// <value>The collection of blog posts.</value> /// <value>The collection of blog posts.</value>
public DbSet<BlogPost> BlogPosts { get; private set; } = null!; public DbSet<BlogPost> BlogPosts { get; private set; } = null!;
/// <summary>
/// Gets the collection of legacy comments in the database.
/// </summary>
/// <value>The collection of legacy comments.</value>
public DbSet<LegacyComment> LegacyComments { get; private set; } = null!;
/// <summary> /// <summary>
/// Gets the collection of users in the database. /// Gets the collection of users in the database.
/// </summary> /// </summary>
@ -43,6 +49,7 @@ internal sealed class BlogContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.ApplyConfiguration(new BlogPostConfiguration()); modelBuilder.ApplyConfiguration(new BlogPostConfiguration());
modelBuilder.ApplyConfiguration(new LegacyCommentConfiguration());
modelBuilder.ApplyConfiguration(new UserConfiguration()); modelBuilder.ApplyConfiguration(new UserConfiguration());
} }
} }

View File

@ -1,4 +1,6 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using OliverBooth.Common.Data;
using OliverBooth.Common.Data.Blog;
using SmartFormat; using SmartFormat;
namespace OliverBooth.Data.Blog; namespace OliverBooth.Data.Blog;
@ -16,6 +18,9 @@ internal sealed class BlogPost : IBlogPost
/// <inheritdoc /> /// <inheritdoc />
public bool EnableComments { get; internal set; } public bool EnableComments { get; internal set; }
/// <inheritdoc />
public string? Excerpt { get; internal set; }
/// <inheritdoc /> /// <inheritdoc />
public Guid Id { get; private set; } = Guid.NewGuid(); public Guid Id { get; private set; } = Guid.NewGuid();
@ -44,7 +49,7 @@ internal sealed class BlogPost : IBlogPost
public DateTimeOffset? Updated { get; internal set; } public DateTimeOffset? Updated { get; internal set; }
/// <inheritdoc /> /// <inheritdoc />
public BlogPostVisibility Visibility { get; internal set; } public Visibility Visibility { get; internal set; }
/// <inheritdoc /> /// <inheritdoc />
public int? WordPressId { get; set; } public int? WordPressId { get; set; }

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OliverBooth.Common.Data;
namespace OliverBooth.Data.Blog.Configuration; namespace OliverBooth.Data.Blog.Configuration;
@ -20,13 +21,14 @@ internal sealed class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
builder.Property(e => e.Updated).IsRequired(false); builder.Property(e => e.Updated).IsRequired(false);
builder.Property(e => e.Title).HasMaxLength(255).IsRequired(); builder.Property(e => e.Title).HasMaxLength(255).IsRequired();
builder.Property(e => e.Body).IsRequired(); builder.Property(e => e.Body).IsRequired();
builder.Property(e => e.Excerpt).HasMaxLength(512).IsRequired(false);
builder.Property(e => e.IsRedirect).IsRequired(); builder.Property(e => e.IsRedirect).IsRequired();
builder.Property(e => e.RedirectUrl).HasConversion<UriToStringConverter>().HasMaxLength(255).IsRequired(false); builder.Property(e => e.RedirectUrl).HasConversion<UriToStringConverter>().HasMaxLength(255).IsRequired(false);
builder.Property(e => e.EnableComments).IsRequired(); builder.Property(e => e.EnableComments).IsRequired();
builder.Property(e => e.DisqusDomain).IsRequired(false); builder.Property(e => e.DisqusDomain).IsRequired(false);
builder.Property(e => e.DisqusIdentifier).IsRequired(false); builder.Property(e => e.DisqusIdentifier).IsRequired(false);
builder.Property(e => e.DisqusPath).IsRequired(false); builder.Property(e => e.DisqusPath).IsRequired(false);
builder.Property(e => e.Visibility).HasConversion(new EnumToStringConverter<BlogPostVisibility>()).IsRequired(); builder.Property(e => e.Visibility).HasConversion(new EnumToStringConverter<Visibility>()).IsRequired();
builder.Property(e => e.Password).HasMaxLength(255).IsRequired(false); builder.Property(e => e.Password).HasMaxLength(255).IsRequired(false);
builder.Property(e => e.Tags).IsRequired() builder.Property(e => e.Tags).IsRequired()
.HasConversion( .HasConversion(

View File

@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace OliverBooth.Data.Blog.Configuration;
internal sealed class LegacyCommentConfiguration : IEntityTypeConfiguration<LegacyComment>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<LegacyComment> builder)
{
builder.ToTable("LegacyComment");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).IsRequired();
builder.Property(e => e.PostId).IsRequired();
builder.Property(e => e.Author).IsRequired().HasMaxLength(50);
builder.Property(e => e.Avatar).IsRequired(false).HasMaxLength(32767);
builder.Property(e => e.Body).IsRequired().HasMaxLength(32767);
builder.Property(e => e.ParentComment).IsRequired(false);
}
}

View File

@ -0,0 +1,34 @@
using System.Web;
using OliverBooth.Common.Data.Blog;
namespace OliverBooth.Data.Blog;
internal sealed class LegacyComment : ILegacyComment
{
/// <inheritdoc />
public string? Avatar { get; private set; }
/// <inheritdoc />
public string Author { get; private set; } = string.Empty;
/// <inheritdoc />
public string Body { get; private set; } = string.Empty;
/// <inheritdoc />
public DateTimeOffset CreatedAt { get; private set; }
/// <inheritdoc />
public Guid Id { get; private set; }
/// <inheritdoc />
public Guid? ParentComment { get; private set; }
/// <inheritdoc />
public Guid PostId { get; private set; }
/// <inheritdoc />
public string GetAvatarUrl()
{
return Avatar ?? $"https://ui-avatars.com/api/?name={HttpUtility.UrlEncode(Author)}";
}
}

View File

@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Cysharp.Text; using Cysharp.Text;
using OliverBooth.Common.Data.Blog;
namespace OliverBooth.Data.Blog; namespace OliverBooth.Data.Blog;

View File

@ -1,34 +1,24 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using OliverBooth.Common.Data.Mastodon;
namespace OliverBooth.Data.Mastodon; namespace OliverBooth.Data.Mastodon;
public sealed class MastodonStatus /// <inheritdoc />
internal sealed class MastodonStatus : IMastodonStatus
{ {
/// <summary> /// <inheritdoc />
/// Gets the content of the status.
/// </summary>
/// <value>The content.</value>
[JsonPropertyName("content")] [JsonPropertyName("content")]
public string Content { get; set; } = string.Empty; public string Content { get; set; } = string.Empty;
/// <summary> /// <inheritdoc />
/// Gets the date and time at which this status was posted.
/// </summary>
/// <value>The post timestamp.</value>
[JsonPropertyName("created_at")] [JsonPropertyName("created_at")]
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
/// <summary> /// <inheritdoc />
/// Gets the media attachments for this status.
/// </summary>
/// <value>The media attachments.</value>
[JsonPropertyName("media_attachments")] [JsonPropertyName("media_attachments")]
public IReadOnlyList<MediaAttachment> MediaAttachments { get; set; } = ArraySegment<MediaAttachment>.Empty; public IReadOnlyList<MediaAttachment> MediaAttachments { get; set; } = ArraySegment<MediaAttachment>.Empty;
/// <summary> /// <inheritdoc />
/// Gets the original URI of the status.
/// </summary>
/// <value>The original URI.</value>
[JsonPropertyName("url")] [JsonPropertyName("url")]
public Uri OriginalUri { get; set; } = null!; public Uri OriginalUri { get; set; } = null!;
} }

View File

@ -1,3 +1,5 @@
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web; namespace OliverBooth.Data.Web;
/// <inheritdoc cref="IBlacklistEntry"/> /// <inheritdoc cref="IBlacklistEntry"/>

View File

@ -1,4 +1,5 @@
using NetBarcode; using NetBarcode;
using OliverBooth.Common.Data.Web;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing;

View File

@ -0,0 +1,16 @@
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web;
/// <inheritdoc />
internal sealed class CodeSnippet : ICodeSnippet
{
/// <inheritdoc />
public string Content { get; } = string.Empty;
/// <inheritdoc />
public int Id { get; }
/// <inheritdoc />
public string Language { get; } = string.Empty;
}

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web.Configuration; namespace OliverBooth.Data.Web.Configuration;

View File

@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace OliverBooth.Data.Web.Configuration;
/// <summary>
/// Represents the configuration for the <see cref="Book" /> entity.
/// </summary>
internal sealed class CodeSnippetConfiguration : IEntityTypeConfiguration<CodeSnippet>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<CodeSnippet> builder)
{
builder.ToTable("CodeSnippet");
builder.HasKey(e => new { e.Id, e.Language });
builder.Property(e => e.Id);
builder.Property(e => e.Language);
builder.Property(e => e.Content);
}
}

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web.Configuration; namespace OliverBooth.Data.Web.Configuration;

View File

@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OliverBooth.Common.Data;
namespace OliverBooth.Data.Web.Configuration;
/// <summary>
/// Represents the configuration for the <see cref="Template" /> entity.
/// </summary>
internal sealed class TutorialArticleConfiguration : IEntityTypeConfiguration<TutorialArticle>
{
public void Configure(EntityTypeBuilder<TutorialArticle> builder)
{
builder.ToTable("TutorialArticle");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).IsRequired();
builder.Property(e => e.Folder).IsRequired();
builder.Property(e => e.Excerpt).HasMaxLength(512).IsRequired(false);
builder.Property(e => e.Published).IsRequired();
builder.Property(e => e.Updated);
builder.Property(e => e.Slug).IsRequired();
builder.Property(e => e.Title).IsRequired();
builder.Property(e => e.PreviewImageUrl).HasConversion<UriToStringConverter>();
builder.Property(e => e.NextPart);
builder.Property(e => e.PreviousPart);
builder.Property(e => e.RedirectFrom).IsRequired(false);
builder.Property(e => e.Visibility).HasConversion<EnumToStringConverter<Visibility>>();
builder.Property(e => e.EnableComments).IsRequired();
}
}

View File

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OliverBooth.Common.Data;
namespace OliverBooth.Data.Web.Configuration;
/// <summary>
/// Represents the configuration for the <see cref="Template" /> entity.
/// </summary>
internal sealed class TutorialFolderConfiguration : IEntityTypeConfiguration<TutorialFolder>
{
public void Configure(EntityTypeBuilder<TutorialFolder> builder)
{
builder.ToTable("TutorialFolder");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).IsRequired();
builder.Property(e => e.Parent);
builder.Property(e => e.Slug).HasMaxLength(50).IsRequired();
builder.Property(e => e.Title).HasMaxLength(255).IsRequired();
builder.Property(e => e.PreviewImageUrl).HasConversion<UriToStringConverter>();
builder.Property(e => e.Visibility).HasConversion<EnumToStringConverter<Visibility>>();
}
}

View File

@ -1,3 +1,5 @@
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web; namespace OliverBooth.Data.Web;
/// <inheritdoc cref="IProgrammingLanguage" /> /// <inheritdoc cref="IProgrammingLanguage" />

View File

@ -1,3 +1,5 @@
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web; namespace OliverBooth.Data.Web;
/// <summary> /// <summary>

View File

@ -1,3 +1,5 @@
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web; namespace OliverBooth.Data.Web;
/// <summary> /// <summary>

View File

@ -0,0 +1,118 @@
using System.ComponentModel.DataAnnotations.Schema;
using OliverBooth.Common.Data;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web;
/// <summary>
/// Represents a tutorial article.
/// </summary>
internal sealed class TutorialArticle : IEquatable<TutorialArticle>, ITutorialArticle
{
/// <inheritdoc />
public string Body { get; private set; } = string.Empty;
/// <inheritdoc />
public bool EnableComments { get; internal set; }
/// <inheritdoc />
public string? Excerpt { get; private set; }
/// <inheritdoc />
public int Folder { get; private set; }
/// <inheritdoc />
[NotMapped]
public bool HasOtherParts => NextPart is not null || PreviousPart is not null;
/// <inheritdoc />
public int Id { get; private set; }
/// <inheritdoc />
public int? NextPart { get; private set; }
/// <inheritdoc />
public Uri? PreviewImageUrl { get; private set; }
/// <inheritdoc />
public int? PreviousPart { get; private set; }
/// <inheritdoc />
public DateTimeOffset Published { get; private set; }
/// <inheritdoc />
public Guid? RedirectFrom { get; private set; }
/// <inheritdoc />
public string Slug { get; private set; } = string.Empty;
/// <inheritdoc />
public string Title { get; private set; } = string.Empty;
/// <inheritdoc />
public DateTimeOffset? Updated { get; private set; }
/// <inheritdoc />
public Visibility Visibility { get; private set; }
/// <summary>
/// Returns a value indicating whether two instances of <see cref="TutorialArticle" /> are equal.
/// </summary>
/// <param name="left">The first instance of <see cref="TutorialArticle" /> to compare.</param>
/// <param name="right">The second instance of <see cref="TutorialArticle" /> to compare.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are equal; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool operator ==(TutorialArticle? left, TutorialArticle? right) => Equals(left, right);
/// <summary>
/// Returns a value indicating whether two instances of <see cref="TutorialArticle" /> are not equal.
/// </summary>
/// <param name="left">The first instance of <see cref="TutorialArticle" /> to compare.</param>
/// <param name="right">The second instance of <see cref="TutorialArticle" /> to compare.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are not equal; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool operator !=(TutorialArticle? left, TutorialArticle? right) => !(left == right);
/// <summary>
/// Returns a value indicating whether this instance of <see cref="TutorialArticle" /> is equal to another
/// instance.
/// </summary>
/// <param name="other">An instance to compare with this instance.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="other" /> is equal to this instance; otherwise,
/// <see langword="false" />.
/// </returns>
public bool Equals(TutorialArticle? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Id.Equals(other.Id);
}
/// <summary>
/// Returns a value indicating whether this instance is equal to a specified object.
/// </summary>
/// <param name="obj">An object to compare with this instance.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="obj" /> is an instance of <see cref="TutorialArticle" /> and
/// equals the value of this instance; otherwise, <see langword="false" />.
/// </returns>
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is TutorialArticle other && Equals(other);
}
/// <summary>
/// Gets the hash code for this instance.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
// ReSharper disable once NonReadonlyMemberInGetHashCode
return Id;
}
}

View File

@ -0,0 +1,89 @@
using OliverBooth.Common.Data;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web;
/// <summary>
/// Represents a folder for tutorial articles.
/// </summary>
internal sealed class TutorialFolder : IEquatable<TutorialFolder>, ITutorialFolder
{
/// <inheritdoc />
public int Id { get; private set; }
/// <inheritdoc />
public int? Parent { get; private set; }
/// <inheritdoc />
public Uri? PreviewImageUrl { get; private set; }
/// <inheritdoc />
public string Slug { get; private set; } = string.Empty;
/// <inheritdoc />
public string Title { get; private set; } = string.Empty;
/// <inheritdoc />
public Visibility Visibility { get; private set; }
/// <summary>
/// Returns a value indicating whether two instances of <see cref="TutorialFolder" /> are equal.
/// </summary>
/// <param name="left">The first instance of <see cref="TutorialFolder" /> to compare.</param>
/// <param name="right">The second instance of <see cref="TutorialFolder" /> to compare.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are equal; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool operator ==(TutorialFolder? left, TutorialFolder? right) => Equals(left, right);
/// <summary>
/// Returns a value indicating whether two instances of <see cref="TutorialFolder" /> are not equal.
/// </summary>
/// <param name="left">The first instance of <see cref="TutorialFolder" /> to compare.</param>
/// <param name="right">The second instance of <see cref="TutorialFolder" /> to compare.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are not equal; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool operator !=(TutorialFolder? left, TutorialFolder? right) => !(left == right);
/// <summary>
/// Returns a value indicating whether this instance of <see cref="TutorialFolder" /> is equal to another
/// instance.
/// </summary>
/// <param name="other">An instance to compare with this instance.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="other" /> is equal to this instance; otherwise,
/// <see langword="false" />.
/// </returns>
public bool Equals(TutorialFolder? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Id.Equals(other.Id);
}
/// <summary>
/// Returns a value indicating whether this instance is equal to a specified object.
/// </summary>
/// <param name="obj">An object to compare with this instance.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="obj" /> is an instance of <see cref="TutorialFolder" /> and
/// equals the value of this instance; otherwise, <see langword="false" />.
/// </returns>
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is TutorialFolder other && Equals(other);
}
/// <summary>
/// Gets the hash code for this instance.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
// ReSharper disable once NonReadonlyMemberInGetHashCode
return Id;
}
}

View File

@ -25,6 +25,12 @@ internal sealed class WebContext : DbContext
/// <value>The collection of books.</value> /// <value>The collection of books.</value>
public DbSet<Book> Books { get; private set; } = null!; public DbSet<Book> Books { get; private set; } = null!;
/// <summary>
/// Gets the collection of code snippets in the database.
/// </summary>
/// <value>The collection of code snippets.</value>
public DbSet<CodeSnippet> CodeSnippets { get; private set; } = null!;
/// <summary> /// <summary>
/// Gets the collection of blacklist entries in the database. /// Gets the collection of blacklist entries in the database.
/// </summary> /// </summary>
@ -55,6 +61,18 @@ internal sealed class WebContext : DbContext
/// <value>The collection of templates.</value> /// <value>The collection of templates.</value>
public DbSet<Template> Templates { get; private set; } = null!; public DbSet<Template> Templates { get; private set; } = null!;
/// <summary>
/// Gets the collection of tutorial articles in the database.
/// </summary>
/// <value>The collection of tutorial articles.</value>
public DbSet<TutorialArticle> TutorialArticles { get; private set; } = null!;
/// <summary>
/// Gets the collection of tutorial folders in the database.
/// </summary>
/// <value>The collection of tutorial folders.</value>
public DbSet<TutorialFolder> TutorialFolders { get; private set; } = null!;
/// <inheritdoc /> /// <inheritdoc />
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@ -68,9 +86,12 @@ internal sealed class WebContext : DbContext
{ {
modelBuilder.ApplyConfiguration(new BlacklistEntryConfiguration()); modelBuilder.ApplyConfiguration(new BlacklistEntryConfiguration());
modelBuilder.ApplyConfiguration(new BookConfiguration()); modelBuilder.ApplyConfiguration(new BookConfiguration());
modelBuilder.ApplyConfiguration(new CodeSnippetConfiguration());
modelBuilder.ApplyConfiguration(new ProgrammingLanguageConfiguration()); modelBuilder.ApplyConfiguration(new ProgrammingLanguageConfiguration());
modelBuilder.ApplyConfiguration(new ProjectConfiguration()); modelBuilder.ApplyConfiguration(new ProjectConfiguration());
modelBuilder.ApplyConfiguration(new TemplateConfiguration()); modelBuilder.ApplyConfiguration(new TemplateConfiguration());
modelBuilder.ApplyConfiguration(new TutorialArticleConfiguration());
modelBuilder.ApplyConfiguration(new TutorialFolderConfiguration());
modelBuilder.ApplyConfiguration(new SiteConfigurationConfiguration()); modelBuilder.ApplyConfiguration(new SiteConfigurationConfiguration());
} }
} }

View File

@ -0,0 +1,153 @@
using System.Web;
using Cysharp.Text;
using OliverBooth.Common.Data.Blog;
using OliverBooth.Common.Data.Web;
using OliverBooth.Common.Services;
namespace OliverBooth.Extensions;
/// <summary>
/// Provides helper methods for generating HTML tags
/// </summary>
public static class HtmlUtility
{
/// <summary>
/// Creates <c>&lt;meta&gt;</c> embed tags by pulling data from the specified blog post.
/// </summary>
/// <param name="post">The blog post whose metadata should be retrieved.</param>
/// <param name="blogPostService">The <see cref="IBlogPostService" /> injected by the page.</param>
/// <returns>A string containing a collection of <c>&lt;meta&gt;</c> embed tags.</returns>
/// <exception cref="ArgumentNullException">
/// <para><paramref name="post" /> is <see langword="null" />.</para>
/// -or-
/// <para><paramref name="blogPostService" /> is <see langword="null" />.</para>
/// </exception>
public static string CreateMetaTagsFromPost(IBlogPost post, IBlogPostService blogPostService)
{
if (post is null)
{
throw new ArgumentNullException(nameof(post));
}
if (blogPostService is null)
{
throw new ArgumentNullException(nameof(blogPostService));
}
string excerpt = blogPostService.RenderExcerpt(post, out _);
var tags = new Dictionary<string, string>
{
["title"] = post.Title,
["description"] = excerpt,
["author"] = post.Author.DisplayName
};
return CreateMetaTags(tags);
}
/// <summary>
/// Creates <c>&lt;meta&gt;</c> embed tags by pulling data from the specified article.
/// </summary>
/// <param name="article">The article whose metadata should be retrieved.</param>
/// <param name="tutorialService">The <see cref="ITutorialService" /> injected by the page.</param>
/// <returns>A string containing a collection of <c>&lt;meta&gt;</c> embed tags.</returns>
/// <exception cref="ArgumentNullException">
/// <para><paramref name="article" /> is <see langword="null" />.</para>
/// -or-
/// <para><paramref name="tutorialService" /> is <see langword="null" />.</para>
/// </exception>
public static string CreateMetaTagsFromTutorialArticle(ITutorialArticle article, ITutorialService tutorialService)
{
if (article is null)
{
throw new ArgumentNullException(nameof(article));
}
if (tutorialService is null)
{
throw new ArgumentNullException(nameof(tutorialService));
}
string excerpt = tutorialService.RenderExcerpt(article, out _);
var tags = new Dictionary<string, string>
{
["title"] = article.Title,
["description"] = excerpt,
["author"] = "Oliver Booth" // TODO add article author support?
};
return CreateMetaTags(tags);
}
/// <summary>
/// Creates <c>&lt;meta&gt;</c> embed tags by pulling data from the specified dictionary.
/// </summary>
/// <param name="tags">
/// A dictionary containing the tag values. This dictionary should be in the form:
///
/// <list type="table">
/// <listheader>
/// <term>Key</term>
/// <description>Description</description>
/// </listheader>
///
/// <item>
/// <term>description</term>
/// <description>
/// The value to apply to the <c>description</c>, <c>og:description</c>, and <c>twitter:description</c>, tags.
/// </description>
/// </item>
///
/// <item>
/// <term>author</term>
/// <description>The value to apply to the <c>og:site_name</c>, and <c>twitter:creator</c>, tags.</description>
/// </item>
///
/// <item>
/// <term>title</term>
/// <description>
/// The value to apply to the <c>title</c>, <c>og:title</c>, and <c>twitter:title</c>, tags.
/// </description>
/// </item>
/// </list>
///
/// Any other values contained with the dictionary are ignored.
/// </param>
/// <returns>A string containing a collection of <c>&lt;meta&gt;</c> embed tags.</returns>
/// <exception cref="ArgumentNullException"><paramref name="tags" /> is <see langword="null" />.</exception>
public static string CreateMetaTags(IReadOnlyDictionary<string, string> tags)
{
if (tags is null)
{
throw new ArgumentNullException(nameof(tags));
}
using Utf8ValueStringBuilder builder = ZString.CreateUtf8StringBuilder();
builder.AppendLine("""<meta property="og:type" content="article">""");
if (tags.TryGetValue("description", out string? description))
{
description = HttpUtility.HtmlEncode(description);
builder.AppendLine($"""<meta name="description" content="{description}">""");
builder.AppendLine($"""<meta property="og:description" content="{description}">""");
builder.AppendLine($"""<meta property="twitter:description" content="{description}">""");
}
if (tags.TryGetValue("author", out string? author))
{
author = HttpUtility.HtmlEncode(author);
builder.AppendLine($"""<meta property="og:site_name" content="{author}">""");
builder.AppendLine($"""<meta property="twitter:creator" content="{author}">""");
}
if (tags.TryGetValue("title", out string? title))
{
title = HttpUtility.HtmlEncode(title);
builder.AppendLine($"""<meta name="title" content="{title}">""");
builder.AppendLine($"""<meta property="og:title" content="{title}">""");
builder.AppendLine($"""<meta property="twitter:title" content="{title}">""");
}
return builder.ToString();
}
}

View File

@ -0,0 +1,140 @@
using System.Diagnostics;
using System.Text;
using Markdig;
using OliverBooth.Common.Data.Web;
using OliverBooth.Common.Services;
using OliverBooth.Extensions.Markdig.Markdown.Template;
namespace OliverBooth.Markdown.Template;
/// <summary>
/// Represents a custom template renderer which renders the <c>{{Snippet}}</c> template.
/// </summary>
internal sealed class CodeSnippetTemplateRenderer : CustomTemplateRenderer
{
private readonly ICodeSnippetService _codeSnippetService;
private readonly Lazy<MarkdownPipeline> _markdownPipeline;
private readonly IProgrammingLanguageService _programmingLanguageService;
/// <summary>
/// Initializes a new instance of the <see cref="CodeSnippetTemplateRenderer" /> class.
/// </summary>
/// <param name="serviceProvider">The service provider.</param>
public CodeSnippetTemplateRenderer(IServiceProvider serviceProvider) : base(serviceProvider)
{
// lazily evaluate to avoid circular dependency problem causing tremendous stack overflow
_markdownPipeline = new Lazy<MarkdownPipeline>(serviceProvider.GetRequiredService<MarkdownPipeline>);
_codeSnippetService = serviceProvider.GetRequiredService<ICodeSnippetService>();
_programmingLanguageService = serviceProvider.GetRequiredService<IProgrammingLanguageService>();
}
/// <inheritdoc />
public override string Render(TemplateInline template)
{
Debug.Assert(template.Name == "Snippet");
Trace.Assert(template.Name == "Snippet");
IReadOnlyList<string> argumentList = template.ArgumentList;
if (argumentList.Count < 1)
{
return DefaultRender(template);
}
if (!int.TryParse(argumentList[0], out int snippetId))
{
return DefaultRender(template);
}
var identifier = Guid.NewGuid();
var snippets = new List<ICodeSnippet>();
IReadOnlyList<string> languages = argumentList.Count > 1
? argumentList[1].Split(';')
: _codeSnippetService.GetLanguagesForSnippet(snippetId);
foreach (string language in languages)
{
if (_codeSnippetService.TryGetCodeSnippetForLanguage(snippetId, language, out ICodeSnippet? snippet))
{
snippets.Add(snippet);
}
}
if (snippets.Count == 1)
{
ICodeSnippet snippet = snippets[0];
return RenderHtml(snippet);
}
var builder = new StringBuilder();
builder.AppendLine($"""
<ul class="nav nav-tabs mb-3" id="snp-{identifier:N}" data-identifier="{identifier:N}" role="tablist"
style="margin-bottom: -0.5em !important;">
""");
for (var index = 0; index < languages.Count; index++)
{
var language = languages[index];
string classList = "";
if (index == 0)
{
classList = " active";
}
builder.AppendLine("""<li class="nav-item" role="presentation">""");
builder.AppendLine($"""
<a
data-tab-init
class="nav-link{classList}"
id="snp-{snippetId}-{identifier:N}-{language}-l"
href="#"
role="tab"
data-tabs="snp-{snippetId}-{identifier:N}"
aria-controls="snp-{snippetId}-{identifier:N}-{language}"
aria-selected="true"
>{_programmingLanguageService.GetLanguageName(language)}</a
>
""");
builder.AppendLine("</li>");
}
builder.AppendLine("</ul>");
builder.AppendLine($"""<div class="tab-content" id="snp-{snippetId}-{identifier:N}">""");
for (var index = 0; index < snippets.Count; index++)
{
string classList = "";
if (index == 0)
{
classList = " show active";
}
var snippet = snippets[index];
string html = RenderHtml(snippet);
builder.AppendLine($"""
<div class="tab-pane fade{classList}" id="snp-{snippetId}-{identifier:N}-{snippet.Language}" data-identifier="{identifier:N}" role="tabpanel"
aria-labelledby="snp-{snippetId}-{identifier:N}-{snippet.Language}">
""");
builder.AppendLine(html);
builder.AppendLine("</div>");
}
builder.AppendLine("</div>");
return builder.ToString();
}
private string RenderHtml(ICodeSnippet snippet)
{
return Markdig.Markdown.ToHtml($"```{snippet.Language}\n{snippet.Content}\n```", _markdownPipeline.Value);
}
private static string DefaultRender(TemplateInline template)
{
return template.ArgumentList.Count == 0
? $"{{{{{template.Name}}}}}"
: $"{{{{{template.Name}|{string.Join('|', template.ArgumentList)}}}}}";
}
}

View File

@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data.Web;
using OliverBooth.Extensions.Markdig.Markdown.Template;
namespace OliverBooth.Markdown.Template;
/// <summary>
/// Represents a custom renderer which overrides the default behaviour of the template engine.
/// </summary>
internal abstract class CustomTemplateRenderer
{
/// <summary>
/// Initializes a new instance of the <see cref="CustomTemplateRenderer" /> class.
/// </summary>
/// <param name="serviceProvider">The service provider.</param>
protected CustomTemplateRenderer(IServiceProvider serviceProvider)
{
DbContextFactory = serviceProvider.GetRequiredService<IDbContextFactory<WebContext>>();
}
/// <summary>
/// Gets the <see cref="WebContext" /> factory that was injected into this instance.
/// </summary>
/// <value>An <see cref="IDbContextFactory{TContext}" /> for <see cref="WebContext" />.</value>
protected IDbContextFactory<WebContext> DbContextFactory { get; }
/// <summary>
/// Renders the specified template.
/// </summary>
/// <param name="template">The template to render.</param>
/// <returns>The rendered result of the template.</returns>
public abstract string Render(TemplateInline template);
}

View File

@ -30,13 +30,9 @@
<PackageReference Include="Alexinea.Extensions.Configuration.Toml" Version="7.0.0"/> <PackageReference Include="Alexinea.Extensions.Configuration.Toml" Version="7.0.0"/>
<PackageReference Include="AspNetCore.ReCaptcha" Version="1.8.1"/> <PackageReference Include="AspNetCore.ReCaptcha" Version="1.8.1"/>
<PackageReference Include="BCrypt.Net-Core" Version="1.6.0"/> <PackageReference Include="BCrypt.Net-Core" Version="1.6.0"/>
<PackageReference Include="HtmlAgilityPack" Version="1.11.59"/>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="MailKit" Version="4.4.0"/> <PackageReference Include="MailKit" Version="4.4.0"/>
<PackageReference Include="MailKitSimplified.Sender" Version="2.9.0"/> <PackageReference Include="MailKitSimplified.Sender" Version="2.9.0"/>
<PackageReference Include="Markdig" Version="0.36.2"/>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.3"/> <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.3"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.3"/>
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="8.0.3"/> <PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="8.0.3"/>
<PackageReference Include="NetBarcode" Version="1.7.0"/> <PackageReference Include="NetBarcode" Version="1.7.0"/>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2"/> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2"/>
@ -45,10 +41,20 @@
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0"/> <PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1"/> <PackageReference Include="Serilog.Sinks.Console" Version="5.0.1"/>
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0"/> <PackageReference Include="Serilog.Sinks.File" Version="5.0.0"/>
<PackageReference Include="SmartFormat.NET" Version="3.3.2"/>
<PackageReference Include="X10D" Version="3.3.1"/> <PackageReference Include="X10D" Version="3.3.1"/>
<PackageReference Include="X10D.Hosting" Version="3.3.1"/> <PackageReference Include="X10D.Hosting" Version="3.3.1"/>
<PackageReference Include="ZString" Version="2.5.1"/> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OliverBooth.Common\OliverBooth.Common.csproj"/>
<ProjectReference Include="..\OliverBooth.Extensions.Markdig\OliverBooth.Extensions.Markdig.csproj"/>
<ProjectReference Include="..\OliverBooth.Extensions.SmartFormat\OliverBooth.Extensions.SmartFormat.csproj"/>
</ItemGroup>
<ItemGroup>
<Compile Update="Pages\Shared\Partials\PageTabsUtility.cs">
<DependentUpon>_PageTabs.cshtml</DependentUpon>
</Compile>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,13 +1,17 @@
@page "/blog/{year:int}/{month:int}/{day:int}/{slug}" @page "/blog/{year:int}/{month:int}/{day:int}/{slug}"
@using Humanizer @using Humanizer
@using OliverBooth.Data.Blog @using Markdig
@using OliverBooth.Services @using Microsoft.AspNetCore.Mvc.TagHelpers
@using OliverBooth.Common.Data
@using OliverBooth.Common.Data.Blog
@using OliverBooth.Common.Services
@inject IBlogPostService BlogPostService @inject IBlogPostService BlogPostService
@inject MarkdownPipeline MarkdownPipeline
@model Article @model Article
@if (Model.ShowPasswordPrompt) @if (Model.ShowPasswordPrompt)
{ {
<div class="alert alert-danger" role="alert"> <div class="callout" data-callout="danger">
This post is private and can only be viewed by those with the password. This post is private and can only be viewed by those with the password.
</div> </div>
@ -44,14 +48,14 @@
@switch (post.Visibility) @switch (post.Visibility)
{ {
case BlogPostVisibility.Private: case Visibility.Private:
<div class="alert alert-danger" role="alert"> <div class="callout" data-callout="danger">
This post is private and can only be viewed by those with the password. This post is private and can only be viewed by those with the password.
</div> </div>
break; break;
case BlogPostVisibility.Unlisted: case Visibility.Unlisted:
<div class="alert alert-warning" role="alert"> <div class="callout" data-callout="warning">
This post is unlisted and can only be viewed by those with the link. This post is unlisted and can only be viewed by those with the link.
</div> </div>
break; break;
@ -73,19 +77,8 @@
Updated @updated.Humanize() Updated @updated.Humanize()
</abbr> </abbr>
} }
@if (post.EnableComments)
{
<span>&bull;</span>
<a href="#disqus_thread" data-disqus-identifier="@post.GetDisqusIdentifier()">0 Comments</a>
}
</p> </p>
<div class="post-tags">
@foreach (string tag in post.Tags)
{
<a asp-page="Index" asp-route-tag="@tag" class="badge bg-secondary">@tag</a>
}
</div>
<hr> <hr>
<article> <article>
@ -94,6 +87,18 @@
<hr> <hr>
<div class="d-flex align-items-center mb-3">
<i data-lucide="tag"></i>
<ul class="ms-2 post-tags">
@foreach (string tag in post.Tags)
{
<li class="post-tag">
<a asp-page="Index" asp-route-tag="@Html.UrlEncoder.Encode(tag)">@tag</a>
</li>
}
</ul>
</div>
<div class="row"> <div class="row">
<div class="col-sm-12 col-md-6"> <div class="col-sm-12 col-md-6">
@if (BlogPostService.GetPreviousPost(post) is { } previousPost) @if (BlogPostService.GetPreviousPost(post) is { } previousPost)
@ -131,31 +136,76 @@
@if (post.EnableComments) @if (post.EnableComments)
{ {
<div id="disqus_thread"></div> <div class="giscus"></div>
<script> @section Scripts
var disqus_config = function () { {
this.page.url = "@post.GetDisqusUrl()"; <script src="https://giscus.app/client.js"
this.page.identifier = "@post.GetDisqusIdentifier()"; data-repo="oliverbooth/oliverbooth.dev"
this.page.title = "@post.Title"; data-repo-id="MDEwOlJlcG9zaXRvcnkyNDUxODEyNDI="
this.page.postId = "@post.GetDisqusPostId()"; data-category="Comments"
}; data-category-id="DIC_kwDODp0rOs4Ce_Nj"
data-mapping="pathname"
data-strict="0"
data-reactions-enabled="1"
data-emit-metadata="0"
data-input-position="bottom"
data-theme="preferred_color_scheme"
data-lang="en"
crossorigin="anonymous"
async>
</script>
}
(function() { int commentCount = BlogPostService.GetLegacyCommentCount(post);
const d = document, s = d.createElement("script"); if (commentCount > 0)
s.async = true; {
s.type = "text/javascript"; <hr>
s.src = "https://oliverbooth-dev.disqus.com/embed.js";
s.setAttribute("data-timestamp", (+ new Date()).toString()); var nestLevelMap = new Dictionary<ILegacyComment, int>();
(d.head || d.body).appendChild(s); IReadOnlyList<ILegacyComment> legacyComments = BlogPostService.GetLegacyComments(post);
})(); var commentStack = new Stack<ILegacyComment>(legacyComments.OrderByDescending(c => c.CreatedAt));
</script> <p class="text-center">
<script id="dsq-count-scr" src="https://oliverbooth-dev.disqus.com/count.js" async></script> <strong>@("legacy comment".ToQuantity(commentCount))</strong>
<noscript> </p>
Please enable JavaScript to view the <p class="text-center">
<a href="https://disqus.com/?ref_noscript" rel="nofollow"> <sub>Legacy comments are comments that were posted using a commenting system that I no longer use. This exists for posterity.</sub>
comments powered by Disqus. </p>
</a>
</noscript> while (commentStack.Count > 0)
{
ILegacyComment comment = commentStack.Pop();
foreach (ILegacyComment reply in BlogPostService.GetLegacyReplies(comment).OrderByDescending(c => c.CreatedAt))
{
if (nestLevelMap.TryGetValue(comment, out int currentLevel))
{
nestLevelMap[reply] = currentLevel + 1;
}
else
{
nestLevelMap[reply] = 1;
}
commentStack.Push(reply);
}
int padding = 0;
if (nestLevelMap.TryGetValue(comment, out int nestLevel))
{
padding = 50 * nestLevel;
}
<div class="legacy-comment" style="margin-left: @(padding)px;">
<img class="blog-author-icon" src="@comment.GetAvatarUrl()" alt="@comment.Author">
@comment.Author &bull;
<abbr class="text-muted" data-bs-toggle="tooltip" data-bs-title="@comment.CreatedAt.ToString("dddd, d MMMM yyyy HH:mm")">
@comment.CreatedAt.Humanize()
</abbr>
<div class="comment">@Html.Raw(Markdown.ToHtml(comment.Body, MarkdownPipeline))</div>
</div>
}
}
} }
else else
{ {

View File

@ -1,8 +1,8 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;
using OliverBooth.Data.Blog; using OliverBooth.Common.Data.Blog;
using OliverBooth.Services; using OliverBooth.Common.Services;
using BC = BCrypt.Net.BCrypt; using BC = BCrypt.Net.BCrypt;
namespace OliverBooth.Pages.Blog; namespace OliverBooth.Pages.Blog;
@ -79,7 +79,6 @@ public class Article : PageModel
var date = new DateOnly(year, month, day); var date = new DateOnly(year, month, day);
if (!_blogPostService.TryGetPost(date, slug, out IBlogPost? post)) if (!_blogPostService.TryGetPost(date, slug, out IBlogPost? post))
{ {
Response.StatusCode = 404;
return NotFound(); return NotFound();
} }

View File

@ -1,95 +1,27 @@
@page @page
@using Humanizer @using OliverBooth.Common.Data
@using OliverBooth.Data.Mastodon @using OliverBooth.Common.Data.Blog
@using OliverBooth.Services @using OliverBooth.Common.Services
@model Index @model Index
@inject IMastodonService MastodonService @inject IBlogPostService BlogPostService
@{ @{
ViewData["Title"] = "Blog"; ViewData["Title"] = "Blog";
MastodonStatus latestStatus = MastodonService.GetLatestStatus();
bool doAprilFools = DateOnly.FromDateTime(DateTime.UtcNow) == new DateOnly(2024, 04, 01) || Environment.GetEnvironmentVariable("DO_AF") == "1";
} }
@if (doAprilFools) @await Html.PartialAsync("Partials/_MastodonStatus")
<div id="all-blog-posts">
@foreach (IBlogPost post in BlogPostService.GetBlogPosts(0, tags: Model.Tag))
{
@await Html.PartialAsync("Partials/_BlogCard", post)
}
</div>
@await Html.PartialAsync("Partials/_PageTabs", new ViewDataDictionary(ViewData)
{ {
<h1>UNDER CONSTRUCTION</h1> ["UrlRoot"] = "/blog",
<div style="text-align: center"> ["Page"] = 1,
<img src="~/img/construction_90x85.gif"> ["Tags"] = Model.Tag,
<img src="~/img/underconstruction_323x118.gif"> ["PageCount"] = BlogPostService.GetPageCount(visibility: Visibility.Published, tags: Model.Tag)
<img src="~/img/construction_90x85.gif"> })
<p>Coming soon WATCH THIS SPACE</p>
</div>
}
else
{
<div class="card text-center mastodon-update-card">
<div class="card-body">
@Html.Raw(latestStatus.Content)
@foreach (MediaAttachment attachment in latestStatus.MediaAttachments)
{
switch (attachment.Type)
{
case AttachmentType.Audio:
<p><audio controls="controls" src="@attachment.Url"></audio></p>
break;
case AttachmentType.Video:
<p><video controls="controls" class="figure-img img-fluid" src="@attachment.Url"></video></p>
break;
case AttachmentType.Image:
case AttachmentType.GifV:
<p><img class="figure-img img-fluid" src="@attachment.Url"></p>
break;
}
}
</div>
<div class="card-footer text-muted">
<abbr title="@latestStatus.CreatedAt.ToString("F")">@latestStatus.CreatedAt.Humanize()</abbr>
&bull;
<a href="@latestStatus.OriginalUri" target="_blank">View on Mastodon</a>
</div>
</div>
<div id="all-blog-posts">
@await Html.PartialAsync("_LoadingSpinner")
</div>
<script id="blog-post-template" type="text/x-handlebars-template">
<div class="card-header">
<span class="text-muted">
<img class="blog-author-icon" src="{{author.avatar}}" alt="{{author.name}}">
<span>{{author.name}}<span>
<span> &bull; </span>
<abbr title="{{ post.formattedDate }}">{{ post.date_humanized }}</abbr>
{{#if post.enable_comments}}
<span> &bull; </span>
<a href="{{post.url}}#disqus_thread" data-disqus-identifier="{{post.disqus_identifier}}">
Loading comment count &hellip;
</a>
{{/if}}
</span>
</div>
<div class="card-body">
<h2>
<a href="{{post.url}}"> {{post.title}}</a>
</h2>
<p>{{{post.excerpt}}}</p>
{{#if post.trimmed}}
<p>
<a href="{{post.url}}">
Read more...
</a>
</p>
{{/if}}
</div>
<div class="card-footer">
{{#each post.tags}}
<a href="?tag={{urlEncode this}}" class="badge text-bg-dark">{{this}}</a>
{{/each}}
</div>
</script>
}

View File

@ -1,7 +1,8 @@
using System.Web;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Data.Blog; using OliverBooth.Common.Data.Blog;
using OliverBooth.Services; using OliverBooth.Common.Services;
namespace OliverBooth.Pages.Blog; namespace OliverBooth.Pages.Blog;
@ -15,11 +16,15 @@ public class Index : PageModel
_blogPostService = blogPostService; _blogPostService = blogPostService;
} }
public string[] Tag { get; private set; } = [];
public IActionResult OnGet([FromQuery(Name = "pid")] Guid? postId = null, public IActionResult OnGet([FromQuery(Name = "pid")] Guid? postId = null,
[FromQuery(Name = "p")] int? wpPostId = null) [FromQuery(Name = "p")] int? wpPostId = null,
[FromQuery(Name = "tag")] string? tag = null)
{ {
if (postId.HasValue == wpPostId.HasValue) if (postId.HasValue == wpPostId.HasValue)
{ {
Tag = (tag?.Split('+').Select(HttpUtility.UrlDecode).ToArray() ?? [])!;
return Page(); return Page();
} }
@ -36,7 +41,7 @@ public class Index : PageModel
return _blogPostService.TryGetPost(wpPostId, out IBlogPost? post) ? RedirectToPost(post) : NotFound(); return _blogPostService.TryGetPost(wpPostId, out IBlogPost? post) ? RedirectToPost(post) : NotFound();
} }
private IActionResult RedirectToPost(IBlogPost post) private RedirectResult RedirectToPost(IBlogPost post)
{ {
var route = new var route = new
{ {

View File

@ -0,0 +1,24 @@
@page "/blog/page/{pageNumber:int}"
@model List
@using OliverBooth.Common.Data
@using OliverBooth.Common.Data.Blog
@using OliverBooth.Common.Services
@inject IBlogPostService BlogPostService
@await Html.PartialAsync("Partials/_MastodonStatus")
<div id="all-blog-posts">
@foreach (IBlogPost post in BlogPostService.GetBlogPosts(Model.PageNumber - 1, tags: Model.Tag))
{
@await Html.PartialAsync("Partials/_BlogCard", post)
}
</div>
@await Html.PartialAsync("Partials/_PageTabs", new ViewDataDictionary(ViewData)
{
["UrlRoot"] = "/blog",
["Page"] = Model.PageNumber,
["Tags"] = Model.Tag,
["PageCount"] = BlogPostService.GetPageCount(visibility: Visibility.Published, tags: Model.Tag)
})

View File

@ -0,0 +1,37 @@
using System.Web;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace OliverBooth.Pages.Blog;
/// <summary>
/// Represents a class which defines the model for the <c>/blog/page/#</c> route.
/// </summary>
public class List : PageModel
{
/// <summary>
/// Gets the requested page number.
/// </summary>
/// <value>The requested page number.</value>
public int PageNumber { get; private set; }
public string[] Tag { get; private set; } = [];
/// <summary>
/// Handles the incoming GET request to the page.
/// </summary>
/// <param name="page">The requested page number, starting from 1.</param>
/// <param name="tag">The tag by which to filter results.</param>
/// <returns></returns>
public IActionResult OnGet([FromRoute(Name = "pageNumber")] int page = 1, [FromQuery(Name = "tag")] string? tag = null)
{
if (page < 2)
{
return RedirectToPage("Index");
}
PageNumber = page;
Tag = (tag?.Split('+').Select(HttpUtility.UrlDecode).ToArray() ?? [])!;
return Page();
}
}

View File

@ -1,8 +1,8 @@
using Cysharp.Text; using Cysharp.Text;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Data.Blog; using OliverBooth.Common.Data.Blog;
using OliverBooth.Services; using OliverBooth.Common.Services;
namespace OliverBooth.Pages.Blog; namespace OliverBooth.Pages.Blog;

View File

@ -1,5 +1,5 @@
@page @page
@using OliverBooth.Data.Web @using OliverBooth.Common.Data.Web
@model OliverBooth.Pages.Books @model OliverBooth.Pages.Books
@{ @{
ViewData["Title"] = "Reading List"; ViewData["Title"] = "Reading List";

View File

@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Data.Web; using OliverBooth.Common.Data.Web;
using OliverBooth.Services; using OliverBooth.Common.Services;
namespace OliverBooth.Pages; namespace OliverBooth.Pages;

View File

@ -1,6 +1,7 @@
@page @page
@using OliverBooth.Data.Web @using Microsoft.AspNetCore.Mvc.TagHelpers
@using OliverBooth.Services @using OliverBooth.Common.Data.Web
@using OliverBooth.Common.Services
@inject IContactService ContactService @inject IContactService ContactService
@{ @{
ViewData["Title"] = "Blacklist"; ViewData["Title"] = "Blacklist";

View File

@ -1,4 +1,5 @@
@page @page
@using Microsoft.AspNetCore.Mvc.TagHelpers
@{ @{
ViewData["Title"] = "Contact"; ViewData["Title"] = "Contact";
} }
@ -11,29 +12,31 @@
if I can. if I can.
</p> </p>
<div class="alert alert-warning"> <div class="callout" data-callout="warning">
<p class="lead"><i class="fa-solid fa-triangle-exclamation"></i> Spam warning</p> <div class="callout-title"><i data-lucide="triangle-alert"></i> Spam Warning</div>
<p> <div class="callout-content">
I am politely asking that you respect my inbox and keep your unsolicited advertising away. This is a simple I am politely asking that you respect my inbox and keep your unsolicited advertising away. This is a simple
request. If you send me any kind of spam after this, you have demonstrated that you do not have the basic request. If you send me any kind of spam after this, you have demonstrated that you do not have the basic
human decency to respect my wishes or my privacy, and you have lost the privilege for me to respect yours. I human decency to respect my wishes or my privacy, and you have lost the privilege for me to respect yours. I
<strong>will</strong> block your email address, and I will add your name to my public blacklist of spammers, <strong>will</strong> block your email address, and I will add your name to my public blacklist of spammers,
which you can find <a asp-page="/Contact/Blacklist">here</a>. which you can find <a asp-page="/Contact/Blacklist">here</a>.
</p> </div>
</div> </div>
<div class="alert alert-info"> <div class="callout" data-callout="tip">
<p class="lead"><i class="fa-solid fa-circle-info"></i> Dear SEO marketing teams</p> <div class="callout-title"><i data-lucide="info"></i> Dear SEO marketing teams</div>
<p> <div class="callout-content">
I have given you enough chances. I asked that you do not send me any SEO offers or else be blocked. <p>
Congratulations, you've been upgraded to "spammer" and will receive the same treatment as any other bad I have given you enough chances. I asked that you do not send me any SEO offers or else be blocked.
actor sending unsolicited crap. Congratulations, you've been upgraded to "spammer" and will receive the same treatment as any other bad
</p> actor sending unsolicited crap.
<p> </p>
If I receive any SEO emails, you will be added to the blacklist and your company name will be shamed too. I <p>
will make my best efforts to ensure no one ever uses you. If you cannot respect the privacy of my inbox, I If I receive any SEO emails, you will be added to the blacklist and your company name will be shamed
will never respect you or your company. too. I will make my best efforts to ensure no one ever uses you. If you cannot respect the privacy of my
</p> inbox, I will never respect you or your company.
</p>
</div>
</div> </div>
<form method="post" asp-controller="Contact" asp-action="HandleForm"> <form method="post" asp-controller="Contact" asp-action="HandleForm">

View File

@ -1,4 +1,5 @@
@page @page
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model OliverBooth.Pages.Contact.Result @model OliverBooth.Pages.Contact.Result
@{ @{

View File

@ -1,34 +0,0 @@
@page "/error/{code:int?}"
@model OliverBooth.Pages.ErrorModel
@{
Layout = "_MinimalLayout";
ViewData["Title"] = "Error";
}
<h2 class="text-danger">
@switch (Model.HttpStatusCode)
{
case 403:
<span>403 Forbidden</span>
break;
case 404:
<span>404 Page not found</span>
break;
case 500:
<span>Internal server error</span>
break;
default:
<span>Something went wrong</span>
break;
}
</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}

View File

@ -1,28 +0,0 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace OliverBooth.Pages;
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
public int HttpStatusCode { get; private set; }
public IActionResult OnGet(int? code = null)
{
HttpStatusCode = code ?? HttpContext.Response.StatusCode;
if (HttpStatusCode == 200)
{
return RedirectToPage("/Index");
}
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
return Page();
}
}

View File

@ -0,0 +1,17 @@
@page "/error/400"
<article>
<div class="d-flex align-items-center justify-content-center">
<div class="d-flex flex-column align-items-center justify-content-center">
<div class="p-2">
<h1 class="text-center">400 Bad Request</h1>
</div>
<div class="p-2">
<p class="text-center">Received invalid request message. Check your request and try again.</p>
</div>
</div>
<div class="align-self-stretch">
<img class="img-fluid" src="~/img/error/400-bad-request.png">
</div>
</div>
</article>

View File

@ -0,0 +1,15 @@
@page "/error/403"
<article>
<div class="text-center d-flex flex-column align-items-center">
<div class="p-2">
<h1 class="text-center">403 Forbidden</h1>
</div>
<div class="p-2">
<img class="img-fluid" src="~/img/error/403-forbidden.png">
</div>
<div class="p-2">
<p class="text-center">Access to the requested page is forbidden.</p>
</div>
</div>
</article>

View File

@ -0,0 +1,17 @@
@page "/error/504"
<article>
<div class="d-flex align-items-center justify-content-center">
<div class="align-self-stretch">
<img class="img-fluid" src="~/img/error/504-gateway-timeout.png">
</div>
<div class="d-flex flex-column align-items-center justify-content-center">
<div class="p-2">
<h1 class="text-center">504 Gateway Timeout</h1>
</div>
<div class="p-2">
<p class="text-center">The server is slacking. Give it more coffee.</p>
</div>
</div>
</div>
</article>

View File

@ -0,0 +1,17 @@
@page "/error/400"
<article>
<div class="d-flex align-items-center justify-content-center">
<div class="d-flex flex-column align-items-center justify-content-center">
<div class="p-2">
<h1 class="text-center">410 Gone</h1>
</div>
<div class="p-2">
<p class="text-center">The requested page has mysteriously disappeared.</p>
</div>
</div>
<div class="align-self-stretch">
<img class="img-fluid" src="~/img/error/410-gone.png">
</div>
</div>
</article>

View File

@ -0,0 +1,17 @@
@page "/error/418"
<article>
<div class="d-flex align-items-center justify-content-center">
<div class="align-self-stretch">
<img class="img-fluid" src="~/img/error/418-im-a-teapot.png">
</div>
<div class="d-flex flex-column align-items-center justify-content-center">
<div class="p-2">
<h1 class="text-center">418 I'm A Teapot</h1>
</div>
<div class="p-2">
<p class="text-center">No coffee available. I am only capable of brewing tea.</p>
</div>
</div>
</div>
</article>

View File

@ -0,0 +1,17 @@
@page "/error/500"
<article>
<div class="d-flex align-items-center justify-content-center">
<div class="align-self-stretch">
<img class="img-fluid" src="~/img/error/500-internal-server-error.png">
</div>
<div class="d-flex flex-column align-items-center justify-content-center">
<div class="p-2">
<h1 class="text-center">500 Internal Server Error</h1>
</div>
<div class="p-2">
<p class="text-center">This is my fault, not yours.</p>
</div>
</div>
</div>
</article>

Some files were not shown because too many files have changed in this diff Show More