- 2021 - @(DateTimeOffset.Now.Year) ©
-
a.k.a jackfiled
-世界很大,时间很长。
-- 平平无奇的计算机科学与技术学徒,连微小的贡献都没做。 -
-恕我不能亲自为您沏茶(?),还是非常欢迎您能够来到我的主页。
-
- 如果您想四处看看,了解一下屏幕对面的人,可以在我的
- 如果您真的很闲,也可以四处搜寻一下,也许存在着一些不为人知的彩蛋。 -
-
+
+ - @(Options.Announcement) + @(Options.Value.Announcement)
+ 2021 - @(DateTimeOffset.Now.Year) ©
+
+ Build Commit #
+
+
如果觉得不错的话,可以支持一下作者哦~
++ 正在明光村幼儿园附属研究生院攻读计算机科学与技术的硕士学位,研究AI编译器和异构编译器。 +
+ ++ 一般在互联网上使用初冬的朝阳或者 + jackfiled的名字活动。 + (都是ICP备案过的人了,网名似乎没有太大的用处) +
++ Fun Fact:jackfiled这个名字来自于2020年我使用链接在树莓派上的9英寸屏幕注册 + GitHub的一时兴起,并没有任何特定的含义。 + 初冬的朝阳则是源自初中,具体典故已不可考。 + 至少到目前为止,还没有在要求唯一ID的平台遇见重名的情况。 + 我的真实名字似乎也是如此。 +
++ 主要是一个.NET程序员,目前也在尝试写一点Rust。 + + 总体上对于编程语言的态度是“大家都是我的翅膀.jpg”。 + +
++ 写过一些前后端分离的项目,对于RISC-V相关的开发项目也颇感兴趣。 +
++ 常常因为现实的压力而写一些C/C++,现在就在和MLIR殊死搏斗。 +
++ 日常使用Arch Linux,KISS的原则深得我心。 +
++ 100%社恐。日常生活是宅在电脑前面自言自语。 +
++ 兴趣活动是读书和看番,目前在玩戴森球计划和三角洲。2022年~2024年的时候沉迷于原神,现在偶尔还会登上去过一过剧情。 +
+
+ 本站肇始于2021年下半年,在开始的两年中个人网站和博客是分别的两个网站,个人网站是裸HTML写的,博客是用
+
+ 2024年,我们决定使用.NET技术完全重构两个网站,合二为一。虽然目前这个版本还是一个半成品,但是我们一定会努力的~(确信。 +
++ 2025年,我们将使用的样式库从Bootstrap迁移到Tailwind CSS,将现代的前端技术同Blazor结合起来。 +
+恕我不能亲自为您沏茶,还是非常欢迎您来,能在广阔的互联网世界中发现这里实属不易。
+
+ 正在攻读计算机科学与技术的硕士学位,研究方向是AI编译和异构编译!
+ 喜欢优雅的代码,香甜的蛋糕等等一切可爱的事物。
+ 更多的情报请见
+
+
+
+ 日常的代码开发使用自建的
元素样式 + // 默认的p-2间距有点太宽了 + foreach (IElement pElement in from e in liElement.Children + where e.LocalName == "p" + select e) + { + pElement.ClassList.Remove("p-2"); + pElement.ClassList.Add("p-1"); + } + } + } + } + + private static void BeatifyInlineCode(IDocument document) + { + // 选择不在
元素内的元素
+ // 即行内代码
+ IEnumerable inlineCodes = from e in document.All
+ where e.LocalName == "code" && e.EnumerateParentElements().All(p => p.LocalName != "pre")
+ select e;
+
+ foreach (IElement e in inlineCodes)
+ {
+ e.ClassList.Add("bg-gray-100 inline p-1 rounded-xs");
+ }
+ }
}
diff --git a/YaeBlog/Processors/HeadlinePostRenderProcessor.cs b/src/YaeBlog/Processors/HeadlinePostRenderProcessor.cs
similarity index 97%
rename from YaeBlog/Processors/HeadlinePostRenderProcessor.cs
rename to src/YaeBlog/Processors/HeadlinePostRenderProcessor.cs
index 955098e..bd2b76c 100644
--- a/YaeBlog/Processors/HeadlinePostRenderProcessor.cs
+++ b/src/YaeBlog/Processors/HeadlinePostRenderProcessor.cs
@@ -67,7 +67,7 @@ public class HeadlinePostRenderProcessor(
logger.LogWarning("Failed to add headline of {}.", essay.FileName);
}
- return essay.WithNewHtmlContent(document.DocumentElement.OuterHtml);
+ return essay with { HtmlContent = document.DocumentElement.OuterHtml };
}
private static BlogHeadline ParserHeadlineElement(IElement element)
diff --git a/YaeBlog/Processors/ImagePostRenderProcessor.cs b/src/YaeBlog/Processors/ImagePostRenderProcessor.cs
similarity index 69%
rename from YaeBlog/Processors/ImagePostRenderProcessor.cs
rename to src/YaeBlog/Processors/ImagePostRenderProcessor.cs
index f64a03a..75f3389 100644
--- a/YaeBlog/Processors/ImagePostRenderProcessor.cs
+++ b/src/YaeBlog/Processors/ImagePostRenderProcessor.cs
@@ -7,7 +7,14 @@ using YaeBlog.Models;
namespace YaeBlog.Processors;
-public class ImagePostRenderProcessor(ILogger logger,
+///
+/// 图片地址路径后处理器
+/// 将本地图片地址修改为图片API地址
+///
+///
+///
+public class ImagePostRenderProcessor(
+ ILogger logger,
IOptions options)
: IPostRenderProcessor
{
@@ -29,22 +36,27 @@ public class ImagePostRenderProcessor(ILogger logger,
if (attr is not null)
{
logger.LogDebug("Found image link: '{}'", attr.Value);
- attr.Value = GenerateImageLink(attr.Value, essay.FileName);
+ attr.Value = GenerateImageLink(attr.Value, essay.FileName, essay.IsDraft);
}
}
- return essay.WithNewHtmlContent(html.DocumentElement.OuterHtml);
+
+ return essay with { HtmlContent = html.DocumentElement.OuterHtml };
}
public string Name => nameof(ImagePostRenderProcessor);
- private string GenerateImageLink(string filename, string essayFilename)
+ private string GenerateImageLink(string filename, string essayFilename, bool isDraft)
{
+ // 如果图片路径中没有包含文件名
+ // 则添加文件名
if (!filename.Contains(essayFilename))
{
filename = Path.Combine(essayFilename, filename);
}
- filename = Path.Combine(_options.Root, "posts", filename);
+ filename = isDraft
+ ? Path.Combine(_options.Root, "drafts", filename)
+ : Path.Combine(_options.Root, "posts", filename);
if (!Path.Exists(filename))
{
@@ -53,7 +65,7 @@ public class ImagePostRenderProcessor(ILogger logger,
}
string imageLink = "api/files/" + filename;
- logger.LogDebug("Generate image link '{}' for image file '{}'.",
+ logger.LogDebug("Generate image link '{link}' for image file '{filename}'.",
imageLink, filename);
return imageLink;
diff --git a/src/YaeBlog/Program.cs b/src/YaeBlog/Program.cs
new file mode 100644
index 0000000..d100ee0
--- /dev/null
+++ b/src/YaeBlog/Program.cs
@@ -0,0 +1,22 @@
+using YaeBlog.Components;
+using YaeBlog.Extensions;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddRazorComponents()
+ .AddInteractiveServerComponents();
+builder.Services.AddControllers();
+builder.AddYaeBlog();
+builder.AddYaeCommand(args);
+
+WebApplication application = builder.Build();
+
+application.MapStaticAssets();
+application.UseAntiforgery();
+application.UseYaeBlog();
+
+application.MapRazorComponents()
+ .AddInteractiveServerRenderMode();
+application.MapControllers();
+
+await application.RunAsync();
diff --git a/YaeBlog/Properties/launchSettings.json b/src/YaeBlog/Properties/launchSettings.json
similarity index 100%
rename from YaeBlog/Properties/launchSettings.json
rename to src/YaeBlog/Properties/launchSettings.json
diff --git a/YaeBlog/Services/BlogChangeWatcher.cs b/src/YaeBlog/Services/BlogChangeWatcher.cs
similarity index 100%
rename from YaeBlog/Services/BlogChangeWatcher.cs
rename to src/YaeBlog/Services/BlogChangeWatcher.cs
diff --git a/YaeBlog/Services/BlogHostedService.cs b/src/YaeBlog/Services/BlogHostedService.cs
similarity index 100%
rename from YaeBlog/Services/BlogHostedService.cs
rename to src/YaeBlog/Services/BlogHostedService.cs
diff --git a/YaeBlog/Services/BlogHotReloadService.cs b/src/YaeBlog/Services/BlogHotReloadService.cs
similarity index 74%
rename from YaeBlog/Services/BlogHotReloadService.cs
rename to src/YaeBlog/Services/BlogHotReloadService.cs
index 581c1b4..80d8c69 100644
--- a/YaeBlog/Services/BlogHotReloadService.cs
+++ b/src/YaeBlog/Services/BlogHotReloadService.cs
@@ -16,11 +16,11 @@ public sealed class BlogHotReloadService(
await rendererService.RenderAsync(true);
- Task[] reloadTasks = [FileWatchTask(stoppingToken)];
+ Task[] reloadTasks = [WatchFileAsync(stoppingToken)];
await Task.WhenAll(reloadTasks);
}
- private async Task FileWatchTask(CancellationToken token)
+ private async Task WatchFileAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
@@ -33,6 +33,15 @@ public sealed class BlogHotReloadService(
break;
}
+ FileInfo changeFileInfo = new(changeFile);
+
+ if (changeFileInfo.Name.StartsWith('.'))
+ {
+ // Ignore dot-started file and directory.
+ logger.LogDebug("Ignore hidden file: {}.", changeFile);
+ continue;
+ }
+
logger.LogInformation("{} changed, re-rendering.", changeFile);
essayContentService.Clear();
await rendererService.RenderAsync(true);
diff --git a/YaeBlog/Services/EssayContentService.cs b/src/YaeBlog/Services/EssayContentService.cs
similarity index 100%
rename from YaeBlog/Services/EssayContentService.cs
rename to src/YaeBlog/Services/EssayContentService.cs
diff --git a/src/YaeBlog/Services/EssayScanService.cs b/src/YaeBlog/Services/EssayScanService.cs
new file mode 100644
index 0000000..b35404a
--- /dev/null
+++ b/src/YaeBlog/Services/EssayScanService.cs
@@ -0,0 +1,245 @@
+using System.Collections.Concurrent;
+using System.Text.RegularExpressions;
+using Imageflow.Bindings;
+using Imageflow.Fluent;
+using Microsoft.Extensions.Options;
+using YaeBlog.Abstraction;
+using YaeBlog.Core.Exceptions;
+using YaeBlog.Models;
+using YamlDotNet.Core;
+using YamlDotNet.Serialization;
+
+namespace YaeBlog.Services;
+
+public partial class EssayScanService : IEssayScanService
+{
+ private readonly BlogOptions _blogOptions;
+ private readonly ISerializer _yamlSerializer;
+ private readonly IDeserializer _yamlDeserializer;
+ private readonly ILogger _logger;
+
+ public EssayScanService(ISerializer yamlSerializer,
+ IDeserializer yamlDeserializer,
+ IOptions blogOptions,
+ ILogger logger)
+ {
+ _yamlSerializer = yamlSerializer;
+ _yamlDeserializer = yamlDeserializer;
+ _logger = logger;
+ _blogOptions = blogOptions.Value;
+ RootDirectory = ValidateRootDirectory();
+ }
+
+ private DirectoryInfo RootDirectory { get; }
+
+ public async Task ScanContents()
+ {
+ ValidateDirectory(out DirectoryInfo drafts, out DirectoryInfo posts);
+
+ return new BlogContents(
+ await ScanContentsInternal(drafts, true),
+ await ScanContentsInternal(posts, false));
+ }
+
+ public async Task SaveBlogContent(BlogContent content, bool isDraft = true)
+ {
+ ValidateDirectory(out DirectoryInfo drafts, out DirectoryInfo posts);
+
+ FileInfo targetFile = isDraft
+ ? new FileInfo(Path.Combine(drafts.FullName, content.BlogName + ".md"))
+ : new FileInfo(Path.Combine(posts.FullName, content.BlogName + ".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");
+
+ if (string.IsNullOrEmpty(content.Content) && isDraft)
+ {
+ // 如果博客为操作且内容为空
+ // 创建简介隔断符号
+ await writer.WriteLineAsync("");
+ }
+ else
+ {
+ await writer.WriteAsync(content.Content);
+ }
+
+ // 保存图片文件
+ await Task.WhenAll(from image in content.Images
+ select File.WriteAllBytesAsync(image.File.FullName, image.Content));
+ }
+
+ private record struct BlogResult(
+ FileInfo BlogFile,
+ string BlogContent,
+ List Images,
+ List NotFoundImages);
+
+ private async Task> ScanContentsInternal(DirectoryInfo directory, bool isDraft)
+ {
+ // 扫描以md结尾且不是隐藏文件的文件
+ IEnumerable markdownFiles = from file in directory.EnumerateFiles()
+ where file.Extension == ".md" && !file.Name.StartsWith('.')
+ select file;
+
+ ConcurrentBag fileContents = [];
+
+ await Parallel.ForEachAsync(markdownFiles, async (file, token) =>
+ {
+ using StreamReader reader = file.OpenText();
+ string blogName = file.Name.Split('.')[0];
+ string blogContent = await reader.ReadToEndAsync(token);
+ ImageResult imageResult =
+ await ScanImagePreBlog(directory, blogName,
+ blogContent);
+
+ fileContents.Add(new BlogResult(file, blogContent, imageResult.Images, imageResult.NotfoundImages));
+ });
+
+ ConcurrentBag contents = [];
+
+ await Task.Run(() =>
+ {
+ foreach (BlogResult blog in fileContents)
+ {
+ if (blog.BlogContent.Length < 4)
+ {
+ // Even not contains a legal header.
+ continue;
+ }
+
+ int endPos = blog.BlogContent.IndexOf("---", 4, StringComparison.Ordinal);
+ if (!blog.BlogContent.StartsWith("---") || endPos is -1 or 0)
+ {
+ _logger.LogWarning("Failed to parse metadata from {}, skipped.", blog.BlogFile.Name);
+ return;
+ }
+
+ string metadataString = blog.BlogContent[4..endPos];
+
+ try
+ {
+ MarkdownMetadata metadata = _yamlDeserializer.Deserialize(metadataString);
+ _logger.LogDebug("Scan metadata title: '{title}' for {name}.", metadata.Title, blog.BlogFile.Name);
+
+ contents.Add(new BlogContent(blog.BlogFile, metadata, blog.BlogContent[(endPos + 3)..], isDraft,
+ blog.Images, blog.NotFoundImages));
+ }
+ catch (YamlException e)
+ {
+ _logger.LogWarning("Failed to parser metadata from {name} due to {exception}, skipping",
+ blog.BlogFile.Name, e);
+ }
+ }
+ });
+
+ return contents;
+ }
+
+ private record struct ImageResult(List Images, List NotfoundImages);
+
+ private async Task ScanImagePreBlog(DirectoryInfo directory, string blogName, string content)
+ {
+ DirectoryInfo imageDirectory = new(Path.Combine(directory.FullName, blogName));
+
+ Dictionary usedImages = imageDirectory.Exists
+ ? imageDirectory.EnumerateFiles().ToDictionary(file => file.FullName, _ => false)
+ : [];
+ List notFoundImages = [];
+
+ // 同时扫描markdown格式和HTML格式的图片
+ MatchCollection markdownMatchResult = MarkdownImagePattern.Matches(content);
+ MatchCollection htmlMatchResult = HtmlImagePattern.Matches(content);
+
+ IEnumerable imageNames = from match in markdownMatchResult.Concat(htmlMatchResult)
+ select match.Groups[1].Value;
+
+ foreach (string imageName in imageNames)
+ {
+ // 判断md文件中的图片名称中是否包含文件夹名称
+ // 例如 blog-1/image.png 或者 image.png
+ // 如果不带文件夹名称
+ // 默认添加同博客名文件夹
+ FileInfo usedFile = imageName.Contains(blogName)
+ ? new FileInfo(Path.Combine(directory.FullName, imageName))
+ : new FileInfo(Path.Combine(directory.FullName, blogName, imageName));
+
+ if (usedImages.TryGetValue(usedFile.FullName, out _))
+ {
+ usedImages[usedFile.FullName] = true;
+ }
+ else
+ {
+ notFoundImages.Add(usedFile);
+ }
+ }
+
+ List images = (await Task.WhenAll((from pair in usedImages
+ select GetImageInfo(new FileInfo(pair.Key), pair.Value)).ToArray())).ToList();
+
+ return new ImageResult(images, notFoundImages);
+ }
+
+ private static async Task GetImageInfo(FileInfo file, bool isUsed)
+ {
+ byte[] image = await File.ReadAllBytesAsync(file.FullName);
+
+ if (file.Extension is ".jpg" or ".jpeg" or ".png")
+ {
+ ImageInfo imageInfo =
+ await ImageJob.GetImageInfoAsync(MemorySource.Borrow(image), SourceLifetime.NowOwnedAndDisposedByTask);
+
+ return new BlogImageInfo(file, imageInfo.ImageWidth, imageInfo.ImageWidth, imageInfo.PreferredMimeType,
+ image, isUsed);
+ }
+
+ return new BlogImageInfo(file, 0, 0, file.Extension switch
+ {
+ "svg" => "image/svg",
+ "avif" => "image/avif",
+ _ => string.Empty
+ }, image, isUsed);
+ }
+
+ [GeneratedRegex(@"\!\[.*?\]\((.*?)\)")]
+ private static partial Regex MarkdownImagePattern { get; }
+
+ [GeneratedRegex("""
]*?src\s*=\s*["']([^"']*)["'][^>]*>""")]
+ private static partial Regex HtmlImagePattern { get; }
+
+
+ private DirectoryInfo ValidateRootDirectory()
+ {
+ DirectoryInfo rootDirectory = new(Path.Combine(Environment.CurrentDirectory, _blogOptions.Root));
+
+ if (!rootDirectory.Exists)
+ {
+ throw new BlogFileException($"'{_blogOptions.Root}' is not a directory.");
+ }
+
+ return rootDirectory;
+ }
+
+ private void ValidateDirectory(out DirectoryInfo drafts, out DirectoryInfo posts)
+ {
+ if (RootDirectory.EnumerateDirectories().All(dir => dir.Name != "drafts"))
+ {
+ throw new BlogFileException($"'{_blogOptions.Root}/drafts' not exists.");
+ }
+
+ if (RootDirectory.EnumerateDirectories().All(dir => dir.Name != "posts"))
+ {
+ throw new BlogFileException($"'{_blogOptions.Root}/posts' not exists.");
+ }
+
+ drafts = new DirectoryInfo(Path.Combine(_blogOptions.Root, "drafts"));
+ posts = new DirectoryInfo(Path.Combine(_blogOptions.Root, "posts"));
+ }
+}
diff --git a/src/YaeBlog/Services/GitHeatMapService.cs b/src/YaeBlog/Services/GitHeatMapService.cs
new file mode 100644
index 0000000..eae9f55
--- /dev/null
+++ b/src/YaeBlog/Services/GitHeatMapService.cs
@@ -0,0 +1,115 @@
+using DotNext;
+using Microsoft.Extensions.Options;
+using YaeBlog.Extensions;
+using YaeBlog.Models;
+
+namespace YaeBlog.Services;
+
+public sealed class GitHeapMapService(IServiceProvider serviceProvider, IOptions giteaOptions,
+ ILogger logger)
+{
+ ///
+ /// 存储贡献列表
+ /// 贡献列表采用懒加载和缓存机制,一天之内只请求一次Gitea服务器获得数据并缓存
+ ///
+ private List _gitContributionsGroupedByWeek = [];
+
+ ///
+ /// 最后一次更新贡献列表的时间
+ ///
+ private DateOnly _updateTime = DateOnly.MinValue;
+
+ public async Task> GetGitContributionGroupedByWeek()
+ {
+ DateOnly today = DateOnly.FromDateTime(DateTimeOffset.Now.DateTime);
+ if (_updateTime == today)
+ {
+ logger.LogDebug("Git contribution grouped by week cache is hit.");
+ return _gitContributionsGroupedByWeek;
+ }
+
+ // 今天尚未更新
+ // 更新一下
+ GiteaFetchService giteaFetchService = serviceProvider.GetRequiredService();
+ Result> r =
+ await giteaFetchService.FetchGiteaContributions(giteaOptions.Value.HeatMapUsername);
+
+ if (!r.TryGet(out List? items))
+ {
+ logger.LogError("Failed to fetch heatmap data: {}", r.Error);
+ return _gitContributionsGroupedByWeek;
+ }
+
+ // The contribution is not grouped by day, so group them.
+ IEnumerable groupedItems = items
+ .GroupBy(i => i.Time)
+ .Select(group => new GitContributionItem(group.Key,
+ group.Select(i => i.ContributionCount).Sum()));
+
+ List result = new(52);
+
+ // Consider the input data is in order.
+ // Start should be one year ago.
+ GitContributionGroupedByWeek groupedContribution = new(DateOnly.Today.AddDays(-365 - 7).LastMonday, []);
+ logger.LogDebug("Create new item group by week {}.", groupedContribution.Monday);
+
+ foreach ((DateOnly date, long contributions) in groupedItems)
+ {
+ DateOnly mondayOfItem = date.LastMonday;
+ logger.LogDebug("Current date of item: {item}, monday is {monday}", date, mondayOfItem);
+
+ // If current item is in the same week of last item.
+ if (mondayOfItem == groupedContribution.Monday)
+ {
+ // Fill the spacing of empty days with 0 contribution.
+ FillSpacing(groupedContribution, date);
+
+ groupedContribution.Contributions.Add(new GitContributionItem(date, contributions));
+ continue;
+ }
+
+ // Current time is in the next (or much more) week of last item.
+ // Fill the spacing, including the last week inner spacing and outer spacing.
+ while (groupedContribution.Monday < mondayOfItem)
+ {
+ FillSpacing(groupedContribution, date);
+ result.Add(groupedContribution);
+ groupedContribution = new GitContributionGroupedByWeek(groupedContribution.Monday.AddDays(7), []);
+ logger.LogDebug("Create new item group by week {}.", groupedContribution.Monday);
+ }
+
+ // Now, the inner spacing of one week.
+ FillSpacing(groupedContribution, date);
+ groupedContribution.Contributions.Add(new GitContributionItem(date, contributions));
+ }
+
+ // Not fill the last item and add directly.
+ result.Add(groupedContribution);
+
+ _gitContributionsGroupedByWeek = result;
+ _updateTime = DateOnly.Today;
+
+ return _gitContributionsGroupedByWeek;
+ }
+
+ private static void FillSpacing(GitContributionGroupedByWeek contribution, in DateOnly date)
+ {
+ if (contribution.Monday == date)
+ {
+ return;
+ }
+
+ if (contribution.Contributions.Count == 0)
+ {
+ contribution.Contributions.Add(new GitContributionItem(contribution.Monday, 0));
+ }
+
+ DateOnly lastDate = contribution.Contributions.Last().Time;
+ // The day in one week is 7, so th count of items of one week should not bigger than 7.
+ while (contribution.Contributions.Count < 7 && lastDate < date.AddDays(-1))
+ {
+ lastDate = lastDate.AddDays(1);
+ contribution.Contributions.Add(new GitContributionItem(lastDate, 0));
+ }
+ }
+}
diff --git a/src/YaeBlog/Services/GiteaFetchService.cs b/src/YaeBlog/Services/GiteaFetchService.cs
new file mode 100644
index 0000000..18ab068
--- /dev/null
+++ b/src/YaeBlog/Services/GiteaFetchService.cs
@@ -0,0 +1,63 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using DotNext;
+using Microsoft.Extensions.Options;
+using YaeBlog.Core.Exceptions;
+using YaeBlog.Models;
+
+namespace YaeBlog.Services;
+
+public sealed class GiteaFetchService
+{
+ private readonly HttpClient _httpClient;
+
+ private static readonly JsonSerializerOptions s_serializerOptions = new()
+ {
+ PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ RespectRequiredConstructorParameters = true, RespectNullableAnnotations = true
+ };
+
+ ///
+ /// For test only.
+ ///
+ internal GiteaFetchService(IOptions giteaOptions, HttpClient httpClient)
+ {
+ _httpClient = httpClient;
+
+ _httpClient.BaseAddress = new Uri(giteaOptions.Value.BaseAddress);
+ _httpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", giteaOptions.Value.ApiKey);
+ }
+
+ public GiteaFetchService(IOptions giteaOptions, IHttpClientFactory httpClientFactory) : this(
+ giteaOptions, httpClientFactory.CreateClient())
+ {
+ }
+
+ private record UserHeatmapData(long Contributions, long Timestamp);
+
+ public async Task>> FetchGiteaContributions(string username)
+ {
+ try
+ {
+ List? data =
+ await _httpClient.GetFromJsonAsync>($"users/{username}/heatmap",
+ s_serializerOptions);
+
+ if (data is null or { Count: 0 })
+ {
+ return Result.FromException>(
+ new GiteaFetchException("Failed to fetch valid data."));
+ }
+
+ return Result.FromValue(data.Select(i =>
+ new GitContributionItem(DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeSeconds(i.Timestamp).DateTime),
+ i.Contributions)).ToList());
+ }
+ catch (HttpRequestException exception)
+ {
+ return Result.FromException>(new GiteaFetchException("Failed to fetch.",
+ exception));
+ }
+ }
+}
diff --git a/src/YaeBlog/Services/ImageCompressService.cs b/src/YaeBlog/Services/ImageCompressService.cs
new file mode 100644
index 0000000..831e230
--- /dev/null
+++ b/src/YaeBlog/Services/ImageCompressService.cs
@@ -0,0 +1,119 @@
+using Imageflow.Fluent;
+using YaeBlog.Abstraction;
+using YaeBlog.Core.Exceptions;
+using YaeBlog.Models;
+
+namespace YaeBlog.Services;
+
+public sealed class ImageCompressService(IEssayScanService essayScanService, ILogger logger)
+{
+ private record struct CompressResult(BlogImageInfo ImageInfo, byte[] CompressContent);
+
+ public async Task> ScanUsedImages()
+ {
+ BlogContents contents = await essayScanService.ScanContents();
+ List originalImages = (from content in contents.Posts.Concat(contents.Drafts)
+ from image in content.Images
+ where image.IsUsed
+ select image).ToList();
+
+ originalImages.Sort();
+
+ return originalImages;
+ }
+
+ public async Task Compress(bool dryRun)
+ {
+ BlogContents contents = await essayScanService.ScanContents();
+
+ // 筛选需要压缩的图片
+ // 即图片被博客使用且是jpeg/png格式
+ List needCompressContents = (from content in contents
+ where content.Images.Any(i => i is { IsUsed: true } and { File.Extension: ".jpg" or ".jpeg" or ".png" })
+ select content).ToList();
+
+ if (needCompressContents.Count == 0)
+ {
+ return;
+ }
+
+ int uncompressedSize = 0;
+ int compressedSize = 0;
+ List compressedContent = new(needCompressContents.Count);
+
+ foreach (BlogContent content in needCompressContents)
+ {
+ List uncompressedImages = (from image in content.Images
+ where image is { IsUsed: true } and { File.Extension: ".jpg" or ".jpeg" or ".png" }
+ select image).ToList();
+
+ uncompressedSize += uncompressedImages.Select(i => i.Size).Sum();
+
+ foreach (BlogImageInfo image in uncompressedImages)
+ {
+ logger.LogInformation("Uncompressed image: {} belonging to blog {}.", image.File.Name,
+ content.BlogName);
+ }
+
+ CompressResult[] compressedImages = (await Task.WhenAll(from image in uncompressedImages
+ select Task.Run(async () => new CompressResult(image, await ConvertToWebp(image))))).ToArray();
+
+ compressedSize += compressedImages.Select(i => i.CompressContent.Length).Sum();
+
+ // 直接在原有的图片列表上添加图片
+ List images = content.Images.Concat(from r in compressedImages
+ select r.ImageInfo with
+ {
+ File = new FileInfo(r.ImageInfo.File.FullName.Split('.')[0] + ".webp"),
+ Content = r.CompressContent,
+ MineType = "image/webp"
+ }).ToList();
+ // 修改文本
+ string blogContent = compressedImages.Aggregate(content.Content, (c, r) =>
+ {
+ string originalName = r.ImageInfo.File.Name;
+ string outputName = originalName.Split('.')[0] + ".webp";
+
+ return c.Replace(originalName, outputName);
+ });
+
+ compressedContent.Add(content with { Images = images, Content = blogContent });
+ }
+
+ logger.LogInformation("Compression ratio: {}%.", (double)compressedSize / uncompressedSize * 100.0);
+
+ if (dryRun is false)
+ {
+ await Task.WhenAll(from content in compressedContent
+ select essayScanService.SaveBlogContent(content, content.IsDraft));
+ }
+ }
+
+ private static async Task ConvertToWebp(BlogImageInfo image)
+ {
+ using ImageJob job = new();
+ BuildJobResult result = await job.Decode(MemorySource.Borrow(image.Content))
+ .Branch(f => f.EncodeToBytes(new WebPLosslessEncoder()))
+ .EncodeToBytes(new WebPLossyEncoder(75))
+ .Finish()
+ .InProcessAsync();
+
+ // 超过128KB的图片使用有损压缩
+ // 反之使用无损压缩
+
+ ArraySegment? losslessImage = result.TryGet(1)?.TryGetBytes();
+ ArraySegment? lossyImage = result.TryGet(2)?.TryGetBytes();
+
+ if (image.Size <= 128 * 1024 && losslessImage.HasValue)
+ {
+ return losslessImage.Value.ToArray();
+ }
+
+ if (lossyImage.HasValue)
+ {
+ return lossyImage.Value.ToArray();
+ }
+
+ throw new BlogCommandException($"Failed to convert {image.File.Name} to webp format: return value is null.");
+ }
+}
diff --git a/src/YaeBlog/Services/MarkdownWordCounter.cs b/src/YaeBlog/Services/MarkdownWordCounter.cs
new file mode 100644
index 0000000..b5d3a84
--- /dev/null
+++ b/src/YaeBlog/Services/MarkdownWordCounter.cs
@@ -0,0 +1,62 @@
+using YaeBlog.Extensions;
+using YaeBlog.Models;
+
+namespace YaeBlog.Services
+{
+ public class MarkdownWordCounter
+ {
+ private bool _inCodeBlock;
+ private int _index;
+ private readonly string _content;
+
+ private uint WordCount { get; set; }
+
+ private MarkdownWordCounter(BlogContent content)
+ {
+ _content = content.Content;
+ }
+
+ private void CountWordInner()
+ {
+ while (_index < _content.Length)
+ {
+ if (IsCodeBlockTag())
+ {
+ _inCodeBlock = !_inCodeBlock;
+ }
+
+ if (!_inCodeBlock && char.IsLetterOrDigit(_content, _index))
+ {
+ WordCount += 1;
+ }
+
+ _index++;
+ }
+ }
+
+ private bool IsCodeBlockTag()
+ {
+ // 首先考虑识别代码块
+ bool outerCodeBlock =
+ Enumerable.Range(0, 3)
+ .Select(i => _index + i < _content.Length && _content.AsSpan().Slice(_index + i, 1) is "`")
+ .All(i => i);
+
+ if (outerCodeBlock)
+ {
+ return true;
+ }
+
+ // 然后识别行内代码
+ return _index < _content.Length && _content.AsSpan().Slice(_index, 1) is "`";
+ }
+
+ public static uint CountWord(BlogContent content)
+ {
+ MarkdownWordCounter counter = new(content);
+ counter.CountWordInner();
+
+ return counter.WordCount;
+ }
+ }
+}
diff --git a/YaeBlog/Services/RendererService.cs b/src/YaeBlog/Services/RendererService.cs
similarity index 69%
rename from YaeBlog/Services/RendererService.cs
rename to src/YaeBlog/Services/RendererService.cs
index dda44ee..a4054d9 100644
--- a/YaeBlog/Services/RendererService.cs
+++ b/src/YaeBlog/Services/RendererService.cs
@@ -9,7 +9,7 @@ using YaeBlog.Models;
namespace YaeBlog.Services;
-public partial class RendererService(
+public sealed partial class RendererService(
ILogger logger,
IEssayScanService essayScanService,
MarkdownPipeline markdownPipeline,
@@ -34,42 +34,32 @@ public partial class RendererService(
}
IEnumerable preProcessedContents = await PreProcess(posts);
+ ConcurrentBag essays = [];
- List essays = [];
- foreach (BlogContent content in preProcessedContents)
+ Parallel.ForEach(preProcessedContents, content =>
{
- uint wordCount = GetWordCount(content);
- BlogEssay essay = new()
- {
- Title = content.Metadata.Title ?? content.FileName,
- FileName = content.FileName,
- IsDraft = content.IsDraft,
- Description = GetDescription(content),
- WordCount = wordCount,
- ReadTime = CalculateReadTime(wordCount),
- PublishTime = content.Metadata.Date ?? DateTime.Now,
- HtmlContent = content.FileContent
- };
+ (uint wordCount, string readTime) = GetWordCount(content);
+ DateTimeOffset publishDate = content.Metadata.Date is null
+ ? DateTimeOffset.Now
+ : DateTimeOffset.Parse(content.Metadata.Date);
+ // 如果不存在最后的更新时间,就把更新时间设置为发布时间
+ DateTimeOffset updateTime = content.Metadata.UpdateTime is null
+ ? publishDate
+ : DateTimeOffset.Parse(content.Metadata.UpdateTime);
+ string description = GetDescription(content);
+ List tags = content.Metadata.Tags ?? [];
- if (content.Metadata.Tags is not null)
- {
- essay.Tags.AddRange(content.Metadata.Tags);
- }
+ string originalHtml = Markdown.ToHtml(content.Content, markdownPipeline);
+
+ BlogEssay essay = new(
+ content.Metadata.Title ?? content.BlogName, content.BlogName, content.IsDraft, publishDate, updateTime,
+ description, wordCount, readTime, tags, originalHtml);
+ logger.LogDebug("Render essay: {}", essay);
essays.Add(essay);
- }
-
- ConcurrentBag postProcessEssays = [];
- Parallel.ForEach(essays, essay =>
- {
- BlogEssay newEssay =
- essay.WithNewHtmlContent(Markdown.ToHtml(essay.HtmlContent, markdownPipeline));
-
- postProcessEssays.Add(newEssay);
- logger.LogDebug("Render markdown file {}.", newEssay);
});
- IEnumerable postProcessedEssays = await PostProcess(postProcessEssays);
+ IEnumerable postProcessedEssays = await PostProcess(essays);
foreach (BlogEssay essay in postProcessedEssays)
{
@@ -156,17 +146,17 @@ public partial class RendererService(
private string GetDescription(BlogContent content)
{
const string delimiter = "";
- int pos = content.FileContent.IndexOf(delimiter, StringComparison.Ordinal);
+ int pos = content.Content.IndexOf(delimiter, StringComparison.Ordinal);
bool breakSentence = false;
if (pos == -1)
{
// 自动截取前50个字符
- pos = content.FileContent.Length < 50 ? content.FileContent.Length : 50;
+ pos = content.Content.Length < 50 ? content.Content.Length : 50;
breakSentence = true;
}
- string rawContent = content.FileContent[..pos];
+ string rawContent = content.Content[..pos];
MatchCollection matches = DescriptionPattern.Matches(rawContent);
StringBuilder builder = new();
@@ -182,28 +172,21 @@ public partial class RendererService(
string description = builder.ToString();
- logger.LogDebug("Description of {} is {}.", content.FileName,
+ logger.LogDebug("Description of {name} is {desc}.", content.BlogName,
description);
return description;
}
- private uint GetWordCount(BlogContent content)
+ private (uint, string) GetWordCount(BlogContent content)
{
- int count = (from c in content.FileContent
- where char.IsLetterOrDigit(c)
- select c).Count();
+ uint count = MarkdownWordCounter.CountWord(content);
- logger.LogDebug("Word count of {} is {}", content.FileName,
+ logger.LogDebug("Word count of {blog} is {count}", content.BlogName,
count);
- return (uint)count;
- }
-
- private static string CalculateReadTime(uint wordCount)
- {
// 据说语文教学大纲规定,中国高中生阅读现代文的速度是600字每分钟
- int second = (int)wordCount / 10;
- TimeSpan span = new(0, 0, second);
+ uint second = count / 10;
+ TimeSpan span = new(0, 0, (int)second);
- return span.ToString("mm'分 'ss'秒'");
+ return (count, span.ToString("mm'分'ss'秒'"));
}
}
diff --git a/src/YaeBlog/Services/YaeCommandService.cs b/src/YaeBlog/Services/YaeCommandService.cs
new file mode 100644
index 0000000..e4dd2ed
--- /dev/null
+++ b/src/YaeBlog/Services/YaeCommandService.cs
@@ -0,0 +1,281 @@
+using System.CommandLine;
+using System.CommandLine.Invocation;
+using System.Text;
+using Microsoft.Extensions.Options;
+using YaeBlog.Abstraction;
+using YaeBlog.Core.Exceptions;
+using YaeBlog.Models;
+
+namespace YaeBlog.Services;
+
+public class YaeCommandService(
+ string[] arguments,
+ IEssayScanService essayScanService,
+ IServiceProvider serviceProvider,
+ IOptions blogOptions,
+ ILogger logger,
+ IHostApplicationLifetime applicationLifetime)
+ : IHostedService
+{
+ private readonly BlogOptions _blogOptions = blogOptions.Value;
+ private bool _oneShotCommandFlag = true;
+
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ RootCommand rootCommand = new("YaeBlog CLI");
+
+ RegisterServeCommand(rootCommand);
+ RegisterWatchCommand(rootCommand, cancellationToken);
+
+ RegisterNewCommand(rootCommand);
+ RegisterUpdateCommand(rootCommand);
+ RegisterScanCommand(rootCommand);
+ RegisterPublishCommand(rootCommand);
+ RegisterCompressCommand(rootCommand);
+
+ int exitCode = await rootCommand.InvokeAsync(arguments);
+
+ if (exitCode != 0)
+ {
+ throw new BlogCommandException($"YaeBlog command exited with no-zero code {exitCode}");
+ }
+
+ if (_oneShotCommandFlag)
+ {
+ applicationLifetime.StopApplication();
+ }
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ private void RegisterServeCommand(RootCommand rootCommand)
+ {
+ Command command = new("serve", "Start http server.");
+ rootCommand.AddCommand(command);
+
+ command.SetHandler(HandleServeCommand);
+
+ // When invoking the root command without sub command, fallback to serve command.
+ rootCommand.SetHandler(HandleServeCommand);
+ }
+
+ private async Task HandleServeCommand(InvocationContext context)
+ {
+ _oneShotCommandFlag = false;
+
+ logger.LogInformation("Failed to load cache, re-render essays.");
+ RendererService rendererService = serviceProvider.GetRequiredService();
+ await rendererService.RenderAsync();
+ }
+
+ private void RegisterWatchCommand(RootCommand rootCommand, CancellationToken cancellationToken)
+ {
+ Command command = new("watch", "Start a blog watcher that re-render when file changes.");
+ rootCommand.AddCommand(command);
+
+ command.SetHandler(async _ =>
+ {
+ _oneShotCommandFlag = false;
+
+ // BlogHotReloadService is derived from BackgroundService, but we do not let framework trigger it.
+ BlogHotReloadService blogHotReloadService = serviceProvider.GetRequiredService();
+ await blogHotReloadService.StartAsync(cancellationToken);
+ });
+ }
+
+ private void RegisterNewCommand(RootCommand rootCommand)
+ {
+ Command command = new("new", "Create a new blog file and image directory.");
+ rootCommand.AddCommand(command);
+
+ Argument filenameArgument = new(name: "blog name", description: "The created blog filename.");
+ command.AddArgument(filenameArgument);
+
+ command.SetHandler(HandleNewCommand, filenameArgument);
+ }
+
+ private async Task HandleNewCommand(string filename)
+ {
+ BlogContents contents = await essayScanService.ScanContents();
+
+ if (contents.Posts.Any(content => content.BlogName == filename))
+ {
+ throw new BlogCommandException("There exits the same title blog in posts.");
+ }
+
+ await essayScanService.SaveBlogContent(new BlogContent(
+ new FileInfo(Path.Combine(_blogOptions.Root, "drafts", filename + ".md")),
+ new MarkdownMetadata
+ {
+ Title = filename,
+ Date = DateTimeOffset.Now.ToString("o"),
+ UpdateTime = DateTimeOffset.Now.ToString("o")
+ },
+ string.Empty, true, [], []
+ ));
+
+ logger.LogInformation("Create new blog '{}'", filename);
+ }
+
+ private void RegisterUpdateCommand(RootCommand rootCommand)
+ {
+ Command command = new("update", "Update the blog essay.");
+ rootCommand.AddCommand(command);
+
+ Argument filenameArgument = new(name: "blog name", description: "The blog filename to update.");
+ command.AddArgument(filenameArgument);
+
+ command.SetHandler(HandleUpdateCommand, filenameArgument);
+ }
+
+ private async Task HandleUpdateCommand(string filename)
+ {
+ logger.LogInformation("The update command only considers published blogs.");
+ BlogContents contents = await essayScanService.ScanContents();
+
+ BlogContent? content = contents.Posts.FirstOrDefault(c => c.BlogName == filename);
+ if (content is null)
+ {
+ throw new BlogCommandException($"Target essay {filename} is not exist.");
+ }
+
+ content.Metadata.UpdateTime = DateTimeOffset.Now.ToString("o");
+ await essayScanService.SaveBlogContent(content, content.IsDraft);
+ logger.LogInformation("Update time of essay '{}' updated.", content.BlogName);
+ }
+
+ private void RegisterScanCommand(RootCommand rootCommand)
+ {
+ Command command = new("scan", "Scan unused and not found images.");
+ rootCommand.AddCommand(command);
+
+ Option removeOption =
+ new(name: "--rm", description: "Remove unused images.", getDefaultValue: () => false);
+ command.AddOption(removeOption);
+
+ command.SetHandler(HandleScanCommand, removeOption);
+ }
+
+ private async Task HandleScanCommand(bool removeUnusedImages)
+ {
+ BlogContents contents = await essayScanService.ScanContents();
+ List unusedImages = (from content in contents
+ from image in content.Images
+ where image is { IsUsed: false }
+ select image).ToList();
+
+ if (unusedImages.Count != 0)
+ {
+ StringBuilder builder = new();
+ builder.Append("Found unused images:").Append('\n');
+
+ foreach (BlogImageInfo image in unusedImages)
+ {
+ builder.Append('\t').Append("- ").Append(image.File.FullName).Append('\n');
+ }
+
+ logger.LogInformation("{}", builder.ToString());
+ logger.LogInformation("HINT: use '--rm' to remove unused images.");
+ }
+
+ if (removeUnusedImages)
+ {
+ foreach (BlogImageInfo image in unusedImages)
+ {
+ image.File.Delete();
+ }
+ }
+
+ StringBuilder infoBuilder = new();
+ infoBuilder.Append("Used not existed images:\n");
+
+ bool flag = false;
+ foreach (BlogContent content in contents)
+ {
+ foreach (FileInfo file in content.NotfoundImages)
+ {
+ flag = true;
+ infoBuilder.Append('\t').Append("- ").Append(file.Name).Append(" in ").Append(content.BlogName)
+ .Append('\n');
+ }
+ }
+
+ if (flag)
+ {
+ logger.LogInformation("{}", infoBuilder.ToString());
+ }
+ }
+
+ private void RegisterPublishCommand(RootCommand rootCommand)
+ {
+ Command command = new("publish", "Publish a new blog file.");
+ rootCommand.AddCommand(command);
+
+ Argument filenameArgument = new(name: "blog name", description: "The published blog filename.");
+ command.AddArgument(filenameArgument);
+
+ command.SetHandler(HandlePublishCommand, filenameArgument);
+ }
+
+ private async Task HandlePublishCommand(string filename)
+ {
+ BlogContents contents = await essayScanService.ScanContents();
+
+ BlogContent? content = (from blog in contents.Drafts
+ where blog.BlogName == filename
+ select blog).FirstOrDefault();
+
+ if (content is null)
+ {
+ throw new BlogCommandException("Target blog doest not exist.");
+ }
+
+ logger.LogInformation("Publish blog {}", content.BlogName);
+
+ // 设置发布的时间
+ content.Metadata.Date = DateTimeOffset.Now.ToString("o");
+ content.Metadata.UpdateTime = DateTimeOffset.Now.ToString("o");
+
+ // 将选中的博客文件复制到posts
+ await essayScanService.SaveBlogContent(content, isDraft: false);
+
+ // 复制图片文件夹
+ DirectoryInfo sourceImageDirectory =
+ new(Path.Combine(blogOptions.Value.Root, "drafts", content.BlogName));
+ DirectoryInfo targetImageDirectory =
+ new(Path.Combine(blogOptions.Value.Root, "posts", content.BlogName));
+
+ if (sourceImageDirectory.Exists)
+ {
+ targetImageDirectory.Create();
+ foreach (FileInfo file in sourceImageDirectory.EnumerateFiles())
+ {
+ file.CopyTo(Path.Combine(targetImageDirectory.FullName, file.Name), true);
+ }
+
+ sourceImageDirectory.Delete(true);
+ }
+
+ // 删除原始的文件
+ FileInfo sourceBlogFile = new(Path.Combine(blogOptions.Value.Root, "drafts", content.BlogName + ".md"));
+ sourceBlogFile.Delete();
+ }
+
+ private void RegisterCompressCommand(RootCommand rootCommand)
+ {
+ Command command = new("compress", "Compress png/jpeg image to webp image to reduce size.");
+ rootCommand.Add(command);
+
+ Option dryRunOption = new("--dry-run", description: "Dry run the compression task but not write.",
+ getDefaultValue: () => false);
+ command.AddOption(dryRunOption);
+
+ command.SetHandler(HandleCompressCommand, dryRunOption);
+ }
+
+ private async Task HandleCompressCommand(bool dryRun)
+ {
+ ImageCompressService imageCompressService = serviceProvider.GetRequiredService();
+ await imageCompressService.Compress(dryRun);
+ }
+}
diff --git a/src/YaeBlog/YaeBlog.csproj b/src/YaeBlog/YaeBlog.csproj
new file mode 100644
index 0000000..645a443
--- /dev/null
+++ b/src/YaeBlog/YaeBlog.csproj
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+ pnpm install
+ pwsh tailwind.ps1
+
+
diff --git a/src/YaeBlog/appsettings.json b/src/YaeBlog/appsettings.json
new file mode 100644
index 0000000..d0fe6e4
--- /dev/null
+++ b/src/YaeBlog/appsettings.json
@@ -0,0 +1,44 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "System.Net.Http.HttpClient": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "Tailwind": {
+ "InputFile": "wwwroot/input.css",
+ "OutputFile": "wwwroot/output.css"
+ },
+ "Blog": {
+ "Root": "../../source",
+ "Announcement": "博客锐意装修中,敬请期待!测试阶段如有问题还请海涵。",
+ "StartYear": 2021,
+ "Links": [
+ {
+ "Name": "Ichirinko",
+ "Description": "黑历史集合地,naive的代价",
+ "Link": "https://ichirinko.top",
+ "AvatarImage": "https://ichirinko-blog-img-1.oss-cn-shenzhen.aliyuncs.com/Pic_res/img/202209122110798.png"
+ },
+ {
+ "Name": "不会写程序的晨旭",
+ "Description": "一个普通大学生",
+ "Link": "https://chenxutalk.top",
+ "AvatarImage": "https://www.chenxutalk.top/img/photo.png"
+ },
+ {
+ "Name": "万木长风",
+ "Description": "世界渲染中...",
+ "Link": "https://ryohai.fun",
+ "AvatarImage": "https://ryohai.fun/static/favicons/favicon-32x32.png"
+ }
+ ]
+ },
+ "Gitea": {
+ "BaseAddress": "https://git.rrricardo.top/api/v1/",
+ "ApiKey": "7e33617e5d084199332fceec3e0cb04c6ddced55",
+ "HeatMapUsername": "jackfiled"
+ }
+}
diff --git a/src/YaeBlog/docker-compose.yaml b/src/YaeBlog/docker-compose.yaml
new file mode 100644
index 0000000..bc985dd
--- /dev/null
+++ b/src/YaeBlog/docker-compose.yaml
@@ -0,0 +1,13 @@
+version: '3.8'
+
+services:
+ blog:
+ image: registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest
+ restart: unless-stopped
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.blog.rule=Host(`rrricardo.top`) || Host(`www.rrricardo.top`)"
+ - "traefik.http.services.blog.loadbalancer.server.port=8080"
+ - "traefik.http.routers.blog.tls=true"
+ - "traefik.http.routers.blog.tls.certresolver=myresolver"
+ - "com.centurylinklabs.watchtower.enable=true"
diff --git a/src/YaeBlog/package.json b/src/YaeBlog/package.json
new file mode 100644
index 0000000..1e0009f
--- /dev/null
+++ b/src/YaeBlog/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "yae-blog",
+ "version": "1.0.0",
+ "description": "",
+ "scripts": {
+ "dev": "tailwindcss -i wwwroot/tailwind.css -o wwwroot/tailwind.g.css -w"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "tailwindcss": "^4.0.0",
+ "@tailwindcss/cli": "^4.0.0"
+ }
+}
diff --git a/src/YaeBlog/pnpm-lock.yaml b/src/YaeBlog/pnpm-lock.yaml
new file mode 100644
index 0000000..725fd8a
--- /dev/null
+++ b/src/YaeBlog/pnpm-lock.yaml
@@ -0,0 +1,545 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ devDependencies:
+ '@tailwindcss/cli':
+ specifier: ^4.0.0
+ version: 4.0.15
+ tailwindcss:
+ specifier: ^4.0.0
+ version: 4.0.15
+
+packages:
+
+ '@parcel/watcher-android-arm64@2.5.1':
+ resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ '@parcel/watcher-darwin-arm64@2.5.1':
+ resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@parcel/watcher-darwin-x64@2.5.1':
+ resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@parcel/watcher-freebsd-x64@2.5.1':
+ resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@parcel/watcher-linux-arm-glibc@2.5.1':
+ resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@parcel/watcher-linux-arm-musl@2.5.1':
+ resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@parcel/watcher-linux-arm64-glibc@2.5.1':
+ resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@parcel/watcher-linux-arm64-musl@2.5.1':
+ resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@parcel/watcher-linux-x64-glibc@2.5.1':
+ resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@parcel/watcher-linux-x64-musl@2.5.1':
+ resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@parcel/watcher-win32-arm64@2.5.1':
+ resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@parcel/watcher-win32-ia32@2.5.1':
+ resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@parcel/watcher-win32-x64@2.5.1':
+ resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ '@parcel/watcher@2.5.1':
+ resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
+ engines: {node: '>= 10.0.0'}
+
+ '@tailwindcss/cli@4.0.15':
+ resolution: {integrity: sha512-52RdNZCpij4O8+25N9sfWZPG124e6ahmIS1uMHcJrdw10UdpPUFgSJtyMwf7COVOnkx0nkXfmp8CcYomPCrQ1Q==}
+ hasBin: true
+
+ '@tailwindcss/node@4.0.15':
+ resolution: {integrity: sha512-IODaJjNmiasfZX3IoS+4Em3iu0fD2HS0/tgrnkYfW4hyUor01Smnr5eY3jc4rRgaTDrJlDmBTHbFO0ETTDaxWA==}
+
+ '@tailwindcss/oxide-android-arm64@4.0.15':
+ resolution: {integrity: sha512-EBuyfSKkom7N+CB3A+7c0m4+qzKuiN0WCvzPvj5ZoRu4NlQadg/mthc1tl5k9b5ffRGsbDvP4k21azU4VwVk3Q==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.0.15':
+ resolution: {integrity: sha512-ObVAnEpLepMhV9VoO0JSit66jiN5C4YCqW3TflsE9boo2Z7FIjV80RFbgeL2opBhtxbaNEDa6D0/hq/EP03kgQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.0.15':
+ resolution: {integrity: sha512-IElwoFhUinOr9MyKmGTPNi1Rwdh68JReFgYWibPWTGuevkHkLWKEflZc2jtI5lWZ5U9JjUnUfnY43I4fEXrc4g==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.0.15':
+ resolution: {integrity: sha512-6BLLqyx7SIYRBOnTZ8wgfXANLJV5TQd3PevRJZp0vn42eO58A2LykRKdvL1qyPfdpmEVtF+uVOEZ4QTMqDRAWA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.0.15':
+ resolution: {integrity: sha512-Zy63EVqO9241Pfg6G0IlRIWyY5vNcWrL5dd2WAKVJZRQVeolXEf1KfjkyeAAlErDj72cnyXObEZjMoPEKHpdNw==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.0.15':
+ resolution: {integrity: sha512-2NemGQeaTbtIp1Z2wyerbVEJZTkAWhMDOhhR5z/zJ75yMNf8yLnE+sAlyf6yGDNr+1RqvWrRhhCFt7i0CIxe4Q==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.0.15':
+ resolution: {integrity: sha512-342GVnhH/6PkVgKtEzvNVuQ4D+Q7B7qplvuH20Cfz9qEtydG6IQczTZ5IT4JPlh931MG1NUCVxg+CIorr1WJyw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.0.15':
+ resolution: {integrity: sha512-g76GxlKH124RuGqacCEFc2nbzRl7bBrlC8qDQMiUABkiifDRHOIUjgKbLNG4RuR9hQAD/MKsqZ7A8L08zsoBrw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.0.15':
+ resolution: {integrity: sha512-Gg/Y1XrKEvKpq6WeNt2h8rMIKOBj/W3mNa5NMvkQgMC7iO0+UNLrYmt6zgZufht66HozNpn+tJMbbkZ5a3LczA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.0.15':
+ resolution: {integrity: sha512-7QtSSJwYZ7ZK1phVgcNZpuf7c7gaCj8Wb0xjliligT5qCGCp79OV2n3SJummVZdw4fbTNKUOYMO7m1GinppZyA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.0.15':
+ resolution: {integrity: sha512-JQ5H+5MLhOjpgNp6KomouE0ZuKmk3hO5h7/ClMNAQ8gZI2zkli3IH8ZqLbd2DVfXDbdxN2xvooIEeIlkIoSCqw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.0.15':
+ resolution: {integrity: sha512-e0uHrKfPu7JJGMfjwVNyt5M0u+OP8kUmhACwIRlM+JNBuReDVQ63yAD1NWe5DwJtdaHjugNBil76j+ks3zlk6g==}
+ engines: {node: '>= 10'}
+
+ braces@3.0.3:
+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+ engines: {node: '>=8'}
+
+ detect-libc@1.0.3:
+ resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
+ engines: {node: '>=0.10'}
+ hasBin: true
+
+ detect-libc@2.0.3:
+ resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
+ engines: {node: '>=8'}
+
+ enhanced-resolve@5.18.1:
+ resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
+ engines: {node: '>=10.13.0'}
+
+ fill-range@7.1.1:
+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+ engines: {node: '>=8'}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ is-number@7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+
+ jiti@2.4.2:
+ resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
+ hasBin: true
+
+ lightningcss-darwin-arm64@1.29.2:
+ resolution: {integrity: sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.29.2:
+ resolution: {integrity: sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.29.2:
+ resolution: {integrity: sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.29.2:
+ resolution: {integrity: sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.29.2:
+ resolution: {integrity: sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.29.2:
+ resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.29.2:
+ resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.29.2:
+ resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-win32-arm64-msvc@1.29.2:
+ resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.29.2:
+ resolution: {integrity: sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.29.2:
+ resolution: {integrity: sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==}
+ engines: {node: '>= 12.0.0'}
+
+ micromatch@4.0.8:
+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+ engines: {node: '>=8.6'}
+
+ mri@1.2.0:
+ resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
+ engines: {node: '>=4'}
+
+ node-addon-api@7.1.1:
+ resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@2.3.1:
+ resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ engines: {node: '>=8.6'}
+
+ tailwindcss@4.0.15:
+ resolution: {integrity: sha512-6ZMg+hHdMJpjpeCCFasX7K+U615U9D+7k5/cDK/iRwl6GptF24+I/AbKgOnXhVKePzrEyIXutLv36n4cRsq3Sg==}
+
+ tapable@2.2.1:
+ resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
+ engines: {node: '>=6'}
+
+ to-regex-range@5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+
+snapshots:
+
+ '@parcel/watcher-android-arm64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-darwin-arm64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-darwin-x64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-freebsd-x64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-arm-glibc@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-arm-musl@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-arm64-glibc@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-arm64-musl@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-x64-glibc@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-x64-musl@2.5.1':
+ optional: true
+
+ '@parcel/watcher-win32-arm64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-win32-ia32@2.5.1':
+ optional: true
+
+ '@parcel/watcher-win32-x64@2.5.1':
+ optional: true
+
+ '@parcel/watcher@2.5.1':
+ dependencies:
+ detect-libc: 1.0.3
+ is-glob: 4.0.3
+ micromatch: 4.0.8
+ node-addon-api: 7.1.1
+ optionalDependencies:
+ '@parcel/watcher-android-arm64': 2.5.1
+ '@parcel/watcher-darwin-arm64': 2.5.1
+ '@parcel/watcher-darwin-x64': 2.5.1
+ '@parcel/watcher-freebsd-x64': 2.5.1
+ '@parcel/watcher-linux-arm-glibc': 2.5.1
+ '@parcel/watcher-linux-arm-musl': 2.5.1
+ '@parcel/watcher-linux-arm64-glibc': 2.5.1
+ '@parcel/watcher-linux-arm64-musl': 2.5.1
+ '@parcel/watcher-linux-x64-glibc': 2.5.1
+ '@parcel/watcher-linux-x64-musl': 2.5.1
+ '@parcel/watcher-win32-arm64': 2.5.1
+ '@parcel/watcher-win32-ia32': 2.5.1
+ '@parcel/watcher-win32-x64': 2.5.1
+
+ '@tailwindcss/cli@4.0.15':
+ dependencies:
+ '@parcel/watcher': 2.5.1
+ '@tailwindcss/node': 4.0.15
+ '@tailwindcss/oxide': 4.0.15
+ enhanced-resolve: 5.18.1
+ lightningcss: 1.29.2
+ mri: 1.2.0
+ picocolors: 1.1.1
+ tailwindcss: 4.0.15
+
+ '@tailwindcss/node@4.0.15':
+ dependencies:
+ enhanced-resolve: 5.18.1
+ jiti: 2.4.2
+ tailwindcss: 4.0.15
+
+ '@tailwindcss/oxide-android-arm64@4.0.15':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.0.15':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.0.15':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.0.15':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.0.15':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.0.15':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.0.15':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.0.15':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.0.15':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.0.15':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.0.15':
+ optional: true
+
+ '@tailwindcss/oxide@4.0.15':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.0.15
+ '@tailwindcss/oxide-darwin-arm64': 4.0.15
+ '@tailwindcss/oxide-darwin-x64': 4.0.15
+ '@tailwindcss/oxide-freebsd-x64': 4.0.15
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.0.15
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.0.15
+ '@tailwindcss/oxide-linux-arm64-musl': 4.0.15
+ '@tailwindcss/oxide-linux-x64-gnu': 4.0.15
+ '@tailwindcss/oxide-linux-x64-musl': 4.0.15
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.0.15
+ '@tailwindcss/oxide-win32-x64-msvc': 4.0.15
+
+ braces@3.0.3:
+ dependencies:
+ fill-range: 7.1.1
+
+ detect-libc@1.0.3: {}
+
+ detect-libc@2.0.3: {}
+
+ enhanced-resolve@5.18.1:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.2.1
+
+ fill-range@7.1.1:
+ dependencies:
+ to-regex-range: 5.0.1
+
+ graceful-fs@4.2.11: {}
+
+ is-extglob@2.1.1: {}
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ is-number@7.0.0: {}
+
+ jiti@2.4.2: {}
+
+ lightningcss-darwin-arm64@1.29.2:
+ optional: true
+
+ lightningcss-darwin-x64@1.29.2:
+ optional: true
+
+ lightningcss-freebsd-x64@1.29.2:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.29.2:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.29.2:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.29.2:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.29.2:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.29.2:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.29.2:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.29.2:
+ optional: true
+
+ lightningcss@1.29.2:
+ dependencies:
+ detect-libc: 2.0.3
+ optionalDependencies:
+ lightningcss-darwin-arm64: 1.29.2
+ lightningcss-darwin-x64: 1.29.2
+ lightningcss-freebsd-x64: 1.29.2
+ lightningcss-linux-arm-gnueabihf: 1.29.2
+ lightningcss-linux-arm64-gnu: 1.29.2
+ lightningcss-linux-arm64-musl: 1.29.2
+ lightningcss-linux-x64-gnu: 1.29.2
+ lightningcss-linux-x64-musl: 1.29.2
+ lightningcss-win32-arm64-msvc: 1.29.2
+ lightningcss-win32-x64-msvc: 1.29.2
+
+ micromatch@4.0.8:
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.1
+
+ mri@1.2.0: {}
+
+ node-addon-api@7.1.1: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@2.3.1: {}
+
+ tailwindcss@4.0.15: {}
+
+ tapable@2.2.1: {}
+
+ to-regex-range@5.0.1:
+ dependencies:
+ is-number: 7.0.0
diff --git a/src/YaeBlog/tailwind.ps1 b/src/YaeBlog/tailwind.ps1
new file mode 100644
index 0000000..3d19da1
--- /dev/null
+++ b/src/YaeBlog/tailwind.ps1
@@ -0,0 +1,11 @@
+#!/pwsh
+
+[cmdletbinding()]
+param(
+ [string]$Output = "wwwroot"
+)
+
+end {
+ Write-Host "Build tailwind css into $Output."
+ pnpm tailwindcss -i wwwroot/tailwind.css -o $Output/tailwind.g.css
+}
diff --git a/YaeBlog/wwwroot/fonts/.gitattributes b/src/YaeBlog/wwwroot/fonts/.gitattributes
similarity index 100%
rename from YaeBlog/wwwroot/fonts/.gitattributes
rename to src/YaeBlog/wwwroot/fonts/.gitattributes
diff --git a/src/YaeBlog/wwwroot/fonts/fa-brands-400.woff2 b/src/YaeBlog/wwwroot/fonts/fa-brands-400.woff2
new file mode 100644
index 0000000..6af4c60
--- /dev/null
+++ b/src/YaeBlog/wwwroot/fonts/fa-brands-400.woff2
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:061dd5c333459ea42dba764617793fd6ea2d316b7ab644f157e4d2354dac02af
+size 101224
diff --git a/src/YaeBlog/wwwroot/fonts/fa-regular-400.woff2 b/src/YaeBlog/wwwroot/fonts/fa-regular-400.woff2
new file mode 100644
index 0000000..296655c
--- /dev/null
+++ b/src/YaeBlog/wwwroot/fonts/fa-regular-400.woff2
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:81159a6b36876a5545555ae689144f074e2fc802d57d36f2c21bc6f3a12f4e48
+size 18988
diff --git a/src/YaeBlog/wwwroot/fonts/fa-solid-900.woff2 b/src/YaeBlog/wwwroot/fonts/fa-solid-900.woff2
new file mode 100644
index 0000000..9b80394
--- /dev/null
+++ b/src/YaeBlog/wwwroot/fonts/fa-solid-900.woff2
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bdd7887ef769948024a5cc37a018f19da6a9b355b4a09973836115e0d31ead55
+size 113152
diff --git a/src/YaeBlog/wwwroot/images/alipay-code.jpeg b/src/YaeBlog/wwwroot/images/alipay-code.jpeg
new file mode 100644
index 0000000..480598c
--- /dev/null
+++ b/src/YaeBlog/wwwroot/images/alipay-code.jpeg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9dfcb06c706d121c2ea672407843e0ad011e43531ef2ea4f2141fca2795012da
+size 102287
diff --git a/YaeBlog/wwwroot/images/avatar.png b/src/YaeBlog/wwwroot/images/avatar.png
similarity index 100%
rename from YaeBlog/wwwroot/images/avatar.png
rename to src/YaeBlog/wwwroot/images/avatar.png
diff --git a/YaeBlog/wwwroot/images/favicon.ico b/src/YaeBlog/wwwroot/images/favicon.ico
similarity index 100%
rename from YaeBlog/wwwroot/images/favicon.ico
rename to src/YaeBlog/wwwroot/images/favicon.ico
diff --git a/src/YaeBlog/wwwroot/images/wechat-code.jpeg b/src/YaeBlog/wwwroot/images/wechat-code.jpeg
new file mode 100644
index 0000000..a036e04
--- /dev/null
+++ b/src/YaeBlog/wwwroot/images/wechat-code.jpeg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:527ba104ee72f1f7b934f7a7f8ed8cb2edf905ba39d1c0f89d986c5421871f3e
+size 49545
diff --git a/src/YaeBlog/wwwroot/images/xiaohongshu-seeklogo.svg b/src/YaeBlog/wwwroot/images/xiaohongshu-seeklogo.svg
new file mode 100644
index 0000000..3a2fb33
--- /dev/null
+++ b/src/YaeBlog/wwwroot/images/xiaohongshu-seeklogo.svg
@@ -0,0 +1,44 @@
+
+
+
+
diff --git a/src/YaeBlog/wwwroot/tailwind.css b/src/YaeBlog/wwwroot/tailwind.css
new file mode 100644
index 0000000..f1d8c73
--- /dev/null
+++ b/src/YaeBlog/wwwroot/tailwind.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/third-party/BlazorSvgComponents b/third-party/BlazorSvgComponents
new file mode 160000
index 0000000..909448d
--- /dev/null
+++ b/third-party/BlazorSvgComponents
@@ -0,0 +1 @@
+Subproject commit 909448d9f5ad274b6e0b61355381a45e63bbc735