microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/linting/Markdown-Link-Check.ps1
405lines · modecode
| 1 | #!/usr/bin/env pwsh |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | #Requires -Version 7.0 |
| 5 | |
| 6 | <# |
| 7 | .SYNOPSIS |
| 8 | Repository-aware wrapper for markdown-link-check. |
| 9 | |
| 10 | .DESCRIPTION |
| 11 | Runs markdown-link-check with the repo-specific configuration to validate |
| 12 | all markdown links across the repository. Only checks files that are tracked |
| 13 | by git (respects .gitignore and only includes committed/staged files). |
| 14 | |
| 15 | .PARAMETER Path |
| 16 | One or more files or directories to scan. Directories are searched |
| 17 | recursively for Markdown files. Defaults to the Docsify navigation sources. |
| 18 | |
| 19 | .PARAMETER ConfigPath |
| 20 | Path to the shared markdown-link-check configuration file. |
| 21 | |
| 22 | .PARAMETER Quiet |
| 23 | Suppress non-error output from markdown-link-check. |
| 24 | |
| 25 | .EXAMPLE |
| 26 | # Validate all markdown files in default paths |
| 27 | ./Markdown-Link-Check.ps1 |
| 28 | |
| 29 | .EXAMPLE |
| 30 | # Validate specific path with verbose output |
| 31 | ./Markdown-Link-Check.ps1 -Path ".github" -Quiet:$false |
| 32 | #> |
| 33 | |
| 34 | [CmdletBinding()] |
| 35 | param( |
| 36 | [string[]]$Path = @( |
| 37 | ".", |
| 38 | ".github", |
| 39 | ".devcontainer" |
| 40 | ), |
| 41 | |
| 42 | [string]$ConfigPath = (Join-Path -Path $PSScriptRoot -ChildPath 'markdown-link-check.config.json'), |
| 43 | |
| 44 | [switch]$Quiet |
| 45 | ) |
| 46 | |
| 47 | $ErrorActionPreference = 'Stop' |
| 48 | |
| 49 | # Import LintingHelpers module |
| 50 | Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'Modules/LintingHelpers.psm1') -Force |
| 51 | Import-Module (Join-Path -Path $PSScriptRoot -ChildPath '../lib/Modules/CIHelpers.psm1') -Force |
| 52 | |
| 53 | function Get-MarkdownTarget { |
| 54 | <# |
| 55 | .SYNOPSIS |
| 56 | Resolves Markdown files to validate from provided path arguments. |
| 57 | |
| 58 | .DESCRIPTION |
| 59 | Accepts files or directories, expanding directories to all git-tracked |
| 60 | Markdown files discovered recursively, and returns a sorted, unique list |
| 61 | of absolute file paths for downstream validation. Only checks files that |
| 62 | are tracked by git (respects .gitignore). |
| 63 | |
| 64 | .PARAMETER InputPath |
| 65 | Files or directories that may contain Markdown content. |
| 66 | |
| 67 | .OUTPUTS |
| 68 | System.String[] |
| 69 | #> |
| 70 | param( |
| 71 | [string[]]$InputPath |
| 72 | ) |
| 73 | |
| 74 | $targets = @() |
| 75 | $repoRoot = git rev-parse --show-toplevel 2>$null |
| 76 | |
| 77 | if ($LASTEXITCODE -ne 0) { |
| 78 | Write-Warning "Not in a git repository, falling back to file system search" |
| 79 | # Fallback to original implementation if not in git repo |
| 80 | foreach ($item in $InputPath) { |
| 81 | if ([string]::IsNullOrWhiteSpace($item)) { |
| 82 | continue |
| 83 | } |
| 84 | |
| 85 | $resolved = Resolve-Path -LiteralPath $item -ErrorAction SilentlyContinue |
| 86 | if (-not $resolved) { |
| 87 | Write-Warning "Unable to resolve path: $item" |
| 88 | continue |
| 89 | } |
| 90 | |
| 91 | foreach ($resolvedPath in $resolved) { |
| 92 | if (Test-Path -LiteralPath $resolvedPath -PathType Container) { |
| 93 | $targets += Get-ChildItem -LiteralPath $resolvedPath -Recurse -Include *.md | |
| 94 | Where-Object { -not $_.PSIsContainer } | |
| 95 | Select-Object -ExpandProperty FullName |
| 96 | } |
| 97 | else { |
| 98 | $targets += $resolvedPath.ProviderPath |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | return ($targets | Sort-Object -Unique) |
| 103 | } |
| 104 | |
| 105 | Write-Verbose "Searching for git-tracked markdown files..." |
| 106 | Write-Verbose "Repository root: $repoRoot" |
| 107 | |
| 108 | # Git-aware implementation |
| 109 | foreach ($item in $InputPath) { |
| 110 | if ([string]::IsNullOrWhiteSpace($item)) { |
| 111 | continue |
| 112 | } |
| 113 | |
| 114 | # Check if it's a specific file or directory |
| 115 | if (Test-Path -Path $item -PathType Leaf) { |
| 116 | # Specific file - check if it's tracked by git |
| 117 | $absolutePath = (Resolve-Path $item).Path |
| 118 | $relativePath = [System.IO.Path]::GetRelativePath($repoRoot, $absolutePath) |
| 119 | $tracked = git ls-files $relativePath 2>$null |
| 120 | |
| 121 | if ($tracked -and $item -like "*.md") { |
| 122 | $targets += $absolutePath |
| 123 | } |
| 124 | elseif (-not $tracked) { |
| 125 | Write-Warning "File not tracked by git: $item" |
| 126 | } |
| 127 | } |
| 128 | elseif (Test-Path -Path $item -PathType Container) { |
| 129 | # Directory - get all tracked markdown files |
| 130 | $absolutePath = (Resolve-Path $item).Path |
| 131 | $relativePath = [System.IO.Path]::GetRelativePath($repoRoot, $absolutePath) |
| 132 | $searchPath = if ($relativePath -eq '.') { '*.md' } else { "$relativePath/**/*.md" } |
| 133 | |
| 134 | Write-Verbose "Searching in: $searchPath" |
| 135 | $trackedFiles = git ls-files $searchPath 2>$null | |
| 136 | Where-Object { $_ -notlike 'scripts/tests/Fixtures/*' } |
| 137 | |
| 138 | |
| 139 | |
| 140 | if ($trackedFiles) { |
| 141 | foreach ($file in $trackedFiles) { |
| 142 | $fullPath = Join-Path $repoRoot $file |
| 143 | if (Test-Path $fullPath) { |
| 144 | $targets += $fullPath |
| 145 | } |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | else { |
| 150 | Write-Warning "Unable to resolve path: $item" |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | Write-Verbose "Found $($targets.Count) git-tracked markdown files" |
| 155 | return ($targets | Sort-Object -Unique) |
| 156 | } |
| 157 | |
| 158 | function Get-RelativePrefix { |
| 159 | <# |
| 160 | .SYNOPSIS |
| 161 | Builds a normalized relative prefix between two paths. |
| 162 | |
| 163 | .DESCRIPTION |
| 164 | Computes the relative path from a source directory to a destination and |
| 165 | enforces forward-slash separators with a trailing slash when required to |
| 166 | produce consistent link prefixes. |
| 167 | |
| 168 | .PARAMETER FromPath |
| 169 | The directory from which the relative path should be calculated. |
| 170 | |
| 171 | .PARAMETER ToPath |
| 172 | The target path that should be expressed relative to the source. |
| 173 | |
| 174 | .OUTPUTS |
| 175 | System.String |
| 176 | #> |
| 177 | param( |
| 178 | [string]$FromPath, |
| 179 | [string]$ToPath |
| 180 | ) |
| 181 | |
| 182 | $relative = [System.IO.Path]::GetRelativePath($FromPath, $ToPath) |
| 183 | if ([string]::IsNullOrWhiteSpace($relative) -or $relative -eq '.') { |
| 184 | return '' |
| 185 | } |
| 186 | |
| 187 | $normalized = $relative -replace '\\', '/' |
| 188 | if (-not $normalized.EndsWith('/')) { |
| 189 | $normalized += '/' |
| 190 | } |
| 191 | |
| 192 | return $normalized |
| 193 | } |
| 194 | |
| 195 | function Invoke-MarkdownLinkCheck { |
| 196 | [CmdletBinding()] |
| 197 | [OutputType([void])] |
| 198 | param( |
| 199 | [string[]]$Path, |
| 200 | [string]$ConfigPath, |
| 201 | [switch]$Quiet |
| 202 | ) |
| 203 | |
| 204 | $scriptRootParent = Split-Path -Path $PSScriptRoot -Parent |
| 205 | $repoRootPath = Split-Path -Path $scriptRootParent -Parent |
| 206 | $repoRoot = Resolve-Path -LiteralPath $repoRootPath |
| 207 | $config = Resolve-Path -LiteralPath $ConfigPath -ErrorAction Stop |
| 208 | $filesToCheck = @(Get-MarkdownTarget -InputPath $Path) |
| 209 | |
| 210 | if (-not $filesToCheck -or @($filesToCheck).Count -eq 0) { |
| 211 | throw 'No markdown files were found to validate.' |
| 212 | } |
| 213 | |
| 214 | $cli = Join-Path -Path $repoRoot.Path -ChildPath 'node_modules/.bin/markdown-link-check' |
| 215 | if ($IsWindows) { |
| 216 | $cli += '.cmd' |
| 217 | } |
| 218 | |
| 219 | if (-not (Test-Path -LiteralPath $cli)) { |
| 220 | throw 'markdown-link-check is not installed. Run "npm install --save-dev markdown-link-check" first.' |
| 221 | } |
| 222 | |
| 223 | $baseArguments = @('-c', $config.Path) |
| 224 | if ($Quiet) { |
| 225 | $baseArguments += '-q' |
| 226 | } |
| 227 | |
| 228 | $failedFiles = @() |
| 229 | $brokenLinks = @() |
| 230 | $totalLinks = 0 |
| 231 | $totalFiles = $filesToCheck.Count |
| 232 | |
| 233 | Push-Location $repoRoot.Path |
| 234 | try { |
| 235 | foreach ($file in $filesToCheck) { |
| 236 | $absolute = Resolve-Path -LiteralPath $file |
| 237 | $relative = [System.IO.Path]::GetRelativePath($repoRoot.Path, $absolute) |
| 238 | Write-Output "Checking $relative" |
| 239 | |
| 240 | # Create temp file for XML output |
| 241 | $xmlFile = [System.IO.Path]::GetTempFileName() + '.xml' |
| 242 | try { |
| 243 | $commandArgs = $baseArguments + @($relative, '--reporters', 'default,junit', '--junit-output', $xmlFile) |
| 244 | |
| 245 | # Run markdown-link-check with XML output and capture output |
| 246 | $output = & $cli @commandArgs 2>&1 |
| 247 | $exitCode = $LASTEXITCODE |
| 248 | |
| 249 | # Display output if verbose mode or if there were errors |
| 250 | if ($VerbosePreference -eq 'Continue' -or $exitCode -ne 0) { |
| 251 | Write-Host $output |
| 252 | } |
| 253 | |
| 254 | # Parse XML output |
| 255 | if (Test-Path $xmlFile) { |
| 256 | [xml]$xml = Get-Content $xmlFile -Raw -Encoding utf8 |
| 257 | |
| 258 | foreach ($testsuite in $xml.testsuites.testsuite) { |
| 259 | foreach ($testcase in $testsuite.testcase) { |
| 260 | $totalLinks++ |
| 261 | |
| 262 | # Extract properties |
| 263 | $url = ($testcase.properties.property | Where-Object { $_.name -eq 'url' }).value |
| 264 | $status = ($testcase.properties.property | Where-Object { $_.name -eq 'status' }).value |
| 265 | $statusCode = ($testcase.properties.property | Where-Object { $_.name -eq 'statusCode' }).value |
| 266 | |
| 267 | # Display human-readable output if not quiet |
| 268 | if (-not $Quiet) { |
| 269 | if ($status -eq 'alive') { |
| 270 | Write-Host " ✓ $url" -ForegroundColor Green |
| 271 | } |
| 272 | elseif ($status -eq 'ignored') { |
| 273 | Write-Host " / $url (ignored)" -ForegroundColor Yellow |
| 274 | } |
| 275 | elseif ($status -eq 'dead') { |
| 276 | Write-Host " ✖ $url → Status: $statusCode" -ForegroundColor Red |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | # Process broken links |
| 281 | if ($status -eq 'dead') { |
| 282 | $brokenLinks += @{ |
| 283 | File = $relative |
| 284 | Link = $url |
| 285 | Status = "$statusCode" |
| 286 | } |
| 287 | |
| 288 | Write-CIAnnotation -Message "Broken link: $url (Status: $statusCode)" -Level Error -File $relative |
| 289 | } |
| 290 | } |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | if ($exitCode -ne 0) { |
| 295 | $failedFiles += $relative |
| 296 | } |
| 297 | } |
| 298 | catch { |
| 299 | Write-Warning "Failed to parse XML output for $relative : $_" |
| 300 | if ($exitCode -ne 0) { |
| 301 | $failedFiles += $relative |
| 302 | } |
| 303 | } |
| 304 | finally { |
| 305 | if (Test-Path $xmlFile) { |
| 306 | Remove-Item $xmlFile -Force |
| 307 | } |
| 308 | } |
| 309 | } |
| 310 | } |
| 311 | finally { |
| 312 | Pop-Location |
| 313 | } |
| 314 | |
| 315 | # Create logs directory and export results |
| 316 | $logsDir = Join-Path -Path $repoRoot.Path -ChildPath 'logs' |
| 317 | if (-not (Test-Path $logsDir)) { |
| 318 | New-Item -ItemType Directory -Path $logsDir -Force | Out-Null |
| 319 | } |
| 320 | |
| 321 | $results = @{ |
| 322 | timestamp = (Get-Date).ToUniversalTime().ToString('o') |
| 323 | script = 'markdown-link-check' |
| 324 | summary = @{ |
| 325 | total_files = $totalFiles |
| 326 | files_with_broken_links = $failedFiles.Count |
| 327 | total_links_checked = $totalLinks |
| 328 | total_broken_links = $brokenLinks.Count |
| 329 | } |
| 330 | broken_links = $brokenLinks |
| 331 | } |
| 332 | |
| 333 | $resultsPath = Join-Path -Path $logsDir -ChildPath 'markdown-link-check-results.json' |
| 334 | $results | ConvertTo-Json -Depth 10 | Set-Content -Path $resultsPath -Encoding UTF8 |
| 335 | |
| 336 | # Generate GitHub step summary |
| 337 | if ($failedFiles.Count -gt 0) { |
| 338 | $summaryContent = @" |
| 339 | ## ❌ Markdown Link Check Failed |
| 340 | |
| 341 | **Files with broken links:** $($failedFiles.Count) / $totalFiles |
| 342 | **Total broken links:** $($brokenLinks.Count) |
| 343 | |
| 344 | ### Broken Links |
| 345 | |
| 346 | | File | Broken Link | |
| 347 | |------|-------------| |
| 348 | "@ |
| 349 | |
| 350 | foreach ($link in $brokenLinks) { |
| 351 | $safeFile = if ((Get-CIPlatform) -eq 'azdo') { |
| 352 | ConvertTo-AzureDevOpsEscaped -Value $link.File |
| 353 | } else { $link.File } |
| 354 | $safeLink = if ((Get-CIPlatform) -eq 'azdo') { |
| 355 | ConvertTo-AzureDevOpsEscaped -Value $link.Link |
| 356 | } else { $link.Link } |
| 357 | $summaryContent += "`n| ``$safeFile`` | ``$safeLink`` |" |
| 358 | } |
| 359 | |
| 360 | $summaryContent += @" |
| 361 | |
| 362 | |
| 363 | ### How to Fix |
| 364 | |
| 365 | 1. Review the broken links listed above |
| 366 | 2. Update or remove invalid links |
| 367 | 3. Re-run the link check to verify fixes |
| 368 | |
| 369 | For more information, see the [markdown-link-check documentation](https://github.com/tcort/markdown-link-check). |
| 370 | "@ |
| 371 | |
| 372 | Write-CIStepSummary -Content $summaryContent |
| 373 | Set-CIEnv -Name "MARKDOWN_LINK_CHECK_FAILED" -Value "true" |
| 374 | |
| 375 | throw ("markdown-link-check reported failures for: {0}" -f ($failedFiles -join ', ')) |
| 376 | } |
| 377 | else { |
| 378 | $summaryContent = @" |
| 379 | ## ✅ Markdown Link Check Passed |
| 380 | |
| 381 | **Files checked:** $totalFiles |
| 382 | **Total links checked:** $totalLinks |
| 383 | **Broken links:** 0 |
| 384 | |
| 385 | Great job! All markdown links are valid. 🎉 |
| 386 | "@ |
| 387 | |
| 388 | Write-CIStepSummary -Content $summaryContent |
| 389 | Write-Output 'markdown-link-check completed successfully.' |
| 390 | } |
| 391 | } |
| 392 | |
| 393 | #region Main Execution |
| 394 | if ($MyInvocation.InvocationName -ne '.') { |
| 395 | try { |
| 396 | Invoke-MarkdownLinkCheck -Path $Path -ConfigPath $ConfigPath -Quiet:$Quiet |
| 397 | exit 0 |
| 398 | } |
| 399 | catch { |
| 400 | Write-Error -ErrorAction Continue "Markdown-Link-Check failed: $($_.Exception.Message)" |
| 401 | Write-CIAnnotation -Message $_.Exception.Message -Level Error |
| 402 | exit 1 |
| 403 | } |
| 404 | } |
| 405 | #endregion Main Execution |
| 406 | |