7 Commits

Author SHA1 Message Date
939f2373e8 refact: add YaeBlog.Abstractions project.
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-03-14 22:24:08 +08:00
6733bbbd2a fix: build action.
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 53s
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-03-14 21:17:37 +08:00
e10c8e7e75 fix: build action.
Some checks failed
Build blog docker image / Build-Blog-Image (push) Failing after 45s
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-03-14 21:15:33 +08:00
45f15c9bd9 fix: build action.
Some checks failed
Build blog docker image / Build-Blog-Image (push) Has been cancelled
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-03-14 19:51:00 +08:00
a1b5af5b0c fix: build action.
Some checks failed
Build blog docker image / Build-Blog-Image (push) Failing after 2s
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-03-14 19:50:06 +08:00
d8e4931d63 refact: Let host to handle command arguments.
Some checks failed
Build blog docker image / Build-Blog-Image (push) Failing after 0s
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-03-14 19:48:44 +08:00
462fbb28ac feat: rewrite about page for 2026. (#21)
Some checks failed
Build blog docker image / Build-Blog-Image (push) Failing after 14s
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
Reviewed-on: #21
2026-03-03 09:09:49 +00:00
55 changed files with 412 additions and 487 deletions

View File

@@ -8,19 +8,19 @@ jobs:
runs-on: archlinux runs-on: archlinux
steps: steps:
- name: Check out code. - name: Check out code.
uses: https://mirrors.rrricardo.top/actions/checkout.git@v4 uses: http://github-mirrors.infra.svc.cluster.local/actions/checkout.git@v4
with: with:
lfs: true lfs: true
- name: Build project. - name: Build project.
run: | run: |
git submodule update --init
podman pull mcr.azure.cn/dotnet/aspnet:10.0 podman pull mcr.azure.cn/dotnet/aspnet:10.0
cd YaeBlog
pwsh build.ps1 build pwsh build.ps1 build
- name: Workaround to make sure podman-login working. - name: Workaround to make sure podman-login working.
run: | run: |
mkdir /root/.docker mkdir -p /root/.docker
- name: Login tencent cloud docker registry. - name: Login tencent cloud docker registry.
uses: https://mirrors.rrricardo.top/actions/podman-login.git@v1 uses: http://github-mirrors.infra.svc.cluster.local/actions/podman-login.git@v1
with: with:
registry: ccr.ccs.tencentyun.com registry: ccr.ccs.tencentyun.com
username: 100044380877 username: 100044380877

1
.gitignore vendored
View File

@@ -184,6 +184,7 @@ DocProject/Help/html
# Click-Once directory # Click-Once directory
publish/ publish/
out/
# Publish Web Output # Publish Web Output
*.[Pp]ublish.xml *.[Pp]ublish.xml

View File

@@ -12,6 +12,7 @@
<File Path="README.md" /> <File Path="README.md" />
</Folder> </Folder>
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/YaeBlog.Abstractions/YaeBlog.Abstractions.csproj" />
<Project Path="src/YaeBlog.Tests/YaeBlog.Tests.csproj" /> <Project Path="src/YaeBlog.Tests/YaeBlog.Tests.csproj" />
<Project Path="src/YaeBlog/YaeBlog.csproj" /> <Project Path="src/YaeBlog/YaeBlog.csproj" />
</Folder> </Folder>

View File

@@ -3,16 +3,15 @@
[cmdletbinding()] [cmdletbinding()]
param( param(
[Parameter(Mandatory = $true, Position = 0, HelpMessage = "Specify the build target")] [Parameter(Mandatory = $true, Position = 0, HelpMessage = "Specify the build target")]
[ValidateSet("tailwind", "publish", "compress", "build", "dev", "new", "watch", "serve", "list")] [ValidateSet("publish", "compress", "build", "dev", "new", "watch", "serve")]
[string]$Target, [string]$Target,
[string]$Output = "wwwroot",
[string]$Essay, [string]$Essay,
[switch]$Compress, [switch]$Compress,
[string]$Root = "source" [string]$Root = "source"
) )
begin { begin {
if ($Target -eq "tailwind") if (($Target -eq "tailwind") -or ($Target -eq "build"))
{ {
# Handle tailwind specially. # Handle tailwind specially.
return return
@@ -82,8 +81,11 @@ process {
function Build-Image function Build-Image
{ {
$commitId = git rev-parse --short=10 HEAD $commitId = git rev-parse --short=10 HEAD
dotnet publish dotnet publish ./src/YaeBlog/YaeBlog.csproj -o out
podman build . -t ccr.ccs.tencentyun.com/jackfiled/blog --build-arg COMMIT_ID=$commitId Write-Host "Succeed to build blog appliocation."
podman build . -t ccr.ccs.tencentyun.com/jackfiled/blog --build-arg COMMIT_ID=$commitId `
-f ./src/YaeBlog/Dockerfile
Write-Host "Succeed to build ccr.ccs.tencentyun.com/jackfiled/blog image."
} }
function Start-Develop { function Start-Develop {
@@ -111,11 +113,6 @@ process {
switch ($Target) switch ($Target)
{ {
"tailwind" {
Write-Host "Build tailwind css into $Output."
pnpm tailwindcss -i wwwroot/tailwind.css -o $Output/tailwind.g.css
break
}
"publish" { "publish" {
Write-Host "Publish essay $Essay..." Write-Host "Publish essay $Essay..."
dotnet run -- publish $Essay dotnet run -- publish $Essay

View File

@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Abstraction; namespace YaeBlog.Abstractions;
public interface IEssayContentService public interface IEssayContentService
{ {

View File

@@ -1,6 +1,6 @@
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Abstraction; namespace YaeBlog.Abstractions;
public interface IEssayScanService public interface IEssayScanService
{ {

View File

@@ -1,6 +1,6 @@
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Abstraction; namespace YaeBlog.Abstractions;
public interface IPostRenderProcessor public interface IPostRenderProcessor
{ {

View File

@@ -1,6 +1,6 @@
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Abstraction; namespace YaeBlog.Abstractions;
public interface IPreRenderProcessor public interface IPreRenderProcessor
{ {

View File

@@ -1,4 +1,4 @@
namespace YaeBlog.Models; namespace YaeBlog.Abstractions.Models;
/// <summary> /// <summary>
/// 单个博客文件的所有数据和元数据 /// 单个博客文件的所有数据和元数据

View File

@@ -1,7 +1,7 @@
using System.Collections; using System.Collections;
using System.Collections.Concurrent; using System.Collections.Concurrent;
namespace YaeBlog.Models; namespace YaeBlog.Abstractions.Models;
public record BlogContents(ConcurrentBag<BlogContent> Drafts, ConcurrentBag<BlogContent> Posts) public record BlogContents(ConcurrentBag<BlogContent> Drafts, ConcurrentBag<BlogContent> Posts)
: IEnumerable<BlogContent> : IEnumerable<BlogContent>

View File

@@ -1,4 +1,4 @@
namespace YaeBlog.Models; namespace YaeBlog.Abstractions.Models;
public record BlogEssay( public record BlogEssay(
string Title, string Title,

View File

@@ -1,4 +1,4 @@
namespace YaeBlog.Models; namespace YaeBlog.Abstractions.Models;
public class BlogHeadline(string title, string selectorId) public class BlogHeadline(string title, string selectorId)
{ {

View File

@@ -1,6 +1,6 @@
using System.Text; using System.Text;
namespace YaeBlog.Models; namespace YaeBlog.Abstractions.Models;
public record BlogImageInfo(FileInfo File, long Width, long Height, string MineType, byte[] Content, bool IsUsed) public record BlogImageInfo(FileInfo File, long Width, long Height, string MineType, byte[] Content, bool IsUsed)
: IComparable<BlogImageInfo> : IComparable<BlogImageInfo>

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace YaeBlog.Models; namespace YaeBlog.Abstractions.Models;
/// <summary> /// <summary>
/// 友链模型类 /// 友链模型类

View File

@@ -1,6 +1,6 @@
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
namespace YaeBlog.Models; namespace YaeBlog.Abstractions.Models;
public class EssayTag(string tagName) : IEquatable<EssayTag> public class EssayTag(string tagName) : IEquatable<EssayTag>
{ {

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace YaeBlog.Models; namespace YaeBlog.Abstractions.Models;
public class GiteaOptions public class GiteaOptions
{ {

View File

@@ -1,4 +1,4 @@
namespace YaeBlog.Models; namespace YaeBlog.Abstractions.Models;
public record GitContributionItem(DateOnly Time, long ContributionCount); public record GitContributionItem(DateOnly Time, long ContributionCount);

View File

@@ -1,4 +1,4 @@
namespace YaeBlog.Models; namespace YaeBlog.Abstractions.Models;
public class MarkdownMetadata public class MarkdownMetadata
{ {

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -1,7 +1,7 @@
using DotNext; using DotNext;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moq; using Moq;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
using YaeBlog.Services; using YaeBlog.Services;
namespace YaeBlog.Tests; namespace YaeBlog.Tests;

View File

@@ -1,6 +1,6 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moq; using Moq;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Tests; namespace YaeBlog.Tests;

View File

@@ -1,33 +0,0 @@
using System.CommandLine.Binding;
using System.Text.Json;
using Microsoft.Extensions.Options;
using YaeBlog.Models;
namespace YaeBlog.Commands.Binders;
public sealed class BlogOptionsBinder : BinderBase<IOptions<BlogOptions>>
{
protected override IOptions<BlogOptions> GetBoundValue(BindingContext bindingContext)
{
bindingContext.AddService<IOptions<BlogOptions>>(_ =>
{
FileInfo settings = new(Path.Combine(Environment.CurrentDirectory, "appsettings.json"));
if (!settings.Exists)
{
throw new InvalidOperationException("Failed to load YaeBlog configurations.");
}
using StreamReader reader = settings.OpenText();
using JsonDocument document = JsonDocument.Parse(reader.ReadToEnd());
JsonElement root = document.RootElement;
JsonElement optionSection = root.GetProperty(BlogOptions.OptionName);
BlogOptions? result = optionSection.Deserialize<BlogOptions>();
return result is null
? throw new InvalidOperationException("Failed to load YaeBlog configuration in appsettings.json.")
: new OptionsWrapper<BlogOptions>(result);
});
return bindingContext.GetRequiredService<IOptions<BlogOptions>>();
}
}

View File

@@ -1,32 +0,0 @@
using System.CommandLine.Binding;
using Microsoft.Extensions.Options;
using YaeBlog.Abstraction;
using YaeBlog.Models;
using YaeBlog.Services;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace YaeBlog.Commands.Binders;
public sealed class EssayScanServiceBinder : BinderBase<IEssayScanService>
{
protected override IEssayScanService GetBoundValue(BindingContext bindingContext)
{
bindingContext.AddService<IEssayScanService>(provider =>
{
DeserializerBuilder deserializerBuilder = new();
deserializerBuilder.WithNamingConvention(CamelCaseNamingConvention.Instance);
deserializerBuilder.IgnoreUnmatchedProperties();
SerializerBuilder serializerBuilder = new();
serializerBuilder.WithNamingConvention(CamelCaseNamingConvention.Instance);
IOptions<BlogOptions> options = provider.GetRequiredService<IOptions<BlogOptions>>();
ILogger<EssayScanService> logger = provider.GetRequiredService<ILogger<EssayScanService>>();
return new EssayScanService(serializerBuilder.Build(), deserializerBuilder.Build(), options, logger);
});
return bindingContext.GetRequiredService<IEssayScanService>();
}
}

View File

@@ -1,21 +0,0 @@
using System.CommandLine.Binding;
using YaeBlog.Abstraction;
using YaeBlog.Services;
namespace YaeBlog.Commands.Binders;
public sealed class ImageCompressServiceBinder : BinderBase<ImageCompressService>
{
protected override ImageCompressService GetBoundValue(BindingContext bindingContext)
{
bindingContext.AddService(provider =>
{
IEssayScanService essayScanService = provider.GetRequiredService<IEssayScanService>();
ILogger<ImageCompressService> logger = provider.GetRequiredService<ILogger<ImageCompressService>>();
return new ImageCompressService(essayScanService, logger);
});
return bindingContext.GetRequiredService<ImageCompressService>();
}
}

View File

@@ -1,18 +0,0 @@
using System.CommandLine.Binding;
namespace YaeBlog.Commands.Binders;
public sealed class LoggerBinder<T> : BinderBase<ILogger<T>>
{
protected override ILogger<T> GetBoundValue(BindingContext bindingContext)
{
bindingContext.AddService(_ => LoggerFactory.Create(builder => builder.AddConsole()));
bindingContext.AddService<ILogger<T>>(provider =>
{
ILoggerFactory factory = provider.GetRequiredService<ILoggerFactory>();
return factory.CreateLogger<T>();
});
return bindingContext.GetRequiredService<ILogger<T>>();
}
}

View File

@@ -1,296 +0,0 @@
using System.CommandLine;
using Microsoft.Extensions.Options;
using YaeBlog.Abstraction;
using YaeBlog.Commands.Binders;
using YaeBlog.Components;
using YaeBlog.Extensions;
using YaeBlog.Models;
using YaeBlog.Services;
namespace YaeBlog.Commands;
public sealed class YaeBlogCommand
{
private readonly RootCommand _rootCommand = new("YaeBlog Cli");
public YaeBlogCommand()
{
AddServeCommand(_rootCommand);
AddWatchCommand(_rootCommand);
AddListCommand(_rootCommand);
AddNewCommand(_rootCommand);
AddUpdateCommand(_rootCommand);
AddPublishCommand(_rootCommand);
AddScanCommand(_rootCommand);
AddCompressCommand(_rootCommand);
}
public Task<int> RunAsync(string[] args)
{
return _rootCommand.InvokeAsync(args);
}
private static void AddServeCommand(RootCommand rootCommand)
{
Command serveCommand = new("serve", "Start http server.");
rootCommand.AddCommand(serveCommand);
serveCommand.SetHandler(async context =>
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddControllers();
builder.AddYaeBlog();
builder.AddServer();
WebApplication application = builder.Build();
application.MapStaticAssets();
application.UseAntiforgery();
application.UseYaeBlog();
application.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
application.MapControllers();
CancellationToken token = context.GetCancellationToken();
await application.RunAsync(token);
});
}
private static void AddWatchCommand(RootCommand rootCommand)
{
Command command = new("watch", "Start a blog watcher that re-render when file changes.");
rootCommand.AddCommand(command);
command.SetHandler(async context =>
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddControllers();
builder.AddYaeBlog();
builder.AddWatcher();
WebApplication application = builder.Build();
application.MapStaticAssets();
application.UseAntiforgery();
application.UseYaeBlog();
application.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
application.MapControllers();
CancellationToken token = context.GetCancellationToken();
await application.RunAsync(token);
});
}
private static void AddNewCommand(RootCommand rootCommand)
{
Command newCommand = new("new", "Create a new blog file and image directory.");
rootCommand.AddCommand(newCommand);
Argument<string> filenameArgument = new(name: "blog name", description: "The created blog filename.");
newCommand.AddArgument(filenameArgument);
newCommand.SetHandler(async (file, blogOption, _, essayScanService) =>
{
BlogContents contents = await essayScanService.ScanContents();
if (contents.Posts.Any(content => content.BlogName == file))
{
Console.WriteLine("There exists the same title blog in posts.");
return;
}
await essayScanService.SaveBlogContent(new BlogContent(
new FileInfo(Path.Combine(blogOption.Value.Root, "drafts", file + ".md")),
new MarkdownMetadata
{
Title = file,
Date = DateTimeOffset.Now.ToString("o"),
UpdateTime = DateTimeOffset.Now.ToString("o")
},
string.Empty, true, [], []));
Console.WriteLine($"Created new blog '{file}.");
}, filenameArgument, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(),
new EssayScanServiceBinder());
}
private static void AddUpdateCommand(RootCommand rootCommand)
{
Command newCommand = new("update", "Update the blog essay.");
rootCommand.AddCommand(newCommand);
Argument<string> filenameArgument = new(name: "blog name", description: "The blog filename to update.");
newCommand.AddArgument(filenameArgument);
newCommand.SetHandler(async (file, _, _, essayScanService) =>
{
Console.WriteLine("HINT: The update command only consider published blogs.");
BlogContents contents = await essayScanService.ScanContents();
BlogContent? content = contents.Posts.FirstOrDefault(c => c.BlogName == file);
if (content is null)
{
Console.WriteLine($"Target essay {file} is not exist.");
return;
}
content.Metadata.UpdateTime = DateTimeOffset.Now.ToString("o");
await essayScanService.SaveBlogContent(content, content.IsDraft);
}, filenameArgument,
new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(), new EssayScanServiceBinder());
}
private static void AddListCommand(RootCommand rootCommand)
{
Command command = new("list", "List all blogs");
rootCommand.AddCommand(command);
command.SetHandler(async (_, _, essyScanService) =>
{
BlogContents contents = await essyScanService.ScanContents();
Console.WriteLine($"All {contents.Posts.Count} Posts:");
foreach (BlogContent content in contents.Posts.OrderBy(x => x.BlogName))
{
Console.WriteLine($" - {content.BlogName}");
}
Console.WriteLine($"All {contents.Drafts.Count} Drafts:");
foreach (BlogContent content in contents.Drafts.OrderBy(x => x.BlogName))
{
Console.WriteLine($" - {content.BlogName}");
}
}, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(), new EssayScanServiceBinder());
}
private static void AddScanCommand(RootCommand rootCommand)
{
Command command = new("scan", "Scan unused and not found images.");
rootCommand.AddCommand(command);
Option<bool> removeOption =
new(name: "--rm", description: "Remove unused images.", getDefaultValue: () => false);
command.AddOption(removeOption);
command.SetHandler(async (_, _, essayScanService, removeOptionValue) =>
{
BlogContents contents = await essayScanService.ScanContents();
List<BlogImageInfo> unusedImages = (from content in contents
from image in content.Images
where image is { IsUsed: false }
select image).ToList();
if (unusedImages.Count != 0)
{
Console.WriteLine("Found unused images:");
Console.WriteLine("HINT: use '--rm' to remove unused images.");
}
foreach (BlogImageInfo image in unusedImages)
{
Console.WriteLine($" - {image.File.FullName}");
}
if (removeOptionValue)
{
foreach (BlogImageInfo image in unusedImages)
{
image.File.Delete();
}
}
Console.WriteLine("Used not existed images:");
foreach (BlogContent content in contents)
{
foreach (FileInfo file in content.NotfoundImages)
{
Console.WriteLine($"- {file.Name} in {content.BlogName}");
}
}
}, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(), new EssayScanServiceBinder(), removeOption);
}
private static void AddPublishCommand(RootCommand rootCommand)
{
Command command = new("publish", "Publish a new blog file.");
rootCommand.AddCommand(command);
Argument<string> filenameArgument = new(name: "blog name", description: "The published blog filename.");
command.AddArgument(filenameArgument);
command.SetHandler(async (blogOptions, _, essayScanService, filename) =>
{
BlogContents contents = await essayScanService.ScanContents();
BlogContent? content = (from blog in contents.Drafts
where blog.BlogName == filename
select blog).FirstOrDefault();
if (content is null)
{
Console.WriteLine("Target blog does not exist.");
return;
}
// 设置发布的时间
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();
}, new BlogOptionsBinder(),
new LoggerBinder<EssayScanService>(), new EssayScanServiceBinder(), filenameArgument);
}
private static void AddCompressCommand(RootCommand rootCommand)
{
Command command = new("compress", "Compress png/jpeg image to webp image to reduce size.");
rootCommand.Add(command);
Option<bool> dryRunOption = new("--dry-run", description: "Dry run the compression task but not write.",
getDefaultValue: () => false);
command.AddOption(dryRunOption);
command.SetHandler(ImageCommandHandler,
new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(), new LoggerBinder<ImageCompressService>(),
new EssayScanServiceBinder(), new ImageCompressServiceBinder(), dryRunOption);
}
private static async Task ImageCommandHandler(IOptions<BlogOptions> _, ILogger<EssayScanService> _1,
ILogger<ImageCompressService> _2,
IEssayScanService _3, ImageCompressService imageCompressService, bool dryRun)
{
await imageCompressService.Compress(dryRun);
}
}

View File

@@ -1,6 +1,6 @@
@using Microsoft.Extensions.Options @using Microsoft.Extensions.Options
@using YaeBlog.Abstraction @using YaeBlog.Abstractions
@using YaeBlog.Models @using YaeBlog.Abstractions.Models
@inject IEssayContentService Contents @inject IEssayContentService Contents
@inject IOptions<BlogOptions> Options @inject IOptions<BlogOptions> Options

View File

@@ -1,5 +1,5 @@
@using System.Text.Encodings.Web @using System.Text.Encodings.Web
@using YaeBlog.Models @using YaeBlog.Abstractions.Models
<div class="flex flex-col p-3"> <div class="flex flex-col p-3">
<div class="text-3xl font-bold py-2"> <div class="text-3xl font-bold py-2">

View File

@@ -1,4 +1,4 @@
@using YaeBlog.Models @using YaeBlog.Abstractions.Models
@using YaeBlog.Services @using YaeBlog.Services
@inject GitHeapMapService GitHeapMapInstance @inject GitHeapMapService GitHeapMapInstance

View File

@@ -1,6 +1,6 @@
@page "/blog/archives" @page "/blog/archives"
@using YaeBlog.Abstraction @using YaeBlog.Abstractions
@using YaeBlog.Models @using YaeBlog.Abstractions.Models
@inject IEssayContentService Contents @inject IEssayContentService Contents

View File

@@ -1,6 +1,6 @@
@page "/blog" @page "/blog"
@using YaeBlog.Abstraction @using YaeBlog.Abstractions
@using YaeBlog.Models @using YaeBlog.Abstractions.Models
@inject IEssayContentService Contents @inject IEssayContentService Contents
@inject NavigationManager NavigationInstance @inject NavigationManager NavigationInstance

View File

@@ -1,7 +1,7 @@
@page "/blog/essays/{BlogKey}" @page "/blog/essays/{BlogKey}"
@using System.Text.Encodings.Web @using System.Text.Encodings.Web
@using YaeBlog.Abstraction @using YaeBlog.Abstractions
@using YaeBlog.Models @using YaeBlog.Abstractions.Models
@inject IEssayContentService Contents @inject IEssayContentService Contents
@inject NavigationManager NavigationInstance @inject NavigationManager NavigationInstance

View File

@@ -1,6 +1,6 @@
@page "/friends" @page "/friends"
@using Microsoft.Extensions.Options @using Microsoft.Extensions.Options
@using YaeBlog.Models @using YaeBlog.Abstractions.Models
@inject IOptions<BlogOptions> BlogOptionInstance @inject IOptions<BlogOptions> BlogOptionInstance
<PageTitle> <PageTitle>

View File

@@ -1,6 +1,6 @@
@page "/" @page "/"
@using YaeBlog.Abstraction @using YaeBlog.Abstractions
@using YaeBlog.Models @using YaeBlog.Abstractions.Models
@inject IEssayContentService EssayContentInstance @inject IEssayContentService EssayContentInstance
<PageTitle> <PageTitle>

View File

@@ -1,7 +1,7 @@
@page "/blog/tags/" @page "/blog/tags/"
@using System.Text.Encodings.Web @using System.Text.Encodings.Web
@using YaeBlog.Abstraction @using YaeBlog.Abstractions
@using YaeBlog.Models @using YaeBlog.Abstractions.Models
@inject IEssayContentService Contents @inject IEssayContentService Contents
@inject NavigationManager NavigationInstance @inject NavigationManager NavigationInstance

View File

@@ -4,8 +4,10 @@ ARG COMMIT_ID
ENV COMMIT_ID=${COMMIT_ID} ENV COMMIT_ID=${COMMIT_ID}
WORKDIR /app WORKDIR /app
COPY bin/Release/net10.0/publish/ ./ COPY out/ ./
COPY source/ ./source/ COPY source/ ./source/
COPY appsettings.json . COPY src/YaeBlog/appsettings.json .
ENV BLOG__ROOT="./source"
ENTRYPOINT ["dotnet", "YaeBlog.dll", "serve"] ENTRYPOINT ["dotnet", "YaeBlog.dll", "serve"]

View File

@@ -1,7 +1,8 @@
using AngleSharp; using AngleSharp;
using YaeBlog.Abstraction; using Microsoft.Extensions.Options;
using YaeBlog.Abstractions;
using YaeBlog.Services; using YaeBlog.Services;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
using YaeBlog.Processors; using YaeBlog.Processors;
namespace YaeBlog.Extensions; namespace YaeBlog.Extensions;
@@ -27,22 +28,25 @@ public static class WebApplicationBuilderExtensions
.AddTransient<HeadlinePostRenderProcessor>() .AddTransient<HeadlinePostRenderProcessor>()
.AddTransient<EssayStylesPostRenderProcessor>() .AddTransient<EssayStylesPostRenderProcessor>()
.AddTransient<GiteaFetchService>() .AddTransient<GiteaFetchService>()
.AddTransient<BlogChangeWatcher>()
.AddTransient<BlogHotReloadService>()
.AddSingleton<GitHeapMapService>(); .AddSingleton<GitHeapMapService>();
return builder; return builder;
} }
public WebApplicationBuilder AddServer() public WebApplicationBuilder AddYaeCommand(string[] arguments)
{ {
builder.Services.AddHostedService<BlogHostedService>(); builder.Services.AddHostedService<YaeCommandService>(provider =>
return builder;
}
public WebApplicationBuilder AddWatcher()
{ {
builder.Services.AddTransient<BlogChangeWatcher>(); IEssayScanService essayScanService = provider.GetRequiredService<IEssayScanService>();
builder.Services.AddHostedService<BlogHotReloadService>(); IOptions<BlogOptions> blogOptions = provider.GetRequiredService<IOptions<BlogOptions>>();
ILogger<YaeCommandService> logger = provider.GetRequiredService<ILogger<YaeCommandService>>();
IHostApplicationLifetime applicationLifetime = provider.GetRequiredService<IHostApplicationLifetime>();
return new YaeCommandService(arguments, essayScanService, provider, blogOptions, logger,
applicationLifetime);
});
return builder; return builder;
} }

View File

@@ -1,4 +1,4 @@
using YaeBlog.Abstraction; using YaeBlog.Abstractions;
using YaeBlog.Processors; using YaeBlog.Processors;
using YaeBlog.Services; using YaeBlog.Services;

View File

@@ -1,8 +1,8 @@
using AngleSharp; using AngleSharp;
using AngleSharp.Dom; using AngleSharp.Dom;
using YaeBlog.Abstraction; using YaeBlog.Abstractions;
using YaeBlog.Extensions; using YaeBlog.Extensions;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Processors; namespace YaeBlog.Processors;

View File

@@ -1,7 +1,7 @@
using AngleSharp; using AngleSharp;
using AngleSharp.Dom; using AngleSharp.Dom;
using YaeBlog.Abstraction; using YaeBlog.Abstractions;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Processors; namespace YaeBlog.Processors;

View File

@@ -1,9 +1,9 @@
using AngleSharp; using AngleSharp;
using AngleSharp.Dom; using AngleSharp.Dom;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using YaeBlog.Abstraction; using YaeBlog.Abstractions;
using YaeBlog.Core.Exceptions; using YaeBlog.Core.Exceptions;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Processors; namespace YaeBlog.Processors;

View File

@@ -1,4 +1,22 @@
using YaeBlog.Commands; using YaeBlog.Components;
using YaeBlog.Extensions;
YaeBlogCommand command = new(); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
await command.RunAsync(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<App>()
.AddInteractiveServerRenderMode();
application.MapControllers();
await application.RunAsync();

View File

@@ -1,5 +1,5 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Services; namespace YaeBlog.Services;

View File

@@ -1,4 +1,4 @@
using YaeBlog.Abstraction; using YaeBlog.Abstractions;
namespace YaeBlog.Services; namespace YaeBlog.Services;

View File

@@ -1,7 +1,7 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using YaeBlog.Abstraction; using YaeBlog.Abstractions;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Services; namespace YaeBlog.Services;

View File

@@ -3,9 +3,9 @@ using System.Text.RegularExpressions;
using Imageflow.Bindings; using Imageflow.Bindings;
using Imageflow.Fluent; using Imageflow.Fluent;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using YaeBlog.Abstraction; using YaeBlog.Abstractions;
using YaeBlog.Core.Exceptions; using YaeBlog.Core.Exceptions;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
using YamlDotNet.Core; using YamlDotNet.Core;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;

View File

@@ -1,7 +1,7 @@
using DotNext; using DotNext;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using YaeBlog.Extensions; using YaeBlog.Extensions;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Services; namespace YaeBlog.Services;

View File

@@ -3,7 +3,7 @@ using System.Text.Json;
using DotNext; using DotNext;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using YaeBlog.Core.Exceptions; using YaeBlog.Core.Exceptions;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Services; namespace YaeBlog.Services;

View File

@@ -1,7 +1,7 @@
using Imageflow.Fluent; using Imageflow.Fluent;
using YaeBlog.Abstraction; using YaeBlog.Abstractions;
using YaeBlog.Core.Exceptions; using YaeBlog.Core.Exceptions;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Services; namespace YaeBlog.Services;

View File

@@ -1,5 +1,5 @@
using YaeBlog.Extensions; using YaeBlog.Extensions;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Services namespace YaeBlog.Services
{ {

View File

@@ -3,9 +3,9 @@ using System.Diagnostics;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Markdig; using Markdig;
using YaeBlog.Abstraction; using YaeBlog.Abstractions;
using YaeBlog.Core.Exceptions; using YaeBlog.Core.Exceptions;
using YaeBlog.Models; using YaeBlog.Abstractions.Models;
namespace YaeBlog.Services; namespace YaeBlog.Services;

View File

@@ -0,0 +1,281 @@
using System.CommandLine;
using System.CommandLine.Invocation;
using System.Text;
using Microsoft.Extensions.Options;
using YaeBlog.Abstractions;
using YaeBlog.Core.Exceptions;
using YaeBlog.Abstractions.Models;
namespace YaeBlog.Services;
public class YaeCommandService(
string[] arguments,
IEssayScanService essayScanService,
IServiceProvider serviceProvider,
IOptions<BlogOptions> blogOptions,
ILogger<YaeCommandService> 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<RendererService>();
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<BlogHotReloadService>();
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<string> 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<string> 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<bool> 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<BlogImageInfo> 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<string> 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<bool> 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<ImageCompressService>();
await imageCompressService.Compress(dryRun);
}
}

View File

@@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="../../third-party/BlazorSvgComponents/src/BlazorSvgComponents/BlazorSvgComponents.csproj" /> <ProjectReference Include="../../third-party/BlazorSvgComponents/src/BlazorSvgComponents/BlazorSvgComponents.csproj" />
<ProjectReference Include="..\YaeBlog.Abstractions\YaeBlog.Abstractions.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -27,6 +28,6 @@
<PropertyGroup> <PropertyGroup>
<ClientAssetsRestoreCommand>pnpm install</ClientAssetsRestoreCommand> <ClientAssetsRestoreCommand>pnpm install</ClientAssetsRestoreCommand>
<ClientAssetsBuildCommand>pwsh ../../build.ps1 tailwind</ClientAssetsBuildCommand> <ClientAssetsBuildCommand>pwsh tailwind.ps1</ClientAssetsBuildCommand>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -18,7 +18,7 @@
"Links": [ "Links": [
{ {
"Name": "Ichirinko", "Name": "Ichirinko",
"Description": "这是个大哥", "Description": "黑历史集合地naive的代价",
"Link": "https://ichirinko.top", "Link": "https://ichirinko.top",
"AvatarImage": "https://ichirinko-blog-img-1.oss-cn-shenzhen.aliyuncs.com/Pic_res/img/202209122110798.png" "AvatarImage": "https://ichirinko-blog-img-1.oss-cn-shenzhen.aliyuncs.com/Pic_res/img/202209122110798.png"
}, },

11
src/YaeBlog/tailwind.ps1 Normal file
View File

@@ -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
}