Compare commits
15 Commits
write-asyn
...
fda4c01c22
Author | SHA1 | Date | |
---|---|---|---|
fda4c01c22 | |||
2b9c374e8c | |||
4df3b98e6d | |||
c293d2f6d7 | |||
132261831b | |||
043376c6d3 | |||
4682dacc79 | |||
383dd41695 | |||
7f3221fde9 | |||
05ea729950 | |||
dcad453eb1 | |||
dec2bc937a | |||
b9c44408ad | |||
e1c5362cf5 | |||
bdcfed5506 |
@@ -7,7 +7,7 @@ jobs:
|
||||
Build-Blog-Image:
|
||||
runs-on: archlinux
|
||||
steps:
|
||||
- uses: https://git.rrricardo.top/actions/checkout@v4
|
||||
- uses: https://mirrors.rrricardo.top/actions/checkout.git@v4
|
||||
name: Check out code
|
||||
with:
|
||||
lfs: true
|
||||
@@ -18,12 +18,16 @@ jobs:
|
||||
- name: Build docker image
|
||||
run: |
|
||||
cd YaeBlog
|
||||
docker build . -t registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest
|
||||
podman build . -t registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest
|
||||
- name: Workaround to make sure podman login succeed
|
||||
run: |
|
||||
mkdir /root/.docker
|
||||
- name: Login aliyun docker registry
|
||||
uses: https://git.rrricardo.top/actions/login-action@v3
|
||||
uses: https://mirrors.rrricardo.top/actions/podman-login.git@v1
|
||||
with:
|
||||
registry: registry.cn-beijing.aliyuncs.com
|
||||
username: 初冬的朝阳
|
||||
password: ${{ secrets.ALIYUN_PASSWORD }}
|
||||
auth_file_path: /etc/containers/auth.json
|
||||
- name: Push docker image
|
||||
run: docker push registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest
|
||||
run: podman push registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -484,4 +484,4 @@ $RECYCLE.BIN/
|
||||
*.swp
|
||||
|
||||
# Tailwind auto-generated stylesheet
|
||||
output.css
|
||||
*.g.css
|
||||
|
41
YaeBlog.sln
41
YaeBlog.sln
@@ -1,41 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YaeBlog", "YaeBlog\YaeBlog.csproj", "{20438EFD-8DDE-43AF-92E2-76495C29233C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".gitea", ".gitea", "{9B5AAA29-37D8-454A-8D8F-3E6B6BCF38E6}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ADBC3DA8-F65C-4B5D-A97A-DC351F8E6592}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
.gitea\workflows\build.yaml = .gitea\workflows\build.yaml
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{753B998C-1B9E-498F-B949-845CE86C4075}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
.editorconfig = .editorconfig
|
||||
.gitattributes = .gitattributes
|
||||
.gitignore = .gitignore
|
||||
README.md = README.md
|
||||
LICENSE = LICENSE
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{20438EFD-8DDE-43AF-92E2-76495C29233C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{20438EFD-8DDE-43AF-92E2-76495C29233C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{20438EFD-8DDE-43AF-92E2-76495C29233C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{20438EFD-8DDE-43AF-92E2-76495C29233C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{ADBC3DA8-F65C-4B5D-A97A-DC351F8E6592} = {9B5AAA29-37D8-454A-8D8F-3E6B6BCF38E6}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
14
YaeBlog.slnx
Normal file
14
YaeBlog.slnx
Normal file
@@ -0,0 +1,14 @@
|
||||
<Solution>
|
||||
<Folder Name="/.gitea/" />
|
||||
<Folder Name="/.gitea/workflows/">
|
||||
<File Path=".gitea/workflows/build.yaml" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/">
|
||||
<File Path=".editorconfig" />
|
||||
<File Path=".gitattributes" />
|
||||
<File Path=".gitignore" />
|
||||
<File Path="LICENSE" />
|
||||
<File Path="README.md" />
|
||||
</Folder>
|
||||
<Project Path="YaeBlog/YaeBlog.csproj" />
|
||||
</Solution>
|
@@ -7,6 +7,4 @@ public interface IEssayScanService
|
||||
public Task<BlogContents> ScanContents();
|
||||
|
||||
public Task SaveBlogContent(BlogContent content, bool isDraft = true);
|
||||
|
||||
public Task<ImageScanResult> ScanImages();
|
||||
}
|
||||
|
21
YaeBlog/Commands/Binders/ImageCompressServiceBinder.cs
Normal file
21
YaeBlog/Commands/Binders/ImageCompressServiceBinder.cs
Normal 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>();
|
||||
}
|
||||
}
|
@@ -1,4 +1,6 @@
|
||||
using System.CommandLine;
|
||||
using Microsoft.Extensions.Options;
|
||||
using YaeBlog.Abstraction;
|
||||
using YaeBlog.Commands.Binders;
|
||||
using YaeBlog.Components;
|
||||
using YaeBlog.Extensions;
|
||||
@@ -19,6 +21,7 @@ public sealed class YaeBlogCommand
|
||||
AddNewCommand(_rootCommand);
|
||||
AddPublishCommand(_rootCommand);
|
||||
AddScanCommand(_rootCommand);
|
||||
AddCompressCommand(_rootCommand);
|
||||
}
|
||||
|
||||
public Task<int> RunAsync(string[] args)
|
||||
@@ -94,22 +97,20 @@ public sealed class YaeBlogCommand
|
||||
Argument<string> filenameArgument = new(name: "blog name", description: "The created blog filename.");
|
||||
newCommand.AddArgument(filenameArgument);
|
||||
|
||||
newCommand.SetHandler(async (file, _, _, essayScanService) =>
|
||||
newCommand.SetHandler(async (file, blogOption, _, essayScanService) =>
|
||||
{
|
||||
BlogContents contents = await essayScanService.ScanContents();
|
||||
|
||||
if (contents.Posts.Any(content => content.FileName == file))
|
||||
if (contents.Posts.Any(content => content.BlogName == file))
|
||||
{
|
||||
Console.WriteLine("There exists the same title blog in posts.");
|
||||
return;
|
||||
}
|
||||
|
||||
await essayScanService.SaveBlogContent(new BlogContent
|
||||
{
|
||||
FileName = file,
|
||||
FileContent = string.Empty,
|
||||
Metadata = new MarkdownMetadata { Title = file, Date = DateTime.Now }
|
||||
});
|
||||
await essayScanService.SaveBlogContent(new BlogContent(
|
||||
new FileInfo(Path.Combine(blogOption.Value.Root, "drafts", file + ".md")),
|
||||
new MarkdownMetadata { Title = file, Date = DateTime.Now },
|
||||
string.Empty, true, [], []));
|
||||
|
||||
Console.WriteLine($"Created new blog '{file}.");
|
||||
}, filenameArgument, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(),
|
||||
@@ -126,15 +127,15 @@ public sealed class YaeBlogCommand
|
||||
BlogContents contents = await essyScanService.ScanContents();
|
||||
|
||||
Console.WriteLine($"All {contents.Posts.Count} Posts:");
|
||||
foreach (BlogContent content in contents.Posts.OrderBy(x => x.FileName))
|
||||
foreach (BlogContent content in contents.Posts.OrderBy(x => x.BlogName))
|
||||
{
|
||||
Console.WriteLine($" - {content.FileName}");
|
||||
Console.WriteLine($" - {content.BlogName}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"All {contents.Drafts.Count} Drafts:");
|
||||
foreach (BlogContent content in contents.Drafts.OrderBy(x => x.FileName))
|
||||
foreach (BlogContent content in contents.Drafts.OrderBy(x => x.BlogName))
|
||||
{
|
||||
Console.WriteLine($" - {content.FileName}");
|
||||
Console.WriteLine($" - {content.BlogName}");
|
||||
}
|
||||
}, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(), new EssayScanServiceBinder());
|
||||
}
|
||||
@@ -150,32 +151,39 @@ public sealed class YaeBlogCommand
|
||||
|
||||
command.SetHandler(async (_, _, essayScanService, removeOptionValue) =>
|
||||
{
|
||||
ImageScanResult result = await essayScanService.ScanImages();
|
||||
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 (result.UnusedImages.Count != 0)
|
||||
if (unusedImages.Count != 0)
|
||||
{
|
||||
Console.WriteLine("Found unused images:");
|
||||
Console.WriteLine("HINT: use '--rm' to remove unused images.");
|
||||
}
|
||||
|
||||
foreach (FileInfo image in result.UnusedImages)
|
||||
foreach (BlogImageInfo image in unusedImages)
|
||||
{
|
||||
Console.WriteLine($" - {image.FullName}");
|
||||
Console.WriteLine($" - {image.File.FullName}");
|
||||
}
|
||||
|
||||
if (removeOptionValue)
|
||||
{
|
||||
foreach (FileInfo image in result.UnusedImages)
|
||||
foreach (BlogImageInfo image in unusedImages)
|
||||
{
|
||||
image.Delete();
|
||||
image.File.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("Used not existed images:");
|
||||
|
||||
foreach (FileInfo image in result.NotFoundImages)
|
||||
foreach (BlogContent content in contents)
|
||||
{
|
||||
Console.WriteLine($" - {image.FullName}");
|
||||
foreach (FileInfo file in content.NotfoundImages)
|
||||
{
|
||||
Console.WriteLine($"- {file.Name} in {content.BlogName}");
|
||||
}
|
||||
}
|
||||
}, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(), new EssayScanServiceBinder(), removeOption);
|
||||
}
|
||||
@@ -193,7 +201,7 @@ public sealed class YaeBlogCommand
|
||||
BlogContents contents = await essayScanService.ScanContents();
|
||||
|
||||
BlogContent? content = (from blog in contents.Drafts
|
||||
where blog.FileName == filename
|
||||
where blog.BlogName == filename
|
||||
select blog).FirstOrDefault();
|
||||
|
||||
if (content is null)
|
||||
@@ -202,14 +210,17 @@ public sealed class YaeBlogCommand
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置发布的时间
|
||||
content.Metadata.Date = DateTime.Now;
|
||||
|
||||
// 将选中的博客文件复制到posts
|
||||
await essayScanService.SaveBlogContent(content, isDraft: false);
|
||||
|
||||
// 复制图片文件夹
|
||||
DirectoryInfo sourceImageDirectory =
|
||||
new(Path.Combine(blogOptions.Value.Root, "drafts", content.FileName));
|
||||
new(Path.Combine(blogOptions.Value.Root, "drafts", content.BlogName));
|
||||
DirectoryInfo targetImageDirectory =
|
||||
new(Path.Combine(blogOptions.Value.Root, "posts", content.FileName));
|
||||
new(Path.Combine(blogOptions.Value.Root, "posts", content.BlogName));
|
||||
|
||||
if (sourceImageDirectory.Exists)
|
||||
{
|
||||
@@ -223,9 +234,30 @@ public sealed class YaeBlogCommand
|
||||
}
|
||||
|
||||
// 删除原始的文件
|
||||
FileInfo sourceBlogFile = new(Path.Combine(blogOptions.Value.Root, "drafts", content.FileName + ".md"));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="YaeBlog.styles.css"/>
|
||||
<link rel="icon" href="images/favicon.ico"/>
|
||||
<link rel="stylesheet" href="globals.css"/>
|
||||
<link rel="stylesheet" href="output.css"/>
|
||||
<link rel="stylesheet" href="tailwind.g.css"/>
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
|
||||
|
18
YaeBlog/Extensions/AngleSharpExtensions.cs
Normal file
18
YaeBlog/Extensions/AngleSharpExtensions.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using AngleSharp.Dom;
|
||||
|
||||
namespace YaeBlog.Extensions;
|
||||
|
||||
public static class AngleSharpExtensions
|
||||
{
|
||||
public static IEnumerable<IElement> EnumerateParentElements(this IElement element)
|
||||
{
|
||||
IElement? e = element.ParentElement;
|
||||
|
||||
while (e is not null)
|
||||
{
|
||||
IElement c = e;
|
||||
e = e.ParentElement;
|
||||
yield return c;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,12 +1,20 @@
|
||||
namespace YaeBlog.Models;
|
||||
|
||||
public class BlogContent
|
||||
/// <summary>
|
||||
/// 单个博客文件的所有数据和元数据
|
||||
/// </summary>
|
||||
/// <param name="BlogFile">博客文件</param>
|
||||
/// <param name="Metadata">文件中的MD元数据</param>
|
||||
/// <param name="Content">文件内容</param>
|
||||
/// <param name="IsDraft">是否为草稿</param>
|
||||
/// <param name="Images">博客中使用的文件</param>
|
||||
public record BlogContent(
|
||||
FileInfo BlogFile,
|
||||
MarkdownMetadata Metadata,
|
||||
string Content,
|
||||
bool IsDraft,
|
||||
List<BlogImageInfo> Images,
|
||||
List<FileInfo> NotfoundImages)
|
||||
{
|
||||
public required string FileName { get; init; }
|
||||
|
||||
public required MarkdownMetadata Metadata { get; init; }
|
||||
|
||||
public required string FileContent { get; set; }
|
||||
|
||||
public bool IsDraft { get; set; } = false;
|
||||
public string BlogName => BlogFile.Name.Split('.')[0];
|
||||
}
|
||||
|
@@ -1,10 +1,15 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace YaeBlog.Models;
|
||||
|
||||
public sealed class BlogContents(ConcurrentBag<BlogContent> drafts, ConcurrentBag<BlogContent> posts)
|
||||
public record BlogContents(ConcurrentBag<BlogContent> Drafts, ConcurrentBag<BlogContent> Posts)
|
||||
: IEnumerable<BlogContent>
|
||||
{
|
||||
public ConcurrentBag<BlogContent> Drafts { get; } = drafts;
|
||||
IEnumerator<BlogContent> IEnumerable<BlogContent>.GetEnumerator()
|
||||
{
|
||||
return Posts.Concat(Drafts).GetEnumerator();
|
||||
}
|
||||
|
||||
public ConcurrentBag<BlogContent> Posts { get; } = posts;
|
||||
public IEnumerator GetEnumerator() => ((IEnumerable<BlogContent>)this).GetEnumerator();
|
||||
}
|
||||
|
44
YaeBlog/Models/BlogImageInfo.cs
Normal file
44
YaeBlog/Models/BlogImageInfo.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Text;
|
||||
|
||||
namespace YaeBlog.Models;
|
||||
|
||||
public record BlogImageInfo(FileInfo File, long Width, long Height, string MineType, byte[] Content, bool IsUsed)
|
||||
: IComparable<BlogImageInfo>
|
||||
{
|
||||
public int Size => Content.Length;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
StringBuilder builder = new();
|
||||
|
||||
builder.AppendLine($"Blog image {File.Name}:");
|
||||
builder.AppendLine($"\tWidth: {Width}; Height: {Height}");
|
||||
builder.AppendLine($"\tSize: {FormatSize()}");
|
||||
builder.AppendLine($"\tImage Format: {MineType}");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public int CompareTo(BlogImageInfo? other)
|
||||
{
|
||||
if (other is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return other.Size.CompareTo(Size);
|
||||
}
|
||||
|
||||
private string FormatSize()
|
||||
{
|
||||
double size = Size;
|
||||
if (size / 1024 > 3)
|
||||
{
|
||||
size /= 1024;
|
||||
|
||||
return size / 1024 > 3 ? $"{size / 1024}MB" : $"{size}KB";
|
||||
}
|
||||
|
||||
return $"{size}B";
|
||||
}
|
||||
}
|
@@ -1,3 +0,0 @@
|
||||
namespace YaeBlog.Models;
|
||||
|
||||
public record struct ImageScanResult(List<FileInfo> UnusedImages, List<FileInfo> NotFoundImages);
|
@@ -1,6 +1,7 @@
|
||||
using AngleSharp;
|
||||
using AngleSharp.Dom;
|
||||
using YaeBlog.Abstraction;
|
||||
using YaeBlog.Extensions;
|
||||
using YaeBlog.Models;
|
||||
|
||||
namespace YaeBlog.Processors;
|
||||
@@ -20,20 +21,21 @@ public sealed class EssayStylesPostRenderProcessor : IPostRenderProcessor
|
||||
|
||||
ApplyGlobalCssStyles(document);
|
||||
BeatifyTable(document);
|
||||
BeatifyList(document);
|
||||
BeatifyInlineCode(document);
|
||||
|
||||
return essay.WithNewHtmlContent(document.DocumentElement.OuterHtml);
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, string> _globalCssStyles = new()
|
||||
{
|
||||
{ "pre", "p-4 bg-slate-300 rounded-sm overflow-x-auto" },
|
||||
{ "pre", "p-4 bg-gray-100 rounded-sm overflow-x-auto" },
|
||||
{ "h2", "text-3xl font-bold py-4" },
|
||||
{ "h3", "text-2xl font-bold py-3" },
|
||||
{ "h4", "text-xl font-bold py-2" },
|
||||
{ "h5", "text-lg font-bold py-1" },
|
||||
{ "p", "p-2" },
|
||||
{ "img", "w-11/12 block mx-auto my-2 rounded-md shadow-md" },
|
||||
{ "ul", "list-disc pl-2" }
|
||||
};
|
||||
|
||||
private void ApplyGlobalCssStyles(IDocument document)
|
||||
@@ -99,4 +101,45 @@ public sealed class EssayStylesPostRenderProcessor : IPostRenderProcessor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void BeatifyList(IDocument document)
|
||||
{
|
||||
foreach (IElement ulElement in from e in document.All
|
||||
where e.LocalName == "ul"
|
||||
select e)
|
||||
{
|
||||
// 首先给<ul>元素添加样式
|
||||
ulElement.ClassList.Add("list-disc ml-10");
|
||||
|
||||
|
||||
foreach (IElement liElement in from e in ulElement.Children
|
||||
where e.LocalName == "li"
|
||||
select e)
|
||||
{
|
||||
// 修改<li>元素中的<p>元素样式
|
||||
// 默认的p-2间距有点太宽了
|
||||
foreach (IElement pElement in from e in liElement.Children
|
||||
where e.LocalName == "p"
|
||||
select e)
|
||||
{
|
||||
pElement.ClassList.Remove("p-2");
|
||||
pElement.ClassList.Add("p-1");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void BeatifyInlineCode(IDocument document)
|
||||
{
|
||||
// 选择不在<pre>元素内的<code>元素
|
||||
// 即行内代码
|
||||
IEnumerable<IElement> inlineCodes = from e in document.All
|
||||
where e.LocalName == "code" && e.EnumerateParentElements().All(p => p.LocalName != "pre")
|
||||
select e;
|
||||
|
||||
foreach (IElement e in inlineCodes)
|
||||
{
|
||||
e.ClassList.Add("bg-gray-100 inline p-1 rounded-xs");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -7,7 +7,8 @@ using YaeBlog.Models;
|
||||
|
||||
namespace YaeBlog.Processors;
|
||||
|
||||
public class ImagePostRenderProcessor(ILogger<ImagePostRenderProcessor> logger,
|
||||
public class ImagePostRenderProcessor(
|
||||
ILogger<ImagePostRenderProcessor> logger,
|
||||
IOptions<BlogOptions> options)
|
||||
: IPostRenderProcessor
|
||||
{
|
||||
@@ -29,22 +30,27 @@ public class ImagePostRenderProcessor(ILogger<ImagePostRenderProcessor> logger,
|
||||
if (attr is not null)
|
||||
{
|
||||
logger.LogDebug("Found image link: '{}'", attr.Value);
|
||||
attr.Value = GenerateImageLink(attr.Value, essay.FileName);
|
||||
attr.Value = GenerateImageLink(attr.Value, essay.FileName, essay.IsDraft);
|
||||
}
|
||||
}
|
||||
|
||||
return essay.WithNewHtmlContent(html.DocumentElement.OuterHtml);
|
||||
}
|
||||
|
||||
public string Name => nameof(ImagePostRenderProcessor);
|
||||
|
||||
private string GenerateImageLink(string filename, string essayFilename)
|
||||
private string GenerateImageLink(string filename, string essayFilename, bool isDraft)
|
||||
{
|
||||
// 如果图片路径中没有包含文件名
|
||||
// 则添加文件名
|
||||
if (!filename.Contains(essayFilename))
|
||||
{
|
||||
filename = Path.Combine(essayFilename, filename);
|
||||
}
|
||||
|
||||
filename = Path.Combine(_options.Root, "posts", filename);
|
||||
filename = isDraft
|
||||
? Path.Combine(_options.Root, "drafts", filename)
|
||||
: Path.Combine(_options.Root, "posts", filename);
|
||||
|
||||
if (!Path.Exists(filename))
|
||||
{
|
||||
|
@@ -1,5 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Imageflow.Bindings;
|
||||
using Imageflow.Fluent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using YaeBlog.Abstraction;
|
||||
using YaeBlog.Core.Exceptions;
|
||||
@@ -9,17 +11,30 @@ using YamlDotNet.Serialization;
|
||||
|
||||
namespace YaeBlog.Services;
|
||||
|
||||
public partial class EssayScanService(
|
||||
ISerializer yamlSerializer,
|
||||
IDeserializer yamlDeserializer,
|
||||
IOptions<BlogOptions> blogOptions,
|
||||
ILogger<EssayScanService> logger) : IEssayScanService
|
||||
public partial class EssayScanService : IEssayScanService
|
||||
{
|
||||
private readonly BlogOptions _blogOptions = blogOptions.Value;
|
||||
private readonly BlogOptions _blogOptions;
|
||||
private readonly ISerializer _yamlSerializer;
|
||||
private readonly IDeserializer _yamlDeserializer;
|
||||
private readonly ILogger<EssayScanService> _logger;
|
||||
|
||||
public EssayScanService(ISerializer yamlSerializer,
|
||||
IDeserializer yamlDeserializer,
|
||||
IOptions<BlogOptions> blogOptions,
|
||||
ILogger<EssayScanService> logger)
|
||||
{
|
||||
_yamlSerializer = yamlSerializer;
|
||||
_yamlDeserializer = yamlDeserializer;
|
||||
_logger = logger;
|
||||
_blogOptions = blogOptions.Value;
|
||||
RootDirectory = ValidateRootDirectory();
|
||||
}
|
||||
|
||||
private DirectoryInfo RootDirectory { get; }
|
||||
|
||||
public async Task<BlogContents> ScanContents()
|
||||
{
|
||||
ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts);
|
||||
ValidateDirectory(out DirectoryInfo drafts, out DirectoryInfo posts);
|
||||
|
||||
return new BlogContents(
|
||||
await ScanContentsInternal(drafts, true),
|
||||
@@ -28,82 +43,92 @@ public partial class EssayScanService(
|
||||
|
||||
public async Task SaveBlogContent(BlogContent content, bool isDraft = true)
|
||||
{
|
||||
ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts);
|
||||
ValidateDirectory(out DirectoryInfo drafts, out DirectoryInfo posts);
|
||||
|
||||
FileInfo targetFile = isDraft
|
||||
? new FileInfo(Path.Combine(drafts.FullName, content.FileName + ".md"))
|
||||
: new FileInfo(Path.Combine(posts.FullName, content.FileName + ".md"));
|
||||
|
||||
if (!isDraft)
|
||||
{
|
||||
content.Metadata.Date = DateTime.Now;
|
||||
}
|
||||
? new FileInfo(Path.Combine(drafts.FullName, content.BlogName + ".md"))
|
||||
: new FileInfo(Path.Combine(posts.FullName, content.BlogName + ".md"));
|
||||
|
||||
if (targetFile.Exists)
|
||||
{
|
||||
logger.LogWarning("Blog {} exists, overriding.", targetFile.Name);
|
||||
_logger.LogWarning("Blog {} exists, overriding.", targetFile.Name);
|
||||
}
|
||||
|
||||
await using StreamWriter writer = targetFile.CreateText();
|
||||
|
||||
await writer.WriteAsync("---\n");
|
||||
await writer.WriteAsync(yamlSerializer.Serialize(content.Metadata));
|
||||
await writer.WriteAsync(_yamlSerializer.Serialize(content.Metadata));
|
||||
await writer.WriteAsync("---\n");
|
||||
|
||||
if (isDraft)
|
||||
if (string.IsNullOrEmpty(content.Content) && isDraft)
|
||||
{
|
||||
// 如果博客为操作且内容为空
|
||||
// 创建简介隔断符号
|
||||
await writer.WriteLineAsync("<!--more-->");
|
||||
}
|
||||
else
|
||||
{
|
||||
await writer.WriteAsync(content.FileContent);
|
||||
await writer.WriteAsync(content.Content);
|
||||
}
|
||||
|
||||
// 保存图片文件
|
||||
await Task.WhenAll(from image in content.Images
|
||||
select File.WriteAllBytesAsync(image.File.FullName, image.Content));
|
||||
}
|
||||
|
||||
private record struct BlogResult(
|
||||
FileInfo BlogFile,
|
||||
string BlogContent,
|
||||
List<BlogImageInfo> Images,
|
||||
List<FileInfo> NotFoundImages);
|
||||
|
||||
private async Task<ConcurrentBag<BlogContent>> ScanContentsInternal(DirectoryInfo directory, bool isDraft)
|
||||
{
|
||||
// 扫描以md结果的但是不是隐藏文件的文件
|
||||
// 扫描以md结尾且不是隐藏文件的文件
|
||||
IEnumerable<FileInfo> markdownFiles = from file in directory.EnumerateFiles()
|
||||
where file.Extension == ".md" && !file.Name.StartsWith('.')
|
||||
select file;
|
||||
|
||||
ConcurrentBag<(string, string)> fileContents = [];
|
||||
ConcurrentBag<BlogResult> fileContents = [];
|
||||
|
||||
await Parallel.ForEachAsync(markdownFiles, async (file, token) =>
|
||||
{
|
||||
using StreamReader reader = file.OpenText();
|
||||
fileContents.Add((file.Name, await reader.ReadToEndAsync(token)));
|
||||
string blogName = file.Name.Split('.')[0];
|
||||
string blogContent = await reader.ReadToEndAsync(token);
|
||||
ImageResult imageResult =
|
||||
await ScanImagePreBlog(directory, blogName,
|
||||
blogContent);
|
||||
|
||||
fileContents.Add(new BlogResult(file, blogContent, imageResult.Images, imageResult.NotfoundImages));
|
||||
});
|
||||
|
||||
ConcurrentBag<BlogContent> contents = [];
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
foreach ((string filename, string content) in fileContents)
|
||||
foreach (BlogResult blog in fileContents)
|
||||
{
|
||||
int endPos = content.IndexOf("---", 4, StringComparison.Ordinal);
|
||||
if (!content.StartsWith("---") || endPos is -1 or 0)
|
||||
int endPos = blog.BlogContent.IndexOf("---", 4, StringComparison.Ordinal);
|
||||
if (!blog.BlogContent.StartsWith("---") || endPos is -1 or 0)
|
||||
{
|
||||
logger.LogWarning("Failed to parse metadata from {}, skipped.", filename);
|
||||
_logger.LogWarning("Failed to parse metadata from {}, skipped.", blog.BlogFile.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
string metadataString = content[4..endPos];
|
||||
string metadataString = blog.BlogContent[4..endPos];
|
||||
|
||||
try
|
||||
{
|
||||
MarkdownMetadata metadata = yamlDeserializer.Deserialize<MarkdownMetadata>(metadataString);
|
||||
logger.LogDebug("Scan metadata title: '{}' for {}.", metadata.Title, filename);
|
||||
MarkdownMetadata metadata = _yamlDeserializer.Deserialize<MarkdownMetadata>(metadataString);
|
||||
_logger.LogDebug("Scan metadata title: '{}' for {}.", metadata.Title, blog.BlogFile.Name);
|
||||
|
||||
contents.Add(new BlogContent
|
||||
{
|
||||
FileName = filename[..^3], Metadata = metadata, FileContent = content[(endPos + 3)..],
|
||||
IsDraft = isDraft
|
||||
});
|
||||
contents.Add(new BlogContent(blog.BlogFile, metadata, blog.BlogContent[(endPos + 3)..], isDraft,
|
||||
blog.Images, blog.NotFoundImages));
|
||||
}
|
||||
catch (YamlException e)
|
||||
{
|
||||
logger.LogWarning("Failed to parser metadata from {} due to {}, skipping", filename, e);
|
||||
_logger.LogWarning("Failed to parser metadata from {} due to {}, skipping", blog.BlogFile.Name, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -111,99 +136,96 @@ public partial class EssayScanService(
|
||||
return contents;
|
||||
}
|
||||
|
||||
public async Task<ImageScanResult> ScanImages()
|
||||
private record struct ImageResult(List<BlogImageInfo> Images, List<FileInfo> NotfoundImages);
|
||||
|
||||
private async Task<ImageResult> ScanImagePreBlog(DirectoryInfo directory, string blogName, string content)
|
||||
{
|
||||
BlogContents contents = await ScanContents();
|
||||
ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts);
|
||||
MatchCollection matchResult = ImagePattern.Matches(content);
|
||||
DirectoryInfo imageDirectory = new(Path.Combine(directory.FullName, blogName));
|
||||
|
||||
List<FileInfo> unusedFiles = [];
|
||||
List<FileInfo> notFoundFiles = [];
|
||||
Dictionary<string, bool> usedImages = imageDirectory.Exists
|
||||
? imageDirectory.EnumerateFiles().ToDictionary(file => file.FullName, _ => false)
|
||||
: [];
|
||||
List<FileInfo> notFoundImages = [];
|
||||
|
||||
ImageScanResult draftResult = await ScanUnusedImagesInternal(contents.Drafts, drafts);
|
||||
ImageScanResult postResult = await ScanUnusedImagesInternal(contents.Posts, posts);
|
||||
|
||||
unusedFiles.AddRange(draftResult.UnusedImages);
|
||||
notFoundFiles.AddRange(draftResult.NotFoundImages);
|
||||
unusedFiles.AddRange(postResult.UnusedImages);
|
||||
notFoundFiles.AddRange(postResult.NotFoundImages);
|
||||
|
||||
return new ImageScanResult(unusedFiles, notFoundFiles);
|
||||
}
|
||||
|
||||
private static Task<ImageScanResult> ScanUnusedImagesInternal(IEnumerable<BlogContent> contents,
|
||||
DirectoryInfo root)
|
||||
{
|
||||
ConcurrentBag<FileInfo> unusedImage = [];
|
||||
ConcurrentBag<FileInfo> notFoundImage = [];
|
||||
|
||||
Parallel.ForEach(contents, content =>
|
||||
foreach (Match match in matchResult)
|
||||
{
|
||||
MatchCollection result = ImagePattern.Matches(content.FileContent);
|
||||
DirectoryInfo imageDirectory = new(Path.Combine(root.FullName, content.FileName));
|
||||
string imageName = match.Groups[1].Value;
|
||||
|
||||
Dictionary<string, bool> usedDictionary;
|
||||
// 判断md文件中的图片名称中是否包含文件夹名称
|
||||
// 例如 blog-1/image.png 或者 image.png
|
||||
// 如果不带文件夹名称
|
||||
// 默认添加同博客名文件夹
|
||||
FileInfo usedFile = imageName.Contains(blogName)
|
||||
? new FileInfo(Path.Combine(directory.FullName, imageName))
|
||||
: new FileInfo(Path.Combine(directory.FullName, blogName, imageName));
|
||||
|
||||
if (imageDirectory.Exists)
|
||||
if (usedImages.TryGetValue(usedFile.FullName, out _))
|
||||
{
|
||||
usedDictionary = (from file in imageDirectory.EnumerateFiles()
|
||||
select new KeyValuePair<string, bool>(file.FullName, false)).ToDictionary();
|
||||
usedImages[usedFile.FullName] = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
usedDictionary = [];
|
||||
notFoundImages.Add(usedFile);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Match match in result)
|
||||
{
|
||||
string imageName = match.Groups[1].Value;
|
||||
List<BlogImageInfo> images = (await Task.WhenAll((from pair in usedImages
|
||||
select GetImageInfo(new FileInfo(pair.Key), pair.Value)).ToArray())).ToList();
|
||||
|
||||
FileInfo usedFile = imageName.Contains(content.FileName)
|
||||
? new FileInfo(Path.Combine(root.FullName, imageName))
|
||||
: new FileInfo(Path.Combine(root.FullName, content.FileName, imageName));
|
||||
return new ImageResult(images, notFoundImages);
|
||||
}
|
||||
|
||||
if (usedDictionary.TryGetValue(usedFile.FullName, out _))
|
||||
{
|
||||
usedDictionary[usedFile.FullName] = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
notFoundImage.Add(usedFile);
|
||||
}
|
||||
}
|
||||
private static async Task<BlogImageInfo> GetImageInfo(FileInfo file, bool isUsed)
|
||||
{
|
||||
byte[] image = await File.ReadAllBytesAsync(file.FullName);
|
||||
|
||||
foreach (KeyValuePair<string, bool> pair in usedDictionary.Where(p => !p.Value))
|
||||
{
|
||||
unusedImage.Add(new FileInfo(pair.Key));
|
||||
}
|
||||
});
|
||||
if (file.Extension is ".jpg" or ".jpeg" or ".png")
|
||||
{
|
||||
ImageInfo imageInfo =
|
||||
await ImageJob.GetImageInfoAsync(MemorySource.Borrow(image), SourceLifetime.NowOwnedAndDisposedByTask);
|
||||
|
||||
return Task.FromResult(new ImageScanResult(unusedImage.ToList(), notFoundImage.ToList()));
|
||||
return new BlogImageInfo(file, imageInfo.ImageWidth, imageInfo.ImageWidth, imageInfo.PreferredMimeType,
|
||||
image, isUsed);
|
||||
}
|
||||
|
||||
return new BlogImageInfo(file, 0, 0, file.Extension switch
|
||||
{
|
||||
"svg" => "image/svg",
|
||||
"avif" => "image/avif",
|
||||
_ => string.Empty
|
||||
}, image, isUsed);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\!\[.*?\]\((.*?)\)")]
|
||||
private static partial Regex ImagePattern { get; }
|
||||
|
||||
private void ValidateDirectory(string root, out DirectoryInfo drafts, out DirectoryInfo posts)
|
||||
|
||||
private DirectoryInfo ValidateRootDirectory()
|
||||
{
|
||||
root = Path.Combine(Environment.CurrentDirectory, root);
|
||||
DirectoryInfo rootDirectory = new(root);
|
||||
DirectoryInfo rootDirectory = new(Path.Combine(Environment.CurrentDirectory, _blogOptions.Root));
|
||||
|
||||
if (!rootDirectory.Exists)
|
||||
{
|
||||
throw new BlogFileException($"'{root}' is not a directory.");
|
||||
throw new BlogFileException($"'{_blogOptions.Root}' is not a directory.");
|
||||
}
|
||||
|
||||
if (rootDirectory.EnumerateDirectories().All(dir => dir.Name != "drafts"))
|
||||
return rootDirectory;
|
||||
}
|
||||
|
||||
private void ValidateDirectory(out DirectoryInfo drafts, out DirectoryInfo posts)
|
||||
{
|
||||
if (RootDirectory.EnumerateDirectories().All(dir => dir.Name != "drafts"))
|
||||
{
|
||||
throw new BlogFileException($"'{root}/drafts' not exists.");
|
||||
throw new BlogFileException($"'{_blogOptions.Root}/drafts' not exists.");
|
||||
}
|
||||
|
||||
if (rootDirectory.EnumerateDirectories().All(dir => dir.Name != "posts"))
|
||||
if (RootDirectory.EnumerateDirectories().All(dir => dir.Name != "posts"))
|
||||
{
|
||||
throw new BlogFileException($"'{root}/posts' not exists.");
|
||||
throw new BlogFileException($"'{_blogOptions.Root}/posts' not exists.");
|
||||
}
|
||||
|
||||
drafts = new DirectoryInfo(Path.Combine(root, "drafts"));
|
||||
posts = new DirectoryInfo(Path.Combine(root, "posts"));
|
||||
drafts = new DirectoryInfo(Path.Combine(_blogOptions.Root, "drafts"));
|
||||
posts = new DirectoryInfo(Path.Combine(_blogOptions.Root, "posts"));
|
||||
}
|
||||
}
|
||||
|
108
YaeBlog/Services/ImageCompressService.cs
Normal file
108
YaeBlog/Services/ImageCompressService.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using Imageflow.Fluent;
|
||||
using YaeBlog.Abstraction;
|
||||
using YaeBlog.Core.Exceptions;
|
||||
using YaeBlog.Models;
|
||||
|
||||
namespace YaeBlog.Services;
|
||||
|
||||
public sealed class ImageCompressService(IEssayScanService essayScanService, ILogger<ImageCompressService> logger)
|
||||
{
|
||||
private record struct CompressResult(BlogImageInfo ImageInfo, byte[] CompressContent);
|
||||
|
||||
public async Task<List<BlogImageInfo>> ScanUsedImages()
|
||||
{
|
||||
BlogContents contents = await essayScanService.ScanContents();
|
||||
List<BlogImageInfo> originalImages = (from content in contents.Posts.Concat(contents.Drafts)
|
||||
from image in content.Images
|
||||
where image.IsUsed
|
||||
select image).ToList();
|
||||
|
||||
originalImages.Sort();
|
||||
|
||||
return originalImages;
|
||||
}
|
||||
|
||||
public async Task Compress(bool dryRun)
|
||||
{
|
||||
BlogContents contents = await essayScanService.ScanContents();
|
||||
|
||||
// 筛选需要压缩的图片
|
||||
// 即图片被博客使用且是jpeg/png格式
|
||||
List<BlogContent> needCompressContents = (from content in contents
|
||||
where content.Images.Any(i => i is { IsUsed: true } and { File.Extension: ".jpg" or ".jpeg" or ".png" })
|
||||
select content).ToList();
|
||||
|
||||
if (needCompressContents.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int uncompressedSize = 0;
|
||||
int compressedSize = 0;
|
||||
List<BlogContent> compressedContent = new(needCompressContents.Count);
|
||||
|
||||
foreach (BlogContent content in needCompressContents)
|
||||
{
|
||||
List<BlogImageInfo> uncompressedImages = (from image in content.Images
|
||||
where image is { IsUsed: true } and { File.Extension: ".jpg" or ".jpeg" or ".png" }
|
||||
select image).ToList();
|
||||
|
||||
uncompressedSize += uncompressedImages.Select(i => i.Size).Sum();
|
||||
|
||||
foreach (BlogImageInfo image in uncompressedImages)
|
||||
{
|
||||
logger.LogInformation("Uncompressed image: {} belonging to blog {}.", image.File.Name,
|
||||
content.BlogName);
|
||||
}
|
||||
|
||||
CompressResult[] compressedImages = (await Task.WhenAll(from image in uncompressedImages
|
||||
select Task.Run(async () => new CompressResult(image, await ConvertToWebp(image.Content))))).ToArray();
|
||||
|
||||
compressedSize += compressedImages.Select(i => i.CompressContent.Length).Sum();
|
||||
|
||||
// 直接在原有的图片列表上添加图片
|
||||
List<BlogImageInfo> images = content.Images.Concat(from r in compressedImages
|
||||
select r.ImageInfo with
|
||||
{
|
||||
File = new FileInfo(r.ImageInfo.File.FullName.Split('.')[0] + ".webp"),
|
||||
Content = r.CompressContent
|
||||
}).ToList();
|
||||
// 修改文本
|
||||
string blogContent = compressedImages.Aggregate(content.Content, (c, r) =>
|
||||
{
|
||||
string originalName = r.ImageInfo.File.Name;
|
||||
string outputName = originalName.Split('.')[0] + ".webp";
|
||||
|
||||
return c.Replace(originalName, outputName);
|
||||
});
|
||||
|
||||
compressedContent.Add(content with { Images = images, Content = blogContent });
|
||||
}
|
||||
|
||||
logger.LogInformation("Compression ratio: {}%.", (double)compressedSize / uncompressedSize * 100.0);
|
||||
|
||||
if (dryRun is false)
|
||||
{
|
||||
await Task.WhenAll(from content in compressedContent
|
||||
select essayScanService.SaveBlogContent(content, content.IsDraft));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ConvertToWebp(byte[] image)
|
||||
{
|
||||
using ImageJob job = new();
|
||||
BuildJobResult result = await job.Decode(MemorySource.Borrow(image))
|
||||
.EncodeToBytes(new WebPLossyEncoder(75))
|
||||
.Finish()
|
||||
.InProcessAsync();
|
||||
|
||||
ArraySegment<byte>? array = result.First?.TryGetBytes();
|
||||
|
||||
if (array.HasValue)
|
||||
{
|
||||
return array.Value.ToArray();
|
||||
}
|
||||
|
||||
throw new BlogFileException();
|
||||
}
|
||||
}
|
@@ -41,14 +41,14 @@ public partial class RendererService(
|
||||
uint wordCount = GetWordCount(content);
|
||||
BlogEssay essay = new()
|
||||
{
|
||||
Title = content.Metadata.Title ?? content.FileName,
|
||||
FileName = content.FileName,
|
||||
Title = content.Metadata.Title ?? content.BlogName,
|
||||
FileName = content.BlogName,
|
||||
IsDraft = content.IsDraft,
|
||||
Description = GetDescription(content),
|
||||
WordCount = wordCount,
|
||||
ReadTime = CalculateReadTime(wordCount),
|
||||
PublishTime = content.Metadata.Date ?? DateTime.Now,
|
||||
HtmlContent = content.FileContent
|
||||
HtmlContent = content.Content
|
||||
};
|
||||
|
||||
if (content.Metadata.Tags is not null)
|
||||
@@ -156,17 +156,17 @@ public partial class RendererService(
|
||||
private string GetDescription(BlogContent content)
|
||||
{
|
||||
const string delimiter = "<!--more-->";
|
||||
int pos = content.FileContent.IndexOf(delimiter, StringComparison.Ordinal);
|
||||
int pos = content.Content.IndexOf(delimiter, StringComparison.Ordinal);
|
||||
bool breakSentence = false;
|
||||
|
||||
if (pos == -1)
|
||||
{
|
||||
// 自动截取前50个字符
|
||||
pos = content.FileContent.Length < 50 ? content.FileContent.Length : 50;
|
||||
pos = content.Content.Length < 50 ? content.Content.Length : 50;
|
||||
breakSentence = true;
|
||||
}
|
||||
|
||||
string rawContent = content.FileContent[..pos];
|
||||
string rawContent = content.Content[..pos];
|
||||
MatchCollection matches = DescriptionPattern.Matches(rawContent);
|
||||
|
||||
StringBuilder builder = new();
|
||||
@@ -182,18 +182,18 @@ public partial class RendererService(
|
||||
|
||||
string description = builder.ToString();
|
||||
|
||||
logger.LogDebug("Description of {} is {}.", content.FileName,
|
||||
logger.LogDebug("Description of {} is {}.", content.BlogName,
|
||||
description);
|
||||
return description;
|
||||
}
|
||||
|
||||
private uint GetWordCount(BlogContent content)
|
||||
{
|
||||
int count = (from c in content.FileContent
|
||||
int count = (from c in content.Content
|
||||
where char.IsLetterOrDigit(c)
|
||||
select c).Count();
|
||||
|
||||
logger.LogDebug("Word count of {} is {}", content.FileName,
|
||||
logger.LogDebug("Word count of {} is {}", content.BlogName,
|
||||
count);
|
||||
return (uint)count;
|
||||
}
|
||||
|
@@ -1,6 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ImageFlow.NativeRuntime.ubuntu-x86_64" Version="2.1.0-rc11"/>
|
||||
<PackageReference Include="ImageFlow.Net" Version="0.13.2"/>
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1"/>
|
||||
<PackageReference Include="AngleSharp" Version="1.1.0"/>
|
||||
<PackageReference Include="Markdig" Version="0.38.0"/>
|
||||
@@ -13,7 +15,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EnsurePnpmInstalled" BeforeTargets="Build">
|
||||
<Target Name="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="low" Text="Ensure pnpm is installed..."/>
|
||||
<Exec Command="pnpm --version" ContinueOnError="true">
|
||||
<Output TaskParameter="ExitCode" PropertyName="ErrorCode"/>
|
||||
@@ -25,9 +27,13 @@
|
||||
<Exec Command="pnpm install"/>
|
||||
</Target>
|
||||
|
||||
<Target Name="TailwindGenerate" AfterTargets="EnsurePnpmInstalled">
|
||||
<Target Name="TailwindGenerate" AfterTargets="EnsurePnpmInstalled" BeforeTargets="BeforeBuild" Condition="'$(_IsPublishing)' == 'yes'">
|
||||
<Message Importance="normal" Text="Generate css files using tailwind..."/>
|
||||
<Exec Command="pnpm tailwind -i wwwroot/input.css -o wwwroot/output.css"/>
|
||||
<Exec Command="pnpm tailwindcss -i wwwroot/tailwind.css -o $(IntermediateOutputPath)tailwind.g.css"/>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="$(IntermediateOutputPath)tailwind.g.css" Visible="false" TargetPath="wwwroot/tailwind.g.css"/>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "YaeBlog",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"scripts": {},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.16"
|
||||
}
|
||||
"name": "yae-blog",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"dev": "tailwindcss -i wwwroot/tailwind.css -o wwwroot/tailwind.g.css -w"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/cli": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
1107
YaeBlog/pnpm-lock.yaml
generated
1107
YaeBlog/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,8 @@
|
||||
title: 2021年终总结
|
||||
date: 2022-01-12 16:27:19
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
- 年终总结
|
||||
---
|
||||
|
||||
2021年已经过去,2022年已经来临。每每一年开始的时候,我都会展开一张纸或者新建一个文档,思量着又是一年时光,也该同诸大杂志一般,写几句意味深长的话语,怀念过去的时光,也祝福未来的自己。可往往脑海中已是三万字的长篇,落在笔头却又是一个字都没有了。
|
||||
|
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: 2022年终总结
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
- 年终总结
|
||||
date: 2022-12-30 14:58:12
|
||||
---
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 2022年暑假碎碎念
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
typora-root-url: 2022-summer-vacation
|
||||
date: 2022-08-22 15:39:13
|
||||
---
|
||||
|
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: 2023年年终总结
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
- 年终总结
|
||||
date: 2024-2-29 20:18:19
|
||||
---
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 人生代码大作业初体验
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
typora-root-url: big-homework
|
||||
date: 2022-07-27 11:34:49
|
||||
---
|
||||
|
@@ -2,7 +2,7 @@
|
||||
title: 日用Linux挑战 第0篇 初见Arch Linux
|
||||
tags:
|
||||
- Linux
|
||||
- 随笔
|
||||
- 杂谈
|
||||
date: 2023-01-15 22:23:08
|
||||
typora-root-url: daily-linux-0
|
||||
---
|
||||
|
@@ -2,7 +2,7 @@
|
||||
title: 日用Linux挑战 第1篇 问题与挑战
|
||||
tags:
|
||||
- Linux
|
||||
- 随笔
|
||||
- 杂谈
|
||||
date: 2023-03-08 22:37:29
|
||||
---
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 日用Linux挑战 第2篇 Wayland
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
- Linux
|
||||
date: 2023-07-23 11:44:34
|
||||
typora-root-url: daily-linux-2
|
||||
|
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 日用Linux挑战 第3篇 放弃Wayland
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
- Linux
|
||||
typora-root-url: daily-linux-3
|
||||
date: 2023-09-04 14:47:46
|
||||
|
@@ -2,7 +2,7 @@
|
||||
title: 日用Linux挑战 第4篇 新的开始
|
||||
tags:
|
||||
- Linux
|
||||
- 随笔
|
||||
- 杂谈
|
||||
date: 2024/03/09 14:00:00
|
||||
---
|
||||
|
||||
|
100
YaeBlog/source/posts/hpc-2025-cpu-architecture.md
Normal file
100
YaeBlog/source/posts/hpc-2025-cpu-architecture.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: High Performance Computing 25 SP CPU Architecture
|
||||
date: 2025-03-13T23:59:08.8167680+08:00
|
||||
tags:
|
||||
- 学习资料
|
||||
- 高性能计算
|
||||
---
|
||||
|
||||
How to use the newly available transistors?
|
||||
|
||||
<!--more-->
|
||||
|
||||
Parallelsim:
|
||||
|
||||
Instruction Level Parallelism(ILP):
|
||||
|
||||
- **Implicit/transparent** to users/programmers.
|
||||
- Instruction pipelining.
|
||||
- Superscalar execution.
|
||||
- Out of order execution.
|
||||
- Register renaming.
|
||||
- Speculative execution.
|
||||
- Branch prediction.
|
||||
|
||||
Task Level Parallelism(TLP):
|
||||
|
||||
- **Explicit** to users/programmers.
|
||||
- Multiple threads or processes executed simultaneously.
|
||||
- Multi-core processors.
|
||||
|
||||
Data Parallelism:
|
||||
|
||||
- Vector processors and SIMD.
|
||||
|
||||
Von Neumann Architecture: the **stored-program** concept. Three components: processor, memory and data path.
|
||||
|
||||
Bandwidth: the gravity of modern computer system.
|
||||
|
||||
## Instruction Pipelining
|
||||
|
||||
Divide incoming instructions into a series of sequential steps performed by different processor unit to keep every part of the processor busy.
|
||||
|
||||
Superscalar execution can execute more than one instruction during a clock cycle.
|
||||
|
||||
Order of order execution.
|
||||
|
||||
Very long instruction word(VLIW): allows programs to explicitly specify instructions to execute at the same time.
|
||||
|
||||
EPIC: Explicit parallel instruction computing.
|
||||
|
||||
Move the complexity of instruction scheduling from the CPU hardware to the software compiler:
|
||||
|
||||
- Check dependencies between instructions.
|
||||
- Assign instructions to the functional units.
|
||||
- Determine when instructions are initiated placed together into a single word.
|
||||
|
||||

|
||||
|
||||
Comparisons between different architecture:
|
||||
|
||||

|
||||
|
||||
## Multi-Core Processor Gala
|
||||
|
||||
Symmetric multiprocessing(SMP): a multiprocessor computer hardware and software architecture.
|
||||
|
||||
Two or more identical processors are connected to a **single shared main memory** and have full access to all input and output devices.
|
||||
|
||||
> Current trend: computer clusters, SMP computers connected with network.
|
||||
|
||||
Multithreading: exploiting thread-level parallelism.
|
||||
|
||||
Multithreading allows multiple threads to share the functional units of a single processor in an overlapping fashion **duplicating only private state**. A thread switch should be much more efficient than a process switch.
|
||||
|
||||
Hardware approaches to multithreading:
|
||||
|
||||
**fine-grained multithreading**:
|
||||
|
||||
- Switches between threads on each clock.
|
||||
- Hide the throughput losses that arise from the both short and long stalls.
|
||||
- Disadvantages: slow down the execution of an individual thread.
|
||||
|
||||
**Coarse-grained multithreading**:
|
||||
|
||||
- Switch threads only on costly stalls.
|
||||
- Limited in its ability to overcome throughput losses
|
||||
|
||||
**Simultaneous multithreading(SMT)**:
|
||||
|
||||
- A variation on fine-grained multithreading
|
||||
|
||||

|
||||
|
||||
## Data Parallelism: Vector Processors
|
||||
|
||||
Provides high-level operations that work on vectors.
|
||||
|
||||
Length of the array also varies depending on hardware.
|
||||
|
||||
SIMD and its generalization in vector parallelism approach improved efficiency by the same operation be performed on multiple data elements.
|
BIN
YaeBlog/source/posts/hpc-2025-cpu-architecture/image-20250313184421305.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/hpc-2025-cpu-architecture/image-20250313184421305.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/hpc-2025-cpu-architecture/image-20250313184732892.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/hpc-2025-cpu-architecture/image-20250313184732892.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/hpc-2025-cpu-architecture/image-20250313190913475.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/hpc-2025-cpu-architecture/image-20250313190913475.png
(Stored with Git LFS)
Normal file
Binary file not shown.
33
YaeBlog/source/posts/hpc-2025-intro.md
Normal file
33
YaeBlog/source/posts/hpc-2025-intro.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: High Performance Computing 25 SP Introduction
|
||||
date: 2025-03-08T00:31:54.5775860+08:00
|
||||
tags:
|
||||
- 高性能计算
|
||||
- 学习资料
|
||||
---
|
||||
|
||||
High performance computing is the use of supercomputing and computing clusters to solve advanced problems.
|
||||
|
||||
<!--more-->
|
||||
|
||||
> High Performance Computing: Software execution speedup.
|
||||
>
|
||||
> High Throughput Computing: Resource utilization efficiency.
|
||||
|
||||
High performance computing:
|
||||
|
||||
- Provide computer power.
|
||||
- Full-Domain HPC = Full-Stack HPC + Full-Network HPC.
|
||||
|
||||
Two key weapons: **partitioning** and **duplicating** to increase granularity.
|
||||
|
||||
HPC History:
|
||||
|
||||
- Mainframe computer
|
||||
- Mini computer
|
||||
- Cluster
|
||||
- Grids
|
||||
- Clouds
|
||||
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 大学生用啥配置——计算机专业
|
||||
tags:
|
||||
- 随笔
|
||||
- 杂谈
|
||||
typora-root-url: laptop-for-computer
|
||||
date: 2022-06-13 16:17:27
|
||||
---
|
||||
|
163
YaeBlog/source/posts/mlir-standalone.md
Normal file
163
YaeBlog/source/posts/mlir-standalone.md
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: 构建运行基于MLIR的独立项目
|
||||
date: 2025-03-19T20:57:31.1928528+08:00
|
||||
tags:
|
||||
- 技术笔记
|
||||
- LLVM
|
||||
---
|
||||
|
||||
MLIR是多层次中间表示形式(Multi-Level Intermediate Representation),是LLVM项目中提供的一项新编译器开发基础设施,使得编译器开发者能够在源代码和可执行代码之间定义多层IR来保留程序信息指导编译优化。本博客指导如何创建一个独立(out-of-tree)的MLIR项目。
|
||||
|
||||
<!--more-->
|
||||
|
||||
## 编译LLVM和MLIR
|
||||
|
||||
考虑到大多数的Linux发行版在打包LLVM时不会编译MLIR,因此自行编译安装包括MLIR项目的LLVM就成为开发独立MLIR项目的前置条件。
|
||||
|
||||
首先在GitHub上下载LLVM的源代码包,我这里选择最新的稳定版本`20.1.0`。
|
||||
|
||||
```shell
|
||||
wget https://github.com/llvm/llvm-project/releases/download/llvmorg-20.1.0/llvm-project-20.1.0.src.tar.xz
|
||||
```
|
||||
|
||||
下载之后解压进入,准备进行构建。
|
||||
|
||||
```shell
|
||||
tar xvf llvm-project-20.1.0.src.tar.xz
|
||||
cd llvm-project-20.1.0.src
|
||||
```
|
||||
|
||||
创建`build`文件夹,使用下面的命令进行生成构建文件。在这里选择使用`Release`构建类型,安装的位置是`~/.local/share/llvm`文件夹,构建的项目包括`llvm`、`clang`和`mlir`三个项目,并指定使用系统上的`clang`和`clang++`编译器作为编译过程中使用的编译器。
|
||||
|
||||
```shell
|
||||
mkdir build
|
||||
cd build
|
||||
cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="/home/ricardo/.local/share/llvm-20.1.0" -DLLVM_ENABLE_PROJECTS="llvm;clang;mlir" -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DLLVM_INSTALL_UTILS=true ../llvm
|
||||
```
|
||||
|
||||

|
||||
|
||||
生成构建文件之后使用`ninja`进行构建。
|
||||
|
||||
```shell
|
||||
ninja
|
||||
```
|
||||
|
||||

|
||||
|
||||
构建在我的i5-13600K上大约需要20分钟。
|
||||
|
||||
构建完成之后进行安装。
|
||||
|
||||
```shell
|
||||
ninja install
|
||||
```
|
||||
|
||||
## 编译MLIR官方的独立项目
|
||||
|
||||
MLIR的官方提供了一个独立项目,项目文件夹在`mlir/examples/standalone`中,将这个文件夹中的内容复制到我们需要的地方,尝试使用上面构建的`mlir`进行构建。
|
||||
|
||||
```shell
|
||||
cp -r ~/Downloads/llvm-project-20.1.0.src/mlir/examples/standalone mlir-standalone
|
||||
cd mlir-standalone
|
||||
```
|
||||
|
||||
### 不启用测试
|
||||
|
||||
编译过程中可能遇到的最大问题是`llvm-lit`,这个使用`python`编写的LLVM集成测试工具,在`standalone`的`README.md`中要求编译过程中使用`LLVM_EXTERNAL_LIT`变量指定到LLVM编译过程中生成的`llvm-lit`可执行文件。
|
||||
|
||||
> 也许就是因为`llvm-lit`是用Python撰写的,所以`llvm-lit`不会安装到`PREFIX`指定的位置。
|
||||
|
||||
不过我们可以禁用测试(笑)。在`CMakeLists.txt`文件中注释对于测试文件夹的添加:
|
||||
|
||||
```cmake
|
||||
add_subdirectory(include)
|
||||
add_subdirectory(lib)
|
||||
if(MLIR_ENABLE_BINDINGS_PYTHON)
|
||||
message(STATUS "Enabling Python API")
|
||||
add_subdirectory(python)
|
||||
endif()
|
||||
#add_subdirectory(test)
|
||||
add_subdirectory(standalone-opt)
|
||||
add_subdirectory(standalone-plugin)
|
||||
add_subdirectory(standalone-translate)
|
||||
|
||||
```
|
||||
|
||||
回到构建文件夹,使用如下的`cmake`指令生成构建文件。
|
||||
|
||||
```shell
|
||||
export LLVM_DIR=/home/ricardo/.local/share/llvm-20.1.0
|
||||
cmake -G Ninja -DMLIR_DIR=$LLVM_DIR/lib/cmake/mlir ..
|
||||
```
|
||||
|
||||
可以顺利通过编译。
|
||||
|
||||

|
||||
|
||||
### 启用测试
|
||||
|
||||
但是测试还是非常重要的。我们尝试启动测试看看,取消对于测试文件夹的注释:
|
||||
|
||||
```shell
|
||||
rm -rf build && mkdir build && cd build
|
||||
cmake -G Ninja -DMLIR_DIR=$LLVM_DIR/lib/cmake/mlir ..
|
||||
```
|
||||
|
||||
很好顺利报错,报错的提示是缺失`FileCheck`、`count`和`not`。
|
||||
|
||||

|
||||
|
||||
那么按照`README.md`中的提示添加上来自构建目录的`llvm-lit`会怎么样呢?
|
||||
|
||||
```shell
|
||||
rm -rf build && mkdir build && cd build
|
||||
export LLVM_BUILD_DIR=/home/ricardo/Downloads/llvm-project-20.1.0.src/build
|
||||
cmake -G Ninja -DMLIR_DIR=$LLVM_DIR/lib/cmake/mlir -DLLVM_EXTERNAL_LIT=$LLVM_BUILD_DIR/bin/llvm-lit ..
|
||||
```
|
||||
|
||||
同样的报错,看来问题不是出在这里。
|
||||
|
||||
经过对于LLVM文档的仔细研究,发现原来是没有启动这个变量:
|
||||
|
||||

|
||||
|
||||
遂修改最初的LLVM编译指令。
|
||||
|
||||
重新运行来自`README.md`的构建文件生成指令之后,测试也完美运行通过:
|
||||
|
||||
```shell
|
||||
ninja test-standalone
|
||||
```
|
||||
|
||||

|
||||
|
||||
不过这个还是有一点令我不是特别满意,这依赖了一个来自于构建目录的工具`llvm-lit`,如果我编译安装的时候眼疾手快的删除了编译目录不就完蛋了。而且我都**standalone**了还依赖似乎有点说不过去?
|
||||
|
||||
于是发现了一篇从`pip`上下载使用`llvm-lit`的[博客](https://medium.com/@mshockwave/using-llvm-lit-out-of-tree-5cddada85a78)和一个LLVM Discourse上面的[帖子](https://discourse.llvm.org/t/running-llvm-lit-on-external-project-test-file-derived-from-standalone-fails/67787),遂进行尝试。
|
||||
|
||||
首先在当前目录下创建一个虚拟环境,并下载安装`llvm-lit`。
|
||||
|
||||
```shell
|
||||
python -m venv .llvm-lit
|
||||
source .llvm-lit/bin/activate
|
||||
pip install lit
|
||||
```
|
||||
|
||||
~~不过这个库似乎没有提供运行入口点,需要我们手动创建一个可执行的`python`文件:~~
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python
|
||||
from lit.main import main
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
经哥们纠正说`lit`包在某个版本之后会安装`lit`的可执行文件,在安装之后可以直接在命令行调用`lit`。因此在激活虚拟环境之后,`cmake`中直接在`LLVM_EXTERNAL_LIT`配置为`$(which lit)`即可。
|
||||
|
||||
```shell
|
||||
cmake -G Ninja -DMLIR_DIR=$LLVM_DIR/lib/cmake/mlir -DLLVM_EXTERNAL_LIT=$(which lit) ..
|
||||
ninja test-standalone
|
||||
```
|
||||
|
||||

|
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319192618697.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319192618697.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319194742171.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319194742171.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319202218503.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319202218503.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319202553644.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319202553644.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319204057832.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319204057832.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319204522857.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319204522857.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319205520649.png
(Stored with Git LFS)
Normal file
BIN
YaeBlog/source/posts/mlir-standalone/image-20250319205520649.png
(Stored with Git LFS)
Normal file
Binary file not shown.
117
YaeBlog/source/posts/msbuild-generate-files.md
Normal file
117
YaeBlog/source/posts/msbuild-generate-files.md
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
title: 如何在MSBuild中复制生成的文件到发布目录中
|
||||
date: 2025-03-20T22:33:21.6955385+08:00
|
||||
tags:
|
||||
- 技术笔记
|
||||
- dotnet
|
||||
---
|
||||
|
||||
如何使用`MSBuild`将构建过程中生成文件复制到生成目录中?
|
||||
|
||||
<!--more-->
|
||||
|
||||
### 遇到的问题
|
||||
|
||||
最近在尝试在`blazor`项目中使用`tailwindcss`作为`css`工具类的提供工具,而不是使用老旧的`bootstrap`框架,不过使用`tailwindcss`需要在项目构建时使用`tailwindcss`工具扫描文件中使用到的`css`属性并生成最终的`css`文件,这就带来了在构建时运行`tailwindcss`生成并复制到输出目录的需求。
|
||||
|
||||
由于我是使用`pnpm`作为前端管理工具,我在项目的`csproj`文件中添加了下面的`Target`来生成文件:
|
||||
|
||||
```xml
|
||||
<Target Name="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="low" Text="Ensure pnpm is installed..."/>
|
||||
<Exec Command="pnpm --version" ContinueOnError="true">
|
||||
<Output TaskParameter="ExitCode" PropertyName="ErrorCode"/>
|
||||
</Exec>
|
||||
|
||||
<Error Condition="$(ErrorCode) != 0" Text="Pnpm is not installed which is required for build."/>
|
||||
|
||||
<Message Importance="normal" Text="Installing pakages using pnpm..."/>
|
||||
<Exec Command="pnpm install"/>
|
||||
</Target>
|
||||
|
||||
<Target Name="TailwindGenerate" AfterTargets="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="normal" Text="Generate css files using tailwind..."/>
|
||||
<Exec Command="pnpm tailwindcss -i wwwroot/tailwind.css -o wwwroot/tailwind.g.css"/>
|
||||
</Target>
|
||||
```
|
||||
|
||||
这套生成逻辑在本地工作良好,但是却在CI上运行时出现了问题:`CI`上打包的`Docker`镜像中没有`tailwind.g.css`文件,导致最终部署的站点丢失了所有的格式。
|
||||
|
||||
### 产生问题的原因
|
||||
|
||||
经过反复实验,我发现只有在构建之前`wwwroot`目录中已经存在`tailwind.g.css`文件的情况下,`MSBuild`才会将生成的文件复制到最终的输出目录中。但是在`CI`环境下,因为使用`.gitignore`没有将`*.g.css`文件添加到代码管理,因此`CI`运行构建之前没有该文件,因此构建的结果中也没有该文件。
|
||||
|
||||
仔细研究`MSBuild`的文档和网络上的[分享](https://gist.github.com/BenVillalobos/c671baa1e32127f4ab582a5abd66b005),我意识到这是由于`MSBuild`的构建流程导致的,MSBuild`的构建流程分成两个大的阶段:
|
||||
|
||||
- 评估阶段(Evaluation Phase)
|
||||
|
||||
在这个阶段,`MSBuild`将会运行读取所有的配置文件,创建需要的属性,展开所有的`glob`,建立好整个构建流程。
|
||||
|
||||
- 执行阶段(Execution Phase)
|
||||
|
||||
在这个阶段,`MSBuild`将按照上一阶段执行的属性执行实际的构建指令。
|
||||
|
||||
这两个阶段的划分就导致在生成阶段才生成的文件不会被包含在复制文件的指令中,因此他们不会被拷贝到最终的输出目录中。
|
||||
|
||||
>这和`cmake`的构建过程很像,首先调用`cmake`生成一些构建指令,在调用实际的构建指令构建二进制文件。
|
||||
|
||||
因此这类问题的推荐解决办法是手动将这些文件添加到构建流程中,即在`BeforeBuild`目标调用之前使用`Content`和`None`等项。
|
||||
|
||||
### 解决问题
|
||||
|
||||
总结上述的解决问题方法,我在上面的构建流程中添加了如下的`None`项:
|
||||
|
||||
```xml
|
||||
<Target Name="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="low" Text="Ensure pnpm is installed..."/>
|
||||
<Exec Command="pnpm --version" ContinueOnError="true">
|
||||
<Output TaskParameter="ExitCode" PropertyName="ErrorCode"/>
|
||||
</Exec>
|
||||
|
||||
<Error Condition="$(ErrorCode) != 0" Text="Pnpm is not installed which is required for build."/>
|
||||
|
||||
<Message Importance="normal" Text="Installing pakages using pnpm..."/>
|
||||
<Exec Command="pnpm install"/>
|
||||
</Target>
|
||||
|
||||
<Target Name="TailwindGenerate" AfterTargets="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="normal" Text="Generate css files using tailwind..."/>
|
||||
<Exec Command="pnpm tailwindcss -i wwwroot/tailwind.css -o wwwroot/tailwind.g.css"/>
|
||||
|
||||
<!-- Make sure generated file will be copied to output directory-->
|
||||
<ItemGroup>
|
||||
<Content Include="wwwroot/tailwind.g.css" Visible="false" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
```
|
||||
|
||||
在运行构建之后,在最终的`publish`文件夹的`wwroot`文件夹中就可以找到`tailwind.g.css`文件。
|
||||
|
||||
不过我还想进行一点优化,`MSBuild`文档中建议将自动生成的文件放在`IntermediateOutputPath`,也就是`obj`文件加中,因此这里尝试将`tailwind.g.css`文件生成到`IntermediateOuputPath`中,优化之后的`Target`项长这个样子:
|
||||
|
||||
```xml
|
||||
<Target Name="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="low" Text="Ensure pnpm is installed..."/>
|
||||
<Exec Command="pnpm --version" ContinueOnError="true">
|
||||
<Output TaskParameter="ExitCode" PropertyName="ErrorCode"/>
|
||||
</Exec>
|
||||
|
||||
<Error Condition="$(ErrorCode) != 0" Text="Pnpm is not installed which is required for build."/>
|
||||
|
||||
<Message Importance="normal" Text="Installing pakages using pnpm..."/>
|
||||
<Exec Command="pnpm install"/>
|
||||
</Target>
|
||||
|
||||
<Target Name="TailwindGenerate" AfterTargets="EnsurePnpmInstalled" BeforeTargets="BeforeBuild">
|
||||
<Message Importance="normal" Text="Generate css files using tailwind..."/>
|
||||
<Exec Command="pnpm tailwindcss -i wwwroot/tailwind.css -o $(IntermediateOutputPath)tailwind.g.css"/>
|
||||
|
||||
<!-- Make sure generated file will be copied to output directory-->
|
||||
<ItemGroup>
|
||||
<Content Include="$(IntermediateOutputPath)tailwind.g.css" Visible="false" TargetPath="wwwroot/tailwind.g.css"/>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
```
|
||||
|
||||
经过测试,这套生成逻辑在`blazor`类库环境下也可以正常运行,类库的文件会被正确地生成到`wwwroot/_content/<ProjectName>/`文件夹下面。
|
||||
|
@@ -1,9 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["**/*.razor", "**/*.cshtml", "**/*.html", "Processors/EssayStylesPostRenderProcessor.cs"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
Binary file not shown.
BIN
YaeBlog/wwwroot/fonts/bootstrap-icons.woff2
(Stored with Git LFS)
BIN
YaeBlog/wwwroot/fonts/bootstrap-icons.woff2
(Stored with Git LFS)
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
1
YaeBlog/wwwroot/tailwind.css
Normal file
1
YaeBlog/wwwroot/tailwind.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
Reference in New Issue
Block a user