feat: 从Bootstrap迁移到Tailwind css #9
|
@ -5,7 +5,9 @@ namespace YaeBlog.Abstraction;
|
|||
|
||||
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; }
|
||||
|
||||
|
@ -16,6 +18,8 @@ public interface IEssayContentService
|
|||
|
||||
public bool TryAdd(BlogEssay essay);
|
||||
|
||||
public bool TryGetEssay(string filename, [NotNullWhen(true)] out BlogEssay? essay);
|
||||
|
||||
public void RefreshTags();
|
||||
|
||||
public void Clear();
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
@(Contents.Essays.Count)
|
||||
@(Contents.Count)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
}
|
||||
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"/>
|
||||
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -7,4 +7,6 @@ public class BlogContent
|
|||
public required MarkdownMetadata Metadata { get; init; }
|
||||
|
||||
public required string FileContent { get; set; }
|
||||
|
||||
public bool IsDraft { get; set; } = false;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@ public class BlogEssay : IComparable<BlogEssay>
|
|||
|
||||
public required string FileName { get; init; }
|
||||
|
||||
public required bool IsDraft { get; init; }
|
||||
|
||||
public required DateTime PublishTime { get; init; }
|
||||
|
||||
public required string Description { get; init; }
|
||||
|
@ -24,6 +26,7 @@ public class BlogEssay : IComparable<BlogEssay>
|
|||
{
|
||||
Title = Title,
|
||||
FileName = FileName,
|
||||
IsDraft = IsDraft,
|
||||
PublishTime = PublishTime,
|
||||
Description = Description,
|
||||
WordCount = WordCount,
|
||||
|
@ -39,10 +42,16 @@ public class BlogEssay : IComparable<BlogEssay>
|
|||
{
|
||||
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()
|
||||
|
|
|
@ -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; }
|
||||
}
|
|
@ -19,7 +19,7 @@
|
|||
</span>
|
||||
</div>
|
||||
|
||||
@foreach (IGrouping<DateTime, KeyValuePair<string, BlogEssay>> group in _essays)
|
||||
@foreach (IGrouping<DateTime, BlogEssay> group in _essays)
|
||||
{
|
||||
<div class="p-2">
|
||||
<div class="flex flex-col">
|
||||
|
@ -28,9 +28,9 @@
|
|||
</div>
|
||||
|
||||
<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="w-20">
|
||||
@(essay.PublishTime.ToString("MM月dd日"))
|
||||
|
@ -51,14 +51,13 @@
|
|||
</div>
|
||||
|
||||
@code {
|
||||
private readonly List<IGrouping<DateTime, KeyValuePair<string, BlogEssay>>> _essays = [];
|
||||
private readonly List<IGrouping<DateTime, BlogEssay>> _essays = [];
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
base.OnInitialized();
|
||||
|
||||
_essays.AddRange(from essay in Contents.Essays
|
||||
orderby essay.Value.PublishTime descending
|
||||
group essay by new DateTime(essay.Value.PublishTime.Year, 1, 1));
|
||||
group essay by new DateTime(essay.PublishTime.Year, 1, 1));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,9 +12,9 @@
|
|||
<div>
|
||||
<div class="grid grid-cols-4">
|
||||
<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"/>
|
||||
|
@ -30,7 +30,7 @@
|
|||
|
||||
[SupplyParameterFromQuery] private int? Page { get; set; }
|
||||
|
||||
private readonly List<KeyValuePair<string, BlogEssay>> _essays = [];
|
||||
private readonly List<BlogEssay> _essays = [];
|
||||
private const int EssaysPerPage = 8;
|
||||
private int _pageCount = 1;
|
||||
private int _page = 1;
|
||||
|
@ -38,16 +38,15 @@
|
|||
protected override void OnInitialized()
|
||||
{
|
||||
_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");
|
||||
return;
|
||||
}
|
||||
|
||||
_essays.AddRange(Contents.Essays
|
||||
.OrderByDescending(p => p.Value.PublishTime)
|
||||
.Skip((_page - 1) * EssaysPerPage)
|
||||
.Take(EssaysPerPage));
|
||||
}
|
||||
|
|
|
@ -110,7 +110,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
if (!Contents.Essays.TryGetValue(BlogKey, out _essay))
|
||||
if (!Contents.TryGetEssay(BlogKey, out _essay))
|
||||
{
|
||||
NavigationInstance.NavigateTo("/NotFound");
|
||||
}
|
||||
|
|
|
@ -6,14 +6,12 @@ public class BlogHostedService(
|
|||
{
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogInformation("Welcome to YaeBlog!");
|
||||
|
||||
logger.LogInformation("Failed to load cache, render essays.");
|
||||
await rendererService.RenderAsync();
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogInformation("YaeBlog stopped!\nHave a nice day!");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,24 +10,32 @@ public sealed class BlogHotReloadService(
|
|||
{
|
||||
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...");
|
||||
string? changFile = await watcher.WaitForChange(stoppingToken);
|
||||
|
||||
if (changFile is null)
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
logger.LogInformation("{} changed, re-rendering.", changFile);
|
||||
logger.LogInformation("{} changed, re-rendering.", changeFile);
|
||||
essayContentService.Clear();
|
||||
await rendererService.RenderAsync();
|
||||
await rendererService.RenderAsync(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,15 +9,28 @@ public class EssayContentService : IEssayContentService
|
|||
{
|
||||
private readonly ConcurrentDictionary<string, BlogEssay> _essays = new();
|
||||
|
||||
private readonly List<BlogEssay> _sortedEssays = [];
|
||||
|
||||
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 TryAdd(BlogEssay essay)
|
||||
{
|
||||
_sortedEssays.Add(essay);
|
||||
return _essays.TryAdd(essay.FileName, essay);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
|
|
@ -22,8 +22,8 @@ public partial class EssayScanService(
|
|||
ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts);
|
||||
|
||||
return new BlogContents(
|
||||
await ScanContentsInternal(drafts),
|
||||
await ScanContentsInternal(posts));
|
||||
await ScanContentsInternal(drafts, true),
|
||||
await ScanContentsInternal(posts, false));
|
||||
}
|
||||
|
||||
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结果的但是不是隐藏文件的文件
|
||||
IEnumerable<FileInfo> markdownFiles = from file in directory.EnumerateFiles()
|
||||
|
@ -97,7 +97,8 @@ public partial class EssayScanService(
|
|||
|
||||
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)
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -21,18 +21,21 @@ public partial class RendererService(
|
|||
|
||||
private readonly List<IPostRenderProcessor> _postRenderProcessors = [];
|
||||
|
||||
public async Task RenderAsync()
|
||||
public async Task RenderAsync(bool includeDrafts = false)
|
||||
{
|
||||
_stopwatch.Start();
|
||||
logger.LogInformation("Render essays start.");
|
||||
|
||||
BlogContents contents = await essayScanService.ScanContents();
|
||||
List<BlogContent> posts = contents.Posts.ToList();
|
||||
if (includeDrafts)
|
||||
{
|
||||
posts.AddRange(contents.Drafts);
|
||||
}
|
||||
|
||||
IEnumerable<BlogContent> preProcessedContents = await PreProcess(posts);
|
||||
|
||||
List<BlogEssay> essays = [];
|
||||
await Task.Run(() =>
|
||||
{
|
||||
foreach (BlogContent content in preProcessedContents)
|
||||
{
|
||||
uint wordCount = GetWordCount(content);
|
||||
|
@ -40,6 +43,7 @@ public partial class RendererService(
|
|||
{
|
||||
Title = content.Metadata.Title ?? content.FileName,
|
||||
FileName = content.FileName,
|
||||
IsDraft = content.IsDraft,
|
||||
Description = GetDescription(content),
|
||||
WordCount = wordCount,
|
||||
ReadTime = CalculateReadTime(wordCount),
|
||||
|
@ -54,7 +58,6 @@ public partial class RendererService(
|
|||
|
||||
essays.Add(essay);
|
||||
}
|
||||
});
|
||||
|
||||
ConcurrentBag<BlogEssay> postProcessEssays = [];
|
||||
Parallel.ForEach(essays, essay =>
|
||||
|
@ -66,7 +69,16 @@ public partial class RendererService(
|
|||
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();
|
||||
|
||||
_stopwatch.Stop();
|
||||
|
@ -117,8 +129,10 @@ public partial class RendererService(
|
|||
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, _) =>
|
||||
{
|
||||
foreach (IPostRenderProcessor processor in _postRenderProcessors)
|
||||
|
@ -126,12 +140,13 @@ public partial class RendererService(
|
|||
essay = await processor.ProcessAsync(essay);
|
||||
}
|
||||
|
||||
if (!essayContentService.TryAdd(essay))
|
||||
{
|
||||
throw new BlogFileException(
|
||||
$"There are two essays with the same name: '{essay.FileName}'.");
|
||||
}
|
||||
processedContents.Add(essay);
|
||||
});
|
||||
|
||||
List<BlogEssay> result = processedContents.ToList();
|
||||
result.Sort();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(?<!\\)[^\#\*_\-\+\`{}\[\]!~]+")]
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user