Skip to content

Commit c2e4d62

Browse files
committed
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.
1 parent 34baf43 commit c2e4d62

File tree

15 files changed

+291
-143
lines changed

15 files changed

+291
-143
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+
var document = parser.ParseAsync(SourceFile, frontMatter, default).GetAwaiter().GetResult();
42+
if (!SourceFile.Exists)
43+
{
44+
_parsed = true;
45+
return null;
46+
}
47+
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: 67 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,73 @@ private void ReadDocumentInstructions(MarkdownDocument document)
191200
else if (Title.AsSpan().ReplaceSubstitutions(subs, out var replacement))
192201
Title = replacement;
193202

194-
var contents = document
203+
var toc = GetAnchors(_set, MarkdownParser, YamlFrontMatter, document, subs, out var anchors);
204+
205+
_tableOfContent.Clear();
206+
foreach (var t in toc)
207+
_tableOfContent[t.Slug] = t;
208+
209+
210+
foreach (var label in anchors)
211+
_ = _anchors.Add(label);
212+
213+
_instructionsParsed = true;
214+
}
215+
216+
public static List<PageTocItem> GetAnchors(
217+
DocumentationSet set,
218+
MarkdownParser parser,
219+
YamlFrontMatter? frontMatter,
220+
MarkdownDocument document,
221+
IReadOnlyDictionary<string, string> subs,
222+
out string[] anchors)
223+
{
224+
var includeBlocks = document.Descendants<IncludeBlock>().ToArray();
225+
var includes = includeBlocks
226+
.Select(i =>
227+
{
228+
var path = i.IncludePathRelative;
229+
if (path is null
230+
|| !set.FlatMappedFiles.TryGetValue(path, out var file)
231+
|| file is not SnippetFile snippet)
232+
return null;
233+
234+
return snippet.GetAnchors(set, parser, frontMatter);
235+
})
236+
.Where(i => i is not null)
237+
.ToArray();
238+
239+
var includedTocs = includes.SelectMany(i => i!.TableOfContentItems).ToArray();
240+
var toc = document
195241
.Descendants<HeadingBlock>()
196242
.Where(block => block is { Level: >= 2 })
197243
.Select(h => (h.GetData("header") as string, h.GetData("anchor") as string, h.Level))
198244
.Select(h =>
199245
{
200246
var header = h.Item1!.StripMarkdown();
201-
if (header.AsSpan().ReplaceSubstitutions(subs, out var replacement))
202-
header = replacement;
203247
return new PageTocItem { Heading = header, Slug = (h.Item2 ?? header).Slugify(), Level = h.Level };
204248
})
249+
.Concat(includedTocs)
250+
.Select(toc => subs.Count == 0
251+
? toc
252+
: toc.Heading.AsSpan().ReplaceSubstitutions(subs, out var r)
253+
? toc with { Heading = r }
254+
: toc)
205255
.ToList();
206256

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;
257+
var includedAnchors = includes.SelectMany(i => i!.Anchors).ToArray();
258+
anchors =
259+
[
260+
..document.Descendants<DirectiveBlock>()
261+
.Select(b => b.CrossReferenceName)
262+
.Where(l => !string.IsNullOrWhiteSpace(l))
263+
.Select(s => s.Slugify())
264+
.Concat(document.Descendants<InlineAnchor>().Select(a => a.Anchor))
265+
.Concat(toc.Select(t => t.Slug))
266+
.Where(anchor => !string.IsNullOrEmpty(anchor))
267+
.Concat(includedAnchors)
268+
];
269+
return toc;
224270
}
225271

226272
private YamlFrontMatter ProcessYamlFrontMatter(MarkdownDocument document)

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? IncludePathRelative { 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+
IncludePathRelative = Path.GetRelativePath(includeFrom, 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; }
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
module ``directive elements``.``include directive``
2+
3+
open Swensen.Unquote
4+
open Xunit
5+
open authoring
6+
open authoring.MarkdownDocumentAssertions
7+
8+
type ``include hoists anchors and table of contents`` () =
9+
10+
static let generator = Setup.Generate [
11+
Index """
12+
# A Document that lives at the root
13+
14+
:::{include} _snippets/my-snippet.md
15+
:::
16+
"""
17+
Snippet "_snippets/my-snippet.md" """
18+
## header from snippet [aa]
19+
20+
"""
21+
Markdown "test-links.md" """
22+
# parent.md
23+
24+
## some header
25+
[link to root with included anchor](index.md#aa)
26+
"""
27+
]
28+
29+
[<Fact>]
30+
let ``validate index.md HTML includes snippet`` () =
31+
generator |> converts "index.md" |> toHtml """
32+
<h1>A Document that lives at the root</h1>
33+
<div class="heading-wrapper" id="aa">
34+
<h2><a class="headerlink" href="#aa">header from snippet</a></h2>
35+
</div>
36+
"""
37+
38+
[<Fact>]
39+
let ``validate test-links.md HTML includes snippet`` () =
40+
generator |> converts "test-links.md" |> toHtml """
41+
<h1>parent.md</h1>
42+
<div class="heading-wrapper" id="some-header">
43+
<h2><a class="headerlink" href="#some-header">some header</a></h2>
44+
</div>
45+
<p><a href="/#aa">link to root with included anchor</a></p>
46+
"""
47+
48+
[<Fact>]
49+
let ``validate index.md includes table of contents`` () =
50+
let page = generator |> converts "index.md" |> markdownFile
51+
test <@ page.TableOfContents.Count = 1 @>
52+
test <@ page.TableOfContents.ContainsKey("aa") @>
53+
54+
[<Fact>]
55+
let ``has no errors`` () = generator |> hasNoErrors
56+
57+

0 commit comments

Comments
 (0)