feat: add blog feed (#3)
* chore: begin working on post service * chore: add debug assets * chore: add whitelist words * feat: add prism for syntax highlighting * feat: updated favicon * feat: add blog feed * fix: add link to written by image * feat: add custom error pages * fix: focus on h2 since h1 is always the journal site title * feat: use status code pages with redirects * fix: add discernible text to link * feat: add support for light and dark theme * fix: wrap body with main tag * fix: style main element in pages to take up full width of container * fix: add heading to home page * feat: add not-found-corgi image * chore: add robots.txt file * chore: clean up place holder posts * fix: namespace header in layouts * fix: use logger instead of console * fix: use new text context in each test when registering services * chore: update workflow to debug failing tests * chore: install playwright deps * fix: extend expect timeout * fix: extend expect timeout * fix: capture traces * fix: capture traces * fix: index file name
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
namespace Blog.Posts;
|
||||
|
||||
class FilePostService(
|
||||
IOptions<FilePostServiceOptions> options,
|
||||
IFileSystem fileSystem,
|
||||
ILogger<FilePostService> logger
|
||||
) : IPostService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder()
|
||||
.UseAdvancedExtensions()
|
||||
.UsePrism()
|
||||
.Build();
|
||||
|
||||
private const string MetaFence = "meta";
|
||||
private const string IndexFile = "index.md";
|
||||
|
||||
private readonly FilePostServiceOptions _options = options.Value;
|
||||
private readonly IFileSystem _fileSystem = fileSystem;
|
||||
private readonly ILogger<FilePostService> _logger = logger;
|
||||
|
||||
private static (string? metadata, MarkdownDocument document) ParsePost(string postText)
|
||||
{
|
||||
var markDoc = Markdown.Parse(postText, MarkdownPipeline);
|
||||
var postMetadata = markDoc
|
||||
.Where(
|
||||
x =>
|
||||
x is FencedCodeBlock fencedCodeBlock &&
|
||||
fencedCodeBlock.Arguments is not null &&
|
||||
fencedCodeBlock.Arguments.Contains(MetaFence)
|
||||
)
|
||||
.Select(x => x as FencedCodeBlock)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (postMetadata is not null)
|
||||
{
|
||||
markDoc.Remove(postMetadata);
|
||||
}
|
||||
|
||||
var metaContent = postMetadata?.Lines.ToString();
|
||||
|
||||
return (metaContent, markDoc);
|
||||
}
|
||||
|
||||
public async Task<List<Post>> GetPostsAsync()
|
||||
{
|
||||
var posts = new List<Post>();
|
||||
|
||||
if (_fileSystem.Directory.Exists(_options.PostsDirectory) is false)
|
||||
{
|
||||
return posts;
|
||||
}
|
||||
|
||||
var subDirectories = _fileSystem.Directory.GetDirectories(_options.PostsDirectory);
|
||||
|
||||
if (subDirectories.Length is 0)
|
||||
{
|
||||
return posts;
|
||||
}
|
||||
|
||||
foreach (var subDirectory in subDirectories)
|
||||
{
|
||||
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);
|
||||
|
||||
if (metaContent is null || document.Count is 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var post = JsonSerializer.Deserialize<Post>(metaContent, JsonSerializerOptions);
|
||||
|
||||
if (post is null || post.IsPublished is false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var finalPost = post with
|
||||
{
|
||||
Slug = _fileSystem.Path.GetFileName(subDirectory),
|
||||
};
|
||||
|
||||
posts.Add(finalPost);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error while processing post in {SubDirectory}", subDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. posts.OrderByDescending(x => x.PublishedAt)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Blog.Posts;
|
||||
|
||||
class FilePostServiceOptions
|
||||
{
|
||||
public string PostsDirectory { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
class FilePostServiceOptionsSetup(IConfiguration configuration) : IConfigureOptions<FilePostServiceOptions>
|
||||
{
|
||||
private const string SectionName = nameof(FilePostServiceOptions);
|
||||
private readonly IConfiguration _configuration = configuration;
|
||||
|
||||
public void Configure(FilePostServiceOptions options)
|
||||
{
|
||||
_configuration.GetSection(SectionName).Bind(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Blog.Posts;
|
||||
|
||||
interface IPostService
|
||||
{
|
||||
Task<List<Post>> GetPostsAsync();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Blog.Posts;
|
||||
|
||||
record Post
|
||||
{
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public string Lead { get; init; } = string.Empty;
|
||||
public bool IsPublished { get; init; } = false;
|
||||
public DateTime PublishedAt { get; init; }
|
||||
public string Slug { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
record PostWithContent : Post
|
||||
{
|
||||
public string Content { get; init; } = string.Empty;
|
||||
}
|
||||
Reference in New Issue
Block a user