feat: 从Bootstrap迁移到Tailwind css #9

Merged
jackfiled merged 6 commits from feat-tailwind into master 2025-01-24 16:46:56 +08:00
18 changed files with 112 additions and 207 deletions
Showing only changes of commit 24fb498d59 - Show all commits

View File

@ -5,7 +5,9 @@ namespace YaeBlog.Abstraction;
public interface IEssayContentService public interface IEssayContentService
{ {
public IReadOnlyDictionary<string, BlogEssay> Essays { get; } public IEnumerable<BlogEssay> Essays { get; }
public int Count { get; }
public IReadOnlyDictionary<EssayTag, List<BlogEssay>> Tags { get; } public IReadOnlyDictionary<EssayTag, List<BlogEssay>> Tags { get; }
@ -16,6 +18,8 @@ public interface IEssayContentService
public bool TryAdd(BlogEssay essay); public bool TryAdd(BlogEssay essay);
public bool TryGetEssay(string filename, [NotNullWhen(true)] out BlogEssay? essay);
public void RefreshTags(); public void RefreshTags();
public void Clear(); public void Clear();

View File

@ -19,7 +19,7 @@
</div> </div>
<div> <div>
@(Contents.Essays.Count) @(Contents.Count)
</div> </div>
</div> </div>

View File

@ -22,7 +22,7 @@
} }
else else
{ {
<PageAnchor Address="@GenerateAddress(Page + 1)" Text="@($"{Page - 1}")"/> <PageAnchor Address="@GenerateAddress(Page - 1)" Text="@($"{Page - 1}")"/>
<PageAnchor Address="@GenerateAddress(Page)" Text="@($"{Page}")" Selected="@true"/> <PageAnchor Address="@GenerateAddress(Page)" Text="@($"{Page}")" Selected="@true"/>

View File

@ -1,12 +0,0 @@
namespace YaeBlog.Core.Exceptions;
public sealed class ProcessInteropException : Exception
{
public ProcessInteropException(string message) : base(message)
{
}
public ProcessInteropException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@ -1,10 +0,0 @@
namespace YaeBlog.Models;
public class AboutInfo
{
public required string Introduction { get; set; }
public required string Description { get; set; }
public required string AvatarImage { get; set; }
}

View File

@ -7,4 +7,6 @@ public class BlogContent
public required MarkdownMetadata Metadata { get; init; } public required MarkdownMetadata Metadata { get; init; }
public required string FileContent { get; set; } public required string FileContent { get; set; }
public bool IsDraft { get; set; } = false;
} }

View File

@ -6,6 +6,8 @@ public class BlogEssay : IComparable<BlogEssay>
public required string FileName { get; init; } public required string FileName { get; init; }
public required bool IsDraft { get; init; }
public required DateTime PublishTime { get; init; } public required DateTime PublishTime { get; init; }
public required string Description { get; init; } public required string Description { get; init; }
@ -24,6 +26,7 @@ public class BlogEssay : IComparable<BlogEssay>
{ {
Title = Title, Title = Title,
FileName = FileName, FileName = FileName,
IsDraft = IsDraft,
PublishTime = PublishTime, PublishTime = PublishTime,
Description = Description, Description = Description,
WordCount = WordCount, WordCount = WordCount,
@ -39,10 +42,16 @@ public class BlogEssay : IComparable<BlogEssay>
{ {
if (other is null) if (other is null)
{ {
return 1; return -1;
} }
return PublishTime.CompareTo(other.PublishTime); // 草稿文章应当排在前面
if (IsDraft != other.IsDraft)
{
return IsDraft ? -1 : 1;
}
return other.PublishTime.CompareTo(PublishTime);
} }
public override string ToString() public override string ToString()

View File

@ -1,10 +0,0 @@
namespace YaeBlog.Models;
public class TailwindOptions
{
public const string OptionName = "Tailwind";
public required string InputFile { get; set; }
public required string OutputFile { get; set; }
}

View File

@ -19,7 +19,7 @@
</span> </span>
</div> </div>
@foreach (IGrouping<DateTime, KeyValuePair<string, BlogEssay>> group in _essays) @foreach (IGrouping<DateTime, BlogEssay> group in _essays)
{ {
<div class="p-2"> <div class="p-2">
<div class="flex flex-col"> <div class="flex flex-col">
@ -28,9 +28,9 @@
</div> </div>
<div class="px-4 py-4 flex flex-col"> <div class="px-4 py-4 flex flex-col">
@foreach ((String name, BlogEssay essay) in group) @foreach (BlogEssay essay in group)
{ {
<a target="_blank" href="@($"/blog/essays/{name}")"> <a target="_blank" href="@($"/blog/essays/{essay.FileName}")">
<div class="flex flex-row p-2 mx-1 rounded-lg hover:bg-gray-300"> <div class="flex flex-row p-2 mx-1 rounded-lg hover:bg-gray-300">
<div class="w-20"> <div class="w-20">
@(essay.PublishTime.ToString("MM月dd日")) @(essay.PublishTime.ToString("MM月dd日"))
@ -51,14 +51,13 @@
</div> </div>
@code { @code {
private readonly List<IGrouping<DateTime, KeyValuePair<string, BlogEssay>>> _essays = []; private readonly List<IGrouping<DateTime, BlogEssay>> _essays = [];
protected override void OnInitialized() protected override void OnInitialized()
{ {
base.OnInitialized(); base.OnInitialized();
_essays.AddRange(from essay in Contents.Essays _essays.AddRange(from essay in Contents.Essays
orderby essay.Value.PublishTime descending group essay by new DateTime(essay.PublishTime.Year, 1, 1));
group essay by new DateTime(essay.Value.PublishTime.Year, 1, 1));
} }
} }

View File

@ -12,9 +12,9 @@
<div> <div>
<div class="grid grid-cols-4"> <div class="grid grid-cols-4">
<div class="col-span-4 md:col-span-3"> <div class="col-span-4 md:col-span-3">
@foreach (KeyValuePair<string, BlogEssay> pair in _essays) @foreach (BlogEssay essay in _essays)
{ {
<EssayCard Essay="@(pair.Value)"/> <EssayCard Essay="@(essay)"/>
} }
<Pagination BaseUrl="/blog/" Page="_page" PageCount="_pageCount"/> <Pagination BaseUrl="/blog/" Page="_page" PageCount="_pageCount"/>
@ -30,7 +30,7 @@
[SupplyParameterFromQuery] private int? Page { get; set; } [SupplyParameterFromQuery] private int? Page { get; set; }
private readonly List<KeyValuePair<string, BlogEssay>> _essays = []; private readonly List<BlogEssay> _essays = [];
private const int EssaysPerPage = 8; private const int EssaysPerPage = 8;
private int _pageCount = 1; private int _pageCount = 1;
private int _page = 1; private int _page = 1;
@ -38,16 +38,15 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
_page = Page ?? 1; _page = Page ?? 1;
_pageCount = Contents.Essays.Count / EssaysPerPage + 1; _pageCount = Contents.Count / EssaysPerPage + 1;
if (EssaysPerPage * _page > Contents.Essays.Count + EssaysPerPage) if (EssaysPerPage * _page > Contents.Count + EssaysPerPage)
{ {
NavigationInstance.NavigateTo("/NotFount"); NavigationInstance.NavigateTo("/NotFount");
return; return;
} }
_essays.AddRange(Contents.Essays _essays.AddRange(Contents.Essays
.OrderByDescending(p => p.Value.PublishTime)
.Skip((_page - 1) * EssaysPerPage) .Skip((_page - 1) * EssaysPerPage)
.Take(EssaysPerPage)); .Take(EssaysPerPage));
} }

View File

@ -110,7 +110,7 @@
return; return;
} }
if (!Contents.Essays.TryGetValue(BlogKey, out _essay)) if (!Contents.TryGetEssay(BlogKey, out _essay))
{ {
NavigationInstance.NavigateTo("/NotFound"); NavigationInstance.NavigateTo("/NotFound");
} }

View File

@ -6,14 +6,12 @@ public class BlogHostedService(
{ {
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
logger.LogInformation("Welcome to YaeBlog!"); logger.LogInformation("Failed to load cache, render essays.");
await rendererService.RenderAsync(); await rendererService.RenderAsync();
} }
public Task StopAsync(CancellationToken cancellationToken) public Task StopAsync(CancellationToken cancellationToken)
{ {
logger.LogInformation("YaeBlog stopped!\nHave a nice day!");
return Task.CompletedTask; return Task.CompletedTask;
} }
} }

View File

@ -10,24 +10,32 @@ public sealed class BlogHotReloadService(
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
logger.LogInformation("BlogHotReloadService is starting."); logger.LogInformation("Hot reload is starting...");
logger.LogInformation("Change essays will lead to hot reload!");
logger.LogInformation("HINT: draft essays will be included.");
await rendererService.RenderAsync(); await rendererService.RenderAsync(true);
while (!stoppingToken.IsCancellationRequested) Task[] reloadTasks = [FileWatchTask(stoppingToken)];
await Task.WhenAll(reloadTasks);
}
private async Task FileWatchTask(CancellationToken token)
{ {
logger.LogDebug("Watching file changes..."); while (!token.IsCancellationRequested)
string? changFile = await watcher.WaitForChange(stoppingToken);
if (changFile is null)
{ {
logger.LogInformation("BlogHotReloadService is stopping."); logger.LogInformation("Watching file changes...");
string? changeFile = await watcher.WaitForChange(token);
if (changeFile is null)
{
logger.LogInformation("File watcher is stopping.");
break; break;
} }
logger.LogInformation("{} changed, re-rendering.", changFile); logger.LogInformation("{} changed, re-rendering.", changeFile);
essayContentService.Clear(); essayContentService.Clear();
await rendererService.RenderAsync(); await rendererService.RenderAsync(true);
} }
} }
} }

