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 /> /// <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();

View File

@ -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();

View File

@ -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>

View File

@ -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();
} }
} }

View File

@ -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>

View File

@ -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; }

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) @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
{ {

View File

@ -8,88 +8,74 @@
@{ @{
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">
{ <div class="card-body">
<h1>UNDER CONSTRUCTION</h1> @Html.Raw(latestStatus.Content)
<div style="text-align: center"> @foreach (MediaAttachment attachment in latestStatus.MediaAttachments)
<img src="~/img/construction_90x85.gif"> {
<img src="~/img/underconstruction_323x118.gif"> switch (attachment.Type)
<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>
case AttachmentType.Audio: break;
<p><audio controls="controls" src="@attachment.Url"></audio></p>
break;
case AttachmentType.Video: case AttachmentType.Video:
<p><video controls="controls" class="figure-img img-fluid" src="@attachment.Url"></video></p> <p><video controls="controls" class="figure-img img-fluid" src="@attachment.Url"></video></p>
break; break;
case AttachmentType.Image: case AttachmentType.Image:
case AttachmentType.GifV: case AttachmentType.GifV:
<p><img class="figure-img img-fluid" src="@attachment.Url"></p> <p><img class="figure-img img-fluid" src="@attachment.Url"></p>
break; break;
}
} }
</div> }
<div class="card-footer text-muted">
<abbr title="@latestStatus.CreatedAt.ToString("F")">@latestStatus.CreatedAt.Humanize()</abbr>
&bull;
<a href="@latestStatus.OriginalUri" target="_blank">View on Mastodon</a>
</div>
</div> </div>
<div class="card-footer text-muted">
<div id="all-blog-posts"> <abbr title="@latestStatus.CreatedAt.ToString("F")">@latestStatus.CreatedAt.Humanize()</abbr>
@await Html.PartialAsync("_LoadingSpinner") &bull;
<a href="@latestStatus.OriginalUri" target="_blank">View on Mastodon</a>
</div> </div>
</div>
<script id="blog-post-template" type="text/x-handlebars-template"> <div id="all-blog-posts">
<div class="card-header"> @await Html.PartialAsync("_LoadingSpinner")
<span class="text-muted"> </div>
<img class="blog-author-icon" src="{{author.avatar}}" alt="{{author.name}}">
<span>{{author.name}}<span> <script id="blog-post-template" type="text/x-handlebars-template">
<div class="card-header">
<span class="text-muted">
<img class="blog-author-icon" src="{{author.avatar}}" alt="{{author.name}}">
<span>{{author.name}}<span>
<span> &bull; </span>
<abbr title="{{ post.formattedDate }}">{{ post.date_humanized }}</abbr>
{{#if post.enable_comments}}
<span> &bull; </span> <span> &bull; </span>
<abbr title="{{ post.formattedDate }}">{{ post.date_humanized }}</abbr> <a href="{{post.url}}#disqus_thread" data-disqus-identifier="{{post.disqus_identifier}}">
{{#if post.enable_comments}} Loading comment count &hellip;
<span> &bull; </span> </a>
<a href="{{post.url}}#disqus_thread" data-disqus-identifier="{{post.disqus_identifier}}">
Loading comment count &hellip;
</a>
{{/if}}
</span>
</div>
<div class="card-body">
<h2>
<a href="{{post.url}}"> {{post.title}}</a>
</h2>
<p>{{{post.excerpt}}}</p>
{{#if post.trimmed}}
<p>
<a href="{{post.url}}">
Read more...
</a>
</p>
{{/if}} {{/if}}
</div> </span>
<div class="card-footer"> </div>
{{#each post.tags}} <div class="card-body">
<a href="?tag={{urlEncode this}}" class="badge text-bg-dark">{{this}}</a> <h2>
{{/each}} <a href="{{post.url}}"> {{post.title}}</a>
</div> </h2>
</script>
} <p>{{{post.excerpt}}}</p>
{{#if post.trimmed}}
<p>
<a href="{{post.url}}">
Read more...
</a>
</p>
{{/if}}
</div>
<div class="card-footer">
{{#each post.tags}}
<a href="?tag={{urlEncode this}}" class="badge text-bg-dark">{{this}}</a>
{{/each}}
</div>
</script>

View File

@ -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>

View File

@ -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) <a href="/"><img src="~/img/ob-256x256.png" alt="Oliver Booth" height="128"> Oliver Booth</a>
{
<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> </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($"&copy; {DateTime.UtcNow.Year}"))</li> <li>&copy; @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>

View File

@ -89,4 +89,33 @@
</p> </p>
} }
</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>
}

View File

@ -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);

View File

@ -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>

View File

@ -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)
{ {