diff --git a/YaeBlog.Core/Abstractions/IEssayContentService.cs b/YaeBlog.Core/Abstractions/IEssayContentService.cs index 3fbbb42..9be561c 100644 --- a/YaeBlog.Core/Abstractions/IEssayContentService.cs +++ b/YaeBlog.Core/Abstractions/IEssayContentService.cs @@ -9,5 +9,14 @@ public interface IEssayContentService public IReadOnlyDictionary> Tags { get; } - public bool SearchByUrlEncodedTag(string tag,[NotNullWhen(true)] out List? result); + public IReadOnlyDictionary Headlines { get; } + + public bool TryAddHeadline(string filename, BlogHeadline headline); + public bool SearchByUrlEncodedTag(string tag, [NotNullWhen(true)] out List? result); + + public bool TryAdd(BlogEssay essay); + + public void RefreshTags(); + + public void Clear(); } diff --git a/YaeBlog.Core/Abstractions/ITableOfContentService.cs b/YaeBlog.Core/Abstractions/ITableOfContentService.cs deleted file mode 100644 index 08b997c..0000000 --- a/YaeBlog.Core/Abstractions/ITableOfContentService.cs +++ /dev/null @@ -1,8 +0,0 @@ -using YaeBlog.Core.Models; - -namespace YaeBlog.Core.Abstractions; - -public interface ITableOfContentService -{ - public IReadOnlyDictionary Headlines { get; } -} diff --git a/YaeBlog.Core/Extensions/WebApplicationBuilderExtensions.cs b/YaeBlog.Core/Extensions/WebApplicationBuilderExtensions.cs index 03d4ad0..f6cd91a 100644 --- a/YaeBlog.Core/Extensions/WebApplicationBuilderExtensions.cs +++ b/YaeBlog.Core/Extensions/WebApplicationBuilderExtensions.cs @@ -22,12 +22,7 @@ public static class WebApplicationBuilderExtensions builder.Services.AddSingleton(_ => Configuration.Default); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(provider => - provider.GetRequiredService()); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(provider => - provider.GetRequiredService()); + builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); @@ -35,8 +30,21 @@ public static class WebApplicationBuilderExtensions builder.Services.AddTransient(provider => provider.GetRequiredService>().Value); + return builder; + } + + public static WebApplicationBuilder AddServer(this WebApplicationBuilder builder) + { builder.Services.AddHostedService(); return builder; } + + public static WebApplicationBuilder AddWatcher(this WebApplicationBuilder builder) + { + builder.Services.AddTransient(); + builder.Services.AddHostedService(); + + return builder; + } } diff --git a/YaeBlog.Core/Processors/HeadlinePostRenderProcessor.cs b/YaeBlog.Core/Processors/HeadlinePostRenderProcessor.cs index ddfed50..74dd5d3 100644 --- a/YaeBlog.Core/Processors/HeadlinePostRenderProcessor.cs +++ b/YaeBlog.Core/Processors/HeadlinePostRenderProcessor.cs @@ -1,14 +1,15 @@ using AngleSharp; using AngleSharp.Dom; +using Microsoft.Extensions.Logging; using YaeBlog.Core.Abstractions; using YaeBlog.Core.Models; -using YaeBlog.Core.Services; namespace YaeBlog.Core.Processors; public class HeadlinePostRenderProcessor( IConfiguration angleConfiguration, - TableOfContentService tableOfContentService) : IPostRenderProcessor + IEssayContentService essayContentService, + ILogger logger) : IPostRenderProcessor { public async Task ProcessAsync(BlogEssay essay) { @@ -62,7 +63,10 @@ public class HeadlinePostRenderProcessor( FindParentHeadline(topHeadline, level2List).Children.AddRange(level3List); topHeadline.Children.AddRange(level2List); - tableOfContentService.AddHeadline(essay.FileName, topHeadline); + if (!essayContentService.TryAddHeadline(essay.FileName, topHeadline)) + { + logger.LogWarning("Failed to add headline of {}.", essay.FileName); + } return essay.WithNewHtmlContent(document.DocumentElement.OuterHtml); } diff --git a/YaeBlog.Core/Services/BlogChangeWatcher.cs b/YaeBlog.Core/Services/BlogChangeWatcher.cs new file mode 100644 index 0000000..a6d4136 --- /dev/null +++ b/YaeBlog.Core/Services/BlogChangeWatcher.cs @@ -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 _logger; + + public BlogChangeWatcher(IOptions options, ILogger 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 WaitForChange(CancellationToken cancellationToken = default) + { + TaskCompletionSource 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(); + } +} diff --git a/YaeBlog.Core/Services/BlogHotReloadService.cs b/YaeBlog.Core/Services/BlogHotReloadService.cs new file mode 100644 index 0000000..f112e10 --- /dev/null +++ b/YaeBlog.Core/Services/BlogHotReloadService.cs @@ -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 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(); + } + } +} diff --git a/YaeBlog.Core/Services/EssayContentService.cs b/YaeBlog.Core/Services/EssayContentService.cs index aa800f2..05f49b6 100644 --- a/YaeBlog.Core/Services/EssayContentService.cs +++ b/YaeBlog.Core/Services/EssayContentService.cs @@ -11,12 +11,18 @@ public class EssayContentService : IEssayContentService private readonly Dictionary> _tags = []; + private readonly ConcurrentDictionary _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 Essays => _essays; public IReadOnlyDictionary> Tags => _tags; + public IReadOnlyDictionary 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(); + } } diff --git a/YaeBlog.Core/Services/RendererService.cs b/YaeBlog.Core/Services/RendererService.cs index 17e3177..8ed7103 100644 --- a/YaeBlog.Core/Services/RendererService.cs +++ b/YaeBlog.Core/Services/RendererService.cs @@ -14,7 +14,7 @@ public partial class RendererService( ILogger logger, IEssayScanService essayScanService, MarkdownPipeline markdownPipeline, - EssayContentService essayContentService) + IEssayContentService essayContentService) { private readonly Stopwatch _stopwatch = new(); diff --git a/YaeBlog.Core/Services/TableOfContentService.cs b/YaeBlog.Core/Services/TableOfContentService.cs deleted file mode 100644 index 4426016..0000000 --- a/YaeBlog.Core/Services/TableOfContentService.cs +++ /dev/null @@ -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 _headlines = []; - - public IReadOnlyDictionary Headlines => _headlines; - - public void AddHeadline(string filename, BlogHeadline headline) - { - if (!_headlines.TryAdd(filename, headline)) - { - throw new InvalidOperationException(); - } - } -} diff --git a/YaeBlog.Core/YaeBlog.Core.csproj b/YaeBlog.Core/YaeBlog.Core.csproj index 315c9c8..ef44f03 100644 --- a/YaeBlog.Core/YaeBlog.Core.csproj +++ b/YaeBlog.Core/YaeBlog.Core.csproj @@ -6,10 +6,6 @@ enable - - - - diff --git a/YaeBlog/Commands/CommandExtensions.cs b/YaeBlog/Commands/CommandExtensions.cs index cc025f5..0ea6ac6 100644 --- a/YaeBlog/Commands/CommandExtensions.cs +++ b/YaeBlog/Commands/CommandExtensions.cs @@ -23,6 +23,38 @@ public static class CommandExtensions builder.Services.AddControllers(); builder.Services.AddBlazorBootstrap(); builder.AddYaeBlog(); + builder.AddServer(); + + WebApplication application = builder.Build(); + + application.UseStaticFiles(); + application.UseAntiforgery(); + application.UseYaeBlog(); + + application.MapRazorComponents() + .AddInteractiveServerRenderMode(); + application.MapControllers(); + + CancellationToken token = context.GetCancellationToken(); + await application.RunAsync(token); + }); + } + + public static void AddWatchCommand(this RootCommand rootCommand) + { + Command command = new("watch", "Start a blog watcher that re-render when file changes."); + rootCommand.AddCommand(command); + + command.SetHandler(async context => + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + + builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + builder.Services.AddControllers(); + builder.Services.AddBlazorBootstrap(); + builder.AddYaeBlog(); + builder.AddWatcher(); WebApplication application = builder.Build(); diff --git a/YaeBlog/Pages/Essays.razor b/YaeBlog/Pages/Essays.razor index c61a9ea..9fdb685 100644 --- a/YaeBlog/Pages/Essays.razor +++ b/YaeBlog/Pages/Essays.razor @@ -4,7 +4,6 @@ @using YaeBlog.Core.Models @inject IEssayContentService Contents -@inject ITableOfContentService TableOfContent @inject NavigationManager NavigationInstance @@ -129,7 +128,7 @@ NavigationInstance.NavigateTo("/NotFound"); } - _headline = TableOfContent.Headlines[BlogKey]; + _headline = Contents.Headlines[BlogKey]; } private string GenerateSelectorUrl(string selectorId) diff --git a/YaeBlog/Program.cs b/YaeBlog/Program.cs index d7c52a1..4cd3540 100644 --- a/YaeBlog/Program.cs +++ b/YaeBlog/Program.cs @@ -6,5 +6,6 @@ RootCommand rootCommand = new("YaeBlog CLI"); rootCommand.AddServeCommand(); rootCommand.AddNewCommand(); rootCommand.AddListCommand(); +rootCommand.AddWatchCommand(); await rootCommand.InvokeAsync(args);