Merge pull request #5 from StevanFreeborn/stevanfreeborn/feat/add-rss-feed
feat: add rss feed
This commit is contained in:
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
"cSpell.words": [
|
||||
"Antiforgery",
|
||||
"blazor",
|
||||
"Entitize",
|
||||
"Hsts",
|
||||
"Markdig",
|
||||
"msedge"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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="WebStoating.Markdig.Prism" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
global using System.IO.Abstractions;
|
||||
global using System.ServiceModel.Syndication;
|
||||
global using System.Text;
|
||||
global using System.Text.Json;
|
||||
global using System.Xml;
|
||||
global using System.Xml.Linq;
|
||||
|
||||
global using Blog.Components;
|
||||
global using Blog.Posts;
|
||||
|
||||
@@ -16,7 +16,7 @@ class FilePostService(
|
||||
.UseAdvancedExtensions()
|
||||
.UsePrism()
|
||||
.Build();
|
||||
|
||||
|
||||
private const string MetaFence = "meta";
|
||||
private const string IndexFile = "index.md";
|
||||
|
||||
@@ -68,12 +68,12 @@ class FilePostService(
|
||||
try
|
||||
{
|
||||
var indexFilePath = _fileSystem.Path.Combine(subDirectory, IndexFile);
|
||||
|
||||
|
||||
if (_fileSystem.File.Exists(indexFilePath) is false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
var postText = await _fileSystem.File.ReadAllTextAsync(indexFilePath);
|
||||
|
||||
var (metaContent, document) = ParsePost(postText);
|
||||
@@ -108,7 +108,7 @@ class FilePostService(
|
||||
|
||||
public async Task<PostWithContent?> GetPostAsync(string slug)
|
||||
{
|
||||
try
|
||||
try
|
||||
{
|
||||
var postPath = _fileSystem.Path.Combine(_options.PostsDirectory, slug, IndexFile);
|
||||
|
||||
@@ -133,10 +133,10 @@ class FilePostService(
|
||||
return null;
|
||||
}
|
||||
|
||||
var postWithContent = post with
|
||||
{
|
||||
var postWithContent = post with
|
||||
{
|
||||
Slug = slug,
|
||||
Content = document.ToHtml(MarkdownPipeline)
|
||||
Content = document.ToHtml(MarkdownPipeline)
|
||||
};
|
||||
|
||||
return postWithContent;
|
||||
|
||||
+63
-1
@@ -21,11 +21,73 @@ app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
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
|
||||
.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
app.UseStatusCodePagesWithRedirects("/Error/{0}");
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ public class BlogTest : PageTest
|
||||
{
|
||||
await Context.Tracing.StopAsync(new()
|
||||
{
|
||||
Path = Path.Combine(
|
||||
Path = Path.Combine(
|
||||
TestContext.CurrentContext.WorkDirectory,
|
||||
"playwright-traces",
|
||||
$"{TestContext.CurrentContext.Test.ClassName}.{TestContext.CurrentContext.Test.Name}.zip"
|
||||
|
||||
@@ -6,20 +6,20 @@ public class CompositeHost(IHost testHost, IHost kestrelHost) : IHost
|
||||
private readonly IHost _kestrelHost = kestrelHost;
|
||||
|
||||
public IServiceProvider Services => _testHost.Services;
|
||||
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_testHost.Dispose();
|
||||
_kestrelHost.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _testHost.StartAsync(cancellationToken);
|
||||
await _kestrelHost.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _testHost.StopAsync(cancellationToken);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Blog.Tests.EndToEnd;
|
||||
|
||||
[TestFixture]
|
||||
public class PostTests : BlogTest
|
||||
public class PostTests : BlogTest
|
||||
{
|
||||
private const string TestPostSlug = "test-blog";
|
||||
private const string TestPostTitle = "Test Blog";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
global using System.IO.Abstractions;
|
||||
global using System.Net;
|
||||
global using System.ServiceModel.Syndication;
|
||||
global using System.Text;
|
||||
global using System.Xml;
|
||||
|
||||
global using Blog.Posts;
|
||||
|
||||
@@ -10,10 +13,10 @@ global using Microsoft.AspNetCore.Hosting.Server;
|
||||
global using Microsoft.AspNetCore.Mvc.Testing;
|
||||
global using Microsoft.Extensions.DependencyInjection;
|
||||
global using Microsoft.Extensions.Hosting;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
global using Microsoft.Extensions.Options;
|
||||
global using Microsoft.Playwright;
|
||||
global using Microsoft.Playwright.NUnit;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
|
||||
global using Moq;
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class FilePostServiceTests
|
||||
.Returns(new FilePostServiceOptions { PostsDirectory = "posts" });
|
||||
|
||||
_sut = new FilePostService(
|
||||
_mockOptions.Object,
|
||||
_mockOptions.Object,
|
||||
_mockFileSystem.Object,
|
||||
_mockLogger.Object
|
||||
);
|
||||
@@ -326,8 +326,8 @@ class FilePostServiceTests
|
||||
_mockFileSystem
|
||||
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
|
||||
.Returns([
|
||||
"valid-blog-post",
|
||||
"another-valid-blog-post",
|
||||
"valid-blog-post",
|
||||
"another-valid-blog-post",
|
||||
"invalid-blog-post"
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user