Skip to content

Commit 82e64d6

Browse files
committed
feat: Create sitemap.xml dynamically
1 parent 72c8543 commit 82e64d6

File tree

15 files changed

+145
-334
lines changed

15 files changed

+145
-334
lines changed

docs/SEO/Readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ This blog also offers an RSS feed ([RSS 2.0 specification](https://validator.w3.
3434

3535
### Sitemap
3636

37-
This blog offers to generate a [sitemap](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) that lists all blog posts, the archive and pages of the blog. A sitemap can be generated in the Admin tab of the navigation bar under "Sitemap". This allows, especially new sites that don't have many inbound links, to be indexed easier by search engines.
37+
This blog automatically generates a [sitemap](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) that lists all blog posts, the archive and pages of the blog.
3838

3939
## JSON LD
4040
This blog supports a JSON-LD for structured data. The current support is limited / rudimentary. Information like `Headline` (the title of the blog post), `Author`, `PublishDated` and `PreviewImage` are present.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System;
2+
using System.IO;
3+
using System.Threading.Tasks;
4+
using System.Xml;
5+
using System.Xml.Serialization;
6+
using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services;
7+
using Microsoft.AspNetCore.Mvc;
8+
using Microsoft.AspNetCore.RateLimiting;
9+
using Microsoft.Extensions.Caching.Memory;
10+
11+
namespace LinkDotNet.Blog.Web.Controller;
12+
13+
[EnableRateLimiting("ip")]
14+
[Route("sitemap.xml")]
15+
public sealed class SitemapController : ControllerBase
16+
{
17+
private readonly ISitemapService sitemapService;
18+
private readonly IXmlWriter xmlWriter;
19+
private readonly IMemoryCache memoryCache;
20+
21+
public SitemapController(
22+
ISitemapService sitemapService,
23+
IXmlWriter xmlWriter,
24+
IMemoryCache memoryCache)
25+
{
26+
this.sitemapService = sitemapService;
27+
this.xmlWriter = xmlWriter;
28+
this.memoryCache = memoryCache;
29+
}
30+
31+
[ResponseCache(Duration = 3600)]
32+
[HttpGet]
33+
public async Task<IActionResult> GetSitemap()
34+
{
35+
var buffer = await memoryCache.GetOrCreateAsync("sitemap.xml", async e =>
36+
{
37+
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
38+
return await GetSitemapBuffer();
39+
})
40+
?? throw new InvalidOperationException("Buffer is null");
41+
42+
return File(buffer, "application/xml");
43+
}
44+
45+
private async Task<byte[]> GetSitemapBuffer()
46+
{
47+
var baseUri = $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
48+
var sitemap = await sitemapService.CreateSitemapAsync(baseUri);
49+
var buffer = await xmlWriter.WriteToBuffer(sitemap);
50+
return buffer;
51+
}
52+
}

src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/Services/ISitemapService.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,5 @@ namespace LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services;
44

55
public interface ISitemapService
66
{
7-
Task<SitemapUrlSet> CreateSitemapAsync();
8-
9-
Task SaveSitemapToFileAsync(SitemapUrlSet sitemap);
10-
}
7+
Task<SitemapUrlSet> CreateSitemapAsync(string baseUri);
8+
}

src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/Services/IXmlFileWriter.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
namespace LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services;
44

5-
public interface IXmlFileWriter
5+
public interface IXmlWriter
66
{
7-
Task WriteObjectToXmlFileAsync<T>(T objectToSave, string fileName);
8-
}
7+
Task<byte[]> WriteToBuffer<T>(T objectToSave);
8+
}

src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/Services/SitemapService.cs

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,66 +2,63 @@
22
using System.Collections.Generic;
33
using System.Collections.Immutable;
44
using System.Globalization;
5+
using System.IO;
56
using System.Linq;
67
using System.Threading.Tasks;
78
using LinkDotNet.Blog.Domain;
89
using LinkDotNet.Blog.Infrastructure.Persistence;
910
using Microsoft.AspNetCore.Components;
11+
using Microsoft.Extensions.Caching.Memory;
1012

1113
namespace LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services;
1214

