From fe79d4ebc2e9b316d08144a230da6d825eb948fe Mon Sep 17 00:00:00 2001
From: Martin Tirion <mtirion@microsoft.com>
Date: Tue, 20 Aug 2024 14:57:02 +0200
Subject: [PATCH 1/2] Added support for DocFx extended file inclusion links

---
 .../Properties/launchSettings.json            |  8 +++
 .../DocLinkChecker.Test/HyperlinkTests.cs     | 68 +++++++++++++++++--
 .../DocLinkChecker.Test/MarkdownTests.cs      | 21 +++++-
 .../DocLinkChecker.Test/MockFileService.cs    | 16 +++++
 .../DocLinkChecker/Helpers/MarkdownHelper.cs  | 27 +++++++-
 .../DocLinkChecker/Models/Hyperlink.cs        | 17 +++++
 .../Services/LinkValidatorService.cs          |  6 +-
 7 files changed, 152 insertions(+), 11 deletions(-)
 create mode 100644 src/DocFxTocGenerator/Properties/launchSettings.json

diff --git a/src/DocFxTocGenerator/Properties/launchSettings.json b/src/DocFxTocGenerator/Properties/launchSettings.json
new file mode 100644
index 0000000..129c698
--- /dev/null
+++ b/src/DocFxTocGenerator/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+  "profiles": {
+    "DocFxTocGenerator": {
+      "commandName": "Project",
+      "commandLineArgs": "-d \"c:\\temp\\docfxindex\" --index"
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/DocLinkChecker/DocLinkChecker.Test/HyperlinkTests.cs b/src/DocLinkChecker/DocLinkChecker.Test/HyperlinkTests.cs
index 57ab102..9a20586 100644
--- a/src/DocLinkChecker/DocLinkChecker.Test/HyperlinkTests.cs
+++ b/src/DocLinkChecker/DocLinkChecker.Test/HyperlinkTests.cs
@@ -2,6 +2,7 @@
 {
     using Bogus;
     using DocLinkChecker.Enums;
+    using DocLinkChecker.Helpers;
     using DocLinkChecker.Interfaces;
     using DocLinkChecker.Models;
     using DocLinkChecker.Services;
@@ -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;
@@ -224,21 +226,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
@@ -291,10 +295,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>();
diff --git a/src/DocLinkChecker/DocLinkChecker.Test/MarkdownTests.cs b/src/DocLinkChecker/DocLinkChecker.Test/MarkdownTests.cs
index 9c713d6..0e5cb3e 100644
--- a/src/DocLinkChecker/DocLinkChecker.Test/MarkdownTests.cs
+++ b/src/DocLinkChecker/DocLinkChecker.Test/MarkdownTests.cs
@@ -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);
@@ -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]
diff --git a/src/DocLinkChecker/DocLinkChecker.Test/MockFileService.cs b/src/DocLinkChecker/DocLinkChecker.Test/MockFileService.cs
index 7c9c15e..e570847 100644
--- a/src/DocLinkChecker/DocLinkChecker.Test/MockFileService.cs
+++ b/src/DocLinkChecker/DocLinkChecker.Test/MockFileService.cs
@@ -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)
diff --git a/src/DocLinkChecker/DocLinkChecker/Helpers/MarkdownHelper.cs b/src/DocLinkChecker/DocLinkChecker/Helpers/MarkdownHelper.cs
index 1979c30..efb4a3b 100644
--- a/src/DocLinkChecker/DocLinkChecker/Helpers/MarkdownHelper.cs
+++ b/src/DocLinkChecker/DocLinkChecker/Helpers/MarkdownHelper.cs
@@ -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;
@@ -12,7 +10,6 @@
     using DocLinkChecker.Models;
     using Markdig;
     using Markdig.Extensions.Tables;
-    using Markdig.Renderers.Html;
     using Markdig.Syntax;
     using Markdig.Syntax.Inlines;
 