View File

@ -9,15 +9,28 @@ public class EssayContentService : IEssayContentService
{ {
private readonly ConcurrentDictionary<string, BlogEssay> _essays = new(); private readonly ConcurrentDictionary<string, BlogEssay> _essays = new();
private readonly List<BlogEssay> _sortedEssays = [];
private readonly Dictionary<EssayTag, List<BlogEssay>> _tags = []; private readonly Dictionary<EssayTag, List<BlogEssay>> _tags = [];
private readonly ConcurrentDictionary<string, BlogHeadline> _headlines = new(); private readonly ConcurrentDictionary<string, BlogHeadline> _headlines = new();
public bool TryAdd(BlogEssay essay) => _essays.TryAdd(essay.FileName, essay); public bool TryAdd(BlogEssay essay)
{
_sortedEssays.Add(essay);
return _essays.TryAdd(essay.FileName, essay);
}
public bool TryAddHeadline(string filename, BlogHeadline headline) => _headlines.TryAdd(filename, headline); public bool TryAddHeadline(string filename, BlogHeadline headline) => _headlines.TryAdd(filename, headline);
public IReadOnlyDictionary<string, BlogEssay> Essays => _essays; public IEnumerable<BlogEssay> Essays => _sortedEssays;
public int Count => _sortedEssays.Count;
public bool TryGetEssay(string filename, [NotNullWhen(true)] out BlogEssay? essay)
{
return _essays.TryGetValue(filename, out essay);
}
public IReadOnlyDictionary<EssayTag, List<BlogEssay>> Tags => _tags; public IReadOnlyDictionary<EssayTag, List<BlogEssay>> Tags => _tags;

View File

@ -22,8 +22,8 @@ public partial class EssayScanService(
ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts); ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts);
return new BlogContents( return new BlogContents(
await ScanContentsInternal(drafts), await ScanContentsInternal(drafts, true),
await ScanContentsInternal(posts)); await ScanContentsInternal(posts, false));
} }
public async Task SaveBlogContent(BlogContent content, bool isDraft = true) public async Task SaveBlogContent(BlogContent content, bool isDraft = true)
@ -60,7 +60,7 @@ public partial class EssayScanService(
} }
} }
private async Task<ConcurrentBag<BlogContent>> ScanContentsInternal(DirectoryInfo directory) private async Task<ConcurrentBag<BlogContent>> ScanContentsInternal(DirectoryInfo directory, bool isDraft)
{ {
// 扫描以md结果的但是不是隐藏文件的文件 // 扫描以md结果的但是不是隐藏文件的文件
IEnumerable<FileInfo> markdownFiles = from file in directory.EnumerateFiles() IEnumerable<FileInfo> markdownFiles = from file in directory.EnumerateFiles()
@ -97,7 +97,8 @@ public partial class EssayScanService(
contents.Add(new BlogContent contents.Add(new BlogContent
{ {
FileName = filename[..^3], Metadata = metadata, FileContent = content[(endPos + 3)..] FileName = filename[..^3], Metadata = metadata, FileContent = content[(endPos + 3)..],
IsDraft = isDraft
}); });
} }
catch (YamlException e) catch (YamlException e)

View File

@ -1,65 +0,0 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using YaeBlog.Core.Exceptions;
namespace YaeBlog.Services;
public class ProcessInteropService(ILogger<ProcessInteropService> logger)
{
public Process StartProcess(string command, string arguments)
{
string commandName;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !command.EndsWith(".exe"))
{
commandName = command + ".exe";
}
else
{
commandName = command;
}
try
{
ProcessStartInfo startInfo = new()
{
FileName = commandName,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
Process? process = Process.Start(startInfo);
if (process is null)
{
throw new ProcessInteropException(
$"Failed to start process: {commandName}, the return process is null.");
}
process.OutputDataReceived += (_, data) =>
{
if (!string.IsNullOrEmpty(data.Data))
{
logger.LogInformation("Receive output from process '{}': '{}'", commandName, data.Data);
}
};
process.ErrorDataReceived += (_, data) =>
{
if (!string.IsNullOrEmpty(data.Data))
{
logger.LogWarning("Receive error from process '{}': '{}'", commandName, data.Data);
}
};
return process;
}
catch (Exception innerException)
{
throw new ProcessInteropException($"Failed to start process '{command}' with arguments '{arguments}",
innerException);
}
}
}

View File

@ -21,18 +21,21 @@ public partial class RendererService(
private readonly List<IPostRenderProcessor> _postRenderProcessors = []; private readonly List<IPostRenderProcessor> _postRenderProcessors = [];
public async Task RenderAsync() public async Task RenderAsync(bool includeDrafts = false)
{ {
_stopwatch.Start(); _stopwatch.Start();
logger.LogInformation("Render essays start."); logger.LogInformation("Render essays start.");
BlogContents contents = await essayScanService.ScanContents(); BlogContents contents = await essayScanService.ScanContents();
List<BlogContent> posts = contents.Posts.ToList(); List<BlogContent> posts = contents.Posts.ToList();
if (includeDrafts)
{
posts.AddRange(contents.Drafts);
}
IEnumerable<BlogContent> preProcessedContents = await PreProcess(posts); IEnumerable<BlogContent> preProcessedContents = await PreProcess(posts);
List<BlogEssay> essays = []; List<BlogEssay> essays = [];
await Task.Run(() =>
{
foreach (BlogContent content in preProcessedContents) foreach (BlogContent content in preProcessedContents)
{ {
uint wordCount = GetWordCount(content); uint wordCount = GetWordCount(content);
@ -40,6 +43,7 @@ public partial class RendererService(
{ {
Title = content.Metadata.Title ?? content.FileName, Title = content.Metadata.Title ?? content.FileName,
FileName = content.FileName, FileName = content.FileName,
IsDraft = content.IsDraft,
Description = GetDescription(content), Description = GetDescription(content),
WordCount = wordCount, WordCount = wordCount,
ReadTime = CalculateReadTime(wordCount), ReadTime = CalculateReadTime(wordCount),
@ -54,7 +58,6 @@ public partial class RendererService(
essays.Add(essay); essays.Add(essay);
} }
});
ConcurrentBag<BlogEssay> postProcessEssays = []; ConcurrentBag<BlogEssay> postProcessEssays = [];
Parallel.ForEach(essays, essay => Parallel.ForEach(essays, essay =>
@ -66,7 +69,16 @@ public partial class RendererService(
logger.LogDebug("Render markdown file {}.", newEssay); logger.LogDebug("Render markdown file {}.", newEssay);
}); });
await PostProcess(postProcessEssays); IEnumerable<BlogEssay> postProcessedEssays = await PostProcess(postProcessEssays);
foreach (BlogEssay essay in postProcessedEssays)
{
if (!essayContentService.TryAdd(essay))
{
throw new BlogFileException($"There are at least two essays with filename '{essay.FileName}'.");
}
}
essayContentService.RefreshTags(); essayContentService.RefreshTags();
_stopwatch.Stop(); _stopwatch.Stop();
@ -117,8 +129,10 @@ public partial class RendererService(
return processedContents; return processedContents;
} }
private async Task PostProcess(IEnumerable<BlogEssay> essays) private async Task<IEnumerable<BlogEssay>> PostProcess(IEnumerable<BlogEssay> essays)
{ {
ConcurrentBag<BlogEssay> processedContents = [];
await Parallel.ForEachAsync(essays, async (essay, _) => await Parallel.ForEachAsync(essays, async (essay, _) =>
{ {
foreach (IPostRenderProcessor processor in _postRenderProcessors) foreach (IPostRenderProcessor processor in _postRenderProcessors)
@ -126,12 +140,13 @@ public partial class RendererService(
essay = await processor.ProcessAsync(essay); essay = await processor.ProcessAsync(essay);
} }
if (!essayContentService.TryAdd(essay)) processedContents.Add(essay);
{
throw new BlogFileException(
$"There are two essays with the same name: '{essay.FileName}'.");
}
}); });
List<BlogEssay> result = processedContents.ToList();
result.Sort();
return result;
} }
[GeneratedRegex(@"(?<!\\)[^\#\*_\-\+\`{}\[\]!~]+")] [GeneratedRegex(@"(?<!\\)[^\#\*_\-\+\`{}\[\]!~]+")]

View File

@ -1,46 +0,0 @@
using System.Diagnostics;
using Microsoft.Extensions.Options;
using YaeBlog.Models;
namespace YaeBlog.Services;
/// <summary>
/// 在应用程序运行的过程中启动Tailwind watch
/// 在程序退出时自动结束进程
/// 只在Development模式下启动
/// </summary>
public sealed class TailwindRefreshService(
IOptions<TailwindOptions> options,
ProcessInteropService processInteropService,
IHostEnvironment hostEnvironment,
ILogger<TailwindRefreshService> logger) : IHostedService, IDisposable
{
private Process? _tailwindProcess;
public Task StartAsync(CancellationToken cancellationToken)
{
if (!hostEnvironment.IsDevelopment())
{
return Task.CompletedTask;
}
logger.LogInformation("Try to start tailwind watcher with input {} and output {}", options.Value.InputFile,
options.Value.OutputFile);
_tailwindProcess = processInteropService.StartProcess("pnpm",
$"tailwind -i {options.Value.InputFile} -o {options.Value.OutputFile} --watch");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_tailwindProcess?.Kill();
return Task.CompletedTask;
}
public void Dispose()
{
_tailwindProcess?.Dispose();
}
}