microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/address-powershell-test-comments

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

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()]
35param(
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
50Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'Modules/LintingHelpers.psm1') -Force
51Import-Module (Join-Path -Path $PSScriptRoot -ChildPath '../lib/Modules/CIHelpers.psm1') -Force
52
53$script:SkipMain = $env:HVE_SKIP_MAIN -eq '1'
54
55function 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
160function 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
198if (-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
3631. Review the broken links listed above
3642. Update or remove invalid links
3653. Re-run the link check to verify fixes
366
367For 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
384Great 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