@@ -61,6 +58,25 @@ 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;
+                }
+
                 objects.AddRange(links);
             }
 
@@ -76,6 +92,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();
diff --git a/src/DocLinkChecker/DocLinkChecker/Models/Hyperlink.cs b/src/DocLinkChecker/DocLinkChecker/Models/Hyperlink.cs
index 9b109b4..7ab906d 100644
--- a/src/DocLinkChecker/DocLinkChecker/Models/Hyperlink.cs
+++ b/src/DocLinkChecker/DocLinkChecker/Models/Hyperlink.cs
@@ -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);
                 }
 
@@ -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:
@@ -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.
diff --git a/src/DocLinkChecker/DocLinkChecker/Services/LinkValidatorService.cs b/src/DocLinkChecker/DocLinkChecker/Services/LinkValidatorService.cs
index 4d7a711..1233c7c 100644
--- a/src/DocLinkChecker/DocLinkChecker/Services/LinkValidatorService.cs
+++ b/src/DocLinkChecker/DocLinkChecker/Services/LinkValidatorService.cs
@@ -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;
@@ -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

From 1137c9bce813d3b7b817ff65946783e8cc4d7e30 Mon Sep 17 00:00:00 2001
From: Martin Tirion <mtirion@microsoft.com>
Date: Tue, 20 Aug 2024 16:58:19 +0200
Subject: [PATCH 2/2] Added handling for DocFx special #tabs

---
 .../DocLinkChecker.Test/HyperlinkTests.cs     | 26 +++++++++++++++++++
 .../DocLinkChecker/Enums/HyperlinkType.cs     |  5 ++++
 .../DocLinkChecker/Helpers/MarkdownHelper.cs  |  7 +++++
 .../Properties/launchSettings.json            |  8 ++++++
 4 files changed, 46 insertions(+)
 create mode 100644 src/DocLinkChecker/DocLinkChecker/Properties/launchSettings.json

diff --git a/src/DocLinkChecker/DocLinkChecker.Test/HyperlinkTests.cs b/src/DocLinkChecker/DocLinkChecker.Test/HyperlinkTests.cs
index 9a20586..6a22fc1 100644
--- a/src/DocLinkChecker/DocLinkChecker.Test/HyperlinkTests.cs
+++ b/src/DocLinkChecker/DocLinkChecker.Test/HyperlinkTests.cs
@@ -205,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()
         {
diff --git a/src/DocLinkChecker/DocLinkChecker/Enums/HyperlinkType.cs b/src/DocLinkChecker/DocLinkChecker/Enums/HyperlinkType.cs
index f7612d5..82080d7 100644
--- a/src/DocLinkChecker/DocLinkChecker/Enums/HyperlinkType.cs
+++ b/src/DocLinkChecker/DocLinkChecker/Enums/HyperlinkType.cs
@@ -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>
diff --git a/src/DocLinkChecker/DocLinkChecker/Helpers/MarkdownHelper.cs b/src/DocLinkChecker/DocLinkChecker/Helpers/MarkdownHelper.cs
index efb4a3b..29ec085 100644
--- a/src/DocLinkChecker/DocLinkChecker/Helpers/MarkdownHelper.cs
+++ b/src/DocLinkChecker/DocLinkChecker/Helpers/MarkdownHelper.cs
@@ -77,6 +77,13 @@ public static (List<MarkdownObjectBase> objects, List<MarkdownError> errors)
                     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);
             }
 
diff --git a/src/DocLinkChecker/DocLinkChecker/Properties/launchSettings.json b/src/DocLinkChecker/DocLinkChecker/Properties/launchSettings.json
new file mode 100644
index 0000000..f7ca845
--- /dev/null
+++ b/src/DocLinkChecker/DocLinkChecker/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+  "profiles": {
+    "DocLinkChecker": {
+      "commandName": "Project",
+      "commandLineArgs": "-d \"c:\\temp\\docs\""
+    }
+  }
+}
\ No newline at end of file