Compare commits
68 Commits
feature/ad
...
main
Author | SHA1 | Date | |
---|---|---|---|
5d10f251a8 | |||
ee8a2cb569 | |||
fdf8f9c32d | |||
fa087a513d | |||
771ccc52ad | |||
59e42ff7cd | |||
d32d46e221 | |||
0bebcb69fe | |||
d3ac89d071 | |||
901a8347b9 | |||
8dbfeb8d38 | |||
eb67c25e09 | |||
15e28bd223 | |||
acb6b32938 | |||
cf4d92c035 | |||
58797b82ca | |||
9991ecf173 | |||
7d21bc0b85 | |||
c9b64cc778 | |||
23d3950695 | |||
83beffe685 | |||
f08a3d3607 | |||
746b4d8728 | |||
435a69b27a | |||
435cae95db | |||
2ec2c0befc | |||
99ff3124c3 | |||
dec9307f1d | |||
6ec4103a3a | |||
e0037fbff2 | |||
ad12d6b836 | |||
5bfe5a044d | |||
7ede8b13fa | |||
29ed46eb9e | |||
b0f0658148 | |||
35a82a9663 | |||
01031057e0 | |||
16618cc135 | |||
a7426b008b | |||
217aaf2f79 | |||
b1f31f7850 | |||
818173b806 | |||
cd304aa09b | |||
98c923b07b | |||
91249029dc | |||
55b9f79e46 | |||
96e63a3088 | |||
1919b1d5c8 | |||
a1dd6ef6ff | |||
985acf7bc3 | |||
879ff6a295 | |||
cd6bbec1a5 | |||
b119861eee | |||
720b636439 | |||
14cac1e38d | |||
ba09fa22df | |||
d527fce02c | |||
81e1d25a7c | |||
e548758608 | |||
0b1066c273 | |||
05638e5deb | |||
238f519e0c | |||
817019ad16 | |||
bd1e9dac1f | |||
9074cf5210 | |||
577f3b0148 | |||
8629f8f963 | |||
9e0410f100 |
11
.github/workflows/dotnet.yml
vendored
11
.github/workflows/dotnet.yml
vendored
@ -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
|
||||||
|
7
.github/workflows/prerelease.yml
vendored
7
.github/workflows/prerelease.yml
vendored
@ -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
|
||||||
|
|
||||||
|
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@ -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
|
||||||
|
|
||||||
|
@ -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.
|
@ -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.
|
54
OliverBooth.Common/Data/Blog/ILegacyComment.cs
Normal file
54
OliverBooth.Common/Data/Blog/ILegacyComment.cs
Normal 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();
|
||||||
|
}
|
@ -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.
|
@ -1,4 +1,4 @@
|
|||||||
namespace OliverBooth.Data.Mastodon;
|
namespace OliverBooth.Common.Data.Mastodon;
|
||||||
|
|
||||||
public enum AttachmentType
|
public enum AttachmentType
|
||||||
{
|
{
|
31
OliverBooth.Common/Data/Mastodon/IMastodonStatus.cs
Normal file
31
OliverBooth.Common/Data/Mastodon/IMastodonStatus.cs
Normal 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; }
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
namespace OliverBooth.Data.Mastodon;
|
namespace OliverBooth.Common.Data.Mastodon;
|
||||||
|
|
||||||
public sealed class MediaAttachment
|
public sealed class MediaAttachment
|
||||||
{
|
{
|
@ -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>
|
@ -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.
|
@ -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.
|
@ -1,4 +1,4 @@
|
|||||||
namespace OliverBooth.Data.Web;
|
namespace OliverBooth.Common.Data.Web;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a book.
|
/// Represents a book.
|
25
OliverBooth.Common/Data/Web/ICodeSnippet.cs
Normal file
25
OliverBooth.Common/Data/Web/ICodeSnippet.cs
Normal 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; }
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
namespace OliverBooth.Data.Web;
|
namespace OliverBooth.Common.Data.Web;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a programming language.
|
/// Represents a programming language.
|
@ -1,4 +1,4 @@
|
|||||||
namespace OliverBooth.Data.Web;
|
namespace OliverBooth.Common.Data.Web;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a project.
|
/// Represents a project.
|
@ -1,4 +1,4 @@
|
|||||||
namespace OliverBooth.Data.Web;
|
namespace OliverBooth.Common.Data.Web;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a template.
|
/// Represents a template.
|
99
OliverBooth.Common/Data/Web/ITutorialArticle.cs
Normal file
99
OliverBooth.Common/Data/Web/ITutorialArticle.cs
Normal 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; }
|
||||||
|
}
|
43
OliverBooth.Common/Data/Web/ITutorialFolder.cs
Normal file
43
OliverBooth.Common/Data/Web/ITutorialFolder.cs
Normal 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; }
|
||||||
|
}
|
@ -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.
|
15
OliverBooth.Common/OliverBooth.Common.csproj
Normal file
15
OliverBooth.Common/OliverBooth.Common.csproj
Normal 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>
|
@ -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>
|
@ -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.
|
32
OliverBooth.Common/Services/ICodeSnippetService.cs
Normal file
32
OliverBooth.Common/Services/ICodeSnippetService.cs
Normal 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);
|
||||||
|
}
|
@ -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.
|
@ -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();
|
||||||
}
|
}
|
14
OliverBooth.Common/Services/IProgrammingLanguageService.cs
Normal file
14
OliverBooth.Common/Services/IProgrammingLanguageService.cs
Normal 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);
|
||||||
|
}
|
@ -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.
|
@ -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.
|
124
OliverBooth.Common/Services/ITutorialService.cs
Normal file
124
OliverBooth.Common/Services/ITutorialService.cs
Normal 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);
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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.
|
56
OliverBooth.Extensions.Markdig/MarkdownPipelineExtensions.cs
Normal file
56
OliverBooth.Extensions.Markdig/MarkdownPipelineExtensions.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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.
|
@ -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.
|
@ -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.
|
@ -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>
|
@ -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
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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; }
|
||||||
|
@ -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(
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
34
OliverBooth/Data/Blog/LegacyComment.cs
Normal file
34
OliverBooth/Data/Blog/LegacyComment.cs
Normal 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)}";
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
|
||||||
|
@ -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!;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
using OliverBooth.Common.Data.Web;
|
||||||
|
|
||||||
namespace OliverBooth.Data.Web;
|
namespace OliverBooth.Data.Web;
|
||||||
|
|
||||||
/// <inheritdoc cref="IBlacklistEntry"/>
|
/// <inheritdoc cref="IBlacklistEntry"/>
|
||||||
|
@ -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;
|
||||||
|
16
OliverBooth/Data/Web/CodeSnippet.cs
Normal file
16
OliverBooth/Data/Web/CodeSnippet.cs
Normal 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;
|
||||||
|
}
|
@ -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;
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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>>();
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,5 @@
|
|||||||
|
using OliverBooth.Common.Data.Web;
|
||||||
|
|
||||||
namespace OliverBooth.Data.Web;
|
namespace OliverBooth.Data.Web;
|
||||||
|
|
||||||
/// <inheritdoc cref="IProgrammingLanguage" />
|
/// <inheritdoc cref="IProgrammingLanguage" />
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
using OliverBooth.Common.Data.Web;
|
||||||
|
|
||||||
namespace OliverBooth.Data.Web;
|
namespace OliverBooth.Data.Web;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
using OliverBooth.Common.Data.Web;
|
||||||
|
|
||||||
namespace OliverBooth.Data.Web;
|
namespace OliverBooth.Data.Web;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
118
OliverBooth/Data/Web/TutorialArticle.cs
Normal file
118
OliverBooth/Data/Web/TutorialArticle.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
89
OliverBooth/Data/Web/TutorialFolder.cs
Normal file
89
OliverBooth/Data/Web/TutorialFolder.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
153
OliverBooth/Extensions/HtmlUtility.cs
Normal file
153
OliverBooth/Extensions/HtmlUtility.cs
Normal 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><meta></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><meta></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><meta></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><meta></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><meta></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><meta></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();
|
||||||
|
}
|
||||||
|
}
|
140
OliverBooth/Markdown/Template/CodeSnippetTemplateRenderer.cs
Normal file
140
OliverBooth/Markdown/Template/CodeSnippetTemplateRenderer.cs
Normal 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)}}}}}";
|
||||||
|
}
|
||||||
|
}
|
33
OliverBooth/Markdown/Template/CustomTemplateRenderer.cs
Normal file
33
OliverBooth/Markdown/Template/CustomTemplateRenderer.cs
Normal 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);
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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>•</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"
|
||||||
(function() {
|
data-strict="0"
|
||||||
const d = document, s = d.createElement("script");
|
data-reactions-enabled="1"
|
||||||
s.async = true;
|
data-emit-metadata="0"
|
||||||
s.type = "text/javascript";
|
data-input-position="bottom"
|
||||||
s.src = "https://oliverbooth-dev.disqus.com/embed.js";
|
data-theme="preferred_color_scheme"
|
||||||
s.setAttribute("data-timestamp", (+ new Date()).toString());
|
data-lang="en"
|
||||||
(d.head || d.body).appendChild(s);
|
crossorigin="anonymous"
|
||||||
})();
|
async>
|
||||||
</script>
|
</script>
|
||||||
<script id="dsq-count-scr" src="https://oliverbooth-dev.disqus.com/count.js" async></script>
|
}
|
||||||
<noscript>
|
|
||||||
Please enable JavaScript to view the
|
int commentCount = BlogPostService.GetLegacyCommentCount(post);
|
||||||
<a href="https://disqus.com/?ref_noscript" rel="nofollow">
|
if (commentCount > 0)
|
||||||
comments powered by Disqus.
|
{
|
||||||
</a>
|
<hr>
|
||||||
</noscript>
|
|
||||||
|
var nestLevelMap = new Dictionary<ILegacyComment, int>();
|
||||||
|
IReadOnlyList<ILegacyComment> legacyComments = BlogPostService.GetLegacyComments(post);
|
||||||
|
var commentStack = new Stack<ILegacyComment>(legacyComments.OrderByDescending(c => c.CreatedAt));
|
||||||
|
<p class="text-center">
|
||||||
|
<strong>@("legacy comment".ToQuantity(commentCount))</strong>
|
||||||
|
</p>
|
||||||
|
<p class="text-center">
|
||||||
|
<sub>Legacy comments are comments that were posted using a commenting system that I no longer use. This exists for posterity.</sub>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
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 •
|
||||||
|
|
||||||
|
<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
|
||||||
{
|
{
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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")
|
||||||
{
|
|
||||||
<h1>UNDER CONSTRUCTION</h1>
|
|
||||||
<div style="text-align: center">
|
|
||||||
<img src="~/img/construction_90x85.gif">
|
|
||||||
<img src="~/img/underconstruction_323x118.gif">
|
|
||||||
<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>
|
|
||||||
•
|
|
||||||
<a href="@latestStatus.OriginalUri" target="_blank">View on Mastodon</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="all-blog-posts">
|
<div id="all-blog-posts">
|
||||||
@await Html.PartialAsync("_LoadingSpinner")
|
@foreach (IBlogPost post in BlogPostService.GetBlogPosts(0, tags: Model.Tag))
|
||||||
</div>
|
{
|
||||||
|
@await Html.PartialAsync("Partials/_BlogCard", post)
|
||||||
<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> • </span>
|
|
||||||
<abbr title="{{ post.formattedDate }}">{{ post.date_humanized }}</abbr>
|
|
||||||
{{#if post.enable_comments}}
|
|
||||||
<span> • </span>
|
|
||||||
<a href="{{post.url}}#disqus_thread" data-disqus-identifier="{{post.disqus_identifier}}">
|
|
||||||
Loading comment count …
|
|
||||||
</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>
|
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@await Html.PartialAsync("Partials/_PageTabs", new ViewDataDictionary(ViewData)
|
||||||
|
{
|
||||||
|
["UrlRoot"] = "/blog",
|
||||||
|
["Page"] = 1,
|
||||||
|
["Tags"] = Model.Tag,
|
||||||
|
["PageCount"] = BlogPostService.GetPageCount(visibility: Visibility.Published, tags: Model.Tag)
|
||||||
|
})
|
@ -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
|
||||||
{
|
{
|
||||||
|
24
OliverBooth/Pages/Blog/List.cshtml
Normal file
24
OliverBooth/Pages/Blog/List.cshtml
Normal 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)
|
||||||
|
})
|
37
OliverBooth/Pages/Blog/List.cshtml.cs
Normal file
37
OliverBooth/Pages/Blog/List.cshtml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
|
||||||
|
@ -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";
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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";
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
@page
|
@page
|
||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Contact";
|
ViewData["Title"] = "Contact";
|
||||||
}
|
}
|
||||||
@ -11,30 +12,32 @@
|
|||||||
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>
|
||||||
|
<div class="callout-content">
|
||||||
<p>
|
<p>
|
||||||
I have given you enough chances. I asked that you do not send me any SEO offers or else be blocked.
|
I have given you enough chances. I asked that you do not send me any SEO offers or else be blocked.
|
||||||
Congratulations, you've been upgraded to "spammer" and will receive the same treatment as any other bad
|
Congratulations, you've been upgraded to "spammer" and will receive the same treatment as any other bad
|
||||||
actor sending unsolicited crap.
|
actor sending unsolicited crap.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
If I receive any SEO emails, you will be added to the blacklist and your company name will be shamed too. I
|
If I receive any SEO emails, you will be added to the blacklist and your company name will be shamed
|
||||||
will make my best efforts to ensure no one ever uses you. If you cannot respect the privacy of my inbox, I
|
too. I will make my best efforts to ensure no one ever uses you. If you cannot respect the privacy of my
|
||||||
will never respect you or your company.
|
inbox, I will never respect you or your company.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="post" asp-controller="Contact" asp-action="HandleForm">
|
<form method="post" asp-controller="Contact" asp-action="HandleForm">
|
||||||
<input type="hidden" name="contact-type" value="other">
|
<input type="hidden" name="contact-type" value="other">
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
@page
|
@page
|
||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
@model OliverBooth.Pages.Contact.Result
|
@model OliverBooth.Pages.Contact.Result
|
||||||
|
|
||||||
@{
|
@{
|
||||||
|
@ -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>
|
|
||||||
}
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
17
OliverBooth/Pages/Error/BadRequest.cshtml
Normal file
17
OliverBooth/Pages/Error/BadRequest.cshtml
Normal 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>
|
15
OliverBooth/Pages/Error/Forbidden.cshtml
Normal file
15
OliverBooth/Pages/Error/Forbidden.cshtml
Normal 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>
|
17
OliverBooth/Pages/Error/GatewayTimeout.cshtml
Normal file
17
OliverBooth/Pages/Error/GatewayTimeout.cshtml
Normal 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>
|
17
OliverBooth/Pages/Error/Gone.cshtml
Normal file
17
OliverBooth/Pages/Error/Gone.cshtml
Normal 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>
|
17
OliverBooth/Pages/Error/ImATeapot.cshtml
Normal file
17
OliverBooth/Pages/Error/ImATeapot.cshtml
Normal 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>
|
17
OliverBooth/Pages/Error/InternalServerError.cshtml
Normal file
17
OliverBooth/Pages/Error/InternalServerError.cshtml
Normal 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
Loading…
Reference in New Issue
Block a user