-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathTest-DocumentationLink.ps1
More file actions
132 lines (118 loc) · 5.53 KB
/
Copy pathTest-DocumentationLink.ps1
File metadata and controls
132 lines (118 loc) · 5.53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
#!/usr/bin/env pwsh
#Requires -Version 7.0
<#
.SYNOPSIS
Validate that every relative Markdown link and heading anchor in the docs resolves.
.DESCRIPTION
Walks the documentation content under src/docs and checks every inline
Markdown link:
- A relative file target must exist on disk - a link to '../Foo.md' or
'Bar/index.md' has to resolve to a real file or directory.
- A heading anchor ('target.md#section', or a same-page '#section') must match
a heading in the target file. Slugs are computed the same way the site's
Markdown processor does, including the '_1', '_2' suffixes for duplicate
headings; an explicit attr_list id ('## Heading { #id }') is recognised as
the heading's anchor.
External links (http, https, mailto, tel), absolute paths, links inside fenced
code blocks, and links inside inline code spans are ignored on purpose.
The script changes nothing. It exits 0 when every link resolves and exits 1,
listing each broken link, otherwise - so it can gate a pull request and a push
to main in CI, alongside linting.
.EXAMPLE
./Test-DocumentationLink.ps1
Validates all documentation links; exits non-zero and lists any that are broken.
#>
[CmdletBinding()]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$Docs = Join-Path $Root 'src/docs'
function ConvertTo-Slug {
param([string]$Heading)
# Mirror the site's Markdown TOC slugifier (python-markdown default): drop
# non-ASCII, remove punctuation except word characters / whitespace / hyphen,
# lowercase, then collapse whitespace and hyphen runs into a single hyphen.
$ascii = -join ([char[]] $Heading | Where-Object { [int] $_ -lt 128 })
$clean = ($ascii -replace '[^\w\s-]', '').Trim().ToLowerInvariant()
return ($clean -replace '[\s-]+', '-')
}
function Get-HeadingSlug {
param([string]$Path)
# The anchor slugs a page exposes, matching the duplicate-slug suffixing
# ('_1', '_2', ...) the Markdown processor applies to repeated headings. A
# heading may also carry an explicit attr_list id ('## Heading { #id }'),
# which the site renderer uses as the anchor verbatim, overriding the text
# slug; recognise those so links to '#id' validate.
$slugs = [System.Collections.Generic.List[string]]::new()
$seen = @{}
$inFence = $false
foreach ($line in [System.IO.File]::ReadAllLines($Path)) {
if ($line -match '^\s*```') { $inFence = -not $inFence; continue }
if ($inFence) { continue }
if ($line -match '^#{1,6}\s+(.+?)\s*$') {
$text = $matches[1]
# An explicit attr_list id ('{ #id }' or '{: #id ... }') wins over
# the text slug, exactly as python-markdown's attr_list assigns it.
if ($text -match '\{\s*:?\s*#([-\w]+)[^}]*\}\s*$') {
$slugs.Add($matches[1])
continue
}
$base = ConvertTo-Slug $text
if (-not $base) { continue }
if ($seen.ContainsKey($base)) { $seen[$base]++; $slugs.Add("${base}_$($seen[$base])") }
else { $seen[$base] = 0; $slugs.Add($base) }
}
}
return $slugs
}
# Parse each target file's anchors once.
$slugCache = @{}
function Get-CachedSlug {
param([string]$Path)
if (-not $slugCache.ContainsKey($Path)) { $slugCache[$Path] = Get-HeadingSlug $Path }
return $slugCache[$Path]
}
$linkPattern = '\[[^\]]*\]\(([^)]+)\)'
$broken = [System.Collections.Generic.List[string]]::new()
foreach ($file in (Get-ChildItem -LiteralPath $Docs -Recurse -File -Filter *.md | Sort-Object FullName)) {
$rel = ($file.FullName.Substring($Root.Length).TrimStart('\', '/')) -replace '\\', '/'
$lines = [System.IO.File]::ReadAllLines($file.FullName)
$inFence = $false
for ($n = 0; $n -lt $lines.Count; $n++) {
$line = $lines[$n]
if ($line -match '^\s*```') { $inFence = -not $inFence; continue }
if ($inFence) { continue }
# Remove inline code spans so links shown as examples are not validated.
$scrubbed = $line -replace '`[^`]*`', ''
foreach ($m in [regex]::Matches($scrubbed, $linkPattern)) {
$target = $m.Groups[1].Value.Trim() -replace '\s+"[^"]*"$', '' # strip optional link title
if (-not $target) { continue }
if ($target -match '^(https?:|mailto:|tel:|//)') { continue }
$lineNo = $n + 1
$path, $frag = $target -split '#', 2
if (-not $path) {
if ($frag -and ($frag -notin (Get-CachedSlug $file.FullName))) {
$broken.Add("${rel}:${lineNo}: '#$frag' - no heading with that anchor on this page")
}
continue
}
if ($path.StartsWith('/')) { continue } # absolute site path - not resolvable here
$resolved = [System.IO.Path]::GetFullPath((Join-Path $file.DirectoryName $path))
if (-not (Test-Path -LiteralPath $resolved)) {
$broken.Add("${rel}:${lineNo}: '$target' - target does not exist")
continue
}
if ($frag -and $resolved.EndsWith('.md') -and ($frag -notin (Get-CachedSlug $resolved))) {
$broken.Add("${rel}:${lineNo}: '$target' - no heading '#$frag' in the target file")
}
}
}
}
if ($broken.Count -eq 0) {
Write-Output 'All documentation links resolve.'
exit 0
}
Write-Output "Broken documentation links ($($broken.Count)):"
$broken | Sort-Object | ForEach-Object { Write-Output " - $_" }
exit 1