Skip to content

Commit 0482d50

Browse files
authored
Refactor TOC handling and support nested TOC configurations (#749)
Introduced `TableOfContentsConfiguration` to refactor how TOC files are parsed and improve code modularity. Added support for a `max_toc_depth` parameter to restrict TOC nesting depth. Updated configurations and comments to clarify support for nested TOCs and modified file paths accordingly.
1 parent 6b7b570 commit 0482d50

File tree

8 files changed

+287
-219
lines changed

8 files changed

+287
-219
lines changed

docs/_docset.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
project: 'doc-builder'
2+
max_toc_depth: 2
23
cross_links:
34
- docs-content
45
exclude:
@@ -92,8 +93,9 @@ toc:
9293
children:
9394
- file: index.md
9495
- file: content-patterns.md
95-
# nested TOCs are only allowed from docset.yml
96+
# nested TOCs are only allowed from docset.yml by default
9697
# to prevent them from being nested deeply arbitrarily
98+
# use max_toc_depth to allow deeper nesting (Expert mode, consult with docs team)
9799
- toc: development
98100
- folder: testing
99101
children:
File renamed without changes.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
toc:
2+
- file: link-validation.md

docs/development/toc.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
toc:
22
- file: index.md
3-
- file: link-validation.md
3+
- toc: link-validation

src/Elastic.Markdown/BuildContext.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ public BuildContext(DiagnosticsCollector collector, IFileSystem readFileSystem,
7777
DocumentationSourceDirectory = ConfigurationPath.Directory!;
7878

7979
Git = GitCheckoutInformation.Create(DocumentationSourceDirectory, ReadFileSystem);
80-
Configuration = new ConfigurationFile(ConfigurationPath, DocumentationSourceDirectory, this);
80+
Configuration = new ConfigurationFile(this);
8181

8282
}
8383

src/Elastic.Markdown/IO/Configuration/ConfigurationFile.cs

Lines changed: 34 additions & 216 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,39 @@
22
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information
44

5-
using System.IO.Abstractions;
6-
using System.Runtime.InteropServices;
75
using DotNet.Globbing;
86
using Elastic.Markdown.Diagnostics;
97
using Elastic.Markdown.Extensions;
108
using Elastic.Markdown.Extensions.DetectionRules;
119
using Elastic.Markdown.IO.State;
12-
using YamlDotNet.RepresentationModel;
1310

1411
namespace Elastic.Markdown.IO.Configuration;
1512

1613
public record ConfigurationFile : DocumentationFile
1714
{
18-
private readonly IDirectoryInfo _rootPath;
1915
private readonly BuildContext _context;
20-
private readonly int _depth;
16+
2117
public string? Project { get; }
18+
2219
public Glob[] Exclude { get; } = [];
23-
public bool SoftLineEndings { get; }
2420

2521
public string[] CrossLinkRepositories { get; } = [];
2622

23+
/// The maximum depth `toc.yml` files may appear
24+
public int MaxTocDepth { get; } = 1;
25+
2726
public EnabledExtensions Extensions { get; } = new([]);
27+
2828
public IReadOnlyCollection<IDocsBuilderExtension> EnabledExtensions { get; } = [];
2929

3030
public IReadOnlyCollection<ITocItem> TableOfContents { get; } = [];
3131

32+
public HashSet<string> Files { get; } = new(StringComparer.OrdinalIgnoreCase);
33+
3234
public Dictionary<string, LinkRedirect>? Redirects { get; }
3335

34-
public HashSet<string> Files { get; } = new(StringComparer.OrdinalIgnoreCase);
3536
public HashSet<string> ImplicitFolders { get; } = new(StringComparer.OrdinalIgnoreCase);
37+
3638
public Glob[] Globs { get; } = [];
3739

3840
private readonly Dictionary<string, string> _substitutions = new(StringComparer.OrdinalIgnoreCase);
@@ -42,19 +44,18 @@ public record ConfigurationFile : DocumentationFile
4244
private FeatureFlags? _featureFlags;
4345
public FeatureFlags Features => _featureFlags ??= new FeatureFlags(_features);
4446

45-
public ConfigurationFile(IFileInfo sourceFile, IDirectoryInfo rootPath, BuildContext context, int depth = 0, string parentPath = "")
46-
: base(sourceFile, rootPath)
47+
public ConfigurationFile(BuildContext context)
48+
: base(context.ConfigurationPath, context.DocumentationSourceDirectory)
4749
{
48-
_rootPath = rootPath;
4950
_context = context;
50-
_depth = depth;
51-
if (!sourceFile.Exists)
51+
if (!context.ConfigurationPath.Exists)
5252
{
5353
Project = "unknown";
54-
context.EmitWarning(sourceFile, "No configuration file found");
54+
context.EmitWarning(context.ConfigurationPath, "No configuration file found");
5555
return;
5656
}
5757

58+
var sourceFile = context.ConfigurationPath;
5859
var redirectFileName = sourceFile.Name.StartsWith('_') ? "_redirects.yml" : "redirects.yml";
5960
var redirectFileInfo = sourceFile.FileSystem.FileInfo.New(Path.Combine(sourceFile.Directory!.FullName, redirectFileName));
6061
var redirectFile = new RedirectFile(redirectFileInfo, _context);
@@ -70,11 +71,12 @@ public ConfigurationFile(IFileInfo sourceFile, IDirectoryInfo rootPath, BuildCon
7071
case "project":
7172
Project = reader.ReadString(entry.Entry);
7273
break;
73-
case "soft_line_endings":
74-
SoftLineEndings = bool.TryParse(reader.ReadString(entry.Entry), out var softLineEndings) && softLineEndings;
74+
case "max_toc_depth":
75+
MaxTocDepth = int.TryParse(reader.ReadString(entry.Entry), out var maxTocDepth) ? maxTocDepth : 1;
7576
break;
7677
case "exclude":
77-
Exclude = [.. YamlStreamReader.ReadStringArray(entry.Entry).Select(Glob.Parse)];
78+
var excludes = YamlStreamReader.ReadStringArray(entry.Entry);
79+
Exclude = [.. excludes.Where(s => !string.IsNullOrEmpty(s)).Select(Glob.Parse)];
7880
break;
7981
case "cross_links":
8082
CrossLinkRepositories = [.. YamlStreamReader.ReadStringArray(entry.Entry)];
@@ -87,15 +89,7 @@ public ConfigurationFile(IFileInfo sourceFile, IDirectoryInfo rootPath, BuildCon
8789
_substitutions = reader.ReadDictionary(entry.Entry);
8890
break;
8991
case "toc":
90-
if (depth > 1)
91-
{
92-
reader.EmitError($"toc.yml files may only be linked from docset.yml", entry.Key);
93-
break;
94-
}
95-
96-
var entries = ReadChildren(reader, entry.Entry, parentPath);
97-
98-
TableOfContents = entries;
92+
// read this later
9993
break;
10094
case "features":
10195
_features = reader.ReadDictionary(entry.Entry).ToDictionary(k => k.Key, v => bool.Parse(v.Value), StringComparer.OrdinalIgnoreCase);
@@ -108,6 +102,21 @@ public ConfigurationFile(IFileInfo sourceFile, IDirectoryInfo rootPath, BuildCon
108102
break;
109103
}
110104
}
105+
106+
//we read it twice to ensure we read 'toc' last
107+
reader = new YamlStreamReader(sourceFile, _context);
108+
foreach (var entry in reader.Read())
109+
{
110+
switch (entry.Key)
111+
{
112+
case "toc":
113+
var toc = new TableOfContentsConfiguration(this, _context, 0, "");
114+
var entries = toc.ReadChildren(reader, entry.Entry);
115+
TableOfContents = entries;
116+
Files = toc.Files; //side-effect ripe for refactor
117+
break;
118+
}
119+
}
111120
}
112121
catch (Exception e)
113122
{
@@ -135,195 +144,4 @@ private IReadOnlyCollection<IDocsBuilderExtension> InstantiateExtensions()
135144
}
136145

137146

138-
private List<ITocItem> ReadChildren(YamlStreamReader reader, KeyValuePair<YamlNode, YamlNode> entry, string parentPath)
139-
{
140-
var entries = new List<ITocItem>();
141-
if (entry.Value is not YamlSequenceNode sequence)
142-
{
143-
if (entry.Key is YamlScalarNode scalarKey)
144-
{
145-
var key = scalarKey.Value;
146-
reader.EmitWarning($"'{key}' is not an array");
147-
}
148-
else
149-
reader.EmitWarning($"'{entry.Key}' is not an array");
150-
151-
return entries;
152-
}
153-
154-
entries.AddRange(
155-
sequence.Children.OfType<YamlMappingNode>()
156-
.SelectMany(tocEntry => ReadChild(reader, tocEntry, parentPath) ?? [])
157-
);
158-
159-
return entries;
160-
}
161-
162-
private IEnumerable<ITocItem>? ReadChild(YamlStreamReader reader, YamlMappingNode tocEntry, string parentPath)
163-
{
164-
string? file = null;
165-
string? folder = null;
166-
string? detectionRules = null;
167-
ConfigurationFile? toc = null;
168-
var fileFound = false;
169-
var folderFound = false;
170-
var detectionRulesFound = false;
171-
var hiddenFile = false;
172-
var inNav = false;
173-
IReadOnlyCollection<ITocItem>? children = null;
174-
foreach (var entry in tocEntry.Children)
175-
{
176-
var key = ((YamlScalarNode)entry.Key).Value;
177-
switch (key)
178-
{
179-
case "toc":
180-
toc = ReadNestedToc(reader, entry, out fileFound);
181-
break;
182-
case "in_nav":
183-
if (!bool.TryParse(reader.ReadString(entry), out inNav))
184-
throw new ArgumentException("in_nav must be a boolean");
185-
break;
186-
case "hidden":
187-
case "file":
188-
hiddenFile = key == "hidden";
189-
file = ReadFile(reader, entry, parentPath, out fileFound);
190-
break;
191-
case "folder":
192-
folder = ReadFolder(reader, entry, parentPath, out folderFound);
193-
parentPath += $"{Path.DirectorySeparatorChar}{folder}";
194-
break;
195-
case "detection_rules":
196-
if (Extensions.IsDetectionRulesEnabled)
197-
{
198-
detectionRules = ReadDetectionRules(reader, entry, parentPath, out detectionRulesFound);
199-
parentPath += $"{Path.DirectorySeparatorChar}{folder}";
200-
}
201-
break;
202-
case "children":
203-
children = ReadChildren(reader, entry, parentPath);
204-
break;
205-
}
206-
}
207-
208-
if (toc is not null)
209-
{
210-
foreach (var f in toc.Files)
211-
_ = Files.Add(f);
212-
213-
return [new FolderReference($"{parentPath}".TrimStart(Path.DirectorySeparatorChar), folderFound, inNav, toc.TableOfContents)];
214-
}
215-
216-
if (file is not null)
217-
{
218-
if (detectionRules is not null)
219-
{
220-
if (children is not null)
221-
reader.EmitError($"'detection_rules' is not allowed to have 'children'", tocEntry);
222-
223-
if (!detectionRulesFound)
224-
{
225-
reader.EmitError($"'detection_rules' folder {parentPath} is not found, skipping'", tocEntry);
226-
children = [];
227-
}
228-
else
229-
{
230-
var extension = EnabledExtensions.OfType<DetectionRulesDocsBuilderExtension>().First();
231-
children = extension.CreateTableOfContentItems(parentPath, detectionRules, Files);
232-
}
233-
}
234-
return [new FileReference($"{parentPath}{Path.DirectorySeparatorChar}{file}".TrimStart(Path.DirectorySeparatorChar), fileFound, hiddenFile, children ?? [])];
235-
}
236-
237-
if (folder is not null)
238-
{
239-
if (children is null)
240-
_ = ImplicitFolders.Add(parentPath.TrimStart(Path.DirectorySeparatorChar));
241-
242-
return [new FolderReference($"{parentPath}".TrimStart(Path.DirectorySeparatorChar), folderFound, inNav, children ?? [])];
243-
}
244-
245-
return null;
246-
}
247-
248-
private string? ReadFolder(YamlStreamReader reader, KeyValuePair<YamlNode, YamlNode> entry, string parentPath, out bool found)
249-
{
250-
found = false;
251-
var folder = reader.ReadString(entry);
252-
if (folder is not null)
253-
{
254-
var path = Path.Combine(_rootPath.FullName, parentPath.TrimStart(Path.DirectorySeparatorChar), folder);
255-
if (!_context.ReadFileSystem.DirectoryInfo.New(path).Exists)
256-
reader.EmitError($"Directory '{path}' does not exist", entry.Key);
257-
else
258-
found = true;
259-
}
260-
261-
return folder;
262-
}
263-
264-
private string? ReadDetectionRules(YamlStreamReader reader, KeyValuePair<YamlNode, YamlNode> entry, string parentPath, out bool found)
265-
{
266-
found = false;
267-
var folder = reader.ReadString(entry);
268-
if (folder is not null)
269-
{
270-
var path = Path.Combine(_rootPath.FullName, parentPath.TrimStart(Path.DirectorySeparatorChar), folder);
271-
if (!_context.ReadFileSystem.DirectoryInfo.New(path).Exists)
272-
reader.EmitError($"Directory '{path}' does not exist", entry.Key);
273-
else
274-
found = true;
275-
}
276-
277-
return folder;
278-
}
279-
280-
private string? ReadFile(YamlStreamReader reader, KeyValuePair<YamlNode, YamlNode> entry, string parentPath, out bool found)
281-
{
282-
found = false;
283-
var file = reader.ReadString(entry);
284-
if (file is null)
285-
return null;
286-
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
287-
file = file.Replace('/', Path.DirectorySeparatorChar);
288-
289-
var path = Path.Combine(_rootPath.FullName, parentPath.TrimStart(Path.DirectorySeparatorChar), file);
290-
if (!_context.ReadFileSystem.FileInfo.New(path).Exists)
291-
reader.EmitError($"File '{path}' does not exist", entry.Key);
292-
else
293-
found = true;
294-
_ = Files.Add((parentPath + Path.DirectorySeparatorChar + file).TrimStart(Path.DirectorySeparatorChar));
295-
296-
return file;
297-
}
298-
299-
private ConfigurationFile? ReadNestedToc(YamlStreamReader reader, KeyValuePair<YamlNode, YamlNode> entry, out bool found)
300-
{
301-
found = false;
302-
var tocPath = reader.ReadString(entry);
303-
if (tocPath is null)
304-
{
305-
reader.EmitError($"Empty toc: reference", entry.Key);
306-
return null;
307-
}
308-
309-
var rootPath = _context.ReadFileSystem.DirectoryInfo.New(Path.Combine(_rootPath.FullName, tocPath));
310-
var path = Path.Combine(rootPath.FullName, "toc.yml");
311-
var source = _context.ReadFileSystem.FileInfo.New(path);
312-
313-
var errorMessage = $"Nested toc: '{source.Directory}' directory has no toc.yml or _toc.yml file";
314-
315-
if (!source.Exists)
316-
{
317-
path = Path.Combine(rootPath.FullName, "_toc.yml");
318-
source = _context.ReadFileSystem.FileInfo.New(path);
319-
}
320-
321-
if (!source.Exists)
322-
reader.EmitError(errorMessage, entry.Key);
323-
else
324-
found = true;
325-
326-
var nestedConfiguration = new ConfigurationFile(source, _rootPath, _context, _depth + 1, tocPath);
327-
return nestedConfiguration;
328-
}
329147
}

0 commit comments

Comments
 (0)