Skip to content

Commit

Permalink
Merge pull request #58 from Ellerbach/mtirion/29-fix-doclinkchecker-i…
Browse files Browse the repository at this point in the history
…nclusion-links

Added support for DocFx extended file inclusion links
  • Loading branch information
mtirionMSFT authored Aug 21, 2024
2 parents 8c5ca12 + 1137c9b commit c7ec3de
Show file tree
Hide file tree
Showing 9 changed files with 198 additions and 11 deletions.
8 changes: 8 additions & 0 deletions src/DocFxTocGenerator/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"profiles": {
"DocFxTocGenerator": {
"commandName": "Project",
"commandLineArgs": "-d \"c:\\temp\\docfxindex\" --index"
}
}
}
94 changes: 89 additions & 5 deletions src/DocLinkChecker/DocLinkChecker.Test/HyperlinkTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
{
using Bogus;
using DocLinkChecker.Enums;
using DocLinkChecker.Helpers;
using DocLinkChecker.Interfaces;
using DocLinkChecker.Models;
using DocLinkChecker.Services;
Expand All @@ -11,6 +12,7 @@
using Moq;
using Moq.Contrib.HttpClient;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
Expand Down Expand Up @@ -203,6 +205,32 @@ public async void ValidateLocalLinkOutsideHierarchyWithConfigShouldNotHaveErrors
service.Errors.Where(x => x.Severity == MarkdownErrorSeverity.Error).Should().BeEmpty();
}

[Theory]
[InlineData("# [Linux](#tab/linux)")]
[InlineData("# [Windows](#tab/windows)")]
public async void ValidateTabHeadersShouldNotHaveErrors(string codeLink)
{
// Arrange
_config.DocumentationFiles.SourceFolder = _fileServiceMock.Root;

LinkValidatorService service = new LinkValidatorService(_serviceProvider, _config, _fileService, _console);

//Act
(List<MarkdownObjectBase> objects, List<MarkdownError> errors) result =
MarkdownHelper.ParseMarkdownString($"{_fileServiceMock.Root}/start-document.md", codeLink, false);

Heading heading = (Heading)result.objects.FirstOrDefault(result => result is Heading);
Hyperlink link = (Hyperlink)result.objects.FirstOrDefault(result => result is Hyperlink);
await service.VerifyHyperlink(link);

// Assert
heading.Should().NotBeNull();

link.LinkType.Should().Be(HyperlinkType.Tab);

service.Errors.Should().BeEmpty();
}

[Fact]
public async void ValidateLocalLinkHeadingShouldNotHaveErrors()
{
Expand All @@ -224,21 +252,23 @@ public async void ValidateLocalLinkHeadingShouldNotHaveErrors()
service.Errors.Should().BeEmpty();
}

[Fact]
public async void ValidateLocalLinkHeadingInSameDocumentShouldNotHaveErrors()
[Theory]
[InlineData("First header", "first-header", "#first-header")]
[InlineData("`Second header`", "second-header", "#second-header")]
public async void ValidateLocalLinkHeadingInSameDocumentShouldNotHaveErrors(string title, string id, string reference)
{
// Arrange
_config.DocumentationFiles.SourceFolder = _fileServiceMock.Root;
string source = $"{_fileServiceMock.Root}\\general\\another-sample.md";

LinkValidatorService service = new LinkValidatorService(_serviceProvider, _config, _fileService, _console);
service.Headings.Add(new(source, 99, 1, "First Header", "first-header"));
service.Headings.Add(new(source, 99, 1, title, id));

//Act
int line = 432;
int column = 771;

Hyperlink link = new Hyperlink(source, line, column, $"#first-header");
Hyperlink link = new Hyperlink(source, line, column, reference);
await service.VerifyHyperlink(link);

// Assert
Expand Down Expand Up @@ -291,10 +321,64 @@ public async void ValidateLocalLinkWithFullPathShouldHaveErrors()
service.Errors.Should().NotBeEmpty();
service.Errors.First().Line.Should().Be(line);
service.Errors.First().Column.Should().Be(column);
service.Errors.First().Severity.Should().Be(Enums.MarkdownErrorSeverity.Error);
service.Errors.First().Severity.Should().Be(MarkdownErrorSeverity.Error);
service.Errors.First().Message.Should().Contain("Full path not allowed");
}

