Merge pull request #5 from StevanFreeborn/stevanfreeborn/feat/add-rss-feed

feat: add rss feed
This commit is contained in:
Stevan Freeborn
2024-04-12 19:37:58 -05:00
committed by GitHub
11 changed files with 120 additions and 17 deletions
+1
View File
@@ -2,6 +2,7 @@
"cSpell.words": [ "cSpell.words": [
"Antiforgery", "Antiforgery",
"blazor", "blazor",
"Entitize",
"Hsts", "Hsts",
"Markdig", "Markdig",
"msedge" "msedge"
+1
View File
@@ -9,6 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Markdig" Version="0.36.2" /> <PackageReference Include="Markdig" Version="0.36.2" />
<PackageReference Include="System.ServiceModel.Syndication" Version="8.0.0" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.2" /> <PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.2" />
<PackageReference Include="WebStoating.Markdig.Prism" Version="1.0.0" /> <PackageReference Include="WebStoating.Markdig.Prism" Version="1.0.0" />
</ItemGroup> </ItemGroup>
+4
View File
@@ -1,5 +1,9 @@
global using System.IO.Abstractions; global using System.IO.Abstractions;
global using System.ServiceModel.Syndication;
global using System.Text;
global using System.Text.Json; global using System.Text.Json;
global using System.Xml;
global using System.Xml.Linq;
global using Blog.Components; global using Blog.Components;
global using Blog.Posts; global using Blog.Posts;
+7 -7
View File
@@ -16,7 +16,7 @@ class FilePostService(
.UseAdvancedExtensions() .UseAdvancedExtensions()
.UsePrism() .UsePrism()
.Build(); .Build();
private const string MetaFence = "meta"; private const string MetaFence = "meta";
private const string IndexFile = "index.md"; private const string IndexFile = "index.md";
@@ -68,12 +68,12 @@ class FilePostService(
try try
{ {
var indexFilePath = _fileSystem.Path.Combine(subDirectory, IndexFile); var indexFilePath = _fileSystem.Path.Combine(subDirectory, IndexFile);
if (_fileSystem.File.Exists(indexFilePath) is false) if (_fileSystem.File.Exists(indexFilePath) is false)
{ {
continue; continue;
} }
var postText = await _fileSystem.File.ReadAllTextAsync(indexFilePath); var postText = await _fileSystem.File.ReadAllTextAsync(indexFilePath);
var (metaContent, document) = ParsePost(postText); var (metaContent, document) = ParsePost(postText);
@@ -108,7 +108,7 @@ class FilePostService(
public async Task<PostWithContent?> GetPostAsync(string slug) public async Task<PostWithContent?> GetPostAsync(string slug)
{ {
try try
{ {
var postPath = _fileSystem.Path.Combine(_options.PostsDirectory, slug, IndexFile); var postPath = _fileSystem.Path.Combine(_options.PostsDirectory, slug, IndexFile);
@@ -133,10 +133,10 @@ class FilePostService(
return null; return null;
} }
var postWithContent = post with var postWithContent = post with
{ {
Slug = slug, Slug = slug,
Content = document.ToHtml(MarkdownPipeline) Content = document.ToHtml(MarkdownPipeline)
}; };
return postWithContent; return postWithContent;
+63 -1
View File
@@ -21,11 +21,73 @@ app.UseHttpsRedirection();
app.UseStaticFiles(); app.UseStaticFiles();
app.UseAntiforgery(); app.UseAntiforgery();
app.UseStatusCodePagesWithRedirects("/Error/{0}");
app
.MapGet("/rss", async (HttpContext context, IPostService postService) =>
{
var req = context.Request;
var url = $"{req.Scheme}://{req.Host}{req.PathBase}";
var posts = await postService.GetPostsAsync();
var items = posts.Select(post =>
{
var uri = new Uri($"{url}/{post.Slug}");
var item = new SyndicationItem(
post.Title,
post.Lead,
uri,
post.Slug,
post.PublishedAt
);
return item;
});
var feed = new SyndicationFeed(
"journal",
"A blog by Stevan Freeborn",
new Uri(url)
)
{
Items = items,
};
XNamespace atom = "http://www.w3.org/2005/Atom";
feed.ElementExtensions.Add(
new XElement(atom + "link",
new XAttribute("href", url + "/rss"),
new XAttribute("rel", "self"),
new XAttribute("type", "application/rss+xml")
)
);
var settings = new XmlWriterSettings
{
Encoding = Encoding.UTF8,
NewLineHandling = NewLineHandling.Entitize,
Indent = true,
Async = true,
};
using var stream = new MemoryStream();
using var writer = XmlWriter.Create(stream, settings);
var rssFormatter = new Rss20FeedFormatter(feed, false);
rssFormatter.WriteTo(writer);
await writer.FlushAsync();
return Results.File(stream.ToArray(), "application/xml");
})
.WithDisplayName("RSS Feed")
.WithDescription("RSS feed for the blog");
app app
.MapRazorComponents<App>() .MapRazorComponents<App>()
.AddInteractiveServerRenderMode(); .AddInteractiveServerRenderMode();
app.UseStatusCodePagesWithRedirects("/Error/{0}");
app.Run(); app.Run();
+1 -1
View File
@@ -32,7 +32,7 @@ public class BlogTest : PageTest
{ {
await Context.Tracing.StopAsync(new() await Context.Tracing.StopAsync(new()
{ {
Path = Path.Combine( Path = Path.Combine(
TestContext.CurrentContext.WorkDirectory, TestContext.CurrentContext.WorkDirectory,
"playwright-traces", "playwright-traces",
$"{TestContext.CurrentContext.Test.ClassName}.{TestContext.CurrentContext.Test.Name}.zip" $"{TestContext.CurrentContext.Test.ClassName}.{TestContext.CurrentContext.Test.Name}.zip"
+3 -3
View File
@@ -6,20 +6,20 @@ public class CompositeHost(IHost testHost, IHost kestrelHost) : IHost
private readonly IHost _kestrelHost = kestrelHost; private readonly IHost _kestrelHost = kestrelHost;
public IServiceProvider Services => _testHost.Services; public IServiceProvider Services => _testHost.Services;
public void Dispose() public void Dispose()
{ {
_testHost.Dispose(); _testHost.Dispose();
_kestrelHost.Dispose(); _kestrelHost.Dispose();
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
public async Task StartAsync(CancellationToken cancellationToken = default) public async Task StartAsync(CancellationToken cancellationToken = default)
{ {
await _testHost.StartAsync(cancellationToken); await _testHost.StartAsync(cancellationToken);
await _kestrelHost.StartAsync(cancellationToken); await _kestrelHost.StartAsync(cancellationToken);
} }
public async Task StopAsync(CancellationToken cancellationToken = default) public async Task StopAsync(CancellationToken cancellationToken = default)
{ {
await _testHost.StopAsync(cancellationToken); await _testHost.StopAsync(cancellationToken);
+1 -1
View File
@@ -1,7 +1,7 @@
namespace Blog.Tests.EndToEnd; namespace Blog.Tests.EndToEnd;
[TestFixture] [TestFixture]
public class PostTests : BlogTest public class PostTests : BlogTest
{ {
private const string TestPostSlug = "test-blog"; private const string TestPostSlug = "test-blog";
private const string TestPostTitle = "Test Blog"; private const string TestPostTitle = "Test Blog";
+32
View File
@@ -0,0 +1,32 @@
namespace Blog.Tests.EndToEnd;
[TestFixture]
public class RssTests : BlogTest
{
[Test]
public async Task Task_Rss_WhenFetched_ItShouldReturnRssFeed()
{
var response = await Page.APIRequest.GetAsync("/rss");
response.Status.Should().Be((int)HttpStatusCode.OK);
var xml = await response.BodyAsync();
var streamReader = new StreamReader(
new MemoryStream(xml),
Encoding.UTF8
);
var settings = new XmlReaderSettings
{
Async = true
};
using var xmlReader = XmlReader.Create(streamReader, settings);
var feed = SyndicationFeed.Load(xmlReader);
feed.Title.Text.Should().Be("journal");
feed.Description.Text.Should().Be("A blog by Stevan Freeborn");
feed.Items.Should().HaveCount(2);
}
}
+4 -1
View File
@@ -1,5 +1,8 @@
global using System.IO.Abstractions; global using System.IO.Abstractions;
global using System.Net; global using System.Net;
global using System.ServiceModel.Syndication;
global using System.Text;
global using System.Xml;
global using Blog.Posts; global using Blog.Posts;
@@ -10,10 +13,10 @@ global using Microsoft.AspNetCore.Hosting.Server;
global using Microsoft.AspNetCore.Mvc.Testing; global using Microsoft.AspNetCore.Mvc.Testing;
global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Hosting; global using Microsoft.Extensions.Hosting;
global using Microsoft.Extensions.Logging;
global using Microsoft.Extensions.Options; global using Microsoft.Extensions.Options;
global using Microsoft.Playwright; global using Microsoft.Playwright;
global using Microsoft.Playwright.NUnit; global using Microsoft.Playwright.NUnit;
global using Microsoft.Extensions.Logging;
global using Moq; global using Moq;
+3 -3
View File
@@ -15,7 +15,7 @@ class FilePostServiceTests
.Returns(new FilePostServiceOptions { PostsDirectory = "posts" }); .Returns(new FilePostServiceOptions { PostsDirectory = "posts" });
_sut = new FilePostService( _sut = new FilePostService(
_mockOptions.Object, _mockOptions.Object,
_mockFileSystem.Object, _mockFileSystem.Object,
_mockLogger.Object _mockLogger.Object
); );
@@ -326,8 +326,8 @@ class FilePostServiceTests
_mockFileSystem _mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>())) .Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns([ .Returns([
"valid-blog-post", "valid-blog-post",
"another-valid-blog-post", "another-valid-blog-post",
"invalid-blog-post" "invalid-blog-post"
]); ]);