using System.Text.Json; using System.Text.RegularExpressions; var repositoryRoot = FindRepositoryRoot(args); var errors = new List(); 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 GetMarkdownFiles(string repositoryRoot, List errors) { var files = new List(); 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 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 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) { var root = navigation.RootElement; var itemIds = GetItemIds(root); if (!root.TryGetProperty("sequence", out var sequence) || sequence.ValueKind != JsonValueKind.Array) return; var containingDirectory = Path.GetDirectoryName(navigationFile)!; foreach (var element in sequence.EnumerateArray()) { if (element.ValueKind != JsonValueKind.String) continue; var entry = element.GetString(); if (string.IsNullOrWhiteSpace(entry) || itemIds.Contains(entry)) continue; var localEntry = entry.Replace('/', Path.DirectorySeparatorChar); var resolvedPath = Path.GetFullPath(Path.Combine(containingDirectory, localEntry)); if (!Path.Exists(resolvedPath)) { errors.Add( $"Missing navigation target in {RelativePath(repositoryRoot, navigationFile)}: {entry}"); } } } } static HashSet GetItemIds(JsonElement root) { var itemIds = new HashSet(StringComparer.Ordinal); if (!root.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) return itemIds; foreach (var item in items.EnumerateArray()) { if (item.ValueKind == JsonValueKind.Object && item.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String && id.GetString() is { Length: > 0 } value) itemIds.Add(value); } return itemIds; } 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( @"!?(?:\[[^\]]*\])\((?[^)]+)\)", RegexOptions.CultureInvariant); }