Browse Source

Merge pull request #23666 from abpframework/Issue-#23643

Document Module - Render Table Of Contents on server-side
pull/23785/head
Yağmur Çelik 1 year ago
committed by GitHub
parent
commit
1393156c9d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      modules/docs/src/Volo.Docs.Web/Markdown/MarkDigMarkdownConverter.cs
  2. 5
      modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml
  3. 20
      modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml.cs
  4. 41
      modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/TableOfContents.cshtml
  5. 2
      modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/index.js
  6. 12
      modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/index.scss
  7. 43
      modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Scripts/vs.js
  8. 15
      modules/docs/src/Volo.Docs.Web/TableOfContents/ITocGeneratorService.cs
  9. 209
      modules/docs/src/Volo.Docs.Web/TableOfContents/TocGeneratorService.cs
  10. 7
      modules/docs/src/Volo.Docs.Web/TableOfContents/TocHeading.cs

2
modules/docs/src/Volo.Docs.Web/Markdown/MarkDigMarkdownConverter.cs

@ -1,5 +1,6 @@
using System.Text; using System.Text;
using Markdig; using Markdig;
using Markdig.Extensions.AutoIdentifiers;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
using Volo.Docs.Markdown.Extensions; using Volo.Docs.Markdown.Extensions;
@ -12,6 +13,7 @@ namespace Volo.Docs.Markdown
public MarkDigMarkdownConverter() public MarkDigMarkdownConverter()
{ {
_markdownPipeline = new MarkdownPipelineBuilder() _markdownPipeline = new MarkdownPipelineBuilder()
.UseAutoIdentifiers(AutoIdentifierOptions.GitHub)
.UseAutoLinks() .UseAutoLinks()
.UseBootstrap() .UseBootstrap()
.UseGridTables() .UseGridTables()

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

@ -39,7 +39,6 @@
<abp-style-bundle name="@typeof(IndexModel).FullName"> <abp-style-bundle name="@typeof(IndexModel).FullName">
<abp-style type="@typeof(PrismjsStyleBundleContributor)"/> <abp-style type="@typeof(PrismjsStyleBundleContributor)"/>
<abp-style type="@typeof(MalihuCustomScrollbarPluginStyleBundleContributor)"/> <abp-style type="@typeof(MalihuCustomScrollbarPluginStyleBundleContributor)"/>
<abp-style src="/Pages/Documents/Project/bootstrap-toc.css"/>
<abp-style src="/Pages/Documents/Shared/Styles/vs.css"/> <abp-style src="/Pages/Documents/Shared/Styles/vs.css"/>
<abp-style src="/Pages/Documents/Project/index.css"/> <abp-style src="/Pages/Documents/Project/index.css"/>
@if (DocsUiOptions.Value.EnableEnlargeImage) @if (DocsUiOptions.Value.EnableEnlargeImage)
@ -72,7 +71,6 @@
<abp-script type="@typeof(PopperJsScriptBundleContributor)"/> <abp-script type="@typeof(PopperJsScriptBundleContributor)"/>
<abp-script src="/client-proxies/docs-proxy.js"/> <abp-script src="/client-proxies/docs-proxy.js"/>
<abp-script src="/client-proxies/docs-common-proxy.js"/> <abp-script src="/client-proxies/docs-common-proxy.js"/>
<abp-script src="/Pages/Documents/Project/bootstrap-toc.js"/>
<abp-script src="/Pages/Documents/Shared/Scripts/vs.js"/> <abp-script src="/Pages/Documents/Shared/Scripts/vs.js"/>
<abp-script src="/Pages/Documents/Project/index.js"/> <abp-script src="/Pages/Documents/Project/index.js"/>
@if (DocsUiOptions.Value.EnableEnlargeImage) @if (DocsUiOptions.Value.EnableEnlargeImage)
@ -599,7 +597,8 @@
<h5 class="card-title">@L["InThisDocument"]</h5> <h5 class="card-title">@L["InThisDocument"]</h5>
<div id="scroll-index" class=""> <div id="scroll-index" class="">
<nav id="docs-sticky-index" class="navbar index-scroll"> <nav id="toc" class="navbar index-scroll">
<partial name="/Pages/Documents/Project/TableOfContents.cshtml" model="Model.TocItems" />
</nav> </nav>
<div class="row"> <div class="row">
<div class="col p-0"> <div class="col p-0">

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

@ -22,11 +22,12 @@ using Volo.Docs.Common.Documents;
using Volo.Docs.Common.Projects; using Volo.Docs.Common.Projects;
using Volo.Docs.Documents; using Volo.Docs.Documents;
using Volo.Docs.Documents.Rendering; using Volo.Docs.Documents.Rendering;
using Volo.Docs.GitHub.Documents.Version;
using Volo.Docs.HtmlConverting; using Volo.Docs.HtmlConverting;
using Volo.Docs.Localization;
using Volo.Docs.Models; using Volo.Docs.Models;
using Volo.Docs.Projects; using Volo.Docs.Projects;
using Volo.Docs.GitHub.Documents.Version; using Volo.Docs.TableOfContents;
using Volo.Docs.Localization;
using Volo.Docs.Utils; using Volo.Docs.Utils;
namespace Volo.Docs.Pages.Documents.Project namespace Volo.Docs.Pages.Documents.Project
@ -75,6 +76,8 @@ namespace Volo.Docs.Pages.Documents.Project
public string DocumentsUrlPrefix { get; set; } public string DocumentsUrlPrefix { get; set; }
public List<TocItem> TocItems { get; set; } = [];
public bool ShowProjectsCombobox { get; set; } public bool ShowProjectsCombobox { get; set; }
public bool ShowProjectsComboboxLabel { get; set; } public bool ShowProjectsComboboxLabel { get; set; }
@ -98,6 +101,7 @@ namespace Volo.Docs.Pages.Documents.Project
public DocumentNavigationsDto DocumentNavigationsDto { get; private set; } public DocumentNavigationsDto DocumentNavigationsDto { get; private set; }
private const int MaxDescriptionMetaTagLength = 200; private const int MaxDescriptionMetaTagLength = 200;
private const int TocLevelCount = 2;
private readonly IDocumentAppService _documentAppService; private readonly IDocumentAppService _documentAppService;
private readonly IDocumentToHtmlConverterFactory _documentToHtmlConverterFactory; private readonly IDocumentToHtmlConverterFactory _documentToHtmlConverterFactory;
private readonly IProjectAppService _projectAppService; private readonly IProjectAppService _projectAppService;
@ -105,6 +109,7 @@ namespace Volo.Docs.Pages.Documents.Project
private readonly DocsUiOptions _uiOptions; private readonly DocsUiOptions _uiOptions;
private readonly IPermissionChecker _permissionChecker; private readonly IPermissionChecker _permissionChecker;
private readonly IDocumentPdfAppService _documentPdfAppService; private readonly IDocumentPdfAppService _documentPdfAppService;
private readonly ITocGeneratorService _tocGeneratorService;
protected IDocsLinkGenerator DocsLinkGenerator => LazyServiceProvider.LazyGetRequiredService<IDocsLinkGenerator>(); protected IDocsLinkGenerator DocsLinkGenerator => LazyServiceProvider.LazyGetRequiredService<IDocsLinkGenerator>();
@ -117,7 +122,8 @@ namespace Volo.Docs.Pages.Documents.Project
IOptions<DocsUiOptions> options, IOptions<DocsUiOptions> options,
IWebDocumentSectionRenderer webDocumentSectionRenderer, IWebDocumentSectionRenderer webDocumentSectionRenderer,
IPermissionChecker permissionChecker, IPermissionChecker permissionChecker,
IDocumentPdfAppService documentPdfAppService) IDocumentPdfAppService documentPdfAppService,
ITocGeneratorService tocGeneratorService)
{ {
ObjectMapperContext = typeof(DocsWebModule); ObjectMapperContext = typeof(DocsWebModule);
@ -128,6 +134,7 @@ namespace Volo.Docs.Pages.Documents.Project
_permissionChecker = permissionChecker; _permissionChecker = permissionChecker;
_documentPdfAppService = documentPdfAppService; _documentPdfAppService = documentPdfAppService;
_uiOptions = options.Value; _uiOptions = options.Value;
_tocGeneratorService = tocGeneratorService;
LocalizationResourceType = typeof(DocsResource); LocalizationResourceType = typeof(DocsResource);
} }
@ -534,7 +541,14 @@ namespace Volo.Docs.Pages.Documents.Project
DocumentLanguageCode = language; DocumentLanguageCode = language;
DocumentNameWithExtension = Document.Name; DocumentNameWithExtension = Document.Name;
SetDocumentPageTitle(); SetDocumentPageTitle();
if (Document != null && !Document.Content.IsNullOrEmpty())
{
TocItems = _tocGeneratorService.GenerateTocItems(Document.Content, TocLevelCount);
}
await ConvertDocumentContentToHtmlAsync(); await ConvertDocumentContentToHtmlAsync();
return true; return true;
} }
catch (DocumentNotFoundException e) catch (DocumentNotFoundException e)

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

