Skip to content

Sitemap dynamic #393

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Dec 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
</PropertyGroup>
<ItemGroup Label="Code Analyzers">
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
<GlobalPackageReference Include="SonarAnalyzer.CSharp" Version="10.3.0.106239" PrivateAssets="All" IncludeAssets="Runtime;Build;Native;contentFiles;Analyzers" />
<GlobalPackageReference Include="SonarAnalyzer.CSharp" Version="10.4.0.108396" PrivateAssets="All" IncludeAssets="Runtime;Build;Native;contentFiles;Analyzers" />
</ItemGroup>
<ItemGroup Label="Infrastructure">
<PackageVersion Include="Azure.Storage.Blobs" Version="12.23.0" />
Expand All @@ -18,11 +18,11 @@
<PackageVersion Include="RavenDB.Client" Version="6.2.2" />
</ItemGroup>
<ItemGroup Label="Web">
<PackageVersion Include="AspNetCore.HealthChecks.UI.Client" Version="8.0.1" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageVersion Include="Blazored.Toast" Version="4.2.1" />
<PackageVersion Include="Blazorise.Bootstrap5" Version="1.7.1" />
<PackageVersion Include="Blazorise.Markdown" Version="1.7.1" />
<PackageVersion Include="Markdig" Version="0.39.0" />
<PackageVersion Include="Markdig" Version="0.39.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.0" />
<PackageVersion Include="NCronJob" Version="3.3.8" />
Expand All @@ -47,4 +47,4 @@
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
<PackageVersion Include="Microsoft.Playwright" Version="1.49.0" />
</ItemGroup>
</Project>
</Project>
2 changes: 1 addition & 1 deletion docs/SEO/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ This blog also offers an RSS feed ([RSS 2.0 specification](https://validator.w3.

### Sitemap

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

## JSON LD
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.
Expand Down
52 changes: 52 additions & 0 deletions src/LinkDotNet.Blog.Web/Controller/SitemapController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System;
using System.IO;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Serialization;
using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Caching.Memory;

namespace LinkDotNet.Blog.Web.Controller;

[EnableRateLimiting("ip")]
[Route("sitemap.xml")]
public sealed class SitemapController : ControllerBase
{
private readonly ISitemapService sitemapService;
private readonly IXmlWriter xmlWriter;
private readonly IMemoryCache memoryCache;

public SitemapController(
ISitemapService sitemapService,
IXmlWriter xmlWriter,
IMemoryCache memoryCache)
{
this.sitemapService = sitemapService;
this.xmlWriter = xmlWriter;
this.memoryCache = memoryCache;
}

[ResponseCache(Duration = 3600)]
[HttpGet]
public async Task<IActionResult> GetSitemap()
{
var buffer = await memoryCache.GetOrCreateAsync("sitemap.xml", async e =>
{
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
return await GetSitemapBuffer();
})
?? throw new InvalidOperationException("Buffer is null");

return File(buffer, "application/xml");
}

private async Task<byte[]> GetSitemapBuffer()
{
var baseUri = $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
var sitemap = await sitemapService.CreateSitemapAsync(baseUri);
var buffer = await xmlWriter.WriteToBuffer(sitemap);
return buffer;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,5 @@ namespace LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services;

public interface ISitemapService
{
Task<SitemapUrlSet> CreateSitemapAsync();

Task SaveSitemapToFileAsync(SitemapUrlSet sitemap);
}
Task<SitemapUrlSet> CreateSitemapAsync(string baseUri);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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

public interface IXmlFileWriter
public interface IXmlWriter
{
Task WriteObjectToXmlFileAsync<T>(T objectToSave, string fileName);
}
Task<byte[]> WriteToBuffer<T>(T objectToSave);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,66 +2,63 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using LinkDotNet.Blog.Domain;
using LinkDotNet.Blog.Infrastructure.Persistence;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Caching.Memory;

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

public sealed class SitemapService : ISitemapService
{
private readonly IRepository<BlogPost> repository;
private readonly NavigationManager navigationManager;
private readonly IXmlFileWriter xmlFileWriter;

public SitemapService(
IRepository<BlogPost> repository,
NavigationManager navigationManager,
IXmlFileWriter xmlFileWriter)
public SitemapService(IRepository<BlogPost> repository)
{
this.repository = repository;
this.navigationManager = navigationManager;
this.xmlFileWriter = xmlFileWriter;
}

public async Task<SitemapUrlSet> CreateSitemapAsync()
public async Task<SitemapUrlSet> CreateSitemapAsync(string baseUri)
{
ArgumentException.ThrowIfNullOrEmpty(baseUri);

var urlSet = new SitemapUrlSet();

if (!baseUri.EndsWith('/'))
{
baseUri += "/";
}

var blogPosts = await repository.GetAllAsync(f => f.IsPublished, b => b.UpdatedDate);

urlSet.Urls.Add(new SitemapUrl { Location = navigationManager.BaseUri });
urlSet.Urls.Add(new SitemapUrl { Location = $"{navigationManager.BaseUri}archive" });
urlSet.Urls.AddRange(CreateUrlsForBlogPosts(blogPosts));
urlSet.Urls.AddRange(CreateUrlsForTags(blogPosts));
urlSet.Urls.Add(new SitemapUrl { Location = baseUri });
urlSet.Urls.Add(new SitemapUrl { Location = $"{baseUri}archive" });
urlSet.Urls.AddRange(CreateUrlsForBlogPosts(blogPosts, baseUri));
urlSet.Urls.AddRange(CreateUrlsForTags(blogPosts, baseUri));

return urlSet;
}

public async Task SaveSitemapToFileAsync(SitemapUrlSet sitemap)
{
await xmlFileWriter.WriteObjectToXmlFileAsync(sitemap, "wwwroot/sitemap.xml");
}

private ImmutableArray<SitemapUrl> CreateUrlsForBlogPosts(IEnumerable<BlogPost> blogPosts)
private static ImmutableArray<SitemapUrl> CreateUrlsForBlogPosts(IEnumerable<BlogPost> blogPosts, string baseUri)
{
return blogPosts.Select(b => new SitemapUrl
{
Location = $"{navigationManager.BaseUri}blogPost/{b.Id}",
Location = $"{baseUri}blogPost/{b.Id}",
LastModified = b.UpdatedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
}).ToImmutableArray();
}

private IEnumerable<SitemapUrl> CreateUrlsForTags(IEnumerable<BlogPost> blogPosts)
private static IEnumerable<SitemapUrl> CreateUrlsForTags(IEnumerable<BlogPost> blogPosts, string baseUri)
{
return blogPosts
.SelectMany(b => b.Tags)
.Distinct()
.Select(t => new SitemapUrl
{
Location = $"{navigationManager.BaseUri}searchByTag/{Uri.EscapeDataString(t)}",
Location = $"{baseUri}searchByTag/{Uri.EscapeDataString(t)}",
});
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.IO;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Serialization;

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

public sealed class XmlWriter : IXmlWriter
{
public async Task<byte[]> WriteToBuffer<T>(T objectToSave)
{
await using var memoryStream = new MemoryStream();
await using var xmlWriter = System.Xml.XmlWriter.Create(memoryStream, new XmlWriterSettings { Indent = true, Async = true });
var serializer = new XmlSerializer(typeof(T));
serializer.Serialize(xmlWriter, objectToSave);
xmlWriter.Close();
return memoryStream.ToArray();
}
}
55 changes: 0 additions & 55 deletions src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/SitemapPage.razor

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Others</h6></li>
<li><a class="dropdown-item" href="short-codes">Shortcodes</a></li>
<li><a class="dropdown-item" href="Sitemap">Sitemap</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" target="_blank" href="https://github.com/linkdotnet/Blog/releases" rel="noreferrer">Releases</a></li>
</ul>
Expand Down
2 changes: 1 addition & 1 deletion src/LinkDotNet.Blog.Web/ServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
services.AddScoped<ISortOrderCalculator, SortOrderCalculator>();
services.AddScoped<IUserRecordService, UserRecordService>();
services.AddScoped<ISitemapService, SitemapService>();
services.AddScoped<IXmlFileWriter, XmlFileWriter>();
services.AddScoped<IXmlWriter, XmlWriter>();
services.AddScoped<IFileProcessor, FileProcessor>();

services.AddSingleton<CacheService>();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,59 +1,41 @@
using System;
using System;
using System.IO;
using System.Threading.Tasks;
using LinkDotNet.Blog.Domain;
using LinkDotNet.Blog.Infrastructure.Persistence;
using LinkDotNet.Blog.TestUtilities;
using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services;
using Microsoft.AspNetCore.Components;
using TestContext = Xunit.TestContext;

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

public sealed class SitemapServiceTests : IDisposable
public sealed class SitemapServiceTests : SqlDatabaseTestBase<BlogPost>
{
private const string OutputDirectory = "wwwroot";
private const string OutputFilename = $"{OutputDirectory}/sitemap.xml";
private readonly SitemapService sut;

public SitemapServiceTests()
{
var repositoryMock = Substitute.For<IRepository<BlogPost>>();
sut = new SitemapService(repositoryMock, Substitute.For<NavigationManager>(), new XmlFileWriter());
Directory.CreateDirectory("wwwroot");
}
=> sut = new SitemapService(Repository);

[Fact]
public async Task ShouldSaveSitemapUrlInCorrectFormat()
{
var urlSet = new SitemapUrlSet
{
Urls =
[
new SitemapUrl { Location = "here", }
],
};
await sut.SaveSitemapToFileAsync(urlSet);

var lines = await File.ReadAllTextAsync(OutputFilename, TestContext.Current.CancellationToken);
lines.ShouldBe(
@"<?xml version=""1.0"" encoding=""utf-8""?>
<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"">
<url>
<loc>here</loc>
</url>
</urlset>");
}

public void Dispose()
{
if (File.Exists(OutputFilename))
{
File.Delete(OutputFilename);
}

if (Directory.Exists(OutputDirectory))
{
Directory.Delete(OutputDirectory, true);
}
var publishedBlogPost = new BlogPostBuilder()
.WithTitle("Title 1")
.WithUpdatedDate(new DateTime(2024, 12, 24))
.IsPublished()
.Build();
var unpublishedBlogPost = new BlogPostBuilder()
.IsPublished(false)
.Build();
await Repository.StoreAsync(publishedBlogPost);
await Repository.StoreAsync(unpublishedBlogPost);

var sitemap = await sut.CreateSitemapAsync("https://www.linkdotnet.blog");

sitemap.Urls.Count.ShouldBe(3);
sitemap.Urls.ShouldContain(u => u.Location == "https://www.linkdotnet.blog/");
sitemap.Urls.ShouldContain(u => u.Location == "https://www.linkdotnet.blog/archive");
sitemap.Urls.ShouldContain(u => u.Location == "https://www.linkdotnet.blog/blogPost/" + publishedBlogPost.Id);
}
}
Loading
Loading