[Theory]
[InlineData("[!code-csharp[](src/sample.cs)]")]
[InlineData("[!code-csharp[](src/sample.cs#region)]")]
[InlineData("[!code-csharp[](src/sample.cs#L8-11)]")]
[InlineData("[!code-csharp[](src/sample.cs?name=MainLoop)]")]
[InlineData("[!code-csharp[](src/sample.cs?highlight=3,5)]")]
[InlineData("[!INCLUDE [the defined way to include](getting-started/README.md)]")]
[InlineData("[!include [using lowercase to include](getting-started/README.md)]")]
public async void ValidateFileInclusionLinksShouldNotHaveErrors(string codeLink)
{
// Arrange
_config.DocumentationFiles.SourceFolder = _fileServiceMock.Root;

LinkValidatorService service = new LinkValidatorService(_serviceProvider, _config, _fileService, _console);

//Act
(List<MarkdownObjectBase> objects, List<MarkdownError> errors) result =
MarkdownHelper.ParseMarkdownString($"{_fileServiceMock.Root}/start-document.md", codeLink, false);

Hyperlink link = (Hyperlink)result.objects.FirstOrDefault(result => result is Hyperlink);
await service.VerifyHyperlink(link);

// Assert
service.Errors.Should().BeEmpty();
}

[Theory]
[InlineData("[!code-csharp[](src/sample_NOT_EXISTING.cs)]")]
[InlineData("[!code-csharp[](src/sample_NOT_EXISTING.cs#region)]")]
[InlineData("[!code-csharp[](src/sample_NOT_EXISTING.cs#L8-11)]")]
[InlineData("[!code-csharp[](src/sample_NOT_EXISTING.cs?name=MainLoop)]")]
[InlineData("[!code-csharp[](src/sample_NOT_EXISTING.cs?highlight=3,5)]")]
[InlineData("[!INCLUDE [the defined way to include](getting-started/README_NOT_EXISTING.md)]")]
[InlineData("[!include [using lowercase to include](getting-started/README_NOT_EXISTING.md)]")]
public async void ValidateFileInclusionLinksShouldHaveErrors(string codeLink)
{
// Arrange
_config.DocumentationFiles.SourceFolder = _fileServiceMock.Root;

LinkValidatorService service = new LinkValidatorService(_serviceProvider, _config, _fileService, _console);

//Act
(List<MarkdownObjectBase> objects, List<MarkdownError> errors) result =
MarkdownHelper.ParseMarkdownString($"{_fileServiceMock.Root}/start-document.md", codeLink, false);

Hyperlink link = (Hyperlink)result.objects.FirstOrDefault(result => result is Hyperlink);
await service.VerifyHyperlink(link);

// Assert
service.Errors.Should().NotBeEmpty();
service.Errors.First().Severity.Should().Be(MarkdownErrorSeverity.Error);
service.Errors.First().Message.Should().Contain("Not found");
}

private ICustomConsoleLogger GetMockedConsoleLogger()
{
Mock<ICustomConsoleLogger> console = new Mock<ICustomConsoleLogger>();
Expand Down
21 changes: 20 additions & 1 deletion src/DocLinkChecker/DocLinkChecker.Test/MarkdownTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ public void FindAllHeadingsWithUnicodeCharacters()
.AddHeading("ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789", 2)
.AddParagraphs(1)
.AddHeading("UNICODE-!@#$%^&*+=~`<>,.?/:;€|Æäßéóčúįǯ-CHARS", 2)
.AddParagraphs(1)
.AddHeading("`Commented header`", 2)
.AddParagraphs(1);

var result = MarkdownHelper.ParseMarkdownString(string.Empty, markdown, true);
Expand All @@ -101,10 +103,27 @@ public void FindAllHeadingsWithUnicodeCharacters()
.OfType<Heading>()
.ToList();

headings.Count.Should().Be(4);
headings.Count.Should().Be(5);
headings[1].Id.Should().Be("abcdefghijklmnopqrstuvwxyz-0123456789");
headings[2].Id.Should().Be("abcdefghijklmnopqrstuvwxyz-0123456789");
headings[3].Id.Should().Be("unicode-æäßéóčúįǯ-chars");
headings[4].Id.Should().Be("commented-header");
}

[Fact]
public void FindAllFileInclusionLinks()
{
string markdown = string.Empty
.AddHeading("Test file inclusion links", 1)
.AddParagraphs(1).AddLink("!code-csharp[](Program.cs)");

var result = MarkdownHelper.ParseMarkdownString(string.Empty, markdown, true);

var links = result.objects
.OfType<Hyperlink>()
.ToList();

links.Count.Should().Be(1);
}

[Fact]
Expand Down
16 changes: 16 additions & 0 deletions src/DocLinkChecker/DocLinkChecker.Test/MockFileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ public void FillDemoSet()

Files.Add($"{Root}\\general\\images\\nature.jpeg", "<image>");
Files.Add($"{Root}\\general\\images\\another-image.png", "<image>");

