|
3 | 3 | // See the LICENSE file in the project root for more information
|
4 | 4 |
|
5 | 5 | using System.Collections.Immutable;
|
| 6 | +using System.IO.Abstractions; |
6 | 7 | using Elastic.Markdown.Diagnostics;
|
7 | 8 | using Elastic.Markdown.IO;
|
8 | 9 | using Elastic.Markdown.Myst.Comments;
|
@@ -35,132 +36,179 @@ public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) { }
|
35 | 36 | public class DiagnosticLinkInlineParser : LinkInlineParser
|
36 | 37 | {
|
37 | 38 | // 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 |
39 | 39 | private static readonly ImmutableHashSet<string> ExcludedSchemes = ["http", "https", "tel", "jdbc"];
|
40 | 40 |
|
41 | 41 | public override bool Match(InlineProcessor processor, ref StringSlice slice)
|
42 | 42 | {
|
43 | 43 | 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) |
48 | 45 | return match;
|
49 | 46 |
|
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) |
53 | 49 | return match;
|
54 | 50 |
|
| 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 | + { |
55 | 60 | var url = link.Url;
|
56 | 61 | var line = link.Line + 1;
|
57 | 62 | var column = link.Column;
|
58 | 63 | var length = url?.Length ?? 1;
|
59 | 64 |
|
60 |
| - var context = processor.GetContext(); |
61 |
| - if (processor.GetContext().SkipValidation) |
62 |
| - return match; |
| 65 | + if (!ValidateBasicUrl(processor, url, line, column, length)) |
| 66 | + return; |
63 | 67 |
|
64 |
| - if (string.IsNullOrEmpty(url)) |
| 68 | + var uri = Uri.TryCreate(url, UriKind.Absolute, out var u) ? u : null; |
| 69 | + |
| 70 | + if (IsCrossLink(uri)) |
65 | 71 | {
|
66 |
| - processor.EmitWarning(line, column, length, $"Found empty url"); |
67 |
| - return match; |
| 72 | + ProcessCrossLink(link, context, line, column, length); |
| 73 | + return; |
68 | 74 | }
|
69 | 75 |
|
| 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 | + } |
70 | 89 | if (url.Contains("{{") || url.Contains("}}"))
|
71 | 90 | {
|
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; |
74 | 95 | }
|
| 96 | + return true; |
| 97 | + } |
75 | 98 |
|
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; |
80 | 103 |
|
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)) |
82 | 106 | {
|
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 | + ); |
95 | 115 | }
|
| 116 | + return true; |
| 117 | + } |
96 | 118 |
|
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 | + } |
100 | 126 |
|
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); |
104 | 131 |
|
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) |
112 | 167 | {
|
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; |
116 | 171 | }
|
117 | 172 |
|
118 |
| - if (link.FirstChild == null || !string.IsNullOrEmpty(anchor)) |
| 173 | + var title = markdown.Title; |
| 174 | + |
| 175 | + if (!string.IsNullOrEmpty(anchor)) |
119 | 176 | {
|
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; |
147 | 180 | }
|
148 | 181 |
|
| 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 | + { |
149 | 201 | 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"); |
155 | 203 |
|
156 |
| - if (!string.IsNullOrEmpty(anchor)) |
157 |
| - link.Url += $"#{anchor}"; |
| 204 | + if (url.StartsWith("/") && !string.IsNullOrWhiteSpace(urlPathPrefix)) |
| 205 | + url = $"{urlPathPrefix.TrimEnd('/')}{url}"; |
158 | 206 |
|
159 |
| - return match; |
| 207 | + link.Url = !string.IsNullOrEmpty(anchor) ? $"{url}#{anchor}" : url; |
160 | 208 | }
|
161 | 209 |
|
162 | 210 | private static bool IsCrossLink(Uri? uri) =>
|
163 |
| - uri != null |
| 211 | + uri != null // This means it's not a local |
164 | 212 | && !ExcludedSchemes.Contains(uri.Scheme)
|
165 | 213 | && !uri.IsFile
|
166 | 214 | && Path.GetExtension(uri.OriginalString) == ".md";
|
|
0 commit comments