4 Commits

Author SHA1 Message Date
4de644036f refact: move projects into src directory.
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-02-26 15:34:08 +08:00
0d10946ec1 fix: compress images linked by native html <img> tag.
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-01-22 16:11:18 +08:00
a3791596da feat: third-party/BlazorSvgComponents
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-01-22 14:53:44 +08:00
2be09b8319 feat: rewrite about page for 2026.
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-01-20 01:10:23 +08:00
75 changed files with 585 additions and 1221 deletions

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -1,344 +0,0 @@
---
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中将会被解析为
![image-20260120153508775](./system-text-json/image-20260120153508775.webp)
因此在需要传递大整数的时候最好使用`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`实际上是一个空值。
![image-20260121221219618](./system-text-json/image-20260121221219618.webp)
幸好,在.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}");
}
}
```
运行的输出结果提示第二个元素为空:
![image-20260120172747047](./system-text-json/image-20260120172747047.webp)
#### 需要才是需要,不为空并不一定不为空
在默认的反序列化行为中如果反序列化对象的某一个属性并不在输入的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)的描述。

View File

@@ -1,215 +0,0 @@
---
title: Tarjan算法与实现
date: 2026-03-28T21:53:45.1681856+08:00
updateTime: 2026-03-28T21:53:45.1733146+08:00
tags:
- 技术笔记
- 算法
---
Tarjan算法是一类用于无向图中割边和割点的算法。
<!--more-->
## Tarjan算法
Tarjan算法是图论中非常常用的一种算法基于深度优先搜索DFS基础版本的Tarjan算法用于求解无向图中的割点和桥。基于此可以求解图论中的一系列问题例如无向图的双连通分量、有向图的强连通分量等问题。
Tarjan算法由计算机科学家Robert Tarjan在1972年于论文*Depth-First Search And Linear Graph Algorithms*中提出。Robert Tarjan是一位著名的计算机科学家解决了图论中的一系列重大问题同时也是斐波那契堆Fibonacci Heap和伸展树Splay Tree的开发者之一。他于1986年获得了图灵奖目前仍在普林斯顿大学担任教职。
## 无向图的割点与桥
如果一个图中所有的边都是无向边,则称之为无向图。
### 割点
如果从无向图中删除节点x和所有与节点x关联的边之后图将会被分成两个或者两个以上不相连的子图那么节点x就是这个图的割点。下图中标注为红色的点就是该图的割点。
![image-20260328213522542](./tarjan-bridge/image-20260328213522542.webp)
### 桥
如果从图中删除边e之后图将分裂为两个不相连的子图那么就称e是图的桥或者割边。
![image-20260328213554309](./tarjan-bridge/image-20260328213554309.webp)
图中被标注为红色的边就是该图的桥。
## 求解图中的割点
Tarjan算法中为了求解桥和割点首先定义了如下几个概念。
### 时间戳
时间戳用来标记图中每个节点在进行深度优先搜索的过程中被访问的时间顺序,这个概念起始也就是在遍历的时候给每个节点编号。
这个编号用`search_number[x]`来表示其中的x是节点。
### 搜索树
在图中如果从一个节点x出发进行深度优先的搜索在搜索的过程中每个节点只能访问一次所有被访问的节点可以构成一棵树这棵树就被称为无向连通图的搜索树。
### 追溯值
追溯值的定义和计算是Tarjan算法的核心。
追溯值被定义为从当前节点x作为搜索树的根节点出现能够访问到的所有节点中时间戳的最小值被记为`low[x]`
定义中主要的限定条件是“能够访问到的所有节点”,主要考虑的是如下两种访问方式:
- 这个节点在以x为根的搜索树上
- 通过一条不属于搜索树的边,可以到达搜索树的节点。
例如上图的例子中考虑直接从节点1出发开始深度优先的遍历此时使用的遍历顺序是节点1、节点2、节点3、节点4、节点5。
![image-20260328213641303](./tarjan-bridge/image-20260328213641303.webp)
当遍历到节点5时考虑以节点5为根的搜索树可以认为此时的搜索树中只有节点5一个节点可以发现有两条不属于搜索树的边(2, 5)和(1, 5)使得节点1和节点2成为了上述“可以访问到的节点”因此将节点5的追溯值更新为1。
![image-20260328213702466](./tarjan-bridge/image-20260328213702466.webp)
此时算法按照深度优先搜索的顺序开始回溯在回溯的过程中逐步更新当前节点的追溯值此时就是按照上面“可以访问的所有节点”中的搜索树情形工作了。例如当回溯到节点3时可以认为存在以节点3为根节点的搜索树{3, 4, 5}其中追溯值的最小值为1, 将节点3的追溯值更新为1。
![image-20260328213720719](./tarjan-bridge/image-20260328213720719.webp)
### 桥的判定法则
在无向图中,对于一条边`e = (u ,v)`,如果满足`search_number[u] < low[v]`,那么该边就是图中的一个桥。
这个条件所蕴含的意思是节点u被访问的时间要小于优先于以下所有这些节点被访问的时间
- 以节点v为根节点的搜索树中的所有节点
- 通过一条非搜索树上的边,能否到达搜索树的所有节点。
## 实现
以下以[1192. 查找集群内的关键连接 - 力扣LeetCode](https://leetcode.cn/problems/critical-connections-in-a-network/description/)为例给出Tarjan算法的实现。
```cpp
namespace {
/// Graph structure.
/// Store the graph using linked forwarded stars.
/// The linked forwarded stars store the graph using linked list.
///
/// To accelerate the loading and storing, use array to simulate the linked
/// list.
struct Graph {
explicit Graph(const size_t nodeCount, const size_t edgeCount) {
// The edgeID starts from 2, as 0 is used as null value.
// And to find the reverse edge by i ^ 1, so 0 and 1 are both skipped.
endNodes = vector<size_t>(edgeCount + 2, 0);
nextEdges = vector<size_t>(edgeCount + 2, 0);
headEdges = vector<size_t>(nodeCount, 0);
}
void addEdge(const size_t x, const size_t y) {
endNodes[edgeID] = y;
nextEdges[edgeID] = headEdges[x];
headEdges[x] = edgeID;
edgeID += 1;
}
vector<bool> calculateBridges() {
// Initialize values used by tarjan algorithm.
const auto nodeCount = headEdges.size();
bridges = vector(endNodes.size(), false);
nodeIDs = vector<size_t>(nodeCount, 0);
lowValues = vector<size_t>(nodeCount, 0);
number = 1;
for (auto i = 0; i < nodeCount; i++) {
if (nodeIDs[i] == 0) {
tarjan(i, 0);
}
}
return bridges;
}
private:
size_t edgeID = 2;
/// Represent the end node of edge i.
vector<size_t> endNodes;
/// Represent the next edge of edge i.
vector<size_t> nextEdges;
/// Represent the head edge of node i.
/// Also, head of simulated linked list.
vector<size_t> headEdges;
vector<bool> bridges;
/// Represent timestamp of node i, 0 is used as unvisited.
vector<size_t> nodeIDs;
vector<size_t> lowValues;
size_t number = 1;
void tarjan(const size_t node, const size_t inEdge) {
nodeIDs[node] = lowValues[node] = number;
number += 1;
for (auto i = headEdges[node]; i != 0; i = nextEdges[i]) {
// If the next node is not visited.
if (const auto end = endNodes[i]; nodeIDs[end] == 0) {
tarjan(end, i);
lowValues[node] = min(lowValues[node], lowValues[end]);
if (lowValues[end] > nodeIDs[node]) {
// Subtract 2 as the edge ID starts from 2.
bridges[i - 2] = true;
bridges[(i ^ 1) - 2] = true;
}
} else {
// If edge i is visited and edge i is not the coming edge.
if (i != (inEdge ^ 1)) {
lowValues[node] = min(lowValues[node], nodeIDs[end]);
}
}
}
}
};
} // namespace
class Solution {
public:
vector<vector<int>> criticalConnections(int n,
vector<vector<int>> &connections) {
// To store the undirected graph, double the edge count.
auto g = Graph{static_cast<size_t>(n), connections.size() * 2};
for (const auto &edge : connections) {
g.addEdge(edge[0], edge[1]);
g.addEdge(edge[1], edge[0]);
}
auto bridges = g.calculateBridges();
vector<vector<int>> result;
for (auto i = 0; i < bridges.size(); i = i + 2) {
if (bridges[i]) {
const auto &edge = connections[i / 2];
result.push_back(edge);
}
}
return result;
}
};
```
### 链式前向星
在上面的实现中使用了一种较为高效的图存储方法-链式前向星Linked Forward Star
链式前向星是一种类似于邻接表的图存储方法,提供了较为高效的边遍历方法。这种方法的本质上是按节点聚合的边链表,不过在上面的实现中使用了数组来存储链表的头结点和每个节点的下一个节点指针。
同时这种存储方法还提供了一种非常方便的反向边查找方法考虑在存储无向图中的边时将一条边成对的存储在数组中由此针对任意一条边i`i ^ 1`就是这条边的反向边。

View File

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

View File

@@ -1,8 +1,7 @@
using DotNext;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using YaeBlog.Abstractions.Models;
using YaeBlog.Models;
using YaeBlog.Services;
namespace YaeBlog.Tests;
@@ -10,7 +9,6 @@ namespace YaeBlog.Tests;
public sealed class GiteaFetchServiceTests
{
private static readonly Mock<IOptions<GiteaOptions>> s_giteaOptionsMock = new();
private static readonly Mock<ILogger<GiteaFetchService>> s_logger = new();
private readonly GiteaFetchService _giteaFetchService;
public GiteaFetchServiceTests()
@@ -18,10 +16,12 @@ public sealed class GiteaFetchServiceTests
s_giteaOptionsMock.SetupGet(o => o.Value)
.Returns(new GiteaOptions
{
BaseAddress = "https://git.rrricardo.top/api/v1/", HeatMapUsername = "jackfiled"
BaseAddress = "https://git.rrricardo.top/api/v1/",
ApiKey = "7e33617e5d084199332fceec3e0cb04c6ddced55",
HeatMapUsername = "jackfiled"
});
_giteaFetchService = new GiteaFetchService(s_giteaOptionsMock.Object, new HttpClient(), s_logger.Object);
_giteaFetchService = new GiteaFetchService(s_giteaOptionsMock.Object, new HttpClient());
}
[Fact]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,296 @@
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 YaeBlog.Abstractions
@using YaeBlog.Abstractions.Models
@using YaeBlog.Abstraction
@using YaeBlog.Models
@inject IEssayContentService Contents
@inject IOptions<BlogOptions> Options

View File

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

View File

@@ -7,13 +7,10 @@
<Anchor Address="https://dotnet.microsoft.com" Text="@DotnetVersion"/>
驱动。
</p>
@if (!string.IsNullOrEmpty(BuildCommitId))
{
<p class="text-md">
Build Commit #
<Anchor Address="@BuildCommitUrl" Text="@BuildCommitId" NewPage="true"/>
</p>
}
<p class="text-md">
Build Commit #
<Anchor Address="@BuildCommitUrl" Text="@BuildCommitId"/>
</p>
</div>
<div>
@@ -27,7 +24,7 @@
{
private static string DotnetVersion => $".NET {Environment.Version}";
private static string? BuildCommitId => Environment.GetEnvironmentVariable("COMMIT_ID");
private static string BuildCommitId => Environment.GetEnvironmentVariable("COMMIT_ID") ?? "local_build";
private static string BuildCommitUrl => $"https://git.rrricardo.top/jackfiled/YaeBlog/commit/{BuildCommitId}";
}

View File

@@ -1,4 +1,4 @@
@using YaeBlog.Abstractions.Models
@using YaeBlog.Models
@using YaeBlog.Services
@inject GitHeapMapService GitHeapMapInstance
@@ -7,13 +7,13 @@
<SvgGroup Transform="@GlobalMonthTransform">
@foreach ((int i, string text) in _monthIndices)
{
<SvgText Content="@text" Transform="@(MonthTextTransform(i))" Class="text-[8px] font-light"/>
<SvgText Content="@text" Transform="@(MonthTextTransform(i))" Class="text-[10px]"/>
}
</SvgGroup>
<SvgGroup Transform="@GlobalWeekTransform">
@foreach ((int i, string text) in Weekdays.Index())
{
<SvgText Content="@text" Transform="@(DayTextTransform(i))" Class="text-[8px] font-light"/>
<SvgText Content="@text" Transform="@(DayTextTransform(i))" Class="text-[10px]"/>
}
</SvgGroup>
<SvgGroup Transform="@GlobalMapTransform">
@@ -23,8 +23,7 @@
@foreach ((int j, GitContributionItem item) in contribution.Contributions.Index())
{
<Rectangle Width="@Width" Height="@Width" Transform="@(WeekdayGridTransform(j))"
Class="@(GetColorByContribution(item.ContributionCount))"
Id="@(item.ItemId)"/>
Class="@(GetColorByContribution(item.ContributionCount))"/>
}
</SvgGroup>
}
@@ -119,4 +118,5 @@
_ => "fill-blue-800"
};
}
}

View File

@@ -24,5 +24,5 @@
@Body
</div>
<Footer/>
<Foonter/>
</main>

View File

@@ -32,5 +32,5 @@
</div>
</div>
<Footer/>
<Foonter/>
</main>

View File

@@ -6,7 +6,7 @@
<div class="flex flex-col">
<div>
<h1 class="text-4xl page-starter">关于</h1>
<h1 class="text-4xl">关于</h1>
</div>
<div class="py-4">
@@ -25,23 +25,16 @@
正在明光村幼儿园附属研究生院攻读计算机科学与技术的硕士学位研究AI编译器和异构编译器。
</p>
<p class="my-1">
一般在互联网上使用<span class="italic">初冬的朝阳</span>或者
<span class="italic">jackfiled</span>的名字活动。
<p class="my-2">
一般在互联网上使用<span class="italic">初冬的朝阳</span>或者<span
class="italic">jackfiled</span>的名字活动。
<span class="line-through">都是ICP备案过的人了网名似乎没有太大的用处</span>
</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 class="my-4">
<p class="my-1">
主要是一个.NET程序员目前也在尝试写一点Rust。
主要是一个C#程序员目前也在尝试写一点Rust。
<span class="line-through">
总体上对于编程语言的态度是“大家都是我的翅膀.jpg”。
</span>
@@ -53,7 +46,7 @@
常常因为现实的压力而写一些C/C++现在就在和MLIR殊死搏斗。
</p>
<p class="my-1">
日常使用Arch LinuxKISS的原则深得我心
日常使用Arch Linux。
</p>
</div>
@@ -62,7 +55,7 @@
100%社恐。日常生活是宅在电脑前面自言自语。
</p>
<p class="my-1">
兴趣活动是读书和看番,目前在玩戴森球计划和三角洲。2022年~2024年的时候沉迷于原神现在偶尔还会登上去过一过剧情。
兴趣活动是读书和看番,目前在玩戴森球计划和三角洲。
</p>
</div>
</div>

View File

@@ -1,6 +1,6 @@
@page "/blog/archives"
@using YaeBlog.Abstractions
@using YaeBlog.Abstractions.Models
@using YaeBlog.Abstraction
@using YaeBlog.Models
@inject IEssayContentService Contents
@@ -10,7 +10,7 @@
<div class="flex flex-col">
<div>
<h1 class="text-4xl page-starter">归档</h1>
<h1 class="text-4xl">归档</h1>
</div>
<div class="py-4">

View File

@@ -1,6 +1,6 @@
@page "/blog"
@using YaeBlog.Abstractions
@using YaeBlog.Abstractions.Models
@using YaeBlog.Abstraction
@using YaeBlog.Models
@inject IEssayContentService Contents
@inject NavigationManager NavigationInstance
@@ -10,7 +10,6 @@
</PageTitle>
<div>
<div class="page-starter"></div>
<div class="grid grid-cols-4">
<div class="col-span-4 md:col-span-3">
@foreach (BlogEssay essay in _essays)

View File

@@ -1,7 +1,7 @@
@page "/blog/essays/{BlogKey}"
@using System.Text.Encodings.Web
@using YaeBlog.Abstractions
@using YaeBlog.Abstractions.Models
@using YaeBlog.Abstraction
@using YaeBlog.Models
@inject IEssayContentService Contents
@inject NavigationManager NavigationInstance
@@ -14,7 +14,7 @@
<div>
<div class="flex flex-col items-center">
<div>
<h1 id="title" class="text-4xl page-starter">@(_essay!.Title)</h1>
<h1 id="title" class="text-4xl">@(_essay!.Title)</h1>
</div>
<div class="flex flex-row gap-4 py-2">

View File

@@ -1,6 +1,6 @@
@page "/friends"
@using Microsoft.Extensions.Options
@using YaeBlog.Abstractions.Models
@using YaeBlog.Models
@inject IOptions<BlogOptions> BlogOptionInstance
<PageTitle>
@@ -9,7 +9,7 @@
<div class="flex flex-col">
<div>
<h1 class="text-4xl page-starter">
<h1 class="text-4xl">
友链
</h1>
</div>

View File

@@ -1,6 +1,6 @@
@page "/"
@using YaeBlog.Abstractions
@using YaeBlog.Abstractions.Models
@using YaeBlog.Abstraction
@using YaeBlog.Models
@inject IEssayContentService EssayContentInstance
<PageTitle>
@@ -17,7 +17,7 @@
<div class="col-span-3 md:col-span-2">
<div class="flex flex-col gap-y-3 items-center md:items-start md:px-6">
<div class="">
<div class="text-3xl font-bold page-starter">初冬的朝阳</div>
<div class="text-3xl font-bold">初冬的朝阳</div>
</div>
<div class="">
@@ -57,17 +57,16 @@
</div>
</div>
<div class="pt-5 pb-1">
<div class="py-5">
<p class="text-lg">恕我不能亲自为您沏茶,还是非常欢迎您来,能在广阔的互联网世界中发现这里实属不易。</p>
</div>
<div class="text-lg">
<div class="text-lg pt-2">
<p class="py-1">
正在攻读计算机科学与技术的硕士学位研究方向是AI编译和异构编译
喜欢优雅的代码,香甜的蛋糕等等一切可爱的事物。
更多的情报请见<Anchor Text="关于" Address="/about/"></Anchor>。
</p>
<p class="py-1">
喜欢优雅的代码,香甜的蛋糕等等一切可爱的事物。
</p>
<p class="py-1">
<Anchor Address="/blog/" Text="个人博客"/>中收集了我的各种奇思妙想,如果感兴趣欢迎移步。
@@ -80,7 +79,7 @@
</p>
<p class="py-1">
日常的代码开发使用自建的<Anchor Text="Gitea" Address="https://git.rrricardo.top" NewPage="@(true)"/>进行,个人
开发的各种项目都可以在上面找到。下面的热力图展示了我在Git上的各种动态<span class="line-through">Everything as Code</span>。
开发的各种项目都可以在上面找到。
</p>
</div>

View File

@@ -5,7 +5,7 @@
</PageTitle>
<div>
<h3 class="text-3xl page-starter">NotFound!</h3>
<h3 class="text-3xl">NotFound!</h3>
</div>
@code {

View File

@@ -1,7 +1,7 @@
@page "/blog/tags/"
@using System.Text.Encodings.Web
@using YaeBlog.Abstractions
@using YaeBlog.Abstractions.Models
@using YaeBlog.Abstraction
@using YaeBlog.Models
@inject IEssayContentService Contents
@inject NavigationManager NavigationInstance
@@ -11,7 +11,7 @@
</PageTitle>
<div class="flex flex-col">
<div class="page-starter">
<div>
@if (TagName is null)
{
<h1 class="text-4xl">标签</h1>

View File

@@ -9,6 +9,6 @@
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)"></RouteView>
}
<FocusOnNavigate RouteData="routeData" Selector="page-starter"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>

View File

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

View File

@@ -18,17 +18,5 @@ public static class DateOnlyExtensions
};
}
}
public int DayNumberOfWeek
{
get
{
return date.DayOfWeek switch
{
DayOfWeek.Sunday => 7,
_ => (int)date.DayOfWeek + 1
};
}
}
}
}

View File

@@ -1,87 +0,0 @@
using AngleSharp;
using Microsoft.Extensions.Options;
using YaeBlog.Abstractions;
using YaeBlog.Services;
using YaeBlog.Abstractions.Models;
using YaeBlog.Processors;
namespace YaeBlog.Extensions;
public static class HostApplicationBuilderExtensions
{
extension(IHostApplicationBuilder builder)
{
public ConsoleInfoService AddYaeCommand(string[] arguments)
{
builder.AddCommonServices();
builder.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning);
builder.Services.AddTransient<ImageCompressService>();
builder.Services.AddHostedService<YaeCommandService>(provider =>
{
IEssayScanService essayScanService = provider.GetRequiredService<IEssayScanService>();
ImageCompressService imageCompressService = provider.GetRequiredService<ImageCompressService>();
ConsoleInfoService consoleInfoService = provider.GetRequiredService<ConsoleInfoService>();
IOptions<BlogOptions> blogOptions = provider.GetRequiredService<IOptions<BlogOptions>>();
ILogger<YaeCommandService> logger = provider.GetRequiredService<ILogger<YaeCommandService>>();
IHostApplicationLifetime hostApplicationLifetime =
provider.GetRequiredService<IHostApplicationLifetime>();
return new YaeCommandService(arguments, essayScanService, imageCompressService, consoleInfoService,
hostApplicationLifetime, blogOptions, logger);
});
ConsoleInfoService infoService = new();
builder.Services.AddSingleton<ConsoleInfoService>(_ => infoService);
return infoService;
}
private void AddCommonServices()
{
builder.Services.AddHttpClient()
.AddMarkdig()
.AddYamlParser();
builder.ConfigureOptions<BlogOptions>(BlogOptions.OptionName)
.ConfigureOptions<GiteaOptions>(GiteaOptions.OptionName);
builder.Services.AddSingleton<IEssayScanService, EssayScanService>();
}
private IHostApplicationBuilder ConfigureOptions<T>(string optionSectionName) where T : class
{
builder.Services
.AddOptions<T>()
.Bind(builder.Configuration.GetSection(optionSectionName))
.ValidateDataAnnotations();
return builder;
}
}
extension(WebApplicationBuilder builder)
{
public WebApplicationBuilder AddYaeServer(ConsoleInfoService consoleInfoService)
{
builder.AddCommonServices();
builder.Services.AddSingleton<AngleSharp.IConfiguration>(_ => Configuration.Default)
.AddSingleton<ConsoleInfoService>(_ => consoleInfoService)
.AddSingleton<IEssayScanService, EssayScanService>()
.AddSingleton<RendererService>()
.AddSingleton<IEssayContentService, EssayContentService>()
.AddTransient<ImagePostRenderProcessor>()
.AddTransient<HeadlinePostRenderProcessor>()
.AddTransient<EssayStylesPostRenderProcessor>()
.AddTransient<GiteaFetchService>()
.AddTransient<BlogChangeWatcher>()
.AddTransient<BlogHotReloadService>()
.AddSingleton<GitHeapMapService>();
builder.Services.AddHostedService<StartServerService>();
return builder;
}
}
}

View File

@@ -0,0 +1,59 @@
using AngleSharp;
using YaeBlog.Abstraction;
using YaeBlog.Services;
using YaeBlog.Models;
using YaeBlog.Processors;
namespace YaeBlog.Extensions;
public static class WebApplicationBuilderExtensions
{
extension(WebApplicationBuilder builder)
{
public WebApplicationBuilder AddYaeBlog()
{
builder.ConfigureOptions<BlogOptions>(BlogOptions.OptionName)
.ConfigureOptions<GiteaOptions>(GiteaOptions.OptionName);
builder.Services.AddHttpClient()
.AddMarkdig()
.AddYamlParser();
builder.Services.AddSingleton<AngleSharp.IConfiguration>(_ => Configuration.Default)
.AddSingleton<IEssayScanService, EssayScanService>()
.AddSingleton<RendererService>()
.AddSingleton<IEssayContentService, EssayContentService>()
.AddTransient<ImagePostRenderProcessor>()
.AddTransient<HeadlinePostRenderProcessor>()
.AddTransient<EssayStylesPostRenderProcessor>()
.AddTransient<GiteaFetchService>()
.AddSingleton<GitHeapMapService>();
return builder;
}
public WebApplicationBuilder AddServer()
{
builder.Services.AddHostedService<BlogHostedService>();
return builder;
}
public WebApplicationBuilder AddWatcher()
{
builder.Services.AddTransient<BlogChangeWatcher>();
builder.Services.AddHostedService<BlogHotReloadService>();
return builder;
}
private WebApplicationBuilder ConfigureOptions<T>(string optionSectionName) where T : class
{
builder.Services
.AddOptions<T>()
.Bind(builder.Configuration.GetSection(optionSectionName))
.ValidateDataAnnotations();
return builder;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace YaeBlog.Abstractions.Models;
namespace YaeBlog.Models;
public class GiteaOptions
{
@@ -8,7 +8,7 @@ public class GiteaOptions
[Required] public required string BaseAddress { get; init; }
public string? ApiKey { get; init; }
[Required] public required string ApiKey { get; init; }
[Required] public required string HeatMapUsername { get; init; }
}

View File

@@ -1,8 +1,5 @@
namespace YaeBlog.Abstractions.Models;
namespace YaeBlog.Models;
public record GitContributionItem(DateOnly Time, long ContributionCount)
{
public string ItemId => $"item-{Time:yyyy-MM-dd}";
}
public record GitContributionItem(DateOnly Time, long ContributionCount);
public record GitContributionGroupedByWeek(DateOnly Monday, List<GitContributionItem> Contributions);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,4 @@
using YaeBlog.Components;
using YaeBlog.Extensions;
using YaeBlog.Services;
using YaeBlog.Commands;
HostApplicationBuilder consoleBuilder = Host.CreateApplicationBuilder(args);
ConsoleInfoService consoleInfoService = consoleBuilder.AddYaeCommand(args);
IHost consoleApp = consoleBuilder.Build();
await consoleApp.RunAsync();
if (consoleInfoService.IsOneShotCommand)
{
return;
}
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddControllers();
builder.AddYaeServer(consoleInfoService);
WebApplication application = builder.Build();
application.MapStaticAssets();
application.UseAntiforgery();
application.UseYaeBlog();
application.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
application.MapControllers();
await application.RunAsync();
YaeBlogCommand command = new();
await command.RunAsync(args);

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
namespace YaeBlog.Services;
public enum ServerCommand
{
Serve,
Watch
}
public sealed class ConsoleInfoService
{
public bool IsOneShotCommand { get; set; }
public ServerCommand Command { get; set; }
}

View File

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

View File

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

View File

@@ -1,13 +1,11 @@
using DotNext;
using Microsoft.Extensions.Options;
using YaeBlog.Extensions;
using YaeBlog.Abstractions.Models;
using YaeBlog.Models;
namespace YaeBlog.Services;
public sealed class GitHeapMapService(
IServiceProvider serviceProvider,
IOptions<GiteaOptions> giteaOptions,
public sealed class GitHeapMapService(IServiceProvider serviceProvider, IOptions<GiteaOptions> giteaOptions,
ILogger<GitHeapMapService> logger)
{
/// <summary>
@@ -85,24 +83,7 @@ public sealed class GitHeapMapService(
groupedContribution.Contributions.Add(new GitContributionItem(date, contributions));
}
// If the last contributing day is not today, fill the spacing.
// But be careful here! If the last grouped contribution is current week, just fill the spacing until today.
// If the last grouped contribution is before current week, first fill the blank week then fill until today.
while (groupedContribution.Monday < today.LastMonday)
{
FillSpacing(groupedContribution, today);
result.Add(groupedContribution);
groupedContribution = new GitContributionGroupedByWeek(groupedContribution.Monday.AddDays(7), []);
}
// Currently the grouped contribution must be current week.
for (DateOnly date = groupedContribution.Monday.AddDays(groupedContribution.Contributions.Count);
date <= today;
date = date.AddDays(1))
{
groupedContribution.Contributions.Add(new GitContributionItem(date, 0));
}
// Not fill the last item and add directly.
result.Add(groupedContribution);
_gitContributionsGroupedByWeek = result;

View File

@@ -3,45 +3,34 @@ using System.Text.Json;
using DotNext;
using Microsoft.Extensions.Options;
using YaeBlog.Core.Exceptions;
using YaeBlog.Abstractions.Models;
using YaeBlog.Models;
namespace YaeBlog.Services;
public sealed class GiteaFetchService
{
private readonly HttpClient _httpClient;
private readonly ILogger<GiteaFetchService> _logger;
private static readonly JsonSerializerOptions s_serializerOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
RespectRequiredConstructorParameters = true,
RespectNullableAnnotations = true
PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
RespectRequiredConstructorParameters = true, RespectNullableAnnotations = true
};
/// <summary>
/// For test only.
/// </summary>
internal GiteaFetchService(IOptions<GiteaOptions> giteaOptions, HttpClient httpClient,
ILogger<GiteaFetchService> logger)
internal GiteaFetchService(IOptions<GiteaOptions> giteaOptions, HttpClient httpClient)
{
_httpClient = httpClient;
_logger = logger;
_httpClient.BaseAddress = new Uri(giteaOptions.Value.BaseAddress);
if (string.IsNullOrWhiteSpace(giteaOptions.Value.ApiKey))
{
return;
}
logger.LogInformation("Api Token is set.");
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("token", giteaOptions.Value.ApiKey);
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", giteaOptions.Value.ApiKey);
}
public GiteaFetchService(IOptions<GiteaOptions> giteaOptions, IHttpClientFactory httpClientFactory,
ILogger<GiteaFetchService> logger) : this(giteaOptions, httpClientFactory.CreateClient(), logger)
public GiteaFetchService(IOptions<GiteaOptions> giteaOptions, IHttpClientFactory httpClientFactory) : this(
giteaOptions, httpClientFactory.CreateClient())
{
}
@@ -61,7 +50,6 @@ public sealed class GiteaFetchService
new GiteaFetchException("Failed to fetch valid data."));
}
_logger.LogInformation("Fetch new user heat map data.");
return Result.FromValue(data.Select(i =>
new GitContributionItem(DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeSeconds(i.Timestamp).DateTime),
i.Contributions)).ToList());

View File

@@ -1,7 +1,7 @@
using Imageflow.Fluent;
using YaeBlog.Abstractions;
using YaeBlog.Abstraction;
using YaeBlog.Core.Exceptions;
using YaeBlog.Abstractions.Models;
using YaeBlog.Models;
namespace YaeBlog.Services;
@@ -34,7 +34,6 @@ public sealed class ImageCompressService(IEssayScanService essayScanService, ILo
if (needCompressContents.Count == 0)
{
logger.LogInformation("No candidates found to be compressed.");
return;
}
@@ -52,7 +51,7 @@ public sealed class ImageCompressService(IEssayScanService essayScanService, ILo
foreach (BlogImageInfo image in uncompressedImages)
{
logger.LogInformation("Uncompressed image: {filename} belonging to blog {blog}.", image.File.Name,
logger.LogInformation("Uncompressed image: {} belonging to blog {}.", image.File.Name,
content.BlogName);
}
@@ -83,7 +82,7 @@ public sealed class ImageCompressService(IEssayScanService essayScanService, ILo
logger.LogInformation("Compression ratio: {}%.", (double)compressedSize / uncompressedSize * 100.0);
if (!dryRun)
if (dryRun is false)
{
await Task.WhenAll(from content in compressedContent
select essayScanService.SaveBlogContent(content, content.IsDraft));

View File

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

View File

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

View File

@@ -1,27 +0,0 @@
namespace YaeBlog.Services;
public sealed class StartServerService(ConsoleInfoService consoleInfoService,
RendererService rendererService,
BlogHotReloadService blogHotReloadService) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
switch (consoleInfoService.Command)
{
case ServerCommand.Serve:
{
await rendererService.RenderAsync();
break;
}
case ServerCommand.Watch:
{
await blogHotReloadService.StartAsync(cancellationToken);
break;
}
default:
throw new InvalidOperationException();
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -1,277 +0,0 @@
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 sealed class YaeCommandService(
string[] arguments,
IEssayScanService essayScanService,
ImageCompressService imageCompressService,
ConsoleInfoService consoleInfoService,
IHostApplicationLifetime hostApplicationLifetime,
IOptions<BlogOptions> blogOptions,
ILogger<YaeCommandService> logger)
: BackgroundService
{
private readonly BlogOptions _blogOptions = blogOptions.Value;
private bool _oneShotCommandFlag = true;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
RootCommand rootCommand = new("YaeBlog CLI");
RegisterServeCommand(rootCommand);
RegisterWatchCommand(rootCommand);
RegisterNewCommand(rootCommand);
RegisterUpdateCommand(rootCommand);
RegisterScanCommand(rootCommand);
RegisterPublishCommand(rootCommand);
RegisterCompressCommand(rootCommand);
// Shit code: wait for the application starting.
// If the command service finished early before the application starting, there will be an ugly exception.
await Task.Delay(500, stoppingToken);
logger.LogInformation("Running YaeBlog Command.");
int exitCode = await rootCommand.InvokeAsync(arguments);
if (exitCode != 0)
{
throw new BlogCommandException($"YaeBlog command exited with no-zero code {exitCode}");
}
consoleInfoService.IsOneShotCommand = _oneShotCommandFlag;
if (!consoleInfoService.IsOneShotCommand)
{
logger.LogInformation("Start YaeBlog command: {}", consoleInfoService.Command);
}
hostApplicationLifetime.StopApplication();
}
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 Task HandleServeCommand(InvocationContext context)
{
_oneShotCommandFlag = false;
consoleInfoService.Command = ServerCommand.Serve;
return Task.CompletedTask;
}
private void RegisterWatchCommand(RootCommand rootCommand)
{
Command command = new("watch", "Start a blog watcher that re-render when file changes.");
rootCommand.AddCommand(command);
command.SetHandler(_ =>
{
_oneShotCommandFlag = false;
consoleInfoService.Command = ServerCommand.Watch;
});
}
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(async dryRun => { await imageCompressService.Compress(dryRun); }, dryRunOption);
}
}

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
#!/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
}