From a8052ecb4ce5a09ba64cfaea98e0f360c2aeef18 Mon Sep 17 00:00:00 2001 From: Genevieve Warren <24882762+gewarren@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:47:52 -0700 Subject: [PATCH] fix root directory for tilde links --- .../LinkValidator/LinkValidatorCreator.cs | 18 +++++++----- .../LinkValidator/LocalLinkValidator.cs | 29 +++++++++++++------ .../MarkdownFilesAnalyzer.cs | 28 +++++++++++++++++- .../LocalLinkValidatorTests.cs | 19 ++++++++++++ 4 files changed, 77 insertions(+), 17 deletions(-) diff --git a/docs-verifier/src/MarkdownLinksVerifier/LinkValidator/LinkValidatorCreator.cs b/docs-verifier/src/MarkdownLinksVerifier/LinkValidator/LinkValidatorCreator.cs index 9e12aec..46e6289 100644 --- a/docs-verifier/src/MarkdownLinksVerifier/LinkValidator/LinkValidatorCreator.cs +++ b/docs-verifier/src/MarkdownLinksVerifier/LinkValidator/LinkValidatorCreator.cs @@ -6,26 +6,30 @@ internal static class LinkValidatorCreator { private static readonly OnlineLinkValidator s_onlineValidator = new(); private static readonly MailtoLinkValidator s_mailtoValidator = new(); - private static readonly Dictionary s_localValidatorDictionary = new(); + private static readonly Dictionary<(string BaseDirectory, string DocfxConfigurationDirectory), LocalLinkValidator> s_localValidatorDictionary = new(); - internal static ILinkValidator Create(LinkClassification classification, string baseDirectory) + internal static ILinkValidator Create( + LinkClassification classification, + string baseDirectory, + string docfxConfigurationDirectory) => classification switch { LinkClassification.Online => s_onlineValidator, - LinkClassification.Local => GetLocalLinkValidator(baseDirectory), + LinkClassification.Local => GetLocalLinkValidator(baseDirectory, docfxConfigurationDirectory), LinkClassification.Mailto => s_mailtoValidator, _ => throw new ArgumentException($"Invalid {nameof(classification)}.", nameof(classification)) }; - private static LocalLinkValidator GetLocalLinkValidator(string baseDirectory) + private static LocalLinkValidator GetLocalLinkValidator(string baseDirectory, string docfxConfigurationDirectory) { - if (s_localValidatorDictionary.TryGetValue(baseDirectory, out LocalLinkValidator? cachedValidator)) + var key = (baseDirectory, docfxConfigurationDirectory); + if (s_localValidatorDictionary.TryGetValue(key, out LocalLinkValidator? cachedValidator)) { return cachedValidator; } - var validator = new LocalLinkValidator(baseDirectory); - s_localValidatorDictionary.Add(baseDirectory, validator); + var validator = new LocalLinkValidator(baseDirectory, docfxConfigurationDirectory); + s_localValidatorDictionary.Add(key, validator); return validator; } } diff --git a/docs-verifier/src/MarkdownLinksVerifier/LinkValidator/LocalLinkValidator.cs b/docs-verifier/src/MarkdownLinksVerifier/LinkValidator/LocalLinkValidator.cs index e5de0e7..c39eb1b 100644 --- a/docs-verifier/src/MarkdownLinksVerifier/LinkValidator/LocalLinkValidator.cs +++ b/docs-verifier/src/MarkdownLinksVerifier/LinkValidator/LocalLinkValidator.cs @@ -10,6 +10,7 @@ namespace MarkdownLinksVerifier.LinkValidator; internal class LocalLinkValidator : ILinkValidator { private readonly string _baseDirectory; + private readonly string _docfxConfigurationDirectory; private static readonly MarkdownPipeline s_pipeline = new MarkdownPipelineBuilder().UseAutoIdentifiers(AutoIdentifierOptions.GitHub).Build(); // TODO: Is AutoIdentifierOptions.GitHub the correct value to use? // https://github.com/dotnet/docfx/blob/64fa7e4ed1c1f416e672f2aee2307fd98a21383e/src/Microsoft.DocAsCode.Dfm/Rules/DfmIncludeBlockRule.cs#L44 @@ -20,7 +21,11 @@ internal class LocalLinkValidator : ILinkValidator new(@"^\[!INCLUDE\s*\-?\s*\[((?:\[[^\]]*\]|[^\[\]]|\](?=[^\[]*\]))*)\]\(\s*?(?:\s+(['""])([\s\S]*?)\3)?\s*\)\]", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline, TimeSpan.FromSeconds(10)); - public LocalLinkValidator(string baseDirectory) => _baseDirectory = baseDirectory; + public LocalLinkValidator(string baseDirectory, string docfxConfigurationDirectory) + { + _baseDirectory = baseDirectory; + _docfxConfigurationDirectory = docfxConfigurationDirectory; + } public ValidationResult Validate(string link, string filePath) { @@ -40,7 +45,7 @@ public ValidationResult Validate(string link, string filePath) } link = link.Replace("%20", " ", StringComparison.Ordinal); - link = AdjustLinkPath(link, _baseDirectory); + link = AdjustLinkPath(link, _baseDirectory, _docfxConfigurationDirectory); string? headingIdWithoutHash = null; int lastIndex = link.LastIndexOf('#'); @@ -84,13 +89,19 @@ public ValidationResult Validate(string link, string filePath) } } - private static string AdjustLinkPath(string link, string baseDirectory) + private static string AdjustLinkPath( + string link, + string baseDirectory, + string docfxConfigurationDirectory) { string relativeTo = baseDirectory; - if (link.StartsWith('/') || link.StartsWith("~/", StringComparison.Ordinal)) + if (link.StartsWith("~/", StringComparison.Ordinal)) + { + link = link[1..]; + relativeTo = docfxConfigurationDirectory; + } + else if (link.StartsWith('/')) { - // The leading slash doesn't matter whether it exists or not. - // Only the ~ is problematic and we want to remove it. link = link[1..]; // Links that start with / are relative to the repository root. // TODO: Does it work locally (e.g. in VS Code)? Consider a warning for it. @@ -100,7 +111,7 @@ private static string AdjustLinkPath(string link, string baseDirectory) return Path.GetFullPath(Path.Join(relativeTo, link)); } - private static bool IsHeadingValid(string path, string headingIdWithoutHash) + private bool IsHeadingValid(string path, string headingIdWithoutHash) { if (headingIdWithoutHash.StartsWith("tab/", StringComparison.Ordinal)) { @@ -159,10 +170,10 @@ static bool IsValidHtml(string tag, string headingIdWithoutHash) } } - private static IEnumerable GetIncludes(string fileContents, string baseDirectory) + private IEnumerable GetIncludes(string fileContents, string baseDirectory) { return s_inlineIncludeRegex .Matches(fileContents) - .Select(m => AdjustLinkPath(m.Groups[2].Value, baseDirectory)); + .Select(m => AdjustLinkPath(m.Groups[2].Value, baseDirectory, _docfxConfigurationDirectory)); } } diff --git a/docs-verifier/src/MarkdownLinksVerifier/MarkdownFilesAnalyzer.cs b/docs-verifier/src/MarkdownLinksVerifier/MarkdownFilesAnalyzer.cs index 4131a40..dc5640f 100644 --- a/docs-verifier/src/MarkdownLinksVerifier/MarkdownFilesAnalyzer.cs +++ b/docs-verifier/src/MarkdownLinksVerifier/MarkdownFilesAnalyzer.cs @@ -36,6 +36,7 @@ public static async Task> GetResultsAsync( string? directory = Path.GetDirectoryName(file) ?? throw new InvalidOperationException($"Cannot get directory for '{file}'."); + string docfxConfigurationDirectory = FindDocfxConfigurationDirectory(directory, rootDirectory); MarkdownDocument document = Markdown.Parse(await File.ReadAllTextAsync(file), s_pipeline); foreach (LinkInline link in @@ -43,7 +44,10 @@ public static async Task> GetResultsAsync( { string url = link.Url!; LinkClassification classification = Classifier.Classify(url); - ILinkValidator validator = LinkValidatorCreator.Create(classification, directory); + ILinkValidator validator = LinkValidatorCreator.Create( + classification, + directory, + docfxConfigurationDirectory); if (!IsLinkExcluded(config, url) && validator.Validate(url, file) is { State: not ValidationState.Valid, AbsolutePathWithoutHeading: var absolutePath }) { result.Add(new LinkError( @@ -63,5 +67,27 @@ static bool IsLinkExcluded(MarkdownLinksVerifierConfiguration? config, string ur return config.IsLinkExcluded(url); } + + static string FindDocfxConfigurationDirectory(string fileDirectory, string rootDirectory) + { + string fullRootDirectory = Path.GetFullPath(rootDirectory); + DirectoryInfo? directory = new(Path.GetFullPath(fileDirectory)); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "docfx.json"))) + { + return directory.FullName; + } + + if (Path.GetRelativePath(fullRootDirectory, directory.FullName) == ".") + { + break; + } + + directory = directory.Parent; + } + + return fullRootDirectory; + } } } diff --git a/docs-verifier/tests/MarkdownLinksVerifier/LinkValidatorTests/LocalLinkValidatorTests.cs b/docs-verifier/tests/MarkdownLinksVerifier/LinkValidatorTests/LocalLinkValidatorTests.cs index 9417fe9..30e9529 100644 --- a/docs-verifier/tests/MarkdownLinksVerifier/LinkValidatorTests/LocalLinkValidatorTests.cs +++ b/docs-verifier/tests/MarkdownLinksVerifier/LinkValidatorTests/LocalLinkValidatorTests.cs @@ -42,6 +42,25 @@ public async Task TestSimpleCase_FileDoesNotExist() Assert.Equal(expected, result, s_linkErrorComparer); } + [Fact] + public async Task TestTildeLinkIsRelativeToNearestDocfxConfiguration() + { + using var workspace = new Workspace + { + Files = + { + { "/docs/docfx.json", "{}" }, + { "/docs/content/page.md", "[target](~/content/target.md)" }, + { "/docs/content/target.md", "Target" } + } + }; + + await workspace.InitializeAsync(); + List result = await GetResultsAsync(); + + Assert.Empty(result); + } + [Fact] public async Task TestExcludedDirectoriesAreNotAnalyzed() {