Skip to content

Commit c04311f

Browse files
authored
Merge pull request #61 from Fabian-Schmidt/60-encode-local-link
DocLinkChecker: Support for %20 in links
2 parents c6694d0 + 67da7cc commit c04311f

File tree

4 files changed

+110
-10
lines changed

4 files changed

+110
-10
lines changed

src/DocLinkChecker/DocLinkChecker.Test/HyperlinkTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,10 @@ public async void ValidateLocalLinkNonExistingHeadingShouldHaveErrors()
306306
[Theory]
307307
[InlineData("~/general/images/nature.jpeg")]
308308
[InlineData("~\\general\\images\\nature.jpeg")]
309+
[InlineData("~/general/images/space%20image.jpeg")]
310+
[InlineData("~\\general\\images\\space%20image.jpeg")]
311+
[InlineData("%7E/general/images/space%20image.jpeg")]
312+
[InlineData("%7E\\general\\images\\space%20image.jpeg")]
309313
public async void ValidateRootLinkShouldHaveNoErrors(string path)
310314
{
311315
// Arrange
@@ -327,6 +331,7 @@ public async void ValidateRootLinkShouldHaveNoErrors(string path)
327331
[Theory]
328332
[InlineData("~/general/images/NON_EXISTING.jpeg")]
329333
[InlineData("~\\NON_EXISTING\\images\\nature.jpeg")]
334+
[InlineData("~/general%2Fimages/nature.jpeg")]
330335
public async void ValidateInvalidRootLinkShouldHaveErrors(string path)
331336
{
332337
// Arrange
@@ -349,6 +354,28 @@ public async void ValidateInvalidRootLinkShouldHaveErrors(string path)
349354
linkError.Severity.Should().Be(MarkdownErrorSeverity.Error);
350355
}
351356

357+
[Theory]
358+
// Adopted behaviour from DocFx tests <https://github.com/dotnet/docfx/blob/cca05f505e30c5ede36973c4b989fce711f2e8ad/test/Docfx.Common.Tests/RelativePathTest.cs#L400-L412>
359+
// Modified that expected result of Encoded var is upper case, instead of same case as original.
360+
[InlineData("a/b/c", "a/b/c")]
361+
[InlineData("../a/b/c", "../a/b/c")]
362+
[InlineData("a/b/c%20d", "a/b/c d")]
363+
[InlineData("../a%2Bb/c/d", "../a+b/c/d")]
364+
[InlineData("a%253fb", "a%3fb")]
365+
[InlineData("a%2fb", "a%2Fb")]
366+
[InlineData("%2A%2F%3A%3F%5C", "%2A%2F%3A%3F%5C")] //*/:?\
367+
[InlineData("%2a%2f%3a%3f%5c", "%2A%2F%3A%3F%5C")]
368+
public void ValidateLocalUrlDecode(string path, string expected)
369+
{
370+
//Act
371+
int line = 499;
372+
int column = 75;
373+
Hyperlink link = new Hyperlink(null, line, column, path);
374+
375+
Assert.Equal(path, link.OriginalUrl);
376+
Assert.Equal(expected, link.Url);
377+
}
378+
352379
[Fact]
353380
public async void ValidateLocalLinkWithFullPathShouldHaveErrors()
354381
{

src/DocLinkChecker/DocLinkChecker.Test/MockFileService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public void FillDemoSet()
6363

6464
Files.Add($"{Root}\\general\\images\\nature.jpeg", "<image>");
6565
Files.Add($"{Root}\\general\\images\\another-image.png", "<image>");
66+
Files.Add($"{Root}\\general\\images\\space image.jpeg", "<image>");
6667

6768
Files.Add($"{Root}\\src", null);
6869
Files.Add($"{Root}\\src\\sample.cs", @"namespace MySampleApp;

src/DocLinkChecker/DocLinkChecker/Models/Hyperlink.cs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
namespace DocLinkChecker.Models
22
{
3+
using System;
34
using System.IO;
5+
using System.Linq;
6+
using System.Text;
47
using DocLinkChecker.Enums;
58

69
/// <summary>
710
/// Model class for hyperlink.
811
/// </summary>
912
public class Hyperlink : MarkdownObjectBase
1013
{
14+
private static readonly char[] UriFragmentOrQueryString = new char[] { '#', '?' };
15+
private static readonly char[] AdditionalInvalidChars = @"\/?:*".ToArray();
16+
private static readonly char[] InvalidPathChars = Path.GetInvalidPathChars().Concat(AdditionalInvalidChars).ToArray();
17+
1118
/// <summary>
1219
/// Initializes a new instance of the <see cref="Hyperlink"/> class.
1320
/// </summary>
@@ -26,6 +33,7 @@ public Hyperlink(string filePath, int line, int col, string url)
2633
: base(filePath, line, col)
2734
{
2835
Url = url;
36+
OriginalUrl = Url;
2937

3038
LinkType = HyperlinkType.Empty;
3139
if (!string.IsNullOrWhiteSpace(url))
@@ -48,6 +56,8 @@ public Hyperlink(string filePath, int line, int col, string url)
4856
}
4957
else
5058
{
59+
Url = UrlDecode(Url);
60+
5161
if (Path.GetExtension(url).ToLower() == ".md" || Path.GetExtension(url) == string.Empty)
5262
{
5363
// link to an MD file or a folder
@@ -67,6 +77,11 @@ public Hyperlink(string filePath, int line, int col, string url)
6777
/// </summary>
6878
public string Url { get; set; }
6979

80+
/// <summary>
81+
/// Gets or sets the original URL as found in the Markdown document. Used for reporting to user so they can find the correct location. Url will be modified.
82+
/// </summary>
83+
public string OriginalUrl { get; set; }
84+
7085
/// <summary>
7186
/// Gets or sets a value indicating whether this is a web link.
7287
/// </summary>
@@ -177,5 +192,62 @@ public string UrlFullPath
177192
return Url;
178193
}
179194
}
195+
196+
/// <summary>
197+
/// Decoding of local Urls. Similar to logic from DocFx RelativePath class.
198+
/// https://github.com/dotnet/docfx/blob/cca05f505e30c5ede36973c4b989fce711f2e8ad/src/Docfx.Common/Path/RelativePath.cs .
199+
/// </summary>
200+
/// <param name="url">Url.</param>
201+
/// <returns>Decoded Url.</returns>
202+
private string UrlDecode(string url)
203+
{
204+
// This logic only applies to relative paths.
205+
if (Path.IsPathRooted(url))
206+
{
207+
return url;
208+
}
209+
210+
var anchor = string.Empty;
211+
var index = url.IndexOfAny(UriFragmentOrQueryString);
212+
if (index != -1)
213+
{
214+
anchor = url.Substring(index);
215+
url = url.Remove(index);
216+
}
217+
218+
var parts = url.Split('/', '\\');
219+
var newUrl = new StringBuilder();
220+
for (int i = 0; i < parts.Length; i++)
221+
{
222+
if (i > 0)
223+
{
224+
newUrl.Append('/');
225+
}
226+
227+
var origin = parts[i];
228+
var value = Uri.UnescapeDataString(origin);
229+
230+
var splittedOnInvalidChars = value.Split(InvalidPathChars);
231+
var originIndex = 0;
232+
var valueIndex = 0;
233+
for (int j = 0; j < splittedOnInvalidChars.Length; j++)
234+
{
235+
if (j > 0)
236+
{
237+
var invalidChar = value[valueIndex];
238+
valueIndex++;
239+
newUrl.Append(Uri.EscapeDataString(invalidChar.ToString()));
240+
}
241+
242+
var splitOnInvalidChars = splittedOnInvalidChars[j];
243+
originIndex += splitOnInvalidChars.Length;
244+
valueIndex += splitOnInvalidChars.Length;
245+
newUrl.Append(splitOnInvalidChars);
246+
}
247+
}
248+
249+
newUrl.Append(anchor);
250+
return newUrl.ToString();
251+
}
180252
}
181253
}

src/DocLinkChecker/DocLinkChecker/Services/LinkValidatorService.cs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -189,12 +189,12 @@ private async Task VerifyWebHyperlink(Hyperlink hyperlink)
189189

190190
if (hyperlink.Url.Matches(whitelist))
191191
{
192-
_console.Verbose($"Skipping whitelisted url {hyperlink.Url}");
192+
_console.Verbose($"Skipping whitelisted url {hyperlink.OriginalUrl}");
193193
return;
194194
}
195195
}
196196

197-
_console.Verbose($"Validating {hyperlink.Url} in {_fileService.GetRelativePath(_config.DocumentationFiles.SourceFolder, hyperlink.FilePath)}");
197+
_console.Verbose($"Validating {hyperlink.OriginalUrl} in {_fileService.GetRelativePath(_config.DocumentationFiles.SourceFolder, hyperlink.FilePath)}");
198198
using var scope = _serviceProvider.CreateScope();
199199
var client = scope.ServiceProvider.GetRequiredService<CheckerHttpClient>();
200200

@@ -204,7 +204,7 @@ private async Task VerifyWebHyperlink(Hyperlink hyperlink)
204204
sw.Stop();
205205
if (sw.ElapsedMilliseconds > _config.DocLinkChecker.ExternalLinkDurationWarning)
206206
{
207-
_console.Warning($"*** WARNING: Checking {hyperlink.Url} took {sw.ElapsedMilliseconds}ms.");
207+
_console.Warning($"*** WARNING: Checking {hyperlink.OriginalUrl} took {sw.ElapsedMilliseconds}ms.");
208208
}
209209

210210
if (!result.success)
@@ -229,7 +229,7 @@ private async Task VerifyWebHyperlink(Hyperlink hyperlink)
229229
hyperlink.Line,
230230
hyperlink.Column,
231231
severity,
232-
$"{hyperlink.Url} => {result.statusCode}"));
232+
$"{hyperlink.OriginalUrl} => {result.statusCode}"));
233233
}
234234
}
235235
else
@@ -241,7 +241,7 @@ private async Task VerifyWebHyperlink(Hyperlink hyperlink)
241241
hyperlink.Line,
242242
hyperlink.Column,
243243
MarkdownErrorSeverity.Error,
244-
$"{hyperlink.Url} => {result.error}"));
244+
$"{hyperlink.OriginalUrl} => {result.error}"));
245245
}
246246
}
247247
}
@@ -268,7 +268,7 @@ private Task VerifyLocalHyperlink(Hyperlink hyperlink)
268268
hyperlink.Line,
269269
hyperlink.Column,
270270
MarkdownErrorSeverity.Error,
271-
$"Full path not allowed as link: {hyperlink.Url}"));
271+
$"Full path not allowed as link: {hyperlink.OriginalUrl}"));
272272
return Task.CompletedTask;
273273
}
274274