Files.Add($"{Root}\\src", null);
Files.Add($"{Root}\\src\\sample.cs", @"namespace MySampleApp;
public class SampleClass
{
public void SampleMethod()
{
// <MainLoop>
foreach(var thing in list)
{
// Do Stuff
}
// </MainLoop>
}
}");
}

public void DeleteFile(string path)
Expand Down
5 changes: 5 additions & 0 deletions src/DocLinkChecker/DocLinkChecker/Enums/HyperlinkType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ public enum HyperlinkType
/// </summary>
Resource,

/// <summary>
/// A tab - DocFx special. See https://dotnet.github.io/docfx/docs/markdown.html?tabs=linux%2Cdotnet#tabs.
/// </summary>
Tab,

/// <summary>
/// Empty link.
/// </summary>
Expand Down
34 changes: 31 additions & 3 deletions src/DocLinkChecker/DocLinkChecker/Helpers/MarkdownHelper.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
namespace DocLinkChecker.Helpers
{
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
Expand All @@ -12,7 +10,6 @@
using DocLinkChecker.Models;
using Markdig;
using Markdig.Extensions.Tables;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;

Expand Down Expand Up @@ -61,6 +58,32 @@ public static (List<MarkdownObjectBase> objects, List<MarkdownError> errors)
.ToList();
if (links != null)
{
// Support for DocFx specific links. See https://dotnet.github.io/docfx/docs/markdown.html
// File inclusion links and Code references
var filerefs = links.Where(x => x.Url.StartsWith("!code-", StringComparison.OrdinalIgnoreCase) ||
x.Url.StartsWith("!INCLUDE", StringComparison.OrdinalIgnoreCase));
foreach (var fileref in filerefs)
{
string url = Regex.Match(fileref.Url, @"\\((.*?)\\)").Value;
fileref.Url = url;
}

// Video inclusion links
var videorefs = links.Where(x => x.Url.StartsWith("!Video"));
foreach (var videoref in videorefs)
{
string url = Regex.Match(videoref.Url, @"!Video (.*)").Value;
videoref.Url = url;
videoref.LinkType = HyperlinkType.Webpage;
}

// Tabs
var tabrefs = links.Where(x => x.Url.StartsWith("#tab/"));
foreach (var tabref in tabrefs)
{
tabref.LinkType = HyperlinkType.Tab;
}

objects.AddRange(links);
}

Expand All @@ -76,6 +99,11 @@ public static (List<MarkdownObjectBase> objects, List<MarkdownError> errors)
{
title = markdown.Substring(child.Span.Start, x.Span.Length - (child.Span.Start - x.Span.Start));
}
else if (x.Inline.FirstChild != null)
{
// fallback for complex headers, like "# `text with quotes`" and such
title = markdown.Substring(x.Inline.FirstChild.Span.Start, x.Span.Length - (x.Inline.FirstChild.Span.Start - x.Span.Start));
}
// custom generation of the id
string id = title.ToLower();
Expand Down
17 changes: 17 additions & 0 deletions src/DocLinkChecker/DocLinkChecker/Models/Hyperlink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ public string UrlTopic
if (IsLocal)
{
int pos = Url.IndexOf("#");
if (pos == -1)
{
// if we don't have a header delimiter, we might have a url delimiter
pos = Url.IndexOf("?");
}

return pos == -1 ? string.Empty : Url.Substring(pos + 1);
}

Expand All @@ -123,6 +129,12 @@ public string UrlWithoutTopic
if (IsLocal)
{
int pos = Url.IndexOf("#");
if (pos == -1)
{
// if we don't have a header delimiter, we might have a url delimiter
pos = Url.IndexOf("?");
}

switch (pos)
{
case -1:
Expand All @@ -149,6 +161,11 @@ public string UrlFullPath
if (IsLocal)
{
int pos = Url.IndexOf("#");
if (pos == -1)
{
// if we don't have a header delimiter, we might have a url delimiter
pos = Url.IndexOf("?");
}

// we want to know that the link is not starting with a # for local reference.
// if local reference, return the filename otherwise the calculated path.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"profiles": {
"DocLinkChecker": {
"commandName": "Project",
"commandLineArgs": "-d \"c:\\temp\\docs\""
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using System.IO;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using DocLinkChecker.Enums;
Expand Down Expand Up @@ -324,7 +323,10 @@ private Task VerifyLocalHyperlink(Hyperlink hyperlink)
break;
}

if (!string.IsNullOrEmpty(hyperlink.UrlTopic))
// if the link references a markdown file and references a header,
// we check if it exists.
if (string.Compare(Path.GetExtension(hyperlink.UrlFullPath), ".md", true) == 0 &&
!string.IsNullOrEmpty(hyperlink.UrlTopic))
{
// validate if heading exists in file
if (Headings
Expand Down

0 comments on commit c7ec3de

Please sign in to comment.