Compare commits

..

6 Commits

15 changed files with 353 additions and 140 deletions

View File

@ -16,6 +16,9 @@ internal sealed class BlogPost : IBlogPost
/// <inheritdoc />
public bool EnableComments { get; internal set; }
/// <inheritdoc />
public string? Excerpt { get; internal set; }
/// <inheritdoc />
public Guid Id { get; private set; } = Guid.NewGuid();

View File

@ -20,6 +20,7 @@ internal sealed class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
builder.Property(e => e.Updated).IsRequired(false);
builder.Property(e => e.Title).HasMaxLength(255).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.RedirectUrl).HasConversion<UriToStringConverter>().HasMaxLength(255).IsRequired(false);
builder.Property(e => e.EnableComments).IsRequired();

View File

@ -25,6 +25,12 @@ public interface IBlogPost
/// </value>
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>
/// Gets the ID of the post.
/// </summary>

View File

@ -16,6 +16,7 @@ internal sealed class TutorialArticleConfiguration : IEntityTypeConfiguration<Tu
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();
@ -24,5 +25,6 @@ internal sealed class TutorialArticleConfiguration : IEntityTypeConfiguration<Tu
builder.Property(e => e.NextPart);
builder.Property(e => e.PreviousPart);
builder.Property(e => e.Visibility).HasConversion<EnumToStringConverter<Visibility>>();
builder.Property(e => e.EnableComments).IsRequired();
}
}

View File

@ -11,6 +11,20 @@ public interface ITutorialArticle
/// <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>

View File

@ -8,6 +8,12 @@ internal sealed class TutorialArticle : IEquatable<TutorialArticle>, ITutorialAr
/// <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; }
@ -15,7 +21,7 @@ internal sealed class TutorialArticle : IEquatable<TutorialArticle>, ITutorialAr
public int Id { get; private set; }
/// <inheritdoc />
public int? NextPart { get; }
public int? NextPart { get; private set; }
/// <inheritdoc />
public Uri? PreviewImageUrl { get; private set; }

View File

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

View File

@ -132,31 +132,25 @@
@if (post.EnableComments)
{
<div id="disqus_thread"></div>
<script>
var disqus_config = function () {
this.page.url = "@post.GetDisqusUrl()";
this.page.identifier = "@post.GetDisqusIdentifier()";
this.page.title = "@post.Title";
this.page.postId = "@post.GetDisqusPostId()";
};
(function() {
const d = document, s = d.createElement("script");
s.async = true;
s.type = "text/javascript";
s.src = "https://oliverbooth-dev.disqus.com/embed.js";
s.setAttribute("data-timestamp", (+ new Date()).toString());
(d.head || d.body).appendChild(s);
})();
</script>
<script id="dsq-count-scr" src="https://oliverbooth-dev.disqus.com/count.js" async></script>
<noscript>
Please enable JavaScript to view the
<a href="https://disqus.com/?ref_noscript" rel="nofollow">
comments powered by Disqus.
</a>
</noscript>
<div class="giscus"></div>
@section Scripts
{
<script src="https://giscus.app/client.js"
data-repo="oliverbooth/oliverbooth.dev"
data-repo-id="MDEwOlJlcG9zaXRvcnkyNDUxODEyNDI="
data-category="Comments"
data-category-id="DIC_kwDODp0rOs4Ce_Nj"
data-mapping="pathname"
data-strict="0"
data-reactions-enabled="1"
data-emit-metadata="0"
data-input-position="bottom"
data-theme="preferred_color_scheme"
data-lang="en"
crossorigin="anonymous"
async>
</script>
}
}
else
{

View File

@ -8,22 +8,9 @@
@{
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)
{
<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 text-center mastodon-update-card">
<div class="card-body">
@Html.Raw(latestStatus.Content)
@foreach (MediaAttachment attachment in latestStatus.MediaAttachments)
@ -50,13 +37,13 @@ else
&bull;
<a href="@latestStatus.OriginalUri" target="_blank">View on Mastodon</a>
</div>
</div>
</div>
<div id="all-blog-posts">
<div id="all-blog-posts">
@await Html.PartialAsync("_LoadingSpinner")
</div>
</div>
<script id="blog-post-template" type="text/x-handlebars-template">
<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}}">
@ -91,5 +78,4 @@ else
<a href="?tag={{urlEncode this}}" class="badge text-bg-dark">{{this}}</a>
{{/each}}
</div>
</script>
}
</script>

View File

@ -5,7 +5,7 @@
<main class="container">
<h1 class="display-4">@ViewData["Title"]</h1>
<p class="lead">Last Updated: 26 May 2023</p>
<p class="lead">Last Updated: 27 April 2024</p>
<div class="alert alert-primary">
This Privacy Policy differs from the policy that applies to my applications published to Google Play. For my
applications' privacy policy, please <a asp-page="/Privacy/GooglePlay">click here</a>.
@ -32,10 +32,13 @@
</p>
<p>I do not use any cookies or similar tracking technologies that can identify individual users.</p>
<p>
Please note that my website includes a Disqus integration for commenting on blog posts. Disqus is a third-party
Please note that my website includes a GitHub integration for commenting on blog posts. GitHub is a third-party
service, and their use of cookies and collection of personal information are governed by their own privacy
policies. I have no control over the information collected by Disqus, and I encourage you to review their
privacy policy to understand how your information may be used by them.
policies. I have no control over the information collected by GitHub, and I encourage you to review
<a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement">
their privacy policy
</a>
to understand how your information may be used by them.
</p>
<h2>Use of Information</h2>

View File

