microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
3a3a0fdf923d96a9e8a9ac734c73f24433b525e8

Branches

Tags

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

Clone

HTTPS

Download ZIP

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()]
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
53function 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
158function 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
195function 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
3651. Review the broken links listed above
3662. Update or remove invalid links
3673. Re-run the link check to verify fixes
368
369For 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
385Great 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
394if ($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