@ -0,0 +1,41 @@
@using Volo.Docs.TableOfContents
@model List<TocItem>
@{
if (Model == null || Model.Count == 0)
{
return;
}
}
<ul class="nav nav-pills flex-column">
@foreach (var item in Model)
{
<li class="nav-item @(item.Children.Any() ? "toc-item-has-children" : "")">
<a class="nav-link" href="#@item.Heading.Id">@item.Heading.Text</a>
@if (item.Children.Any())
{
RenderChildrenRecursive(item.Children, 1);
}
</li>
}
</ul>
@{
void RenderChildrenRecursive(List<TocItem> children, int depth)
{
<ul class="nav nav-pills flex-column toc-depth-@depth">
@foreach (var child in children)
{
<li class="nav-item @(child.Children.Any() ? "toc-item-has-children" : "")">
<a class="nav-link" href="#@child.Heading.Id">@child.Heading.Text</a>
@if (child.Children.Any())
{
RenderChildrenRecursive(child.Children, depth + 1);
}
</li>
}
</ul>
}
}

2
modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/index.js

@ -42,8 +42,6 @@ var doc = doc || {};
$ul.append($li); $ul.append($li);
$lazyLiElement.append($ul) $lazyLiElement.append($ul)
window.Toc.helpers.initNavEvent();
}, },
loadAll : function(lazyLiElements){ loadAll : function(lazyLiElements){
if(doc.lazyExpandableNavigation.isAllLoaded){ if(doc.lazyExpandableNavigation.isAllLoaded){

12
modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/index.scss

@ -71,4 +71,16 @@ body {
background-color: transparent; background-color: transparent;
border-color: transparent; border-color: transparent;
font-size: 14px; 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;
} }

43
modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Scripts/vs.js

@ -1,8 +1,6 @@
(function ($) { (function ($) {
$(function () { $(function () {
window.Toc.helpers.initNavEvent();
var scrollTopBtn = $('.scroll-top-btn'); var scrollTopBtn = $('.scroll-top-btn');
var enoughHeight = $('.docs-sidebar-wrapper > .docs-top').height(); var enoughHeight = $('.docs-sidebar-wrapper > .docs-top').height();
var enoughHeightPlus = 500; var enoughHeightPlus = 500;
@ -64,10 +62,10 @@
handleCustomScrolls(); handleCustomScrolls();
var $myNav = $('#docs-sticky-index'); var $myNav = $('#docs-sticky-index');
Toc.init($myNav);
$('body').scrollspy({ $('body').scrollspy({
target: $myNav, target: $myNav,
offset:100
}); });
$('#docs-sticky-index a').on('click', function (event) { $('#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 () { $('.btn-toggle').on('click', function () {
$('.toggle-row').slideToggle(400); $('.toggle-row').slideToggle(400);
$(this).toggleClass('less'); $(this).toggleClass('less');
@ -99,6 +114,7 @@
$('.docs-tree-list').slideToggle(); $('.docs-tree-list').slideToggle();
}); });
initMenuToggle();
scrollToHashLink(); scrollToHashLink();
}); });
@ -125,26 +141,7 @@
}); });
} }
window.Toc.helpers.createNavList = function () { function initMenuToggle() {
return $('<ul class="nav nav-pills flex-column"></ul>');
};
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 class="nav-link"></a>');
$a.attr('href', '#' + anchor);
$a.text(text);
var $li = $('<li class="nav-item"></li>');
$li.append($a);
return $li;
};
window.Toc.helpers.initNavEvent = function () {
$('li:not(.last-link) a.tree-toggle').off('click'); $('li:not(.last-link) a.tree-toggle').off('click');
$('li:not(.last-link) span.plus-icon i.fa-chevron-right').off('click'); $('li:not(.last-link) span.plus-icon i.fa-chevron-right').off('click');

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

@ -0,0 +1,15 @@
using System.Collections.Generic;
using Volo.Abp.Application.Services;
namespace Volo.Docs.TableOfContents;
public interface ITocGeneratorService : IApplicationService
{
List<TocHeading> GenerateTocHeadings(string markdownContent);
List<TocItem> GenerateTocItems(List<TocHeading> tocHeadings, int topLevel, int levelCount);
int GetTopLevel(List<TocHeading> tocHeadings);
List<TocItem> GenerateTocItems(string markdownContent, int levelCount, int? topLevel = null);
}

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

@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
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;
namespace Volo.Docs.TableOfContents;
public class TocGeneratorService : ITocGeneratorService, ITransientDependency
{
private const int MinHeadingLevel = 1;
private const int MaxHeadingLevel = 6;
public virtual List<TocHeading> GenerateTocHeadings(string markdownContent)
{
if (markdownContent.IsNullOrWhiteSpace())
{
return new List<TocHeading>();
}
var markdownPipeline = CreateMarkdownPipeline();
var document = Markdig.Markdown.Parse(markdownContent, markdownPipeline);
var headingBlocks = document.Descendants<HeadingBlock>();
return headingBlocks
.Select(CreateTocHeading)
.ToList();
}
public virtual List<TocItem> GenerateTocItems(List<TocHeading> tocHeadings, int topLevel, int levelCount)
{
var maxLevel = GetMaxLevel(tocHeadings, topLevel, levelCount);
var filteredHeadings = tocHeadings
.Where(heading => heading.Level >= topLevel && heading.Level <= maxLevel)
.ToList();
return BuildHierarchicalStructure(filteredHeadings, topLevel);
}
public virtual int GetTopLevel(List<TocHeading> headings)
{
return Enumerable.Range(MinHeadingLevel, MaxHeadingLevel)
.FirstOrDefault(level => headings.Count(h => h.Level == level) > 1, MinHeadingLevel);
}
public virtual List<TocItem> GenerateTocItems(string markdownContent, int levelCount, int? topLevel = null)
{
var headings = GenerateTocHeadings(markdownContent);
if (headings.Count == 0)
{
return new List<TocItem>();
}
var resolvedTopLevel = topLevel ?? GetTopLevel(headings);
return GenerateTocItems(headings, resolvedTopLevel, levelCount);
}
protected virtual MarkdownPipeline CreateMarkdownPipeline()
{
return new MarkdownPipelineBuilder()
.UseAutoIdentifiers(AutoIdentifierOptions.GitHub)
.UseAdvancedExtensions()
.Build();
}
protected virtual TocHeading CreateTocHeading(HeadingBlock headingBlock)
{
var plainText = GetPlainText(headingBlock.Inline);
var id = headingBlock.GetAttributes().Id;
return new TocHeading(headingBlock.Level, plainText, id);
}
protected virtual List<TocItem> BuildHierarchicalStructure(List<TocHeading> headings, int topLevel)
{
var result = new List<TocItem>();
for (var i = 0; i < headings.Count; i++)
{
var currentHeading = headings[i];
if (currentHeading.Level != topLevel)
{
continue;
}
var children = GetDirectChildren(headings, i, currentHeading.Level);
result.Add(new TocItem(currentHeading, children));
}
return result;
}
protected virtual List<TocItem> GetDirectChildren(List<TocHeading> allHeadings, int parentIndex, int parentLevel)
{
var targetChildLevel = parentLevel + 1;
var children = new List<TocItem>();
for (var i = parentIndex + 1; i < allHeadings.Count; i++)
{
var heading = allHeadings[i];
// Stop if we encounter a heading at the same level or higher than parent
if (heading.Level <= parentLevel)
{
break;
}
// Only process direct children (not grandchildren)
if (heading.Level != targetChildLevel)
{
continue;
}
var grandChildren = GetDirectChildren(allHeadings, i, heading.Level);
children.Add(new TocItem(heading, grandChildren));
}
return children;
}
protected virtual string GetPlainText(ContainerInline container)
{
if (container == null)
{
return string.Empty;
}
// Optimization for simple case with single literal inline
if (HasExactCount(container, 1) && container.First() is LiteralInline singleLiteral)
{
return singleLiteral.Content.ToString();
}
return ProcessInlineContent(container);
}
protected virtual string ProcessInlineContent(ContainerInline container)
{
var textBuilder = new StringBuilder();
var processingQueue = new Queue<Inline>();
// Enqueue all initial inlines
foreach (var inline in container)
{
processingQueue.Enqueue(inline);
}
// Process each inline in the queue
while (processingQueue.Count > 0)
{
var currentInline = processingQueue.Dequeue();
AppendInlineText(currentInline, textBuilder, processingQueue);
}
return textBuilder.ToString();
}
protected virtual void AppendInlineText(Inline inline, StringBuilder builder, Queue<Inline> processingQueue)
{
switch (inline)
{
case LiteralInline literal:
builder.Append(literal.Content);
break;
case CodeInline code:
builder.Append(code.Content);
break;
case ContainerInline containerInline:
foreach (var childInline in containerInline)
{
processingQueue.Enqueue(childInline);
}
break;
}
}
protected virtual int GetMaxLevel(List<TocHeading> tocHeadings, int topLevel, int levelCount)
{
return tocHeadings.Where(h => h.Level >= topLevel)
.Select(h => h.Level)
.Distinct()
.OrderBy(level => level)
.Skip(levelCount - 1)
.FirstOrDefault(topLevel);
}
protected virtual bool HasExactCount<T>(IEnumerable<T> enumerable, int count)
{
var itemCount = 0;
foreach (var _ in enumerable)
{
itemCount++;
if (itemCount > count)
{
return false;
}
}
return itemCount == count;
}
}

7
modules/docs/src/Volo.Docs.Web/TableOfContents/TocHeading.cs

@ -0,0 +1,7 @@
using System.Collections.Generic;
namespace Volo.Docs.TableOfContents;
public record TocHeading(int Level, string Text, string Id);
public record TocItem(TocHeading Heading, List<TocItem> Children);
Loading…
Cancel
Save