Compare commits
6 Commits
b119861eee
...
96e63a3088
Author | SHA1 | Date | |
---|---|---|---|
96e63a3088 | |||
1919b1d5c8 | |||
a1dd6ef6ff | |||
985acf7bc3 | |||
879ff6a295 | |||
cd6bbec1a5 |
@ -16,6 +16,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();
|
||||||
|
|
||||||
|
@ -20,6 +20,7 @@ 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();
|
||||||
|
@ -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>
|
||||||
|
@ -16,6 +16,7 @@ internal sealed class TutorialArticleConfiguration : IEntityTypeConfiguration<Tu
|
|||||||
|
|
||||||
builder.Property(e => e.Id).IsRequired();
|
builder.Property(e => e.Id).IsRequired();
|
||||||
builder.Property(e => e.Folder).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.Published).IsRequired();
|
||||||
builder.Property(e => e.Updated);
|
builder.Property(e => e.Updated);
|
||||||
builder.Property(e => e.Slug).IsRequired();
|
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.NextPart);
|
||||||
builder.Property(e => e.PreviousPart);
|
builder.Property(e => e.PreviousPart);
|
||||||
builder.Property(e => e.Visibility).HasConversion<EnumToStringConverter<Visibility>>();
|
builder.Property(e => e.Visibility).HasConversion<EnumToStringConverter<Visibility>>();
|
||||||
|
builder.Property(e => e.EnableComments).IsRequired();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,20 @@ public interface ITutorialArticle
|
|||||||
/// <value>The body.</value>
|
/// <value>The body.</value>
|
||||||
string Body { get; }
|
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>
|
/// <summary>
|
||||||
/// Gets the ID of the folder this article is contained within.
|
/// Gets the ID of the folder this article is contained within.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -8,6 +8,12 @@ internal sealed class TutorialArticle : IEquatable<TutorialArticle>, ITutorialAr
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string Body { get; private set; } = string.Empty;
|
public string Body { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool EnableComments { get; internal set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? Excerpt { get; private set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public int Folder { get; private set; }
|
public int Folder { get; private set; }
|
||||||
|
|
||||||
@ -15,7 +21,7 @@ internal sealed class TutorialArticle : IEquatable<TutorialArticle>, ITutorialAr
|
|||||||
public int Id { get; private set; }
|
public int Id { get; private set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public int? NextPart { get; }
|
public int? NextPart { get; private set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Uri? PreviewImageUrl { get; private set; }
|
public Uri? PreviewImageUrl { get; private set; }
|
||||||
|
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.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><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();
|
||||||
|
}
|
||||||
|
}
|
@ -132,31 +132,25 @@
|
|||||||
|
|
||||||
@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
|
|
||||||
<a href="https://disqus.com/?ref_noscript" rel="nofollow">
|
|
||||||
comments powered by Disqus.
|
|
||||||
</a>
|
|
||||||
</noscript>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
@ -8,22 +8,9 @@
|
|||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Blog";
|
ViewData["Title"] = "Blog";
|
||||||
MastodonStatus latestStatus = MastodonService.GetLatestStatus();
|
MastodonStatus latestStatus = MastodonService.GetLatestStatus();
|
||||||
bool doAprilFools = DateOnly.FromDateTime(DateTime.UtcNow) == new DateOnly(2024, 04, 01) || Environment.GetEnvironmentVariable("DO_AF") == "1";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (doAprilFools)
|
<div class="card text-center mastodon-update-card">
|
||||||
{
|
|
||||||
<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">
|
<div class="card-body">
|
||||||
@Html.Raw(latestStatus.Content)
|
@Html.Raw(latestStatus.Content)
|
||||||
@foreach (MediaAttachment attachment in latestStatus.MediaAttachments)
|
@foreach (MediaAttachment attachment in latestStatus.MediaAttachments)
|
||||||
@ -50,13 +37,13 @@ else
|
|||||||
•
|
•
|
||||||
<a href="@latestStatus.OriginalUri" target="_blank">View on Mastodon</a>
|
<a href="@latestStatus.OriginalUri" target="_blank">View on Mastodon</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="all-blog-posts">
|
<div id="all-blog-posts">
|
||||||
@await Html.PartialAsync("_LoadingSpinner")
|
@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">
|
<div class="card-header">
|
||||||
<span class="text-muted">
|
<span class="text-muted">
|
||||||
<img class="blog-author-icon" src="{{author.avatar}}" alt="{{author.name}}">
|
<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>
|
<a href="?tag={{urlEncode this}}" class="badge text-bg-dark">{{this}}</a>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</div>
|
</div>
|
||||||
</script>
|
</script>
|
||||||
}
|
|
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<h1 class="display-4">@ViewData["Title"]</h1>
|
<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">
|
<div class="alert alert-primary">
|
||||||
This Privacy Policy differs from the policy that applies to my applications published to Google Play. For my
|
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>.
|
applications' privacy policy, please <a asp-page="/Privacy/GooglePlay">click here</a>.
|
||||||
@ -32,10 +32,13 @@
|
|||||||
</p>
|
</p>
|
||||||
<p>I do not use any cookies or similar tracking technologies that can identify individual users.</p>
|
<p>I do not use any cookies or similar tracking technologies that can identify individual users.</p>
|
||||||
<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
|
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
|
policies. I have no control over the information collected by GitHub, and I encourage you to review
|
||||||
privacy policy to understand how your information may be used by them.
|
<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>
|
</p>
|
||||||
|
|
||||||
<h2>Use of Information</h2>
|
<h2>Use of Information</h2>
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
@using OliverBooth.Data.Blog
|
@using OliverBooth.Data.Blog
|
||||||
|
@using OliverBooth.Data.Web
|
||||||
|
@using OliverBooth.Extensions
|
||||||
@using OliverBooth.Services
|
@using OliverBooth.Services
|
||||||
@inject IBlogPostService BlogPostService
|
@inject IBlogPostService BlogPostService
|
||||||
|
@inject ITutorialService TutorialService
|
||||||
@{
|
@{
|
||||||
HttpRequest request = Context.Request;
|
HttpRequest request = Context.Request;
|
||||||
var url = new Uri($"{request.Scheme}://{request.Host}{request.Path}{request.QueryString}");
|
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>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-bs-theme="dark">
|
<html lang="en" data-bs-theme="dark">
|
||||||
@ -28,15 +30,11 @@
|
|||||||
}
|
}
|
||||||
@if (ViewData["Post"] is IBlogPost post)
|
@if (ViewData["Post"] is IBlogPost post)
|
||||||
{
|
{
|
||||||
string excerpt = BlogPostService.RenderExcerpt(post, out bool trimmed);
|
@Html.Raw(HtmlUtility.CreateMetaTagsFromPost(post, BlogPostService))
|
||||||
<meta name="title" content="@post.Title">
|
}
|
||||||
<meta name="description" content="@excerpt">
|
else if (ViewData["Post"] is ITutorialArticle article)
|
||||||
<meta property="og:title" content="@post.Title">
|
{
|
||||||
<meta property="og:description" content="@excerpt">
|
@Html.Raw(HtmlUtility.CreateMetaTagsFromTutorialArticle(article, TutorialService))
|
||||||
<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">
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -62,28 +60,12 @@
|
|||||||
<link rel="stylesheet" href="~/css/prism.vs.min.css" asp-append-version="true">
|
<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/app.min.css" asp-append-version="true">
|
||||||
<link rel="stylesheet" href="~/css/ribbon.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="container" style="margin-top: 20px;">
|
<header class="container" style="margin-top: 20px;">
|
||||||
<div id="site-title" class="text-center">
|
<div id="site-title" class="text-center">
|
||||||
<h1>
|
<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>
|
<a href="/"><img src="~/img/ob-256x256.png" alt="Oliver Booth" height="128"> Oliver Booth</a>
|
||||||
}
|
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -113,13 +95,6 @@
|
|||||||
<div style="margin:50px 0;"></div>
|
<div style="margin:50px 0;"></div>
|
||||||
|
|
||||||
<div class="container">
|
<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">
|
<main role="main" class="pb-3">
|
||||||
@RenderBody()
|
@RenderBody()
|
||||||
</main>
|
</main>
|
||||||
@ -137,7 +112,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<ul class="footer-nav" style="margin-top: 20px;">
|
<ul class="footer-nav" style="margin-top: 20px;">
|
||||||
<li>@(doAprilFools ? "(C) 2003" : Html.Raw($"© {DateTime.UtcNow.Year}"))</li>
|
<li>© @DateTime.UtcNow.Year</li>
|
||||||
<li><a asp-page="/privacy/index">Privacy</a></li>
|
<li><a asp-page="/privacy/index">Privacy</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -90,3 +90,32 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
}
|
@ -90,6 +90,12 @@ internal sealed class BlogPostService : IBlogPostService
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string RenderExcerpt(IBlogPost post, out bool wasTrimmed)
|
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;
|
string body = post.Body;
|
||||||
int moreIndex = body.IndexOf("<!--more-->", StringComparison.Ordinal);
|
int moreIndex = body.IndexOf("<!--more-->", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
@ -67,6 +67,17 @@ public interface ITutorialService
|
|||||||
/// <returns>The rendered HTML of the article.</returns>
|
/// <returns>The rendered HTML of the article.</returns>
|
||||||
string RenderArticle(ITutorialArticle article);
|
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>
|
/// <summary>
|
||||||
/// Attempts to find an article by its ID.
|
/// Attempts to find an article by its ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Cysharp.Text;
|
using Cysharp.Text;
|
||||||
|
using Humanizer;
|
||||||
using Markdig;
|
using Markdig;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using OliverBooth.Data;
|
using OliverBooth.Data;
|
||||||
@ -108,6 +109,29 @@ internal sealed class TutorialService : ITutorialService
|
|||||||
return Markdig.Markdown.ToHtml(article.Body, _markdownPipeline);
|
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 />
|
/// <inheritdoc />
|
||||||
public bool TryGetArticle(int id, [NotNullWhen(true)] out ITutorialArticle? article)
|
public bool TryGetArticle(int id, [NotNullWhen(true)] out ITutorialArticle? article)
|
||||||
{
|
{
|
||||||
|
Loading…
Reference in New Issue
Block a user