@@ -285,7 +285,7 @@ private Task VerifyLocalHyperlink(Hyperlink hyperlink)
285285
hyperlink.Line,
286286
hyperlink.Column,
287287
MarkdownErrorSeverity.Error,
288-
$"Not found: {hyperlink.Url}"));
288+
$"Not found: {hyperlink.OriginalUrl}"));
289289
return Task.CompletedTask;
290290
}
291291
}
@@ -301,7 +301,7 @@ private Task VerifyLocalHyperlink(Hyperlink hyperlink)
301301
hyperlink.Line,
302302
hyperlink.Column,
303303
MarkdownErrorSeverity.Error,
304-
$"Not found: {hyperlink.Url}"));
304+
$"Not found: {hyperlink.OriginalUrl}"));
305305
return Task.CompletedTask;
306306
}
307307
}
@@ -317,7 +317,7 @@ private Task VerifyLocalHyperlink(Hyperlink hyperlink)
317317
hyperlink.Line,
318318
hyperlink.Column,
319319
MarkdownErrorSeverity.Error,
320-
$"File referenced outside of the same /docs hierarchy not allowed: {hyperlink.Url}"));
320+
$"File referenced outside of the same /docs hierarchy not allowed: {hyperlink.OriginalUrl}"));
321321
return Task.CompletedTask;
322322
}
323323

@@ -332,7 +332,7 @@ private Task VerifyLocalHyperlink(Hyperlink hyperlink)
332332
hyperlink.Line,
333333
hyperlink.Column,
334334
MarkdownErrorSeverity.Error,
335-
$"File referenced outside of anything else then a /docs hierarchy not allowed: {hyperlink.Url}"));
335+
$"File referenced outside of anything else then a /docs hierarchy not allowed: {hyperlink.OriginalUrl}"));
336336
return Task.CompletedTask;
337337
}
338338

0 commit comments

Comments
 (0)