1315
public sealed class SitemapService : ISitemapService
1416
{
1517
private readonly IRepository<BlogPost> repository;
16-
private readonly NavigationManager navigationManager;
17-
private readonly IXmlFileWriter xmlFileWriter;
1818

19-
public SitemapService(
20-
IRepository<BlogPost> repository,
21-
NavigationManager navigationManager,
22-
IXmlFileWriter xmlFileWriter)
19+
public SitemapService(IRepository<BlogPost> repository)
2320
{
2421
this.repository = repository;
25-
this.navigationManager = navigationManager;
26-
this.xmlFileWriter = xmlFileWriter;
2722
}
2823

29-
public async Task<SitemapUrlSet> CreateSitemapAsync()
24+
public async Task<SitemapUrlSet> CreateSitemapAsync(string baseUri)
3025
{
26+
ArgumentException.ThrowIfNullOrEmpty(baseUri);
27+
3128
var urlSet = new SitemapUrlSet();
3229

30+
if (!baseUri.EndsWith('/'))
31+
{
32+
baseUri += "/";
33+
}
34+
3335
var blogPosts = await repository.GetAllAsync(f => f.IsPublished, b => b.UpdatedDate);
3436

35-
urlSet.Urls.Add(new SitemapUrl { Location = navigationManager.BaseUri });
36-
urlSet.Urls.Add(new SitemapUrl { Location = $"{navigationManager.BaseUri}archive" });
37-
urlSet.Urls.AddRange(CreateUrlsForBlogPosts(blogPosts));
38-
urlSet.Urls.AddRange(CreateUrlsForTags(blogPosts));
37+
urlSet.Urls.Add(new SitemapUrl { Location = baseUri });
38+
urlSet.Urls.Add(new SitemapUrl { Location = $"{baseUri}archive" });
39+
urlSet.Urls.AddRange(CreateUrlsForBlogPosts(blogPosts, baseUri));
40+
urlSet.Urls.AddRange(CreateUrlsForTags(blogPosts, baseUri));
3941

4042
return urlSet;
4143
}
4244

43-
public async Task SaveSitemapToFileAsync(SitemapUrlSet sitemap)
44-
{
45-
await xmlFileWriter.WriteObjectToXmlFileAsync(sitemap, "wwwroot/sitemap.xml");
46-
}
47-
48-
private ImmutableArray<SitemapUrl> CreateUrlsForBlogPosts(IEnumerable<BlogPost> blogPosts)
45+
private static ImmutableArray<SitemapUrl> CreateUrlsForBlogPosts(IEnumerable<BlogPost> blogPosts, string baseUri)
4946
{
5047
return blogPosts.Select(b => new SitemapUrl
5148
{
52-
Location = $"{navigationManager.BaseUri}blogPost/{b.Id}",
49+
Location = $"{baseUri}blogPost/{b.Id}",
5350
LastModified = b.UpdatedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
5451
}).ToImmutableArray();
5552
}
5653

57-
private IEnumerable<SitemapUrl> CreateUrlsForTags(IEnumerable<BlogPost> blogPosts)
54+
private static IEnumerable<SitemapUrl> CreateUrlsForTags(IEnumerable<BlogPost> blogPosts, string baseUri)
5855
{
5956
return blogPosts
6057
.SelectMany(b => b.Tags)
6158
.Distinct()
6259
.Select(t => new SitemapUrl
6360
{
64-
Location = $"{navigationManager.BaseUri}searchByTag/{Uri.EscapeDataString(t)}",
61+
Location = $"{baseUri}searchByTag/{Uri.EscapeDataString(t)}",
6562
});
6663
}
6764
}

src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/Services/XmlFileWriter.cs

Lines changed: 0 additions & 19 deletions
This file was deleted.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System.IO;
2+
using System.Threading.Tasks;
3+
using System.Xml;
4+
using System.Xml.Serialization;
5+
6+
namespace LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services;
7+
8+
public sealed class XmlWriter : IXmlWriter
9+
{
10+
public async Task<byte[]> WriteToBuffer<T>(T objectToSave)
11+
{
12+
await using var memoryStream = new MemoryStream();
13+
await using var xmlWriter = System.Xml.XmlWriter.Create(memoryStream, new XmlWriterSettings { Indent = true, Async = true });
14+
var serializer = new XmlSerializer(typeof(T));
15+
serializer.Serialize(xmlWriter, objectToSave);
16+
xmlWriter.Close();
17+
return memoryStream.ToArray();
18+
}
19+
}

src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/SitemapPage.razor

Lines changed: 0 additions & 55 deletions
This file was deleted.

