diff --git a/OliverBooth/Data/Blog/BlogPost.cs b/OliverBooth/Data/Blog/BlogPost.cs
index 05ece19..59067b5 100644
--- a/OliverBooth/Data/Blog/BlogPost.cs
+++ b/OliverBooth/Data/Blog/BlogPost.cs
@@ -16,6 +16,9 @@ internal sealed class BlogPost : IBlogPost
///
public bool EnableComments { get; internal set; }
+ ///
+ public string? Excerpt { get; internal set; }
+
///
public Guid Id { get; private set; } = Guid.NewGuid();
diff --git a/OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs b/OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs
index 607f9d3..1ecde2f 100644
--- a/OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs
+++ b/OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs
@@ -20,6 +20,7 @@ internal sealed class BlogPostConfiguration : IEntityTypeConfiguration
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().HasMaxLength(255).IsRequired(false);
builder.Property(e => e.EnableComments).IsRequired();
diff --git a/OliverBooth/Data/Blog/IBlogPost.cs b/OliverBooth/Data/Blog/IBlogPost.cs
index d6e4e95..bc1a2e5 100644
--- a/OliverBooth/Data/Blog/IBlogPost.cs
+++ b/OliverBooth/Data/Blog/IBlogPost.cs
@@ -25,6 +25,12 @@ public interface IBlogPost
///
bool EnableComments { get; }
+ ///
+ /// Gets the excerpt of this post, if it has one.
+ ///
+ /// The excerpt, or if this post has no excerpt.
+ string? Excerpt { get; }
+
///
/// Gets the ID of the post.
///
diff --git a/OliverBooth/Data/Web/Configuration/TutorialArticleConfiguration.cs b/OliverBooth/Data/Web/Configuration/TutorialArticleConfiguration.cs
index 56924fd..7e859df 100644
--- a/OliverBooth/Data/Web/Configuration/TutorialArticleConfiguration.cs
+++ b/OliverBooth/Data/Web/Configuration/TutorialArticleConfiguration.cs
@@ -16,6 +16,7 @@ internal sealed class TutorialArticleConfiguration : IEntityTypeConfiguration 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();
diff --git a/OliverBooth/Data/Web/ITutorialArticle.cs b/OliverBooth/Data/Web/ITutorialArticle.cs
index c1be295..a25c4ef 100644
--- a/OliverBooth/Data/Web/ITutorialArticle.cs
+++ b/OliverBooth/Data/Web/ITutorialArticle.cs
@@ -11,6 +11,12 @@ public interface ITutorialArticle
/// The body.
string Body { get; }
+ ///
+ /// Gets the excerpt of this article, if it has one.
+ ///
+ /// The excerpt, or if this article has no excerpt.
+ string? Excerpt { get; }
+
///
/// Gets the ID of the folder this article is contained within.
///
diff --git a/OliverBooth/Data/Web/TutorialArticle.cs b/OliverBooth/Data/Web/TutorialArticle.cs
index 1938e75..91132ae 100644
--- a/OliverBooth/Data/Web/TutorialArticle.cs
+++ b/OliverBooth/Data/Web/TutorialArticle.cs
@@ -8,6 +8,9 @@ internal sealed class TutorialArticle : IEquatable, ITutorialAr
///
public string Body { get; private set; } = string.Empty;
+ ///
+ public string? Excerpt { get; private set; }
+
///
public int Folder { get; private set; }
@@ -15,7 +18,7 @@ internal sealed class TutorialArticle : IEquatable, ITutorialAr
public int Id { get; private set; }
///
- public int? NextPart { get; }
+ public int? NextPart { get; private set; }
///
public Uri? PreviewImageUrl { get; private set; }
diff --git a/OliverBooth/Services/BlogPostService.cs b/OliverBooth/Services/BlogPostService.cs
index 9fcd8cf..049cd6e 100644
--- a/OliverBooth/Services/BlogPostService.cs
+++ b/OliverBooth/Services/BlogPostService.cs
@@ -90,6 +90,12 @@ internal sealed class BlogPostService : IBlogPostService
///
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("", StringComparison.Ordinal);
diff --git a/OliverBooth/Services/ITutorialService.cs b/OliverBooth/Services/ITutorialService.cs
index ad85bbf..a5bd547 100644
--- a/OliverBooth/Services/ITutorialService.cs
+++ b/OliverBooth/Services/ITutorialService.cs
@@ -67,6 +67,17 @@ public interface ITutorialService
/// The rendered HTML of the article.
string RenderArticle(ITutorialArticle article);
+ ///
+ /// Renders the excerpt of the specified article.
+ ///
+ /// The article whose excerpt to render.
+ ///
+ /// When this method returns, contains if the excerpt was trimmed; otherwise,
+ /// .
+ ///
+ /// The rendered HTML of the article's excerpt.
+ string RenderExcerpt(ITutorialArticle article, out bool wasTrimmed);
+
///
/// Attempts to find an article by its ID.
///
diff --git a/OliverBooth/Services/TutorialService.cs b/OliverBooth/Services/TutorialService.cs
index e8d6c9d..c6e1f7d 100644
--- a/OliverBooth/Services/TutorialService.cs
+++ b/OliverBooth/Services/TutorialService.cs
@@ -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);
}
+ ///
+ 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("", 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);
+ }
+
///
public bool TryGetArticle(int id, [NotNullWhen(true)] out ITutorialArticle? article)
{