Skip to content

Commit 05c8f8c

Browse files
committed
Add utility class for crosslink validation
1 parent 0a52f75 commit 05c8f8c

File tree

6 files changed

+160
-36
lines changed

6 files changed

+160
-36
lines changed

docs/_docset.yml

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ toc:
129129
- file: req.md
130130
- folder: nested
131131
- file: cross-links.md
132+
children:
133+
- title: "Getting Started Guide"
134+
crosslink: docs-content://get-started/introduction.md
132135
- file: custom-highlighters.md
133136
- hidden: archive.md
134137
- hidden: landing-page.md
@@ -153,8 +156,4 @@ toc:
153156
- file: bar.md
154157
- folder: baz
155158
children:
156-
- file: qux.md
157-
- title: "Getting Started Guide"
158-
crosslink: docs-content://get-started/introduction.md
159-
- title: "Test title"
160-
crosslink: docs-content://solutions/search/elasticsearch-basics-quickstart.md
159+
- file: qux.md

src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Runtime.InteropServices;
77
using Elastic.Documentation.Configuration.Plugins.DetectionRules.TableOfContents;
88
using Elastic.Documentation.Configuration.TableOfContents;
9+
using Elastic.Documentation.Links;
910
using Elastic.Documentation.Navigation;
1011
using YamlDotNet.RepresentationModel;
1112

