Skip to content

Feature - Autotoc generation #10574

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
27 changes: 26 additions & 1 deletion src/Docfx.Build/TableOfContents/BuildTocDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
using Docfx.Common;
using Docfx.DataContracts.Common;
using Docfx.Plugins;

namespace Docfx.Build.TableOfContents;

[Export(nameof(TocDocumentProcessor), typeof(IDocumentBuildStep))]
Expand All @@ -24,6 +23,32 @@ 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);
});

// The list of models would contain all toc.yml including ones that are outside docfx base directory.
// Filter get the root toc
var rootTocModel = tocModels.Where(m =>
!m.LocalPathFromRoot.Contains("..")).OrderBy(f => f.LocalPathFromRoot.Split('/').Count()).FirstOrDefault();

// If there is no toc along side docfx.json, skip tocgen - validate behavior with yuefi
if (rootTocModel != null && rootTocModel.LocalPathFromRoot.Equals(tocFileName))
{
TocHelper.PopulateToc(rootTocModel, host.SourceFiles.Keys, tocCache);
Logger.LogInfo($"toc autogen complete.");
}
return TocHelper.ResolveToc(models);
}

Expand Down
130 changes: 130 additions & 0 deletions src/Docfx.Build/TableOfContents/TocHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,134 @@ 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;
while (!tocCache.TryGetValue(parentTocFolder, out parentToc))
{
idx = parentTocFolder.LastIndexOf('/');
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 toc 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 = Path.GetFileNameWithoutExtension(currentFolderPath);
tocToLinkFrom.Href = folderToProcessSanitized;
parentToc.Items.Add(tocToLinkFrom);
}
}
}
}

internal static void PopulateToc(FileModel rootTocFileModel, IEnumerable<string> sourceFilePaths, Dictionary<string, TocItemViewModel> tocCache)
{
var toc = ((TocItemViewModel)rootTocFileModel.Content);
if (!(toc != null && toc.Auto.HasValue && toc.Auto.Value))
{
Logger.LogInfo($"auto value is not set to true in {rootTocFileModel.File}. skipping toc auto gen.");
return;
}
var tocFileName = rootTocFileModel.Key.Split('/').Last();
var folderPathForModel = Path.GetDirectoryName(rootTocFileModel.Key).Replace("\\", "/");

// Omit the files that are outside the docfx base directory.
var fileNames = sourceFilePaths
.Where(s => !Path.GetRelativePath(folderPathForModel, 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("\\", "/");

// If the folder has a toc available use it.
var (folderHasToc, tocToProcess) = TryGetOrCreateToc(tocCache, folderToProcess, virtualTocs);

// Link the toc we are processing, back to a parent.
// Look for a toc one level up until we find the root toc.
LinkToParentToc(tocCache, folderToProcess, tocToProcess, virtualTocs, folderHasToc);

// If the toc we currently processed didnot have auto enabled.
// There is no need to populate the toc, move on.
if (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.Equals(filePath) || i.Href.Equals(Path.GetFileName(filePath)))).Any())
{
var item = new TocItemViewModel();
item.Name = fileNameWithoutExtension;
item.Href = filePath;
tocToProcess.Items.Add(item);
}
}
}
}
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