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