21 Commits

Author SHA1 Message Date
939f2373e8 refact: add YaeBlog.Abstractions project.
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-03-14 22:24:08 +08:00
6733bbbd2a fix: build action.
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 53s
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-03-14 21:17:37 +08:00
e10c8e7e75 fix: build action.
Some checks failed
Build blog docker image / Build-Blog-Image (push) Failing after 45s
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-03-14 21:15:33 +08:00
45f15c9bd9 fix: build action.
Some checks failed
Build blog docker image / Build-Blog-Image (push) Has been cancelled
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-03-14 19:51:00 +08:00
a1b5af5b0c fix: build action.
Some checks failed
Build blog docker image / Build-Blog-Image (push) Failing after 2s
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-03-14 19:50:06 +08:00
d8e4931d63 refact: Let host to handle command arguments.
Some checks failed
Build blog docker image / Build-Blog-Image (push) Failing after 0s
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-03-14 19:48:44 +08:00
462fbb28ac feat: rewrite about page for 2026. (#21)
Some checks failed
Build blog docker image / Build-Blog-Image (push) Failing after 14s
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
Reviewed-on: #21
2026-03-03 09:09:49 +00:00
6ea14b186a blog: system-text-json
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 3m40s
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-01-21 22:13:44 +08:00
fa01b74f09 fix: the unordered list has no style.
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 3m31s
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-01-18 20:15:08 +08:00
dd81e9a6f4 fix: Use string for date field of markdown metadata POCO.
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 1m40s
Add new command for build.ps1 script.

Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-01-14 23:19:29 +08:00
35f069f40a feat: move TOBs to the left of essays.
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 3m32s
Fix the word counter to not count the characters in code blocks.

Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-01-10 19:46:06 +08:00
80e48a2043 feat: add update time.
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 3m57s
Make the link in blog blue.
2026-01-07 23:21:09 +08:00
1be39327aa blog: ASP.NET Core static web assets blog.
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 3m40s
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-01-04 17:48:29 +08:00
c050d1b790 feat: add build.ps1 script.
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 3m39s
2026-01-04 17:21:52 +08:00
56374a4e6b feat: update to .net 10. (#20)
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 3m41s
Use ClientAssets to build tailwind styles.
Make blog anchors not open new tab.

Reviewed-on: #20
2026-01-04 01:04:00 +08:00
58ba4b2a2f fix: not watching hidden files when triggering hot reload.
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 1m37s
Fix incorrect page count calculation.
2025-10-22 21:32:49 +08:00
009e86b553 blog: DNS failed in Podman Container (#19)
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 2m7s
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
Reviewed-on: #19
2025-10-22 20:04:32 +08:00
d1ec3a51d1 feat: update build action to use tencent cloud container registry.
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 1m42s
2025-10-19 16:37:16 +08:00
dab866f13a fix: use the right secrets to login registry in build action.
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 55s
2025-09-02 21:13:46 +08:00
94421168c6 blog: high-performance-computing notebook (#17)
Some checks failed
Build blog docker image / Build-Blog-Image (push) Failing after 52s
Reviewed-on: #17
2025-08-31 13:54:08 +08:00
938fe1c715 feat: 增加了赞赏码 (#16)
All checks were successful
Build blog docker image / Build-Blog-Image (push) Successful in 55s
Reviewed-on: #16
2025-06-28 18:24:46 +08:00
401 changed files with 3927 additions and 1130 deletions

View File

@@ -12,7 +12,10 @@ indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[project.json]
[{project.json,appsettings.json,appsettings.*.json}]
indent_size = 2
[*.{yaml,yml}]
indent_size = 2
# C# and Visual Basic files

1
.gitattributes vendored
View File

@@ -1,4 +1,5 @@
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.avif filter=lfs diff=lfs merge=lfs -text
*.webp filter=lfs diff=lfs merge=lfs -text

View File

@@ -7,27 +7,24 @@ jobs:
Build-Blog-Image:
runs-on: archlinux
steps:
- uses: https://mirrors.rrricardo.top/actions/checkout.git@v4
name: Check out code
- name: Check out code.
uses: http://github-mirrors.infra.svc.cluster.local/actions/checkout.git@v4
with:
lfs: true
- name: Build project
- name: Build project.
run: |
cd YaeBlog
dotnet publish
- name: Build docker image
git submodule update --init
podman pull mcr.azure.cn/dotnet/aspnet:10.0
pwsh build.ps1 build
- name: Workaround to make sure podman-login working.
run: |
cd YaeBlog
podman build . -t registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest --build-arg COMMIT_ID=$(git rev-parse --short=10 HEAD)
- name: Workaround to make sure podman login succeed
run: |
mkdir /root/.docker
- name: Login aliyun docker registry
uses: https://mirrors.rrricardo.top/actions/podman-login.git@v1
mkdir -p /root/.docker
- name: Login tencent cloud docker registry.
uses: http://github-mirrors.infra.svc.cluster.local/actions/podman-login.git@v1
with:
registry: registry.cn-beijing.aliyuncs.com
username: 初冬的朝阳
password: ${{ secrets.ALIYUN_PASSWORD }}
registry: ccr.ccs.tencentyun.com
username: 100044380877
password: ${{ secrets.TENCENT_REGISTRY_PASSWORD }}
auth_file_path: /etc/containers/auth.json
- name: Push docker image
run: podman push registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest
- name: Push docker image.
run: podman push ccr.ccs.tencentyun.com/jackfiled/blog:latest

1
.gitignore vendored
View File

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

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "third-party/BlazorSvgComponents"]
path = third-party/BlazorSvgComponents
url = https://git.rrricardo.top/jackfiled/BlazorSvgComponents.git

View File

@@ -7,8 +7,18 @@
<File Path=".editorconfig" />
<File Path=".gitattributes" />
<File Path=".gitignore" />
<File Path="build.ps1" />
<File Path="LICENSE" />
<File Path="README.md" />
</Folder>
<Project Path="YaeBlog/YaeBlog.csproj" />
<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>
<Folder Name="/third-party/" />
<Folder Name="/third-party/BlazorSvgComponents/" />
<Folder Name="/third-party/BlazorSvgComponents/src/">
<Project Path="third-party/BlazorSvgComponents/src/BlazorSvgComponents/BlazorSvgComponents.csproj" />
</Folder>
</Solution>

View File

@@ -1,10 +0,0 @@
using YaeBlog.Models;
namespace YaeBlog.Abstraction;
public interface IEssayScanService
{
public Task<BlogContents> ScanContents();
public Task SaveBlogContent(BlogContent content, bool isDraft = true);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,263 +0,0 @@
using System.CommandLine;
using Microsoft.Extensions.Options;
using YaeBlog.Abstraction;
using YaeBlog.Commands.Binders;
using YaeBlog.Components;
using YaeBlog.Extensions;
using YaeBlog.Models;
using YaeBlog.Services;
namespace YaeBlog.Commands;
public sealed class YaeBlogCommand
{
private readonly RootCommand _rootCommand = new("YaeBlog Cli");
public YaeBlogCommand()
{
AddServeCommand(_rootCommand);
AddWatchCommand(_rootCommand);
AddListCommand(_rootCommand);
AddNewCommand(_rootCommand);
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.UseStaticFiles();
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.UseStaticFiles();
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 = DateTime.Now },
string.Empty, true, [], []));
Console.WriteLine($"Created new blog '{file}.");
}, 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 = DateTime.Now;
// 将选中的博客文件复制到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,20 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<link rel="stylesheet" href="YaeBlog.styles.css"/>
<link rel="icon" href="images/favicon.ico"/>
<link rel="stylesheet" href="globals.css"/>
<link rel="stylesheet" href="tailwind.g.css"/>
<HeadOutlet/>
</head>
<body>
<Routes/>
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -1,33 +0,0 @@
using Markdig;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace YaeBlog.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMarkdig(this IServiceCollection collection)
{
MarkdownPipelineBuilder builder = new();
builder.UseAdvancedExtensions();
collection.AddSingleton<MarkdownPipeline>(_ => builder.Build());
return collection;
}
public static IServiceCollection AddYamlParser(this IServiceCollection collection)
{
DeserializerBuilder deserializerBuilder = new();
deserializerBuilder.WithNamingConvention(CamelCaseNamingConvention.Instance);
deserializerBuilder.IgnoreUnmatchedProperties();
collection.AddSingleton(deserializerBuilder.Build());
SerializerBuilder serializerBuilder = new();
serializerBuilder.WithNamingConvention(CamelCaseNamingConvention.Instance);
collection.AddSingleton(serializerBuilder.Build());
return collection;
}
}

View File

@@ -1,47 +0,0 @@
using AngleSharp;
using Microsoft.Extensions.Options;
using YaeBlog.Abstraction;
using YaeBlog.Services;
using YaeBlog.Models;
using YaeBlog.Processors;
namespace YaeBlog.Extensions;
public static class WebApplicationBuilderExtensions
{
public static WebApplicationBuilder AddYaeBlog(this WebApplicationBuilder builder)
{
builder.Services.Configure<BlogOptions>(builder.Configuration.GetSection(BlogOptions.OptionName));
builder.Services.AddHttpClient();
builder.Services.AddMarkdig();
builder.Services.AddYamlParser();
builder.Services.AddSingleton<AngleSharp.IConfiguration>(_ => Configuration.Default);
builder.Services.AddSingleton<IEssayScanService, EssayScanService>();
builder.Services.AddSingleton<RendererService>();
builder.Services.AddSingleton<IEssayContentService, EssayContentService>();
builder.Services.AddTransient<ImagePostRenderProcessor>();
builder.Services.AddTransient<HeadlinePostRenderProcessor>();
builder.Services.AddTransient<EssayStylesPostRenderProcessor>();
builder.Services.AddTransient<BlogOptions>(provider =>
provider.GetRequiredService<IOptions<BlogOptions>>().Value);
return builder;
}
public static WebApplicationBuilder AddServer(this WebApplicationBuilder builder)
{
builder.Services.AddHostedService<BlogHostedService>();
return builder;
}
public static WebApplicationBuilder AddWatcher(this WebApplicationBuilder builder)
{
builder.Services.AddTransient<BlogChangeWatcher>();
builder.Services.AddHostedService<BlogHotReloadService>();
return builder;
}
}

View File

@@ -1,44 +0,0 @@
@inherits LayoutComponentBase
@attribute [StreamRendering]
<main class="container mx-auto flex flex-col min-h-screen">
<div class="grid grid-cols-3 mx-3">
<div class="md:col-span-2 col-span-3 h-20 flex items-center">
<a href="/blog/">
<span class="text-blue-600 text-2xl">Ricardo's Blog</span>
</a>
</div>
<div class="md:col-span-1 col-span-3 h-20 flex items-center">
<div class="flex flex-row w-full px-2 gap-3 md:justify-center justify-end">
<div>
<a href="/blog/archives/">
<span class="text-xl text-blue-600">归档</span>
</a>
</div>
<div>
<a href="/blog/tags/">
<span class="text-xl text-blue-600">标签</span>
</a>
</div>
<div>
<a href="/about/" target="_blank">
<span class="text-xl text-blue-600">关于</span>
</a>
</div>
<div>
<a href="/friends/" target="_blank">
<span class="text-xl text-blue-600">友链</span>
</a>
</div>
</div>
</div>
</div>
<div class="px-4 py-2 flex-grow">
@Body
</div>
<Foonter/>
</main>

View File

@@ -1,15 +0,0 @@
using System.Collections;
using System.Collections.Concurrent;
namespace YaeBlog.Models;
public record BlogContents(ConcurrentBag<BlogContent> Drafts, ConcurrentBag<BlogContent> Posts)
: IEnumerable<BlogContent>
{
IEnumerator<BlogContent> IEnumerable<BlogContent>.GetEnumerator()
{
return Posts.Concat(Drafts).GetEnumerator();
}
public IEnumerator GetEnumerator() => ((IEnumerable<BlogContent>)this).GetEnumerator();
}

View File

@@ -1,61 +0,0 @@
namespace YaeBlog.Models;
public class BlogEssay : IComparable<BlogEssay>
{
public required string Title { get; init; }
public required string FileName { get; init; }
public required bool IsDraft { get; init; }
public required DateTime PublishTime { get; init; }
public required string Description { get; init; }
public required uint WordCount { get; init; }
public required string ReadTime { get; init; }
public List<string> Tags { get; } = [];
public required string HtmlContent { get; init; }
public BlogEssay WithNewHtmlContent(string newHtmlContent)
{
var essay = new BlogEssay
{
Title = Title,
FileName = FileName,
IsDraft = IsDraft,
PublishTime = PublishTime,
Description = Description,
WordCount = WordCount,
ReadTime = ReadTime,
HtmlContent = newHtmlContent
};
essay.Tags.AddRange(Tags);
return essay;
}
public int CompareTo(BlogEssay? other)
{
if (other is null)
{
return -1;
}
// 草稿文章应当排在前面
if (IsDraft != other.IsDraft)
{
return IsDraft ? -1 : 1;
}
return other.PublishTime.CompareTo(PublishTime);
}
public override string ToString()
{
return $"{Title}-{PublishTime}";
}
}

View File

@@ -1,26 +0,0 @@
namespace YaeBlog.Models;
public class BlogOptions
{
public const string OptionName = "Blog";
/// <summary>
/// 博客markdown文件的根目录
/// </summary>
public required string Root { get; set; }
/// <summary>
/// 博客正文的广而告之
/// </summary>
public required string Announcement { get; set; }
/// <summary>
/// 博客的起始年份
/// </summary>
public required int StartYear { get; set; }
/// <summary>
/// 博客的友链
/// </summary>
public required List<FriendLink> Links { get; set; }
}

View File

@@ -1,27 +0,0 @@
namespace YaeBlog.Models;
/// <summary>
/// 友链模型类
/// </summary>
public class FriendLink
{
/// <summary>
/// 友链名称
/// </summary>
public required string Name { get; set; }
/// <summary>
/// 友链的简单介绍
/// </summary>
public required string Description { get; set; }
/// <summary>
/// 友链地址
/// </summary>
public required string Link { get; set; }
/// <summary>
/// 头像地址
/// </summary>
public required string AvatarImage { get; set; }
}

View File

@@ -1,10 +0,0 @@
namespace YaeBlog.Models;
public class MarkdownMetadata
{
public string? Title { get; set; }
public DateTime? Date { get; set; }
public List<string>? Tags { get; set; }
}

View File

@@ -1,79 +0,0 @@
@page "/about"
<PageTitle>
关于
</PageTitle>
<div class="flex flex-col">
<div>
<h1 class="text-4xl">关于</h1>
</div>
<div class="py-4">
<span class="italic">把字刻在石头上!(・’ω’・)</span>
</div>
<div class="flex flex-col p-2">
<div class="flex flex-col p-2">
<div class="pb-2">
<h3 class="text-2xl">关于我</h3>
</div>
<div class="py-2">
计算机科学与技术在读大学生,明光村幼儿园附属大学所属。正处于读书和失业的叠加态。
一般在互联网上使用<span class="italic">初冬的朝阳</span>或者<span class="italic">jackfiled</span>的名字活动。
<span class="line-through">都是ICP备案过的人了网名似乎没有太大的用处</span>
</div>
<div class="py-2">
主要是一个C#程序员目前也在尝试写一点Rust。
总体上对于编程语言的态度是“<span>大家都是我的翅膀.jpg</span>”。
前后端分离的项目本当上手。
常常因为现实的压力而写一些C/C++。
<span class="line-through">对于Java和Go的评价很低。</span>
日常使用ArchLinux。
</div>
<div class="py-2">
100%社恐。日常生活是宅在电脑前面自言自语。
兴趣活动是读书和看番,目前在玩原神和三角洲。
</div>
<div class="py-4">
常常被人批评没有梦想,这里就随便瞎编一下。
成为嵌入式工程师,修好桌面上的<a href="https://www.bilibili.com/video/BV1VA411p7MD">HoloCubic</a>。
完成第一个不是课程设计的个人开源项目。
遇到能够搭伙过日子的人也算是一大梦想,虽然社恐人根本不知道从何开始的说,
<span class="line-through">什么时候天上才能掉美少女?</span>
</div>
<div class="py-2">
公开的联系渠道是<a href="mailto:shicangjuner@outlook.com">电子邮件</a>。
也可以试试在各大平台搜索上面提到的名字。
</div>
</div>
<div class="flex flex-col p-2">
<div class="pb-2">
<h3 class="text-2xl">关于本站</h3>
</div>
<div class="py-2">
本站肇始于2021年下半年在开始的两年中个人网站和博客是分别的两个网站个人网站是裸HTML写的博客是用
<a href="https://hexo.io">Hexo</a>渲染的。
</div>
<div class="py-2">
2024年我们决定使用.NET技术完全重构两个网站合二为一。虽然目前这个版本还是一个半成品但是我们一定会努力的~(确信。
</div>
<div class="py-2">
2025年我们将使用的样式库从Bootstrap迁移到Tailwind CSS将现代的前端技术同Blazor结合起来。
</div>
</div>
</div>
</div>
@code {
}

View File

@@ -1,59 +0,0 @@
@page "/"
<PageTitle>
Ricardo's Index
</PageTitle>
<div class="mx-20">
<div class="grid grid-cols-3 py-4">
<div class="col-span-3 md:col-span-1 p-5 p-lg-0">
<img src="images/avatar.png" alt="Ricardo's Avatar" class="h-auto max-w-full">
</div>
<div class="col-span-3 md:col-span-2">
<div class="flex flex-col px-3 gap-y-3">
<div class="">
<div class="text-3xl font-bold">初冬的朝阳 (Ricardo Ren)</div>
</div>
<div class="">
<p class="text-lg">a.k.a jackfiled</p>
</div>
<div class="">
<p class="text-lg italic">世界很大,时间很长。</p>
</div>
<div class="">
<p class="text-lg">
平平无奇的计算机科学与技术学徒,连微小的贡献都没做。
</p>
</div>
</div>
</div>
</div>
<div class="py-5">
<p class="text-lg">恕我不能亲自为您沏茶(?),还是非常欢迎您能够来到我的主页。</p>
</div>
<div>
<p class="text-lg py-1">
如果您想四处看看,了解一下屏幕对面的人,可以在我的 <Anchor Address="/blog/" Text="博客"/> 看看。
如果您对于明光村幼儿园某附属技校的计算机教学感兴趣,您可以移步到
<Anchor Address="https://jackfiled.github.io/wiki/" Text="我的学习笔记"/>
<span class="fs-5 text-decoration-line-through">虽然这笔记我自己也木有看过。</span>
如果您想批判一下我的代码,在
<Anchor Address="https://github.com/jackfiled/" Text="Github"/> 和
<Anchor Address="https://git.rrricardo.top/jackfiled/" Text="Gitea"/>
都可以找到。
</p>
<p class="text-lg py-1">
如果您真的很闲,也可以四处搜寻一下,也许存在着一些不为人知的彩蛋。
</p>
</div>
</div>
@code {
}

View File

@@ -1,4 +0,0 @@
using YaeBlog.Commands;
YaeBlogCommand command = new();
await command.RunAsync(args);

View File

@@ -1,40 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<PackageReference Include="ImageFlow.NativeRuntime.ubuntu-x86_64" Version="2.1.0-rc11" Condition="$([MSBuild]::IsOsPlatform('Linux'))"/>
<PackageReference Include="ImageFlow.NativeRuntime.osx-arm64" Version="2.1.0-rc11" Condition="$([MSBuild]::IsOsPlatform('OSX'))"/>
<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"/>
<PackageReference Include="YamlDotNet" Version="16.2.1"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<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" Condition="'$(_IsPublishing)' == 'yes'">
<Message Importance="normal" Text="Generate css files using tailwind..."/>
<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>

View File

@@ -1,44 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Tailwind": {
"InputFile": "wwwroot/input.css",
"OutputFile": "wwwroot/output.css"
},
"Blog": {
"Root": "source",
"Announcement": "博客锐意装修中,敬请期待!测试阶段如有问题还请海涵。",
"StartYear": 2021,
"Links": [
{
"Name": "Ichirinko",
"Description": "这是个大哥",
"Link": "https://ichirinko.top",
"AvatarImage": "https://ichirinko-blog-img-1.oss-cn-shenzhen.aliyuncs.com/Pic_res/img/202209122110798.png"
},
{
"Name": "志田千陽",
"Description": "日出多值得",
"Link": "https://zzachary.top/",
"AvatarImage": "https://zzachary.top/img/ztqy_hub928259802d192ff5718c06370f0f2c4_48203_300x0_resize_q75_box.jpg"
},
{
"Name": "不会写程序的晨旭",
"Description": "一个普通大学生",
"Link": "https://chenxutalk.top",
"AvatarImage": "https://www.chenxutalk.top/img/photo.png"
},
{
"Name": "万木长风",
"Description": "世界渲染中...",
"Link": "https://ryohai.fun",
"AvatarImage": "https://ryohai.fun/icon.jpg"
}
]
}
}

View File

@@ -1,13 +0,0 @@
version: '3.8'
services:
blog:
image: registry.cn-beijing.aliyuncs.com/jackfiled/blog:latest
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.blog.rule=Host(`rrricardo.top`) || Host(`www.rrricardo.top`)"
- "traefik.http.services.blog.loadbalancer.server.port=8080"
- "traefik.http.routers.blog.tls=true"
- "traefik.http.routers.blog.tls.certresolver=myresolver"
- "com.centurylinklabs.watchtower.enable=true"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,15 +0,0 @@
@font-face {
font-family: "Font Awesome 6 Free";
font-style: normal;
font-weight: 400;
font-display: block;
src: url(fonts/fa-regular-400.woff2) format("woff2"), url(fonts/fa-regular-400.ttf) format("truetype")
}
@font-face {
font-family: "Font Awesome 6 Free";
font-style: normal;
font-weight: 900;
font-display: block;
src: url(fonts/fa-solid-900.woff2) format("woff2"), url(fonts/fa-solid-900.ttf) format("truetype")
}

158
build.ps1 Executable file
View File

@@ -0,0 +1,158 @@
#!pwsh
[cmdletbinding()]
param(
[Parameter(Mandatory = $true, Position = 0, HelpMessage = "Specify the build target")]
[ValidateSet("publish", "compress", "build", "dev", "new", "watch", "serve")]
[string]$Target,
[string]$Essay,
[switch]$Compress,
[string]$Root = "source"
)
begin {
if (($Target -eq "tailwind") -or ($Target -eq "build"))
{
# Handle tailwind specially.
return
}
# Set the content root.
$fullRootPath = Join-Path $(Get-Location) $Root
if (-not (Test-Path $fullRootPath))
{
Write-Error "Content root $fullRootPath not existed."
exit 1
}
Write-Host "Use content from" $fullRootPath
$env:BLOG__ROOT=$fullRootPath
Write-Host "Building $Target..."
if ($Target -eq "publish")
{
if ($Essay -eq "")
{
Write-Error "No publish target, please add with --essay argument."
exit 1
}
}
if ($Target -eq "new")
{
if ($Essay -eq "")
{
Write-Error "No new name, please add with --essay argument."
exit 1
}
}
# Set to the current location.
Push-Location src/YaeBlog
}
process {
function Compress-Image
{
Write-Host "Compress image assets..."
dotnet run -- compress --dry-run
$confirm = Read-Host "Really compress images? (y/n)"
if ($confirm -notmatch "^[yY]$")
{
Write-Host "Not compress images."
return
}
Write-Host "Do compress image..."
dotnet run -- compress
dotnet run -- scan
$confirm = Read-Host "Really delete unused images? (y/n)"
if ($confirm -notmatch "^[yY]$")
{
Write-Host "Not delete images."
return
}
Write-Host "Do delete unused images.."
dotnet run -- scan --rm
}
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."
}
function Start-Develop {
Write-Host "Start tailwindcss and dotnet watch servers..."
$pnpmProcess = Start-Process pnpm "tailwindcss -i wwwroot/tailwind.css -o obj/Debug/net10.0/ClientAssets/tailwind.g.css -w" `
-PassThru
try
{
Write-Host "Started pnpm process exit? " $pnpmProcess.HasExited
Start-Process dotnet "watch -- serve" -PassThru | Wait-Process
}
finally
{
if ($pnpmProcess.HasExited)
{
Write-Error "pnpm process has exited!"
exit 1
}
Write-Host "Kill tailwindcss and dotnet watch servers..."
$pnpmProcess | Stop-Process
}
}
switch ($Target)
{
"publish" {
Write-Host "Publish essay $Essay..."
dotnet run -- publish $Essay
if ($Compress)
{
Compress-Image
}
break
}
"compress" {
Compress-Image
break
}
"build" {
Build-Image
break
}
"dev" {
Start-Develop
break
}
"new" {
dotnet run -- new $Essay
}
"watch" {
dotnet run -- watch
break
}
"serve" {
dotnet run -- serve
break
}
"list" {
dotnet run -- list
break
}
}
}
end {
Pop-Location
}

View File

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -0,0 +1,224 @@
---
title: ASP.NET Core中的静态Web资产
date: 2026-01-04T16:36:36.5629759+08:00
tags:
- 技术笔记
- dotnet
- ASP.NET Core
---
Web服务器应该如何扫描与提供静态Web文件尤其是在考虑到缓存、压缩的情况下还需要正确的处理开发环境和部署环境之间的差异让我们来看看ASP.NET Core是如何处理这个问题的。以及如何将通过其他工具例如`pnpm`生成的前端资产文件集成到ASP.NET Core中。
<!--more-->
### 引言——Blazor开发中的静态Web资源
Blazor是ASP.NET Core中~~新推出的~~Web应用程序开发框架通过一系列精巧的设计实现了使用HTML和C#编写运行在浏览器中的应用程序避免了使用丑陋的JavaScript。但是现代的前端开发生态几乎都建立在JavaScript之上尤其是考虑在JavaScript在很长的一段时间都是浏览器唯一支持的脚本语言在Blazor项目开发的过程中必然会遇到一些只能编写JavaScript才能解决的问题。同时一系列的现代前端工具例如[tailwindcss](https://tailwindcss.com/)提供了更加优秀的前端开发体验但是这些都基于NodeJS和NPM等前端工具。以上的前端生态引入了一个问题如何在MSBuild驱动的Blazor应用构建流程中自然地运行前端工具链和ASP.NET Core支持的服务器部署生成的静态资源
Blazor目前提供了一个入口简洁但是功能丰富的静态Web资源提供功能。在使用默认应用目录的情况下项目将会提供一个`wwwroot`文件夹,这个文件夹中的内容将可以从`/`直接寻址。为了提升前端静态文件的使用体验,该文件夹下的文件将会经过一个复杂的管道:
- 在构建扫描到这些资产文件之后MSBuild将会给静态文件加上内容指纹以防止重复使用旧文件。资源还会被压缩以减少资产交付的时间。
- 在运行时,所发现的资产文件将会作为终结点公开,并添加上合适的缓存头和内容类型头。在设置`ETtag``Last-Modified``Content-Type`头之后,浏览器将可以合理的缓存这些静态文件直到应用更新。
该静态文件功能还需要适应应用程序的部署状态:
- 在开发时,或者说运行`dotnet run``dotnet build`该功能需要将对应的静态文件终结点URL映射到磁盘上存储的实际静态文件上就像它们实际上就在`wwwroot`文件夹中一样。考虑到实际上开发过程中会用到Blazor内部的资产文件`blazor.web.js`,引用项目中的资产文件等等,这实际上一个相当复杂的检测-映射流程。
- 在发布时,或者说运行`dotnet publish`时,该功能需要收集所有需要的静态文件并复制到最终发布文件夹的`wwwroot`文件夹之下。
### Microsoft.AspNetCore.ClientAssets
在默认的应用模板下如果需要使用其他的现代前端工具生成静态资产文件最简单的方法就是手动或者编写MSBuild目标Target生成资产文件并放在`wwwroot`文件夹中。但是这个方法存在着如下几个问题:
- 开发者需要编写 MSBuild 目标targets来调用他们的工具。
- 开发者通常没有在构建过程的恰当时机执行其自定义目标。
- 开发者将工具生成的输出文件放入应用的 wwwroot 文件夹中,这会导致这些文件在下一次构建时被误认为是输入文件。
- 开发者没有为这些工具正确声明输入和输出依赖,导致即使输出文件已是最新,工具仍会重复运行,从而增加构建耗时。
面对这些问题M$提供了一个Alpha状态的库`Microsoft.AspNetCore.ClientAssets`来解决这个问题。不幸的是,这个库已经因为年久失修(上一次[更新](https://github.com/aspnet/AspLabs/pull/572)是在3年前引入对于.NET 7的支持在.NET 9引入新的静态资产部署管线之后使用会直接报错了。
^^ 相关的Issues链接[#38445](https://github.com/dotnet/aspnetcore/issues/38445)[#62925](https://github.com/dotnet/aspnetcore/issues/62925)
为了良好地解决如上的问题我们需要首先了解一下ASP.NET Core中静态资产文件的构建和部署过程。
### StaticWebAssetsSdk
在.NET中构建静态资产文件的相关代码在[dotnet/sdk](https://github.com/dotnet/sdk)仓库中,称作`StaticWebAssetsSdk`
静态 Web 资源会接管应用程序 wwwroot 文件夹中的内容项,并全面管理这些内容。在开发过程中,系统会生成一个 JSON 清单manifest其中包含以下信息
- 版本号version number标识清单格式的版本。
- 清单内容的哈希值hash用于判断清单内容是否发生变化。
- 库的包 IDlibrary package id用于区分当前项目与其他项目所提供的资源。
- 库的资源基础路径asset base path在将其他库的路径应用“发现模式”时用于确定要添加的基础路径。
- 清单模式manifest mode定义来自特定项目的资源在构建和发布时应如何处理。
- 相关项目清单及其哈希值的列表:用于判断自清单生成以来,项目引用是否发生变化,或是否有清单被更新。
- “发现模式”discovery patterns列表用于在清单构建完成后有选择性地在运行时提供某些资源。例如可以使用如下模式
```json
{ "Path": "<Project>/Pages", "BasePath": "_content/Something", "Pattern": "**/*.js" }
```
表示仅提供该目录下扩展名为`js`的文件。如果有人添加了图片或其他文件,它们将不会被提供。(这一点很重要,因为这些文件并不符合任何资源规则,也不会包含在发布输出目录中。)
- 构建/发布过程中生成的静态 Web 资源列表。
系统会生成两套清单:**构建清单build manifest** 和 **发布清单publish manifest**
- **构建清单**在构建过程中生成,用于开发阶段,使资源表现得如同它们属于应用程序本身。
- **发布清单**在发布过程中生成,记录了发布阶段对资源执行的所有转换操作。
资源可以在构建阶段或发布阶段定义,并可在任意阶段被标记为“仅构建”或“仅发布”。例如,你可以有两个文件:一个用于开发,一个用于发布,但它们都需要通过相同的 URL 路径提供服务。`service-worker.js` 就是一个典型例子。
**构建时清单**由项目中发现的资源以及来自被引用项目和包的资源共同组成。
**发布清单**则以构建清单为基础,过滤掉仅用于构建的文件,并包含在发布过程中对这些文件执行的所有转换(如链接、打包、压缩等)。
这种机制使得在发布阶段可以执行如链接Linking、打包Bundling、压缩Compression等优化操作。被引用的项目也会生成自己的发布清单其内容会在发布过程中与当前项目的清单合并。同时在发布过程中我们仍会保留被引用项目的原始构建清单以便应用程序可以选择忽略被引用项目的发布资源并对整个依赖传递闭包中的资源执行全局优化。例如一个类库在发布时可能生成一个压缩后的 JS 包,而主应用可以选择不使用多个独立的包,而是收集所有原始构建阶段的资源,生成一个统一的包。通常情况下,构建清单和发布清单内容相同,除非存在仅在发布阶段才应用的转换。
每份清单中会列出在构建/发布过程中生成或计算出的所有资源及其属性。这些属性包括:
- **Identity**:资源的唯一标识(文件的完整路径)。
- **SourceType**:资源类型('Discovered', 'Computed', 'Project', 'Package')。
- **ContentRoot**:开发阶段资源暴露的原始路径。
- **BasePath**:资源暴露的基础路径。
- **RelativePath**:资源的相对路径。
- **AssetKind**:资源用途('Build', 'Publish', 'All'),由 `CopyToOutputDirectory` / `CopyToPublishDirectory` 推断得出。
- **AssetMode**:资源作用范围('CurrentProject', 'Reference', 'All')。
- **AssetRole**:资源角色('Primary', 'Related', 'Alternative')。
- **AssetMergeSource**:当资源被嵌入到其他 TFM目标框架时的来源。
- **AssetMergeBehavior**:当同一 TFM 中出现资源冲突时的合并行为。
- **RelatedAsset**:当前资源所依赖的主资源的 Identity。
- **AssetTraitName**:区分相关或替代资源与主资源的特征名称(如语言、编码格式等)。
- **AssetTraitValue**:该特征的具体值。
- **CopyToBuildDirectory**:与 Content 项一致(如 PreserveNewest、Always
- **CopyToPublishDirectory**:与 Content 项一致。
- **OriginalItemSpec**:定义该资源的原始项规范。
关于资源在不同场景下的使用(作为主项目的一部分,或作为被引用项目的一部分),有三种可能的选项:
- **All**:资源在所有情况下都应被使用。
- **Root**:资源仅在当前项目作为主项目构建时使用。
- **Reference**:资源仅在当前项目被其他项目引用时使用。
例如CSS 隔离CSS isolation生成的两个包
- `<<Project>>.styles.css` 是 **Root** 资源,仅在作为主项目时使用。
- `<<Project>>.lib.bundle.css` 是 **Reference** 资源,仅在被其他项目引用时使用。
除了上述三种使用模式,项目还需定义其在构建和发布过程中如何处理清单中的文件。对此有三种模式:
- **Default**:项目在发布时将所有内容复制到发布输出目录,但当被其他项目引用时不做任何操作,而是期望引用方负责处理静态 Web 资源的发布。
→ 通常用于类库class libraries
- **Root**:项目被视为静态 Web 资源的“根”,即使被引用,其资源也应像主项目一样被处理(例如,不复制传递依赖资源,而只复制 Root 资源)。
→ 用于如 Blazor WebAssembly 托管项目(被 ASP.NET Core 主机项目引用,但资源应视为根项目)。
- **Isolated**:与 Root 类似,但引用项目完全不知道静态 Web 资源的存在;当前项目会自行在发布时设置处理程序,将资源复制到正确位置。
→ 用于如 Blazor 桌面应用,将静态 Web 资源自动纳入 `GetCopyToPublishDirectoryItems`,使引用方无需了解静态 Web 资源机制。
关于资源类型,静态 Web 资源可分为四类:
- **Discovered assets**:从项目中已有项(如 Content、None 等)中发现的资源。
- **Computed assets**:在构建过程中生成、需要在构建时复制到最终位置的资源。
- **Project**:来自被引用项目的资源。当合并被引用项目的清单时,其 Discovered 和 Computed 资源会转换为 Project 类型。
- **Package**:来自被引用 NuGet 包的资源。
关于资源角色Asset Role有三种
- **Primary主资源**:表示可通过其相对路径直接访问的资源。大多数资源属于此类。
- **Related相关资源**:表示与另一个资源相关,但两者都可通过各自的相对路径独立访问。
- **Alternative替代资源**:表示是另一个资源的替代形式,例如预压缩版本或不同格式版本。通常应通过与主资源相同的相对路径提供(具体实现由运行时决定)。静态 Web 资源层仅记录这种关系。
对于 Related 和 Alternative 资源,其 `RelatedAsset` 属性指向其所依赖的主资源。这种依赖链可多层嵌套,以表示一个资源的多种表示形式。静态 Web 资源仅记录这些信息,具体如何使用由 MSBuild 目标决定。
`AssetTraitName` 和 `AssetTraitValue` 用于区分相关/替代资源与其主资源。例如:
- 对于全球化程序集可记录程序集的文化culture
- 对于压缩资源,可记录编码方式(如 gzip、brotli
下图展示了在构建过程中被调用的MSBuild Target
![image-20251231225433184](./aspnetcore-swa/image-20251231225433184.webp)
Sdk提供了一些重要的MSBuild Task供程序员调用
- `DefineStaticWebAssets`该Task扫描提供了一系列候选的资产文件并构建一个*标准化的*静态资产对象;
- `DefineStaticWebAssetEndpoints`该Task以上一个任务输出的静态资产对象为输入输出每个静态资产文件的Web终结点
在构建中过程中`GenerateStaticWebAssetsManifest`和`GenerateStaticWebAssetsDevelopmentManifest`、`GenerateStaticWebAssetEndpointsManifest`等几个任务会产生一个重要的清单文件,这些文件通常存放在*obj*文件夹中,名称为`staticwebassets.*.json`。其中一个较为重要的清单文件是`staticwebassets.development.json`,其存储了所有的静态资产文件和对应的存储目录。这个文件在构建的过程中会被复制到输出目录`bin`中,名称为`$(PackageId).staticwebassets.runtime.json`。这个文件将会在生产模式下被静态资产中间件读取,作为建立静态文件终结点到实际物理文件的索引。这个文件也为需要调试`StaticWebAssetsSdk`的程序员提供了重要的调试信息是解决ASP.NET Core中静态资产问题的不二法门。
### 解决方案
现在已经充分了解了`StaticWebAssetsSdk`可以来设计在MSBuild中集成前端工具并生成最终静态资产文件的管线了。
首先来研究过程的步骤,`npm`或者其类似物也使用类似于MSBuild的先还原再构建两步首先需要安装程序中使用到的包然后在运行构建指令构建对应的静态文件构建完成之后还需要将构建产物交给MSBuild中的静态资产处理管线进行进一步的处理。因此设计如下的三个步骤
1. `RestoreClientAssets`这个Target需要运行`npm install`或者类似的指令安装依赖包;
2. `BuildClientAssets`这个Target运行`npm run build`或者类似的指令构建项目;
3. `DefineClientAssets`这个Target调用`DefineStaticWebAssets`等Task声明静态资产文件。
确定好生成步骤之后,声明一下会在生成过程中会用到的,可以提供给用户自定义的属性。安装和构建的相应软件包肯定是需要提供给用户自定义的。在一般情况下,前端工具链把将静态文件生成到`dist`文件夹中。为了符合MSBuild的惯例这里将中间静态文件生成到*obj*文件夹下的`ClientAssets`文件夹中。为了实现这一点,构建过程中的指令就需要支持一个指定生成目录的参数,这个参数也作为一个属性暴露给用户可以自定义。这里就形成了下面三个提供给用户自定义的参数。
```xml
<PropertyGroup>
<ClientAssetsRestoreCommand Condition="'$(ClientAssesRestoreCommand)' == ''">pnpm install</ClientAssetsRestoreCommand>
<ClientAssetsBuildCommand Condition="'$(ClientAssetsBuildCommand)' == ''">pnpm run build</ClientAssetsBuildCommand>
<ClientAssetsBuildOutputParameter Condition="'$(ClientAssetsBuildOutputParameter)' == ''">--output</ClientAssetsBuildOutputParameter>
</PropertyGroup>
```
最终就是完成的构建原始代码了。第一个运行的构建目标`RestoreClientAssets`将会在`DispatchToInnerBuilds`任务运行之后运行这个目标是MSBuild构建管线中一个不论是针对单架构生成还是多架构生成都只会运行一次的目标这样在项目需要同时编译到.NET 8和.NET 10的情况下仍然只会运行前端的安装命令一次。`BuildClientAssets`目标紧接着`RestoreClientAssets`目标的运行而运行,并将所有生成的前端文件添加到`_ClientAssetsBuildOutput`项中。最终的`DefineClientAssets`目标在负责解析项目中的所有静态文件的目标`ResolveWebAssetsConfiguration`运行之前运行,调用`DefineStaticWebAssets`和`DefineStaticWebAssetEndpoints`将前面生成的所有前端静态文件添加到MSBuild的静态文件处理管线中。
```xml
<PropertyGroup>
<_RestoreClientAssetsBeforeTargets Condition="'$(TargetFramework)' == ''">DispatchToInnerBuilds</_RestoreClientAssetsBeforeTargets>
</PropertyGroup>
<Target Name="RestoreClientAssets" BeforeTargets="$(_RestoreClientAssetsBeforeTargets)">
<Message Importance="high" Text="Running $(ClientAssetsRestoreCommand)"/>
<Exec Command="$(ClientAssetsRestoreCommand)"/>
</Target>
<Target Name="BuildClientAssets" DependsOnTargets="RestoreClientAssets" BeforeTargets="AssignTargetPaths">
<PropertyGroup>
<_ClientAssetsOutputFullPath>$([System.IO.Path]::GetFullPath('$(IntermediateOutputPath)ClientAssets'))</_ClientAssetsOutputFullPath>
</PropertyGroup>
<MakeDir Directories="$(_ClientAssetsOutputFullPath"/>
<Exec Command="$(ClientAssetsBuildCommand) -- $(ClientAssetsBuildOutputParameter) $(_ClientAssetsOutputFullPath)"/>
<ItemGroup>
<_ClientAssetsBuildOutput Include="$(IntermediateOutputPath)ClientAssets\**"/>
</ItemGroup>
</Target>
<Target Name="DefineClientAssets" AfterTargets="BuildClientAssets" DependsOnTargets="ResolveWebAssetsConfiguration">
<ItemGroup>
<FileWrites Include="@(_ClientAssetsBuildOutput)"/>
</ItemGroup>
<DefineStaticWebAssets
CandidateAssets="@(_ClientAssetsBuildOutput)"
SourceId="$(PackageId)"
SourceType="Computed"
ContentRoot="$(_ClientAssetsOutputFullPath)"
BasePath="$(StaticWebAssetBasePath)"
>
<Output TaskParameter="Assets" ItemName="StaticWebAsset"/>
<Output TaskParameter="Assets" ItemName="_ClientAssetsStaticWebAsset"/>
</DefineStaticWebAssets>
<DefineStaticWebAssetEndpoints
CandidateAssets="@(_ClientAssetsStaticWebAsset)"
ContentTypeMappings="@(StaticWebAssetContentTypeMapping)"
>
<Output TaskParameter="Endpoints" ItemName="StaticWebAssetEndpoint" />
</DefineStaticWebAssetEndpoints>
</Target>
```
为了测试如下的代码,可以在项目中新建一个`Directory.Build.targets`文件,将上述的内容复制进去进行测试,当然别忘了用`<Project>`标签包裹这一切。

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More