add: table of contents
This commit is contained in:
parent
12777c227d
commit
91ae6d8ac1
8
YaeBlog.Core/Abstractions/ITableOfContentService.cs
Normal file
8
YaeBlog.Core/Abstractions/ITableOfContentService.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
using YaeBlog.Core.Models;
|
||||
|
||||
namespace YaeBlog.Core.Abstractions;
|
||||
|
||||
public interface ITableOfContentService
|
||||
{
|
||||
public IReadOnlyDictionary<string, BlogHeadline> Headlines { get; }
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using AngleSharp;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using YaeBlog.Core.Abstractions;
|
||||
|
@ -18,14 +19,19 @@ public static class WebApplicationBuilderExtensions
|
|||
|
||||
builder.Services.AddMarkdig();
|
||||
builder.Services.AddYamlParser();
|
||||
builder.Services.AddSingleton<IConfiguration>(_ => Configuration.Default);
|
||||
builder.Services.AddSingleton<EssayScanService>();
|
||||
builder.Services.AddSingleton<RendererService>();
|
||||
builder.Services.AddSingleton<EssayContentService>();
|
||||
builder.Services.AddSingleton<IEssayContentService, EssayContentService>(provider =>
|
||||
provider.GetRequiredService<EssayContentService>());
|
||||
builder.Services.AddSingleton<TableOfContentService>();
|
||||
builder.Services.AddSingleton<ITableOfContentService, TableOfContentService>(provider =>
|
||||
provider.GetRequiredService<TableOfContentService>());
|
||||
builder.Services.AddTransient<ImagePostRenderProcessor>();
|
||||
builder.Services.AddTransient<CodeBlockPostRenderProcessor>();
|
||||
builder.Services.AddTransient<TablePostRenderProcessor>();
|
||||
builder.Services.AddTransient<HeadlinePostRenderProcessor>();
|
||||
builder.Services.AddTransient<BlogOptions>(provider =>
|
||||
provider.GetRequiredService<IOptions<BlogOptions>>().Value);
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ public static class WebApplicationExtensions
|
|||
application.UsePostRenderProcessor<ImagePostRenderProcessor>();
|
||||
application.UsePostRenderProcessor<CodeBlockPostRenderProcessor>();
|
||||
application.UsePostRenderProcessor<TablePostRenderProcessor>();
|
||||
application.UsePostRenderProcessor<HeadlinePostRenderProcessor>();
|
||||
}
|
||||
|
||||
private static void UsePreRenderProcessor<T>(this WebApplication application) where T : IPreRenderProcessor
|
||||
|
|
10
YaeBlog.Core/Models/BlogHeadline.cs
Normal file
10
YaeBlog.Core/Models/BlogHeadline.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
namespace YaeBlog.Core.Models;
|
||||
|
||||
public class BlogHeadline(string title, string selectorId)
|
||||
{
|
||||
public string Title { get; } = title;
|
||||
|
||||
public string SelectorId { get; set; } = selectorId;
|
||||
|
||||
public List<BlogHeadline> Children { get; } = [];
|
||||
}
|
105
YaeBlog.Core/Processors/HeadlinePostRenderProcessor.cs
Normal file
105
YaeBlog.Core/Processors/HeadlinePostRenderProcessor.cs
Normal file
|
@ -0,0 +1,105 @@
|
|||
using AngleSharp;
|
||||
using AngleSharp.Dom;
|
||||
using YaeBlog.Core.Abstractions;
|
||||
using YaeBlog.Core.Models;
|
||||
using YaeBlog.Core.Services;
|
||||
|
||||
namespace YaeBlog.Core.Processors;
|
||||
|
||||
public class HeadlinePostRenderProcessor(
|
||||
IConfiguration angleConfiguration,
|
||||
TableOfContentService tableOfContentService) : IPostRenderProcessor
|
||||
{
|
||||
public async Task<BlogEssay> ProcessAsync(BlogEssay essay)
|
||||
{
|
||||
BrowsingContext browsingContext = new(angleConfiguration);
|
||||
IDocument document = await browsingContext.OpenAsync(req => req.Content(essay.HtmlContent));
|
||||
|
||||
IEnumerable<IElement> elements = from item in document.All
|
||||
where item.LocalName is "h2" or "h3" or "h4"
|
||||
select item;
|
||||
|
||||
BlogHeadline topHeadline = new(essay.Title, "#title");
|
||||
List<BlogHeadline> level2List = [];
|
||||
List<BlogHeadline> level3List = [];
|
||||
List<BlogHeadline> level4List = [];
|
||||
|
||||
foreach (IElement element in elements)
|
||||
{
|
||||
switch (element.LocalName)
|
||||
{
|
||||
case "h2":
|
||||
{
|
||||
FindParentHeadline(topHeadline, level2List, level3List).Children.AddRange(level4List);
|
||||
level4List.Clear();
|
||||
FindParentHeadline(topHeadline, level2List).Children.AddRange(level3List);
|
||||
level3List.Clear();
|
||||
|
||||
BlogHeadline headline = ParserHeadlineElement(element);
|
||||
level2List.Add(headline);
|
||||
break;
|
||||
}
|
||||
case "h3":
|
||||
{
|
||||
FindParentHeadline(topHeadline, level2List, level3List).Children.AddRange(level4List);
|
||||
level4List.Clear();
|
||||
|
||||
BlogHeadline headline = ParserHeadlineElement(element);
|
||||
level3List.Add(headline);
|
||||
break;
|
||||
}
|
||||
case "h4":
|
||||
{
|
||||
BlogHeadline headline = ParserHeadlineElement(element);
|
||||
level4List.Add(headline);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 太抽象了(((
|
||||
FindParentHeadline(topHeadline, level2List, level3List).Children.AddRange(level4List);
|
||||
FindParentHeadline(topHeadline, level2List).Children.AddRange(level3List);
|
||||
topHeadline.Children.AddRange(level2List);
|
||||
|
||||
tableOfContentService.AddHeadline(essay.FileName, topHeadline);
|
||||
|
||||
return essay.WithNewHtmlContent(document.DocumentElement.OuterHtml);
|
||||
}
|
||||
|
||||
private static BlogHeadline ParserHeadlineElement(IElement element)
|
||||
{
|
||||
element.Id ??= element.TextContent;
|
||||
return new BlogHeadline(element.TextContent, element.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 找到h4标题的父级标题
|
||||
/// </summary>
|
||||
/// <param name="topHeadline"></param>
|
||||
/// <param name="level2"></param>
|
||||
/// <param name="level3"></param>
|
||||
/// <returns></returns>
|
||||
private static BlogHeadline FindParentHeadline(BlogHeadline topHeadline, List<BlogHeadline> level2,
|
||||
List<BlogHeadline> level3)
|
||||
{
|
||||
BlogHeadline? result = level3.LastOrDefault();
|
||||
if (result is not null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return level2.LastOrDefault() ?? topHeadline;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 找到h3标题的父级标题
|
||||
/// </summary>
|
||||
/// <param name="topHeadline"></param>
|
||||
/// <param name="level2"></param>
|
||||
/// <returns></returns>
|
||||
private static BlogHeadline FindParentHeadline(BlogHeadline topHeadline, List<BlogHeadline> level2) =>
|
||||
FindParentHeadline(topHeadline, level2, []);
|
||||
|
||||
public string Name => nameof(HeadlinePostRenderProcessor);
|
||||
}
|
20
YaeBlog.Core/Services/TableOfContentService.cs
Normal file
20
YaeBlog.Core/Services/TableOfContentService.cs
Normal file
|
@ -0,0 +1,20 @@
|
|||
using System.Collections.Concurrent;
|
||||
using YaeBlog.Core.Abstractions;
|
||||
using YaeBlog.Core.Models;
|
||||
|
||||
namespace YaeBlog.Core.Services;
|
||||
|
||||
public class TableOfContentService : ITableOfContentService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, BlogHeadline> _headlines = [];
|
||||
|
||||
public IReadOnlyDictionary<string, BlogHeadline> Headlines => _headlines;
|
||||
|
||||
public void AddHeadline(string filename, BlogHeadline headline)
|
||||
{
|
||||
if (!_headlines.TryAdd(filename, headline))
|
||||
{
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
@using YaeBlog.Core.Models
|
||||
|
||||
@inject IEssayContentService Contents
|
||||
@inject ITableOfContentService TableOfContent
|
||||
@inject NavigationManager NavigationInstance
|
||||
|
||||
<PageTitle>
|
||||
|
@ -12,7 +13,7 @@
|
|||
<div class="container py-4">
|
||||
<div class="row">
|
||||
<div class="col-auto">
|
||||
<h1>@(_essay!.Title)</h1>
|
||||
<h1 id="title">@(_essay!.Title)</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -41,16 +42,75 @@
|
|||
|
||||
<LicenseDisclaimer EssayAddress="@BlogKey"/>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4 col-md-12">
|
||||
<div class="row sticky-lg-top justify-content-center">
|
||||
<div class="col-auto">
|
||||
<div class="row">
|
||||
<div class="col-auto">
|
||||
<h3 style="margin-block-start: 1em; margin-block-end: 0.5em">
|
||||
文章目录
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" style="padding-left: 10px">
|
||||
<div class="col-auto">
|
||||
@foreach (BlogHeadline level2 in _headline!.Children)
|
||||
{
|
||||
<div class="row py-1">
|
||||
<div class="col-auto">
|
||||
<a href="@(GenerateSelectorUrl(level2.SelectorId))">@(level2.Title)</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@foreach (BlogHeadline level3 in level2.Children)
|
||||
{
|
||||
<div class="row py-1">
|
||||
<div class="col-auto">
|
||||
<a style="padding-left: 20px" href="@GenerateSelectorUrl(level3.SelectorId)">
|
||||
@(level3.Title)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@foreach (BlogHeadline level4 in level3.Children)
|
||||
{
|
||||
<div class="row py-1">
|
||||
<div class="col-auto">
|
||||
<a style="padding-left: 40px" href="@(GenerateSelectorUrl(level4.SelectorId))">
|
||||
@(level4.Title)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_headline!.Children.Count == 0)
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col fst-italic">
|
||||
坏了(* Ŏ∀Ŏ),没有在文章中识别到目录
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? BlogKey { get; set; }
|
||||
[Parameter] public string? BlogKey { get; set; }
|
||||
|
||||
private BlogEssay? _essay;
|
||||
|
||||
private BlogHeadline? _headline;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
base.OnInitialized();
|
||||
|
@ -65,6 +125,11 @@
|
|||
{
|
||||
NavigationInstance.NavigateTo("/NotFound");
|
||||
}
|
||||
|
||||
_headline = TableOfContent.Headlines[BlogKey];
|
||||
}
|
||||
|
||||
private string GenerateSelectorUrl(string selectorId)
|
||||
=> $"/blog/essays/{BlogKey!}#{selectorId}";
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user