src/LinkDotNet.Blog.Web/Features/Home/Components/AccessControl.razor

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
<li><hr class="dropdown-divider"></li>
1717
<li><h6 class="dropdown-header">Others</h6></li>
1818
<li><a class="dropdown-item" href="short-codes">Shortcodes</a></li>
19-
<li><a class="dropdown-item" href="Sitemap">Sitemap</a></li>
2019
<li><hr class="dropdown-divider"></li>
2120
<li><a class="dropdown-item" target="_blank" href="https://github.com/linkdotnet/Blog/releases" rel="noreferrer">Releases</a></li>
2221
</ul>

src/LinkDotNet.Blog.Web/ServiceExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
2020
services.AddScoped<ISortOrderCalculator, SortOrderCalculator>();
2121
services.AddScoped<IUserRecordService, UserRecordService>();
2222
services.AddScoped<ISitemapService, SitemapService>();
23-
services.AddScoped<IXmlFileWriter, XmlFileWriter>();
23+
services.AddScoped<IXmlWriter, XmlWriter>();
2424
services.AddScoped<IFileProcessor, FileProcessor>();
2525

2626
services.AddSingleton<CacheService>();
Lines changed: 21 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,41 @@
1-
using System;
1+
using System;
22
using System.IO;
33
using System.Threading.Tasks;
44
using LinkDotNet.Blog.Domain;
55
using LinkDotNet.Blog.Infrastructure.Persistence;
6+
using LinkDotNet.Blog.TestUtilities;
67
using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services;
78
using Microsoft.AspNetCore.Components;
89
using TestContext = Xunit.TestContext;
910

1011
namespace LinkDotNet.Blog.IntegrationTests.Web.Shared.Services;
1112

12-
public sealed class SitemapServiceTests : IDisposable
13+
public sealed class SitemapServiceTests : SqlDatabaseTestBase<BlogPost>
1314
{
14-
private const string OutputDirectory = "wwwroot";
15-
private const string OutputFilename = $"{OutputDirectory}/sitemap.xml";
1615
private readonly SitemapService sut;
1716

1817
public SitemapServiceTests()
19-
{
20-
var repositoryMock = Substitute.For<IRepository<BlogPost>>();
21-
sut = new SitemapService(repositoryMock, Substitute.For<NavigationManager>(), new XmlFileWriter());
22-
Directory.CreateDirectory("wwwroot");
23-
}
18+
=> sut = new SitemapService(Repository);
2419

2520
[Fact]
2621
public async Task ShouldSaveSitemapUrlInCorrectFormat()
2722
{
28-
var urlSet = new SitemapUrlSet
29-
{
30-
Urls =
31-
[
32-
new SitemapUrl { Location = "here", }
33-
],
34-
};
35-
await sut.SaveSitemapToFileAsync(urlSet);
36-
37-
var lines = await File.ReadAllTextAsync(OutputFilename, TestContext.Current.CancellationToken);
38-
lines.ShouldBe(
39-
@"<?xml version=""1.0"" encoding=""utf-8""?>
40-
<urlset xmlns:xsi=""http://www.w3.org/2001/XMLSchema-instance"" xmlns:xsd=""http://www.w3.org/2001/XMLSchema"" xmlns=""http://www.sitemaps.org/schemas/sitemap/0.9"">
41-
<url>
42-
<loc>here</loc>
43-
</url>
44-
</urlset>");
45-
}
46-
47-
public void Dispose()
48-
{
49-
if (File.Exists(OutputFilename))
50-
{
51-
File.Delete(OutputFilename);
52-
}
53-
54-
if (Directory.Exists(OutputDirectory))
55-
{
56-
Directory.Delete(OutputDirectory, true);
57-
}
23+
var publishedBlogPost = new BlogPostBuilder()
24+
.WithTitle("Title 1")
25+
.WithUpdatedDate(new DateTime(2024, 12, 24))
26+
.IsPublished()
27+
.Build();
28+
var unpublishedBlogPost = new BlogPostBuilder()
29+
.IsPublished(false)
30+
.Build();
31+
await Repository.StoreAsync(publishedBlogPost);
32+
await Repository.StoreAsync(unpublishedBlogPost);
33+
34+
var sitemap = await sut.CreateSitemapAsync("https://www.linkdotnet.blog");
35+
36+
sitemap.Urls.Count.ShouldBe(3);
37+
sitemap.Urls.ShouldContain(u => u.Location == "https://www.linkdotnet.blog/");
38+
sitemap.Urls.ShouldContain(u => u.Location == "https://www.linkdotnet.blog/archive");
39+
sitemap.Urls.ShouldContain(u => u.Location == "https://www.linkdotnet.blog/blogPost/" + publishedBlogPost.Id);
5840
}
5941
}

0 commit comments

Comments
 (0)