Skip to content

Commit a853a3b

Browse files
authored
Support anchors and table of contents from included files (#585)
* Support anchors and table of contents from included files If an included snippet defines it's own headers and anchors they did not contribute to the pages anchors and headers. -- page.md ```markdown :::{include} _snippet/snippet.md ::: ``` This resulted in link check failures to `page.md#included-from-snippet`, as well as the headers from a snippet not appearing on the page's table of contents. This PR also extends our testing framework by allowing to collect data from the conversion using `IConversionCollector`. The `authoring` test framework uses the same `DocumentationGenerator` that `docs-builder` uses. The default for `IConversionCollector` is `null` in real world usage as we don't want the added memory pressure. An upside of this is that we can now really fully test the whole HTML layout vs just the markdown HTML. Lastly this PR includes a tiny fix to DiagnosticLinkInlineParser.cs to always report the resolved path as the full path. That way folks don't need to parse all the parent up `../` instructions themselves when dealing with this error instance. * Add back AngleSharp.Diffing * ensure we lookup snippets anchored to the root * fix license headers * don't lookup anchors in includes that are not found (or are cyclical) * remove htmx tags from test assertions for now
1 parent d387acc commit a853a3b

16 files changed

+345
-73
lines changed

src/Elastic.Markdown/DocumentationGenerator.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,19 @@
99
using Elastic.Markdown.IO;
1010
using Elastic.Markdown.IO.State;
1111
using Elastic.Markdown.Slices;
12+
using Markdig.Syntax;
1213
using Microsoft.Extensions.Logging;
1314

1415
namespace Elastic.Markdown;
1516

17+
public interface IConversionCollector
18+
{
19+
void Collect(MarkdownFile file, MarkdownDocument document, string html);
20+
}
21+
1622
public class DocumentationGenerator
1723
{
24+
private readonly IConversionCollector? _conversionCollector;
1825
private readonly IFileSystem _readFileSystem;
1926
private readonly ILogger _logger;
2027
private readonly IFileSystem _writeFileSystem;
@@ -26,9 +33,11 @@ public class DocumentationGenerator
2633

2734
public DocumentationGenerator(
2835
DocumentationSet docSet,
29-
ILoggerFactory logger
36+
ILoggerFactory logger,
37+
IConversionCollector? conversionCollector = null
3038
)
3139
{
40+
_conversionCollector = conversionCollector;
3241
_readFileSystem = docSet.Context.ReadFileSystem;
3342
_writeFileSystem = docSet.Context.WriteFileSystem;
3443
_logger = logger.CreateLogger(nameof(DocumentationGenerator));
@@ -161,7 +170,7 @@ private async Task ProcessFile(HashSet<string> offendingFiles, DocumentationFile
161170
_logger.LogTrace("--> {FileFullPath}", file.SourceFile.FullName);
162171
var outputFile = OutputFile(file.RelativePath);
163172
if (file is MarkdownFile markdown)
164-
await HtmlWriter.WriteAsync(outputFile, markdown, token);
173+
await HtmlWriter.WriteAsync(outputFile, markdown, _conversionCollector, token);
165174
else
166175
{
167176
if (outputFile.Directory is { Exists: false })

src/Elastic.Markdown/IO/DocumentationFile.cs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
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
using System.IO.Abstractions;
5+
using Elastic.Markdown.Myst;
6+
using Elastic.Markdown.Myst.FrontMatter;
7+
using Elastic.Markdown.Slices;
58

69
namespace Elastic.Markdown.IO;
710

@@ -22,4 +25,32 @@ public record ExcludedFile(IFileInfo SourceFile, IDirectoryInfo RootPath)
2225
: DocumentationFile(SourceFile, RootPath);
2326

2427
public record SnippetFile(IFileInfo SourceFile, IDirectoryInfo RootPath)
25-
: DocumentationFile(SourceFile, RootPath);
28+
: DocumentationFile(SourceFile, RootPath)
29+
{
30+
private SnippetAnchors? Anchors { get; set; }
31+
private bool _parsed;
32+
33+
public SnippetAnchors? GetAnchors(
34+
DocumentationSet set,
35+
MarkdownParser parser,
36+
YamlFrontMatter? frontMatter
37+
)
38+
{
39+
if (_parsed)
40+
return Anchors;
41+
if (!SourceFile.Exists)
42+
{
43+
_parsed = true;
44+
return null;
45+
}
46+
47+
var document = parser.MinimalParseAsync(SourceFile, default).GetAwaiter().GetResult();
48+
var toc = MarkdownFile.GetAnchors(set, parser, frontMatter, document, new Dictionary<string, string>(), out var anchors);
49+
Anchors = new SnippetAnchors(anchors, toc);
50+
_parsed = true;
51+
return Anchors;
52+
}
53+
}
54+
55+
public record SnippetAnchors(string[] Anchors, IReadOnlyCollection<PageTocItem> TableOfContentItems);
56+

src/Elastic.Markdown/IO/DocumentationSet.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,16 +187,16 @@ private DocumentationFile CreateMarkDownFile(IFileInfo file, BuildContext contex
187187
if (Configuration.Exclude.Any(g => g.IsMatch(relativePath)))
188188
return new ExcludedFile(file, SourcePath);
189189

190-
if (Configuration.Files.Contains(relativePath))
191-
return new MarkdownFile(file, SourcePath, MarkdownParser, context);
192-
193-
if (Configuration.Globs.Any(g => g.IsMatch(relativePath)))
194-
return new MarkdownFile(file, SourcePath, MarkdownParser, context);
195-
196190
// we ignore files in folders that start with an underscore
197191
if (relativePath.Contains("_snippets"))
198192
return new SnippetFile(file, SourcePath);
199193

194+
if (Configuration.Files.Contains(relativePath))
195+
return new MarkdownFile(file, SourcePath, MarkdownParser, context, this);
196+
197+
if (Configuration.Globs.Any(g => g.IsMatch(relativePath)))
198+
return new MarkdownFile(file, SourcePath, MarkdownParser, context, this);
199+
200200
// we ignore files in folders that start with an underscore
201201
if (relativePath.IndexOf("/_", StringComparison.Ordinal) > 0 || relativePath.StartsWith('_'))
202202
return new ExcludedFile(file, SourcePath);

src/Elastic.Markdown/IO/MarkdownFile.cs

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,15 @@ public record MarkdownFile : DocumentationFile
2121
{
2222
private string? _navigationTitle;
2323

24-
public MarkdownFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext context)
24+
private readonly DocumentationSet _set;
25+
26+
public MarkdownFile(
27+
IFileInfo sourceFile,
28+
IDirectoryInfo rootPath,
29+
MarkdownParser parser,
30+
BuildContext context,
31+
DocumentationSet set
32+
)
2533
: base(sourceFile, rootPath)
2634
{
2735
FileName = sourceFile.Name;
@@ -30,6 +38,7 @@ public MarkdownFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParse
3038
MarkdownParser = parser;
3139
Collector = context.Collector;
3240
_configurationFile = context.Configuration.SourceFile;
41+
_set = set;
3342
}
3443

3544
public string Id { get; } = Guid.NewGuid().ToString("N")[..8];
@@ -191,36 +200,78 @@ private void ReadDocumentInstructions(MarkdownDocument document)
191200
else if (Title.AsSpan().ReplaceSubstitutions(subs, out var replacement))
192201
Title = replacement;
193202

194-
var contents = document
203+
if (RelativePath.Contains("esql-functions-operators"))
204+
{
205+
206+
}
207+
var toc = GetAnchors(_set, MarkdownParser, YamlFrontMatter, document, subs, out var anchors);
208+
209+
_tableOfContent.Clear();
210+
foreach (var t in toc)
211+
_tableOfContent[t.Slug] = t;
212+
213+
214+
foreach (var label in anchors)
215+
_ = _anchors.Add(label);
216+
217+
_instructionsParsed = true;
218+
}
219+
220+
public static List<PageTocItem> GetAnchors(
221+
DocumentationSet set,
222+
MarkdownParser parser,
223+
YamlFrontMatter? frontMatter,
224+
MarkdownDocument document,
225+
IReadOnlyDictionary<string, string> subs,
226+
out string[] anchors)
227+
{
228+
var includeBlocks = document.Descendants<IncludeBlock>().ToArray();
229+
var includes = includeBlocks
230+
.Where(i => i.Found)
231+
.Select(i =>
232+
{
233+
var path = i.IncludePathFromSourceDirectory;
234+
if (path is null
235+
|| !set.FlatMappedFiles.TryGetValue(path, out var file)
236+
|| file is not SnippetFile snippet)
237+
return null;
238+
239+
return snippet.GetAnchors(set, parser, frontMatter);
240+
})
241+
.Where(i => i is not null)
242+
.ToArray();
243+
244+
var includedTocs = includes.SelectMany(i => i!.TableOfContentItems).ToArray();
245+
var toc = document
195246
.Descendants<HeadingBlock>()
196247
.Where(block => block is { Level: >= 2 })
197248
.Select(h => (h.GetData("header") as string, h.GetData("anchor") as string, h.Level))
198249
.Select(h =>
199250
{
200251
var header = h.Item1!.StripMarkdown();
201-
if (header.AsSpan().ReplaceSubstitutions(subs, out var replacement))
202-
header = replacement;
203252
return new PageTocItem { Heading = header, Slug = (h.Item2 ?? header).Slugify(), Level = h.Level };
204253
})
254+
.Concat(includedTocs)
255+
.Select(toc => subs.Count == 0
256+
? toc
257+
: toc.Heading.AsSpan().ReplaceSubstitutions(subs, out var r)
258+
? toc with { Heading = r }
259+
: toc)
205260
.ToList();
206261

207-
_tableOfContent.Clear();
208-
foreach (var t in contents)
209-
_tableOfContent[t.Slug] = t;
210-
211-
var anchors = document.Descendants<DirectiveBlock>()
212-
.Select(b => b.CrossReferenceName)
213-
.Where(l => !string.IsNullOrWhiteSpace(l))
214-
.Select(s => s.Slugify())
215-
.Concat(document.Descendants<InlineAnchor>().Select(a => a.Anchor))
216-
.Concat(_tableOfContent.Values.Select(t => t.Slug))
217-
.Where(anchor => !string.IsNullOrEmpty(anchor))
218-
.ToArray();
219-
220-
foreach (var label in anchors)
221-
_ = _anchors.Add(label);
222-
223-
_instructionsParsed = true;
262+
var includedAnchors = includes.SelectMany(i => i!.Anchors).ToArray();
263+
anchors =
264+
[
265+
..document.Descendants<DirectiveBlock>()
266+
.Select(b => b.CrossReferenceName)
267+
.Where(l => !string.IsNullOrWhiteSpace(l))
268+
.Select(s => s.Slugify())
269+
.Concat(document.Descendants<InlineAnchor>().Select(a => a.Anchor))
270+
.Concat(toc.Select(t => t.Slug))
271+
.Where(anchor => !string.IsNullOrEmpty(anchor))
272+
.Concat(includedAnchors)
273+
];
274+
return toc;
224275
}
225276

226277
private YamlFrontMatter ProcessYamlFrontMatter(MarkdownDocument document)

src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,6 @@ private static void WriteIncludeBlock(HtmlRenderer renderer, IncludeBlock block)
231231
var document = parser.ParseAsync(file, block.FrontMatter, default).GetAwaiter().GetResult();
232232
var html = document.ToHtml(MarkdownParser.Pipeline);
233233
_ = renderer.Write(html);
234-
//var slice = Include.Create(new IncludeViewModel { Html = html });
235-
//RenderRazorSlice(slice, renderer, block);
236234
}
237235

238236
private static void WriteSettingsBlock(HtmlRenderer renderer, SettingsBlock block)

src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public class IncludeBlock(DirectiveBlockParser parser, ParserContext context) :
3535
public YamlFrontMatter? FrontMatter { get; } = context.FrontMatter;
3636

3737
public string? IncludePath { get; private set; }
38+
public string? IncludePathFromSourceDirectory { get; private set; }
3839

3940
public bool Found { get; private set; }
4041

@@ -69,6 +70,7 @@ private void ExtractInclusionPath(ParserContext context)
6970
includeFrom = DocumentationSourcePath.FullName;
7071

7172
IncludePath = Path.Combine(includeFrom, includePath.TrimStart('/'));
73+
IncludePathFromSourceDirectory = Path.GetRelativePath(DocumentationSourcePath.FullName, IncludePath);
7274
if (FileSystem.File.Exists(IncludePath))
7375
Found = true;
7476
else

src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ private static void ValidateInternalUrl(InlineProcessor processor, string url, s
190190
if (string.IsNullOrWhiteSpace(url))
191191
return;
192192

193-
var pathOnDisk = Path.Combine(includeFrom, url.TrimStart('/'));
193+
var pathOnDisk = Path.GetFullPath(Path.Combine(includeFrom, url.TrimStart('/')));
194194
if (!context.Build.ReadFileSystem.File.Exists(pathOnDisk))
195195
processor.EmitError(link, $"`{url}` does not exist. resolved to `{pathOnDisk}");
196196
}

src/Elastic.Markdown/Slices/HtmlWriter.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// See the LICENSE file in the project root for more information
44
using System.IO.Abstractions;
55
using Elastic.Markdown.IO;
6+
using Markdig.Syntax;
67
using RazorSlices;
78

89
namespace Elastic.Markdown.Slices;
@@ -26,6 +27,11 @@ private async Task<string> RenderNavigation(MarkdownFile markdown, Cancel ctx =
2627
public async Task<string> RenderLayout(MarkdownFile markdown, Cancel ctx = default)
2728
{
2829
var document = await markdown.ParseFullAsync(ctx);
30+
return await RenderLayout(markdown, document, ctx);
31+
}
32+
33+
public async Task<string> RenderLayout(MarkdownFile markdown, MarkdownDocument document, Cancel ctx = default)
34+
{
2935
var html = MarkdownFile.CreateHtml(document);
3036
await DocumentationSet.Tree.Resolve(ctx);
3137
_renderedNavigation ??= await RenderNavigation(markdown, ctx);
@@ -57,7 +63,7 @@ public async Task<string> RenderLayout(MarkdownFile markdown, Cancel ctx = defau
5763
return await slice.RenderAsync(cancellationToken: ctx);
5864
}
5965

60-
public async Task WriteAsync(IFileInfo outputFile, MarkdownFile markdown, Cancel ctx = default)
66+
public async Task WriteAsync(IFileInfo outputFile, MarkdownFile markdown, IConversionCollector? collector, Cancel ctx = default)
6167
{
6268
if (outputFile.Directory is { Exists: false })
6369
outputFile.Directory.Create();
@@ -79,7 +85,9 @@ public async Task WriteAsync(IFileInfo outputFile, MarkdownFile markdown, Cancel
7985
: Path.Combine(dir, "index.html");
8086
}
8187

82-
var rendered = await RenderLayout(markdown, ctx);
88+
var document = await markdown.ParseFullAsync(ctx);
89+
var rendered = await RenderLayout(markdown, document, ctx);
90+
collector?.Collect(markdown, document, rendered);
8391
await writeFileSystem.File.WriteAllTextAsync(path, rendered, ctx);
8492
}
8593

src/Elastic.Markdown/Slices/_ViewModels.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public string Link(string path)
6969
}
7070
}
7171

72-
public class PageTocItem
72+
public record PageTocItem
7373
{
7474
public required string Heading { get; init; }
7575
public required string Slug { get; init; }

0 commit comments

Comments
 (0)