Skip to content

Commit 9f3dc7d

Browse files
authored
Refactor DiagnosticLinkInlineParser (#387)
1 parent ef215e8 commit 9f3dc7d

File tree

2 files changed

+136
-88
lines changed

2 files changed

+136
-88
lines changed

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

Lines changed: 134 additions & 86 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

55
using System.Collections.Immutable;
6+
using System.IO.Abstractions;
67
using Elastic.Markdown.Diagnostics;
78
using Elastic.Markdown.IO;
89
using Elastic.Markdown.Myst.Comments;
@@ -35,132 +36,179 @@ public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) { }
3536
public class DiagnosticLinkInlineParser : LinkInlineParser
3637
{
3738
// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml for a list of URI schemes
38-
// We can add more schemes as needed
3939
private static readonly ImmutableHashSet<string> ExcludedSchemes = ["http", "https", "tel", "jdbc"];
4040

4141
public override bool Match(InlineProcessor processor, ref StringSlice slice)
4242
{
4343
var match = base.Match(processor, ref slice);
44-
if (!match)
45-
return false;
46-
47-
if (processor.Inline is not LinkInline link)
44+
if (!match || processor.Inline is not LinkInline link)
4845
return match;
4946

50-
// Links in comments should not be validated
51-
// This works for the current test cases, but we might need to revisit this in case it needs some traversal
52-
if (link.Parent?.ParentBlock is CommentBlock)
47+
var context = processor.GetContext();
48+
if (IsInCommentBlock(link) || context.SkipValidation)
5349
return match;
5450

51+
ValidateAndProcessLink(processor, link, context);
52+
return match;
53+
}
54+
55+
private static bool IsInCommentBlock(LinkInline link) =>
56+
link.Parent?.ParentBlock is CommentBlock;
57+
58+
private void ValidateAndProcessLink(InlineProcessor processor, LinkInline link, ParserContext context)
59+
{
5560
var url = link.Url;
5661
var line = link.Line + 1;
5762
var column = link.Column;
5863
var length = url?.Length ?? 1;
5964

60-
var context = processor.GetContext();
61-
if (processor.GetContext().SkipValidation)
62-
return match;
65+
if (!ValidateBasicUrl(processor, url, line, column, length))
66+
return;
6367

64-
if (string.IsNullOrEmpty(url))
68+
var uri = Uri.TryCreate(url, UriKind.Absolute, out var u) ? u : null;
69+
70+
if (IsCrossLink(uri))
6571
{
66-
processor.EmitWarning(line, column, length, $"Found empty url");
67-
return match;
72+
ProcessCrossLink(link, context, line, column, length);
73+
return;
6874
}
6975

76+
if (ValidateExternalUri(processor, uri, context, line, column, length))
77+
return;
78+
79+
ProcessInternalLink(processor, link, context, line, column, length);
80+
}
81+
82+
private bool ValidateBasicUrl(InlineProcessor processor, string? url, int line, int column, int length)
83+
{
84+
if (string.IsNullOrEmpty(url))
85+
{
86+
processor.EmitWarning(line, column, length, "Found empty url");
87+
return false;
88+
}
7089
if (url.Contains("{{") || url.Contains("}}"))
7190
{
72-
processor.EmitWarning(line, column, length, "The url contains a template expression. Please do not use template expressions in links. See https://github.com/elastic/docs-builder/issues/182 for further information.");
73-
return match;
91+
processor.EmitWarning(line, column, length,
92+
"The url contains a template expression. Please do not use template expressions in links. " +
93+
"See https://github.com/elastic/docs-builder/issues/182 for further information.");
94+
return false;
7495
}
96+
return true;
97+
}
7598

76-
var uri = Uri.TryCreate(url, UriKind.Absolute, out var u) ? u : null;
77-
78-
if (IsCrossLink(uri))
79-
processor.GetContext().Build.Collector.EmitCrossLink(url!);
99+
private bool ValidateExternalUri(InlineProcessor processor, Uri? uri, ParserContext context, int line, int column, int length)
100+
{
101+
if (uri == null || !uri.Scheme.StartsWith("http"))
102+
return false;
80103

81-
if (uri != null && uri.Scheme.StartsWith("http"))
104+
var baseDomain = uri.Host == "localhost" ? "localhost" : string.Join('.', uri.Host.Split('.')[^2..]);
105+
if (!context.Configuration.ExternalLinkHosts.Contains(baseDomain))
82106
{
83-
var baseDomain = uri.Host == "localhost" ? "localhost" : string.Join('.', uri.Host.Split('.')[^2..]);
84-
if (!context.Configuration.ExternalLinkHosts.Contains(baseDomain))
85-
{
86-
processor.EmitWarning(
87-
line,
88-
column,
89-
length,
90-
$"External URI '{uri}' is not allowed. Add '{baseDomain}' to the " +
91-
$"'external_hosts' list in {context.Configuration.SourceFile} to " +
92-
"allow links to this domain.");
93-
}
94-
return match;
107+
processor.EmitWarning(
108+
line,
109+
column,
110+
length,
111+
$"External URI '{uri}' is not allowed. Add '{baseDomain}' to the " +
112+
$"'external_hosts' list in the configuration file '{context.Configuration.SourceFile}' " +
113+
"to allow links to this domain."
114+
);
95115
}
116+
return true;
117+
}
96118

97-
var includeFrom = context.Path.Directory!.FullName;
98-
if (url.StartsWith('/'))
99-
includeFrom = context.Parser.SourcePath.FullName;
119+
private static void ProcessCrossLink(LinkInline link, ParserContext context, int line, int column, int length)
120+
{
121+
var url = link.Url;
122+
if (url != null)
123+
context.Build.Collector.EmitCrossLink(url);
124+
// TODO: The link is not rendered correctly yet, will be fixed in a follow-up
125+
}
100126

101-
var anchors = url.Split('#');
102-
var anchor = anchors.Length > 1 ? anchors[1].Trim() : null;
103-
url = anchors[0];
127+
private static void ProcessInternalLink(InlineProcessor processor, LinkInline link, ParserContext context, int line, int column, int length)
128+
{
129+
var (url, anchor) = SplitUrlAndAnchor(link.Url ?? string.Empty);
130+
var includeFrom = GetIncludeFromPath(url, context);
104131

105-
if (!string.IsNullOrWhiteSpace(url))
106-
{
107-
var pathOnDisk = Path.Combine(includeFrom, url.TrimStart('/'));
108-
if ((uri is null || uri.IsFile) && !context.Build.ReadFileSystem.File.Exists(pathOnDisk))
109-
processor.EmitError(line, column, length, $"`{url}` does not exist. resolved to `{pathOnDisk}");
110-
}
111-
else
132+
ValidateInternalUrl(processor, url, includeFrom, line, column, length, context);
133+
ProcessLinkText(processor, link, context, url, anchor, line, column, length);
134+
UpdateLinkUrl(link, url, anchor, context.Build.UrlPathPrefix ?? string.Empty);
135+
}
136+
137+
private static (string url, string? anchor) SplitUrlAndAnchor(string fullUrl)
138+
{
139+
var parts = fullUrl.Split('#');
140+
return (parts[0], parts.Length > 1 ? parts[1].Trim() : null);
141+
}
142+
143+
private static string GetIncludeFromPath(string url, ParserContext context) =>
144+
url.StartsWith('/')
145+
? context.Parser.SourcePath.FullName
146+
: context.Path.Directory!.FullName;
147+
148+
private static void ValidateInternalUrl(InlineProcessor processor, string url, string includeFrom, int line, int column, int length, ParserContext context)
149+
{
150+
if (string.IsNullOrWhiteSpace(url))
151+
return;
152+
153+
var pathOnDisk = Path.Combine(includeFrom, url.TrimStart('/'));
154+
if (!context.Build.ReadFileSystem.File.Exists(pathOnDisk))
155+
processor.EmitError(line, column, length, $"`{url}` does not exist. resolved to `{pathOnDisk}");
156+
}
157+
158+
private static void ProcessLinkText(InlineProcessor processor, LinkInline link, ParserContext context, string url, string? anchor, int line, int column, int length)
159+
{
160+
if (link.FirstChild != null && string.IsNullOrEmpty(anchor))
161+
return;
162+
163+
var file = ResolveFile(context, url);
164+
var markdown = context.GetDocumentationFile?.Invoke(file) as MarkdownFile;
165+
166+
if (markdown == null)
112167
{
113-
if (string.IsNullOrEmpty(anchor))
114-
processor.EmitWarning(line, column, length, $"No url was specified for the link.");
115-
link.Url = "";
168+
processor.EmitWarning(line, column, length,
169+
$"'{url}' could not be resolved to a markdown file while creating an auto text link, '{file.FullName}' does not exist.");
170+
return;
116171
}
117172

118-
if (link.FirstChild == null || !string.IsNullOrEmpty(anchor))
173+
var title = markdown.Title;
174+
175+
if (!string.IsNullOrEmpty(anchor))
119176
{
120-
var file = string.IsNullOrWhiteSpace(url)
121-
? context.Path
122-
: url.StartsWith('/')
123-
? context.Build.ReadFileSystem.FileInfo.New(Path.Combine(context.Build.SourcePath.FullName, url.TrimStart('/')))
124-
: context.Build.ReadFileSystem.FileInfo.New(Path.Combine(context.Path.Directory!.FullName, url));
125-
var markdown = context.GetDocumentationFile?.Invoke(file) as MarkdownFile;
126-
if (markdown == null)
127-
{
128-
processor.EmitWarning(line,
129-
column,
130-
length,
131-
$"'{url}' could not be resolved to a markdown file while creating an auto text link, '{file.FullName}' does not exist.");
132-
}
133-
134-
var title = markdown?.Title;
135-
136-
if (!string.IsNullOrEmpty(anchor))
137-
{
138-
if (markdown == null || !markdown.Anchors.Contains(anchor))
139-
processor.EmitError(line, column, length, $"`{anchor}` does not exist in {markdown?.FileName}.");
140-
else if (link.FirstChild == null && markdown.TableOfContents.TryGetValue(anchor, out var heading))
141-
title += " > " + heading.Heading;
142-
143-
}
144-
145-
if (link.FirstChild == null && !string.IsNullOrEmpty(title))
146-
link.AppendChild(new LiteralInline(title));
177+
ValidateAnchor(processor, markdown, anchor, line, column, length);
178+
if (link.FirstChild == null && markdown.TableOfContents.TryGetValue(anchor, out var heading))
179+
title += " > " + heading.Heading;
147180
}
148181

182+
if (link.FirstChild == null && !string.IsNullOrEmpty(title))
183+
link.AppendChild(new LiteralInline(title));
184+
}
185+
186+
private static IFileInfo ResolveFile(ParserContext context, string url) =>
187+
string.IsNullOrWhiteSpace(url)
188+
? context.Path
189+
: url.StartsWith('/')
190+
? context.Build.ReadFileSystem.FileInfo.New(Path.Combine(context.Build.SourcePath.FullName, url.TrimStart('/')))
191+
: context.Build.ReadFileSystem.FileInfo.New(Path.Combine(context.Path.Directory!.FullName, url));
192+
193+
private static void ValidateAnchor(InlineProcessor processor, MarkdownFile markdown, string anchor, int line, int column, int length)
194+
{
195+
if (!markdown.Anchors.Contains(anchor))
196+
processor.EmitError(line, column, length, $"`{anchor}` does not exist in {markdown.FileName}.");
197+
}
198+
199+
private static void UpdateLinkUrl(LinkInline link, string url, string? anchor, string urlPathPrefix)
200+
{
149201
if (url.EndsWith(".md"))
150-
link.Url = Path.ChangeExtension(url, ".html");
151-
// rooted links might need the configured path prefix to properly link
152-
var prefix = processor.GetBuildContext().UrlPathPrefix;
153-
if (url.StartsWith("/") && !string.IsNullOrWhiteSpace(prefix))
154-
link.Url = $"{prefix.TrimEnd('/')}{link.Url}";
202+
url = Path.ChangeExtension(url, ".html");
155203

156-
if (!string.IsNullOrEmpty(anchor))
157-
link.Url += $"#{anchor}";
204+
if (url.StartsWith("/") && !string.IsNullOrWhiteSpace(urlPathPrefix))
205+
url = $"{urlPathPrefix.TrimEnd('/')}{url}";
158206

159-
return match;
207+
link.Url = !string.IsNullOrEmpty(anchor) ? $"{url}#{anchor}" : url;
160208
}
161209

162210
private static bool IsCrossLink(Uri? uri) =>
163-
uri != null
211+
uri != null // This means it's not a local
164212
&& !ExcludedSchemes.Contains(uri.Scheme)
165213
&& !uri.IsFile
166214
&& Path.GetExtension(uri.OriginalString) == ".md";

tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ public void GeneratesHtml() =>
132132
// language=html
133133
Html.Should().Contain(
134134
// TODO: The link is not rendered correctly yet, will be fixed in a follow-up
135-
"""<p><a href="kibana://index.html">test</a></p>"""
135+
"""<p><a href="kibana://index.md">test</a></p>"""
136136
);
137137

138138
[Fact]
@@ -158,7 +158,7 @@ public void GeneratesHtml() =>
158158
// language=html
159159
Html.Should().Contain(
160160
// TODO: The link is not rendered correctly yet, will be fixed in a follow-up
161-
"""<p>Go to <a href="kibana://index.html">test</a></p>"""
161+
"""<p>Go to <a href="kibana://index.md">test</a></p>"""
162162
);
163163

164164
[Fact]

0 commit comments

Comments
 (0)