Skip to content

Feature/sitemap generator #37

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 9 commits into from
Oct 10, 2021
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
57 changes: 57 additions & 0 deletions LinkDotNet.Blog.UnitTests/Web/Pages/Admin/SitemapTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.Linq;
using Bunit;
using Bunit.TestDoubles;
using FluentAssertions;
using LinkDotNet.Blog.Web.Pages.Admin;
using LinkDotNet.Blog.Web.Shared.Services.Sitemap;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Xunit;

namespace LinkDotNet.Blog.UnitTests.Web.Pages.Admin
{
public class SitemapTests : TestContext
{
[Fact]
public void ShouldSaveSitemap()
{
this.AddTestAuthorization().SetAuthorized("steven");
var sitemapMock = new Mock<ISitemapService>();
Services.AddScoped(_ => sitemapMock.Object);
var sitemap = new SitemapUrlSet();
sitemapMock.Setup(s => s.CreateSitemapAsync())
.ReturnsAsync(sitemap);
var cut = RenderComponent<Sitemap>();

cut.Find("button").Click();

sitemapMock.Verify(s => s.SaveSitemapToFileAsync(sitemap));
}

[Fact]
public void ShouldDisplaySitemap()
{
this.AddTestAuthorization().SetAuthorized("steven");
var sitemapMock = new Mock<ISitemapService>();
Services.AddScoped(_ => sitemapMock.Object);
var sitemap = new SitemapUrlSet
{
Urls = new List<SitemapUrl>
{
new() { Location = "loc", LastModified = "Now" },
},
};
sitemapMock.Setup(s => s.CreateSitemapAsync())
.ReturnsAsync(sitemap);
var cut = RenderComponent<Sitemap>();

cut.Find("button").Click();

cut.WaitForState(() => cut.FindAll("tr").Count > 1);
var row = cut.FindAll("tr").Last();
row.Children.First().InnerHtml.Should().Be("loc");
row.Children.Last().InnerHtml.Should().Be("Now");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Bunit;
using Bunit.TestDoubles;
using FluentAssertions;
using LinkDotNet.Blog.Domain;
using LinkDotNet.Blog.Infrastructure.Persistence;
using LinkDotNet.Blog.TestUtilities;
using LinkDotNet.Blog.Web.Shared.Services;
using LinkDotNet.Blog.Web.Shared.Services.Sitemap;
using Moq;
using X.PagedList;
using Xunit;

namespace LinkDotNet.Blog.UnitTests.Web.Shared.Services
{
public class SitemapServiceTests : TestContext
{
private readonly Mock<IRepository<BlogPost>> repositoryMock;
private readonly Mock<IXmlFileWriter> xmlFileWriterMock;
private readonly SitemapService sut;
private readonly FakeNavigationManager fakeNavigationManager;

public SitemapServiceTests()
{
repositoryMock = new Mock<IRepository<BlogPost>>();
fakeNavigationManager = new FakeNavigationManager(Renderer);

xmlFileWriterMock = new Mock<IXmlFileWriter>();
sut = new SitemapService(
repositoryMock.Object,
fakeNavigationManager,
xmlFileWriterMock.Object);
}

[Fact]
public async Task ShouldCreateSitemap()
{
var bp1 = new BlogPostBuilder()
.WithUpdatedDate(new DateTime(2020, 1, 1))
.WithTags("tag1", "tag2")
.Build();
bp1.Id = "id1";
var bp2 = new BlogPostBuilder()
.WithUpdatedDate(new DateTime(2019, 1, 1))
.WithTags("tag2")
.Build();
bp2.Id = "id2";
var blogPosts = new[] { bp1, bp2 };
repositoryMock.Setup(r => r.GetAllAsync(
It.IsAny<Expression<Func<BlogPost, bool>>>(),
p => p.UpdatedDate,
true,
It.IsAny<int>(),
It.IsAny<int>()))
.ReturnsAsync(new PagedList<BlogPost>(blogPosts, 1, 10));

var sitemap = await sut.CreateSitemapAsync();

sitemap.Namespace.Should().Be("http://www.sitemaps.org/schemas/sitemap/0.9");
sitemap.Urls.Should().HaveCount(5);
sitemap.Urls[0].Location.Should().Be($"{fakeNavigationManager.BaseUri}");
sitemap.Urls[1].Location.Should().Be($"{fakeNavigationManager.BaseUri}blogPost/id1");
sitemap.Urls[2].Location.Should().Be($"{fakeNavigationManager.BaseUri}blogPost/id2");
sitemap.Urls[3].Location.Should().Be($"{fakeNavigationManager.BaseUri}searchByTag/tag1");
sitemap.Urls[4].Location.Should().Be($"{fakeNavigationManager.BaseUri}searchByTag/tag2");
}

[Fact]
public async Task ShouldSaveSitemapToFile()
{
var sitemap = new SitemapUrlSet();

await sut.SaveSitemapToFileAsync(sitemap);

xmlFileWriterMock.Verify(x => x.WriteObjectToXmlFileAsync(sitemap, "wwwroot/sitemap.xml"));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System;
using System.IO;
using System.Threading.Tasks;
using FluentAssertions;
using LinkDotNet.Blog.Web.Shared.Services;
using Xunit;

namespace LinkDotNet.Blog.UnitTests.Web.Shared.Services
{
public sealed class XmlFileWriterTests : IDisposable
{
private const string OutputFilename = "somefile.txt";

[Fact]
public async Task ShouldWriteToFile()
{
var myObj = new MyObject { Property = "Prop" };

await new XmlFileWriter().WriteObjectToXmlFileAsync(myObj, OutputFilename);

var content = await File.ReadAllTextAsync(OutputFilename);
content.Should().NotBeNull();
content.Should().Contain("<MyObject");
content.Should().Contain("<Property>Prop</Property>");
}

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

public class MyObject
{
public string Property { get; set; }
}
}
}
43 changes: 43 additions & 0 deletions LinkDotNet.Blog.Web/Pages/Admin/Sitemap.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
@page "/Sitemap"
@using LinkDotNet.Blog.Web.Shared.Services.Sitemap
@inject ISitemapService sitemapService
@attribute [Authorize]
<h3 xmlns="http://www.w3.org/1999/html">Sitemap</h3>
<div class="row px-2">
<p>A sitemap is a file which lists all important links in a webpage. It helps crawler to find all of the
important pages. Especially newer sites benefit from having a sitemap.xml.
The file will be created at the root of the site. To see the sitemap.xml go here: <a href="/sitemap.xml">sitemap.xml</a>.<br/>
If you get a 404 there is currently no sitemap.xml</p>
<button class="btn btn-primary" @onclick="CreateSitemap">Create Sitemap</button>

@if (sitemapUrlSet != null)
{
<table class="table table-striped table-hover h-50">
<thead>
<tr>
<th>Url</th>
<th>Last Changed</th>
</tr>
</thead>
<tbody>
@foreach (var url in sitemapUrlSet.Urls)
{
<tr>
<td>@url.Location</td>
<td>@url.LastModified</td>
</tr>
}
</tbody>
</table>
}
</div>

@code {
private SitemapUrlSet sitemapUrlSet;

private async Task CreateSitemap()
{
sitemapUrlSet = await sitemapService.CreateSitemapAsync();
await sitemapService.SaveSitemapToFileAsync(sitemapUrlSet);
}
}
20 changes: 20 additions & 0 deletions LinkDotNet.Blog.Web/ServiceExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using LinkDotNet.Blog.Web.Pages.Admin;
using LinkDotNet.Blog.Web.Shared.Services;
using LinkDotNet.Blog.Web.Shared.Services.Sitemap;
using Microsoft.Extensions.DependencyInjection;

namespace LinkDotNet.Blog.Web
{
public static class ServiceExtensions
{
public static void RegisterServices(this IServiceCollection services)
{
services.AddScoped<ILocalStorageService, LocalStorageService>();
services.AddSingleton<ISortOrderCalculator, SortOrderCalculator>();
services.AddScoped<IUserRecordService, UserRecordService>();
services.AddScoped<IDashboardService, DashboardService>();
services.AddScoped<ISitemapService, SitemapService>();
services.AddScoped<IXmlFileWriter, XmlFileWriter>();
}
}
}
3 changes: 3 additions & 0 deletions LinkDotNet.Blog.Web/Shared/AccessControl.razor
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
<li><h6 class="dropdown-header">Analytics</h6></li>
<li><a class="dropdown-item" href="dashboard">Dashboard</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Others</h6></li>
<li><a class="dropdown-item" href="Sitemap">Sitemap</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="https://github.com/linkdotnet/Blog/releases">Version 2.1.2</a></li>
</ul>
</li>
Expand Down
9 changes: 9 additions & 0 deletions LinkDotNet.Blog.Web/Shared/Services/IXmlFileWriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Threading.Tasks;

namespace LinkDotNet.Blog.Web.Shared.Services
{
public interface IXmlFileWriter
{
Task WriteObjectToXmlFileAsync<T>(T objectToSave, string fileName);
}
}
11 changes: 11 additions & 0 deletions LinkDotNet.Blog.Web/Shared/Services/Sitemap/ISitemapService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Threading.Tasks;

namespace LinkDotNet.Blog.Web.Shared.Services.Sitemap
{
public interface ISitemapService
{
Task<SitemapUrlSet> CreateSitemapAsync();

Task SaveSitemapToFileAsync(SitemapUrlSet sitemap);
}
}
70 changes: 70 additions & 0 deletions LinkDotNet.Blog.Web/Shared/Services/Sitemap/SitemapService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LinkDotNet.Blog.Domain;
using LinkDotNet.Blog.Infrastructure.Persistence;
using Microsoft.AspNetCore.Components;

namespace LinkDotNet.Blog.Web.Shared.Services.Sitemap
{
public 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)
{
this.repository = repository;
this.navigationManager = navigationManager;
this.xmlFileWriter = xmlFileWriter;
}

public async Task<SitemapUrlSet> CreateSitemapAsync()
{
const string sitemapNamespace = "http://www.sitemaps.org/schemas/sitemap/0.9";
var urlSet = new SitemapUrlSet
{
Namespace = sitemapNamespace,
};

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

urlSet.Urls.Add(new SitemapUrl { Location = navigationManager.BaseUri });
urlSet.Urls.AddRange(CreateUrlsForBlogPosts(blogPosts));
urlSet.Urls.AddRange(CreateUrlsForTags(blogPosts));

return urlSet;
}

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

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

private IEnumerable<SitemapUrl> CreateUrlsForTags(IEnumerable<BlogPost> blogPosts)
{
return blogPosts
.SelectMany(b => b.Tags)
.Select(t => t.Content)
.Distinct()
.Select(t => new SitemapUrl
{
Location = $"{navigationManager.BaseUri}searchByTag/{Uri.EscapeDataString(t)}",
});
}
}
}
14 changes: 14 additions & 0 deletions LinkDotNet.Blog.Web/Shared/Services/Sitemap/SitemapUrl.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Xml.Serialization;

namespace LinkDotNet.Blog.Web.Shared.Services.Sitemap
{
[XmlRoot(ElementName="url")]
public class SitemapUrl
{
[XmlElement(ElementName="loc")]
public string Location { get; set; }

[XmlElement(ElementName="lastmod")]
public string LastModified { get; set; }
}
}
15 changes: 15 additions & 0 deletions LinkDotNet.Blog.Web/Shared/Services/Sitemap/SitemapUrlSet.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Xml.Serialization;

namespace LinkDotNet.Blog.Web.Shared.Services.Sitemap
{
[XmlRoot(ElementName="urlset")]
public class SitemapUrlSet
{
[XmlElement(ElementName = "url")]
public List<SitemapUrl> Urls { get; set; } = new();

[XmlAttribute(AttributeName="xmlns")]
public string Namespace { get; set; }
}
}
Loading