Skip to content
17 changes: 16 additions & 1 deletion src/Docfx.Build/TableOfContents/BuildTocDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
using Docfx.Common;
using Docfx.DataContracts.Common;
using Docfx.Plugins;

using YamlDotNet.Core.Tokens;
namespace Docfx.Build.TableOfContents;

[Export(nameof(TocDocumentProcessor), typeof(IDocumentBuildStep))]
Expand All @@ -24,6 +24,21 @@ class BuildTocDocument : BaseDocumentBuildStep
/// </summary>
public override IEnumerable<FileModel> Prebuild(ImmutableList<FileModel> models, IHostService host)
{

if (!models.Any())
{
return TocHelper.ResolveToc(models.ToImmutableList());
}

// Keep auto toc agnostic to the toc file naming convention.
var tocFileName = models.First().Key.Split('/').Last();
var tocModels = models.OrderBy(f => f.File.Split('/').Count());
var tocCache = new Dictionary<string, TocItemViewModel>();
models.ForEach(model =>
{
tocCache.Add(model.Key.Replace("\\", "/").Replace("/" + tocFileName, string.Empty), (TocItemViewModel)model.Content);
});
TocHelper.RecursivelyPopulateTocs(tocFileName, host.SourceFiles.Keys, tocCache);
return TocHelper.ResolveToc(models);
}

Expand Down
136 changes: 135 additions & 1 deletion src/Docfx.Build/TableOfContents/TocHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Immutable;

using System.Globalization;
using System.Web;
using Docfx.Common;
using Docfx.DataContracts.Common;
using Docfx.Plugins;
Expand All @@ -11,6 +12,8 @@ namespace Docfx.Build.TableOfContents;

public static class TocHelper
{
private static TextInfo TextInfo = new CultureInfo("en-US", false).TextInfo;

private static readonly YamlDeserializerWithFallback _deserializer =
YamlDeserializerWithFallback.Create<List<TocItemViewModel>>()
.WithFallback<TocItemViewModel>();
Expand Down Expand Up @@ -82,4 +85,135 @@ public static TocItemViewModel LoadSingleToc(string file)

throw new NotSupportedException($"{file} is not a valid TOC file, supported TOC files should be either \"{Constants.TableOfContents.MarkdownTocFileName}\" or \"{Constants.TableOfContents.YamlTocFileName}\".");
}

private static (bool, TocItemViewModel) TryGetOrCreateToc(Dictionary<string, TocItemViewModel> pathToToc, string currentFolderPath, HashSet<string> virtualTocPaths)
{
bool folderHasToc = false;
TocItemViewModel tocItem;
if (pathToToc.TryGetValue(currentFolderPath, out tocItem))
{
folderHasToc = true;
}
else
{
var idx = currentFolderPath.LastIndexOf('/');
if (idx != -1)
{
tocItem = new TocItemViewModel
{
Name = currentFolderPath.Substring(idx + 1),
Auto = true
};
pathToToc[currentFolderPath] = tocItem;
virtualTocPaths.Add(currentFolderPath);

}
else
{
tocItem = new TocItemViewModel();
}
}
return (folderHasToc, tocItem);
}

private static void LinkToParentToc(Dictionary<string, TocItemViewModel> tocCache, string currentFolderPath, TocItemViewModel tocItem, HashSet<string> virtualTocPaths, bool folderHasToc)
{
int idx = currentFolderPath.LastIndexOf('/');
if (idx != -1 && !currentFolderPath.EndsWith(".."))
{
// This is an existing behavior, href: ~/foldername/ doesnot work, but href: ./foldername/ does.
// var folderToProcessSanitized = currentFolderPath.Replace("~", ".") + "/";
// validate this behavior with yuefi
var parentTocFolder = currentFolderPath.Substring(0, idx);
TocItemViewModel parentToc = null;
while (idx != -1 && !tocCache.TryGetValue(parentTocFolder, out parentToc))
{
idx = parentTocFolder.LastIndexOf('/');
if (idx != -1)
{
parentTocFolder = currentFolderPath.Substring(0, idx);
}
}


if (parentToc != null)
{
var folderToProcessSanitized = currentFolderPath.Replace(parentTocFolder, ".") + "/";
if (parentToc.Items == null)
{
parentToc.Items = new List<TocItemViewModel>();
}

// Only link to parent rootToc if the auto is enabled.
if (!folderHasToc &&
parentToc.Auto.HasValue &&
parentToc.Auto.Value)
{
parentToc.Items.Add(tocItem);
}
else if (folderHasToc &&
parentToc.Auto.HasValue &&
parentToc.Auto.Value &&
!virtualTocPaths.Contains(currentFolderPath) &&
!parentToc.Items.Any(i => i.Href != null && Path.GetRelativePath(i.Href.Replace('~', '.'), folderToProcessSanitized) == "."))
{
var tocToLinkFrom = new TocItemViewModel();
tocToLinkFrom.Name = StandarizeName(Path.GetFileNameWithoutExtension(currentFolderPath));
tocToLinkFrom.Href = folderToProcessSanitized;
parentToc.Items.Add(tocToLinkFrom);
}
}
}
}

