Skip to content

Commit 1a2dd84

Browse files
authored
Initial support for cross link resolving (#491)
* Initial support for cross link resolving * ensure cross_links is pluralized in config everywhere * use docs-content for cross links for now * add missing license header * ensure anchor lookups work and urls uses extensionless paths * dotnet format * fix test * remove TODO
1 parent 510683f commit 1a2dd84

29 files changed

+441
-78
lines changed

docs/docset.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
project: 'doc-builder'
2+
cross_links:
3+
- docs-content
24
# docs-builder will warn for links to external hosts not declared here
35
external_hosts:
46
- slack.com

docs/testing/cross-links.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Cross Links
22

3-
[Elasticsearch](elasticsearch://index.md)
3+
[Elasticsearch](docs-content://index.md)
44

55
[Kibana][1]
66

7-
[1]: kibana://index.md
7+
[1]: docs-content://index.md
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Collections.Frozen;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.Text.Json;
8+
using Elastic.Markdown.IO.Configuration;
9+
using Elastic.Markdown.IO.State;
10+
using Microsoft.Extensions.Logging;
11+
12+
namespace Elastic.Markdown.CrossLinks;
13+
14+
public interface ICrossLinkResolver
15+
{
16+
Task FetchLinks();
17+
bool TryResolve(Action<string> errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri);
18+
}
19+
20+
public class CrossLinkResolver(ConfigurationFile configuration, ILoggerFactory logger) : ICrossLinkResolver
21+
{
22+
private readonly string[] _links = configuration.CrossLinkRepositories;
23+
private FrozenDictionary<string, LinkReference> _linkReferences = new Dictionary<string, LinkReference>().ToFrozenDictionary();
24+
private readonly ILogger _logger = logger.CreateLogger(nameof(CrossLinkResolver));
25+
26+
public static LinkReference Deserialize(string json) =>
27+
JsonSerializer.Deserialize(json, SourceGenerationContext.Default.LinkReference)!;
28+
29+
public async Task FetchLinks()
30+
{
31+
using var client = new HttpClient();
32+
var dictionary = new Dictionary<string, LinkReference>();
33+
foreach (var link in _links)
34+
{
35+
var url = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/{link}/main/links.json";
36+
_logger.LogInformation($"Fetching {url}");
37+
var json = await client.GetStringAsync(url);
38+
var linkReference = Deserialize(json);
39+
dictionary.Add(link, linkReference);
40+
}
41+
_linkReferences = dictionary.ToFrozenDictionary();
42+
}
43+
44+
public bool TryResolve(Action<string> errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) =>
45+
TryResolve(errorEmitter, _linkReferences, crossLinkUri, out resolvedUri);
46+
47+
private static Uri BaseUri { get; } = new Uri("https://docs-v3-preview.elastic.dev");
48+
49+
public static bool TryResolve(Action<string> errorEmitter, IDictionary<string, LinkReference> lookup, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri)
50+
{
51+
resolvedUri = null;
52+
if (!lookup.TryGetValue(crossLinkUri.Scheme, out var linkReference))
53+
{
54+
errorEmitter($"'{crossLinkUri.Scheme}' is not declared as valid cross link repository in docset.yml under cross_links");
55+
return false;
56+
}
57+
var lookupPath = crossLinkUri.AbsolutePath.TrimStart('/');
58+
if (string.IsNullOrEmpty(lookupPath) && crossLinkUri.Host.EndsWith(".md"))
59+
lookupPath = crossLinkUri.Host;
60+
61+
if (!linkReference.Links.TryGetValue(lookupPath, out var link))
62+
{
63+
errorEmitter($"'{lookupPath}' is not a valid link in the '{crossLinkUri.Scheme}' cross link repository.");
64+
return false;
65+
}
66+
67+
//https://docs-v3-preview.elastic.dev/elastic/docs-content/tree/main/cloud-account/change-your-password
68+
var path = lookupPath.Replace(".md", "");
69+
if (path.EndsWith("/index"))
70+
path = path.Substring(0, path.Length - 6);
71+
if (path == "index")
72+
path = string.Empty;
73+
74+
if (!string.IsNullOrEmpty(crossLinkUri.Fragment))
75+
{
76+
if (link.Anchors is null)
77+
{
78+
errorEmitter($"'{lookupPath}' does not have any anchors so linking to '{crossLinkUri.Fragment}' is impossible.");
79+
return false;
80+
}
81+
82+
if (!link.Anchors.Contains(crossLinkUri.Fragment.TrimStart('#')))
83+
{
84+
errorEmitter($"'{lookupPath}' has no anchor named: '{crossLinkUri.Fragment}'.");
85+
return false;
86+
}
87+
path += crossLinkUri.Fragment;
88+
}
89+
90+
resolvedUri = new Uri(BaseUri, $"elastic/{crossLinkUri.Scheme}/tree/main/{path}");
91+
return true;
92+
}
93+
}

src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Elastic.Markdown.Myst.Directives;
88
using Markdig.Helpers;
99
using Markdig.Parsers;
10+
using Markdig.Syntax.Inlines;
1011

1112
namespace Elastic.Markdown.Diagnostics;
1213

@@ -131,4 +132,50 @@ public static void EmitWarning(this IBlockExtension block, string message)
131132
};
132133
block.Build.Collector.Channel.Write(d);
133134
}
135+
136+
137+
public static void EmitError(this InlineProcessor processor, LinkInline inline, string message)
138+
{
139+
var url = inline.Url;
140+
var line = inline.Line + 1;
141+
var column = inline.Column;
142+
var length = url?.Length ?? 1;
143+
144+
var context = processor.GetContext();
145+
if (context.SkipValidation)
146+
return;
147+
var d = new Diagnostic
148+
{
149+
Severity = Severity.Error,
150+
File = processor.GetContext().Path.FullName,
151+
Column = column,
152+
Line = line,
153+
Message = message,
154+
Length = length
155+
};
156+
context.Build.Collector.Channel.Write(d);
157+
}
158+
159+
160+
public static void EmitWarning(this InlineProcessor processor, LinkInline inline, string message)
161+
{
162+
var url = inline.Url;
163+
var line = inline.Line + 1;
164+
var column = inline.Column;
165+
var length = url?.Length ?? 1;
166+
167+
var context = processor.GetContext();
168+
if (context.SkipValidation)
169+
return;
170+
var d = new Diagnostic
171+
{
172+
Severity = Severity.Warning,
173+
File = processor.GetContext().Path.FullName,
174+
Column = column,
175+
Line = line,
176+
Message = message,
177+
Length = length
178+
};
179+
context.Build.Collector.Channel.Write(d);
180+
}
134181
}

