Compare commits
8 Commits
4de644036f
...
feat/code-
| Author | SHA1 | Date | |
|---|---|---|---|
|
939f2373e8
|
|||
|
6733bbbd2a
|
|||
|
e10c8e7e75
|
|||
|
45f15c9bd9
|
|||
|
a1b5af5b0c
|
|||
|
d8e4931d63
|
|||
|
462fbb28ac
|
|||
|
6ea14b186a
|
@@ -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
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
17
build.ps1
17
build.ps1
@@ -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
|
||||||
|
|||||||
344
source/posts/system-text-json.md
Normal file
344
source/posts/system-text-json.md
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
---
|
||||||
|
title: 使用System.Text.Json序列化和反序列化JSON
|
||||||
|
date: 2026-01-21T22:07:38.4297603+08:00
|
||||||
|
updateTime: 2026-01-21T22:07:38.4370636+08:00
|
||||||
|
tags:
|
||||||
|
- 技术笔记
|
||||||
|
- dotnet
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
如何使用`System.Text.Json`高效地序列化和反序列化JSON?
|
||||||
|
|
||||||
|
<!--more-->
|
||||||
|
|
||||||
|
### 序列化
|
||||||
|
|
||||||
|
序列化JSON几乎总是简单的,直接使用`JsonSerializer.Serialize`就可以序列化为字符串。
|
||||||
|
|
||||||
|
唯一需要注意的是,JSON理论上唯一的数字类型`number`默认是双精度浮点数,只能**精确地**表示53位(二进制)以下的整数。在对于`long`类型进行序列化时,虽然框架可以输出正确的数值,但是JavaScript中无法正确的解析。
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void LongSerializeTest()
|
||||||
|
{
|
||||||
|
JsonBody body = new(long.MaxValue - 1);
|
||||||
|
string output = JsonSerializer.Serialize(body);
|
||||||
|
// Output: {"Number":9223372036854775806}
|
||||||
|
outputHelper.WriteLine(output);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
上述的JSON字符串中在JavaScript中将会被解析为:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
因此在需要传递大整数的时候最好使用`String`。
|
||||||
|
|
||||||
|
### 反序列化
|
||||||
|
|
||||||
|
而反序列化中需要考虑的东西就很多了。
|
||||||
|
|
||||||
|
#### 使用记录声明反序列化的对象
|
||||||
|
|
||||||
|
在`System.Text.Json`的早期版本中,无法将JSON反序列化为`record`这类关键词声明的不可变类型,因为当时库的逻辑是首先调用类型的公共无参数构造函数构造对象,再使用setter为需要反序列化的属性赋值。在后来的版本中,序列化程序可以直接调用类型的构造函数进行反序列化,这就为反序列化到`record`和`struct`提供了方便。
|
||||||
|
|
||||||
|
例如可以使用如下的代码快速地进行反序列化:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private record JsonBody(int Code, string Username);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeserializeTest()
|
||||||
|
{
|
||||||
|
const string input = """
|
||||||
|
{
|
||||||
|
"code": 111,
|
||||||
|
"username": "ricardo"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
JsonBody? body = JsonSerializer.Deserialize<JsonBody>(input, s_serializerOptions);
|
||||||
|
Assert.NotNull(body);
|
||||||
|
|
||||||
|
Assert.Equal(111, body.Code);
|
||||||
|
Assert.Equal("ricardo", body.Username);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
但是这样进行反序列化有一个小小的坑,就是缺少对于空值的有效处理。例如对于下面的JSON,上面的代码都会正常地进行反序列化。
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void DeserializeFromNonexistFieldTest()
|
||||||
|
{
|
||||||
|
const string input = """
|
||||||
|
{
|
||||||
|
"code": 111
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
JsonBody? body = JsonSerializer.Deserialize<JsonBody>(input, s_serializerOptions);
|
||||||
|
Assert.NotNull(body);
|
||||||
|
|
||||||
|
Assert.Equal(111, body.Code);
|
||||||
|
Assert.Equal("", body.Username);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void DeserializeFromNullValueTest()
|
||||||
|
{
|
||||||
|
const string input = """
|
||||||
|
{
|
||||||
|
"code": 111,
|
||||||
|
"username": null
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
JsonBody? body = JsonSerializer.Deserialize<JsonBody>(input, s_serializerOptions);
|
||||||
|
Assert.NotNull(body);
|
||||||
|
|
||||||
|
Assert.Equal(111, body.Code);
|
||||||
|
Assert.Equal("", body.Username);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
但是对于返回结果的校验会发现`body.Username`实际上是一个空值。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
幸好,在.NET 9中为`JsonSerializerOptions`添加了一个尊重可为空注释的选项`RespectNullableAnnotations`,将这个选项设置为`true`可以在**一定程度上**缓解这个问题。打开这个开关之后,对于`"username": null`的反序列化就会抛出异常了。
|
||||||
|
|
||||||
|
但是针对第一段JSON,也就是缺少了`username`字段的反序列化并不会报错,这就是反序列化的第二个坑,这里先按下不表。
|
||||||
|
|
||||||
|
因为在.NET运行时的设计初期并没有考虑空安全这一至关重要的特性,因此在IL中并没有针对引用类型的不可为空性的显式抽象(虽然后续的C#编译器会为所有不可为空的应用类型添加属性元数据)。所以,针对如下元素的不可为空约束是无效的:
|
||||||
|
|
||||||
|
1. 顶级类型;
|
||||||
|
2. 集合的元素类型;
|
||||||
|
3. 任何含有泛型的属性、字段和构造函数参数。
|
||||||
|
|
||||||
|
例如,针对下面这个反序列化代码并不会报错,需要程序员自行处理其中的空值:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void DeserializeListTest()
|
||||||
|
{
|
||||||
|
const string input = """
|
||||||
|
{
|
||||||
|
"names": [
|
||||||
|
"1",
|
||||||
|
null,
|
||||||
|
"2"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
JsonListBody? body = JsonSerializer.Deserialize<JsonListBody>(input, s_serializerOptions);
|
||||||
|
Assert.NotNull(body);
|
||||||
|
|
||||||
|
foreach ((int i, string value) in body.Names.Index())
|
||||||
|
{
|
||||||
|
outputHelper.WriteLine($"{i} is null? {value is null}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
运行的输出结果提示第二个元素为空:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 需要才是需要,不为空并不一定不为空
|
||||||
|
|
||||||
|
在默认的反序列化行为中,如果反序列化对象的某一个属性并不在输入的JSON对象中,反序列化器并不为报错而是直接设置为null,这显然会给破环空安全的假定,即使打开了尊重空值注释也是这样。这在.NET文档中被称为**缺失值和空值**:
|
||||||
|
|
||||||
|
- **显式空值null**将会在`RespectNullableAnnontations=true`的情况下引发异常;
|
||||||
|
- **缺少的属性**不会引发任何异常,即使对应的属性被声明为不可为空。
|
||||||
|
|
||||||
|
为了让序列化程序确保缺少属性时会报错,需要将这个属性声明为**需要的**。这一点可以通过C#的`required`关键词或者`[Required]`属性来实现。
|
||||||
|
|
||||||
|
而且,这两种属性对于C#语言和序列化程序来说是正交的,即:
|
||||||
|
|
||||||
|
1. 可以有一个可以为空的必需属性:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
MyPoco poco = new() { Value = null }; // No compiler warnings.
|
||||||
|
|
||||||
|
class MyPoco
|
||||||
|
{
|
||||||
|
public required string? Value { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 可以有一个不可为空的可选属性:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
class MyPoco
|
||||||
|
{
|
||||||
|
public string Value { get; set; } = "default";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
但是对于`record`类型来说,前者在语义上是冗余的,语法上是错误的,后者则对于程序员带来了额外的心智负担,需要手动给每一个字段加上一个额外的注解。
|
||||||
|
|
||||||
|
考虑到序列化程序也支持使用有参数的公共构造函数,上面这两个属性对于构造函数的参数来说也是成立的:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
record MyPoco(
|
||||||
|
string RequiredNonNullable,
|
||||||
|
string? RequiredNullable,
|
||||||
|
string OptionalNonNullable = "default",
|
||||||
|
string? OptionalNullable = "default"
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
不过在.NET 9之前,所有构造函数的参数都被序列化程序认为是可选的。在.NET 9之后,`JsonSerializerOptions`添加了一个尊重必须构造函数参数的选项(别忘了对于`record`这类不可变对象的反序列化是通过构造函数来实现的)`RespectRequiredConstructorParameters`。在打开这个选项之后,针对缺少属性的反序列化就会正常报错了。
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static readonly JsonSerializerOptions s_serializerOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
RespectNullableAnnotations = true,
|
||||||
|
RespectRequiredConstructorParameters = true
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeserializeFromNonexistFieldTest()
|
||||||
|
{
|
||||||
|
const string input = """
|
||||||
|
{
|
||||||
|
"code": 111
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<JsonBody>(input, s_serializerOptions));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 反序列化为结构
|
||||||
|
|
||||||
|
结构作为值类型,虽然在函数之间传递时需要被拷贝而带来了额外的性能开销,但是也因为这一点而可以被直接分配在栈上,给GC带来的压力较小。因此在部分需要极端性能优化的场景可以直接针对`struct`进行反序列化。
|
||||||
|
|
||||||
|
`struct`的反序列化也是通过构造函数来实现的,序列化程序遵循如下的规则来选择构造函数:
|
||||||
|
|
||||||
|
1. 对于类,如果唯一的构造函数是参数化构造函数,则选择这一构造函数;
|
||||||
|
2. 对于结构或者具有多个构造函数的类,需要使用`[JsonConstructor]`手动指定需要使用的构造函数,否则**只会**使用公共无参构造函数(如果存在)。
|
||||||
|
|
||||||
|
因此,如果需要针对不可变的结构进行反序列化,需要加上`[JsonConstructor]`注解。例如,针对下面的代码,如果不加上注解,反序列化又会静默地失败。
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private struct JsonStruct
|
||||||
|
{
|
||||||
|
public int Id { get; }
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
[JsonConstructor]
|
||||||
|
public JsonStruct(int id, string name)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeserializeToStructTest()
|
||||||
|
{
|
||||||
|
const string input = """
|
||||||
|
{
|
||||||
|
"Id": 1,
|
||||||
|
"Name": "ricardo"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
JsonStruct r = JsonSerializer.Deserialize<JsonStruct>(input, s_serializerOptions);
|
||||||
|
Assert.Equal(1, r.Id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
为了简化语法,不可变的结构可以使用`readonly record struct`语法来替代:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private readonly record struct JsonRecordStruct(int Id, string Name);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeserializeToRecordStructTest()
|
||||||
|
{
|
||||||
|
const string input = """
|
||||||
|
{
|
||||||
|
"Id": 1,
|
||||||
|
"Name": "ricardo"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
JsonRecordStruct r = JsonSerializer.Deserialize<JsonRecordStruct>(input, s_serializerOptions);
|
||||||
|
Assert.Equal(1, r.Id);
|
||||||
|
Assert.Equal("ricardo", r.Name);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
不过这里有一个很奇怪的点,使用`readonly record struct`语法之后就不需要`[JsonConstructor]`了。
|
||||||
|
|
||||||
|
可以实验一下是`readonly`还是`record`发挥了作用。
|
||||||
|
|
||||||
|
在仅仅添加了`readonly`的情况下,反序列化不会成功:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private readonly struct JsonReadonlyStruct
|
||||||
|
{
|
||||||
|
public int Id { get; }
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public JsonReadonlyStruct(int id, string name)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeserializeToReadonlyStructTest()
|
||||||
|
{
|
||||||
|
const string input = """
|
||||||
|
{
|
||||||
|
"Id": 1,
|
||||||
|
"Name": "ricardo"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
JsonReadonlyStruct r = JsonSerializer.Deserialize<JsonReadonlyStruct>(input, s_serializerOptions);
|
||||||
|
Assert.Equal(0, r.Id);
|
||||||
|
Assert.Null(r.Name);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
而在仅仅加上`record`的情况下,序列化程序就可以选择正确的构造函数了:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private record struct JsonRecordStruct(int Id, string Name);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeserializeToRecordStructTest()
|
||||||
|
{
|
||||||
|
const string input = """
|
||||||
|
{
|
||||||
|
"Id": 1,
|
||||||
|
"Name": "ricardo"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
JsonRecordStruct r = JsonSerializer.Deserialize<JsonRecordStruct>(input, s_serializerOptions);
|
||||||
|
Assert.Equal(1, r.Id);
|
||||||
|
Assert.Equal("ricardo", r.Name);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 不过这样说来`readonly record struct`中的`readonly`似乎是冗余的?
|
||||||
|
>
|
||||||
|
> 原来,`record struct`声明的对象是可变的。详见文档中对于[不可变性](https://learn.microsoft.com/zh-cn/dotnet/csharp/language-reference/builtin-types/record#immutability)的描述。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
BIN
source/posts/system-text-json/image-20260120153508775.webp
LFS
Normal file
BIN
source/posts/system-text-json/image-20260120153508775.webp
LFS
Normal file
Binary file not shown.
BIN
source/posts/system-text-json/image-20260120172747047.webp
LFS
Normal file
BIN
source/posts/system-text-json/image-20260120172747047.webp
LFS
Normal file
Binary file not shown.
BIN
source/posts/system-text-json/image-20260121221219618.webp
LFS
Normal file
BIN
source/posts/system-text-json/image-20260121221219618.webp
LFS
Normal file
Binary file not shown.
@@ -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
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using YaeBlog.Models;
|
using YaeBlog.Abstractions.Models;
|
||||||
|
|
||||||
namespace YaeBlog.Abstraction;
|
namespace YaeBlog.Abstractions;
|
||||||
|
|
||||||
public interface IEssayScanService
|
public interface IEssayScanService
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using YaeBlog.Models;
|
using YaeBlog.Abstractions.Models;
|
||||||
|
|
||||||
namespace YaeBlog.Abstraction;
|
namespace YaeBlog.Abstractions;
|
||||||
|
|
||||||
public interface IPostRenderProcessor
|
public interface IPostRenderProcessor
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using YaeBlog.Models;
|
using YaeBlog.Abstractions.Models;
|
||||||
|
|
||||||
namespace YaeBlog.Abstraction;
|
namespace YaeBlog.Abstractions;
|
||||||
|
|
||||||
public interface IPreRenderProcessor
|
public interface IPreRenderProcessor
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace YaeBlog.Models;
|
namespace YaeBlog.Abstractions.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 单个博客文件的所有数据和元数据
|
/// 单个博客文件的所有数据和元数据
|
||||||
@@ -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>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace YaeBlog.Models;
|
namespace YaeBlog.Abstractions.Models;
|
||||||
|
|
||||||
public record BlogEssay(
|
public record BlogEssay(
|
||||||
string Title,
|
string Title,
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
@@ -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>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace YaeBlog.Models;
|
namespace YaeBlog.Abstractions.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 友链模型类
|
/// 友链模型类
|
||||||
@@ -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>
|
||||||
{
|
{
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -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);
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace YaeBlog.Models;
|
namespace YaeBlog.Abstractions.Models;
|
||||||
|
|
||||||
public class MarkdownMetadata
|
public class MarkdownMetadata
|
||||||
{
|
{
|
||||||
9
src/YaeBlog.Abstractions/YaeBlog.Abstractions.csproj
Normal file
9
src/YaeBlog.Abstractions/YaeBlog.Abstractions.csproj
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@using YaeBlog.Models
|
@using YaeBlog.Abstractions.Models
|
||||||
@using YaeBlog.Services
|
@using YaeBlog.Services
|
||||||
@inject GitHeapMapService GitHeapMapInstance
|
@inject GitHeapMapService GitHeapMapInstance
|
||||||
|
|
||||||
|
|||||||
@@ -25,16 +25,23 @@
|
|||||||
正在明光村幼儿园附属研究生院攻读计算机科学与技术的硕士学位,研究AI编译器和异构编译器。
|
正在明光村幼儿园附属研究生院攻读计算机科学与技术的硕士学位,研究AI编译器和异构编译器。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="my-2">
|
<p class="my-1">
|
||||||
一般在互联网上使用<span class="italic">初冬的朝阳</span>或者<span
|
一般在互联网上使用<span class="italic">初冬的朝阳</span>或者
|
||||||
class="italic">jackfiled</span>的名字活动。
|
<span class="italic">jackfiled</span>的名字活动。
|
||||||
<span class="line-through">(都是ICP备案过的人了,网名似乎没有太大的用处)</span>
|
<span class="line-through">(都是ICP备案过的人了,网名似乎没有太大的用处)</span>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="my-1">
|
||||||
|
Fun Fact:<span class="italic">jackfiled</span>这个名字来自于2020年我使用链接在树莓派上的9英寸屏幕注册
|
||||||
|
GitHub的一时兴起,并没有任何特定的含义。
|
||||||
|
<span class="italic">初冬的朝阳</span>则是源自初中,具体典故已不可考。
|
||||||
|
至少到目前为止,还没有在要求唯一ID的平台遇见重名的情况。
|
||||||
|
<span class="line-through">我的真实名字似乎也是如此。</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-4">
|
<div class="my-4">
|
||||||
<p class="my-1">
|
<p class="my-1">
|
||||||
主要是一个C#程序员,目前也在尝试写一点Rust。
|
主要是一个.NET程序员,目前也在尝试写一点Rust。
|
||||||
<span class="line-through">
|
<span class="line-through">
|
||||||
总体上对于编程语言的态度是“大家都是我的翅膀.jpg”。
|
总体上对于编程语言的态度是“大家都是我的翅膀.jpg”。
|
||||||
</span>
|
</span>
|
||||||
@@ -46,7 +53,7 @@
|
|||||||
常常因为现实的压力而写一些C/C++,现在就在和MLIR殊死搏斗。
|
常常因为现实的压力而写一些C/C++,现在就在和MLIR殊死搏斗。
|
||||||
</p>
|
</p>
|
||||||
<p class="my-1">
|
<p class="my-1">
|
||||||
日常使用Arch Linux。
|
日常使用Arch Linux,KISS的原则深得我心。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,7 +62,7 @@
|
|||||||
100%社恐。日常生活是宅在电脑前面自言自语。
|
100%社恐。日常生活是宅在电脑前面自言自语。
|
||||||
</p>
|
</p>
|
||||||
<p class="my-1">
|
<p class="my-1">
|
||||||
兴趣活动是读书和看番,目前在玩戴森球计划和三角洲。
|
兴趣活动是读书和看番,目前在玩戴森球计划和三角洲。2022年~2024年的时候沉迷于原神,现在偶尔还会登上去过一过剧情。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -57,16 +57,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="py-5">
|
<div class="pt-5 pb-1">
|
||||||
<p class="text-lg">恕我不能亲自为您沏茶,还是非常欢迎您来,能在广阔的互联网世界中发现这里实属不易。</p>
|
<p class="text-lg">恕我不能亲自为您沏茶,还是非常欢迎您来,能在广阔的互联网世界中发现这里实属不易。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-lg pt-2">
|
<div class="text-lg">
|
||||||
<p class="py-1">
|
<p class="py-1">
|
||||||
正在攻读计算机科学与技术的硕士学位,研究方向是AI编译和异构编译!
|
正在攻读计算机科学与技术的硕士学位,研究方向是AI编译和异构编译!
|
||||||
|
喜欢优雅的代码,香甜的蛋糕等等一切可爱的事物。
|
||||||
|
更多的情报请见<Anchor Text="关于" Address="/about/"></Anchor>。
|
||||||
</p>
|
</p>
|
||||||
<p class="py-1">
|
<p class="py-1">
|
||||||
喜欢优雅的代码,香甜的蛋糕等等一切可爱的事物。
|
|
||||||
</p>
|
</p>
|
||||||
<p class="py-1">
|
<p class="py-1">
|
||||||
<Anchor Address="/blog/" Text="个人博客"/>中收集了我的各种奇思妙想,如果感兴趣欢迎移步。
|
<Anchor Address="/blog/" Text="个人博客"/>中收集了我的各种奇思妙想,如果感兴趣欢迎移步。
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="py-1">
|
<p class="py-1">
|
||||||
日常的代码开发使用自建的<Anchor Text="Gitea" Address="https://git.rrricardo.top" NewPage="@(true)"/>进行,个人
|
日常的代码开发使用自建的<Anchor Text="Gitea" Address="https://git.rrricardo.top" NewPage="@(true)"/>进行,个人
|
||||||
开发的各种项目都可以在上面找到。
|
开发的各种项目都可以在上面找到。下面的热力图展示了我在Git上的各种动态<span class="line-through">(Everything as Code)</span>。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using YaeBlog.Abstraction;
|
using YaeBlog.Abstractions;
|
||||||
using YaeBlog.Processors;
|
using YaeBlog.Processors;
|
||||||
using YaeBlog.Services;
|
using YaeBlog.Services;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using YaeBlog.Abstraction;
|
using YaeBlog.Abstractions;
|
||||||
|
|
||||||
namespace YaeBlog.Services;
|
namespace YaeBlog.Services;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using YaeBlog.Extensions;
|
using YaeBlog.Extensions;
|
||||||
using YaeBlog.Models;
|
using YaeBlog.Abstractions.Models;
|
||||||
|
|
||||||
namespace YaeBlog.Services
|
namespace YaeBlog.Services
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
281
src/YaeBlog/Services/YaeCommandService.cs
Normal file
281
src/YaeBlog/Services/YaeCommandService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
11
src/YaeBlog/tailwind.ps1
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user