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 @@ -