add: table of contents

This commit is contained in:
jackfiled 2024-07-29 21:52:23 +08:00
parent 12777c227d
commit 91ae6d8ac1
7 changed files with 219 additions and 4 deletions

View File

@ -0,0 +1,8 @@
using YaeBlog.Core.Models;
namespace YaeBlog.Core.Abstractions;
public interface ITableOfContentService
{
public IReadOnlyDictionary<string, BlogHeadline> Headlines { get; }
}

View File

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Builder; using AngleSharp;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using YaeBlog.Core.Abstractions; using YaeBlog.Core.Abstractions;
@ -18,14 +19,19 @@ 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<EssayScanService>(); builder.Services.AddSingleton<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 =>
provider.GetRequiredService<EssayContentService>()); provider.GetRequiredService<EssayContentService>());
builder.Services.AddSingleton<TableOfContentService>();
builder.Services.AddSingleton<ITableOfContentService, TableOfContentService>(provider =>
provider.GetRequiredService<TableOfContentService>());
builder.Services.AddTransient<ImagePostRenderProcessor>(); builder.Services.AddTransient<ImagePostRenderProcessor>();
builder.Services.AddTransient<CodeBlockPostRenderProcessor>(); builder.Services.AddTransient<CodeBlockPostRenderProcessor>();
builder.Services.AddTransient<TablePostRenderProcessor>(); builder.Services.AddTransient<TablePostRenderProcessor>();
builder.Services.AddTransient<HeadlinePostRenderProcessor>();
builder.Services.AddTransient<BlogOptions>(provider => builder.Services.AddTransient<BlogOptions>(provider =>
provider.GetRequiredService<IOptions<BlogOptions>>().Value); provider.GetRequiredService<IOptions<BlogOptions>>().Value);

View File

@ -13,6 +13,7 @@ public static class WebApplicationExtensions
application.UsePostRenderProcessor<ImagePostRenderProcessor>(); application.UsePostRenderProcessor<ImagePostRenderProcessor>();
application.UsePostRenderProcessor<CodeBlockPostRenderProcessor>(); application.UsePostRenderProcessor<CodeBlockPostRenderProcessor>();
application.UsePostRenderProcessor<TablePostRenderProcessor>(); application.UsePostRenderProcessor<TablePostRenderProcessor>();
application.UsePostRenderProcessor<HeadlinePostRenderProcessor>();
} }
private static void UsePreRenderProcessor<T>(this WebApplication application) where T : IPreRenderProcessor private static void UsePreRenderProcessor<T>(this WebApplication application) where T : IPreRenderProcessor

View 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; } = [];
}

View 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);
}

View 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();
}
}
}

View File

@ -3,6 +3,7 @@
@using YaeBlog.Core.Models @using YaeBlog.Core.Models
@inject IEssayContentService Contents @inject IEssayContentService Contents
@inject ITableOfContentService TableOfContent
@inject NavigationManager NavigationInstance @inject NavigationManager NavigationInstance
<PageTitle> <PageTitle>
@ -12,7 +13,7 @@
<div class="container py-4"> <div class="container py-4">
<div class="row"> <div class="row">
<div class="col-auto"> <div class="col-auto">
<h1>@(_essay!.Title)</h1> <h1 id="title">@(_essay!.Title)</h1>
</div> </div>
</div> </div>
@ -41,16 +42,75 @@
<LicenseDisclaimer EssayAddress="@BlogKey"/> <LicenseDisclaimer EssayAddress="@BlogKey"/>
</div> </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>
</div> </div>
@code { @code {
[Parameter] [Parameter] public string? BlogKey { get; set; }
public string? BlogKey { get; set; }
private BlogEssay? _essay; private BlogEssay? _essay;
private BlogHeadline? _headline;
protected override void OnInitialized() protected override void OnInitialized()
{ {
base.OnInitialized(); base.OnInitialized();
@ -65,6 +125,11 @@
{ {
NavigationInstance.NavigateTo("/NotFound"); NavigationInstance.NavigateTo("/NotFound");
} }
_headline = TableOfContent.Headlines[BlogKey];
} }
private string GenerateSelectorUrl(string selectorId)
=> $"/blog/essays/{BlogKey!}#{selectorId}";
} }