@ -1,10 +1,12 @@
@using OliverBooth.Data.Blog
@using OliverBooth.Data.Web
@using OliverBooth.Extensions
@using OliverBooth.Services
@inject IBlogPostService BlogPostService
@inject ITutorialService TutorialService
@{
HttpRequest request = Context.Request;
var url = new Uri($"{request.Scheme}://{request.Host}{request.Path}{request.QueryString}");
bool doAprilFools = DateOnly.FromDateTime(DateTime.UtcNow) == new DateOnly(2024, 04, 01) || Environment.GetEnvironmentVariable("DO_AF") == "1";
}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
@ -28,15 +30,11 @@
}
@if (ViewData["Post"] is IBlogPost post)
{
string excerpt = BlogPostService.RenderExcerpt(post, out bool trimmed);
<meta name="title" content="@post.Title">
<meta name="description" content="@excerpt">
<meta property="og:title" content="@post.Title">
<meta property="og:description" content="@excerpt">
<meta property="og:type" content="article">
<meta property="twitter:title" content="@post.Title">
<meta property="twitter:creator" content="@post.Author.DisplayName">
<meta property="twitter:description" content="@excerpt">
@Html.Raw(HtmlUtility.CreateMetaTagsFromPost(post, BlogPostService))
}
else if (ViewData["Post"] is ITutorialArticle article)
{
@Html.Raw(HtmlUtility.CreateMetaTagsFromTutorialArticle(article, TutorialService))
}
else
{
@ -62,28 +60,12 @@
<link rel="stylesheet" href="~/css/prism.vs.min.css" asp-append-version="true">
<link rel="stylesheet" href="~/css/app.min.css" asp-append-version="true">
<link rel="stylesheet" href="~/css/ribbon.min.css" asp-append-version="true">
@if (doAprilFools)
{
<link rel="stylesheet" href="~/css/af-app.min.css" asp-append-version="true">
}
</head>
<body>
<header class="container" style="margin-top: 20px;">
<div id="site-title" class="text-center">
<h1>
@if (doAprilFools)
{
<marquee>
<a href="/">
<img src="~/img/ob-af-256x256.png" alt="Oliver Booth" height="128">
<img src="~/img/af-oliverbooth-1236x293.png" alt="Oliver Booth" height="128">
</a>
</marquee>
}
else
{
<a href="/"><img src="~/img/ob-256x256.png" alt="Oliver Booth" height="128"> Oliver Booth</a>
}
</h1>
</div>
</header>
@ -113,13 +95,6 @@
<div style="margin:50px 0;"></div>
<div class="container">
@if (doAprilFools)
{
<h1 style="text-decoration: underline; color: #0f0 !important; margin: 20px 0;">
<img src="~/img/af-homepage_500x383.jpg" alt="WELCOME TO MY HOMEPAGE!!!!111SHIFT+1">
</h1>
}
<main role="main" class="pb-3">
@RenderBody()
</main>
@ -137,7 +112,7 @@
</ul>
<ul class="footer-nav" style="margin-top: 20px;">
<li>@(doAprilFools ? "(C) 2003" : Html.Raw($"&copy; {DateTime.UtcNow.Year}"))</li>
<li>&copy; @DateTime.UtcNow.Year</li>
<li><a asp-page="/privacy/index">Privacy</a></li>
</ul>
</div>

View File

@ -90,3 +90,32 @@
}
</div>
</div>
<hr/>
@if (article.EnableComments)
{
<div class="giscus"></div>
@section Scripts
{
<script src="https://giscus.app/client.js"
data-repo="oliverbooth/oliverbooth.dev"
data-repo-id="MDEwOlJlcG9zaXRvcnkyNDUxODEyNDI="
data-category="Comments"
data-category-id="DIC_kwDODp0rOs4Ce_Nj"
data-mapping="pathname"
data-strict="0"
data-reactions-enabled="1"
data-emit-metadata="0"
data-input-position="bottom"
data-theme="preferred_color_scheme"
data-lang="en"
crossorigin="anonymous"
async>
</script>
}
}
else
{
<p class="text-center text-muted">Comments are not enabled for this post.</p>
}

View File

@ -90,6 +90,12 @@ internal sealed class BlogPostService : IBlogPostService
/// <inheritdoc />
public string RenderExcerpt(IBlogPost post, out bool wasTrimmed)
{
if (!string.IsNullOrWhiteSpace(post.Excerpt))
{
wasTrimmed = false;
return Markdig.Markdown.ToHtml(post.Excerpt, _markdownPipeline);
}
string body = post.Body;
int moreIndex = body.IndexOf("<!--more-->", StringComparison.Ordinal);

View File

@ -67,6 +67,17 @@ public interface ITutorialService
/// <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>

View File

@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Cysharp.Text;
using Humanizer;
using Markdig;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data;
@ -108,6 +109,29 @@ internal sealed class TutorialService : ITutorialService
return Markdig.Markdown.ToHtml(article.Body, _markdownPipeline);
}
/// <inheritdoc />
public string RenderExcerpt(ITutorialArticle article, out bool wasTrimmed)
{
if (!string.IsNullOrWhiteSpace(article.Excerpt))
{
wasTrimmed = false;
return Markdig.Markdown.ToHtml(article.Excerpt, _markdownPipeline);
}
string body = article.Body;
int moreIndex = body.IndexOf("<!--more-->", StringComparison.Ordinal);
if (moreIndex == -1)
{
string excerpt = body.Truncate(255, "...");
wasTrimmed = body.Length > 255;
return Markdig.Markdown.ToHtml(excerpt, _markdownPipeline);
}
wasTrimmed = true;
return Markdig.Markdown.ToHtml(body[..moreIndex], _markdownPipeline);
}
/// <inheritdoc />
public bool TryGetArticle(int id, [NotNullWhen(true)] out ITutorialArticle? article)
{