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)
{
DeserializerBuilder builder = new();
DeserializerBuilder deserializerBuilder = new();
deserializerBuilder.WithNamingConvention(CamelCaseNamingConvention.Instance);
deserializerBuilder.IgnoreUnmatchedProperties();
collection.AddSingleton(deserializerBuilder.Build());
builder.WithNamingConvention(CamelCaseNamingConvention.Instance);
builder.IgnoreUnmatchedProperties();
collection.AddSingleton<IDeserializer>(_ => builder.Build());
SerializerBuilder serializerBuilder = new();
serializerBuilder.WithNamingConvention(CamelCaseNamingConvention.Instance);
collection.AddSingleton(serializerBuilder.Build());
return collection;
}

View File

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

View File

@ -4,5 +4,7 @@ public class BlogContent
{
public required string FileName { get; init; }
public required MarkdownMetadata Metadata { get; init; }
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.Options;
using YaeBlog.Core.Abstractions;
using YaeBlog.Core.Exceptions;
using YaeBlog.Core.Models;
namespace YaeBlog.Core.Processors;
@ -47,12 +48,12 @@ public class ImagePostRenderProcessor(ILogger<ImagePostRenderProcessor> logger,
filename = Path.Combine(essayFilename, filename);
}
filename = Path.Combine(_options.Root, filename);
filename = Path.Combine(_options.Root, "posts", filename);
if (!Path.Exists(filename))
{
logger.LogError("Failed to found image: {}.", filename);
throw new InvalidOperationException();
throw new BlogFileException($"Image {filename} doesn't exist.");
}
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 Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using YaeBlog.Core.Abstractions;
using YaeBlog.Core.Exceptions;
using YaeBlog.Core.Models;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
namespace YaeBlog.Core.Services;
public class EssayScanService(
ISerializer yamlSerializer,
IDeserializer yamlDeserializer,
IOptions<BlogOptions> blogOptions,
ILogger<EssayContentService> logger)
ILogger<EssayScanService> logger) : IEssayScanService
{
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);
if (!rootDirectory.Exists)
@ -22,36 +106,17 @@ public class EssayScanService(
throw new BlogFileException($"'{root}' is not a directory.");
}
List<FileInfo> markdownFiles = [];
await Task.Run(() =>
if (rootDirectory.EnumerateDirectories().All(dir => dir.Name != "drafts"))
{
foreach (FileInfo fileInfo in rootDirectory.EnumerateFiles())
{
if (fileInfo.Extension != ".md")
{
continue;
}
throw new BlogFileException($"'{root}/drafts' not exists.");
}
logger.LogDebug("Scan markdown file: {}.", fileInfo.Name);
markdownFiles.Add(fileInfo);
}
});
ConcurrentBag<BlogContent> contents = [];
await Parallel.ForEachAsync(markdownFiles, async (info, token) =>
if (rootDirectory.EnumerateDirectories().All(dir => dir.Name != "posts"))
{
StreamReader reader = new(info.OpenRead());
throw new BlogFileException($"'{root}/posts' not exists.");
}
BlogContent content = new()
{
FileName = info.Name.Split('.')[0], FileContent = await reader.ReadToEndAsync(token)
};
contents.Add(content);
});
return contents.ToList();
drafts = new DirectoryInfo(Path.Combine(root, "drafts"));
posts = new DirectoryInfo(Path.Combine(root, "posts"));
}
}

View File

