Browse Source

Refactor TOC generation to use heading data model

Replaces HTML-based TOC generation with a model-based approach by extracting heading data from markdown and rendering the table of contents via a new partial view. Removes the custom Markdig extension and related classes, updates the service interface, and adapts the page model and views to use the new heading list.
pull/23666/head
Ahmet Çelik 1 year ago
parent
commit
42a921ca4d
  1. 4
      modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml
  2. 13
      modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml.cs
  3. 73
      modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/TableOfContents.cshtml
  4. 11
      modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/TableOfContents.cshtml.cs
  5. 73
      modules/docs/src/Volo.Docs.Web/TableOfContents/CustomHeadingRenderer.cs
  6. 30
      modules/docs/src/Volo.Docs.Web/TableOfContents/HeadingExtractionExtension.cs
  7. 6
      modules/docs/src/Volo.Docs.Web/TableOfContents/ITocGeneratorService.cs
  8. 118
      modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs

4
modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml

@ -597,8 +597,8 @@
<h5 class="card-title">@L["InThisDocument"]</h5>
<div id="scroll-index" class="">
<nav id="docs-sticky-index" class="navbar index-scroll">
@Html.Raw(Model.TocHtml)
<nav id="toc" class="navbar index-scroll">
<partial name="TableOfContents" model="Model" />
</nav>
<div class="row">
<div class="col p-0">

13
modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml.cs

