feat: 添加内容热重载指令 (#4)
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 1m34s

Reviewed-on: #4
This commit is contained in:
2024-08-23 20:24:32 +08:00
parent e6ed407285
commit 9111affeec
143 changed files with 894 additions and 222 deletions

View File

@@ -0,0 +1,61 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using YaeBlog.Core.Models;
namespace YaeBlog.Core.Services;
public sealed class BlogChangeWatcher : IDisposable
{
private readonly FileSystemWatcher _fileSystemWatcher;
private readonly ILogger<BlogChangeWatcher> _logger;
public BlogChangeWatcher(IOptions<BlogOptions> options, ILogger<BlogChangeWatcher> logger)
{
_logger = logger;
_fileSystemWatcher = new FileSystemWatcher(Path.Combine(Environment.CurrentDirectory, options.Value.Root));
_fileSystemWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName |
NotifyFilters.DirectoryName | NotifyFilters.Size;
_fileSystemWatcher.IncludeSubdirectories = true;
_fileSystemWatcher.EnableRaisingEvents = true;
}
public async Task<string?> WaitForChange(CancellationToken cancellationToken = default)
{
TaskCompletionSource<string?> tcs = new();
cancellationToken.Register(() => tcs.TrySetResult(null));
_logger.LogDebug("Register file change handle.");
_fileSystemWatcher.Changed += FileChangedCallback;
_fileSystemWatcher.Created += FileChangedCallback;
_fileSystemWatcher.Deleted += FileChangedCallback;
_fileSystemWatcher.Renamed += FileChangedCallback;
string? result;
try
{
result = await tcs.Task;
}
finally
{
_logger.LogDebug("Unregister file change handle.");
_fileSystemWatcher.Changed -= FileChangedCallback;
_fileSystemWatcher.Created -= FileChangedCallback;
_fileSystemWatcher.Deleted -= FileChangedCallback;
_fileSystemWatcher.Renamed -= FileChangedCallback;
}
return result;
void FileChangedCallback(object _, FileSystemEventArgs e)
{
_logger.LogDebug("File {} change detected.", e.Name);
tcs.TrySetResult(e.Name);
}
}
public void Dispose()
{
_fileSystemWatcher.Dispose();
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using YaeBlog.Core.Abstractions;
namespace YaeBlog.Core.Services;
public sealed class BlogHotReloadService(
RendererService rendererService,
IEssayContentService essayContentService,
BlogChangeWatcher watcher,
ILogger<BlogHotReloadService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("BlogHotReloadService is starting.");
await rendererService.RenderAsync();
while (!stoppingToken.IsCancellationRequested)
{
logger.LogDebug("Watching file changes...");
string? changFile = await watcher.WaitForChange(stoppingToken);
if (changFile is null)
{
logger.LogInformation("BlogHotReloadService is stopping.");
break;
}
logger.LogInformation("{} changed, re-rendering.", changFile);
essayContentService.Clear();
await rendererService.RenderAsync();
}
}
}

View File

@@ -11,12 +11,18 @@ public class EssayContentService : IEssayContentService
private readonly Dictionary<EssayTag, List<BlogEssay>> _tags = [];
private readonly ConcurrentDictionary<string, BlogHeadline> _headlines = new();
public bool TryAdd(BlogEssay essay) => _essays.TryAdd(essay.FileName, essay);
public bool TryAddHeadline(string filename, BlogHeadline headline) => _headlines.TryAdd(filename, headline);
public IReadOnlyDictionary<string, BlogEssay> Essays => _essays;
public IReadOnlyDictionary<EssayTag, List<BlogEssay>> Tags => _tags;
public IReadOnlyDictionary<string, BlogHeadline> Headlines => _headlines;
public void RefreshTags()
{
_tags.Clear();
@@ -45,4 +51,11 @@ public class EssayContentService : IEssayContentService
return result is not null;
}
public void Clear()
{
_essays.Clear();
_tags.Clear();
_headlines.Clear();
}
}

View File

@@ -1,20 +1,104 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using YaeBlog.Core.Abstractions;
using YaeBlog.Core.Exceptions;
using YaeBlog.Core.Models;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
namespace YaeBlog.Core.Services;
public class EssayScanService(
ISerializer yamlSerializer,
IDeserializer yamlDeserializer,
IOptions<BlogOptions> blogOptions,
ILogger<EssayContentService> logger)
ILogger<EssayScanService> logger) : IEssayScanService
{
private readonly BlogOptions _blogOptions = blogOptions.Value;
public async Task<List<BlogContent>> ScanAsync()
public async Task<BlogContents> ScanContents()
{
string root = Path.Combine(Environment.CurrentDirectory, _blogOptions.Root);
ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts);
return new BlogContents(
await ScanContentsInternal(drafts),
await ScanContentsInternal(posts));
}
public async Task SaveBlogContent(BlogContent content, bool isDraft = true)
{
ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts);
FileInfo targetFile = isDraft
? new FileInfo(Path.Combine(drafts.FullName, content.FileName + ".md"))
: new FileInfo(Path.Combine(posts.FullName, content.FileName + ".md"));
if (targetFile.Exists)
{
logger.LogWarning("Blog {} exists, overriding.", targetFile.Name);
}
await using StreamWriter writer = targetFile.CreateText();
await writer.WriteAsync("---\n");
await writer.WriteAsync(yamlSerializer.Serialize(content.Metadata));
await writer.WriteAsync("---\n");
await writer.WriteAsync("<!--more-->\n");
}
private async Task<ConcurrentBag<BlogContent>> ScanContentsInternal(DirectoryInfo directory)
{
IEnumerable<FileInfo> markdownFiles = from file in directory.EnumerateFiles()
where file.Extension == ".md"
select file;
ConcurrentBag<(string, string)> fileContents = [];
await Parallel.ForEachAsync(markdownFiles, async (file, token) =>
{
using StreamReader reader = file.OpenText();
fileContents.Add((file.Name, await reader.ReadToEndAsync(token)));
});
ConcurrentBag<BlogContent> contents = [];
await Task.Run(() =>
{
foreach ((string filename, string content) in fileContents)
{
int endPos = content.IndexOf("---", 4, StringComparison.Ordinal);
if (!content.StartsWith("---") || endPos is -1 or 0)
{
logger.LogWarning("Failed to parse metadata from {}, skipped.", filename);
return;
}
string metadataString = content[4..endPos];
try
{
MarkdownMetadata metadata = yamlDeserializer.Deserialize<MarkdownMetadata>(metadataString);
logger.LogDebug("Scan metadata title: '{}' for {}.", metadata.Title, filename);
contents.Add(new BlogContent
{
FileName = filename[..^3], Metadata = metadata, FileContent = content[(endPos + 3)..]
});
}
catch (YamlException e)
{
logger.LogWarning("Failed to parser metadata from {} due to {}, skipping", filename, e);
}
}
});
return contents;
}
private void ValidateDirectory(string root, out DirectoryInfo drafts, out DirectoryInfo posts)
{
root = Path.Combine(Environment.CurrentDirectory, root);
DirectoryInfo rootDirectory = new(root);
if (!rootDirectory.Exists)
@@ -22,36 +106,17 @@ public class EssayScanService(
throw new BlogFileException($"'{root}' is not a directory.");
}
List<FileInfo> markdownFiles = [];
await Task.Run(() =>
if (rootDirectory.EnumerateDirectories().All(dir => dir.Name != "drafts"))
{
foreach (FileInfo fileInfo in rootDirectory.EnumerateFiles())
{
if (fileInfo.Extension != ".md")
{
continue;
}
throw new BlogFileException($"'{root}/drafts' not exists.");
}
logger.LogDebug("Scan markdown file: {}.", fileInfo.Name);
markdownFiles.Add(fileInfo);
}
});
ConcurrentBag<BlogContent> contents = [];
await Parallel.ForEachAsync(markdownFiles, async (info, token) =>
if (rootDirectory.EnumerateDirectories().All(dir => dir.Name != "posts"))
{
StreamReader reader = new(info.OpenRead());
throw new BlogFileException($"'{root}/posts' not exists.");
}
BlogContent content = new()
{
FileName = info.Name.Split('.')[0], FileContent = await reader.ReadToEndAsync(token)
};
contents.Add(content);
});
return contents.ToList();
drafts = new DirectoryInfo(Path.Combine(root, "drafts"));
posts = new DirectoryInfo(Path.Combine(root, "posts"));
}
}

