Skip to content
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