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