@ -22,13 +22,14 @@ using Volo.Docs.Common.Documents;
using Volo.Docs.Common.Projects;
using Volo.Docs.Documents;
using Volo.Docs.Documents.Rendering;
using Volo.Docs.GitHub.Documents.Version;
using Volo.Docs.HtmlConverting;
using Volo.Docs.Localization;
using Volo.Docs.Models;
using Volo.Docs.Projects;
using Volo.Docs.GitHub.Documents.Version;
using Volo.Docs.Localization;
using Volo.Docs.Utils;
using Volo.Docs.TableOfContents;
using Volo.Docs.Utils;
using static Volo.Docs.TableOfContents.TocGeneratorService;
namespace Volo.Docs.Pages.Documents.Project
{
@ -76,7 +77,7 @@ namespace Volo.Docs.Pages.Documents.Project
public string DocumentsUrlPrefix { get; set; }
public string TocHtml { get; set; } = string.Empty;
public List<Heading> TocHeadings { get; set; } = [];
public bool ShowProjectsCombobox { get; set; }
@ -541,9 +542,9 @@ namespace Volo.Docs.Pages.Documents.Project
DocumentNameWithExtension = Document.Name;
SetDocumentPageTitle();
if (Document != null && !string.IsNullOrEmpty(Document.Content))
if (Document != null && !Document.Content.IsNullOrEmpty())
{
TocHtml = _tocGeneratorService.GenerateToc(Document.Content);
TocHeadings = _tocGeneratorService.GenerateTocHeadings(Document.Content);
}
await ConvertDocumentContentToHtmlAsync();

73
modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/TableOfContents.cshtml

@ -0,0 +1,73 @@
@model Volo.Docs.Pages.Documents.Project.IndexModel
@{
if (Model.TocHeadings == null ||Model.TocHeadings.Count == 0)
{
return;
}
var relevantHeadings = Model.TocHeadings
.Where(h => h.Level is 2 or 3)
.ToList();
if (relevantHeadings.Count == 0)
{
relevantHeadings = Model.TocHeadings
.Where(h => h.Level == 1)
.ToList();
}
if (relevantHeadings.Count == 0)
{
return;
}
var baseLevel = relevantHeadings.Min(h => h.Level);
var normalizedHeadings = relevantHeadings
.Select(h => h with { Level = h.Level - baseLevel + 1 })
.ToList();
var levelStack = new Stack<int>();
levelStack.Push(0);
}
@for (var i = 0; i < normalizedHeadings.Count; i++)
{
var heading = normalizedHeadings[i];
var previousLevel = levelStack.Peek();
if (heading.Level < previousLevel)
{
@while (heading.Level < levelStack.Peek())
{
@:</li></ul>
levelStack.Pop();
}
@:</li>
}
else if (heading.Level > previousLevel)
{
@:<ul class="nav nav-pills flex-column">
levelStack.Push(heading.Level);
}
else if (i > 0)
{
@:</li>
}
var hasChildren = (i + 1 < normalizedHeadings.Count) &&
(normalizedHeadings[i + 1].Level > heading.Level);
var liClass = hasChildren ? "nav-item toc-item-has-children" : "nav-item";
@:<li class="@liClass"><a class="nav-link" href="#@heading.Id">@heading.Text</a>
}
@if (normalizedHeadings.Any())
{
@:</li>
}
@while (levelStack.Count > 1)
{
@:</ul>
levelStack.Pop();
}

11
modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/TableOfContents.cshtml.cs

@ -0,0 +1,11 @@
using Volo.Abp.AspNetCore.Mvc.UI.RazorPages;
namespace Volo.Docs.Pages.Documents.Project;
public class TableOfContentsModel : AbpPageModel
{
public void OnGet()
{
}
}

73
modules/docs/src/Volo.Docs.Web/TableOfContents/CustomHeadingRenderer.cs

@ -1,73 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Markdig.Renderers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace Volo.Docs.TableOfContents;
public class CustomHeadingRenderer : MarkdownObjectRenderer<HtmlRenderer, HeadingBlock>
{
private readonly HeadingExtractionExtension _extension;
private readonly HeadingRenderer _originalRenderer;
public CustomHeadingRenderer(HeadingExtractionExtension extension, HeadingRenderer originalRenderer)
{
_extension = extension;
_originalRenderer = originalRenderer ?? new HeadingRenderer();
}
protected override void Write(HtmlRenderer renderer, HeadingBlock headingBlock)
{
var headingText = GetPlainText(headingBlock.Inline);
var headingId = headingBlock.TryGetAttributes()?.Id ?? string.Empty;
_extension.Headings.Add((headingBlock.Level, headingText, headingId));
_originalRenderer.Write(renderer, headingBlock);
}
private static string GetPlainText(ContainerInline container)
{
if (container == null)
{
return string.Empty;
}
var builder = new StringBuilder();
var inlinesToProcess = new Stack<Inline>();
// Push items in reverse for left-to-right processing (LIFO stack behavior)
foreach (var inline in container.Reverse())
{
inlinesToProcess.Push(inline);
}
while (inlinesToProcess.Count > 0)
{
var currentInline = inlinesToProcess.Pop();
switch (currentInline)
{
// Case 1: Simple leaf nodes with text content
case LiteralInline literal:
builder.Append(literal.Content);
break;
case CodeInline code:
builder.Append(code.Content);
break;
// Case 2: Container nodes - process their children next
case ContainerInline childContainer:
foreach (var childInline in childContainer.Reverse())
{
inlinesToProcess.Push(childInline);
}
break;
}
}
return builder.ToString();
}
}

30
modules/docs/src/Volo.Docs.Web/TableOfContents/HeadingExtractionExtension.cs

@ -1,30 +0,0 @@
using System.Collections.Generic;
using Markdig;
using Markdig.Renderers;
using Markdig.Renderers.Html;
namespace Volo.Docs.TableOfContents;
public class HeadingExtractionExtension : IMarkdownExtension
{
public List<(int Level, string Text, string Id)> Headings { get; } = [];
public void Setup(MarkdownPipelineBuilder pipeline)
{
}
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
{
if (renderer is not HtmlRenderer)
{
return;
}
var originalHeadingRenderer = renderer.ObjectRenderers.Find<HeadingRenderer>();
if (originalHeadingRenderer != null)
{
renderer.ObjectRenderers.Remove(originalHeadingRenderer);
}
renderer.ObjectRenderers.Add(new CustomHeadingRenderer(this, originalHeadingRenderer));
}
}

6
modules/docs/src/Volo.Docs.Web/TableOfContents/ITocGeneratorService.cs

@ -1,8 +1,10 @@
using Volo.Abp.Application.Services;
using System.Collections.Generic;
using Volo.Abp.Application.Services;
using static Volo.Docs.TableOfContents.TocGeneratorService;
namespace Volo.Docs.TableOfContents;
public interface ITocGeneratorService : IApplicationService
{
string GenerateToc(string markdownContent);
List<Heading> GenerateTocHeadings(string markdownContent);
}

118
modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs

@ -4,8 +4,10 @@ using System.Linq;
using System.Text;
using Markdig;
using Markdig.Extensions.AutoIdentifiers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using Volo.Abp.DependencyInjection;
using Volo.Docs.Markdown;
namespace Volo.Docs.TableOfContents;
@ -13,104 +15,80 @@ public class TocGeneratorService : ITocGeneratorService, ITransientDependency
{
public record Heading(int Level, string Text, string Id);
public string GenerateToc(string markdownContent)
public List<Heading> GenerateTocHeadings(string markdownContent)
{
if (markdownContent.IsNullOrWhiteSpace())
{
return string.Empty;
return null;
}
var headingExtractionExtension = new HeadingExtractionExtension();
var pipelineBuilder = new MarkdownPipelineBuilder()
.UseAutoIdentifiers(AutoIdentifierOptions.GitHub)
.UseAdvancedExtensions();
pipelineBuilder.Use(headingExtractionExtension);
var pipeline = pipelineBuilder.Build();
Markdig.Markdown.ToHtml(markdownContent, pipeline);
var headings = headingExtractionExtension.Headings
.Select(h => new Heading(h.Level, h.Text, h.Id))
.ToList();
var headings = new List<Heading>();
return BuildTocHtml(headings);
}
var document = Markdig.Markdown.Parse(markdownContent, pipeline);
private static string BuildTocHtml(List<Heading> headings)
{
if (headings == null || headings.Count == 0)
var headingBlocks = document.Descendants<HeadingBlock>();
foreach (var headingBlock in headingBlocks)
{
return string.Empty;
headings.Add(new Heading(
headingBlock.Level,
GetPlainText(headingBlock.Inline),
headingBlock.GetAttributes()?.Id
));
}
var relevantHeadings = headings
.Where(h => h.Level is 2 or 3)
.ToList();
return headings;
}
if (relevantHeadings.Count == 0)
private static string GetPlainText(ContainerInline container)
{
if (container == null)
{
relevantHeadings = headings
.Where(h => h.Level == 1)
.ToList();
return string.Empty;
}
if (relevantHeadings.Count == 0)
if(container.Count() == 1 && container.First() is LiteralInline literalInline)
{
return string.Empty;
return literalInline.Content.ToString();
}
var baseLevel = relevantHeadings.Min(h => h.Level);
var normalizedHeadings = relevantHeadings
.Select(h => h with { Level = h.Level - baseLevel + 1 })
.ToList();
var builder = new StringBuilder();
var inlinesToProcess = new Queue<Inline>();
var tocBuilder = new StringBuilder();
var levelStack = new Stack<int>();
levelStack.Push(0);
foreach (var inline in container)
{
inlinesToProcess.Enqueue(inline);
}
for (var i = 0; i < normalizedHeadings.Count; i++)
while (inlinesToProcess.Count > 0)
{
var heading = normalizedHeadings[i];
var previousLevel = levelStack.Peek();
var currentInline = inlinesToProcess.Dequeue();
if (heading.Level < previousLevel)
switch (currentInline)
{
while (heading.Level < levelStack.Peek())
{
tocBuilder.Append("</li></ul>");
levelStack.Pop();
}
tocBuilder.Append("</li>");
case LiteralInline literal:
builder.Append(literal.Content);
break;
case CodeInline code:
builder.Append(code.Content);
break;
case ContainerInline childContainer:
foreach (var childInline in childContainer)
{
inlinesToProcess.Enqueue(childInline);
}
break;
}
else if (heading.Level > previousLevel)
{
tocBuilder.Append("<ul class=\"nav nav-pills flex-column\">");
levelStack.Push(heading.Level);
}
else if (i > 0)
{
tocBuilder.Append("</li>");
}
var hasChildren = (i + 1 < normalizedHeadings.Count) &&
(normalizedHeadings[i + 1].Level > heading.Level);
var liClass = hasChildren ? "nav-item toc-item-has-children" : "nav-item";
tocBuilder.Append($"<li class=\"{liClass}\"><a class=\"nav-link\" href=\"#{heading.Id}\">{heading.Text}</a>");
}
if (normalizedHeadings.Count > 0)
{
tocBuilder.Append("</li>");
}
while (levelStack.Count > 1)
{
tocBuilder.Append("</ul>");
levelStack.Pop();
}
return tocBuilder.ToString();
return builder.ToString();
}
}

Loading…
Cancel
Save