@ -7,16 +7,13 @@ using Microsoft.Extensions.Logging;
using YaeBlog.Core.Abstractions;
using YaeBlog.Core.Exceptions;
using YaeBlog.Core.Models;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
namespace YaeBlog.Core.Services;
public partial class RendererService(
ILogger<RendererService> logger,
EssayScanService essayScanService,
IEssayScanService essayScanService,
MarkdownPipeline markdownPipeline,
IDeserializer yamlDeserializer,
EssayContentService essayContentService)
{
private readonly Stopwatch _stopwatch = new();
@ -30,30 +27,30 @@ public partial class RendererService(
_stopwatch.Start();
logger.LogInformation("Render essays start.");
List<BlogContent> contents = await essayScanService.ScanAsync();
IEnumerable<BlogContent> preProcessedContents = await PreProcess(contents);
BlogContents contents = await essayScanService.ScanContents();
List<BlogContent> posts = contents.Posts.ToList();
IEnumerable<BlogContent> preProcessedContents = await PreProcess(posts);
List<BlogEssay> essays = [];
await Task.Run(() =>
{
foreach (BlogContent content in preProcessedContents)
{
MarkdownMetadata? metadata = TryParseMetadata(content);
uint wordCount = GetWordCount(content);
BlogEssay essay = new()
{
Title = metadata?.Title ?? content.FileName,
Title = content.Metadata.Title ?? content.FileName,
FileName = content.FileName,
Description = GetDescription(content),
WordCount = wordCount,
ReadTime = CalculateReadTime(wordCount),
PublishTime = metadata?.Date ?? DateTime.Now,
PublishTime = content.Metadata.Date ?? DateTime.Now,
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);
@ -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(@"(?<!\\)[^\#\*_\-\+\`{}\[\]!~]+")]
private static partial Regex DescriptionPattern();

View File

@ -6,16 +6,6 @@
<ImplicitUsings>enable</ImplicitUsings>
</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>
<SupportedPlatform Include="browser" />
</ItemGroup>
@ -32,12 +22,6 @@
<PackageReference Include="YamlDotNet" Version="13.7.1" />
</ItemGroup>
<ItemGroup>
<None Include="../.gitignore" />
<None Include="../.editorconfig" />
<None Include="./README.md" Pack="true" PackagePath="/"/>
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>

View File

@ -14,6 +14,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
.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
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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 YaeBlog.Commands.Binders;
using YaeBlog.Components;
using YaeBlog.Core.Extensions;
using YaeBlog.Core.Models;
using YaeBlog.Core.Services;
namespace YaeBlog.Commands;
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.");
rootCommand.AddCommand(serveCommand);
@ -34,11 +37,9 @@ public static class CommandExtensions
CancellationToken token = context.GetCancellationToken();
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.");
rootCommand.AddCommand(newCommand);
@ -46,45 +47,40 @@ public static class CommandExtensions
Argument<string> filenameArgument = new(name: "blog name", description: "The created blog filename.");
newCommand.AddArgument(filenameArgument);
newCommand.SetHandler(async (file, blogOptions) =>
newCommand.SetHandler(async (file, _, _, essayScanService) =>
{
await essayScanService.SaveBlogContent(new BlogContent
{
FileName = file,
FileContent = string.Empty,
Metadata = new MarkdownMetadata { Title = file, Date = DateTime.Now }
});
Console.WriteLine($"Created new blog '{file}.");
}, 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);
command.SetHandler(async (_, _, essyScanService) =>
{
string fileWithExtension;
if (file.EndsWith(".md"))
BlogContents contents = await essyScanService.ScanContents();
Console.WriteLine($"All {contents.Posts.Count} Posts:");
foreach (BlogContent content in contents.Posts)
{
fileWithExtension = file;
file = fileWithExtension[..fileWithExtension.LastIndexOf('.')];
}
else
{
fileWithExtension = file + ".md";
Console.WriteLine($" - {content.FileName}");
}
DirectoryInfo rootDir = new(Path.Combine(Environment.CurrentDirectory, blogOptions.Root));
if (!rootDir.Exists)
Console.WriteLine($"All {contents.Drafts.Count} Drafts:");
foreach (BlogContent content in contents.Drafts)
{
throw new InvalidOperationException($"Blog source directory '{blogOptions.Root} doesn't exist.");
Console.WriteLine($" - {content.FileName}");
}
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}.");
}, filenameArgument, new BlogOptionsBinder());
return newCommand;
}, new BlogOptionsBinder(), new LoggerBinder<EssayScanService>(), new EssayScanServiceBinder());
}
}

View File

@ -5,5 +5,6 @@ RootCommand rootCommand = new("YaeBlog CLI");
rootCommand.AddServeCommand();
rootCommand.AddNewCommand();
rootCommand.AddListCommand();
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