-
Notifications
You must be signed in to change notification settings - Fork 39
Expand file tree
/
Copy pathProgram.cs
More file actions
172 lines (147 loc) · 4.86 KB
/
Copy pathProgram.cs
File metadata and controls
172 lines (147 loc) · 4.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
using System.Text.Json;
using System.Text.RegularExpressions;
var repositoryRoot = FindRepositoryRoot(args);
var errors = new List<string>();
var markdownFiles = GetMarkdownFiles(repositoryRoot, errors);
foreach (var markdownFile in markdownFiles)
ValidateMarkdownLinks(repositoryRoot, markdownFile, errors);
var navigationFiles = Directory
.EnumerateFiles(Path.Combine(repositoryRoot, "docs"), "_nav.json", SearchOption.AllDirectories)
.Order(StringComparer.Ordinal)
.ToArray();
foreach (var navigationFile in navigationFiles)
ValidateNavigation(repositoryRoot, navigationFile, errors);
if (errors.Count != 0)
{
foreach (var error in errors)
Console.Error.WriteLine(error);
return 1;
}
Console.WriteLine(
$"Validated {markdownFiles.Count} Markdown files and {navigationFiles.Length} navigation files.");
return 0;
static string FindRepositoryRoot(string[] args)
{
if (args.Length > 1)
throw new ArgumentException("Usage: ValidateDocs [repository-root]");
if (args.Length == 1)
return Path.GetFullPath(args[0]);
for (var directory = new DirectoryInfo(Environment.CurrentDirectory);
directory != null;
directory = directory.Parent)
{
if (File.Exists(Path.Combine(directory.FullName, "README.md")) &&
Directory.Exists(Path.Combine(directory.FullName, "docs")))
return directory.FullName;
}
throw new DirectoryNotFoundException(
"Could not find the repository root. Pass it as the first argument.");
}
static List<string> GetMarkdownFiles(string repositoryRoot, List<string> errors)
{
var files = new List<string>();
AddRequiredFile(Path.Combine(repositoryRoot, "README.md"));
var docsDirectory = Path.Combine(repositoryRoot, "docs");
if (Directory.Exists(docsDirectory))
{
files.AddRange(Directory.EnumerateFiles(
docsDirectory,
"*.md",
SearchOption.AllDirectories));
}
else
{
errors.Add("Missing documentation directory: docs");
}
AddRequiredFile(Path.Combine(
repositoryRoot,
"src",
"ZoneTree",
"docs",
"ZoneTree",
"README-NUGET.md"));
files.Sort(StringComparer.Ordinal);
return files;
void AddRequiredFile(string path)
{
if (File.Exists(path))
files.Add(path);
else
errors.Add($"Missing documentation file: {RelativePath(repositoryRoot, path)}");
}
}
static void ValidateMarkdownLinks(
string repositoryRoot,
string markdownFile,
List<string> errors)
{
var content = File.ReadAllText(markdownFile);
foreach (Match match in MarkdownLinkPattern().Matches(content))
{
var target = match.Groups["target"].Value.Trim();
if (target.StartsWith('<') && target.EndsWith('>'))
target = target[1..^1];
if (IsExternalOrDocumentAnchor(target))
continue;
var fragmentIndex = target.IndexOf('#');
var pathPart = fragmentIndex < 0 ? target : target[..fragmentIndex];
if (string.IsNullOrWhiteSpace(pathPart))
continue;
try
{
pathPart = Uri.UnescapeDataString(pathPart)
.Replace('/', Path.DirectorySeparatorChar);
var containingDirectory = Path.GetDirectoryName(markdownFile)!;
var resolvedPath = Path.GetFullPath(Path.Combine(containingDirectory, pathPart));
if (!Path.Exists(resolvedPath))
{
errors.Add(
$"Broken local link in {RelativePath(repositoryRoot, markdownFile)}: {target}");
}
}
catch (Exception exception) when (
exception is ArgumentException or UriFormatException or NotSupportedException)
{
errors.Add(
$"Invalid local link in {RelativePath(repositoryRoot, markdownFile)}: " +
$"{target} ({exception.Message})");
}
}
}
static bool IsExternalOrDocumentAnchor(string target) =>
target.StartsWith('#') ||
target.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
target.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
target.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) ||
target.StartsWith("data:", StringComparison.OrdinalIgnoreCase);
static void ValidateNavigation(
string repositoryRoot,
string navigationFile,
List<string> errors)
{
JsonDocument navigation;
try
{
navigation = JsonDocument.Parse(File.ReadAllText(navigationFile));
}
catch (JsonException exception)
{
errors.Add(
$"Invalid JSON in {RelativePath(repositoryRoot, navigationFile)}: {exception.Message}");
return;
}
using (navigation)
{
// Navigation sequences are intentionally tolerant of missing targets. The
// docs renderer ignores absent entries, so CI should only require valid JSON.
}
}
static string RelativePath(string repositoryRoot, string path) =>
Path.GetRelativePath(repositoryRoot, path).Replace('\\', '/');
static Regex MarkdownLinkPattern() => MarkdownPatterns.Link;
static class MarkdownPatterns
{
internal static readonly Regex Link = new(
@"!?(?:\[[^\]]*\])\((?<target>[^)]+)\)",
RegexOptions.CultureInvariant);
}