feat: 添加内容热重载指令 (#4)
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 1m34s
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 1m34s
Reviewed-on: #4
This commit is contained in:
61
YaeBlog.Core/Services/BlogChangeWatcher.cs
Normal file
61
YaeBlog.Core/Services/BlogChangeWatcher.cs
Normal 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();
|
||||
}
|
||||
}
|
35
YaeBlog.Core/Services/BlogHotReloadService.cs
Normal file
35
YaeBlog.Core/Services/BlogHotReloadService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
||||
|
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
@@ -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();
|
||||
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user