diff --git a/src/DocNet/Config.cs b/src/DocNet/Config.cs index 2ff4195..2907117 100644 --- a/src/DocNet/Config.cs +++ b/src/DocNet/Config.cs @@ -216,6 +216,14 @@ namespace Docnet } } + public int MaxLevelInToC + { + get + { + return _configData.MaxLevelInToC ?? 2; + } + } + public string ThemeName { get diff --git a/src/DocNet/INavigationElement.cs b/src/DocNet/INavigationElement.cs index 0e97aac..12a4695 100644 --- a/src/DocNet/INavigationElement.cs +++ b/src/DocNet/INavigationElement.cs @@ -43,8 +43,9 @@ namespace Docnet /// /// The navigated path to the current element, which doesn't necessarily have to be this element. /// The relative path back to the URL root, e.g. ../.., so it can be used for links to elements in this path. + /// The maximum level. /// - string GenerateToCFragment(NavigatedPath navigatedPath, string relativePathToRoot); + string GenerateToCFragment(NavigatedPath navigatedPath, string relativePathToRoot, int maxLevel); /// /// Collects the search index entries. These are created from simple navigation elements found in this container, which aren't index element. /// diff --git a/src/DocNet/NavigatedPath.cs b/src/DocNet/NavigatedPath.cs index b53f6c1..e08e76e 100644 --- a/src/DocNet/NavigatedPath.cs +++ b/src/DocNet/NavigatedPath.cs @@ -73,11 +73,12 @@ namespace Docnet /// /// Creates the ToC HTML for the element reached by the elements in this path. All containers in this path are expanded, all elements inside these containers which - /// aren't, are not expanded. + /// aren't, are not expanded. /// /// The relative path back to the URL root, e.g. ../.., so it can be used for links to elements in this path. + /// The maximum level. /// - public string CreateToCHTML(string relativePathToRoot) + public string CreateToCHTML(string relativePathToRoot, int maxLevel) { // the root container is the bottom element of this path. We use that container to build the root and navigate any node open along the navigated path. var rootContainer = this.Reverse().FirstOrDefault() as NavigationLevel; @@ -86,7 +87,7 @@ namespace Docnet // no root container, no TOC return string.Empty; } - return rootContainer.GenerateToCFragment(this, relativePathToRoot); + return rootContainer.GenerateToCFragment(this, relativePathToRoot, maxLevel); } } } diff --git a/src/DocNet/NavigationElement.cs b/src/DocNet/NavigationElement.cs index dd86175..4a26709 100644 --- a/src/DocNet/NavigationElement.cs +++ b/src/DocNet/NavigationElement.cs @@ -43,8 +43,9 @@ namespace Docnet /// /// The navigated path to the current element, which doesn't necessarily have to be this element. /// The relative path back to the URL root, e.g. ../.., so it can be used for links to elements in this path. + /// The maximum level. /// - public abstract string GenerateToCFragment(NavigatedPath navigatedPath, string relativePathToRoot); + public abstract string GenerateToCFragment(NavigatedPath navigatedPath, string relativePathToRoot, int maxLevel); /// /// Collects the search index entries. These are created from simple navigation elements found in this container, which aren't index element. /// diff --git a/src/DocNet/NavigationLevel.cs b/src/DocNet/NavigationLevel.cs index 57bc38b..3b29373 100644 --- a/src/DocNet/NavigationLevel.cs +++ b/src/DocNet/NavigationLevel.cs @@ -106,8 +106,9 @@ namespace Docnet /// /// The navigated path to the current element, which doesn't necessarily have to be this element. /// The relative path back to the URL root, e.g. ../.., so it can be used for links to elements in this path. + /// The maximum level. /// - public override string GenerateToCFragment(NavigatedPath navigatedPath, string relativePathToRoot) + public override string GenerateToCFragment(NavigatedPath navigatedPath, string relativePathToRoot, int maxLevel) { var fragments = new List(); if(!this.IsRoot) @@ -138,7 +139,7 @@ namespace Docnet { if(this.IsRoot) { - fragments.Add(indexElement.PerformGenerateToCFragment(navigatedPath, relativePathToRoot)); + fragments.Add(indexElement.PerformGenerateToCFragment(navigatedPath, relativePathToRoot, maxLevel)); } else { @@ -149,7 +150,7 @@ namespace Docnet // then the elements in the container. Index elements are skipped here. foreach(var element in this.Value) { - fragments.Add(element.GenerateToCFragment(navigatedPath, relativePathToRoot)); + fragments.Add(element.GenerateToCFragment(navigatedPath, relativePathToRoot, maxLevel)); } fragments.Add(""); } diff --git a/src/DocNet/SimpleNavigationElement.cs b/src/DocNet/SimpleNavigationElement.cs index c47f2b7..6ecf0bd 100644 --- a/src/DocNet/SimpleNavigationElement.cs +++ b/src/DocNet/SimpleNavigationElement.cs @@ -27,6 +27,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; using System.Web; +using MarkdownDeep; namespace Docnet { @@ -34,13 +35,14 @@ namespace Docnet { #region Members private string _targetURLForHTML; - private List> _relativeH2LinksOnPage; // first element in Tuple is anchor name, second is name for ToC. + + private readonly List _relativeLinksOnPage; // first element in Tuple is anchor name, second is name for ToC. #endregion public SimpleNavigationElement() { - _relativeH2LinksOnPage = new List>(); + _relativeLinksOnPage = new List(); } @@ -52,50 +54,50 @@ namespace Docnet public override void GenerateOutput(Config activeConfig, NavigatedPath activePath) { // if we're the __index element, we're not pushing ourselves on the path, as we're representing the container we're in, which is already on the path. - if(!this.IsIndexElement) + if (!this.IsIndexElement) { activePath.Push(this); } - _relativeH2LinksOnPage.Clear(); + _relativeLinksOnPage.Clear(); var sourceFile = Utils.MakeAbsolutePath(activeConfig.Source, this.Value); var destinationFile = Utils.MakeAbsolutePath(activeConfig.Destination, this.TargetURL); var sb = new StringBuilder(activeConfig.PageTemplateContents.Length + 2048); var content = string.Empty; this.MarkdownFromFile = string.Empty; var relativePathToRoot = Utils.MakeRelativePathForUri(Path.GetDirectoryName(destinationFile), activeConfig.Destination); - if(File.Exists(sourceFile)) + if (File.Exists(sourceFile)) { this.MarkdownFromFile = File.ReadAllText(sourceFile, Encoding.UTF8); // Check if the content contains @@include tag content = Utils.IncludeProcessor(this.MarkdownFromFile, Utils.MakeAbsolutePath(activeConfig.Source, activeConfig.IncludeFolder)); - content = Utils.ConvertMarkdownToHtml(content, Path.GetDirectoryName(destinationFile), activeConfig.Destination, sourceFile, _relativeH2LinksOnPage, activeConfig.ConvertLocalLinks); + content = Utils.ConvertMarkdownToHtml(content, Path.GetDirectoryName(destinationFile), activeConfig.Destination, sourceFile, _relativeLinksOnPage, activeConfig.ConvertLocalLinks); } else { // if we're not the index element, the file is missing and potentially it's an error in the config page. // Otherwise we can simply assume we are a missing index page and we'll generate default markdown so the user has something to look at. - if(this.IsIndexElement) + if (this.IsIndexElement) { // replace with default markdown snippet. This is the name of our container and links to the elements in that container as we are the index page that's not // specified / existend. var defaultMarkdown = new StringBuilder(); defaultMarkdown.AppendFormat("# {0}{1}{1}", this.ParentContainer.Name, Environment.NewLine); defaultMarkdown.AppendFormat("Please select one of the topics in this section:{0}{0}", Environment.NewLine); - foreach(var sibling in this.ParentContainer.Value) + foreach (var sibling in this.ParentContainer.Value) { - if(sibling == this) + if (sibling == this) { continue; } defaultMarkdown.AppendFormat("* [{0}]({1}{2}){3}", sibling.Name, relativePathToRoot, HttpUtility.UrlPathEncode(sibling.TargetURL), Environment.NewLine); } defaultMarkdown.Append(Environment.NewLine); - content = Utils.ConvertMarkdownToHtml(defaultMarkdown.ToString(), Path.GetDirectoryName(destinationFile), activeConfig.Destination, string.Empty, _relativeH2LinksOnPage, activeConfig.ConvertLocalLinks); + content = Utils.ConvertMarkdownToHtml(defaultMarkdown.ToString(), Path.GetDirectoryName(destinationFile), activeConfig.Destination, string.Empty, _relativeLinksOnPage, activeConfig.ConvertLocalLinks); } else { // target not found. See if there's a content producer func to produce html for us. If not, we can only conclude an error in the config file. - if(this.ContentProducerFunc == null) + if (this.ContentProducerFunc == null) { throw new FileNotFoundException(string.Format("The specified markdown file '{0}' couldn't be found. Aborting", sourceFile)); } @@ -107,17 +109,17 @@ namespace Docnet sb.Replace("{{Footer}}", activeConfig.Footer); sb.Replace("{{TopicTitle}}", this.Name); sb.Replace("{{Path}}", relativePathToRoot); - sb.Replace("{{RelativeSourceFileName}}", Utils.MakeRelativePathForUri(activeConfig.Destination, sourceFile).TrimEnd('/')); - sb.Replace("{{RelativeTargetFileName}}", Utils.MakeRelativePathForUri(activeConfig.Destination, destinationFile).TrimEnd('/')); - sb.Replace("{{Breadcrumbs}}", activePath.CreateBreadCrumbsHTML(relativePathToRoot)); - sb.Replace("{{ToC}}", activePath.CreateToCHTML(relativePathToRoot)); + sb.Replace("{{RelativeSourceFileName}}", Utils.MakeRelativePathForUri(activeConfig.Destination, sourceFile).TrimEnd('/')); + sb.Replace("{{RelativeTargetFileName}}", Utils.MakeRelativePathForUri(activeConfig.Destination, destinationFile).TrimEnd('/')); + sb.Replace("{{Breadcrumbs}}", activePath.CreateBreadCrumbsHTML(relativePathToRoot)); + sb.Replace("{{ToC}}", activePath.CreateToCHTML(relativePathToRoot, activeConfig.MaxLevelInToC)); sb.Replace("{{ExtraScript}}", (this.ExtraScriptProducerFunc == null) ? string.Empty : this.ExtraScriptProducerFunc(this)); // the last action has to be replacing the content marker, so markers in the content which we have in the template as well aren't replaced sb.Replace("{{Content}}", content); Utils.CreateFoldersIfRequired(destinationFile); File.WriteAllText(destinationFile, sb.ToString()); - if(!this.IsIndexElement) + if (!this.IsIndexElement) { activePath.Pop(); } @@ -133,7 +135,7 @@ namespace Docnet { activePath.Push(this); // simply convert ourselves into an entry if we're not an index - if(!this.IsIndexElement) + if (!this.IsIndexElement) { var toAdd = new SearchIndexEntry(); toAdd.Fill(this.MarkdownFromFile, this.TargetURL, this.Name, activePath); @@ -148,16 +150,17 @@ namespace Docnet /// /// The navigated path to the current element, which doesn't necessarily have to be this element. /// The relative path back to the URL root, e.g. ../.., so it can be used for links to elements in this path. + /// The maximum level. /// - public override string GenerateToCFragment(NavigatedPath navigatedPath, string relativePathToRoot) + public override string GenerateToCFragment(NavigatedPath navigatedPath, string relativePathToRoot, int maxLevel) { // index elements are rendered in the parent container. - if(this.IsIndexElement) + if (this.IsIndexElement) { return string.Empty; } - return PerformGenerateToCFragment(navigatedPath, relativePathToRoot); + return PerformGenerateToCFragment(navigatedPath, relativePathToRoot, maxLevel, null); } @@ -167,15 +170,17 @@ namespace Docnet /// /// The navigated path. /// The relative path to root. + /// The maximum level. + /// The parent heading. /// - public string PerformGenerateToCFragment(NavigatedPath navigatedPath, string relativePathToRoot) + public string PerformGenerateToCFragment(NavigatedPath navigatedPath, string relativePathToRoot, int maxLevel) { // we can't navigate deeper from here. If we are the element being navigated to, we are the current and will have to emit any additional relative URLs too. bool isCurrent = navigatedPath.Contains(this); var fragments = new List(); var liClass = "tocentry"; var aClass = string.Empty; - if(isCurrent) + if (isCurrent) { liClass = "tocentry current"; aClass = "current"; @@ -186,20 +191,46 @@ namespace Docnet relativePathToRoot, HttpUtility.UrlPathEncode(this.TargetURL), this.Name)); - if(isCurrent && _relativeH2LinksOnPage.Any()) + + if (isCurrent) { - // generate relative links - fragments.Add(string.Format("
    ", this.ParentContainer.IsRoot ? "currentrelativeroot" : "currentrelative")); - foreach(var p in _relativeH2LinksOnPage) + var content = PerformGenerateToCFragment(navigatedPath, relativePathToRoot, maxLevel, null); + if (!string.IsNullOrWhiteSpace(content)) { - fragments.Add(string.Format("
  • {1}
  • ", p.Item1, p.Item2)); + fragments.Add(content); } - fragments.Add("
"); } - else + + fragments.Add(""); + + return string.Join(Environment.NewLine, fragments.ToArray()); + } + + private string PerformGenerateToCFragment(NavigatedPath navigatedPath, string relativePathToRoot, int maxLevel, Heading parentHeading) + { + var fragments = new List(); + + var headings = (parentHeading != null) ? parentHeading.Children : _relativeLinksOnPage; + var includedHeadings = headings.Where(x => x.Level > 1 && x.Level <= maxLevel).ToList(); + if (includedHeadings.Count > 0) { - fragments.Add(""); + fragments.Add(string.Format("
    ", this.ParentContainer.IsRoot ? "currentrelativeroot" : "currentrelative")); + + // generate relative links + foreach (var heading in includedHeadings) + { + fragments.Add(string.Format("
  • {1}
  • ", heading.Id, heading.Name)); + + var headingContent = PerformGenerateToCFragment(navigatedPath, relativePathToRoot, maxLevel, heading); + if (!string.IsNullOrWhiteSpace(headingContent)) + { + fragments.Add(headingContent); + } + } + + fragments.Add("
"); } + return string.Join(Environment.NewLine, fragments.ToArray()); } @@ -209,12 +240,12 @@ namespace Docnet { get { - if(_targetURLForHTML==null) + if (_targetURLForHTML == null) { _targetURLForHTML = (this.Value ?? string.Empty); - if(_targetURLForHTML.ToLowerInvariant().EndsWith(".md")) + if (_targetURLForHTML.ToLowerInvariant().EndsWith(".md")) { - _targetURLForHTML = _targetURLForHTML.Substring(0, _targetURLForHTML.Length-3) + ".htm"; + _targetURLForHTML = _targetURLForHTML.Substring(0, _targetURLForHTML.Length - 3) + ".htm"; } _targetURLForHTML = _targetURLForHTML.Replace("\\", "/"); } diff --git a/src/DocNet/Utils.cs b/src/DocNet/Utils.cs index d5becc3..1aefc4e 100644 --- a/src/DocNet/Utils.cs +++ b/src/DocNet/Utils.cs @@ -27,6 +27,7 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; +using MarkdownDeep; namespace Docnet { @@ -50,7 +51,7 @@ namespace Docnet /// if set to true, convert local links to md files to target files. /// public static string ConvertMarkdownToHtml(string toConvert, string destinationDocumentPath, string siteRoot, string sourceDocumentFilename, - List> createdAnchorCollector, bool convertLocalLinks) + List createdAnchorCollector, bool convertLocalLinks) { var parser = new MarkdownDeep.Markdown { @@ -67,7 +68,9 @@ namespace Docnet }; var toReturn = parser.Transform(toConvert); - createdAnchorCollector.AddRange(parser.CreatedH2IdCollector); + + createdAnchorCollector.AddRange(parser.Headings.ConvertToHierarchy()); + return toReturn; } diff --git a/src/MarkdownDeep/Block.cs b/src/MarkdownDeep/Block.cs index 4df2802..08fb719 100644 --- a/src/MarkdownDeep/Block.cs +++ b/src/MarkdownDeep/Block.cs @@ -193,14 +193,19 @@ namespace MarkdownDeep { b.Append("<" + BlockType.ToString() + ">"); } - if(m.DocNetMode && BlockType == BlockType.h2 && !string.IsNullOrWhiteSpace(id)) + if(m.DocNetMode && !string.IsNullOrWhiteSpace(id)) { - // collect h2 id + text in collector - var h2ContentSb = new StringBuilder(); - m.SpanFormatter.Format(h2ContentSb, Buf, ContentStart, ContentLen); - var h2ContentAsString = h2ContentSb.ToString(); - b.Append(h2ContentAsString); - m.CreatedH2IdCollector.Add(new Tuple(id, h2ContentAsString)); + // collect id + text in collector + var headerContentStringBuilder = new StringBuilder(); + m.SpanFormatter.Format(headerContentStringBuilder, Buf, ContentStart, ContentLen); + var headerContentAsString = headerContentStringBuilder.ToString(); + b.Append(headerContentAsString); + m.Headings.Add(new Heading + { + Level = (int)BlockType, + Id = id, + Name = headerContentAsString + }); } else { diff --git a/src/MarkdownDeep/Extensions.cs b/src/MarkdownDeep/Extensions.cs new file mode 100644 index 0000000..4901e42 --- /dev/null +++ b/src/MarkdownDeep/Extensions.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; + +namespace MarkdownDeep +{ + public static class Extensions + { + public static List ConvertToHierarchy(this List headings) + { + var hierarchy = new List(); + + for (var i = 0; i < headings.Count; i++) + { + if (i > 0) + { + var previousHeading = headings[i - 1]; + var currentHeading = headings[i]; + + SetParentForHeading(previousHeading, currentHeading); + + var parent = currentHeading.Parent; + if (parent == null) + { + hierarchy.Add(currentHeading); + } + else + { + parent.Children.Add(currentHeading); + } + } + else + { + hierarchy.Add(headings[i]); + } + } + + return hierarchy; + } + + private static void SetParentForHeading(Heading previousHeading, Heading headingToAdd) + { + if (previousHeading.Level == headingToAdd.Level) + { + headingToAdd.Parent = previousHeading.Parent; + } + else if (previousHeading.Level < headingToAdd.Level) + { + headingToAdd.Parent = previousHeading; + } + else if (previousHeading.Level > headingToAdd.Level) + { + var previousHeadingParent = previousHeading.Parent; + if (previousHeadingParent != null) + { + SetParentForHeading(previousHeadingParent, headingToAdd); + } + } + } + } +} \ No newline at end of file diff --git a/src/MarkdownDeep/Heading.cs b/src/MarkdownDeep/Heading.cs new file mode 100644 index 0000000..6ff8814 --- /dev/null +++ b/src/MarkdownDeep/Heading.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Text; + +namespace MarkdownDeep +{ + public class Heading + { + public Heading() + { + Children = new List(); + } + + public Heading Parent { get; set; } + + public List Children { get; private set; } + + public int Level { get; set; } + + public string Id { get; set; } + + public string Name { get; set; } + + public override string ToString() + { + var stringBuilder = new StringBuilder(); + + for (var i = 0; i < Level; i++) + { + stringBuilder.Append("#"); + } + + stringBuilder.AppendLine($"{Id} - {Name}"); + + foreach (var child in Children) + { + stringBuilder.AppendLine(child.ToString()); + } + + var value = stringBuilder.ToString(); + return value; + } + } +} \ No newline at end of file diff --git a/src/MarkdownDeep/MardownDeep.cs b/src/MarkdownDeep/MardownDeep.cs index 9b496fe..0e2eaa9 100644 --- a/src/MarkdownDeep/MardownDeep.cs +++ b/src/MarkdownDeep/MardownDeep.cs @@ -57,7 +57,9 @@ namespace MarkdownDeep m_Footnotes = new Dictionary(); m_UsedFootnotes = new List(); m_UsedHeaderIDs = new Dictionary(); - this.CreatedH2IdCollector = new List>(); + + this.Headings = new List(); + _tabIdCounter = 0; } @@ -951,13 +953,11 @@ namespace MarkdownDeep set; } - /// - /// Collector for the created id's for H2 headers. First element in Tuple is id name, second is name for ToC (the text for H2). Id's are generated + /// Collector for the created id's for headers. First element in Tuple is id name, second is name for ToC (the text for header). Id's are generated /// by the parser and use pandoc algorithm, as AutoHeadingId's is switched on. Only in use if DocNetMode is set to true /// - public List> CreatedH2IdCollector { get; private set; } - + public List Headings { get; private set; } // Set the html class for the footnotes div // (defaults to "footnotes") diff --git a/src/MarkdownDeep/MarkdownDeep.csproj b/src/MarkdownDeep/MarkdownDeep.csproj index 15dce5a..9cec4eb 100644 --- a/src/MarkdownDeep/MarkdownDeep.csproj +++ b/src/MarkdownDeep/MarkdownDeep.csproj @@ -77,7 +77,9 @@ + + diff --git a/src/MarkdownDeepTests/ExtensionsTests.cs b/src/MarkdownDeepTests/ExtensionsTests.cs new file mode 100644 index 0000000..601e5d8 --- /dev/null +++ b/src/MarkdownDeepTests/ExtensionsTests.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using MarkdownDeep; +using NUnit.Framework; + +namespace MarkdownDeepTests +{ + [TestFixture] + public class ExtensionsTests + { + [TestCase] + public void ConvertsHeadingsHierarchy() + { + var headings = new List(); + headings.Add(new Heading { Level = 1, Name = "1" }); + headings.Add(new Heading { Level = 2, Name = "1.1" }); + headings.Add(new Heading { Level = 3, Name = "1.1.1" }); + headings.Add(new Heading { Level = 2, Name = "1.2" }); + headings.Add(new Heading { Level = 4, Name = "1.2.1.1" }); + headings.Add(new Heading { Level = 2, Name = "1.3" }); + headings.Add(new Heading { Level = 1, Name = "2" }); + headings.Add(new Heading { Level = 3, Name = "2.1.1" }); + headings.Add(new Heading { Level = 2, Name = "2.2" }); + + var hierarchy = headings.ConvertToHierarchy(); + + Assert.AreEqual(2, hierarchy.Count); + + var heading1 = hierarchy[0]; + Assert.AreEqual("1", heading1.Name); + Assert.AreEqual(3, heading1.Children.Count); + + var heading1_1 = heading1.Children[0]; + Assert.AreEqual("1.1", heading1_1.Name); + Assert.AreEqual(1, heading1_1.Children.Count); + + var heading1_1_1 = heading1_1.Children[0]; + Assert.AreEqual("1.1.1", heading1_1_1.Name); + Assert.AreEqual(0, heading1_1_1.Children.Count); + + var heading1_2 = heading1.Children[1]; + Assert.AreEqual("1.2", heading1_2.Name); + Assert.AreEqual(1, heading1_2.Children.Count); + + var heading1_2_1_1 = heading1_2.Children[0]; + Assert.AreEqual("1.2.1.1", heading1_2_1_1.Name); + Assert.AreEqual(0, heading1_2_1_1.Children.Count); + + var heading1_3 = heading1.Children[2]; + Assert.AreEqual("1.3", heading1_3.Name); + Assert.AreEqual(0, heading1_3.Children.Count); + + var heading2 = hierarchy[1]; + Assert.AreEqual("2", heading2.Name); + Assert.AreEqual(2, heading2.Children.Count); + + var heading2_1_1 = heading2.Children[0]; + Assert.AreEqual("2.1.1", heading2_1_1.Name); + Assert.AreEqual(0, heading2_1_1.Children.Count); + + var heading2_2 = heading2.Children[1]; + Assert.AreEqual("2.2", heading2_2.Name); + Assert.AreEqual(0, heading2_2.Children.Count); + } + } +} \ No newline at end of file diff --git a/src/MarkdownDeepTests/MarkdownDeepTests.csproj b/src/MarkdownDeepTests/MarkdownDeepTests.csproj index 21def54..d3512f9 100644 --- a/src/MarkdownDeepTests/MarkdownDeepTests.csproj +++ b/src/MarkdownDeepTests/MarkdownDeepTests.csproj @@ -75,6 +75,7 @@ +