Skip to content

Commit 995bf7a

Browse files
authored
feat: Add slug for SEO friendly URL
* add BlogPostIdGenerator to generate shorter id * add SeoFriendlyUrl prop to blog posts * fix routing parameter * fix RSS links * refactor SeoFriendlyUrl property name * unused NotMapped removed * URL-Friendly removed from the RSS part * BlogPostId value generator removed * GenerateSearchEngineFriendlyUrl fixed * make searchEngineFriendlyTitle optional * renaming SearchEngineFriendlyUrl to Slug * small refactoring in coding style * removing id from slug generator * generate Regex with source generators * ShouldOpenBlogPost test fixed * tests for slug added * more testcase added for ShouldGenerateValidSlug * small issue fixed in MatchIfSpecialCharactersExist Regex
1 parent 02dfd95 commit 995bf7a

File tree

7 files changed

+88
-13
lines changed

7 files changed

+88
-13
lines changed

src/LinkDotNet.Blog.Domain/BlogPost.cs

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
using System;
1+
using System;
22
using System.Collections.Generic;
33
using System.Collections.Immutable;
4+
using System.Globalization;
45
using System.Linq;
6+
using System.Text;
7+
using System.Text.RegularExpressions;
58

69
namespace LinkDotNet.Blog.Domain;
710