View File

@@ -7,17 +7,14 @@ using Microsoft.Extensions.Logging;
using YaeBlog.Core.Abstractions;
using YaeBlog.Core.Exceptions;
using YaeBlog.Core.Models;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
namespace YaeBlog.Core.Services;
public partial class RendererService(
ILogger<RendererService> logger,
EssayScanService essayScanService,
IEssayScanService essayScanService,
MarkdownPipeline markdownPipeline,
IDeserializer yamlDeserializer,
EssayContentService essayContentService)
IEssayContentService essayContentService)
{
private readonly Stopwatch _stopwatch = new();
@@ -30,30 +27,30 @@ public partial class RendererService(
_stopwatch.Start();
logger.LogInformation("Render essays start.");
List<BlogContent> contents = await essayScanService.ScanAsync();
IEnumerable<BlogContent> preProcessedContents = await PreProcess(contents);
BlogContents contents = await essayScanService.ScanContents();
List<BlogContent> posts = contents.Posts.ToList();
IEnumerable<BlogContent> preProcessedContents = await PreProcess(posts);
List<BlogEssay> essays = [];
await Task.Run(() =>
{
foreach (BlogContent content in preProcessedContents)
{
MarkdownMetadata? metadata = TryParseMetadata(content);
uint wordCount = GetWordCount(content);
BlogEssay essay = new()
{
Title = metadata?.Title ?? content.FileName,
Title = content.Metadata.Title ?? content.FileName,
FileName = content.FileName,
Description = GetDescription(content),
WordCount = wordCount,
ReadTime = CalculateReadTime(wordCount),
PublishTime = metadata?.Date ?? DateTime.Now,
PublishTime = content.Metadata.Date ?? DateTime.Now,
HtmlContent = content.FileContent
};
if (metadata?.Tags is not null)
if (content.Metadata.Tags is not null)
{
essay.Tags.AddRange(metadata.Tags);
essay.Tags.AddRange(content.Metadata.Tags);
}
essays.Add(essay);
@@ -138,45 +135,6 @@ public partial class RendererService(
});
}
private MarkdownMetadata? TryParseMetadata(BlogContent content)
{
string fileContent = content.FileContent.Trim();
if (!fileContent.StartsWith("---"))
{
return null;
}
// 移除起始的---
fileContent = fileContent[3..];
int lastPos = fileContent.IndexOf("---", StringComparison.Ordinal);
if (lastPos is -1 or 0)
{
return null;
}
string yamlContent = fileContent[..lastPos];
// 返回去掉元数据之后的文本
lastPos += 3;
content.FileContent = fileContent[lastPos..];
try
{
MarkdownMetadata metadata =
yamlDeserializer.Deserialize<MarkdownMetadata>(yamlContent);
logger.LogDebug("Title: {}, Publish Date: {}.",
metadata.Title, metadata.Date);
return metadata;
}
catch (YamlException e)
{
logger.LogWarning("Failed to parse '{}' metadata: {}", yamlContent, e);
return null;
}
}
[GeneratedRegex(@"(?<!\\)[^\#\*_\-\+\`{}\[\]!~]+")]
private static partial Regex DescriptionPattern();

View File

@@ -1,20 +0,0 @@
using System.Collections.Concurrent;
using YaeBlog.Core.Abstractions;
using YaeBlog.Core.Models;
namespace YaeBlog.Core.Services;
public class TableOfContentService : ITableOfContentService
{
private readonly ConcurrentDictionary<string, BlogHeadline> _headlines = [];
public IReadOnlyDictionary<string, BlogHeadline> Headlines => _headlines;
public void AddHeadline(string filename, BlogHeadline headline)
{
if (!_headlines.TryAdd(filename, headline))
{
throw new InvalidOperationException();
}
}
}