@@ -157,19 +158,9 @@ private IReadOnlyCollection<ITocItem> ReadChildren(YamlStreamReader reader, KeyV
157158
hiddenFile = false;
158159
crossLink = reader.ReadString(entry);
159160
// Validate crosslink URI early
160-
if (string.IsNullOrWhiteSpace(crossLink))
161+
if (!CrossLinkValidator.IsValidCrossLink(crossLink, out var errorMessage))
161162
{
162-
reader.EmitError("Cross-link entries must specify a non-empty 'crosslink' URI", tocEntry);
163-
crossLink = null; // Reset to prevent further processing
164-
}
165-
else if (!Uri.TryCreate(crossLink, UriKind.Absolute, out var parsedUri))
166-
{
167-
reader.EmitError($"Cross-link URI '{crossLink}' is not a valid absolute URI format", tocEntry);
168-
crossLink = null; // Reset to prevent further processing
169-
}
170-
else if (parsedUri.Scheme is "http" or "https" or "ftp" or "file")
171-
{
172-
reader.EmitError($"Cross-link URI '{crossLink}' cannot use standard web schemes (http, https, ftp, file). Use cross-repository schemes like 'docs-content://', 'kibana://', etc.", tocEntry);
163+
reader.EmitError(errorMessage!, tocEntry);
173164
crossLink = null; // Reset to prevent further processing
174165
}
175166
break;
@@ -197,6 +188,15 @@ private IReadOnlyCollection<ITocItem> ReadChildren(YamlStreamReader reader, KeyV
197188
return null;
198189
}
199190

191+
// Validate that standalone titles (without content) are not allowed
192+
if (!string.IsNullOrWhiteSpace(title) &&
193+
file is null && crossLink is null && folder is null && toc is null &&
194+
(detectionRules is null || detectionRules.Length == 0))
195+
{
196+
reader.EmitError($"Table of contents entries with only a 'title' are not allowed. Entry must specify content (file, crosslink, folder, or toc). Title: '{title}'", tocEntry);
197+
return null;
198+
}
199+
200200
if (toc is not null)
201201
{
202202
foreach (var f in toc.Files)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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.Immutable;
6+
7+
namespace Elastic.Documentation.Links;
8+
9+
/// <summary>
10+
/// Utility class for validating and identifying cross-repository links
11+
/// </summary>
12+
public static class CrossLinkValidator
13+
{
14+
/// <summary>
15+
/// URI schemes that are excluded from being treated as cross-repository links.
16+
/// These are standard web/protocol schemes that should not be processed as crosslinks.
17+
/// </summary>
18+
private static readonly ImmutableHashSet<string> ExcludedSchemes =
19+
ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase,
20+
"http", "https", "ftp", "file", "tel", "jdbc", "mailto");
21+
22+
/// <summary>
23+
/// Validates that a URI string is a valid cross-repository link.
24+
/// </summary>
25+
/// <param name="uriString">The URI string to validate</param>
26+
/// <param name="errorMessage">Error message if validation fails</param>
27+
/// <returns>True if valid crosslink, false otherwise</returns>
28+
public static bool IsValidCrossLink(string? uriString, out string? errorMessage)
29+
{
30+
errorMessage = null;
31+
32+
if (string.IsNullOrWhiteSpace(uriString))
33+
{
34+
errorMessage = "Cross-link entries must specify a non-empty URI";
35+
return false;
36+
}
37+
38+
if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
39+
{
40+
errorMessage = $"Cross-link URI '{uriString}' is not a valid absolute URI format";
41+
return false;
42+
}
43+
44+
if (ExcludedSchemes.Contains(uri.Scheme))
45+
{
46+
errorMessage = $"Cross-link URI '{uriString}' cannot use standard web/protocol schemes ({string.Join(", ", ExcludedSchemes)}). Use cross-repository schemes like 'docs-content://', 'kibana://', etc.";
47+
return false;
48+
}
49+
50+
return true;
51+
}
52+
53+
/// <summary>
54+
/// Determines if a URI is a cross-repository link (for identification purposes).
55+
/// This is more permissive than validation and is used by the Markdown parser.
56+
/// </summary>
57+
/// <param name="uri">The URI to check</param>
58+
/// <returns>True if this should be treated as a crosslink</returns>
59+
public static bool IsCrossLink(Uri? uri) =>
60+
uri != null
61+
&& !ExcludedSchemes.Contains(uri.Scheme)
62+
&& !uri.IsFile
63+
&& !string.IsNullOrEmpty(uri.Scheme);
64+
65+
/// <summary>
66+
/// Gets the list of excluded URI schemes for reference
67+
/// </summary>
68+
public static IReadOnlySet<string> GetExcludedSchemes() => ExcludedSchemes;
69+
}

src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Elastic.Documentation.Configuration;
88
using Elastic.Documentation.Configuration.TableOfContents;
99
using Elastic.Documentation.Extensions;
10+
using Elastic.Documentation.Links;
1011
using Elastic.Documentation.Site.Navigation;
1112

1213
namespace Elastic.Markdown.IO.Navigation;
@@ -123,19 +124,10 @@ void AddToNavigationItems(INavigationItem item, ref int fileIndex)
123124
{
124125
if (tocItem is CrossLinkReference crossLink)
125126
{
126-
// Validate that cross-link URI is not empty
127-
if (string.IsNullOrWhiteSpace(crossLink.CrossLinkUri))
127+
// Validate crosslink URI and title
128+
if (!CrossLinkValidator.IsValidCrossLink(crossLink.CrossLinkUri, out var errorMessage))
128129
{
129-
context.EmitError(context.ConfigurationPath,
130-
"Cross-link entries must have a 'crosslink' URI specified.");
131-
continue;
132-
}
133-
134-
// Validate that cross-link URI is a valid URI format
135-
if (!Uri.TryCreate(crossLink.CrossLinkUri, UriKind.Absolute, out var parsedUri))
136-
{
137-
context.EmitError(context.ConfigurationPath,
138-
$"Cross-link URI '{crossLink.CrossLinkUri}' is not a valid absolute URI format.");
130+
context.EmitError(context.ConfigurationPath, errorMessage!);
139131
continue;
140132
}
141133

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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.Immutable;
6+
7+
namespace Elastic.Markdown.Links.CrossLinks;
8+
9+
/// <summary>
10+
/// Utility class for validating and identifying cross-repository links
11+
/// </summary>
12+
public static class CrossLinkValidator
13+
{
14+
/// <summary>
15+
/// URI schemes that are excluded from being treated as cross-repository links.
16+
/// These are standard web/protocol schemes that should not be processed as crosslinks.
17+
/// </summary>
18+
private static readonly ImmutableHashSet<string> ExcludedSchemes =
19+
ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase,
20+
"http", "https", "ftp", "file", "tel", "jdbc", "mailto");
21+
22+
/// <summary>
23+
/// Validates that a URI string is a valid cross-repository link.
24+
/// </summary>
25+
/// <param name="uriString">The URI string to validate</param>
26+
/// <param name="errorMessage">Error message if validation fails</param>
27+
/// <returns>True if valid crosslink, false otherwise</returns>
28+
public static bool IsValidCrossLink(string? uriString, out string? errorMessage)
29+
{
30+
errorMessage = null;
31+
32+
if (string.IsNullOrWhiteSpace(uriString))
33+
{
34+
errorMessage = "Cross-link entries must specify a non-empty URI";
35+
return false;
36+
}
37+
38+
if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
39+
{
40+
errorMessage = $"Cross-link URI '{uriString}' is not a valid absolute URI format";
41+
return false;
42+
}
43+
44+
if (ExcludedSchemes.Contains(uri.Scheme))
45+
{
46+
errorMessage = $"Cross-link URI '{uriString}' cannot use standard web/protocol schemes ({string.Join(", ", ExcludedSchemes)}). Use cross-repository schemes like 'docs-content://', 'kibana://', etc.";
47+
return false;
48+
}
49+
50+
return true;
51+
}
52+
53+
/// <summary>
54+
/// Determines if a URI is a cross-repository link (for identification purposes).
55+
/// This is more permissive than validation and is used by the Markdown parser.
56+
/// </summary>
57+
/// <param name="uri">The URI to check</param>
58+
/// <returns>True if this should be treated as a crosslink</returns>
59+
public static bool IsCrossLink(Uri? uri) =>
60+
uri != null
61+
&& !ExcludedSchemes.Contains(uri.Scheme)
62+
&& !uri.IsFile
63+
&& !string.IsNullOrEmpty(uri.Scheme);
64+
65+
/// <summary>
66+
/// Gets the list of excluded URI schemes for reference
67+
/// </summary>
68+
public static IReadOnlySet<string> GetExcludedSchemes() => ExcludedSchemes;
69+
}

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.IO.Abstractions;
88
using System.Runtime.InteropServices;
99
using System.Text.RegularExpressions;
10+
using Elastic.Documentation.Links;
1011
using Elastic.Markdown.Diagnostics;
1112
using Elastic.Markdown.Helpers;
1213
using Elastic.Markdown.IO;
@@ -46,9 +47,6 @@ internal sealed partial class LinkRegexExtensions
4647

4748
public class DiagnosticLinkInlineParser : LinkInlineParser
4849
{
49-
// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml for a list of URI schemes
50-
private static readonly ImmutableHashSet<string> ExcludedSchemes = ["http", "https", "tel", "jdbc", "mailto"];
51-
5250
public override bool Match(InlineProcessor processor, ref StringSlice slice)
5351
{
5452
var match = base.Match(processor, ref slice);
@@ -389,8 +387,5 @@ public static string UpdateRelativeUrl(ParserContext context, string url)
389387
}
390388

391389
private static bool IsCrossLink([NotNullWhen(true)] Uri? uri) =>
392-
uri != null // This means it's not a local
393-
&& !ExcludedSchemes.Contains(uri.Scheme)
394-
&& !uri.IsFile
395-
&& !string.IsNullOrEmpty(uri.Scheme);
390+
CrossLinkValidator.IsCrossLink(uri);
396391
}

0 commit comments

Comments
 (0)