8-
public sealed class BlogPost : Entity
11+
public sealed partial class BlogPost : Entity
912
{
1013
private BlogPost()
1114
{
@@ -35,6 +38,54 @@ private BlogPost()
3538

3639
public string TagsAsString => Tags is null ? string.Empty : string.Join(", ", Tags);
3740

41+
public string Slug => GenerateSlug();
42+
43+
private string GenerateSlug()
44+
{
45+
// Remove all accents and make the string lower case.
46+
if (string.IsNullOrWhiteSpace(Title))
47+
return Title;
48+
49+
Title = Title.Normalize(NormalizationForm.FormD);
50+
var chars = Title
51+
.Where(c => CharUnicodeInfo.GetUnicodeCategory(c)
52+
!= UnicodeCategory.NonSpacingMark)
53+
.ToArray();
54+
55+
Title = new string(chars).Normalize(NormalizationForm.FormC);
56+
57+
var slug = Title.ToLower(CultureInfo.CurrentCulture);
58+
59+
// Remove all special characters from the string.
60+
slug = MatchIfSpecialCharactersExist().Replace(slug, "");
61+
62+
// Remove all additional spaces in favour of just one.
63+
slug= MatchIfAdditionalSpacesExist().Replace(slug," ").Trim();
64+
65+
// Replace all spaces with the hyphen.
66+
slug= MatchIfSpaceExist().Replace(slug, "-");
67+
68+
return slug;
69+
}
70+
71+
[GeneratedRegex(
72+
@"[^A-Za-z0-9\s]",
73+
RegexOptions.CultureInvariant,
74+
matchTimeoutMilliseconds: 1000)]
75+
private static partial Regex MatchIfSpecialCharactersExist();
76+
77+
[GeneratedRegex(
78+
@"\s+",
79+
RegexOptions.CultureInvariant,
80+
matchTimeoutMilliseconds: 1000)]
81+
private static partial Regex MatchIfAdditionalSpacesExist();
82+
83+
[GeneratedRegex(
84+
@"\s",
85+
RegexOptions.CultureInvariant,
86+
matchTimeoutMilliseconds: 1000)]
87+
private static partial Regex MatchIfSpaceExist();
88+
3889
public static BlogPost Create(
3990
string title,
4091
string shortDescription,

src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostConfiguration.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using LinkDotNet.Blog.Domain;
1+
using LinkDotNet.Blog.Domain;
22
using Microsoft.EntityFrameworkCore;
33
using Microsoft.EntityFrameworkCore.Metadata.Builders;
44

src/LinkDotNet.Blog.Web/Features/Archive/ArchivePage.razor

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@
2121
<ul class="ps-5">
2222
@foreach (var blogPost in yearGroup.OrderByDescending(b => b.UpdatedDate))
2323
{
24-
<li class="pt-1"><a href="/blogPost/@blogPost.Id">@blogPost.Title</a></li>
24+
<li class="pt-1"><a href="/blogPost/@blogPost.Id/@blogPost.Slug">@blogPost.Title</a></li>
2525
}
2626
</ul>
2727
}
2828
}
29-
</div >
29+
</div>
3030

3131
@code {
3232
private IReadOnlyCollection<IGrouping<int, BlogPostPerYear>> blogPostsPerYear;
@@ -35,7 +35,7 @@
3535
protected override async Task OnInitializedAsync()
3636
{
3737
var blogPosts = await Repository.GetAllByProjectionAsync(
38-
p => new BlogPostPerYear(p.Id, p.Title, p.UpdatedDate),
38+
p => new BlogPostPerYear(p.Id, p.Slug, p.Title, p.UpdatedDate),
3939
p => p.IsPublished);
4040
blogPostCount = blogPosts.Count;
4141
blogPostsPerYear = blogPosts
@@ -44,5 +44,5 @@
4444
.ToImmutableArray();
4545
}
4646

47-
private sealed record BlogPostPerYear(string Id, string Title, DateTime UpdatedDate);
47+
private sealed record BlogPostPerYear(string Id, string Slug, string Title, DateTime UpdatedDate);
4848
}

src/LinkDotNet.Blog.Web/Features/Components/ShortBlogPost.razor

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
@using System.Net
1+
@using System.Net
22
@using System.Text.RegularExpressions
33
@using System.Web
44
@using LinkDotNet.Blog.Domain
@@ -43,7 +43,7 @@
4343
<h2></h2>
4444
<p>@MarkdownConverter.ToMarkupString(BlogPost.ShortDescription)</p>
4545
<p class="read-more">
46-
<a href="/blogPost/@BlogPost.Id" aria-label="@BlogPost.Title">Read the whole article</a>
46+
<a href="/blogPost/@BlogPost.Id/@BlogPost.Slug" aria-label="@BlogPost.Title">Read the whole article</a>
4747
</p>
4848
</div>
4949
</div>

src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
@page "/blogPost/{blogPostId}"
1+
@page "/blogPost/{blogPostId}/{slug?}"
22
@using Markdig
33
@using LinkDotNet.Blog.Domain
44
@using LinkDotNet.Blog.Infrastructure.Persistence
@@ -73,6 +73,9 @@ else
7373
[Parameter]
7474
public string BlogPostId { get; set; }
7575

76+
[Parameter]
77+
public string Slug { get; set; }
78+
7679
private string OgDataImage => BlogPost.PreviewImageUrlFallback ?? BlogPost.PreviewImageUrl;
7780

7881
private BlogPost BlogPost { get; set; }

tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostTests.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Linq;
33
using LinkDotNet.Blog.Domain;
44
using LinkDotNet.Blog.TestUtilities;
@@ -24,6 +24,27 @@ public void ShouldUpdateBlogPost()
2424
blogPostToUpdate.PreviewImageUrlFallback.Should().Be("Url2");
2525
blogPostToUpdate.IsPublished.Should().BeTrue();
2626
blogPostToUpdate.Tags.Should().BeNullOrEmpty();
27+
blogPostToUpdate.Slug.Should().NotBeNull();
28+
}
29+
30+
[Theory]
31+
[InlineData("blog title","blog-title")]
32+
[InlineData("blog title", "blog-title")]
33+
[InlineData("blog +title", "blog-title")]
34+
[InlineData("blog/title", "blogtitle")]
35+
[InlineData("blog /title", "blog-title")]
36+
[InlineData("BLOG TITLE", "blog-title")]
37+
[InlineData("àccent", "accent")]
38+
[InlineData("get 100$ quick", "get-100-quick")]
39+
[InlineData("blog,title", "blogtitle")]
40+
[InlineData("blog?!title", "blogtitle")]
41+
[InlineData("blog----title", "blogtitle")]
42+
[InlineData("überaus gut", "uberaus-gut")]
43+
public void ShouldGenerateValidSlug(string title, string expectedSlug)
44+
{
45+
var blogPost = new BlogPostBuilder().WithTitle(title).Build();
46+
47+
blogPost.Slug.Should().Be(expectedSlug);
2748
}
2849

2950
[Fact]

tests/LinkDotNet.Blog.UnitTests/Web/Features/Components/ShortBlogPostTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Linq;
33
using LinkDotNet.Blog.TestUtilities;
44
using LinkDotNet.Blog.Web.Features.Components;
@@ -17,7 +17,7 @@ public void ShouldOpenBlogPost()
1717

1818
var readMore = cut.Find(".read-more a");
1919

20-
readMore.Attributes.Single(a => a.Name == "href").Value.Should().Be("/blogPost/SomeId");
20+
readMore.Attributes.Single(a => a.Name == "href").Value.Should().Be("/blogPost/SomeId/blogpost");
2121
}
2222

2323
[Fact]

0 commit comments

Comments
 (0)