src/Elastic.Markdown/DocumentationGenerator.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
// Licensed to Elasticsearch B.V under one or more agreements.
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
4+
45
using System.IO.Abstractions;
56
using System.Reflection;
67
using System.Text.Json;
8+
using Elastic.Markdown.CrossLinks;
79
using Elastic.Markdown.IO;
810
using Elastic.Markdown.IO.State;
911
using Elastic.Markdown.Slices;
@@ -20,6 +22,7 @@ public class DocumentationGenerator
2022

2123
public DocumentationSet DocumentationSet { get; }
2224
public BuildContext Context { get; }
25+
public ICrossLinkResolver Resolver { get; }
2326

2427
public DocumentationGenerator(
2528
DocumentationSet docSet,
@@ -32,6 +35,7 @@ ILoggerFactory logger
3235

3336
DocumentationSet = docSet;
3437
Context = docSet.Context;
38+
Resolver = docSet.LinkResolver;
3539
HtmlWriter = new HtmlWriter(DocumentationSet, _writeFileSystem);
3640

3741
_logger.LogInformation($"Created documentation set for: {DocumentationSet.Name}");
@@ -66,6 +70,9 @@ public async Task GenerateAll(Cancel ctx)
6670
if (CompilationNotNeeded(generationState, out var offendingFiles, out var outputSeenChanges))
6771
return;
6872

73+
_logger.LogInformation($"Fetching external links");
74+
await Resolver.FetchLinks();
75+
6976
await ResolveDirectoryTree(ctx);
7077

7178
await ProcessDocumentationFiles(offendingFiles, outputSeenChanges, ctx);
@@ -77,6 +84,7 @@ public async Task GenerateAll(Cancel ctx)
7784

7885
_logger.LogInformation($"Generating documentation compilation state");
7986
await GenerateDocumentationState(ctx);
87+
8088
_logger.LogInformation($"Generating links.json");
8189
await GenerateLinkReference(ctx);
8290

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ public record ConfigurationFile : DocumentationFile
1919
public string? Project { get; }
2020
public Glob[] Exclude { get; } = [];
2121

22+
public string[] CrossLinkRepositories { get; } = [];
23+
2224
public IReadOnlyCollection<ITocItem> TableOfContents { get; } = [];
2325

2426
public HashSet<string> Files { get; } = new(StringComparer.OrdinalIgnoreCase);
@@ -72,6 +74,9 @@ public ConfigurationFile(IFileInfo sourceFile, IDirectoryInfo rootPath, BuildCon
7274
.Select(Glob.Parse)
7375
.ToArray();
7476
break;
77+
case "cross_links":
78+
CrossLinkRepositories = ReadStringArray(entry).ToArray();
79+
break;
7580
case "subs":
7681
_substitutions = ReadDictionary(entry);
7782
break;

src/Elastic.Markdown/IO/DocumentationSet.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
using System.Collections.Frozen;
66
using System.IO.Abstractions;
7+
using Elastic.Markdown.CrossLinks;
78
using Elastic.Markdown.Diagnostics;
89
using Elastic.Markdown.IO.Configuration;
910
using Elastic.Markdown.IO.Navigation;
1011
using Elastic.Markdown.Myst;
12+
using Microsoft.Extensions.Logging;
1113

1214
namespace Elastic.Markdown.IO;
1315

@@ -29,15 +31,18 @@ public class DocumentationSet
2931

3032
public MarkdownParser MarkdownParser { get; }
3133

32-
public DocumentationSet(BuildContext context)
34+
public ICrossLinkResolver LinkResolver { get; }
35+
36+
public DocumentationSet(BuildContext context, ILoggerFactory logger, ICrossLinkResolver? linkResolver = null)
3337
{
3438
Context = context;
3539
SourcePath = context.SourcePath;
3640
OutputPath = context.OutputPath;
3741
RelativeSourcePath = Path.GetRelativePath(Paths.Root.FullName, SourcePath.FullName);
3842
Configuration = new ConfigurationFile(context.ConfigurationPath, SourcePath, context);
43+
LinkResolver = linkResolver ?? new CrossLinkResolver(Configuration, logger);
3944

40-
MarkdownParser = new MarkdownParser(SourcePath, context, GetMarkdownFile, Configuration);
45+
MarkdownParser = new MarkdownParser(SourcePath, context, GetMarkdownFile, Configuration, LinkResolver);
4146

4247
Name = SourcePath.FullName;
4348
OutputStateFile = OutputPath.FileSystem.FileInfo.New(Path.Combine(OutputPath.FullName, ".doc.state"));

src/Elastic.Markdown/IO/State/LinkReference.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ public record LinkMetadata
1111
{
1212
[JsonPropertyName("anchors")]
1313
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
14-
public required string[]? Anchors { get; init; } = [];
14+
public string[]? Anchors { get; init; } = [];
1515

1616
[JsonPropertyName("hidden")]
1717
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
18-
public required bool Hidden { get; init; }
18+
public bool Hidden { get; init; }
1919
}
2020

2121
public record LinkReference
@@ -26,7 +26,7 @@ public record LinkReference
2626
[JsonPropertyName("url_path_prefix")]
2727
public required string? UrlPathPrefix { get; init; }
2828

29-
/// Mapping of relative filepath and all the page's anchors for deeplinks
29+
/// Mapping of relative filepath and all the page's anchors for deep links
3030
[JsonPropertyName("links")]
3131
public required Dictionary<string, LinkMetadata> Links { get; init; } = [];
3232

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,9 @@ private void WriteIncludeBlock(HtmlRenderer renderer, IncludeBlock block)
225225
if (!block.Found || block.IncludePath is null)
226226
return;
227227

228-
var parser = new MarkdownParser(block.DocumentationSourcePath, block.Build, block.GetDocumentationFile,
229-
block.Configuration);
228+
var parser = new MarkdownParser(
229+
block.DocumentationSourcePath, block.Build, block.GetDocumentationFile,
230+
block.Configuration, block.LinksResolver);
230231
var file = block.FileSystem.FileInfo.New(block.IncludePath);
231232
var document = parser.ParseAsync(file, block.FrontMatter, default).GetAwaiter().GetResult();
232233
var html = document.ToHtml(MarkdownParser.Pipeline);
@@ -240,7 +241,10 @@ private void WriteSettingsBlock(HtmlRenderer renderer, SettingsBlock block)
240241
if (!block.Found || block.IncludePath is null)
241242
return;
242243

243-
var parser = new MarkdownParser(block.DocumentationSourcePath, block.Build, block.GetDocumentationFile, block.Configuration);
244+
var parser = new MarkdownParser(
245+
block.DocumentationSourcePath, block.Build, block.GetDocumentationFile, block.Configuration
246+
, block.LinksResolver
247+
);
244248

245249
var file = block.FileSystem.FileInfo.New(block.IncludePath);
246250

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
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.CrossLinks;
56
using Elastic.Markdown.Diagnostics;
67
using Elastic.Markdown.IO;
78
using Elastic.Markdown.IO.Configuration;
@@ -25,6 +26,8 @@ public class IncludeBlock(DirectiveBlockParser parser, ParserContext context) :
2526

2627
public ConfigurationFile Configuration { get; } = context.Configuration;
2728

29+
public ICrossLinkResolver LinksResolver { get; } = context.LinksResolver;
30+
2831
public IFileSystem FileSystem { get; } = context.Build.ReadFileSystem;
2932

3033
public IDirectoryInfo DocumentationSourcePath { get; } = context.Parser.SourcePath;
@@ -40,7 +43,6 @@ public class IncludeBlock(DirectiveBlockParser parser, ParserContext context) :
4043
public string? Caption { get; private set; }
4144
public string? Label { get; private set; }
4245

43-
4446
//TODO add all options from
4547
//https://mystmd.org/guide/directives#directive-include
4648
public override void FinalizeAndValidate(ParserContext context)

0 commit comments

Comments
 (0)