refact: Rewrite essay scan service and interface.

This commit is contained in:
jackfiled 2024-08-22 22:37:34 +08:00
parent e6ed407285
commit 6cc74bf1e3
133 changed files with 270 additions and 177 deletions

View File

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

View File

@ -20,12 +20,14 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddYamlParser(this IServiceCollection collection) public static IServiceCollection AddYamlParser(this IServiceCollection collection)
{ {
DeserializerBuilder builder = new(); DeserializerBuilder deserializerBuilder = new();
deserializerBuilder.WithNamingConvention(CamelCaseNamingConvention.Instance);
deserializerBuilder.IgnoreUnmatchedProperties();
collection.AddSingleton(deserializerBuilder.Build());
builder.WithNamingConvention(CamelCaseNamingConvention.Instance); SerializerBuilder serializerBuilder = new();
builder.IgnoreUnmatchedProperties(); serializerBuilder.WithNamingConvention(CamelCaseNamingConvention.Instance);
collection.AddSingleton(serializerBuilder.Build());
collection.AddSingleton<IDeserializer>(_ => builder.Build());
return collection; return collection;
} }

View File

@ -20,7 +20,7 @@ public static class WebApplicationBuilderExtensions
builder.Services.AddMarkdig(); builder.Services.AddMarkdig();
builder.Services.AddYamlParser(); builder.Services.AddYamlParser();
builder.Services.AddSingleton<IConfiguration>(_ => Configuration.Default); builder.Services.AddSingleton<IConfiguration>(_ => Configuration.Default);
builder.Services.AddSingleton<EssayScanService>(); builder.Services.AddSingleton<IEssayScanService, EssayScanService>();
builder.Services.AddSingleton<RendererService>(); builder.Services.AddSingleton<RendererService>();
builder.Services.AddSingleton<EssayContentService>(); builder.Services.AddSingleton<EssayContentService>();
builder.Services.AddSingleton<IEssayContentService, EssayContentService>(provider => builder.Services.AddSingleton<IEssayContentService, EssayContentService>(provider =>

View File

@ -4,5 +4,7 @@ public class BlogContent
{ {
public required string FileName { get; init; } public required string FileName { get; init; }
public required MarkdownMetadata Metadata { get; init; }
public required string FileContent { get; set; } public required string FileContent { get; set; }
} }

View File

@ -0,0 +1,10 @@
using System.Collections.Concurrent;
namespace YaeBlog.Core.Models;
public sealed class BlogContents(ConcurrentBag<BlogContent> drafts, ConcurrentBag<BlogContent> posts)
{
public ConcurrentBag<BlogContent> Drafts { get; } = drafts;
public ConcurrentBag<BlogContent> Posts { get; } = posts;
}

View File

@ -3,6 +3,7 @@ using AngleSharp.Dom;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using YaeBlog.Core.Abstractions; using YaeBlog.Core.Abstractions;
using YaeBlog.Core.Exceptions;
using YaeBlog.Core.Models; using YaeBlog.Core.Models;
namespace YaeBlog.Core.Processors; namespace YaeBlog.Core.Processors;
@ -47,12 +48,12 @@ public class ImagePostRenderProcessor(ILogger<ImagePostRenderProcessor> logger,
filename = Path.Combine(essayFilename, filename); filename = Path.Combine(essayFilename, filename);
} }
filename = Path.Combine(_options.Root, filename); filename = Path.Combine(_options.Root, "posts", filename);
if (!Path.Exists(filename)) if (!Path.Exists(filename))
{ {
logger.LogError("Failed to found image: {}.", filename); logger.LogError("Failed to found image: {}.", filename);
throw new InvalidOperationException(); throw new BlogFileException($"Image {filename} doesn't exist.");
} }
string imageLink = "api/files/" + filename; string imageLink = "api/files/" + filename;

View File

@ -1,5 +0,0 @@
# YaeBlog.Core
A blog generation totally based on Blazor.
You can using this to create your blog all in .NET stack!

View File

@ -1,20 +1,104 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using YaeBlog.Core.Abstractions;
using YaeBlog.Core.Exceptions; using YaeBlog.Core.Exceptions;
using YaeBlog.Core.Models; using YaeBlog.Core.Models;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
namespace YaeBlog.Core.Services; namespace YaeBlog.Core.Services;
public class EssayScanService( public class EssayScanService(
ISerializer yamlSerializer,
IDeserializer yamlDeserializer,
IOptions<BlogOptions> blogOptions, IOptions<BlogOptions> blogOptions,
ILogger<EssayContentService> logger) ILogger<EssayScanService> logger) : IEssayScanService
{ {
private readonly BlogOptions _blogOptions = blogOptions.Value; private readonly BlogOptions _blogOptions = blogOptions.Value;
public async Task<List<BlogContent>> ScanAsync() public async Task<BlogContents> ScanContents()
{ {
string root = Path.Combine(Environment.CurrentDirectory, _blogOptions.Root); ValidateDirectory(_blogOptions.Root, out DirectoryInfo drafts, out DirectoryInfo posts);
return new BlogContents(
await ScanContentsInternal(drafts),
await ScanContentsInternal(posts));
}
public async Task SaveBlogContent(BlogContent content, bool isDraft = true)
{
ValidateDirectory(_blogOptions.Root, 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 (targetFile.Exists)
{
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("---\n");
await writer.WriteAsync("<!--more-->\n");
}
private async Task<ConcurrentBag<BlogContent>> ScanContentsInternal(DirectoryInfo directory)
{
IEnumerable<FileInfo> markdownFiles = from file in directory.EnumerateFiles()
where file.Extension == ".md"
select file;
ConcurrentBag<(string, string)> fileContents = [];
await Parallel.ForEachAsync(markdownFiles, async (file, token) =>
{
using StreamReader reader = file.OpenText();
fileContents.Add((file.Name, await reader.ReadToEndAsync(token)));
});
ConcurrentBag<BlogContent> contents = [];
await Task.Run(() =>
{
foreach ((string filename, string content) in fileContents)
{
int endPos = content.IndexOf("---", 4, StringComparison.Ordinal);
if (!content.StartsWith("---") || endPos is -1 or 0)
{
logger.LogWarning("Failed to parse metadata from {}, skipped.", filename);
return;
}
string metadataString = content[4..endPos];
try
{
MarkdownMetadata metadata = yamlDeserializer.Deserialize<MarkdownMetadata>(metadataString);
logger.LogDebug("Scan metadata title: '{}' for {}.", metadata.Title, filename);
contents.Add(new BlogContent
{
FileName = filename[..^3], Metadata = metadata, FileContent = content[(endPos + 3)..]
});
}
catch (YamlException e)
{
logger.LogWarning("Failed to parser metadata from {} due to {}, skipping", filename, e);
}
}
});
return contents;
}
private void ValidateDirectory(string root, out DirectoryInfo drafts, out DirectoryInfo posts)
{
root = Path.Combine(Environment.CurrentDirectory, root);
DirectoryInfo rootDirectory = new(root); DirectoryInfo rootDirectory = new(root);
if (!rootDirectory.Exists) if (!rootDirectory.Exists)
@ -22,36 +106,17 @@ public class EssayScanService(
throw new BlogFileException($"'{root}' is not a directory."); throw new BlogFileException($"'{root}' is not a directory.");
} }
List<FileInfo> markdownFiles = []; if (rootDirectory.EnumerateDirectories().All(dir => dir.Name != "drafts"))
await Task.Run(() =>
{ {
foreach (FileInfo fileInfo in rootDirectory.EnumerateFiles()) throw new BlogFileException($"'{root}/drafts' not exists.");
{
if (fileInfo.Extension != ".md")
{
continue;
} }
logger.LogDebug("Scan markdown file: {}.", fileInfo.Name); if (rootDirectory.EnumerateDirectories().All(dir => dir.Name != "posts"))
markdownFiles.Add(fileInfo);
}
});
ConcurrentBag<BlogContent> contents = [];
await Parallel.ForEachAsync(markdownFiles, async (info, token) =>
{ {
StreamReader reader = new(info.OpenRead()); throw new BlogFileException($"'{root}/posts' not exists.");
}
BlogContent content = new() drafts = new DirectoryInfo(Path.Combine(root, "drafts"));
{ posts = new DirectoryInfo(Path.Combine(root, "posts"));
FileName = info.Name.Split('.')[0], FileContent = await reader.ReadToEndAsync(token)
};
contents.Add(content);
});
return contents.ToList();
} }
} }

View File

@ -7,16 +7,13 @@ using Microsoft.Extensions.Logging;
using YaeBlog.Core.Abstractions; using YaeBlog.Core.Abstractions;
using YaeBlog.Core.Exceptions; using YaeBlog.Core.Exceptions;
using YaeBlog.Core.Models; using YaeBlog.Core.Models;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
namespace YaeBlog.Core.Services; namespace YaeBlog.Core.Services;
public partial class RendererService( public partial class RendererService(
ILogger<RendererService> logger, ILogger<RendererService> logger,
EssayScanService essayScanService, IEssayScanService essayScanService,
MarkdownPipeline markdownPipeline, MarkdownPipeline markdownPipeline,
IDeserializer yamlDeserializer,
EssayContentService essayContentService) EssayContentService essayContentService)
{ {
private readonly Stopwatch _stopwatch = new(); private readonly Stopwatch _stopwatch = new();
@ -30,30 +27,30 @@ public partial class RendererService(
_stopwatch.Start(); _stopwatch.Start();
logger.LogInformation("Render essays start."); logger.LogInformation("Render essays start.");
List<BlogContent> contents = await essayScanService.ScanAsync(); BlogContents contents = await essayScanService.ScanContents();
IEnumerable<BlogContent> preProcessedContents = await PreProcess(contents); List<BlogContent> posts = contents.Posts.ToList();
IEnumerable<BlogContent> preProcessedContents = await PreProcess(posts);
List<BlogEssay> essays = []; List<BlogEssay> essays = [];
await Task.Run(() => await Task.Run(() =>
{ {
foreach (BlogContent content in preProcessedContents) foreach (BlogContent content in preProcessedContents)
{ {
MarkdownMetadata? metadata = TryParseMetadata(content);
uint wordCount = GetWordCount(content); uint wordCount = GetWordCount(content);
BlogEssay essay = new() BlogEssay essay = new()
{ {
Title = metadata?.Title ?? content.FileName, Title = content.Metadata.Title ?? content.FileName,
FileName = content.FileName, FileName = content.FileName,
Description = GetDescription(content), Description = GetDescription(content),
WordCount = wordCount, WordCount = wordCount,
ReadTime = CalculateReadTime(wordCount), ReadTime = CalculateReadTime(wordCount),
PublishTime = metadata?.Date ?? DateTime.Now, PublishTime = content.Metadata.Date ?? DateTime.Now,
HtmlContent = content.FileContent HtmlContent = content.FileContent
}; };
if (metadata?.Tags is not null) if (content.Metadata.Tags is not null)
{ {
essay.Tags.AddRange(metadata.Tags); essay.Tags.AddRange(content.Metadata.Tags);
} }
essays.Add(essay); essays.Add(essay);
@ -138,45 +135,6 @@ public partial class RendererService(
}); });
} }
private MarkdownMetadata? TryParseMetadata(BlogContent content)
{
string fileContent = content.FileContent.Trim();
if (!fileContent.StartsWith("---"))
{
return null;
}
// 移除起始的---
fileContent = fileContent[3..];
int lastPos = fileContent.IndexOf("---", StringComparison.Ordinal);
if (lastPos is -1 or 0)
{
return null;
}
string yamlContent = fileContent[..lastPos];
// 返回去掉元数据之后的文本
lastPos += 3;
content.FileContent = fileContent[lastPos..];
try
{
MarkdownMetadata metadata =
yamlDeserializer.Deserialize<MarkdownMetadata>(yamlContent);
logger.LogDebug("Title: {}, Publish Date: {}.",
metadata.Title, metadata.Date);
return metadata;
}
catch (YamlException e)
{
logger.LogWarning("Failed to parse '{}' metadata: {}", yamlContent, e);
return null;
}
}
[GeneratedRegex(@"(?<!\\)[^\#\*_\-\+\`{}\[\]!~]+")] [GeneratedRegex(@"(?<!\\)[^\#\*_\-\+\`{}\[\]!~]+")]
private static partial Regex DescriptionPattern(); private static partial Regex DescriptionPattern();

View File

@ -6,16 +6,6 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<PropertyGroup>
<PackageId>YaeBlog.Core</PackageId>
<Version>0.1.0</Version>
<Authors>Ricardo Ren</Authors>
<Company>Ricardo Ren</Company>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<SupportedPlatform Include="browser" /> <SupportedPlatform Include="browser" />
</ItemGroup> </ItemGroup>
@ -32,12 +22,6 @@
<PackageReference Include="YamlDotNet" Version="13.7.1" /> <PackageReference Include="YamlDotNet" Version="13.7.1" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Include="../.gitignore" />
<None Include="../.editorconfig" />
<None Include="./README.md" Pack="true" PackagePath="/"/>
</ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="wwwroot\" /> <Folder Include="wwwroot\" />
</ItemGroup> </ItemGroup>

View File

@ -14,6 +14,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
.gitea\workflows\build.yaml = .gitea\workflows\build.yaml .gitea\workflows\build.yaml = .gitea\workflows\build.yaml
EndProjectSection EndProjectSection
EndProject 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
EndProjectSection
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU

View File

@ -0,0 +1,36 @@
using System.CommandLine.Binding;
using System.Text.Json;
using Microsoft.Extensions.Options;
using YaeBlog.Core.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

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

View File

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

View File

@ -1,30 +0,0 @@
using System.CommandLine.Binding;
using System.Text.Json;
using YaeBlog.Core.Models;
namespace YaeBlog.Commands;
public sealed class BlogOptionsBinder : BinderBase<BlogOptions>
{
protected override BlogOptions GetBoundValue(BindingContext bindingContext)
{
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 result;
}
}

View File

@ -1,12 +1,15 @@
using System.CommandLine; using System.CommandLine;
using YaeBlog.Commands.Binders;
using YaeBlog.Components; using YaeBlog.Components;
using YaeBlog.Core.Extensions; using YaeBlog.Core.Extensions;
using YaeBlog.Core.Models;
using YaeBlog.Core.Services;
namespace YaeBlog.Commands; namespace YaeBlog.Commands;
public static class CommandExtensions public static class CommandExtensions
{ {
public static Command AddServeCommand(this RootCommand rootCommand) public static void AddServeCommand(this RootCommand rootCommand)
{ {
Command serveCommand = new("serve", "Start http server."); Command serveCommand = new("serve", "Start http server.");
rootCommand.AddCommand(serveCommand); rootCommand.AddCommand(serveCommand);
@ -34,11 +37,9 @@ public static class CommandExtensions
CancellationToken token = context.GetCancellationToken(); CancellationToken token = context.GetCancellationToken();
await application.RunAsync(token); await application.RunAsync(token);
}); });
return rootCommand;
} }
public static Command AddNewCommand(this RootCommand rootCommand) public static void AddNewCommand(this RootCommand rootCommand)
{ {
Command newCommand = new("new", "Create a new blog file and image directory."); Command newCommand = new("new", "Create a new blog file and image directory.");
rootCommand.AddCommand(newCommand); rootCommand.AddCommand(newCommand);
@ -46,45 +47,40 @@ public static class CommandExtensions
Argument<string> filenameArgument = new(name: "blog name", description: "The created blog filename."); Argument<string> filenameArgument = new(name: "blog name", description: "The created blog filename.");
newCommand.AddArgument(filenameArgument); newCommand.AddArgument(filenameArgument);
newCommand.SetHandler(async (file, blogOptions) => newCommand.SetHandler(async (file, _, _, essayScanService) =>
{ {
string fileWithExtension; await essayScanService.SaveBlogContent(new BlogContent
if (file.EndsWith(".md"))
{ {
fileWithExtension = file; FileName = file,
file = fileWithExtension[..fileWithExtension.LastIndexOf('.')]; FileContent = string.Empty,
} Metadata = new MarkdownMetadata { Title = file, Date = DateTime.Now }
else });
{
fileWithExtension = file + ".md";
}
DirectoryInfo rootDir = new(Path.Combine(Environment.CurrentDirectory, blogOptions.Root));
if (!rootDir.Exists)
{
throw new InvalidOperationException($"Blog source directory '{blogOptions.Root} doesn't exist.");
}
if (rootDir.EnumerateFiles().Any(f => f.Name == fileWithExtension))
{
throw new InvalidOperationException($"Target blog '{file}' has been created!");
}
FileInfo newBlogFile = new(Path.Combine(rootDir.FullName, fileWithExtension));
await using StreamWriter newStream = newBlogFile.CreateText();
await newStream.WriteAsync($"""
---
title: {file}
tags:
---
<!--more-->
""");
Console.WriteLine($"Created new blog '{file}."); Console.WriteLine($"Created new blog '{file}.");
}, filenameArgument, new BlogOptionsBinder()); }, filenameArgument, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(),
new EssayScanServiceBinder());
}
public static void AddListCommand(this RootCommand rootCommand)
{
Command command = new("list", "List all blogs");
rootCommand.Add(command);
return newCommand; command.SetHandler(async (_, _, essyScanService) =>
{
BlogContents contents = await essyScanService.ScanContents();
Console.WriteLine($"All {contents.Posts.Count} Posts:");
foreach (BlogContent content in contents.Posts)
{
Console.WriteLine($" - {content.FileName}");
}
Console.WriteLine($"All {contents.Drafts.Count} Drafts:");
foreach (BlogContent content in contents.Drafts)
{
Console.WriteLine($" - {content.FileName}");
}
}, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(), new EssayScanServiceBinder());
} }
} }

View File

@ -5,5 +5,6 @@ RootCommand rootCommand = new("YaeBlog CLI");
rootCommand.AddServeCommand(); rootCommand.AddServeCommand();
rootCommand.AddNewCommand(); rootCommand.AddNewCommand();
rootCommand.AddListCommand();
await rootCommand.InvokeAsync(args); await rootCommand.InvokeAsync(args);

View File

@ -0,0 +1,6 @@
---
title: test-essay
date: 2024-08-22T22:31:34.3177253+08:00
tags:
---
<!--more-->

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