Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, LocalLinkValidator> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
{
Expand All @@ -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('#');
Expand Down Expand Up @@ -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.
Expand All @@ -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))
{
Expand Down Expand Up @@ -159,10 +170,10 @@ static bool IsValidHtml(string tag, string headingIdWithoutHash)
}
}

private static IEnumerable<string> GetIncludes(string fileContents, string baseDirectory)
private IEnumerable<string> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,18 @@ public static async Task<List<LinkError>> 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
document.Descendants<LinkInline>().Where(link => link.Url is not null))
{
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(
Expand All @@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<LinkError> result = await GetResultsAsync();

Assert.Empty(result);
}

[Fact]
public async Task TestExcludedDirectoriesAreNotAnalyzed()
{
Expand Down
Loading