diff --git a/eng/common/pipelines/templates/steps/verify-links.yml b/eng/common/pipelines/templates/steps/verify-links.yml index 62a95aea8a..9f629fdad9 100644 --- a/eng/common/pipelines/templates/steps/verify-links.yml +++ b/eng/common/pipelines/templates/steps/verify-links.yml @@ -9,6 +9,7 @@ parameters: BranchReplaceRegex: "^(${env:SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI}(?:\\.git)?/(?:blob|tree)/)$(DefaultBranch)(/.*)$" BranchReplacementName: "${env:SYSTEM_PULLREQUEST_SOURCECOMMITID}" Condition: succeeded() # If you want to run on failure for the link checker, set it to `Condition: succeededOrFailed()`. + AllowRelativeLinksFile: '$(Build.SourcesDirectory)/eng/common/scripts/allow-relative-links.txt' steps: - template: /eng/common/pipelines/templates/steps/set-default-branch.yml @@ -32,3 +33,4 @@ steps: -localBuildRepoName "$env:BUILD_REPOSITORY_NAME" -localBuildRepoPath $(Build.SourcesDirectory) -inputCacheFile "https://azuresdkartifacts.blob.core.windows.net/verify-links-cache/verify-links-cache.txt" + -allowRelativeLinksFile "${{ parameters.AllowRelativeLinksFile }}" diff --git a/eng/common/scripts/Verify-Links.ps1 b/eng/common/scripts/Verify-Links.ps1 index 0eb1798da6..da8eca8c89 100644 --- a/eng/common/scripts/Verify-Links.ps1 +++ b/eng/common/scripts/Verify-Links.ps1 @@ -54,6 +54,11 @@ .PARAMETER requestTimeoutSec The number of seconds before we timeout when sending an individual web request. Default is 15 seconds. + .PARAMETER allowRelativeLinksFile + Path to a file containing file path patterns (supporting wildcards) for which relative links are permitted even when + checkLinkGuidance is true. Relative links in matching files are still verified for correctness. One pattern per line; + lines beginning with '#' are treated as comments. + .EXAMPLE PS> .\Verify-Links.ps1 C:\README.md @@ -80,7 +85,8 @@ param ( [string] $localGithubClonedRoot = "", [string] $localBuildRepoName = "", [string] $localBuildRepoPath = "", - [string] $requestTimeoutSec = 15 + [string] $requestTimeoutSec = 15, + [string] $allowRelativeLinksFile = (Join-Path $PSScriptRoot "allow-relative-links.txt") ) Set-StrictMode -Version 3.0 @@ -247,8 +253,9 @@ function ResolveUri ([System.Uri]$referralUri, [string]$link) $linkUri = [System.Uri]$link; # Our link guidelines do not allow relative links so only resolve them when we are not - # validating links against our link guidelines (i.e. !$checkLinkGuideance) - if ($checkLinkGuidance -and !$linkUri.IsAbsoluteUri) { + # validating links against our link guidelines (i.e. !$checkLinkGuidance) or when + # relative links are explicitly allowed for the current page. + if ($checkLinkGuidance -and !$allowRelativeLinksForCurrentPage -and !$linkUri.IsAbsoluteUri) { return $linkUri } @@ -428,7 +435,7 @@ function CheckLink ([System.Uri]$linkUri, $allowRetry=$true) $linkValid = $false } # Check if the url is relative links, suppress the archor link validation. - if (!$linkUri.IsAbsoluteUri -and !$link.StartsWith("#")) { + if (!$allowRelativeLinksForCurrentPage -and !$linkUri.IsAbsoluteUri -and !$link.StartsWith("#")) { LogWarning "DO NOT use relative link $linkUri. Please use absolute link instead. Check here for more information: https://aka.ms/azsdk/guideline/links" $linkValid = $false } @@ -512,12 +519,41 @@ if ($PSVersionTable.PSVersion.Major -lt 6) } $ignoreLinks = @(); if (Test-Path $ignoreLinksFile) { - $ignoreLinks = (Get-Content $ignoreLinksFile).Where({ $_.Trim() -ne "" -and !$_.StartsWith("#") }) + $ignoreLinks = (Get-Content $ignoreLinksFile).Where({ $_.Trim() -ne "" -and !$_.Trim().StartsWith("#") }) +} + +$allowRelativeLinkRegexes = @() +if ($allowRelativeLinksFile -and (Test-Path $allowRelativeLinksFile)) { + $allowRelativeLinkRegexes = (Get-Content $allowRelativeLinksFile).Where({ $_.Trim() -ne "" -and !$_.Trim().StartsWith("#") }) | ForEach-Object { + $normalizedPattern = $_.Trim().Replace('\', '/') + # Convert glob pattern to regex: ** matches anything including separators, * matches within a segment + $regexStr = "^.*" + [regex]::Escape($normalizedPattern).Replace("\*\*", ".*").Replace("\*", "[^/]*") + ".*$" + @{ + Pattern = $normalizedPattern + Regex = [System.Text.RegularExpressions.Regex]::new($regexStr, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + } + } + Write-Verbose "Loaded $($allowRelativeLinkRegexes.Count) allow-relative-links pattern(s) from '$allowRelativeLinksFile'." +} + +function Test-PageUriMatchesRelativeLinkPattern([System.Uri]$pageUri) { + if ($allowRelativeLinkRegexes.Count -eq 0) { return $false } + $pathToCheck = if ($pageUri.IsFile) { $pageUri.LocalPath } else { $pageUri.ToString() } + # Normalize separators for consistent matching + $pathToCheck = $pathToCheck.Replace('\', '/') + foreach ($entry in $allowRelativeLinkRegexes) { + if ($entry.Regex.IsMatch($pathToCheck)) { + Write-Verbose "Page '$pathToCheck' matches allow-relative-links pattern '$($entry.Pattern)'." + return $true + } + } + return $false } # Use default hashtable constructor instead of @{} because we need them to be case sensitive $checkedPages = New-Object Hashtable $checkedLinks = New-Object Hashtable +$allowRelativeLinksForCurrentPage = $false if ($inputCacheFile) { @@ -535,7 +571,7 @@ if ($inputCacheFile) elseif (Test-Path $inputCacheFile) { $cacheContent = Get-Content $inputCacheFile -Raw } - $goodLinks = $cacheContent.Split("`n").Where({ $_.Trim() -ne "" -and !$_.StartsWith("#") }) + $goodLinks = $cacheContent.Split("`n").Where({ $_.Trim() -ne "" -and !$_.Trim().StartsWith("#") }) foreach ($goodLink in $goodLinks) { $goodLink = $goodLink.Trim() @@ -558,8 +594,6 @@ foreach ($url in $urls) { LogGroupStart "Link checking details" -$originalcheckLinkGuidance = $checkLinkGuidance - while ($pageUrisToCheck.Count -ne 0) { $pageUri = $pageUrisToCheck.Dequeue(); @@ -568,10 +602,10 @@ while ($pageUrisToCheck.Count -ne 0) if ($checkedPages.ContainsKey($pageUri)) { continue } $checkedPages[$pageUri] = $true; - # copilot instructions require the use of relative links which is against our general guidance - # but we mainly care about those guidelines for docs publishing and not copilot instructions - # so we can disable the guidelines while validating copilot instruction files. - if ($pageUri -match "instructions.md$") { $checkLinkGuidance = $false } + # Allow relative links for pages matching patterns in the allow-relative-links configuration file. + # The links themselves are still checked for correctness, only the relative-link restriction is lifted. + # Other link guidance (e.g. http vs https, uppercase anchors, locale) continues to apply. + if ($checkLinkGuidance -and (Test-PageUriMatchesRelativeLinkPattern $pageUri)) { $allowRelativeLinksForCurrentPage = $true } [string[]] $linkUris = GetLinks $pageUri Write-Host "Checking $($linkUris.Count) links found on page $pageUri"; @@ -597,7 +631,7 @@ while ($pageUrisToCheck.Count -ne 0) Write-Host "Exception encountered while processing pageUri $pageUri : $($_.Exception)" throw } finally { - $checkLinkGuidance = $originalcheckLinkGuidance + $allowRelativeLinksForCurrentPage = $false } } diff --git a/eng/common/scripts/allow-relative-links.txt b/eng/common/scripts/allow-relative-links.txt new file mode 100644 index 0000000000..2e7895e0b7 --- /dev/null +++ b/eng/common/scripts/allow-relative-links.txt @@ -0,0 +1,9 @@ +# Files matching patterns listed here are permitted to use relative links even when checkLinkGuidance is enabled. +# Relative links in matching files are still verified for correctness. +# One glob pattern per line. Lines beginning with '#' are ignored. +# Patterns support wildcards: * matches within a path segment, ** matches across segments. +# +# Allow relative links for all files under the .github folder (e.g. copilot instructions). +.github/** +# Allow relative links for all files under the eng folder (e.g. engineering system scripts and templates). +eng/**