diff --git a/src/LinkDotNet.Blog.Domain/BlogPost.cs b/src/LinkDotNet.Blog.Domain/BlogPost.cs index 6930fe3c..f4648f26 100644 --- a/src/LinkDotNet.Blog.Domain/BlogPost.cs +++ b/src/LinkDotNet.Blog.Domain/BlogPost.cs @@ -1,11 +1,14 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.Linq; +using System.Text; +using System.Text.RegularExpressions; namespace LinkDotNet.Blog.Domain; -public sealed class BlogPost : Entity +public sealed partial class BlogPost : Entity { private BlogPost() { @@ -35,6 +38,54 @@ private BlogPost() public string TagsAsString => Tags is null ? string.Empty : string.Join(", ", Tags); + public string Slug => GenerateSlug(); + + private string GenerateSlug() + { + // Remove all accents and make the string lower case. + if (string.IsNullOrWhiteSpace(Title)) + return Title; + + Title = Title.Normalize(NormalizationForm.FormD); + var chars = Title + .Where(c => CharUnicodeInfo.GetUnicodeCategory(c) + != UnicodeCategory.NonSpacingMark) + .ToArray(); + + Title = new string(chars).Normalize(NormalizationForm.FormC); + + var slug = Title.ToLower(CultureInfo.CurrentCulture); + + // Remove all special characters from the string. + slug = MatchIfSpecialCharactersExist().Replace(slug, ""); + + // Remove all additional spaces in favour of just one. + slug= MatchIfAdditionalSpacesExist().Replace(slug," ").Trim(); + + // Replace all spaces with the hyphen. + slug= MatchIfSpaceExist().Replace(slug, "-"); + + return slug; + } + + [GeneratedRegex( + @"[^A-Za-z0-9\s]", + RegexOptions.CultureInvariant, + matchTimeoutMilliseconds: 1000)] + private static partial Regex MatchIfSpecialCharactersExist(); + + [GeneratedRegex( + @"\s+", + RegexOptions.CultureInvariant, + matchTimeoutMilliseconds: 1000)] + private static partial Regex MatchIfAdditionalSpacesExist(); + + [GeneratedRegex( + @"\s", + RegexOptions.CultureInvariant, + matchTimeoutMilliseconds: 1000)] + private static partial Regex MatchIfSpaceExist(); + public static BlogPost Create( string title, string shortDescription, diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostConfiguration.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostConfiguration.cs index 05928931..01919252 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostConfiguration.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostConfiguration.cs @@ -1,4 +1,4 @@ -using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/src/LinkDotNet.Blog.Web/Features/Archive/ArchivePage.razor b/src/LinkDotNet.Blog.Web/Features/Archive/ArchivePage.razor index b6315af9..08d176d5 100644 --- a/src/LinkDotNet.Blog.Web/Features/Archive/ArchivePage.razor +++ b/src/LinkDotNet.Blog.Web/Features/Archive/ArchivePage.razor @@ -21,12 +21,12 @@ } } - + @code { private IReadOnlyCollection> blogPostsPerYear; @@ -35,7 +35,7 @@ protected override async Task OnInitializedAsync() { var blogPosts = await Repository.GetAllByProjectionAsync( - p => new BlogPostPerYear(p.Id, p.Title, p.UpdatedDate), + p => new BlogPostPerYear(p.Id, p.Slug, p.Title, p.UpdatedDate), p => p.IsPublished); blogPostCount = blogPosts.Count; blogPostsPerYear = blogPosts @@ -44,5 +44,5 @@ .ToImmutableArray(); } - private sealed record BlogPostPerYear(string Id, string Title, DateTime UpdatedDate); + private sealed record BlogPostPerYear(string Id, string Slug, string Title, DateTime UpdatedDate); } diff --git a/src/LinkDotNet.Blog.Web/Features/Components/ShortBlogPost.razor b/src/LinkDotNet.Blog.Web/Features/Components/ShortBlogPost.razor index ceaa3efe..cc59cafd 100644 --- a/src/LinkDotNet.Blog.Web/Features/Components/ShortBlogPost.razor +++ b/src/LinkDotNet.Blog.Web/Features/Components/ShortBlogPost.razor @@ -1,4 +1,4 @@ -@using System.Net +@using System.Net @using System.Text.RegularExpressions @using System.Web @using LinkDotNet.Blog.Domain @@ -43,7 +43,7 @@

@MarkdownConverter.ToMarkupString(BlogPost.ShortDescription)

- Read the whole article + Read the whole article

diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor index 184f0013..49d67030 100644 --- a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor @@ -1,4 +1,4 @@ -@page "/blogPost/{blogPostId}" +@page "/blogPost/{blogPostId}/{slug?}" @using Markdig @using LinkDotNet.Blog.Domain @using LinkDotNet.Blog.Infrastructure.Persistence @@ -73,6 +73,9 @@ else [Parameter] public string BlogPostId { get; set; } + [Parameter] + public string Slug { get; set; } + private string OgDataImage => BlogPost.PreviewImageUrlFallback ?? BlogPost.PreviewImageUrl; private BlogPost BlogPost { get; set; } diff --git a/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostTests.cs b/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostTests.cs index d0422927..dacb4448 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.TestUtilities; @@ -24,6 +24,27 @@ public void ShouldUpdateBlogPost() blogPostToUpdate.PreviewImageUrlFallback.Should().Be("Url2"); blogPostToUpdate.IsPublished.Should().BeTrue(); blogPostToUpdate.Tags.Should().BeNullOrEmpty(); + blogPostToUpdate.Slug.Should().NotBeNull(); + } + + [Theory] + [InlineData("blog title","blog-title")] + [InlineData("blog title", "blog-title")] + [InlineData("blog +title", "blog-title")] + [InlineData("blog/title", "blogtitle")] + [InlineData("blog /title", "blog-title")] + [InlineData("BLOG TITLE", "blog-title")] + [InlineData("àccent", "accent")] + [InlineData("get 100$ quick", "get-100-quick")] + [InlineData("blog,title", "blogtitle")] + [InlineData("blog?!title", "blogtitle")] + [InlineData("blog----title", "blogtitle")] + [InlineData("überaus gut", "uberaus-gut")] + public void ShouldGenerateValidSlug(string title, string expectedSlug) + { + var blogPost = new BlogPostBuilder().WithTitle(title).Build(); + + blogPost.Slug.Should().Be(expectedSlug); } [Fact] diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Components/ShortBlogPostTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Components/ShortBlogPostTests.cs index 66a19356..c913b7b1 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Components/ShortBlogPostTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Components/ShortBlogPostTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using LinkDotNet.Blog.TestUtilities; using LinkDotNet.Blog.Web.Features.Components; @@ -17,7 +17,7 @@ public void ShouldOpenBlogPost() var readMore = cut.Find(".read-more a"); - readMore.Attributes.Single(a => a.Name == "href").Value.Should().Be("/blogPost/SomeId"); + readMore.Attributes.Single(a => a.Name == "href").Value.Should().Be("/blogPost/SomeId/blogpost"); } [Fact]