From b8f72cf24400ce1ae8851a2c3929a1acfc430fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C3=87elik?= Date: Wed, 3 Sep 2025 21:03:38 +0300 Subject: [PATCH 01/12] Implement server-side Table of Contents generation Replaces client-side TOC generation with a new ITocGeneratorService and TocGeneratorService using HtmlAgilityPack. Updates the project page to render the TOC from the server, removes bootstrap-toc dependencies, and adjusts related JS and CSS for the new TOC structure. Adds HtmlAgilityPack as a dependency. --- Directory.Packages.props | 1 + .../Pages/Documents/Project/Index.cshtml | 3 +- .../Pages/Documents/Project/Index.cshtml.cs | 19 ++- .../Pages/Documents/Project/index.js | 2 - .../Pages/Documents/Project/index.scss | 12 ++ .../Pages/Documents/Shared/Scripts/vs.js | 43 +++-- .../TableOfContents/ITocGeneratorService.cs | 8 + .../TableOfContents/TocGeneratorService.cs | 154 ++++++++++++++++++ .../src/Volo.Docs.Web/Volo.Docs.Web.csproj | 1 + 9 files changed, 214 insertions(+), 29 deletions(-) create mode 100644 modules/docs/src/Volo.Docs.Web/TableOfContents/ITocGeneratorService.cs create mode 100644 modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 05b99f9dd1..ae0429b944 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,6 +29,7 @@ + diff --git a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml index 21d4aab5ac..aac7adbf5e 100644 --- a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml +++ b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml @@ -39,7 +39,6 @@ - @if (DocsUiOptions.Value.EnableEnlargeImage) @@ -72,7 +71,6 @@ - @if (DocsUiOptions.Value.EnableEnlargeImage) @@ -600,6 +598,7 @@
diff --git a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml.cs b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml.cs index 8ffe5c4c7a..d6e86c585d 100644 --- a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml.cs +++ b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml.cs @@ -28,6 +28,7 @@ using Volo.Docs.Projects; using Volo.Docs.GitHub.Documents.Version; using Volo.Docs.Localization; using Volo.Docs.Utils; +using Volo.Docs.TableOfContents; namespace Volo.Docs.Pages.Documents.Project { @@ -73,7 +74,9 @@ namespace Volo.Docs.Pages.Documents.Project public VersionInfoViewModel LatestVersionInfo { get; private set; } - public string DocumentsUrlPrefix { get; set; } + public string DocumentsUrlPrefix { get; set; } + + public string TocHtml { get; set; } = string.Empty; public bool ShowProjectsCombobox { get; set; } @@ -105,6 +108,7 @@ namespace Volo.Docs.Pages.Documents.Project private readonly DocsUiOptions _uiOptions; private readonly IPermissionChecker _permissionChecker; private readonly IDocumentPdfAppService _documentPdfAppService; + private readonly ITocGeneratorService _tocGeneratorService; protected IDocsLinkGenerator DocsLinkGenerator => LazyServiceProvider.LazyGetRequiredService(); @@ -117,7 +121,8 @@ namespace Volo.Docs.Pages.Documents.Project IOptions options, IWebDocumentSectionRenderer webDocumentSectionRenderer, IPermissionChecker permissionChecker, - IDocumentPdfAppService documentPdfAppService) + IDocumentPdfAppService documentPdfAppService, + ITocGeneratorService tocGeneratorService) { ObjectMapperContext = typeof(DocsWebModule); @@ -128,6 +133,7 @@ namespace Volo.Docs.Pages.Documents.Project _permissionChecker = permissionChecker; _documentPdfAppService = documentPdfAppService; _uiOptions = options.Value; + _tocGeneratorService = tocGeneratorService; LocalizationResourceType = typeof(DocsResource); } @@ -535,6 +541,15 @@ namespace Volo.Docs.Pages.Documents.Project DocumentNameWithExtension = Document.Name; SetDocumentPageTitle(); await ConvertDocumentContentToHtmlAsync(); + + if (Document != null && !string.IsNullOrEmpty(Document.Content)) + { + var (toc, processedContent) = _tocGeneratorService.GenerateTocAndProcessHeadings(Document.Content); + + Document.Content = processedContent; + TocHtml = toc; + } + return true; } catch (DocumentNotFoundException e) diff --git a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/index.js b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/index.js index d55d9f2ef9..2e3bdf1f30 100644 --- a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/index.js +++ b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/index.js @@ -42,8 +42,6 @@ var doc = doc || {}; $ul.append($li); $lazyLiElement.append($ul) - - window.Toc.helpers.initNavEvent(); }, loadAll : function(lazyLiElements){ if(doc.lazyExpandableNavigation.isAllLoaded){ diff --git a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/index.scss b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/index.scss index 9589b1fb73..1af1c41cf5 100644 --- a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/index.scss +++ b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/index.scss @@ -71,4 +71,16 @@ body { background-color: transparent; border-color: transparent; font-size: 14px; +} + +.toc-item-has-children > ul { + max-height: 0; + overflow: hidden; + opacity: 0; + transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out; +} + +.toc-item-has-children.open > ul { + max-height: 1000px; + opacity: 1; } \ No newline at end of file diff --git a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Scripts/vs.js b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Scripts/vs.js index 5208ac99c0..0bddcf844c 100644 --- a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Scripts/vs.js +++ b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Scripts/vs.js @@ -1,8 +1,6 @@ (function ($) { $(function () { - window.Toc.helpers.initNavEvent(); - var scrollTopBtn = $('.scroll-top-btn'); var enoughHeight = $('.docs-sidebar-wrapper > .docs-top').height(); var enoughHeightPlus = 500; @@ -64,10 +62,10 @@ handleCustomScrolls(); var $myNav = $('#docs-sticky-index'); - Toc.init($myNav); $('body').scrollspy({ target: $myNav, + offset:100 }); $('#docs-sticky-index a').on('click', function (event) { @@ -86,6 +84,23 @@ } }); + $("body").on('activate.bs.scrollspy', function (e) { + var $activeLink = $('.nav-link.active', $('#docs-sticky-index')); + + var $activeLi = $activeLink.parent('li.nav-item'); + + $myNav.find('li.toc-item-has-children.open').each(function () { + if ($(this).has($activeLi).length === 0) { + $(this).removeClass('open'); + } + }); + + var $parentToOpen = $activeLi.closest('li.toc-item-has-children'); + if ($parentToOpen.length > 0) { + $parentToOpen.addClass('open'); + } + }); + $('.btn-toggle').on('click', function () { $('.toggle-row').slideToggle(400); $(this).toggleClass('less'); @@ -99,6 +114,7 @@ $('.docs-tree-list').slideToggle(); }); + initMenuToggle(); scrollToHashLink(); }); @@ -125,26 +141,7 @@ }); } - window.Toc.helpers.createNavList = function () { - return $(''); - }; - - window.Toc.helpers.createChildNavList = function ($parent) { - var $childList = this.createNavList(); - $parent.append($childList); - return $childList; - }; - - window.Toc.helpers.generateNavEl = function (anchor, text) { - var $a = $(''); - $a.attr('href', '#' + anchor); - $a.text(text); - var $li = $(''); - $li.append($a); - return $li; - }; - - window.Toc.helpers.initNavEvent = function () { + function initMenuToggle() { $('li:not(.last-link) a.tree-toggle').off('click'); $('li:not(.last-link) span.plus-icon i.fa-chevron-right').off('click'); diff --git a/modules/docs/src/Volo.Docs.Web/TableOfContents/ITocGeneratorService.cs b/modules/docs/src/Volo.Docs.Web/TableOfContents/ITocGeneratorService.cs new file mode 100644 index 0000000000..3c3e77e88a --- /dev/null +++ b/modules/docs/src/Volo.Docs.Web/TableOfContents/ITocGeneratorService.cs @@ -0,0 +1,8 @@ +using Volo.Abp.Application.Services; + +namespace Volo.Docs.TableOfContents; + +public interface ITocGeneratorService : IApplicationService +{ + (string TocHtml, string ProcessedContent) GenerateTocAndProcessHeadings(string content); +} diff --git a/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs b/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs new file mode 100644 index 0000000000..d53bb0f91b --- /dev/null +++ b/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Volo.Abp.DependencyInjection; +using HtmlAgilityPack; + +namespace Volo.Docs.TableOfContents; + +public class TocGeneratorService : ITocGeneratorService, ITransientDependency +{ + private readonly HashSet _generatedIds = []; + + public (string TocHtml, string ProcessedContent) GenerateTocAndProcessHeadings(string content) + { + if (content.IsNullOrWhiteSpace()) + { + return (string.Empty, string.Empty); + } + + _generatedIds.Clear(); + var tocHeadings = new List<(int Level, string Text, string Id)>(); + + var doc = new HtmlDocument(); + doc.LoadHtml(content); + + var nodesWithId = doc.DocumentNode.SelectNodes("//*[@id]"); + if (nodesWithId != null) + { + foreach (var node in nodesWithId) + { + _generatedIds.Add(node.Id); + } + } + + var headingNodes = doc.DocumentNode.SelectNodes("//h1|//h2|//h3|//h4|//h5|//h6"); + if (headingNodes != null) + { + foreach (var node in headingNodes) + { + var id = node.Id; + + if (id.IsNullOrWhiteSpace()) + { + id = GenerateUniqueId(node.InnerText.Trim()); + node.SetAttributeValue("id", id); + } + + var level = int.Parse(node.Name.Substring(1)); + if (level == 2 || level == 3) + { + tocHeadings.Add((level, node.InnerText.Trim(), id)); + } + } + } + + var tocHtml = BuildTocHtml(tocHeadings); + + var processedContent = doc.DocumentNode.OuterHtml; + + return (tocHtml, processedContent); + } + + private string GenerateUniqueId(string text) + { + if (text.IsNullOrWhiteSpace()) + { + return $"section-{Guid.NewGuid().ToString("N")[..8]}"; + } + + var baseId = text.ToLowerInvariant(); + + baseId = Regex.Replace(baseId, @"[^a-z0-9]+", "-", RegexOptions.Compiled); + + baseId = baseId.Trim('-'); + + if (baseId.IsNullOrWhiteSpace()) + { + return $"section-{Guid.NewGuid().ToString("N")[..8]}"; + } + + var finalId = baseId; + var counter = 1; + + while (!_generatedIds.Add(finalId)) + { + finalId = $"{baseId}-{++counter}"; + } + + return finalId; + } + + private static string BuildTocHtml(List<(int Level, string Text, string Id)> headings) + { + if (headings == null || headings.Count == 0) + { + return string.Empty; + } + + var tocBuilder = new StringBuilder(); + tocBuilder.Append("
    "); + + var currentLevel = 0; + var isFirstH2 = true; + + foreach (var (index, heading) in headings.Select((h, i) => (i, h))) + { + var isLastItem = index == headings.Count - 1; + var nextHeading = isLastItem ? default : headings[index + 1]; + var hasChildren = nextHeading.Level == 3 && heading.Level == 2; + + if (heading.Level < currentLevel) + { + tocBuilder.Append("
"); + } + else if (!isFirstH2 && heading.Level == 2 && currentLevel == 2) + { + tocBuilder.Append(""); + } + + if (heading.Level == 2) + { + var liClass = hasChildren ? "nav-item toc-item-has-children" : "nav-item"; + tocBuilder.Append($"
  • {heading.Text}"); + isFirstH2 = false; + } + else if (heading.Level == 3) + { + if (currentLevel != 3) + { + tocBuilder.Append("
      "); + } + + tocBuilder.Append($"
    • {heading.Text}
    • "); + } + + currentLevel = heading.Level; + } + + if (currentLevel == 3) + { + tocBuilder.Append("
  • "); + } + else if (currentLevel == 2 && !isFirstH2) + { + tocBuilder.Append(""); + } + + tocBuilder.Append(""); + + return tocBuilder.ToString(); + } +} diff --git a/modules/docs/src/Volo.Docs.Web/Volo.Docs.Web.csproj b/modules/docs/src/Volo.Docs.Web/Volo.Docs.Web.csproj index 353f15811c..2215e85a92 100644 --- a/modules/docs/src/Volo.Docs.Web/Volo.Docs.Web.csproj +++ b/modules/docs/src/Volo.Docs.Web/Volo.Docs.Web.csproj @@ -21,6 +21,7 @@ + From 33b6f0f57db7acebc168b33d79d41af2573fdc9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C3=87elik?= Date: Thu, 4 Sep 2025 10:42:35 +0300 Subject: [PATCH 02/12] Refactor TOC heading handling with Heading record Introduces a Heading record to replace tuple usage for TOC headings, improving code readability and maintainability. Updates related logic in TocGeneratorService to use the new Heading type and adds constants for heading levels. --- .../TableOfContents/TocGeneratorService.cs | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs b/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs index d53bb0f91b..668147820b 100644 --- a/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs +++ b/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs @@ -11,6 +11,7 @@ namespace Volo.Docs.TableOfContents; public class TocGeneratorService : ITocGeneratorService, ITransientDependency { private readonly HashSet _generatedIds = []; + public record Heading(int Level, string Text, string Id); public (string TocHtml, string ProcessedContent) GenerateTocAndProcessHeadings(string content) { @@ -20,7 +21,7 @@ public class TocGeneratorService : ITocGeneratorService, ITransientDependency } _generatedIds.Clear(); - var tocHeadings = new List<(int Level, string Text, string Id)>(); + var tocHeadings = new List(); var doc = new HtmlDocument(); doc.LoadHtml(content); @@ -50,7 +51,7 @@ public class TocGeneratorService : ITocGeneratorService, ITransientDependency var level = int.Parse(node.Name.Substring(1)); if (level == 2 || level == 3) { - tocHeadings.Add((level, node.InnerText.Trim(), id)); + tocHeadings.Add(new Heading(level, node.InnerText.Trim(), id)); } } } @@ -91,13 +92,16 @@ public class TocGeneratorService : ITocGeneratorService, ITransientDependency return finalId; } - private static string BuildTocHtml(List<(int Level, string Text, string Id)> headings) + private static string BuildTocHtml(List headings) { if (headings == null || headings.Count == 0) { return string.Empty; } + const int H2Level = 2; + const int H3Level = 3; + var tocBuilder = new StringBuilder(); tocBuilder.Append("
      "); @@ -107,47 +111,47 @@ public class TocGeneratorService : ITocGeneratorService, ITransientDependency foreach (var (index, heading) in headings.Select((h, i) => (i, h))) { var isLastItem = index == headings.Count - 1; - var nextHeading = isLastItem ? default : headings[index + 1]; - var hasChildren = nextHeading.Level == 3 && heading.Level == 2; + var nextHeading = isLastItem ? null : headings[index + 1]; + + var hasChildren = nextHeading?.Level == H3Level && heading.Level == H2Level; if (heading.Level < currentLevel) { tocBuilder.Append("
    "); } - else if (!isFirstH2 && heading.Level == 2 && currentLevel == 2) + else if (heading.Level == currentLevel && heading.Level == H2Level && !isFirstH2) { tocBuilder.Append(""); } - if (heading.Level == 2) + if (heading.Level == H2Level) { var liClass = hasChildren ? "nav-item toc-item-has-children" : "nav-item"; tocBuilder.Append($"
  • {heading.Text}"); isFirstH2 = false; } - else if (heading.Level == 3) + else if (heading.Level == H3Level) { - if (currentLevel != 3) + if (currentLevel < H3Level) { tocBuilder.Append("
      "); } - tocBuilder.Append($"
    • {heading.Text}
    • "); } currentLevel = heading.Level; } - if (currentLevel == 3) + if (currentLevel == H3Level) { - tocBuilder.Append("
  • "); + tocBuilder.Append(""); } - else if (currentLevel == 2 && !isFirstH2) + else if (currentLevel == H2Level) { tocBuilder.Append(""); } - tocBuilder.Append(""); + tocBuilder.Append(""); return tocBuilder.ToString(); } From 1eecff9ea77f3881b1356b508c0492f33f653027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C3=87elik?= Date: Tue, 9 Sep 2025 16:10:41 +0300 Subject: [PATCH 03/12] Refactor TOC generation to use Markdig and remove HtmlAgilityPack Replaces the previous HTML-based table of contents (TOC) extraction using HtmlAgilityPack with a Markdig-based approach. Introduces custom Markdig extensions and renderers to extract headings directly from markdown, updates the TOC service and interface, and removes the HtmlAgilityPack dependency from the project. --- Directory.Packages.props | 1 - .../Markdown/MarkDigMarkdownConverter.cs | 2 + .../Pages/Documents/Project/Index.cshtml.cs | 12 +- .../TableOfContents/CustomHeadingRenderer.cs | 73 ++++++++ .../HeadingExtractionExtension.cs | 30 ++++ .../TableOfContents/ITocGeneratorService.cs | 2 +- .../TableOfContents/TocGeneratorService.cs | 169 +++++++----------- .../src/Volo.Docs.Web/Volo.Docs.Web.csproj | 1 - 8 files changed, 177 insertions(+), 113 deletions(-) create mode 100644 modules/docs/src/Volo.Docs.Web/TableOfContents/CustomHeadingRenderer.cs create mode 100644 modules/docs/src/Volo.Docs.Web/TableOfContents/HeadingExtractionExtension.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index ae0429b944..05b99f9dd1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,7 +29,6 @@ - diff --git a/modules/docs/src/Volo.Docs.Web/Markdown/MarkDigMarkdownConverter.cs b/modules/docs/src/Volo.Docs.Web/Markdown/MarkDigMarkdownConverter.cs index a45dc0862f..1a19c779ba 100644 --- a/modules/docs/src/Volo.Docs.Web/Markdown/MarkDigMarkdownConverter.cs +++ b/modules/docs/src/Volo.Docs.Web/Markdown/MarkDigMarkdownConverter.cs @@ -1,5 +1,6 @@ using System.Text; using Markdig; +using Markdig.Extensions.AutoIdentifiers; using Volo.Abp.DependencyInjection; using Volo.Docs.Markdown.Extensions; @@ -12,6 +13,7 @@ namespace Volo.Docs.Markdown public MarkDigMarkdownConverter() { _markdownPipeline = new MarkdownPipelineBuilder() + .UseAutoIdentifiers(AutoIdentifierOptions.GitHub) .UseAutoLinks() .UseBootstrap() .UseGridTables() diff --git a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml.cs b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml.cs index d6e86c585d..ff6d1c79cc 100644 --- a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml.cs +++ b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml.cs @@ -539,17 +539,15 @@ namespace Volo.Docs.Pages.Documents.Project Document = await GetSpecificDocumentOrDefaultAsync(language); DocumentLanguageCode = language; DocumentNameWithExtension = Document.Name; - SetDocumentPageTitle(); - await ConvertDocumentContentToHtmlAsync(); - + SetDocumentPageTitle(); + if (Document != null && !string.IsNullOrEmpty(Document.Content)) { - var (toc, processedContent) = _tocGeneratorService.GenerateTocAndProcessHeadings(Document.Content); - - Document.Content = processedContent; - TocHtml = toc; + TocHtml = _tocGeneratorService.GenerateToc(Document.Content); } + await ConvertDocumentContentToHtmlAsync(); + return true; } catch (DocumentNotFoundException e) diff --git a/modules/docs/src/Volo.Docs.Web/TableOfContents/CustomHeadingRenderer.cs b/modules/docs/src/Volo.Docs.Web/TableOfContents/CustomHeadingRenderer.cs new file mode 100644 index 0000000000..c11d551284 --- /dev/null +++ b/modules/docs/src/Volo.Docs.Web/TableOfContents/CustomHeadingRenderer.cs @@ -0,0 +1,73 @@ +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 +{ + 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(); + + // 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(); + } +} diff --git a/modules/docs/src/Volo.Docs.Web/TableOfContents/HeadingExtractionExtension.cs b/modules/docs/src/Volo.Docs.Web/TableOfContents/HeadingExtractionExtension.cs new file mode 100644 index 0000000000..36961450bf --- /dev/null +++ b/modules/docs/src/Volo.Docs.Web/TableOfContents/HeadingExtractionExtension.cs @@ -0,0 +1,30 @@ +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(); + if (originalHeadingRenderer != null) + { + renderer.ObjectRenderers.Remove(originalHeadingRenderer); + } + renderer.ObjectRenderers.Add(new CustomHeadingRenderer(this, originalHeadingRenderer)); + } +} diff --git a/modules/docs/src/Volo.Docs.Web/TableOfContents/ITocGeneratorService.cs b/modules/docs/src/Volo.Docs.Web/TableOfContents/ITocGeneratorService.cs index 3c3e77e88a..b9b10a2f34 100644 --- a/modules/docs/src/Volo.Docs.Web/TableOfContents/ITocGeneratorService.cs +++ b/modules/docs/src/Volo.Docs.Web/TableOfContents/ITocGeneratorService.cs @@ -4,5 +4,5 @@ namespace Volo.Docs.TableOfContents; public interface ITocGeneratorService : IApplicationService { - (string TocHtml, string ProcessedContent) GenerateTocAndProcessHeadings(string content); + string GenerateToc(string markdownContent); } diff --git a/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs b/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs index 668147820b..2a73b6217b 100644 --- a/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs +++ b/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs @@ -2,157 +2,120 @@ using System.Collections.Generic; using System.Linq; using System.Text; -using System.Text.RegularExpressions; +using Markdig; +using Markdig.Extensions.AutoIdentifiers; using Volo.Abp.DependencyInjection; -using HtmlAgilityPack; +using Volo.Docs.Markdown; namespace Volo.Docs.TableOfContents; public class TocGeneratorService : ITocGeneratorService, ITransientDependency { - private readonly HashSet _generatedIds = []; public record Heading(int Level, string Text, string Id); + public IMarkdownConverter _markdownConverter; - public (string TocHtml, string ProcessedContent) GenerateTocAndProcessHeadings(string content) + public TocGeneratorService(IMarkdownConverter markdownConverter) { - if (content.IsNullOrWhiteSpace()) - { - return (string.Empty, string.Empty); - } - - _generatedIds.Clear(); - var tocHeadings = new List(); - - var doc = new HtmlDocument(); - doc.LoadHtml(content); + _markdownConverter = markdownConverter; + } - var nodesWithId = doc.DocumentNode.SelectNodes("//*[@id]"); - if (nodesWithId != null) + public string GenerateToc(string markdownContent) + { + if (markdownContent.IsNullOrWhiteSpace()) { - foreach (var node in nodesWithId) - { - _generatedIds.Add(node.Id); - } + return string.Empty; } - var headingNodes = doc.DocumentNode.SelectNodes("//h1|//h2|//h3|//h4|//h5|//h6"); - if (headingNodes != null) - { - foreach (var node in headingNodes) - { - var id = node.Id; - - if (id.IsNullOrWhiteSpace()) - { - id = GenerateUniqueId(node.InnerText.Trim()); - node.SetAttributeValue("id", id); - } - - var level = int.Parse(node.Name.Substring(1)); - if (level == 2 || level == 3) - { - tocHeadings.Add(new Heading(level, node.InnerText.Trim(), id)); - } - } - } + var headingExtractionExtension = new HeadingExtractionExtension(); + var pipelineBuilder = new MarkdownPipelineBuilder() + .UseAutoIdentifiers(AutoIdentifierOptions.GitHub) + .UseAdvancedExtensions(); + pipelineBuilder.Use(headingExtractionExtension); - var tocHtml = BuildTocHtml(tocHeadings); + var pipeline = pipelineBuilder.Build(); + Markdig.Markdown.ToHtml(markdownContent, pipeline); - var processedContent = doc.DocumentNode.OuterHtml; + var headings = headingExtractionExtension.Headings + .Select(h => new Heading(h.Level, h.Text, h.Id)) + .ToList(); - return (tocHtml, processedContent); + return BuildTocHtml(headings); } - private string GenerateUniqueId(string text) + private static string BuildTocHtml(List headings) { - if (text.IsNullOrWhiteSpace()) - { - return $"section-{Guid.NewGuid().ToString("N")[..8]}"; - } - - var baseId = text.ToLowerInvariant(); - - baseId = Regex.Replace(baseId, @"[^a-z0-9]+", "-", RegexOptions.Compiled); - - baseId = baseId.Trim('-'); - - if (baseId.IsNullOrWhiteSpace()) + if (headings == null || headings.Count == 0) { - return $"section-{Guid.NewGuid().ToString("N")[..8]}"; + return string.Empty; } - var finalId = baseId; - var counter = 1; + var relevantHeadings = headings + .Where(h => h.Level is 2 or 3) + .ToList(); - while (!_generatedIds.Add(finalId)) + if (relevantHeadings.Count == 0) { - finalId = $"{baseId}-{++counter}"; + relevantHeadings = headings + .Where(h => h.Level == 1) + .ToList(); } - return finalId; - } - - private static string BuildTocHtml(List headings) - { - if (headings == null || headings.Count == 0) + if (relevantHeadings.Count == 0) { return string.Empty; } - const int H2Level = 2; - const int H3Level = 3; + var baseLevel = relevantHeadings.Min(h => h.Level); + var normalizedHeadings = relevantHeadings + .Select(h => h with { Level = h.Level - baseLevel + 1 }) + .ToList(); var tocBuilder = new StringBuilder(); - tocBuilder.Append("
      "); + var levelStack = new Stack(); + levelStack.Push(0); - var currentLevel = 0; - var isFirstH2 = true; - - foreach (var (index, heading) in headings.Select((h, i) => (i, h))) + for (var i = 0; i < normalizedHeadings.Count; i++) { - var isLastItem = index == headings.Count - 1; - var nextHeading = isLastItem ? null : headings[index + 1]; - - var hasChildren = nextHeading?.Level == H3Level && heading.Level == H2Level; + var heading = normalizedHeadings[i]; + var previousLevel = levelStack.Peek(); - if (heading.Level < currentLevel) - { - tocBuilder.Append("
    "); - } - else if (heading.Level == currentLevel && heading.Level == H2Level && !isFirstH2) + if (heading.Level < previousLevel) { - tocBuilder.Append(""); + while (heading.Level < levelStack.Peek()) + { + tocBuilder.Append(""); + levelStack.Pop(); + } } - - if (heading.Level == H2Level) + else if (heading.Level > previousLevel) { - var liClass = hasChildren ? "nav-item toc-item-has-children" : "nav-item"; - tocBuilder.Append($"
  • {heading.Text}"); - isFirstH2 = false; + tocBuilder.Append("
      "); + levelStack.Push(heading.Level); } - else if (heading.Level == H3Level) + else if (i > 0) { - if (currentLevel < H3Level) - { - tocBuilder.Append("
        "); - } - tocBuilder.Append($"
      • {heading.Text}
      • "); + tocBuilder.Append(""); } - currentLevel = heading.Level; - } + var hasChildren = (i + 1 < normalizedHeadings.Count) && + (normalizedHeadings[i + 1].Level > heading.Level); - if (currentLevel == H3Level) - { - tocBuilder.Append("
      "); + var liClass = hasChildren ? "nav-item toc-item-has-children" : "nav-item"; + + tocBuilder.Append($"
    • {heading.Text}"); } - else if (currentLevel == H2Level) + + if (normalizedHeadings.Count > 0) { tocBuilder.Append("
    • "); } - tocBuilder.Append("
    "); - + while (levelStack.Count > 1) + { + tocBuilder.Append(""); + levelStack.Pop(); + } + return tocBuilder.ToString(); } } diff --git a/modules/docs/src/Volo.Docs.Web/Volo.Docs.Web.csproj b/modules/docs/src/Volo.Docs.Web/Volo.Docs.Web.csproj index 2215e85a92..353f15811c 100644 --- a/modules/docs/src/Volo.Docs.Web/Volo.Docs.Web.csproj +++ b/modules/docs/src/Volo.Docs.Web/Volo.Docs.Web.csproj @@ -21,7 +21,6 @@ - From 2ba1fc269aafe390252b27c5c9db1cb68a2b5317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C3=87elik?= Date: Tue, 9 Sep 2025 16:16:57 +0300 Subject: [PATCH 04/12] Update TocGeneratorService.cs --- .../Volo.Docs.Web/TableOfContents/TocGeneratorService.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs b/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs index 2a73b6217b..6142e48669 100644 --- a/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs +++ b/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs @@ -12,12 +12,6 @@ namespace Volo.Docs.TableOfContents; public class TocGeneratorService : ITocGeneratorService, ITransientDependency { public record Heading(int Level, string Text, string Id); - public IMarkdownConverter _markdownConverter; - - public TocGeneratorService(IMarkdownConverter markdownConverter) - { - _markdownConverter = markdownConverter; - } public string GenerateToc(string markdownContent) { From 0a1ad0ba94c63c211a6a29be658a7b23d52f9054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C3=87elik?= Date: Tue, 9 Sep 2025 16:39:43 +0300 Subject: [PATCH 05/12] Update TocGeneratorService.cs --- .../src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs b/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs index 6142e48669..d619c71f76 100644 --- a/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs +++ b/modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs @@ -80,6 +80,7 @@ public class TocGeneratorService : ITocGeneratorService, ITransientDependency tocBuilder.Append("
  • "); levelStack.Pop(); } + tocBuilder.Append(""); } else if (heading.Level > previousLevel) { From 42a921ca4dc328c2058353748ef5475a55e4c94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C3=87elik?= Date: Thu, 11 Sep 2025 11:06:09 +0300 Subject: [PATCH 06/12] 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. --- .../Pages/Documents/Project/Index.cshtml | 4 +- .../Pages/Documents/Project/Index.cshtml.cs | 13 +- .../Documents/Project/TableOfContents.cshtml | 73 +++++++++++ .../Project/TableOfContents.cshtml.cs | 11 ++ .../TableOfContents/CustomHeadingRenderer.cs | 73 ----------- .../HeadingExtractionExtension.cs | 30 ----- .../TableOfContents/ITocGeneratorService.cs | 6 +- .../TableOfContents/TocGeneratorService.cs | 118 +++++++----------- 8 files changed, 145 insertions(+), 183 deletions(-) create mode 100644 modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/TableOfContents.cshtml create mode 100644 modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/TableOfContents.cshtml.cs delete mode 100644 modules/docs/src/Volo.Docs.Web/TableOfContents/CustomHeadingRenderer.cs delete mode 100644 modules/docs/src/Volo.Docs.Web/TableOfContents/HeadingExtractionExtension.cs diff --git a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml index aac7adbf5e..20e895b9ec 100644 --- a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml +++ b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml @@ -597,8 +597,8 @@
    @L["InThisDocument"]
    -