Skip to content

Commit e723f2c

Browse files
authored
Merge branch 'main' into feature/redesign-headings
2 parents 7dbd234 + 3dbf8e9 commit e723f2c

29 files changed

+532
-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: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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+
private readonly HashSet<string> _declaredRepositories = new();
26+
27+
public static LinkReference Deserialize(string json) =>
28+
JsonSerializer.Deserialize(json, SourceGenerationContext.Default.LinkReference)!;
29+
30+
public async Task FetchLinks()
31+
{
32+
using var client = new HttpClient();
33+
var dictionary = new Dictionary<string, LinkReference>();
34+
foreach (var link in _links)
35+
{
36+
_declaredRepositories.Add(link);
37+
try
38+
{
39+
var url = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/{link}/main/links.json";
40+
_logger.LogInformation($"Fetching {url}");
41+
var json = await client.GetStringAsync(url);
42+
var linkReference = Deserialize(json);
43+
dictionary.Add(link, linkReference);
44+
}
45+
catch when (link == "docs-content")
46+
{
47+
throw;
48+
}
49+
catch when (link != "docs-content")
50+
{
51+
// TODO: ignored for now while we wait for all links.json files to populate
52+
}
53+
}
54+
_linkReferences = dictionary.ToFrozenDictionary();
55+
}
56+
57+
public bool TryResolve(Action<string> errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) =>
58+
TryResolve(errorEmitter, _declaredRepositories, _linkReferences, crossLinkUri, out resolvedUri);
59+
60+
private static Uri BaseUri { get; } = new Uri("https://docs-v3-preview.elastic.dev");
61+
62+
public static bool TryResolve(Action<string> errorEmitter, HashSet<string> declaredRepositories, IDictionary<string, LinkReference> lookup, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri)
63+
{
64+
resolvedUri = null;
65+
if (crossLinkUri.Scheme == "docs-content")
66+
{
67+
if (!lookup.TryGetValue(crossLinkUri.Scheme, out var linkReference))
68+
{
69+
errorEmitter($"'{crossLinkUri.Scheme}' is not declared as valid cross link repository in docset.yml under cross_links");
70+
return false;
71+
}
72+
return TryFullyValidate(errorEmitter, linkReference, crossLinkUri, out resolvedUri);
73+
}
74+
75+
// TODO this is temporary while we wait for all links.json files to be published
76+
if (!declaredRepositories.Contains(crossLinkUri.Scheme))
77+
{
78+
errorEmitter($"'{crossLinkUri.Scheme}' is not declared as valid cross link repository in docset.yml under cross_links");
79+
return false;
80+
}
81+
82+
var lookupPath = crossLinkUri.AbsolutePath.TrimStart('/');
83+
var path = ToTargetUrlPath(lookupPath);
84+
if (!string.IsNullOrEmpty(crossLinkUri.Fragment))
85+
path += crossLinkUri.Fragment;
86+
87+
var branch = GetBranch(crossLinkUri);
88+
resolvedUri = new Uri(BaseUri, $"elastic/{crossLinkUri.Scheme}/tree/{branch}/{path}");
89+
return true;
90+
}
91+
92+
private static bool TryFullyValidate(Action<string> errorEmitter, LinkReference linkReference, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri)
93+
{
94+
resolvedUri = null;
95+
var lookupPath = crossLinkUri.AbsolutePath.TrimStart('/');
96+
if (string.IsNullOrEmpty(lookupPath) && crossLinkUri.Host.EndsWith(".md"))
97+
lookupPath = crossLinkUri.Host;
98+
99+
if (!linkReference.Links.TryGetValue(lookupPath, out var link))
100+
{
101+
errorEmitter($"'{lookupPath}' is not a valid link in the '{crossLinkUri.Scheme}' cross link repository.");
102+
return false;
103+
}
104+
105+
var path = ToTargetUrlPath(lookupPath);
106+
107+
if (!string.IsNullOrEmpty(crossLinkUri.Fragment))
108+
{
109+
if (link.Anchors is null)
110+
{
111+
errorEmitter($"'{lookupPath}' does not have any anchors so linking to '{crossLinkUri.Fragment}' is impossible.");
112+
return false;
113+
}
114+
115+
if (!link.Anchors.Contains(crossLinkUri.Fragment.TrimStart('#')))
116+
{
117+
errorEmitter($"'{lookupPath}' has no anchor named: '{crossLinkUri.Fragment}'.");
118+
return false;
119+
}
120+
path += crossLinkUri.Fragment;
121+
}
122+
123+
var branch = GetBranch(crossLinkUri);
124+
resolvedUri = new Uri(BaseUri, $"elastic/{crossLinkUri.Scheme}/tree/{branch}/{path}");
125+
return true;
126+
}
127+
128+
/// Hardcoding these for now, we'll have an index.json pointing to all links.json files
129+
/// at some point from which we can query the branch soon.
130+
private static string GetBranch(Uri crossLinkUri)
131+
{
132+
var branch = crossLinkUri.Scheme switch
133+
{
134+
"docs-content" => "main",
135+
_ => "main"
136+
};
137+
return branch;
138+
}
139+
140+
141+
private static string ToTargetUrlPath(string lookupPath)
142+
{
143+
//https://docs-v3-preview.elastic.dev/elastic/docs-content/tree/main/cloud-account/change-your-password
144+
var path = lookupPath.Replace(".md", "");
145+
if (path.EndsWith("/index"))
146+
path = path.Substring(0, path.Length - 6);
147+
if (path == "index")
148+
path = string.Empty;
149+
return path;
150+
}
151+
}

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

0 commit comments

Comments
 (0)