internal static void RecursivelyPopulateTocs(string tocFileName, IEnumerable<string> sourceFilePaths, Dictionary<string, TocItemViewModel> tocCache)
{
var rootToc = tocCache.GetValueOrDefault(RelativePath.WorkingFolderString);
/*if (!(rootToc != null && rootToc.Auto.HasValue && rootToc.Auto.Value))
{
Logger.LogInfo($"auto value is not set to true. skipping auto gen.");
return;
}*/
var folderPathForRootToc = RelativePath.WorkingFolderString;

// Omit the files that are outside the docfx base directory.
var fileNames = sourceFilePaths
.Where(s => !Path.GetRelativePath(folderPathForRootToc, s).Contains("..") && !s.EndsWith(tocFileName))
.Select(p => p.Replace("\\", "/"))
.OrderBy(f => f.Split('/').Count());

var virtualTocs = new HashSet<string>();
foreach (var filePath in fileNames)
{
var folderToProcess = Path.GetDirectoryName(filePath).Replace("\\", "/");

var (folderHasToc, tocToProcess) = TryGetOrCreateToc(tocCache, folderToProcess, virtualTocs);

LinkToParentToc(tocCache, folderToProcess, tocToProcess, virtualTocs, folderHasToc);

// If the rootToc we currently process didnot have auto enabled.
// There is no need to populate the rootToc, move on.
if (!tocToProcess.Auto.HasValue || (tocToProcess.Auto.HasValue && !tocToProcess.Auto.Value))
{
continue;
}

var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filePath);

if (tocToProcess.Items == null)
{
tocToProcess.Items = new List<TocItemViewModel>();
}

if (!(tocToProcess.Items.Where(i => i.Href !=null && (i.Href.Equals(filePath) || i.Href.Equals(Path.GetFileName(filePath))))).Any())
{
var item = new TocItemViewModel();
item.Name = item.Name != null ? item.Name : StandarizeName(fileNameWithoutExtension);
item.Href = filePath;
tocToProcess.Items.Add(item);
}
}
}

internal static string StandarizeName(string name) => TextInfo.ToTitleCase(HttpUtility.UrlDecode(name)).Replace('-', ' ');
}
1 change: 1 addition & 0 deletions src/Docfx.DataContracts.Common/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public static class DocumentType

public static class PropertyName
{
public const string Auto = "auto";
public const string Uid = "uid";
public const string CommentId = "commentId";
public const string Id = "id";
Expand Down
5 changes: 5 additions & 0 deletions src/Docfx.DataContracts.Common/TocItemViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ namespace Docfx.DataContracts.Common;

public class TocItemViewModel
{
[YamlMember(Alias = Constants.PropertyName.Auto)]
[JsonProperty(Constants.PropertyName.Auto)]
[JsonPropertyName(Constants.PropertyName.Auto)]
public bool? Auto { get; set; }

[YamlMember(Alias = Constants.PropertyName.Uid)]
[JsonProperty(Constants.PropertyName.Uid)]
[JsonPropertyName(Constants.PropertyName.Uid)]
Expand Down
27 changes: 7 additions & 20 deletions test/Docfx.Build.Tests/TocDocumentProcessorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public void ProcessMarkdownTocWithComplexHrefShouldSucceed()
]
};

AssertTocEqual(expectedModel, model);
TocHelperTest.AssertTocEqual(expectedModel, model);
}

[Fact]
Expand Down Expand Up @@ -132,7 +132,7 @@ public void ProcessMarkdownTocWithAbsoluteHrefShouldSucceed()
]
};

AssertTocEqual(expectedModel, model);
TocHelperTest.AssertTocEqual(expectedModel, model);
}

[Fact]
Expand Down Expand Up @@ -204,7 +204,7 @@ public void ProcessMarkdownTocWithRelativeHrefShouldSucceed()
]
};

AssertTocEqual(expectedModel, model);
TocHelperTest.AssertTocEqual(expectedModel, model);
}

[Fact]
Expand Down Expand Up @@ -273,7 +273,7 @@ public void ProcessYamlTocWithFolderShouldSucceed()
]
};

AssertTocEqual(expectedModel, model);
TocHelperTest.AssertTocEqual(expectedModel, model);
}

[Fact]
Expand Down Expand Up @@ -335,7 +335,7 @@ public void ProcessYamlTocWithMetadataShouldSucceed()
}
]
};
AssertTocEqual(expectedModel, model);
TocHelperTest.AssertTocEqual(expectedModel, model);
}

[Fact]
Expand Down Expand Up @@ -534,7 +534,7 @@ public void ProcessYamlTocWithReferencedTocShouldSucceed()
]
};

AssertTocEqual(expectedModel, model);
TocHelperTest.AssertTocEqual(expectedModel, model);

// Referenced TOC File should exist
var referencedTocPath = Path.Combine(_outputFolder, Path.ChangeExtension(sub1tocmd, RawModelFileExtension));
Expand Down Expand Up @@ -684,7 +684,7 @@ public void ProcessYamlTocWithTocHrefShouldSucceed()
]
};

AssertTocEqual(expectedModel, model);
TocHelperTest.AssertTocEqual(expectedModel, model);
}

[Fact]
Expand Down Expand Up @@ -939,18 +939,5 @@ private void BuildDocument(FileCollection files)
builder.Build(parameters);
}

private static void AssertTocEqual(TocItemViewModel expected, TocItemViewModel actual, bool noMetadata = true)
{
using var swForExpected = new StringWriter();
YamlUtility.Serialize(swForExpected, expected);
using var swForActual = new StringWriter();
if (noMetadata)
{
actual.Metadata.Clear();
}
YamlUtility.Serialize(swForActual, actual);
Assert.Equal(swForExpected.ToString(), swForActual.ToString());
}

#endregion
}
Loading